diff --git a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs index 92c39ac1..7165d88f 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,21 +351,20 @@ 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 - /// singleton instance in the _instances dictionary. + /// 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. ///
- /// Singleton instances in _instances take precedence over lazy - /// registrations in _extensions. ///
protected TExtension? ResolveExtension() where TExtension : class { - if (_extensions.TryGetValue(typeof(TExtension), out var t)) - return Activator.CreateInstance((Type)t) 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 8549a745..6a69eb98 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.UpdateRequestConfigJsonContext.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..9bfebdf6 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,15 +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. 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. /// /// 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. + /// 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 = null) where T : class { @@ -158,31 +161,40 @@ 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); +#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); } /// /// 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. 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. /// /// If the file does not exist, no exception is thrown and null is returned. - /// Supports source generator mode via JsonTypeInfo. + /// 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 = 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); - 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/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 7c7835a0..3e75b352 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,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 ti == null ? JsonSerializer.Deserialize(rj) : 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; } ///