From 4cac3717c95d510a9bc9043e3e9948850bea5ad9 Mon Sep 17 00:00:00 2001 From: Canon Date: Wed, 8 Apr 2026 14:14:33 +0800 Subject: [PATCH] Add Dependency Injection for Future Updates --- Commands/ScanCommand.cs | 17 +-- Commands/UpdateCommand.cs | 19 +-- .../Services/GitHubApiServiceTests.cs | 10 +- FCPModUpdater.csproj | 2 + Infrastructure/TypeRegistrar.cs | 20 +++ Program.cs | 26 +++- Services/GitHubApiService.cs | 27 +--- Services/UpdateCheckService.cs | 20 +-- packages.lock.json | 117 ++++++++++++++++++ 9 files changed, 185 insertions(+), 73 deletions(-) create mode 100644 Infrastructure/TypeRegistrar.cs diff --git a/Commands/ScanCommand.cs b/Commands/ScanCommand.cs index d444f00..16ed6f5 100644 --- a/Commands/ScanCommand.cs +++ b/Commands/ScanCommand.cs @@ -6,33 +6,26 @@ using Spectre.Console.Cli; namespace FCPModUpdater.Commands; -public class ScanCommand : AsyncCommand +public class ScanCommand( + IGitService gitService, + IGitHubApiService gitHubApiService, + IModDiscoveryService modDiscoveryService, + UpdateCheckService updateCheckService) : AsyncCommand { public override async Task ExecuteAsync(CommandContext context, ModPathSettings settings, CancellationToken cancellationToken) { try { - // Resolve mods directory var modsDirectory = ModsDirectoryResolver.Resolve(settings.ModDirectory?.FullName, interactive: true); if (modsDirectory == null) - { return 1; - } AnsiConsole.MarkupLine($"[grey]Using mods directory: {modsDirectory}[/]"); AnsiConsole.WriteLine(); - // Initialize services - var gitService = new GitService(); - var gitHubApiService = new GitHubApiService(); - var modDiscoveryService = new ModDiscoveryService(gitService, gitHubApiService); - - // Start update check in background (non-blocking) - var updateCheckService = new UpdateCheckService(gitHubApiService.HttpClient); var updateCheckTask = updateCheckService.CheckForUpdateAsync(cancellationToken); - // Run interactive menu var menu = new InteractiveMenu( gitService, gitHubApiService, diff --git a/Commands/UpdateCommand.cs b/Commands/UpdateCommand.cs index ae7a56a..f1cf23f 100644 --- a/Commands/UpdateCommand.cs +++ b/Commands/UpdateCommand.cs @@ -9,7 +9,10 @@ using Spectre.Console.Cli; namespace FCPModUpdater.Commands; [UsedImplicitly] -public class UpdateCommand : AsyncCommand +public class UpdateCommand( + IGitService gitService, + IModDiscoveryService modDiscoveryService, + UpdateCheckService updateCheckService) : AsyncCommand { public static IReadOnlyList GetUpdateableMods(IReadOnlyList mods) => mods.Where(m => m.Source == ModSource.Git && m.Status == ModStatus.Behind).ToList(); @@ -19,31 +22,19 @@ public class UpdateCommand : AsyncCommand { try { - // Resolve mods directory var modsDirectory = ModsDirectoryResolver.Resolve(settings.ModDirectory?.FullName, interactive: false); if (modsDirectory == null) - { return 1; - } AnsiConsole.MarkupLine($"[grey]Using mods directory: {modsDirectory}[/]"); AnsiConsole.WriteLine(); - // Initialize services - var gitService = new GitService(); - var gitHubApiService = new GitHubApiService(); - var modDiscoveryService = new ModDiscoveryService(gitService, gitHubApiService); - - // Start update check in background (non-blocking) - var updateCheckService = new UpdateCheckService(gitHubApiService.HttpClient); var updateCheckTask = updateCheckService.CheckForUpdateAsync(cancellationToken); - // Discover mods var mods = await ProgressReporter.WithStatusAsync( "Scanning mods directory...", async () => await modDiscoveryService.DiscoverModsAsync(modsDirectory, ct: cancellationToken)); - // Find updateable mods var updateableMods = GetUpdateableMods(mods); if (updateableMods.Count == 0) @@ -60,7 +51,6 @@ public class UpdateCommand : AsyncCommand AnsiConsole.WriteLine(); - // Update all var results = await ProgressReporter.WithBatchProgressAsync( "Updating mods", updateableMods, @@ -81,7 +71,6 @@ public class UpdateCommand : AsyncCommand ModTableRenderer.RenderUpdateSummary(results); - // Show update notification if available var updateResult = await updateCheckTask; if (updateResult != null) { diff --git a/FCPModUpdater.Tests/Services/GitHubApiServiceTests.cs b/FCPModUpdater.Tests/Services/GitHubApiServiceTests.cs index 2fe9bbe..50db0d3 100644 --- a/FCPModUpdater.Tests/Services/GitHubApiServiceTests.cs +++ b/FCPModUpdater.Tests/Services/GitHubApiServiceTests.cs @@ -9,14 +9,14 @@ public class GitHubApiServiceTests : IDisposable public GitHubApiServiceTests() { _handler = new MockHttpMessageHandler(); - _httpClient = new HttpClient(_handler); + _httpClient = new HttpClient(_handler) { BaseAddress = new Uri("https://api.github.com") }; _service = new GitHubApiService(_httpClient); } public void Dispose() { GC.SuppressFinalize(this); - _service.Dispose(); + _httpClient.Dispose(); } private static RemoteRepo MakeRepo(string name, List? topics = null) => @@ -176,12 +176,11 @@ public class GitHubApiServiceTests : IDisposable // Since cache is still fresh, this will return cached anyway // Let's just verify network error on fresh service var handler2 = new MockHttpMessageHandler { ThrowOnSend = true }; - var client2 = new HttpClient(handler2); + var client2 = new HttpClient(handler2) { BaseAddress = new Uri("https://api.github.com") }; var service2 = new GitHubApiService(client2); IReadOnlyList result = await service2.GetOrganizationReposAsync(token); Assert.Empty(result); - service2.Dispose(); client2.Dispose(); } @@ -191,12 +190,11 @@ public class GitHubApiServiceTests : IDisposable CancellationToken token = TestContext.Current.CancellationToken; var handler = new MockHttpMessageHandler { ThrowOnSend = true }; - var client = new HttpClient(handler); + var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.github.com") }; var service = new GitHubApiService(client); IReadOnlyList result = await service.GetOrganizationReposAsync(token); Assert.Empty(result); - service.Dispose(); client.Dispose(); } diff --git a/FCPModUpdater.csproj b/FCPModUpdater.csproj index 423e3d9..68065e9 100644 --- a/FCPModUpdater.csproj +++ b/FCPModUpdater.csproj @@ -21,6 +21,8 @@ + + diff --git a/Infrastructure/TypeRegistrar.cs b/Infrastructure/TypeRegistrar.cs new file mode 100644 index 0000000..1b97412 --- /dev/null +++ b/Infrastructure/TypeRegistrar.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace FCPModUpdater.Infrastructure; + +// Used to hook up Spectre.Cli to Dependency Injection + +public sealed class TypeRegistrar(IServiceCollection services) : ITypeRegistrar +{ + public ITypeResolver Build() => new TypeResolver(services.BuildServiceProvider()); + public void Register(Type service, Type implementation) => services.AddSingleton(service, implementation); + public void RegisterInstance(Type service, object implementation) => services.AddSingleton(service, implementation); + public void RegisterLazy(Type service, Func factory) => services.AddSingleton(service, _ => factory()); +} + +internal sealed class TypeResolver(IServiceProvider provider) : ITypeResolver, IDisposable +{ + public object? Resolve(Type? type) => type is null ? null : provider.GetService(type); + public void Dispose() => (provider as IDisposable)?.Dispose(); +} diff --git a/Program.cs b/Program.cs index 3bcefd9..d887973 100644 --- a/Program.cs +++ b/Program.cs @@ -1,10 +1,22 @@ +using System.Net.Http.Headers; using System.Runtime.InteropServices; using FCPModUpdater; using FCPModUpdater.Commands; +using FCPModUpdater.Infrastructure; +using FCPModUpdater.Services; +using Microsoft.Extensions.DependencyInjection; using Spectre.Console; using Spectre.Console.Cli; -var app = new CommandApp(); +var services = new ServiceCollection(); + +services.AddHttpClient(ConfigureGitHubClient); +services.AddHttpClient(ConfigureGitHubClient); + +services.AddSingleton(); +services.AddSingleton(); + +var app = new CommandApp(new TypeRegistrar(services)); app.Configure(config => { @@ -21,7 +33,6 @@ app.Configure(config => .WithDescription("Update all FCP mods (non-interactive)") .WithExample("update") .WithExample("update", "--directory", "/path/to/RimWorld/Mods"); - }); app.SetDefaultCommand(); @@ -37,3 +48,14 @@ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) } return result; + +void ConfigureGitHubClient(HttpClient client) +{ + client.BaseAddress = new Uri("https://api.github.com"); + client.DefaultRequestHeaders.UserAgent.ParseAdd($"FCPModUpdater/{AppVersion.SemanticVersion}"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + + var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + if (!string.IsNullOrEmpty(token)) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); +} diff --git a/Services/GitHubApiService.cs b/Services/GitHubApiService.cs index 8319341..8299e07 100644 --- a/Services/GitHubApiService.cs +++ b/Services/GitHubApiService.cs @@ -3,36 +3,20 @@ using FCPModUpdater.Models; namespace FCPModUpdater.Services; -public class GitHubApiService : IGitHubApiService, IDisposable +public class GitHubApiService(HttpClient httpClient) : IGitHubApiService { private const string OrgName = "FalloutCollaborationProject"; - private const string BaseUrl = "https://api.github.com"; private const string RequiredTopic = "rimworld-mod"; - private readonly HttpClient _httpClient; + private readonly HttpClient _httpClient = httpClient; private readonly TimeSpan _cacheExpiry = TimeSpan.FromHours(1); private IReadOnlyList? _cachedRepos; private DateTimeOffset _cacheTime = DateTimeOffset.MinValue; - public HttpClient HttpClient => _httpClient; public int? RemainingRateLimit { get; private set; } public DateTimeOffset? RateLimitReset { get; private set; } - public GitHubApiService(HttpClient? httpClient = null) - { - _httpClient = httpClient ?? new HttpClient(); - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd($"FCPModUpdater/{AppVersion.SemanticVersion}"); - _httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); - - var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); - if (!string.IsNullOrEmpty(token)) - { - _httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - } - } - public async Task> GetOrganizationReposAsync(CancellationToken ct = default) { if (_cachedRepos != null && DateTimeOffset.UtcNow - _cacheTime < _cacheExpiry) @@ -46,7 +30,7 @@ public class GitHubApiService : IGitHubApiService, IDisposable while (true) { - var url = $"{BaseUrl}/orgs/{OrgName}/repos?per_page={perPage}&page={page}"; + var url = $"orgs/{OrgName}/repos?per_page={perPage}&page={page}"; try { @@ -120,9 +104,4 @@ public class GitHubApiService : IGitHubApiService, IDisposable } } - public void Dispose() - { - _httpClient.Dispose(); - GC.SuppressFinalize(this); - } } diff --git a/Services/UpdateCheckService.cs b/Services/UpdateCheckService.cs index 859d325..90474aa 100644 --- a/Services/UpdateCheckService.cs +++ b/Services/UpdateCheckService.cs @@ -3,18 +3,10 @@ using FCPModUpdater.Models; namespace FCPModUpdater.Services; -public class UpdateCheckService +public class UpdateCheckService(HttpClient httpClient) { private const string RepoOwner = "FalloutCollaborationProject"; private const string RepoName = "FCP-Mod-Updater"; - private const string BaseUrl = "https://api.github.com"; - - private readonly HttpClient _httpClient; - - public UpdateCheckService(HttpClient httpClient) - { - _httpClient = httpClient; - } /// /// Check if a newer version is available. Returns null if check fails or no update needed. @@ -24,8 +16,8 @@ public class UpdateCheckService { try { - var url = $"{BaseUrl}/repos/{RepoOwner}/{RepoName}/releases?per_page=15"; - var releases = await _httpClient.GetFromJsonAsync>(url, ct); + var url = $"repos/{RepoOwner}/{RepoName}/releases?per_page=15"; + var releases = await httpClient.GetFromJsonAsync>(url, ct); if (releases is null || releases.Count == 0) return null; @@ -34,13 +26,13 @@ public class UpdateCheckService ReleaseInfo? newest = null; Version? newestVersion = null; - foreach (var release in releases) + foreach (ReleaseInfo release in releases) { if (release.Draft) continue; var tagBase = GetBaseVersion(release.TagName.TrimStart('v')); - if (!Version.TryParse(tagBase, out var ver)) + if (!Version.TryParse(tagBase, out Version? ver)) continue; if (newestVersion is null || ver > newestVersion) @@ -54,7 +46,7 @@ public class UpdateCheckService return null; var currentBase = GetBaseVersion(AppVersion.SemanticVersion); - if (!Version.TryParse(currentBase, out var currentVer)) + if (!Version.TryParse(currentBase, out Version? currentVer)) return null; if (newestVersion > currentVer) diff --git a/packages.lock.json b/packages.lock.json index 504c536..fa60c66 100644 --- a/packages.lock.json +++ b/packages.lock.json @@ -14,6 +14,29 @@ "resolved": "2025.2.4", "contentHash": "TwbgxAkXxY+vNEhNVx/QXjJ4vqxmepOjsgRvvImQPbHkHMMb4W+ahL3laMsxXKtNT7iMy+E1B3xkqao2hf1n3A==" }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Http": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "AiFvHYM8nP0wPC7bGPI3NHQlSYSLqjjT7DMJUuuxhd+7pz3O89iu2gdQfgACy5DxsXENiok5i1bMacJL7KR8jA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, "Spectre.Console": { "type": "Direct", "requested": "[0.54.0, )", @@ -28,6 +51,100 @@ "dependencies": { "Spectre.Console": "0.53.1" } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Binder": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g==" } } }