Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 9 additions & 6 deletions src/Snipdeck.App/Snipdeck.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,15 @@
<PropertyGroup>
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
<!-- Trimming is disabled until we move JSON serialisation onto
System.Text.Json source-gen (JsonSerializerContext). The
untyped Serialize/DeserializeAsync overloads are IL2026'd
and Jdenticon / Microsoft.Windows.SDK.NET / WinRT.Runtime
also produce trim warnings, which TreatWarningsAsErrors
promotes into a failed publish. -->
<!-- PublishTrimmed stays off. The JSON stores now serialise via source-gen
(StoreJsonContext), which removed the IL2026 reflection warnings — but a
trimmed Release publish still fails on IL2104 from Microsoft.Windows.SDK.NET,
WinRT.Runtime and Jdenticon, which aren't annotated trim-safe. Silencing
those wouldn't make trimming safe: the WinRT interop layer uses reflection,
so full trimming risks runtime breakage that can't be caught by a build.
A WinUI head can't be fully trimmed without partial-trim (which only trims
our own tiny assemblies, so negligible savings). Revisit only if the
WinAppSDK/WinRT assemblies ship trim annotations. -->
<PublishTrimmed>False</PublishTrimmed>
</PropertyGroup>
</Project>
5 changes: 5 additions & 0 deletions src/Snipdeck.Core/Models/CloseBehaviour.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
using System.Text.Json.Serialization;

namespace Snipdeck.Core.Models
{
public enum CloseBehaviour
{
[JsonStringEnumMemberName("hideToTray")]
HideToTray = 0,

[JsonStringEnumMemberName("exit")]
Exit = 1,
}
}
11 changes: 11 additions & 0 deletions src/Snipdeck.Core/Models/HotkeyBinding.cs
Original file line number Diff line number Diff line change
@@ -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,
}

Expand Down
5 changes: 5 additions & 0 deletions src/Snipdeck.Core/Models/ParameterType.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
using System.Text.Json.Serialization;

namespace Snipdeck.Core.Models
{
public enum ParameterType
{
[JsonStringEnumMemberName("text")]
Text = 0,

[JsonStringEnumMemberName("choice")]
Choice = 1,
}
}
7 changes: 7 additions & 0 deletions src/Snipdeck.Core/Models/ThemePreference.cs
Original file line number Diff line number Diff line change
@@ -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,
}
}
15 changes: 2 additions & 13 deletions src/Snipdeck.Core/Services/JsonSettingsStore.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;

using Snipdeck.Core.Abstractions;
using Snipdeck.Core.Models;
Expand All @@ -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)
Expand Down Expand Up @@ -45,7 +34,7 @@ public async Task<AppConfig> LoadAsync(CancellationToken cancellationToken = def
FileShare.Read);

var config = await JsonSerializer
.DeserializeAsync<AppConfig>(stream, _serializerOptions, cancellationToken)
.DeserializeAsync(stream, StoreJsonContext.Default.AppConfig, cancellationToken)
.ConfigureAwait(false);

if (config is null)
Expand Down Expand Up @@ -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);
}
Expand Down
12 changes: 2 additions & 10 deletions src/Snipdeck.Core/Services/JsonSnipStore.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;

using Snipdeck.Core.Abstractions;
using Snipdeck.Core.Models;
Expand All @@ -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)
Expand Down Expand Up @@ -42,7 +34,7 @@ public async Task<SnipStoreDocument> LoadAsync(CancellationToken cancellationTok
FileShare.Read);

var document = await JsonSerializer
.DeserializeAsync<SnipStoreDocument>(stream, _serializerOptions, cancellationToken)
.DeserializeAsync(stream, StoreJsonContext.Default.SnipStoreDocument, cancellationToken)
.ConfigureAwait(false)
?? new SnipStoreDocument();

Expand Down Expand Up @@ -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);
}
Expand Down
26 changes: 26 additions & 0 deletions src/Snipdeck.Core/Services/StoreJsonContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;

using Snipdeck.Core.Models;

namespace Snipdeck.Core.Services
{
/// <summary>
/// System.Text.Json source-generated metadata for the persisted documents.
/// Using generated <see cref="System.Text.Json.Serialization.Metadata.JsonTypeInfo{T}"/>
/// 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.
/// </summary>
[JsonSourceGenerationOptions(
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
UseStringEnumConverter = true)]
[JsonSerializable(typeof(SnipStoreDocument))]
[JsonSerializable(typeof(AppConfig))]
public sealed partial class StoreJsonContext : JsonSerializerContext
{
}
}
102 changes: 102 additions & 0 deletions tests/Snipdeck.Core.Tests/Services/StoreJsonCompatibilityTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Proves the source-generated <see cref="StoreJsonContext"/> 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.
/// </summary>
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);
}
}
}
Loading