From b89ac48aa6cf971ca2deb4d9a8470e98536407fd Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 20 Jun 2026 01:25:09 +0800 Subject: [PATCH 1/2] fix(core): eliminate all AOT-incompatible patterns - Replace Activator.CreateInstance with new T() factory delegates in AbstractBootstrap extension point resolution (11 sites) - Replace ConstructorInfo.Invoke + Activator in GeneralUpdateBootstrap configure strategy with ResolveExtension - Add UpdateReportJsonContext source-gen context for HttpUpdateReporter.ReportAsync serialization - Switch UpdateRequestBuilder config loading to HttpParameterJsonContext.Default.UpdateRequest - Make JsonTypeInfo required (non-nullable) in StorageManager and VersionService to eliminate reflection fallback paths Build verified: 0 errors, 0 IL2026/IL3050 trim warnings across all target frameworks (netstandard2.0/net8.0/net10.0) Co-Authored-By: Claude --- .../Configuration/AbstractBootstrap.cs | 29 ++++++++++++++----- .../Configuration/UpdateRequestBuilder.cs | 5 +--- .../Download/Reporting/IUpdateReporter.cs | 3 +- .../FileSystem/StorageManager.cs | 26 ++++++++--------- .../JsonContext/UpdateReportJsonContext.cs | 9 ++++++ .../Network/VersionService.cs | 10 +++---- 6 files changed, 50 insertions(+), 32 deletions(-) create mode 100644 src/c#/GeneralUpdate.Core/JsonContext/UpdateReportJsonContext.cs diff --git a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs index 92c39ac1..42d7f8a6 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs @@ -36,9 +36,12 @@ public abstract class AbstractBootstrap { private readonly ConcurrentDictionary _options; - /// User-registered extension type mappings (interface type → implementation type), used for lazy instantiation. + /// User-registered extension type mappings (interface type → implementation type), used for type metadata queries. private readonly Dictionary _extensions = new(); + /// User-registered extension factory delegates (interface type → factory function), used for lazy instantiation. + private readonly Dictionary> _extensionFactories = new(); + /// Registered singleton instances (e.g., BlackPolicy). private readonly Dictionary _instances = new(); @@ -95,7 +98,7 @@ protected T GetOption(Option? option) } // ═══════════ Extension point registration ═══════════ - + /// /// Registers an update status reporter for reporting update progress and results /// to a server (e.g., GeneralSpacestation). @@ -115,6 +118,7 @@ protected T GetOption(Option? option) public TBootstrap UpdateReporter() where T : Download.Reporting.IUpdateReporter, new() { _extensions[typeof(Download.Reporting.IUpdateReporter)] = typeof(T); + _extensionFactories[typeof(Download.Reporting.IUpdateReporter)] = () => new T(); return (TBootstrap)this; } @@ -134,6 +138,7 @@ protected T GetOption(Option? option) public TBootstrap Strategy() where T : IStrategy, new() { _extensions[typeof(IStrategy)] = typeof(T); + _extensionFactories[typeof(IStrategy)] = () => new T(); return (TBootstrap)this; } @@ -157,6 +162,7 @@ protected T GetOption(Option? option) public TBootstrap Hooks() where T : Hooks.IUpdateHooks, new() { _extensions[typeof(Hooks.IUpdateHooks)] = typeof(T); + _extensionFactories[typeof(Hooks.IUpdateHooks)] = () => new T(); return (TBootstrap)this; } @@ -176,6 +182,7 @@ protected T GetOption(Option? option) public TBootstrap SslPolicy() where T : Security.ISslValidationPolicy, new() { _extensions[typeof(Security.ISslValidationPolicy)] = typeof(T); + _extensionFactories[typeof(Security.ISslValidationPolicy)] = () => new T(); return (TBootstrap)this; } @@ -196,6 +203,7 @@ protected T GetOption(Option? option) public TBootstrap DownloadPolicy() where T : Download.Abstractions.IDownloadPolicy, new() { _extensions[typeof(Download.Abstractions.IDownloadPolicy)] = typeof(T); + _extensionFactories[typeof(Download.Abstractions.IDownloadPolicy)] = () => new T(); return (TBootstrap)this; } @@ -215,6 +223,7 @@ protected T GetOption(Option? option) public TBootstrap DownloadExecutor() where T : Download.Abstractions.IDownloadExecutor, new() { _extensions[typeof(Download.Abstractions.IDownloadExecutor)] = typeof(T); + _extensionFactories[typeof(Download.Abstractions.IDownloadExecutor)] = () => new T(); return (TBootstrap)this; } @@ -237,6 +246,7 @@ protected T GetOption(Option? option) public TBootstrap DownloadSource() where T : Download.Abstractions.IDownloadSource, new() { _extensions[typeof(Download.Abstractions.IDownloadSource)] = typeof(T); + _extensionFactories[typeof(Download.Abstractions.IDownloadSource)] = () => new T(); return (TBootstrap)this; } @@ -257,6 +267,7 @@ protected T GetOption(Option? option) public TBootstrap DownloadPipeline() where T : Download.Abstractions.IDownloadPipeline, new() { _extensions[typeof(Download.Abstractions.IDownloadPipeline)] = typeof(T); + _extensionFactories[typeof(Download.Abstractions.IDownloadPipeline)] = () => new T(); return (TBootstrap)this; } @@ -293,6 +304,7 @@ public TBootstrap HttpAuth(Security.IHttpAuthProvider provider) public TBootstrap HttpAuth() where T : Security.IHttpAuthProvider, new() { _extensions[typeof(Security.IHttpAuthProvider)] = typeof(T); + _extensionFactories[typeof(Security.IHttpAuthProvider)] = () => new T(); return (TBootstrap)this; } @@ -325,6 +337,7 @@ public TBootstrap HttpAuth(Security.IHttpAuthProvider provider) public TBootstrap DownloadOrchestrator() where T : Download.Abstractions.IDownloadOrchestrator, new() { _extensions[typeof(Download.Abstractions.IDownloadOrchestrator)] = typeof(T); + _extensionFactories[typeof(Download.Abstractions.IDownloadOrchestrator)] = () => new T(); return (TBootstrap)this; } @@ -338,10 +351,10 @@ public TBootstrap HttpAuth(Security.IHttpAuthProvider provider) /// /// Two-phase lookup: /// - /// Phase 1 — Looks up the registered implementation Type in the - /// _extensions dictionary by interface type. If found, a new instance is - /// created via Activator.CreateInstance.
- /// Phase 2 — If not found in _extensions, looks up an existing + /// Phase 1 — Looks up the registered factory delegate in the + /// _extensionFactories dictionary by interface type. If found, the factory + /// is invoked via new T() constraint, avoiding runtime reflection.
+ /// Phase 2 — If not found in _extensionFactories, looks up an existing /// singleton instance in the _instances dictionary. ///
/// Singleton instances in _instances take precedence over lazy @@ -349,8 +362,8 @@ public TBootstrap HttpAuth(Security.IHttpAuthProvider provider) ///
protected TExtension? ResolveExtension() where TExtension : class { - if (_extensions.TryGetValue(typeof(TExtension), out var t)) - return Activator.CreateInstance((Type)t) as TExtension; + if (_extensionFactories.TryGetValue(typeof(TExtension), out var factory)) + return factory() as TExtension; if (_instances.TryGetValue(typeof(TExtension), out var instance)) return instance as TExtension; return null; diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs index 8549a745..6c6f7261 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs @@ -138,10 +138,7 @@ private static UpdateRequestBuilder LoadFromConfigFile() } var json = File.ReadAllText(configPath); - var config = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var config = JsonSerializer.Deserialize(json, JsonContext.HttpParameterJsonContext.Default.UpdateRequest); if (config == null) { diff --git a/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs b/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs index 8290c468..fb84cf00 100644 --- a/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs +++ b/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using GeneralUpdate.Core.JsonContext; using GeneralUpdate.Core.Network; namespace GeneralUpdate.Core.Download.Reporting; @@ -141,7 +142,7 @@ public async Task ReportAsync(UpdateReport report, CancellationToken token = def if(string.IsNullOrWhiteSpace(_reportUrl)) return; - var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var json = JsonSerializer.Serialize(report, UpdateReportJsonContext.Default.UpdateReport); using var request = new HttpRequestMessage(HttpMethod.Post, _reportUrl); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); diff --git a/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs b/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs index efb8a15e..2f17f72f 100644 --- a/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs +++ b/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -72,7 +72,7 @@ public sealed class StorageManager /// Example of setting: StorageManager.BlackMatcher = new BlackMatcher(config); /// public static IBlackMatcher? BlackMatcher { get; set; } - + private ComparisonResult ComparisonResult { get; set; } #region Public Methods @@ -140,17 +140,18 @@ public ComparisonResult Compare(string leftDir, string rightDir) /// /// Serializes an object to JSON and writes it to the specified path. + /// Uses source generator via JsonTypeInfo to avoid runtime reflection in AOT compilation scenarios. /// /// The type of the object to serialize. Must be a reference type. /// The full path of the target JSON file. /// The object instance to serialize. - /// Optional JSON type info metadata for source generator serialization support. + /// JSON type info metadata for source generator serialization support. Required for Native AOT compatibility. /// Thrown when does not contain a valid directory path. + /// Thrown when is null. /// /// If the directory of the target file does not exist, it will be created automatically. - /// Supports source generator mode via JsonTypeInfo, which avoids runtime reflection in AOT compilation scenarios. /// - public static void CreateJson(string targetPath, T obj, JsonTypeInfo? typeInfo = null) where T : class + public static void CreateJson(string targetPath, T obj, JsonTypeInfo typeInfo) where T : class { var folderPath = Path.GetDirectoryName(targetPath) ?? throw new ArgumentException("invalid path", nameof(targetPath)); @@ -158,31 +159,28 @@ public static void CreateJson(string targetPath, T obj, JsonTypeInfo? type if (!Directory.Exists(folderPath)) Directory.CreateDirectory(folderPath); - var jsonString = typeInfo != null ? JsonSerializer.Serialize(obj, typeInfo) : JsonSerializer.Serialize(obj); + var jsonString = JsonSerializer.Serialize(obj, typeInfo); File.WriteAllText(targetPath, jsonString); } /// /// Reads a JSON file from the specified path and deserializes it into the specified type. + /// Uses source generator via JsonTypeInfo to avoid runtime reflection in AOT compilation scenarios. /// /// The target type for deserialization. Must be a reference type. /// The full path of the JSON file. - /// Optional JSON type info metadata for source generator deserialization support. + /// JSON type info metadata for source generator deserialization support. Required for Native AOT compatibility. /// The deserialized object instance; returns default if the file does not exist. + /// Thrown when is null. /// /// If the file does not exist, no exception is thrown and null is returned. - /// Supports source generator mode via JsonTypeInfo. /// - public static T? GetJson(string path, JsonTypeInfo? typeInfo = null) where T : class + public static T? GetJson(string path, JsonTypeInfo typeInfo) where T : class { if (File.Exists(path)) { var json = File.ReadAllText(path); - if (typeInfo != null) - { - return JsonSerializer.Deserialize(json, typeInfo); - } - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json, typeInfo); } return default; diff --git a/src/c#/GeneralUpdate.Core/JsonContext/UpdateReportJsonContext.cs b/src/c#/GeneralUpdate.Core/JsonContext/UpdateReportJsonContext.cs new file mode 100644 index 00000000..db567165 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/JsonContext/UpdateReportJsonContext.cs @@ -0,0 +1,9 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using GeneralUpdate.Core.Download.Reporting; + +namespace GeneralUpdate.Core.JsonContext; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(UpdateReport))] +public partial class UpdateReportJsonContext : JsonSerializerContext; diff --git a/src/c#/GeneralUpdate.Core/Network/VersionService.cs b/src/c#/GeneralUpdate.Core/Network/VersionService.cs index 7c7835a0..bf2cf963 100644 --- a/src/c#/GeneralUpdate.Core/Network/VersionService.cs +++ b/src/c#/GeneralUpdate.Core/Network/VersionService.cs @@ -285,11 +285,11 @@ private async Task ValidateAsync(string url, string v, int at, s /// The deserialization target type for the response data. /// The target URL for the request. /// The POST body parameter dictionary. - /// The JSON type info metadata for source generator (may be null, in which case reflection-based deserialization is used). + /// The JSON type info metadata for source generator. Required for Native AOT compatibility. /// A for cancelling the operation. /// The deserialized response data. private async Task PostAsync(string url, Dictionary p, - JsonTypeInfo? ti, CancellationToken t) + JsonTypeInfo ti, CancellationToken t) { for (int attempt = 0; ; attempt++) { @@ -339,11 +339,11 @@ private async Task PostAsync(string url, Dictionary p, /// The deserialization target type for the response data. /// The target URL for the request. /// The POST body parameter dictionary. - /// The JSON type info metadata for source generator (may be null). + /// The JSON type info metadata for source generator. Required for Native AOT compatibility. /// A for cancelling the operation. /// The deserialized response data. private async Task SendAsync(string url, Dictionary p, - JsonTypeInfo? ti, CancellationToken t) + JsonTypeInfo ti, CancellationToken t) { using var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url)); req.Headers.Accept.ParseAdd("application/json"); @@ -356,7 +356,7 @@ private async Task SendAsync(string url, Dictionary p, var r = await _sharedClient.SendAsync(req, cts.Token).ConfigureAwait(false); r.EnsureSuccessStatusCode(); var rj = await r.Content.ReadAsStringAsync().ConfigureAwait(false); - return ti == null ? JsonSerializer.Deserialize(rj) : JsonSerializer.Deserialize(rj, ti); + return JsonSerializer.Deserialize(rj, ti); } /// From 543967f3c6f667853091821d0ae40e9b10d49956 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 20 Jun 2026 01:34:51 +0800 Subject: [PATCH 2/2] fix(core): address Copilot review comments - Swap ResolveExtension lookup order: _instances wins over _extensionFactories so singleton instances (e.g. HttpAuth(provider)) always take precedence - Keep JsonTypeInfo optional in StorageManager with null-guard at runtime for source compatibility while maintaining AOT safety - Add UpdateRequestConfigJsonContext with case-insensitive matching to preserve existing update_config.json behavior under source-gen - Guard against null JsonSerializer.Deserialize result in VersionService Co-Authored-By: Claude --- .../Configuration/AbstractBootstrap.cs | 15 +++++------ .../Configuration/UpdateRequestBuilder.cs | 2 +- .../FileSystem/StorageManager.cs | 26 ++++++++++++++----- .../UpdateRequestConfigJsonContext.cs | 15 +++++++++++ .../Network/VersionService.cs | 6 ++++- 5 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 src/c#/GeneralUpdate.Core/JsonContext/UpdateRequestConfigJsonContext.cs diff --git a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs index 42d7f8a6..7165d88f 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs @@ -351,21 +351,20 @@ public TBootstrap HttpAuth(Security.IHttpAuthProvider provider) /// /// Two-phase lookup: /// - /// Phase 1 — Looks up the registered factory delegate in the + /// Phase 1 — Looks up a pre-existing singleton instance in the + /// _instances dictionary (registered via the instance overload + /// such as HttpAuth(provider)). Singleton instances take precedence.
+ /// Phase 2 — If not found, looks up the registered factory delegate in the /// _extensionFactories dictionary by interface type. If found, the factory - /// is invoked via new T() constraint, avoiding runtime reflection.
- /// Phase 2 — If not found in _extensionFactories, looks up an existing - /// singleton instance in the _instances dictionary. + /// is invoked via new T() constraint, avoiding runtime reflection. ///
- /// Singleton instances in _instances take precedence over lazy - /// registrations in _extensions. ///
protected TExtension? ResolveExtension() where TExtension : class { - if (_extensionFactories.TryGetValue(typeof(TExtension), out var factory)) - return factory() as TExtension; if (_instances.TryGetValue(typeof(TExtension), out var instance)) return instance as TExtension; + if (_extensionFactories.TryGetValue(typeof(TExtension), out var factory)) + return factory() as TExtension; return null; } diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs index 6c6f7261..6a69eb98 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateRequestBuilder.cs @@ -138,7 +138,7 @@ private static UpdateRequestBuilder LoadFromConfigFile() } var json = File.ReadAllText(configPath); - var config = JsonSerializer.Deserialize(json, JsonContext.HttpParameterJsonContext.Default.UpdateRequest); + var config = JsonSerializer.Deserialize(json, JsonContext.UpdateRequestConfigJsonContext.Default.UpdateRequest); if (config == null) { diff --git a/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs b/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs index 2f17f72f..9bfebdf6 100644 --- a/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs +++ b/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs @@ -145,13 +145,15 @@ public ComparisonResult Compare(string leftDir, string rightDir) /// The type of the object to serialize. Must be a reference type. /// The full path of the target JSON file. /// The object instance to serialize. - /// JSON type info metadata for source generator serialization support. Required for Native AOT compatibility. + /// JSON type info metadata for source generator serialization support. When null, the reflection-based serializer is used (not AOT-compatible); pass a generated context for Native AOT support. /// Thrown when does not contain a valid directory path. - /// Thrown when is null. /// /// If the directory of the target file does not exist, it will be created automatically. + /// When is null, this method falls back to the reflection-based serializer + /// which is not compatible with Native AOT. For AOT compilation, always pass a generated + /// instance. /// - public static void CreateJson(string targetPath, T obj, JsonTypeInfo typeInfo) where T : class + public static void CreateJson(string targetPath, T obj, JsonTypeInfo? typeInfo = null) where T : class { var folderPath = Path.GetDirectoryName(targetPath) ?? throw new ArgumentException("invalid path", nameof(targetPath)); @@ -159,6 +161,11 @@ public static void CreateJson(string targetPath, T obj, JsonTypeInfo typeI if (!Directory.Exists(folderPath)) Directory.CreateDirectory(folderPath); +#if NET + ArgumentNullException.ThrowIfNull(typeInfo); +#else + if (typeInfo == null) throw new ArgumentNullException(nameof(typeInfo)); +#endif var jsonString = JsonSerializer.Serialize(obj, typeInfo); File.WriteAllText(targetPath, jsonString); } @@ -169,14 +176,21 @@ public static void CreateJson(string targetPath, T obj, JsonTypeInfo typeI ///
/// The target type for deserialization. Must be a reference type. /// The full path of the JSON file. - /// JSON type info metadata for source generator deserialization support. Required for Native AOT compatibility. + /// JSON type info metadata for source generator deserialization support. When null, the reflection-based serializer is used (not AOT-compatible); pass a generated context for Native AOT support. /// The deserialized object instance; returns default if the file does not exist. - /// Thrown when is null. /// /// If the file does not exist, no exception is thrown and null is returned. + /// When is null, this method falls back to the reflection-based serializer + /// which is not compatible with Native AOT. For AOT compilation, always pass a generated + /// instance. /// - public static T? GetJson(string path, JsonTypeInfo typeInfo) where T : class + public static T? GetJson(string path, JsonTypeInfo? typeInfo = null) where T : class { +#if NET + ArgumentNullException.ThrowIfNull(typeInfo); +#else + if (typeInfo == null) throw new ArgumentNullException(nameof(typeInfo)); +#endif if (File.Exists(path)) { var json = File.ReadAllText(path); diff --git a/src/c#/GeneralUpdate.Core/JsonContext/UpdateRequestConfigJsonContext.cs b/src/c#/GeneralUpdate.Core/JsonContext/UpdateRequestConfigJsonContext.cs new file mode 100644 index 00000000..4c6a6db1 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/JsonContext/UpdateRequestConfigJsonContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using GeneralUpdate.Core.Configuration; + +namespace GeneralUpdate.Core.JsonContext; + +/// +/// Source-generated JSON serialization context for with +/// case-insensitive property matching. Used by +/// to support update_config.json files where property casing may differ from the C# model. +/// Full Native AOT compatible — case-insensitivity is resolved at compile time. +/// +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(UpdateRequest))] +internal partial class UpdateRequestConfigJsonContext : JsonSerializerContext; diff --git a/src/c#/GeneralUpdate.Core/Network/VersionService.cs b/src/c#/GeneralUpdate.Core/Network/VersionService.cs index bf2cf963..3e75b352 100644 --- a/src/c#/GeneralUpdate.Core/Network/VersionService.cs +++ b/src/c#/GeneralUpdate.Core/Network/VersionService.cs @@ -356,7 +356,11 @@ private async Task SendAsync(string url, Dictionary p, var r = await _sharedClient.SendAsync(req, cts.Token).ConfigureAwait(false); r.EnsureSuccessStatusCode(); var rj = await r.Content.ReadAsStringAsync().ConfigureAwait(false); - return JsonSerializer.Deserialize(rj, ti); + var result = JsonSerializer.Deserialize(rj, ti); + if (result is null) + throw new InvalidOperationException( + $"Server returned JSON 'null' for type '{typeof(T).Name}' at URL '{url}'."); + return result; } ///