Add Dependency Injection for Future Updates

This commit is contained in:
Canon
2026-04-08 14:14:33 +08:00
parent d90329a3eb
commit 4cac3717c9
9 changed files with 185 additions and 73 deletions
+5 -12
View File
@@ -6,33 +6,26 @@ using Spectre.Console.Cli;
namespace FCPModUpdater.Commands;
public class ScanCommand : AsyncCommand<ModPathSettings>
public class ScanCommand(
IGitService gitService,
IGitHubApiService gitHubApiService,
IModDiscoveryService modDiscoveryService,
UpdateCheckService updateCheckService) : AsyncCommand<ModPathSettings>
{
public override async Task<int> 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,
+4 -15
View File
@@ -9,7 +9,10 @@ using Spectre.Console.Cli;
namespace FCPModUpdater.Commands;
[UsedImplicitly]
public class UpdateCommand : AsyncCommand<ModPathSettings>
public class UpdateCommand(
IGitService gitService,
IModDiscoveryService modDiscoveryService,
UpdateCheckService updateCheckService) : AsyncCommand<ModPathSettings>
{
public static IReadOnlyList<InstalledMod> GetUpdateableMods(IReadOnlyList<InstalledMod> mods)
=> mods.Where(m => m.Source == ModSource.Git && m.Status == ModStatus.Behind).ToList();
@@ -19,31 +22,19 @@ public class UpdateCommand : AsyncCommand<ModPathSettings>
{
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<ModPathSettings>
AnsiConsole.WriteLine();
// Update all
var results = await ProgressReporter.WithBatchProgressAsync(
"Updating mods",
updateableMods,
@@ -81,7 +71,6 @@ public class UpdateCommand : AsyncCommand<ModPathSettings>
ModTableRenderer.RenderUpdateSummary(results);
// Show update notification if available
var updateResult = await updateCheckTask;
if (updateResult != null)
{
@@ -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<string>? 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<RemoteRepo> 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<RemoteRepo> result = await service.GetOrganizationReposAsync(token);
Assert.Empty(result);
service.Dispose();
client.Dispose();
}
+2
View File
@@ -21,6 +21,8 @@
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.10.1" />
<PackageReference Include="JetBrains.Annotations" Version="2025.2.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
<PackageReference Include="Spectre.Console" Version="0.54.0" />
<PackageReference Include="Spectre.Console.Cli" Version="0.53.1" />
</ItemGroup>
+20
View File
@@ -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<object> 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();
}
+24 -2
View File
@@ -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<IGitHubApiService, GitHubApiService>(ConfigureGitHubClient);
services.AddHttpClient<UpdateCheckService>(ConfigureGitHubClient);
services.AddSingleton<IGitService, GitService>();
services.AddSingleton<IModDiscoveryService, ModDiscoveryService>();
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<ScanCommand>();
@@ -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);
}
+3 -24
View File
@@ -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<RemoteRepo>? _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<IReadOnlyList<RemoteRepo>> 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);
}
}
+6 -14
View File
@@ -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;
}
/// <summary>
/// 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<List<ReleaseInfo>>(url, ct);
var url = $"repos/{RepoOwner}/{RepoName}/releases?per_page=15";
var releases = await httpClient.GetFromJsonAsync<List<ReleaseInfo>>(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)
+117
View File
@@ -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=="
}
}
}