diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index b09572b..e2bc6fd 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -436,6 +436,12 @@ Translator: Ylimhs License: AGPLv3 Check for updates + Download and install update + Check automatically on startup + Check interval (days): + Include prerelease updates + Latest version: + Last checked: Checks on demand using the official GitHub Releases. Reset to Defaults @@ -474,6 +480,15 @@ New version available: {0} Application is up to date. Installed version: {0} Error while checking updates: {0} + Unknown + Not checked + Never + Install ThreadPilot update + ThreadPilot will download and verify version {0}, then ask Windows for permission to run the installer. Continue? + Update canceled. + Downloading and verifying update... + Update installer started. + Update install failed: {0} Settings have been modified Settings match the saved configuration Simplified Chinese diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 8f4bcfc..d3cc1fa 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -436,6 +436,12 @@ 翻译人员:Ylimhs 软件授权: AGPLv3 检查新版本 + 下载并安装更新 + 启动时自动检查更新 + 检查间隔(天): + 包含预发布版本 + 最新版本: + 上次检查: 按需通过官方 GitHub Releases API 检索最新稳定版本。 重置为默认值 @@ -474,6 +480,15 @@ 发现新版本: {0} 应用已是最新版本。已安装版本: {0} 检查更新时出错: {0} + 未知 + 尚未检查 + 从未检查 + 安装 ThreadPilot 更新 + ThreadPilot 将下载并验证版本 {0},然后请求 Windows 权限运行安装程序。是否继续? + 更新已取消。 + 正在下载并验证更新... + 更新安装程序已启动。 + 更新安装失败: {0} 设置已被修改 设置与已保存的配置一致 简体中文 diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index a4da578..1c857ee 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -157,27 +157,26 @@ private async Task CheckForUpdatesAtStartupAsync() try { this.LogDebug("Startup update check started"); - var checker = this.serviceProvider.GetRequiredService(); - var currentVersion = GetCurrentApplicationVersion(); - var (latest, _) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot"); + var updateService = this.serviceProvider.GetRequiredService(); + var result = await updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup)); - if (latest == null) + if (result.Status == UpdateCheckStatus.Skipped) { - this.LogDebug("Startup update check completed without release information"); + this.LogDebug($"Startup update check skipped: {result.Message}"); return; } - if (latest <= currentVersion) + if (!result.IsUpdateAvailable || result.Release == null) { - this.LogDebug($"Startup update check complete: installed {currentVersion}, latest {latest}"); + this.LogDebug($"Startup update check complete: {result.Message}"); return; } await this.notificationService.ShowNotificationAsync( "Update available", - $"ThreadPilot {latest} is available from GitHub releases.", + $"ThreadPilot {result.Release.Version} is available. Open Settings to download and install it.", NotificationType.Information); - this.LogDebug($"Startup update check found update: installed {currentVersion}, latest {latest}"); + this.LogDebug($"Startup update check found update: installed {result.CurrentVersion}, latest {result.Release.Version}"); } catch (Exception ex) { diff --git a/Models/ApplicationSettingsModel.cs b/Models/ApplicationSettingsModel.cs index 40e6c78..3219d8a 100644 --- a/Models/ApplicationSettingsModel.cs +++ b/Models/ApplicationSettingsModel.cs @@ -154,6 +154,18 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel [ObservableProperty] private string language = LocalizationService.DefaultLanguage; + [ObservableProperty] + private bool enableAutomaticUpdateChecks = true; + + [ObservableProperty] + private DateTimeOffset? lastUpdateCheckUtc = null; + + [ObservableProperty] + private int updateCheckIntervalDays = 7; + + [ObservableProperty] + private bool includePrereleaseUpdates = false; + // Monitoring Settings [ObservableProperty] private int pollingIntervalMs = 5000; @@ -253,6 +265,10 @@ public void CopyFrom(ApplicationSettingsModel other) this.UseDarkTheme = other.UseDarkTheme; this.HasUserThemePreference = other.HasUserThemePreference; this.Language = LocalizationService.NormalizeLanguage(other.Language); + this.EnableAutomaticUpdateChecks = other.EnableAutomaticUpdateChecks; + this.LastUpdateCheckUtc = other.LastUpdateCheckUtc; + this.UpdateCheckIntervalDays = other.UpdateCheckIntervalDays; + this.IncludePrereleaseUpdates = other.IncludePrereleaseUpdates; // Monitoring Settings this.PollingIntervalMs = other.PollingIntervalMs; @@ -298,6 +314,11 @@ public ValidationResult Validate() errors.Add("Fallback polling interval must be between 1 and 60 seconds"); } + if (this.UpdateCheckIntervalDays < 1 || this.UpdateCheckIntervalDays > 365) + { + errors.Add("Update check interval must be between 1 and 365 days"); + } + return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray()); } diff --git a/Services/Abstractions/IGitHubReleaseClient.cs b/Services/Abstractions/IGitHubReleaseClient.cs index bf062d6..15f0c2e 100644 --- a/Services/Abstractions/IGitHubReleaseClient.cs +++ b/Services/Abstractions/IGitHubReleaseClient.cs @@ -9,5 +9,7 @@ namespace ThreadPilot.Services.Abstractions public interface IGitHubReleaseClient { Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default); + + Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default); } } diff --git a/Services/ApplicationSettingsService.cs b/Services/ApplicationSettingsService.cs index 02d52b7..d7ae681 100644 --- a/Services/ApplicationSettingsService.cs +++ b/Services/ApplicationSettingsService.cs @@ -246,18 +246,28 @@ public void ValidateAndFixSettings() this.settings.MaxNotificationHistoryItems = 1000; } - // Validate custom icon path - if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath)) - { - if (!File.Exists(this.settings.CustomTrayIconPath)) - { + // Validate custom icon path + if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath)) + { + if (!File.Exists(this.settings.CustomTrayIconPath)) + { this.logger.LogWarning("Custom tray icon file not found: {Path}", this.settings.CustomTrayIconPath); - this.settings.UseCustomTrayIcon = false; - } - } - - this.settings.Language = LocalizationService.NormalizeLanguage(this.settings.Language); - } + this.settings.UseCustomTrayIcon = false; + } + } + + this.settings.Language = LocalizationService.NormalizeLanguage(this.settings.Language); + + if (this.settings.UpdateCheckIntervalDays < 1) + { + this.settings.UpdateCheckIntervalDays = 1; + } + + if (this.settings.UpdateCheckIntervalDays > 365) + { + this.settings.UpdateCheckIntervalDays = 365; + } + } public async Task ExportSettingsAsync(string filePath) { diff --git a/Services/ApplicationVersionProvider.cs b/Services/ApplicationVersionProvider.cs new file mode 100644 index 0000000..b62b44a --- /dev/null +++ b/Services/ApplicationVersionProvider.cs @@ -0,0 +1,36 @@ +/* + * ThreadPilot - application version provider for update checks. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Linq; + using System.Reflection; + + public sealed class ApplicationVersionProvider : IApplicationVersionProvider + { + public SemanticVersion CurrentVersion + { + get + { + var rawVersion = GetRawVersion(); + return SemanticVersion.TryParse(rawVersion, out var version) + ? version + : new SemanticVersion(0, 0, 0); + } + } + + public string DisplayVersion => $"v{this.CurrentVersion}"; + + private static string GetRawVersion() + { + return typeof(App).Assembly + .GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()? + .InformationalVersion + ?? typeof(App).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; + } + } +} diff --git a/Services/AuthenticodeSignatureVerifier.cs b/Services/AuthenticodeSignatureVerifier.cs new file mode 100644 index 0000000..63b1735 --- /dev/null +++ b/Services/AuthenticodeSignatureVerifier.cs @@ -0,0 +1,46 @@ +/* + * ThreadPilot - best-effort Authenticode signature detection. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Security.Cryptography; + using System.Security.Cryptography.X509Certificates; + + public sealed class AuthenticodeSignatureVerifier : IUpdateSignatureVerifier + { + public UpdateSignatureStatus Verify(string installerPath) + { + if (!File.Exists(installerPath)) + { + return UpdateSignatureStatus.Invalid; + } + + try + { + using var certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(installerPath)); + using var chain = new X509Chain + { + ChainPolicy = + { + RevocationMode = X509RevocationMode.Online, + RevocationFlag = X509RevocationFlag.ExcludeRoot, + }, + }; + + return chain.Build(certificate) + ? UpdateSignatureStatus.Valid + : UpdateSignatureStatus.Unknown; + } + catch (CryptographicException) + { + return UpdateSignatureStatus.Unknown; + } + catch (PlatformNotSupportedException) + { + return UpdateSignatureStatus.Unknown; + } + } + } +} diff --git a/Services/GitHubReleaseClient.cs b/Services/GitHubReleaseClient.cs index ac4a542..655ce50 100644 --- a/Services/GitHubReleaseClient.cs +++ b/Services/GitHubReleaseClient.cs @@ -23,5 +23,11 @@ public Task GetLatestReleaseJsonAsync(string owner, string repo, Cancell var url = $"https://api.github.com/repos/{owner}/{repo}/releases/latest"; return this.httpClient.GetStringAsync(url, cancellationToken); } + + public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + var url = $"https://api.github.com/repos/{owner}/{repo}/releases"; + return this.httpClient.GetStringAsync(url, cancellationToken); + } } } diff --git a/Services/GitHubUpdateChecker.cs b/Services/GitHubUpdateChecker.cs index 027753c..621b31e 100644 --- a/Services/GitHubUpdateChecker.cs +++ b/Services/GitHubUpdateChecker.cs @@ -17,6 +17,8 @@ namespace ThreadPilot.Services { using System; + using System.Collections.Generic; + using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -26,7 +28,14 @@ public sealed class GitHubUpdateChecker { private readonly IGitHubReleaseClient gitHubReleaseClient; - private record LatestRelease(string Tag_name, bool Prerelease, bool Draft, string Html_url); + private record LatestRelease( + string Tag_name, + bool Prerelease, + bool Draft, + string Html_url, + IReadOnlyList? Assets); + + private record LatestReleaseAsset(string Name, string Browser_download_url, long Size); public GitHubUpdateChecker(IGitHubReleaseClient gitHubReleaseClient) { @@ -71,6 +80,64 @@ public GitHubUpdateChecker(IGitHubReleaseClient gitHubReleaseClient) ? (version, release.Html_url) : (null, release.Html_url); } + + public async Task GetLatestReleaseInfoAsync( + string owner, + string repo, + bool includePrereleases = false, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(owner)) + { + throw new ArgumentException("Owner is required", nameof(owner)); + } + + if (string.IsNullOrWhiteSpace(repo)) + { + throw new ArgumentException("Repository is required", nameof(repo)); + } + + var json = await this.gitHubReleaseClient.GetReleasesJsonAsync(owner, repo, cancellationToken).ConfigureAwait(false); + var releases = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }); + + if (releases == null || releases.Count == 0) + { + return null; + } + + return releases + .Where(release => !release.Draft) + .Where(release => includePrereleases || !release.Prerelease) + .Select(TryMapRelease) + .Where(release => release != null) + .Cast() + .OrderByDescending(release => release.Version) + .FirstOrDefault(); + } + + private static UpdateReleaseInfo? TryMapRelease(LatestRelease release) + { + if (!SemanticVersion.TryParse(release.Tag_name, out var version) || + string.IsNullOrWhiteSpace(release.Html_url) || + !Uri.TryCreate(release.Html_url, UriKind.Absolute, out var releasePageUrl)) + { + return null; + } + + var assets = (release.Assets ?? Array.Empty()) + .Where(asset => !string.IsNullOrWhiteSpace(asset.Name)) + .Where(asset => Uri.TryCreate(asset.Browser_download_url, UriKind.Absolute, out _)) + .Select(asset => new UpdateAsset( + asset.Name, + new Uri(asset.Browser_download_url, UriKind.Absolute), + asset.Size)) + .ToArray(); + + return new UpdateReleaseInfo(version, release.Tag_name, releasePageUrl, release.Prerelease, assets); + } } } diff --git a/Services/HttpUpdateDownloadClient.cs b/Services/HttpUpdateDownloadClient.cs new file mode 100644 index 0000000..a98aba1 --- /dev/null +++ b/Services/HttpUpdateDownloadClient.cs @@ -0,0 +1,44 @@ +/* + * ThreadPilot - HTTP downloads for update assets. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + public sealed class HttpUpdateDownloadClient : IUpdateDownloadClient + { + private readonly HttpClient httpClient; + + public HttpUpdateDownloadClient(HttpClient httpClient) + { + this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default) + { + using var response = await this.httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var destination = File.Create(destinationPath); + await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false); + } + + public async Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default) + { + try + { + return await this.httpClient.GetStringAsync(uri, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException) + { + return null; + } + } + } +} diff --git a/Services/SemanticVersion.cs b/Services/SemanticVersion.cs new file mode 100644 index 0000000..cf8dd64 --- /dev/null +++ b/Services/SemanticVersion.cs @@ -0,0 +1,104 @@ +/* + * ThreadPilot - semantic version parsing for updater decisions. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Globalization; + + public readonly record struct SemanticVersion(int Major, int Minor, int Patch, string? Prerelease = null) + : IComparable + { + public bool IsPrerelease => !string.IsNullOrWhiteSpace(this.Prerelease); + + public static bool TryParse(string? value, out SemanticVersion version) + { + version = default; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var sanitized = value.Trim(); + if (sanitized.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + sanitized = sanitized[1..]; + } + + sanitized = sanitized.Split('+')[0]; + var versionAndPrerelease = sanitized.Split('-', 2); + var parts = versionAndPrerelease[0].Split('.'); + if (parts.Length < 2 || parts.Length > 3) + { + return false; + } + + if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) || + !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)) + { + return false; + } + + var patch = 0; + if (parts.Length == 3 && + !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out patch)) + { + return false; + } + + version = new SemanticVersion( + major, + minor, + patch, + versionAndPrerelease.Length == 2 ? versionAndPrerelease[1] : null); + return true; + } + + public int CompareTo(SemanticVersion other) + { + var major = this.Major.CompareTo(other.Major); + if (major != 0) + { + return major; + } + + var minor = this.Minor.CompareTo(other.Minor); + if (minor != 0) + { + return minor; + } + + var patch = this.Patch.CompareTo(other.Patch); + if (patch != 0) + { + return patch; + } + + if (!this.IsPrerelease && other.IsPrerelease) + { + return 1; + } + + if (this.IsPrerelease && !other.IsPrerelease) + { + return -1; + } + + return string.Compare(this.Prerelease, other.Prerelease, StringComparison.OrdinalIgnoreCase); + } + + public override string ToString() + { + var version = $"{this.Major}.{this.Minor}.{this.Patch}"; + return this.IsPrerelease ? $"{version}-{this.Prerelease}" : version; + } + + public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0; + + public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0; + + public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0; + + public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0; + } +} diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 23dc385..45a0a46 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -78,6 +78,16 @@ private static IServiceCollection ConfigureServiceInfrastructure(this IServiceCo }); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Memory caching for performance - PERFORMANCE IMPROVEMENT services.AddMemoryCache(); diff --git a/Services/SystemUpdateClock.cs b/Services/SystemUpdateClock.cs new file mode 100644 index 0000000..35f8351 --- /dev/null +++ b/Services/SystemUpdateClock.cs @@ -0,0 +1,12 @@ +/* + * ThreadPilot - updater clock abstraction. + */ +namespace ThreadPilot.Services +{ + using System; + + public sealed class SystemUpdateClock : IUpdateClock + { + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + } +} diff --git a/Services/UpdateAssetSelector.cs b/Services/UpdateAssetSelector.cs new file mode 100644 index 0000000..10819f0 --- /dev/null +++ b/Services/UpdateAssetSelector.cs @@ -0,0 +1,81 @@ +/* + * ThreadPilot - release asset selection for safe installer updates. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Linq; + + public static class UpdateAssetSelector + { + public static bool TrySelectInstaller(UpdateReleaseInfo release, out UpdateAsset asset) + { + var selected = release.Assets + .Where(IsInstallerAsset) + .OrderByDescending(candidate => candidate.Name.Contains("setup", StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(); + + asset = selected!; + return selected != null; + } + + public static UpdateAsset? SelectChecksumAsset(UpdateReleaseInfo release) + { + return release.Assets.FirstOrDefault(asset => + string.Equals(asset.Name, "SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase)); + } + + public static bool IsSafeGitHubAssetUrl(Uri uri) + { + if (uri.Scheme != Uri.UriSchemeHttps) + { + return false; + } + + return string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase) || + string.Equals(uri.Host, "objects.githubusercontent.com", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsSafeAssetFileName(string assetName) + { + if (string.IsNullOrWhiteSpace(assetName)) + { + return false; + } + + if (!string.Equals(Path.GetFileName(assetName), assetName, StringComparison.Ordinal)) + { + return false; + } + + return assetName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; + } + + private static bool IsInstallerAsset(UpdateAsset asset) + { + if (!IsSafeGitHubAssetUrl(asset.DownloadUrl) || !IsSafeAssetFileName(asset.Name)) + { + return false; + } + + if (!asset.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!asset.Name.StartsWith("ThreadPilot", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (asset.Name.Contains("portable", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return asset.Name.Contains("setup", StringComparison.OrdinalIgnoreCase) || + asset.Name.Contains("installer", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Services/UpdateChecksumVerifier.cs b/Services/UpdateChecksumVerifier.cs new file mode 100644 index 0000000..82ff53e --- /dev/null +++ b/Services/UpdateChecksumVerifier.cs @@ -0,0 +1,73 @@ +/* + * ThreadPilot - SHA256SUMS parsing and verification. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Globalization; + using System.IO; + using System.Linq; + using System.Security.Cryptography; + + public static class UpdateChecksumVerifier + { + public static bool TryFindExpectedHash(string checksumsText, string fileName, out string expectedHash) + { + expectedHash = string.Empty; + foreach (var rawLine in checksumsText.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) + { + continue; + } + + if (line.StartsWith("SHA256(", StringComparison.OrdinalIgnoreCase)) + { + var close = line.IndexOf(')'); + var equals = line.IndexOf('=', StringComparison.Ordinal); + if (close > 7 && equals > close) + { + var listedName = line[7..close]; + var hash = line[(equals + 1)..].Trim(); + if (IsHash(hash) && string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase)) + { + expectedHash = hash.ToUpperInvariant(); + return true; + } + } + } + + var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && IsHash(parts[0])) + { + var listedName = parts[^1].TrimStart('*'); + if (string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase)) + { + expectedHash = parts[0].ToUpperInvariant(); + return true; + } + } + } + + return false; + } + + public static string ComputeSha256(string filePath) + { + using var stream = File.OpenRead(filePath); + var hash = SHA256.HashData(stream); + return string.Concat(hash.Select(b => b.ToString("X2", CultureInfo.InvariantCulture))); + } + + public static bool Verify(string filePath, string expectedHash) + { + return string.Equals(ComputeSha256(filePath), expectedHash, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsHash(string value) + { + return value.Length == 64 && value.All(Uri.IsHexDigit); + } + } +} diff --git a/Services/UpdateDownloadService.cs b/Services/UpdateDownloadService.cs new file mode 100644 index 0000000..e14c343 --- /dev/null +++ b/Services/UpdateDownloadService.cs @@ -0,0 +1,103 @@ +/* + * ThreadPilot - secure update installer download and verification. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + + public sealed class UpdateDownloadService : IUpdateDownloadService + { + private readonly IUpdateDownloadClient downloadClient; + private readonly IUpdateTempDirectoryProvider tempDirectoryProvider; + private readonly IUpdateSignatureVerifier signatureVerifier; + private readonly ILogger logger; + + public UpdateDownloadService( + IUpdateDownloadClient downloadClient, + IUpdateTempDirectoryProvider tempDirectoryProvider, + IUpdateSignatureVerifier signatureVerifier, + ILogger logger) + { + this.downloadClient = downloadClient ?? throw new ArgumentNullException(nameof(downloadClient)); + this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider)); + this.signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task DownloadInstallerAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default) + { + if (!UpdateAssetSelector.TrySelectInstaller(release, out var installerAsset)) + { + throw new InvalidOperationException("Release does not contain a ThreadPilot installer asset."); + } + + var tempDirectory = this.tempDirectoryProvider.CreateUpdateTempDirectory(release.Version); + try + { + var installerPath = Path.Combine(tempDirectory, installerAsset.Name); + await this.downloadClient.DownloadFileAsync(installerAsset.DownloadUrl, installerPath, cancellationToken) + .ConfigureAwait(false); + + var checksumVerified = false; + var checksumAsset = UpdateAssetSelector.SelectChecksumAsset(release); + if (checksumAsset != null) + { + var checksumText = await this.downloadClient.TryDownloadStringAsync(checksumAsset.DownloadUrl, cancellationToken) + .ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(checksumText) || + !UpdateChecksumVerifier.TryFindExpectedHash(checksumText, installerAsset.Name, out var expectedHash)) + { + throw new InvalidOperationException("SHA256SUMS.txt did not contain the installer checksum."); + } + + if (!UpdateChecksumVerifier.Verify(installerPath, expectedHash)) + { + throw new InvalidOperationException("Installer SHA256 checksum did not match SHA256SUMS.txt."); + } + + checksumVerified = true; + } + + var signatureStatus = this.signatureVerifier.Verify(installerPath); + if (signatureStatus == UpdateSignatureStatus.Invalid) + { + throw new InvalidOperationException("Installer Authenticode signature is invalid."); + } + + this.logger.LogInformation( + "Downloaded ThreadPilot update installer {InstallerName}; checksum verified: {ChecksumVerified}; signature: {SignatureStatus}", + installerAsset.Name, + checksumVerified, + signatureStatus); + + return new UpdateDownloadResult( + installerPath, + tempDirectory, + checksumVerified, + signatureStatus, + checksumVerified ? "Installer checksum verified." : "No SHA256SUMS.txt asset was available."); + } + catch + { + this.TryCleanup(tempDirectory); + throw; + } + } + + private void TryCleanup(string tempDirectory) + { + try + { + this.tempDirectoryProvider.Cleanup(tempDirectory); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to clean update temp directory {TempDirectory}", tempDirectory); + } + } + } +} diff --git a/Services/UpdateInstallerService.cs b/Services/UpdateInstallerService.cs new file mode 100644 index 0000000..d710d65 --- /dev/null +++ b/Services/UpdateInstallerService.cs @@ -0,0 +1,64 @@ +/* + * ThreadPilot - elevated update installer launch. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + + public sealed class UpdateInstallerService : IUpdateInstallerService + { + private readonly IUpdateTempDirectoryProvider tempDirectoryProvider; + private readonly IUpdateProcessLauncher processLauncher; + + public UpdateInstallerService( + IUpdateTempDirectoryProvider tempDirectoryProvider, + IUpdateProcessLauncher processLauncher) + { + this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider)); + this.processLauncher = processLauncher ?? throw new ArgumentNullException(nameof(processLauncher)); + } + + public Task LaunchInstallerElevatedAsync(string installerPath, CancellationToken cancellationToken = default) + { + if (!File.Exists(installerPath)) + { + throw new FileNotFoundException("Update installer was not found.", installerPath); + } + + if (!string.Equals(Path.GetExtension(installerPath), ".exe", StringComparison.OrdinalIgnoreCase) || + !this.tempDirectoryProvider.IsSafeUpdateTempPath(installerPath)) + { + throw new InvalidOperationException("Update installer path is not trusted."); + } + + return this.processLauncher.LaunchElevatedAsync(installerPath, Array.Empty(), cancellationToken); + } + } + + public sealed class ShellUpdateProcessLauncher : IUpdateProcessLauncher + { + public Task LaunchElevatedAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = true, + Verb = "runas", + WorkingDirectory = Path.GetDirectoryName(fileName) ?? Environment.CurrentDirectory, + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + Process.Start(startInfo); + return Task.CompletedTask; + } + } +} diff --git a/Services/UpdateModels.cs b/Services/UpdateModels.cs new file mode 100644 index 0000000..85cfe3e --- /dev/null +++ b/Services/UpdateModels.cs @@ -0,0 +1,126 @@ +/* + * ThreadPilot - updater models and abstractions. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + public enum UpdateCheckTrigger + { + Startup, + Manual, + } + + public enum UpdateCheckStatus + { + Skipped, + UpToDate, + UpdateAvailable, + Failed, + } + + public enum UpdateInstallStatus + { + Started, + Failed, + } + + public enum UpdateSignatureStatus + { + Valid, + Invalid, + Unknown, + } + + public sealed record UpdateCheckRequest(UpdateCheckTrigger Trigger); + + public sealed record UpdateAsset(string Name, Uri DownloadUrl, long Size); + + public sealed record UpdateReleaseInfo( + SemanticVersion Version, + string TagName, + Uri ReleasePageUrl, + bool IsPrerelease, + IReadOnlyList Assets); + + public sealed record UpdateCheckResult( + UpdateCheckStatus Status, + SemanticVersion CurrentVersion, + UpdateReleaseInfo? Release, + string Message) + { + public bool IsUpdateAvailable => this.Status == UpdateCheckStatus.UpdateAvailable && this.Release != null; + } + + public sealed record UpdateDownloadResult( + string InstallerPath, + string TempDirectory, + bool ChecksumVerified, + UpdateSignatureStatus SignatureStatus, + string Message); + + public sealed record UpdateInstallResult(UpdateInstallStatus Status, string Message); + + public interface IApplicationVersionProvider + { + SemanticVersion CurrentVersion { get; } + + string DisplayVersion { get; } + } + + public interface IUpdateClock + { + DateTimeOffset UtcNow { get; } + } + + public interface IUpdateService + { + Task CheckForUpdatesAsync(UpdateCheckRequest request, CancellationToken cancellationToken = default); + + Task DownloadAndInstallAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default); + } + + public interface IUpdateDownloadService + { + Task DownloadInstallerAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default); + } + + public interface IUpdateInstallerService + { + Task LaunchInstallerElevatedAsync(string installerPath, CancellationToken cancellationToken = default); + } + + public interface IUpdateDownloadClient + { + Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default); + + Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default); + } + + public interface IUpdateTempDirectoryProvider + { + string CreateUpdateTempDirectory(SemanticVersion version); + + bool IsSafeUpdateTempPath(string path); + + void Cleanup(string path); + } + + public interface IUpdateSignatureVerifier + { + UpdateSignatureStatus Verify(string installerPath); + } + + public interface IUpdateProcessLauncher + { + Task LaunchElevatedAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default); + } + + public interface IApplicationShutdownService + { + void RequestShutdownForUpdate(); + } +} diff --git a/Services/UpdateService.cs b/Services/UpdateService.cs new file mode 100644 index 0000000..5cc3256 --- /dev/null +++ b/Services/UpdateService.cs @@ -0,0 +1,158 @@ +/* + * ThreadPilot - safe in-app update orchestration. + */ +namespace ThreadPilot.Services +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + + public sealed class UpdateService : IUpdateService + { + private const string OfficialOwner = "PrimeBuild-pc"; + private const string OfficialRepository = "ThreadPilot"; + + private readonly GitHubUpdateChecker updateChecker; + private readonly IApplicationSettingsService settingsService; + private readonly IApplicationVersionProvider versionProvider; + private readonly IUpdateDownloadService downloadService; + private readonly IUpdateInstallerService installerService; + private readonly IUpdateTempDirectoryProvider tempDirectoryProvider; + private readonly IApplicationShutdownService shutdownService; + private readonly IUpdateClock clock; + private readonly ILogger logger; + private readonly SemaphoreSlim checkGate = new(1, 1); + private readonly SemaphoreSlim installGate = new(1, 1); + + public UpdateService( + GitHubUpdateChecker updateChecker, + IApplicationSettingsService settingsService, + IApplicationVersionProvider versionProvider, + IUpdateDownloadService downloadService, + IUpdateInstallerService installerService, + IUpdateTempDirectoryProvider tempDirectoryProvider, + IApplicationShutdownService shutdownService, + IUpdateClock clock, + ILogger logger) + { + this.updateChecker = updateChecker ?? throw new ArgumentNullException(nameof(updateChecker)); + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider)); + this.downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService)); + this.installerService = installerService ?? throw new ArgumentNullException(nameof(installerService)); + this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider)); + this.shutdownService = shutdownService ?? throw new ArgumentNullException(nameof(shutdownService)); + this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CheckForUpdatesAsync(UpdateCheckRequest request, CancellationToken cancellationToken = default) + { + var currentVersion = this.versionProvider.CurrentVersion; + var settings = this.settingsService.Settings; + + if (request.Trigger == UpdateCheckTrigger.Startup) + { + if (!settings.EnableAutomaticUpdateChecks) + { + return new UpdateCheckResult(UpdateCheckStatus.Skipped, currentVersion, null, "Automatic update checks are disabled."); + } + + var intervalDays = Math.Max(1, settings.UpdateCheckIntervalDays); + if (settings.LastUpdateCheckUtc.HasValue && + this.clock.UtcNow - settings.LastUpdateCheckUtc.Value < TimeSpan.FromDays(intervalDays)) + { + return new UpdateCheckResult(UpdateCheckStatus.Skipped, currentVersion, null, "Startup update check throttled."); + } + } + + await this.checkGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await this.MarkUpdateCheckAttemptAsync(cancellationToken).ConfigureAwait(false); + + var release = await this.updateChecker.GetLatestReleaseInfoAsync( + OfficialOwner, + OfficialRepository, + settings.IncludePrereleaseUpdates, + cancellationToken).ConfigureAwait(false); + + if (release == null) + { + return new UpdateCheckResult(UpdateCheckStatus.Failed, currentVersion, null, "Unable to determine the latest ThreadPilot release."); + } + + if (release.Version > currentVersion) + { + this.logger.LogInformation( + "ThreadPilot update available: current {CurrentVersion}, latest {LatestVersion}", + currentVersion, + release.Version); + return new UpdateCheckResult(UpdateCheckStatus.UpdateAvailable, currentVersion, release, "A newer ThreadPilot version is available."); + } + + return new UpdateCheckResult(UpdateCheckStatus.UpToDate, currentVersion, release, "ThreadPilot is up to date."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "ThreadPilot update check failed"); + return new UpdateCheckResult(UpdateCheckStatus.Failed, currentVersion, null, ex.Message); + } + finally + { + this.checkGate.Release(); + } + } + + public async Task DownloadAndInstallAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default) + { + if (!await this.installGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + return new UpdateInstallResult(UpdateInstallStatus.Failed, "Another update is already in progress."); + } + + UpdateDownloadResult? download = null; + try + { + download = await this.downloadService.DownloadInstallerAsync(release, cancellationToken).ConfigureAwait(false); + await this.installerService.LaunchInstallerElevatedAsync(download.InstallerPath, cancellationToken).ConfigureAwait(false); + this.shutdownService.RequestShutdownForUpdate(); + return new UpdateInstallResult(UpdateInstallStatus.Started, "Update installer started."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "ThreadPilot update install failed"); + return new UpdateInstallResult(UpdateInstallStatus.Failed, ex.Message); + } + finally + { + if (download != null) + { + this.TryCleanup(download.TempDirectory); + } + + this.installGate.Release(); + } + } + + private async Task MarkUpdateCheckAttemptAsync(CancellationToken cancellationToken) + { + var settings = this.settingsService.Settings; + settings.LastUpdateCheckUtc = this.clock.UtcNow; + await this.settingsService.UpdateSettingsAsync(settings).ConfigureAwait(false); + } + + private void TryCleanup(string tempDirectory) + { + try + { + this.tempDirectoryProvider.Cleanup(tempDirectory); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to clean update temp directory {TempDirectory}", tempDirectory); + } + } + } +} diff --git a/Services/UpdateTempDirectoryProvider.cs b/Services/UpdateTempDirectoryProvider.cs new file mode 100644 index 0000000..d2ac116 --- /dev/null +++ b/Services/UpdateTempDirectoryProvider.cs @@ -0,0 +1,68 @@ +/* + * ThreadPilot - safe temporary directory management for update downloads. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + + public sealed class UpdateTempDirectoryProvider : IUpdateTempDirectoryProvider + { + private readonly string rootDirectory; + + public UpdateTempDirectoryProvider() + : this(Path.Combine(Path.GetTempPath(), "ThreadPilot", "Updates")) + { + } + + public UpdateTempDirectoryProvider(string rootDirectory) + { + this.rootDirectory = Path.GetFullPath(rootDirectory ?? throw new ArgumentNullException(nameof(rootDirectory))); + } + + public string CreateUpdateTempDirectory(SemanticVersion version) + { + var directory = Path.Combine(this.rootDirectory, version.ToString(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + return directory; + } + + public bool IsSafeUpdateTempPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var fullPath = Path.GetFullPath(path); + var rootWithSeparator = this.rootDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return fullPath.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase); + } + + public void Cleanup(string path) + { + if (!this.IsSafeUpdateTempPath(path) || !Directory.Exists(path)) + { + return; + } + + Directory.Delete(path, recursive: true); + this.DeleteEmptyParentsUntilRoot(Path.GetDirectoryName(Path.GetFullPath(path))); + } + + private void DeleteEmptyParentsUntilRoot(string? directory) + { + while (!string.IsNullOrWhiteSpace(directory) && this.IsSafeUpdateTempPath(directory)) + { + if (Directory.GetFileSystemEntries(directory).Length > 0) + { + return; + } + + Directory.Delete(directory); + directory = Path.GetDirectoryName(directory); + } + } + } +} diff --git a/Services/WpfApplicationShutdownService.cs b/Services/WpfApplicationShutdownService.cs new file mode 100644 index 0000000..2ae900d --- /dev/null +++ b/Services/WpfApplicationShutdownService.cs @@ -0,0 +1,21 @@ +/* + * ThreadPilot - graceful shutdown hook after updater launch. + */ +namespace ThreadPilot.Services +{ + using System.Windows; + + public sealed class WpfApplicationShutdownService : IApplicationShutdownService + { + public void RequestShutdownForUpdate() + { + var application = Application.Current; + if (application == null) + { + return; + } + + application.Dispatcher.InvokeAsync(application.Shutdown); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs index 214ac48..6eed3cb 100644 --- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs @@ -14,6 +14,10 @@ public void Constructor_StartMinimizedDefaultsFalse_ForManualLaunchVisibility() Assert.True(settings.ApplyPersistentRulesOnProcessStart); Assert.False(settings.HasSeenStartupMinimizedSuggestion); Assert.Equal("en-US", settings.Language); + Assert.True(settings.EnableAutomaticUpdateChecks); + Assert.Equal(7, settings.UpdateCheckIntervalDays); + Assert.False(settings.IncludePrereleaseUpdates); + Assert.Null(settings.LastUpdateCheckUtc); } [Fact] diff --git a/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs b/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs index 4af15aa..51143bc 100644 --- a/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs @@ -1,84 +1,89 @@ -namespace ThreadPilot.Core.Tests -{ - using System.Threading; - using System.Threading.Tasks; - using ThreadPilot.Services; - using ThreadPilot.Services.Abstractions; - - public sealed class GitHubUpdateCheckerTests - { - [Fact] - public async Task GetLatestVersionAsync_ReturnsStableRelease_WhenTagIsSemver() - { - var client = new FakeGitHubReleaseClient( - """ - { - "tag_name": "v1.2.3", - "prerelease": false, - "draft": false, - "html_url": "https://example.test/releases/v1.2.3" - } - """); - var checker = new GitHubUpdateChecker(client); - - var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot"); - - Assert.Equal(new System.Version(1, 2, 3), latest); - Assert.Equal("https://example.test/releases/v1.2.3", releaseUrl); - } - - [Fact] - public async Task GetLatestVersionAsync_IgnoresDraftOrPrerelease() - { - var client = new FakeGitHubReleaseClient( - """ - { - "tag_name": "v1.2.3-beta1", - "prerelease": true, - "draft": false, - "html_url": "https://example.test/releases/v1.2.3-beta1" - } - """); - var checker = new GitHubUpdateChecker(client); - - var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot"); - - Assert.Null(latest); - Assert.Null(releaseUrl); - } - - [Fact] - public async Task GetLatestVersionAsync_ReturnsNull_WhenTagCannotBeParsed() - { - var client = new FakeGitHubReleaseClient( - """ - { - "tag_name": "release-main", - "prerelease": false, - "draft": false, - "html_url": "https://example.test/releases/release-main" - } - """); - var checker = new GitHubUpdateChecker(client); - - var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot"); - - Assert.Null(latest); - Assert.Equal("https://example.test/releases/release-main", releaseUrl); - } - - private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient - { - private readonly string responseJson; - - public FakeGitHubReleaseClient(string responseJson) +namespace ThreadPilot.Core.Tests +{ + using System.Threading; + using System.Threading.Tasks; + using ThreadPilot.Services; + using ThreadPilot.Services.Abstractions; + + public sealed class GitHubUpdateCheckerTests + { + [Fact] + public async Task GetLatestVersionAsync_ReturnsStableRelease_WhenTagIsSemver() + { + var client = new FakeGitHubReleaseClient( + """ + { + "tag_name": "v1.2.3", + "prerelease": false, + "draft": false, + "html_url": "https://example.test/releases/v1.2.3" + } + """); + var checker = new GitHubUpdateChecker(client); + + var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot"); + + Assert.Equal(new System.Version(1, 2, 3), latest); + Assert.Equal("https://example.test/releases/v1.2.3", releaseUrl); + } + + [Fact] + public async Task GetLatestVersionAsync_IgnoresDraftOrPrerelease() + { + var client = new FakeGitHubReleaseClient( + """ + { + "tag_name": "v1.2.3-beta1", + "prerelease": true, + "draft": false, + "html_url": "https://example.test/releases/v1.2.3-beta1" + } + """); + var checker = new GitHubUpdateChecker(client); + + var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot"); + + Assert.Null(latest); + Assert.Null(releaseUrl); + } + + [Fact] + public async Task GetLatestVersionAsync_ReturnsNull_WhenTagCannotBeParsed() + { + var client = new FakeGitHubReleaseClient( + """ + { + "tag_name": "release-main", + "prerelease": false, + "draft": false, + "html_url": "https://example.test/releases/release-main" + } + """); + var checker = new GitHubUpdateChecker(client); + + var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot"); + + Assert.Null(latest); + Assert.Equal("https://example.test/releases/release-main", releaseUrl); + } + + private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient + { + private readonly string responseJson; + + public FakeGitHubReleaseClient(string responseJson) + { + this.responseJson = responseJson; + } + + public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) { - this.responseJson = responseJson; + return Task.FromResult(this.responseJson); } - public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) + public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) { - return Task.FromResult(this.responseJson); + return Task.FromResult($"[{this.responseJson}]"); } } } diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index 4b344fe..9348f80 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -254,7 +254,8 @@ public void MainWindow_QueuesStartupUpdateCheckOnceWithoutBlockingStartup() Assert.Contains("QueueStartupUpdateCheck();", source, StringComparison.Ordinal); Assert.Contains("Interlocked.Exchange(ref this.startupUpdateCheckStarted, 1)", updateCheckSection, StringComparison.Ordinal); Assert.Contains("TaskSafety.FireAndForget(this.CheckForUpdatesAtStartupAsync()", updateCheckSection, StringComparison.Ordinal); - Assert.Contains("GetLatestVersionAsync(\"PrimeBuild-pc\", \"ThreadPilot\")", updateCheckSection, StringComparison.Ordinal); + Assert.Contains("GetRequiredService()", updateCheckSection, StringComparison.Ordinal); + Assert.Contains("CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup))", updateCheckSection, StringComparison.Ordinal); Assert.Contains("Startup update check ignored failure", updateCheckSection, StringComparison.Ordinal); Assert.DoesNotContain("System.Windows.MessageBox.Show", updateCheckSection, StringComparison.Ordinal); } diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs index 2d69ee3..4afd6e0 100644 --- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -6,7 +6,6 @@ namespace ThreadPilot.Core.Tests using Moq; using ThreadPilot.Models; using ThreadPilot.Services; - using ThreadPilot.Services.Abstractions; using ThreadPilot.ViewModels; public sealed class SettingsViewModelThemeTests @@ -201,6 +200,10 @@ private sealed class Harness public Mock Localization { get; } = new(MockBehavior.Loose); + public Mock Updates { get; } = new(MockBehavior.Loose); + + public Mock VersionProvider { get; } = new(MockBehavior.Loose); + public Mock Logging { get; } = new(MockBehavior.Loose); public ActivityAuditService Audit { get; } = new(NullLogger.Instance); @@ -228,6 +231,8 @@ public Harness(bool initialDarkTheme = false) this.Associations .Setup(service => service.GetDefaultPowerPlanAsync()) .ReturnsAsync((string.Empty, string.Empty)); + this.VersionProvider.SetupGet(service => service.DisplayVersion).Returns("v1.3.1"); + this.VersionProvider.SetupGet(service => service.CurrentVersion).Returns(new SemanticVersion(1, 3, 1)); } public SettingsViewModel CreateViewModel() => @@ -241,7 +246,8 @@ public SettingsViewModel CreateViewModel() => this.ProcessMonitorManager.Object, this.Theme.Object, this.Tray.Object, - new GitHubUpdateChecker(new Mock().Object), + this.Updates.Object, + this.VersionProvider.Object, this.Localization.Object, this.Logging.Object, this.Audit); diff --git a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs new file mode 100644 index 0000000..2f9729e --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs @@ -0,0 +1,323 @@ +namespace ThreadPilot.Core.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.Services.Abstractions; + + public sealed class UpdateServiceTests + { + [Fact] + public void SemanticVersion_OrdersStableAbovePrerelease() + { + Assert.True(SemanticVersion.TryParse("v1.4.0-beta.1", out var prerelease)); + Assert.True(SemanticVersion.TryParse("1.4.0", out var stable)); + + Assert.True(stable > prerelease); + } + + [Fact] + public async Task GitHubUpdateChecker_ExcludesPrereleasesByDefault() + { + var checker = new GitHubUpdateChecker(new FakeGitHubReleaseClient( + """ + [ + { "tag_name": "v1.5.0-beta.1", "prerelease": true, "draft": false, "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.5.0-beta.1", "assets": [] }, + { "tag_name": "v1.4.0", "prerelease": false, "draft": false, "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0", "assets": [] } + ] + """)); + + var release = await checker.GetLatestReleaseInfoAsync("PrimeBuild-pc", "ThreadPilot"); + + Assert.NotNull(release); + Assert.Equal("1.4.0", release.Version.ToString()); + } + + [Fact] + public async Task CheckForUpdatesAsync_StartupSkipsWhenLastCheckInsideInterval() + { + var harness = new Harness(); + harness.Settings.LastUpdateCheckUtc = harness.Clock.UtcNow.AddDays(-2); + + var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup)); + + Assert.Equal(UpdateCheckStatus.Skipped, result.Status); + Assert.False(harness.ReleaseClient.RequestedReleases); + } + + [Fact] + public async Task CheckForUpdatesAsync_ManualFindsNewerStableRelease() + { + var harness = new Harness(); + + var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual)); + + Assert.True(result.IsUpdateAvailable); + Assert.Equal("1.4.0", result.Release?.Version.ToString()); + Assert.Equal(harness.Clock.UtcNow, harness.SavedSettings?.LastUpdateCheckUtc); + } + + [Fact] + public void UpdateAssetSelector_SelectsInstallerAndRejectsPortable() + { + var release = CreateRelease( + new UpdateAsset("ThreadPilot_v1.4.0_Portable.zip", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Portable.zip"), 1), + new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 1)); + + var selected = UpdateAssetSelector.TrySelectInstaller(release, out var asset); + + Assert.True(selected); + Assert.Equal("ThreadPilot_v1.4.0_Setup.exe", asset.Name); + } + + [Fact] + public async Task DownloadInstallerAsync_VerifiesChecksum() + { + using var tempRoot = new TempDirectory(); + var installerBytes = Encoding.UTF8.GetBytes("installer-content"); + var expectedHash = ComputeSha256(installerBytes); + var client = new FakeUpdateDownloadClient(installerBytes, $"{expectedHash} ThreadPilot_v1.4.0_Setup.exe"); + var service = CreateDownloadService(tempRoot.Path, client); + + var result = await service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum()); + + Assert.True(result.ChecksumVerified); + Assert.True(File.Exists(result.InstallerPath)); + } + + [Fact] + public async Task DownloadInstallerAsync_RejectsInvalidChecksumAndCleansTemp() + { + using var tempRoot = new TempDirectory(); + var client = new FakeUpdateDownloadClient(Encoding.UTF8.GetBytes("installer-content"), $"{new string('0', 64)} ThreadPilot_v1.4.0_Setup.exe"); + var service = CreateDownloadService(tempRoot.Path, client); + + await Assert.ThrowsAsync(() => service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum())); + Assert.Empty(Directory.GetDirectories(tempRoot.Path)); + } + + [Fact] + public void UpdateTempDirectoryProvider_DoesNotDeleteOutsideUpdateRoot() + { + using var tempRoot = new TempDirectory(); + using var outside = new TempDirectory(); + File.WriteAllText(Path.Combine(outside.Path, "settings.json"), "{}"); + var provider = new UpdateTempDirectoryProvider(tempRoot.Path); + + provider.Cleanup(outside.Path); + + Assert.True(File.Exists(Path.Combine(outside.Path, "settings.json"))); + } + + [Fact] + public async Task StartupCheck_DoesNotDownloadOrInstallWithoutUserConsent() + { + var harness = new Harness(); + + var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup)); + + Assert.True(result.IsUpdateAvailable); + harness.Download.Verify(service => service.DownloadInstallerAsync(It.IsAny(), It.IsAny()), Times.Never); + harness.Installer.Verify(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DownloadAndInstallAsync_StartsInstallerAndRequestsShutdown() + { + var harness = new Harness(); + harness.Download + .Setup(service => service.DownloadInstallerAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UpdateDownloadResult( + Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"), + harness.TempDirectory, + true, + UpdateSignatureStatus.Unknown, + "ok")); + File.WriteAllText(Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"), "installer"); + + var result = await harness.Service.DownloadAndInstallAsync(CreateReleaseWithInstallerAndChecksum()); + + Assert.Equal(UpdateInstallStatus.Started, result.Status); + harness.Installer.Verify(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()), Times.Once); + harness.Shutdown.Verify(service => service.RequestShutdownForUpdate(), Times.Once); + } + + private static UpdateDownloadService CreateDownloadService(string tempRoot, IUpdateDownloadClient client) + { + var signature = new Mock(); + signature.Setup(verifier => verifier.Verify(It.IsAny())).Returns(UpdateSignatureStatus.Unknown); + return new UpdateDownloadService( + client, + new UpdateTempDirectoryProvider(tempRoot), + signature.Object, + NullLogger.Instance); + } + + private static UpdateReleaseInfo CreateReleaseWithInstallerAndChecksum() + { + return CreateRelease( + new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 10), + new UpdateAsset("SHA256SUMS.txt", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/SHA256SUMS.txt"), 10)); + } + + private static UpdateReleaseInfo CreateRelease(params UpdateAsset[] assets) + { + return new UpdateReleaseInfo( + new SemanticVersion(1, 4, 0), + "v1.4.0", + new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0"), + false, + assets); + } + + private static string ComputeSha256(byte[] bytes) + { + var hash = System.Security.Cryptography.SHA256.HashData(bytes); + return Convert.ToHexString(hash); + } + + private sealed class Harness + { + public ApplicationSettingsModel Settings { get; } = new(); + + public ApplicationSettingsModel? SavedSettings { get; private set; } + + public FakeClock Clock { get; } = new(); + + public FakeGitHubReleaseClient ReleaseClient { get; } = new( + """ + [ + { + "tag_name": "v1.4.0", + "prerelease": false, + "draft": false, + "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0", + "assets": [ + { "name": "ThreadPilot_v1.4.0_Setup.exe", "browser_download_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe", "size": 100 }, + { "name": "SHA256SUMS.txt", "browser_download_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/SHA256SUMS.txt", "size": 100 } + ] + } + ] + """); + + public Mock Download { get; } = new(MockBehavior.Strict); + + public Mock Installer { get; } = new(MockBehavior.Strict); + + public Mock Shutdown { get; } = new(MockBehavior.Strict); + + public string TempDirectory { get; } + + public UpdateService Service { get; } + + public Harness() + { + this.TempDirectory = Directory.CreateTempSubdirectory("ThreadPilotUpdateTest").FullName; + var settingsService = new Mock(); + settingsService.SetupGet(service => service.Settings).Returns(() => (ApplicationSettingsModel)this.Settings.Clone()); + settingsService + .Setup(service => service.UpdateSettingsAsync(It.IsAny())) + .Callback(settings => + { + this.SavedSettings = (ApplicationSettingsModel)settings.Clone(); + this.Settings.CopyFrom(settings); + }) + .Returns(Task.CompletedTask); + + var versionProvider = new Mock(); + versionProvider.SetupGet(provider => provider.CurrentVersion).Returns(new SemanticVersion(1, 3, 1)); + versionProvider.SetupGet(provider => provider.DisplayVersion).Returns("v1.3.1"); + + var tempProvider = new Mock(); + tempProvider.Setup(provider => provider.Cleanup(It.IsAny())); + + this.Shutdown.Setup(service => service.RequestShutdownForUpdate()); + this.Installer + .Setup(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + this.Service = new UpdateService( + new GitHubUpdateChecker(this.ReleaseClient), + settingsService.Object, + versionProvider.Object, + this.Download.Object, + this.Installer.Object, + tempProvider.Object, + this.Shutdown.Object, + this.Clock, + NullLogger.Instance); + } + } + + private sealed class FakeClock : IUpdateClock + { + public DateTimeOffset UtcNow { get; } = new(2026, 6, 7, 12, 0, 0, TimeSpan.Zero); + } + + private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient + { + private readonly string releasesJson; + + public bool RequestedReleases { get; private set; } + + public FakeGitHubReleaseClient(string releasesJson) + { + this.releasesJson = releasesJson; + } + + public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default) + { + this.RequestedReleases = true; + return Task.FromResult(this.releasesJson); + } + } + + private sealed class FakeUpdateDownloadClient : IUpdateDownloadClient + { + private readonly byte[] fileBytes; + private readonly string? checksumsText; + + public FakeUpdateDownloadClient(byte[] fileBytes, string? checksumsText) + { + this.fileBytes = fileBytes; + this.checksumsText = checksumsText; + } + + public Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default) + { + File.WriteAllBytes(destinationPath, this.fileBytes); + return Task.CompletedTask; + } + + public Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.checksumsText); + } + } + + private sealed class TempDirectory : IDisposable + { + public string Path { get; } = Directory.CreateTempSubdirectory("ThreadPilotUpdateTest").FullName; + + public void Dispose() + { + if (Directory.Exists(this.Path)) + { + Directory.Delete(this.Path, recursive: true); + } + } + } + } +} diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index c764aad..fdbcde9 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -19,7 +19,6 @@ namespace ThreadPilot.ViewModels using System; using System.Collections.Generic; using System.Collections.ObjectModel; - using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; @@ -48,13 +47,15 @@ public partial class SettingsViewModel : BaseViewModel private readonly IProcessMonitorManagerService processMonitorManagerService; private readonly IThemeService themeService; private readonly ISystemTrayService systemTrayService; - private readonly GitHubUpdateChecker gitHubUpdateChecker; + private readonly IUpdateService updateService; + private readonly IApplicationVersionProvider versionProvider; private readonly ILocalizationService localizationService; private ApplicationSettingsModel savedSettingsSnapshot; private bool isSyncingFromService = false; private bool? appliedThemePreference; private string cachedDefaultPowerPlanGuid = string.Empty; private string cachedDefaultPowerPlanName = string.Empty; + private UpdateReleaseInfo? availableUpdate; private static readonly JsonSerializerOptions ImportExportJsonOptions = new() { WriteIndented = true, @@ -96,6 +97,19 @@ public partial class SettingsViewModel : BaseViewModel public ICommand CheckUpdatesCommand { get; } + public IAsyncRelayCommand DownloadAndInstallUpdateCommand { get; } + + [ObservableProperty] + private string latestUpdateVersion = string.Empty; + + [ObservableProperty] + private string lastUpdateCheckText = string.Empty; + + [ObservableProperty] + private bool isUpdateAvailable = false; + + public bool CanDownloadAndInstallUpdate => this.IsUpdateAvailable && !this.IsLoading; + public SettingsViewModel( ILogger logger, IApplicationSettingsService settingsService, @@ -106,7 +120,8 @@ public SettingsViewModel( IProcessMonitorManagerService processMonitorManagerService, IThemeService themeService, ISystemTrayService systemTrayService, - GitHubUpdateChecker gitHubUpdateChecker, + IUpdateService updateService, + IApplicationVersionProvider versionProvider, ILocalizationService localizationService, IEnhancedLoggingService? enhancedLoggingService = null, IActivityAuditService? activityAuditService = null) @@ -120,24 +135,18 @@ public SettingsViewModel( this.processMonitorManagerService = processMonitorManagerService ?? throw new ArgumentNullException(nameof(processMonitorManagerService)); this.themeService = themeService ?? throw new ArgumentNullException(nameof(themeService)); this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService)); - this.gitHubUpdateChecker = gitHubUpdateChecker ?? throw new ArgumentNullException(nameof(gitHubUpdateChecker)); + this.updateService = updateService ?? throw new ArgumentNullException(nameof(updateService)); + this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider)); this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); - // Get version and strip the git commit hash (everything after '+') - var rawVersion = typeof(App).Assembly - .GetCustomAttribute()? - .InformationalVersion - ?? typeof(App).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; - - // Remove commit hash suffix and add 'v' prefix - var cleanVersion = rawVersion.Split('+')[0]; - this.ApplicationVersion = $"v{cleanVersion}"; + this.ApplicationVersion = this.versionProvider.DisplayVersion; // Initialize with current settings this.settings = (ApplicationSettingsModel)this.settingsService.Settings.Clone(); this.savedSettingsSnapshot = (ApplicationSettingsModel)this.settings.Clone(); this.appliedThemePreference = this.settings.UseDarkTheme; + this.LatestUpdateVersion = this.GetLocalizedString("Settings_UpdateNotChecked", "Not checked"); + this.UpdateLastCheckedText(); // Initialize commands this.SaveSettingsCommand = new AsyncRelayCommand(this.SaveSettingsAsync); @@ -147,6 +156,9 @@ public SettingsViewModel( this.TestNotificationCommand = new AsyncRelayCommand(this.TestNotificationAsync); this.RefreshPowerPlansCommand = new AsyncRelayCommand(this.RefreshPowerPlansAsync); this.CheckUpdatesCommand = new AsyncRelayCommand(this.CheckUpdatesAsync); + this.DownloadAndInstallUpdateCommand = new AsyncRelayCommand( + this.DownloadAndInstallUpdateAsync, + () => this.CanDownloadAndInstallUpdate); // Subscribe to property changes to track unsaved changes this.Settings.PropertyChanged += this.OnSettingsPropertyChanged; @@ -265,6 +277,14 @@ partial void OnHasUnsavedChangesChanged(bool value) partial void OnIsLoadingChanged(bool value) { OnPropertyChanged(nameof(CanSaveSettings)); + OnPropertyChanged(nameof(CanDownloadAndInstallUpdate)); + this.DownloadAndInstallUpdateCommand.NotifyCanExecuteChanged(); + } + + partial void OnIsUpdateAvailableChanged(bool value) + { + OnPropertyChanged(nameof(CanDownloadAndInstallUpdate)); + this.DownloadAndInstallUpdateCommand.NotifyCanExecuteChanged(); } private async Task SaveSettingsAsync() @@ -671,40 +691,34 @@ private async Task CheckUpdatesAsync() this.IsLoading = true; this.StatusMessage = this.GetLocalizedString("Settings_StatusCheckingUpdates", "Checking for updates..."); - var currentVersion = ParseVersion(this.ApplicationVersion); - var (latest, releaseUrl) = await this.gitHubUpdateChecker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot"); + var result = await this.updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual)); + this.UpdateLastCheckedText(); - if (latest is null) + if (result.Status == UpdateCheckStatus.Failed) { this.StatusMessage = this.GetLocalizedString("Settings_StatusLatestUnknown", "Unable to determine the latest version."); await this.notificationService.ShowErrorNotificationAsync( "Update Check", - "Unable to retrieve latest release information."); + result.Message); return; } - if (latest > currentVersion) + if (result.IsUpdateAvailable && result.Release != null) { - this.StatusMessage = this.GetLocalizedString("Settings_StatusNewVersionFormat", "New version available: {0}", latest); - - var result = System.Windows.MessageBox.Show( - $"Update available\nInstalled version: {this.ApplicationVersion}\nNew version: {latest}\n\nDo you want to open the download page?", + this.availableUpdate = result.Release; + this.LatestUpdateVersion = $"v{result.Release.Version}"; + this.IsUpdateAvailable = true; + this.StatusMessage = this.GetLocalizedString("Settings_StatusNewVersionFormat", "New version available: {0}", result.Release.Version); + await this.notificationService.ShowNotificationAsync( "Update available", - MessageBoxButton.YesNo, - MessageBoxImage.Information); - - if (result == MessageBoxResult.Yes) - { - var url = releaseUrl ?? "https://github.com/PrimeBuild-pc/ThreadPilot/releases/latest"; - Process.Start(new ProcessStartInfo - { - FileName = url, - UseShellExecute = true, - }); - } + $"ThreadPilot {result.Release.Version} is available.", + NotificationType.Information); } else { + this.availableUpdate = null; + this.LatestUpdateVersion = result.Release != null ? $"v{result.Release.Version}" : this.GetLocalizedString("Settings_UpdateLatestUnknown", "Unknown"); + this.IsUpdateAvailable = false; this.StatusMessage = this.GetLocalizedString("Settings_StatusUpToDateFormat", "Application is up to date. Installed version: {0}", this.ApplicationVersion); await this.notificationService.ShowSuccessNotificationAsync( "Application up to date", @@ -727,24 +741,73 @@ await this.notificationService.ShowErrorNotificationAsync( } } - private static Version ParseVersion(string versionString) + private async Task DownloadAndInstallUpdateAsync() { - if (string.IsNullOrWhiteSpace(versionString)) + if (this.availableUpdate == null) { - return new Version(0, 0, 0); + this.StatusMessage = this.GetLocalizedString("Settings_StatusLatestUnknown", "Unable to determine the latest version."); + return; } - var sanitized = versionString.Trim(); - if (sanitized.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + var message = this.GetLocalizedString( + "Settings_UpdateConfirmMessageFormat", + "ThreadPilot will download and verify version {0}, then ask Windows for permission to run the installer. Continue?", + this.availableUpdate.Version); + var confirmation = System.Windows.MessageBox.Show( + message, + this.GetLocalizedString("Settings_UpdateConfirmTitle", "Install ThreadPilot update"), + MessageBoxButton.YesNo, + MessageBoxImage.Information); + + if (confirmation != MessageBoxResult.Yes) { - sanitized = sanitized[1..]; + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateCanceled", "Update canceled."); + return; } - sanitized = sanitized.Split('-', '+')[0]; + try + { + this.IsLoading = true; + this.StatusMessage = this.GetLocalizedString("Settings_StatusDownloadingUpdate", "Downloading and verifying update..."); - return Version.TryParse(sanitized, out var parsed) - ? parsed - : new Version(0, 0, 0); + var result = await this.updateService.DownloadAndInstallAsync(this.availableUpdate); + if (result.Status == UpdateInstallStatus.Started) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallerStarted", "Update installer started."); + await this.notificationService.ShowNotificationAsync( + "Update installer started", + "ThreadPilot will close while the installer runs.", + NotificationType.Information); + } + else + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallFailedFormat", "Update install failed: {0}", result.Message); + await this.notificationService.ShowErrorNotificationAsync( + "Update install failed", + result.Message); + } + } + catch (Exception ex) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallFailedFormat", "Update install failed: {0}", ex.Message); + this.Logger.LogError(ex, "Error downloading or installing update"); + await this.notificationService.ShowErrorNotificationAsync( + "Update install failed", + "Unable to download or start the update installer", + ex); + } + finally + { + this.IsLoading = false; + } + } + + private void UpdateLastCheckedText() + { + var lastCheck = this.settingsService.Settings.LastUpdateCheckUtc; + this.LastUpdateCheckText = lastCheck.HasValue + ? lastCheck.Value.LocalDateTime.ToString("g", System.Globalization.CultureInfo.CurrentCulture) + : this.GetLocalizedString("Settings_UpdateLastCheckedNever", "Never"); } private string GetLocalizedString(string key, string fallback, params object[] args) diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml index c98d358..bb09a94 100644 --- a/Views/SettingsView.xaml +++ b/Views/SettingsView.xaml @@ -350,6 +350,38 @@ + + + + + + + + + + + + + + + + + + + + + @@ -359,6 +391,12 @@ Command="{Binding CheckUpdatesCommand}" Padding="15,6" HorizontalAlignment="Left"/> +