From 806adafd901323593a3bb120b52411d6ff9418ec Mon Sep 17 00:00:00 2001 From: Canon Date: Tue, 10 Feb 2026 18:30:04 +0800 Subject: [PATCH] Add Update Checker --- Commands/ScanCommand.cs | 7 ++- Commands/UpdateCommand.cs | 15 ++++++ Services/GitHubApiService.cs | 3 +- Services/UpdateCheckService.cs | 96 ++++++++++++++++++++++++++++++++++ UI/InteractiveMenu.cs | 21 +++++++- packages.lock.json | 3 +- 6 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 Services/UpdateCheckService.cs diff --git a/Commands/ScanCommand.cs b/Commands/ScanCommand.cs index e5af3b5..d444f00 100644 --- a/Commands/ScanCommand.cs +++ b/Commands/ScanCommand.cs @@ -28,12 +28,17 @@ public class ScanCommand : AsyncCommand 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, modDiscoveryService, - modsDirectory); + modsDirectory, + updateCheckTask); await menu.RunAsync(cancellationToken); diff --git a/Commands/UpdateCommand.cs b/Commands/UpdateCommand.cs index a6db99e..e753f32 100644 --- a/Commands/UpdateCommand.cs +++ b/Commands/UpdateCommand.cs @@ -31,6 +31,10 @@ public class UpdateCommand : AsyncCommand 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...", @@ -76,6 +80,17 @@ public class UpdateCommand : AsyncCommand ModTableRenderer.RenderUpdateSummary(results); + // Show update notification if available + var updateResult = await updateCheckTask; + if (updateResult != null) + { + AnsiConsole.WriteLine(); + var label = updateResult.IsPrerelease ? "Pre-release available" : "Update available"; + AnsiConsole.MarkupLine( + $"[yellow bold]{label}: v{updateResult.LatestVersion}[/] [grey](current: {updateResult.CurrentVersion})[/]"); + AnsiConsole.MarkupLine($"[grey]Download: {updateResult.ReleaseUrl}[/]"); + } + var failCount = results.Count(r => !r.Success); return failCount > 0 ? 1 : 0; } diff --git a/Services/GitHubApiService.cs b/Services/GitHubApiService.cs index e67b19f..8319341 100644 --- a/Services/GitHubApiService.cs +++ b/Services/GitHubApiService.cs @@ -15,13 +15,14 @@ public class GitHubApiService : IGitHubApiService, IDisposable 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/1.0"); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd($"FCPModUpdater/{AppVersion.SemanticVersion}"); _httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); diff --git a/Services/UpdateCheckService.cs b/Services/UpdateCheckService.cs new file mode 100644 index 0000000..859d325 --- /dev/null +++ b/Services/UpdateCheckService.cs @@ -0,0 +1,96 @@ +using System.Net.Http.Json; +using FCPModUpdater.Models; + +namespace FCPModUpdater.Services; + +public class UpdateCheckService +{ + 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. + /// Checks both stable and prerelease versions. + /// + public async Task CheckForUpdateAsync(CancellationToken ct = default) + { + try + { + var url = $"{BaseUrl}/repos/{RepoOwner}/{RepoName}/releases?per_page=15"; + var releases = await _httpClient.GetFromJsonAsync>(url, ct); + + if (releases is null || releases.Count == 0) + return null; + + // Find the release with the highest semver (stable or prerelease) + ReleaseInfo? newest = null; + Version? newestVersion = null; + + foreach (var release in releases) + { + if (release.Draft) + continue; + + var tagBase = GetBaseVersion(release.TagName.TrimStart('v')); + if (!Version.TryParse(tagBase, out var ver)) + continue; + + if (newestVersion is null || ver > newestVersion) + { + newestVersion = ver; + newest = release; + } + } + + if (newest is null || newestVersion is null) + return null; + + var currentBase = GetBaseVersion(AppVersion.SemanticVersion); + if (!Version.TryParse(currentBase, out var currentVer)) + return null; + + if (newestVersion > currentVer) + { + var latestTag = newest.TagName.TrimStart('v'); + return new UpdateCheckResult( + CurrentVersion: AppVersion.InformationalVersion, + LatestVersion: latestTag, + ReleaseUrl: newest.HtmlUrl, + ReleaseName: newest.Name, + PublishedAt: newest.PublishedAt, + IsPrerelease: newest.Prerelease + ); + } + + return null; + } + catch + { + // Update check is non-critical — fail silently + return null; + } + } + + private static string GetBaseVersion(string version) + { + var dashIndex = version.IndexOf('-'); + return dashIndex >= 0 ? version[..dashIndex] : version; + } +} + +public record UpdateCheckResult( + string CurrentVersion, + string LatestVersion, + string ReleaseUrl, + string ReleaseName, + DateTimeOffset PublishedAt, + bool IsPrerelease +); diff --git a/UI/InteractiveMenu.cs b/UI/InteractiveMenu.cs index cd6d734..ff4367d 100644 --- a/UI/InteractiveMenu.cs +++ b/UI/InteractiveMenu.cs @@ -10,19 +10,23 @@ public class InteractiveMenu private readonly IGitHubApiService _gitHubApiService; private readonly IModDiscoveryService _modDiscoveryService; private readonly string _modsDirectory; + private readonly Task _updateCheckTask; private IReadOnlyList _mods = []; + private bool _updateNotificationShown; public InteractiveMenu( IGitService gitService, IGitHubApiService gitHubApiService, IModDiscoveryService modDiscoveryService, - string modsDirectory) + string modsDirectory, + Task updateCheckTask) { _gitService = gitService; _gitHubApiService = gitHubApiService; _modDiscoveryService = modDiscoveryService; _modsDirectory = modsDirectory; + _updateCheckTask = updateCheckTask; } public async Task RunAsync(CancellationToken ct = default) @@ -35,6 +39,21 @@ public class InteractiveMenu AnsiConsole.Clear(); ModTableRenderer.RenderModTable(_mods, _gitHubApiService.RemainingRateLimit, _gitHubApiService.RateLimitReset); + + // Show update notification once, non-blocking + if (!_updateNotificationShown && _updateCheckTask.IsCompleted) + { + _updateNotificationShown = true; + var updateResult = await _updateCheckTask; + if (updateResult != null) + { + var label = updateResult.IsPrerelease ? "Pre-release available" : "Update available"; + AnsiConsole.MarkupLine( + $"[yellow bold]{label}: v{updateResult.LatestVersion}[/] [grey](current: {updateResult.CurrentVersion})[/]"); + AnsiConsole.MarkupLine($"[grey]Download: {updateResult.ReleaseUrl}[/]"); + } + } + AnsiConsole.WriteLine(); var choice = AnsiConsole.Prompt( diff --git a/packages.lock.json b/packages.lock.json index 71754a8..8ab699c 100644 --- a/packages.lock.json +++ b/packages.lock.json @@ -29,6 +29,7 @@ "Spectre.Console": "0.53.1" } } - } + }, + "net10.0/linux-x64": {} } } \ No newline at end of file