diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index 2b6bc0e4a7..a9b8be9926 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -23,6 +23,8 @@ namespace UniGetUI.Avalonia.Infrastructure; /// internal static partial class AvaloniaAutoUpdater { + private const string INSTALLER_VALIDATION_FAILURE_MARKER_SUFFIX = ".validation-failed"; + // ------------------------------------------------------------------ constants private const string REGISTRY_PATH = @"Software\Devolutions\UniGetUI"; private const string DEFAULT_PRODUCTINFO_URL = "https://devolutions.net/productinfo.json"; @@ -370,10 +372,10 @@ public static async Task UpdateCheckLoopAsync() return; } - bool success = await CheckAndInstallUpdatesAsync(autoLaunch: isFirstLaunch); + await CheckAndInstallUpdatesAsync(autoLaunch: isFirstLaunch); isFirstLaunch = false; - await Task.Delay(TimeSpan.FromMinutes(success ? 60 : 10)); + await Task.Delay(UpdaterDownloadEngine.DefaultAutomaticUpdateCheckInterval); } } @@ -425,6 +427,11 @@ internal static async Task CheckAndInstallUpdatesAsync(bool autoLaunch = f // macOS and Linux both ship as self-contained .tar.gz archives. installerName = "UniGetUI Updater.tar.gz"; string installerPath = Path.Join(CoreData.UniGetUIDataDirectory, installerName); + UpdaterDownloadIdentity installerIdentity = new( + candidate.VersionName, + candidate.InstallerHash, + candidate.InstallerDownloadUrl + ); // Try cached installer first if ( @@ -433,12 +440,44 @@ internal static async Task CheckAndInstallUpdatesAsync(bool autoLaunch = f && CheckInstallerSignerThumbprint(installerPath, overrides) ) { + ClearInstallerValidationFailure(GetInstallerValidationFailureMarkerPath(installerPath)); + UpdaterDownloadEngine.ClearFailureState( + UpdaterDownloadEngine.GetFailureStatePath(installerPath), + message => LogUpdateWarn(message) + ); LogUpdateInfo("Cached valid installer found, preparing to launch..."); return await PrepareAndLaunchAsync(installerPath, candidate.VersionName, autoLaunch, manualCheck); } - // Delete invalid/outdated cached copy - try { File.Delete(installerPath); } catch { } + string installerValidationFailureMarkerPath = + GetInstallerValidationFailureMarkerPath(installerPath); + string installerDownloadFailureStatePath = + UpdaterDownloadEngine.GetFailureStatePath(installerPath); + if (File.Exists(installerPath)) + { + LogUpdateWarn( + "Cached installer is invalid; it will be kept until a replacement is validated." + ); + } + + if ( + !manualCheck + && UpdaterDownloadEngine.IsFailureBackoffActive( + installerDownloadFailureStatePath, + installerIdentity, + DateTime.UtcNow, + out TimeSpan remainingBackoff, + out UpdaterDownloadFailureState? failureState, + message => LogUpdateWarn(message) + ) + ) + { + LogUpdateWarn( + $"Skipping installer download for version {candidate.VersionName}; previous {failureState?.FailureClass ?? "download"} failure is backed off for {remainingBackoff:g}." + ); + MarkAttemptFinished("installer download skipped by failure backoff"); + return true; + } RaiseStatus( CoreTools.Translate( @@ -449,17 +488,59 @@ internal static async Task CheckAndInstallUpdatesAsync(bool autoLaunch = f isClosable: false); LogUpdateInfo("Downloading installer..."); - await DownloadInstallerAsync(candidate.InstallerDownloadUrl, installerPath, overrides); + string downloadedInstallerPath; + try + { + downloadedInstallerPath = await DownloadInstallerAsync( + candidate.InstallerDownloadUrl, + installerPath, + overrides, + installerIdentity + ); + } + catch (Exception ex) + { + UpdaterDownloadEngine.RecordFailure( + installerDownloadFailureStatePath, + installerIdentity, + "download", + DateTime.UtcNow, + message => LogUpdateWarn(message) + ); + LogUpdateWarn($"Installer download failed: {ex.Message}"); + throw; + } if ( - await CheckInstallerHashAsync(installerPath, candidate.InstallerHash, overrides) - && CheckInstallerSignerThumbprint(installerPath, overrides) + await CheckInstallerHashAsync(downloadedInstallerPath, candidate.InstallerHash, overrides) + && CheckInstallerSignerThumbprint(downloadedInstallerPath, overrides) ) { + UpdaterDownloadEngine.PromotePartialDownload( + installerPath, + message => LogUpdateWarn(message) + ); + ClearInstallerValidationFailure(installerValidationFailureMarkerPath); + UpdaterDownloadEngine.ClearFailureState( + installerDownloadFailureStatePath, + message => LogUpdateWarn(message) + ); LogUpdateInfo("Downloaded installer is valid, preparing to launch..."); return await PrepareAndLaunchAsync(installerPath, candidate.VersionName, autoLaunch, manualCheck); } + UpdaterDownloadEngine.RecordFailure( + installerDownloadFailureStatePath, + installerIdentity, + "validation", + DateTime.UtcNow, + message => LogUpdateWarn(message) + ); + UpdaterDownloadEngine.DeletePartialDownload( + installerPath, + message => LogUpdateWarn(message) + ); + LogUpdateError("Installer authenticity could not be verified. Aborting update."); RaiseStatus( CoreTools.Translate("The installer authenticity could not be verified."), @@ -469,7 +550,7 @@ await CheckInstallerHashAsync(installerPath, candidate.InstallerHash, overrides) actionButtonText: CoreTools.Translate("View log"), actionButtonAction: OpenUpdateLog); MarkAttemptFinished("authenticity verification failed"); - return false; + return !manualCheck; } catch (PlatformArtifactMissingException ex) { @@ -508,6 +589,33 @@ await CheckInstallerHashAsync(installerPath, candidate.InstallerHash, overrides) } // ------------------------------------------------------------------ update flow + internal static string GetInstallerValidationFailureMarkerPath(string installerPath) + { + return installerPath + INSTALLER_VALIDATION_FAILURE_MARKER_SUFFIX; + } + + private static void ClearInstallerValidationFailure(string markerPath) + { + DeleteFileIfExists(markerPath, "installer validation failure marker"); + } + + private static void DeleteFileIfExists(string path, string description) + { + if (!File.Exists(path)) + { + return; + } + + try + { + File.Delete(path); + } + catch (Exception ex) + { + LogUpdateWarn($"Could not delete {description} at '{path}': {ex.Message}"); + } + } + private static async Task PrepareAndLaunchAsync( string installerPath, string versionName, @@ -1329,10 +1437,11 @@ private static bool VerifyMacAppSignature(string appBundlePath) } // ------------------------------------------------------------------ download - private static async Task DownloadInstallerAsync( + private static async Task DownloadInstallerAsync( string url, string destination, - UpdaterOverrides overrides) + UpdaterOverrides overrides, + UpdaterDownloadIdentity identity) { if (!IsSourceUrlAllowed(url, overrides.AllowUnsafeUrls)) { @@ -1343,14 +1452,18 @@ private static async Task DownloadInstallerAsync( using HttpClient client = new(CreateHttpClientHandler(overrides)); client.Timeout = TimeSpan.FromSeconds(600); client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); - - HttpResponseMessage response = await client.GetAsync(url); - response.EnsureSuccessStatusCode(); - - using FileStream fs = new(destination, FileMode.OpenOrCreate); - await response.Content.CopyToAsync(fs); + using CancellationTokenSource downloadTimeout = new(TimeSpan.FromSeconds(600)); + + UpdaterDownloadResult result = await UpdaterDownloadEngine.DownloadInstallerPartAsync( + client, + identity, + destination, + message => LogUpdateDebug(message), + downloadTimeout.Token + ); LogUpdateDebug("Installer download complete."); + return result.PartialPath; } // ------------------------------------------------------------------ HTTP client diff --git a/src/UniGetUI.Core.IconEngine.Tests/IconDatabaseTests.cs b/src/UniGetUI.Core.IconEngine.Tests/IconDatabaseTests.cs index 0d1dfc70c3..56df770f7e 100644 --- a/src/UniGetUI.Core.IconEngine.Tests/IconDatabaseTests.cs +++ b/src/UniGetUI.Core.IconEngine.Tests/IconDatabaseTests.cs @@ -50,5 +50,29 @@ public async Task TestGetNonExistingIconAndImagesAsync() ); Assert.Empty(nonexistent_screenshots); } + + [Fact] + public void TestCachedDatabaseFreshness() + { + string path = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + try + { + File.WriteAllText(path, "{}"); + DateTime now = DateTime.UtcNow; + + File.SetLastWriteTimeUtc(path, now.AddHours(-23)); + Assert.True(IconDatabase.IsCachedDatabaseFresh(path, now)); + + File.SetLastWriteTimeUtc(path, now.AddHours(-25)); + Assert.False(IconDatabase.IsCachedDatabaseFresh(path, now)); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } } } diff --git a/src/UniGetUI.Core.IconStore/IconDatabase.cs b/src/UniGetUI.Core.IconStore/IconDatabase.cs index cd9afb9c48..32118b3b85 100644 --- a/src/UniGetUI.Core.IconStore/IconDatabase.cs +++ b/src/UniGetUI.Core.IconStore/IconDatabase.cs @@ -11,6 +11,9 @@ namespace UniGetUI.Core.IconEngine /// public class IconDatabase { + private const string ICON_DATABASE_FILE_NAME = "Icon Database.json"; + private static readonly TimeSpan ICON_DATABASE_REFRESH_INTERVAL = TimeSpan.FromDays(1); + public struct IconCount { public int PackagesWithIconCount = 0; @@ -40,16 +43,31 @@ private Dictionary< /// public async Task LoadIconAndScreenshotsDatabaseAsync() { - try + string IconsAndScreenshotsFile = GetIconsAndScreenshotsFile(); + bool hasCustomDownloadUrl = Settings.Get(Settings.K.IconDataBaseURL); + if ( + !hasCustomDownloadUrl + && IsCachedDatabaseFresh(IconsAndScreenshotsFile, DateTime.UtcNow) + ) { - string IconsAndScreenshotsFile = Path.Join( - CoreData.UniGetUICacheDirectory_Data, - "Icon Database.json" + Logger.Debug("Using cached icons and screenshots database; refresh is not due yet"); + await LoadFromCacheAsync(); + if (__icon_count.PackagesWithIconCount > 0) + { + return; + } + + Logger.Warn( + "Cached icons and screenshots database could not be loaded; refreshing it" ); + } + + try + { Uri DownloadUrl = new( "https://github.com/Devolutions/UniGetUI/raw/refs/heads/main/WebBasedData/screenshot-database-v2.json" ); - if (Settings.Get(Settings.K.IconDataBaseURL)) + if (hasCustomDownloadUrl) { DownloadUrl = new Uri(Settings.GetValue(Settings.K.IconDataBaseURL)); } @@ -58,7 +76,7 @@ public async Task LoadIconAndScreenshotsDatabaseAsync() { client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); string fileContents = await client.GetStringAsync(DownloadUrl); - await File.WriteAllTextAsync(IconsAndScreenshotsFile, fileContents); + await WriteCacheFileAsync(IconsAndScreenshotsFile, fileContents); } Logger.ImportantInfo("Downloaded new icons and screenshots successfully!"); @@ -79,14 +97,34 @@ public async Task LoadIconAndScreenshotsDatabaseAsync() await LoadFromCacheAsync(); } + internal static bool IsCachedDatabaseFresh(string path, DateTime utcNow) + { + if (!File.Exists(path)) + { + return false; + } + + DateTime lastWriteTimeUtc = File.GetLastWriteTimeUtc(path); + return utcNow - lastWriteTimeUtc < ICON_DATABASE_REFRESH_INTERVAL; + } + + private static string GetIconsAndScreenshotsFile() + { + return Path.Join(CoreData.UniGetUICacheDirectory_Data, ICON_DATABASE_FILE_NAME); + } + + private static async Task WriteCacheFileAsync(string path, string contents) + { + string temporaryPath = path + ".tmp"; + await File.WriteAllTextAsync(temporaryPath, contents); + File.Move(temporaryPath, path, overwrite: true); + } + public async Task LoadFromCacheAsync() { try { - string IconsAndScreenshotsFile = Path.Join( - CoreData.UniGetUICacheDirectory_Data, - "Icon Database.json" - ); + string IconsAndScreenshotsFile = GetIconsAndScreenshotsFile(); IconScreenshotDatabase_v2 JsonData = IconStoreJson.DeserializeIconDatabase( await File.ReadAllTextAsync(IconsAndScreenshotsFile) diff --git a/src/UniGetUI.Core.IconStore/InternalsVisibleTo.cs b/src/UniGetUI.Core.IconStore/InternalsVisibleTo.cs new file mode 100644 index 0000000000..15598c5c1a --- /dev/null +++ b/src/UniGetUI.Core.IconStore/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.Core.IconEngine.Tests")] diff --git a/src/UniGetUI.Core.Tools.Tests/UpdaterDownloadEngineTests.cs b/src/UniGetUI.Core.Tools.Tests/UpdaterDownloadEngineTests.cs new file mode 100644 index 0000000000..a73c265f8e --- /dev/null +++ b/src/UniGetUI.Core.Tools.Tests/UpdaterDownloadEngineTests.cs @@ -0,0 +1,420 @@ +using System.Net; +using System.Net.Http.Headers; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Core.Tools.Tests; + +public sealed class UpdaterDownloadEngineTests +{ + [Fact] + public void DefaultAutomaticUpdateCheckInterval_IsOneHour() + { + Assert.Equal(TimeSpan.FromMinutes(60), UpdaterDownloadEngine.DefaultAutomaticUpdateCheckInterval); + } + + [Fact] + public async Task DownloadInstallerPartAsync_ResumesInterruptedDownloadWithRangeAndIfRange() + { + string destination = GetTempDestination(); + byte[] content = "abcdefghij"u8.ToArray(); + DateTimeOffset lastModified = new(2026, 6, 15, 17, 46, 1, TimeSpan.Zero); + int requestCount = 0; + using HttpClient client = new(new QueueHttpMessageHandler(request => + { + requestCount++; + if (requestCount == 1) + { + Assert.Null(request.Headers.Range); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new FaultingReadStream(content, 5)), + }; + response.Content.Headers.ContentLength = content.Length; + response.Content.Headers.LastModified = lastModified; + return response; + } + + Assert.Equal(2, requestCount); + Assert.NotNull(request.Headers.Range); + RangeItemHeaderValue range = Assert.Single(request.Headers.Range!.Ranges); + Assert.Equal(5, range.From); + Assert.Null(range.To); + Assert.NotNull(request.Headers.IfRange?.Date); + Assert.Equal(lastModified, request.Headers.IfRange!.Date); + + var resumed = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(content[5..]), + }; + resumed.Content.Headers.ContentRange = new ContentRangeHeaderValue(5, 9, 10); + resumed.Content.Headers.ContentLength = 5; + resumed.Content.Headers.LastModified = lastModified; + return resumed; + })); + UpdaterDownloadIdentity identity = new("2026.2.1.0", "hash", "https://example.test/installer.exe"); + + try + { + await Assert.ThrowsAsync( + () => UpdaterDownloadEngine.DownloadInstallerPartAsync(client, identity, destination) + ); + Assert.Equal(5, new FileInfo(UpdaterDownloadEngine.GetPartialPath(destination)).Length); + + UpdaterDownloadResult result = await UpdaterDownloadEngine.DownloadInstallerPartAsync( + client, + identity, + destination + ); + + Assert.True(result.Resumed); + Assert.Equal(HttpStatusCode.PartialContent, result.StatusCode); + Assert.Equal(content.Length, result.BytesInPartialFile); + Assert.Equal(content, await File.ReadAllBytesAsync(result.PartialPath)); + Assert.Equal(2, requestCount); + } + finally + { + DeleteUpdaterTempFiles(destination); + } + } + + [Fact] + public async Task DownloadInstallerPartAsync_RestartsWhenServerIgnoresRange() + { + string destination = GetTempDestination(); + byte[] content = "abcdefghij"u8.ToArray(); + DateTimeOffset lastModified = new(2026, 6, 15, 17, 46, 1, TimeSpan.Zero); + int requestCount = 0; + using HttpClient client = new(new QueueHttpMessageHandler(request => + { + requestCount++; + if (requestCount == 1) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new FaultingReadStream(content, 5)), + }; + response.Content.Headers.ContentLength = content.Length; + response.Content.Headers.LastModified = lastModified; + return response; + } + + Assert.NotNull(request.Headers.Range); + var restart = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(content), + }; + restart.Content.Headers.ContentLength = content.Length; + restart.Content.Headers.LastModified = lastModified; + return restart; + })); + UpdaterDownloadIdentity identity = new("2026.2.1.0", "hash", "https://example.test/installer.exe"); + + try + { + await Assert.ThrowsAsync( + () => UpdaterDownloadEngine.DownloadInstallerPartAsync(client, identity, destination) + ); + + UpdaterDownloadResult result = await UpdaterDownloadEngine.DownloadInstallerPartAsync( + client, + identity, + destination + ); + + Assert.False(result.Resumed); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(content.Length, result.BytesInPartialFile); + Assert.Equal(content, await File.ReadAllBytesAsync(result.PartialPath)); + } + finally + { + DeleteUpdaterTempFiles(destination); + } + } + + [Fact] + public async Task DownloadInstallerPartAsync_RestartsWhenResumeValidatorChanges() + { + string destination = GetTempDestination(); + byte[] content = "abcdefghij"u8.ToArray(); + int requestCount = 0; + using HttpClient client = new(new QueueHttpMessageHandler(request => + { + requestCount++; + if (requestCount == 1) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new FaultingReadStream(content, 5)), + }; + response.Headers.ETag = new EntityTagHeaderValue("\"old\""); + response.Content.Headers.ContentLength = content.Length; + return response; + } + + if (requestCount == 2) + { + Assert.NotNull(request.Headers.Range); + Assert.Equal("\"old\"", request.Headers.IfRange?.EntityTag?.ToString()); + var changedValidator = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(content[5..]), + }; + changedValidator.Headers.ETag = new EntityTagHeaderValue("\"new\""); + changedValidator.Content.Headers.ContentRange = new ContentRangeHeaderValue(5, 9, 10); + return changedValidator; + } + + Assert.Equal(3, requestCount); + Assert.Null(request.Headers.Range); + var restart = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(content), + }; + restart.Headers.ETag = new EntityTagHeaderValue("\"new\""); + restart.Content.Headers.ContentLength = content.Length; + return restart; + })); + UpdaterDownloadIdentity identity = new("2026.2.1.0", "hash", "https://example.test/installer.exe"); + + try + { + await Assert.ThrowsAsync( + () => UpdaterDownloadEngine.DownloadInstallerPartAsync(client, identity, destination) + ); + + UpdaterDownloadResult result = await UpdaterDownloadEngine.DownloadInstallerPartAsync( + client, + identity, + destination + ); + + Assert.False(result.Resumed); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(content, await File.ReadAllBytesAsync(result.PartialPath)); + Assert.Equal(3, requestCount); + } + finally + { + DeleteUpdaterTempFiles(destination); + } + } + + [Fact] + public async Task DownloadInstallerPartAsync_ReusesCompletePartialWithoutNetwork() + { + string destination = GetTempDestination(); + byte[] content = "abc"u8.ToArray(); + int requestCount = 0; + using HttpClient firstClient = new(new QueueHttpMessageHandler(_ => + { + requestCount++; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(content), + }; + response.Content.Headers.ContentLength = content.Length; + response.Content.Headers.LastModified = new DateTimeOffset(2026, 6, 15, 17, 46, 1, TimeSpan.Zero); + return response; + })); + UpdaterDownloadIdentity identity = new("2026.2.1.0", "hash", "https://example.test/installer.exe"); + + try + { + await UpdaterDownloadEngine.DownloadInstallerPartAsync(firstClient, identity, destination); + using HttpClient failingClient = new(new QueueHttpMessageHandler(_ => + throw new InvalidOperationException("Network should not be used for a complete partial") + )); + + UpdaterDownloadResult result = await UpdaterDownloadEngine.DownloadInstallerPartAsync( + failingClient, + identity, + destination + ); + + Assert.Equal(1, requestCount); + Assert.Equal(HttpStatusCode.NotModified, result.StatusCode); + Assert.Equal(content.Length, result.BytesInPartialFile); + } + finally + { + DeleteUpdaterTempFiles(destination); + } + } + + [Fact] + public void FailureBackoff_IsPersistentAndKeyedByInstallerIdentity() + { + string destination = GetTempDestination(); + string statePath = UpdaterDownloadEngine.GetFailureStatePath(destination); + DateTime now = DateTime.UtcNow; + UpdaterDownloadIdentity identity = new("2026.2.1.0", "ABC", "https://example.test/installer.exe"); + UpdaterDownloadIdentity differentHash = identity with { ExpectedHash = "DEF" }; + + try + { + UpdaterDownloadEngine.RecordFailure(statePath, identity, "download", now); + + Assert.True( + UpdaterDownloadEngine.IsFailureBackoffActive( + statePath, + identity, + now.AddMinutes(1), + out TimeSpan remaining, + out UpdaterDownloadFailureState? state + ) + ); + Assert.True(remaining > TimeSpan.FromMinutes(50)); + Assert.Equal(1, state?.AttemptCount); + Assert.Equal("download", state?.FailureClass); + + UpdaterDownloadEngine.RecordFailure(statePath, identity, "validation", now.AddMinutes(2)); + Assert.True( + UpdaterDownloadEngine.IsFailureBackoffActive( + statePath, + identity, + now.AddMinutes(3), + out _, + out state + ) + ); + Assert.Equal(1, state?.AttemptCount); + Assert.Equal("validation", state?.FailureClass); + + Assert.False( + UpdaterDownloadEngine.IsFailureBackoffActive( + statePath, + differentHash, + now.AddMinutes(1), + out _, + out _ + ) + ); + } + finally + { + DeleteUpdaterTempFiles(destination); + } + } + + [Fact] + public void CalculateFailureBackoff_UsesOneHourMinimumAndCapsAtOneDay() + { + UpdaterDownloadIdentity identity = new("2026.2.1.0", "ABC", "https://example.test/installer.exe"); + + TimeSpan first = UpdaterDownloadEngine.CalculateFailureBackoff(identity, "download", 1); + TimeSpan second = UpdaterDownloadEngine.CalculateFailureBackoff(identity, "download", 2); + TimeSpan capped = UpdaterDownloadEngine.CalculateFailureBackoff(identity, "download", 20); + + Assert.True(first >= TimeSpan.FromMinutes(60)); + Assert.True(first <= TimeSpan.FromMinutes(72)); + Assert.True(second > first); + Assert.Equal(TimeSpan.FromHours(24), capped); + } + + private static string GetTempDestination() + { + return Path.Join(Path.GetTempPath(), $"{Guid.NewGuid():N}.exe"); + } + + private static void DeleteUpdaterTempFiles(string destination) + { + foreach ( + string path in new[] + { + destination, + UpdaterDownloadEngine.GetPartialPath(destination), + UpdaterDownloadEngine.GetPartialMetadataPath(destination), + UpdaterDownloadEngine.GetFailureStatePath(destination), + } + ) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + private sealed class QueueHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + public QueueHttpMessageHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + return Task.FromResult(_handler(request)); + } + } + + private sealed class FaultingReadStream : Stream + { + private readonly byte[] _data; + private readonly int _failAfterBytes; + private int _position; + + public FaultingReadStream(byte[] data, int failAfterBytes) + { + _data = data; + _failAfterBytes = failAfterBytes; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _data.Length; + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_position >= _failAfterBytes) + { + throw new IOException("Simulated interrupted download"); + } + + int remainingBeforeFailure = _failAfterBytes - _position; + int bytesToCopy = Math.Min(count, remainingBeforeFailure); + Array.Copy(_data, _position, buffer, offset, bytesToCopy); + _position += bytesToCopy; + return bytesToCopy; + } + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) + { + if (_position >= _failAfterBytes) + { + throw new IOException("Simulated interrupted download"); + } + + int remainingBeforeFailure = _failAfterBytes - _position; + int bytesToCopy = Math.Min(buffer.Length, remainingBeforeFailure); + _data.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return ValueTask.FromResult(bytesToCopy); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + } +} diff --git a/src/UniGetUI.Core.Tools/UpdaterDownloadEngine.cs b/src/UniGetUI.Core.Tools/UpdaterDownloadEngine.cs new file mode 100644 index 0000000000..f095918efe --- /dev/null +++ b/src/UniGetUI.Core.Tools/UpdaterDownloadEngine.cs @@ -0,0 +1,657 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using UniGetUI.Core.Logging; + +namespace UniGetUI.Core.Tools; + +public readonly record struct UpdaterDownloadIdentity( + string Version, + string ExpectedHash, + string Url +); + +public sealed class UpdaterInstallerDownloadMetadata +{ + public string Version { get; set; } = ""; + public string ExpectedHash { get; set; } = ""; + public string Url { get; set; } = ""; + public string ETag { get; set; } = ""; + public DateTimeOffset? LastModified { get; set; } + public long? TotalLength { get; set; } + public DateTime UpdatedUtc { get; set; } +} + +public sealed class UpdaterDownloadFailureState +{ + public string Version { get; set; } = ""; + public string ExpectedHash { get; set; } = ""; + public string Url { get; set; } = ""; + public string FailureClass { get; set; } = ""; + public int AttemptCount { get; set; } + public DateTime LastFailureUtc { get; set; } + public DateTime NextAttemptUtc { get; set; } +} + +public readonly record struct UpdaterDownloadResult( + string PartialPath, + long BytesInPartialFile, + long? TotalBytes, + HttpStatusCode StatusCode, + bool Resumed +); + +public static class UpdaterDownloadEngine +{ + public static readonly TimeSpan DefaultAutomaticUpdateCheckInterval = TimeSpan.FromMinutes(60); + + private const int CopyBufferSize = 81920; + private const string PartialMarker = ".part"; + private const string MetadataSuffix = ".json"; + private const string FailureStateSuffix = ".download-failed.json"; + private static readonly TimeSpan BaseFailureBackoff = TimeSpan.FromMinutes(60); + private static readonly TimeSpan MaxFailureBackoff = TimeSpan.FromHours(24); + private const double MaxJitterRatio = 0.20; + + public static string GetPartialPath(string destinationPath) + { + string? directory = Path.GetDirectoryName(destinationPath); + string fileName = Path.GetFileName(destinationPath); + string extension = Path.GetExtension(fileName); + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); + string partialFileName = $"{fileNameWithoutExtension}{PartialMarker}{extension}"; + return string.IsNullOrEmpty(directory) + ? partialFileName + : Path.Join(directory, partialFileName); + } + + public static string GetPartialMetadataPath(string destinationPath) + { + return GetPartialPath(destinationPath) + MetadataSuffix; + } + + public static string GetFailureStatePath(string destinationPath) + { + return destinationPath + FailureStateSuffix; + } + + public static async Task DownloadInstallerPartAsync( + HttpClient client, + UpdaterDownloadIdentity identity, + string destinationPath, + Action? log = null, + CancellationToken cancellationToken = default + ) + { + return await DownloadInstallerPartAsync( + client, + identity, + destinationPath, + allowRestart: true, + log, + cancellationToken + ); + } + + private static async Task DownloadInstallerPartAsync( + HttpClient client, + UpdaterDownloadIdentity identity, + string destinationPath, + bool allowRestart, + Action? log, + CancellationToken cancellationToken + ) + { + string partialPath = GetPartialPath(destinationPath); + string metadataPath = GetPartialMetadataPath(destinationPath); + Directory.CreateDirectory(Path.GetDirectoryName(partialPath) ?? "."); + + UpdaterInstallerDownloadMetadata? metadata = TryReadMetadata(metadataPath, log); + bool canUsePartial = IsCompatible(metadata, identity); + if (!canUsePartial) + { + DeletePartialDownload(destinationPath, log); + metadata = null; + } + + long partialLength = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0; + if (metadata?.TotalLength is > 0 && partialLength > metadata.TotalLength.Value) + { + log?.Invoke( + $"Partial updater download is larger than expected ({partialLength}>{metadata.TotalLength}); restarting." + ); + DeletePartialDownload(destinationPath, log); + metadata = null; + partialLength = 0; + } + + if (metadata?.TotalLength is > 0 && partialLength == metadata.TotalLength.Value) + { + log?.Invoke( + $"Partial updater download is already complete ({partialLength} bytes); validating cached partial." + ); + return new UpdaterDownloadResult( + partialPath, + partialLength, + metadata.TotalLength, + HttpStatusCode.NotModified, + Resumed: false + ); + } + + RangeHeaderValue? range = null; + RangeConditionHeaderValue? ifRange = null; + bool requestResume = partialLength > 0 && TryConfigureResume(metadata, partialLength, out range, out ifRange); + if (partialLength > 0 && !requestResume) + { + log?.Invoke("Partial updater download cannot be resumed safely; restarting."); + DeletePartialDownload(destinationPath, log); + metadata = null; + partialLength = 0; + } + + using HttpRequestMessage request = new(HttpMethod.Get, identity.Url); + if (requestResume) + { + request.Headers.Range = range!; + request.Headers.IfRange = ifRange; + log?.Invoke($"Resuming updater download from byte {partialLength}."); + } + + using HttpResponseMessage response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ); + + if (response.StatusCode is HttpStatusCode.RequestedRangeNotSatisfiable && allowRestart) + { + log?.Invoke("Updater download range was not satisfiable; restarting from byte 0."); + DeletePartialDownload(destinationPath, log); + return await DownloadInstallerPartAsync( + client, + identity, + destinationPath, + allowRestart: false, + log, + cancellationToken + ); + } + + response.EnsureSuccessStatusCode(); + + bool appendToPartial = response.StatusCode is HttpStatusCode.PartialContent; + if (appendToPartial && !IsValidPartialResponse(response, partialLength)) + { + if (!allowRestart) + { + throw new InvalidDataException( + "The updater download server returned an invalid partial content range." + ); + } + + log?.Invoke("Updater download returned an invalid partial response; restarting."); + DeletePartialDownload(destinationPath, log); + return await DownloadInstallerPartAsync( + client, + identity, + destinationPath, + allowRestart: false, + log, + cancellationToken + ); + } + + if (appendToPartial && !IsResumeValidatorCompatible(metadata, response)) + { + if (!allowRestart) + { + throw new InvalidDataException( + "The updater download server returned partial content with a changed validator." + ); + } + + log?.Invoke("Updater download validator changed during resume; restarting."); + DeletePartialDownload(destinationPath, log); + return await DownloadInstallerPartAsync( + client, + identity, + destinationPath, + allowRestart: false, + log, + cancellationToken + ); + } + + if (!appendToPartial && partialLength > 0) + { + log?.Invoke("Updater download server ignored Range; restarting from byte 0."); + partialLength = 0; + } + + UpdaterInstallerDownloadMetadata newMetadata = CreateMetadata( + identity, + response, + appendToPartial + ); + WriteMetadata(metadataPath, newMetadata, log); + + FileMode fileMode = appendToPartial ? FileMode.Append : FileMode.Create; + await using (FileStream fileStream = new( + partialPath, + fileMode, + FileAccess.Write, + FileShare.Read, + CopyBufferSize, + useAsync: true + )) + await using (Stream downloadStream = await response.Content.ReadAsStreamAsync(cancellationToken)) + { + await downloadStream.CopyToAsync(fileStream, cancellationToken); + } + + long bytesInPartial = new FileInfo(partialPath).Length; + newMetadata.TotalLength ??= bytesInPartial; + newMetadata.UpdatedUtc = DateTime.UtcNow; + WriteMetadata(metadataPath, newMetadata, log); + + return new UpdaterDownloadResult( + partialPath, + bytesInPartial, + newMetadata.TotalLength, + response.StatusCode, + Resumed: appendToPartial + ); + } + + public static void PromotePartialDownload(string destinationPath, Action? log = null) + { + string partialPath = GetPartialPath(destinationPath); + if (!File.Exists(partialPath)) + { + throw new FileNotFoundException("The completed updater partial file was not found.", partialPath); + } + + File.Move(partialPath, destinationPath, overwrite: true); + DeleteFileIfExists(GetPartialMetadataPath(destinationPath), log); + } + + public static void DeletePartialDownload(string destinationPath, Action? log = null) + { + DeleteFileIfExists(GetPartialPath(destinationPath), log); + DeleteFileIfExists(GetPartialMetadataPath(destinationPath), log); + } + + public static bool IsFailureBackoffActive( + string statePath, + UpdaterDownloadIdentity identity, + DateTime utcNow, + out TimeSpan remaining, + out UpdaterDownloadFailureState? state, + Action? log = null + ) + { + remaining = TimeSpan.Zero; + state = TryReadFailureState(statePath, log); + if (state is null || !IsCompatible(state, identity)) + { + return false; + } + + remaining = state.NextAttemptUtc - utcNow; + if (remaining <= TimeSpan.Zero) + { + return false; + } + + if (remaining > MaxFailureBackoff + TimeSpan.FromHours(1)) + { + log?.Invoke( + $"Ignoring updater download backoff with unexpected future timestamp ({state.NextAttemptUtc:O})." + ); + return false; + } + + return true; + } + + public static void RecordFailure( + string statePath, + UpdaterDownloadIdentity identity, + string failureClass, + DateTime utcNow, + Action? log = null + ) + { + try + { + UpdaterDownloadFailureState? previous = TryReadFailureState(statePath, log); + int attemptCount = previous is not null + && IsCompatible(previous, identity) + && string.Equals( + previous.FailureClass, + failureClass, + StringComparison.OrdinalIgnoreCase + ) + ? previous.AttemptCount + 1 + : 1; + TimeSpan delay = CalculateFailureBackoff(identity, failureClass, attemptCount); + UpdaterDownloadFailureState state = new() + { + Version = identity.Version, + ExpectedHash = identity.ExpectedHash, + Url = identity.Url, + FailureClass = failureClass, + AttemptCount = attemptCount, + LastFailureUtc = utcNow, + NextAttemptUtc = utcNow + delay, + }; + + string? directory = Path.GetDirectoryName(statePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + WriteFailureState(statePath, state); + log?.Invoke( + string.Format( + CultureInfo.InvariantCulture, + "Recorded updater download failure '{0}' attempt {1}; next automatic attempt after {2:O}.", + failureClass, + attemptCount, + state.NextAttemptUtc + ) + ); + } + catch (Exception ex) + { + Logger.Warn($"Could not record updater download failure state: {ex.Message}"); + } + } + + public static void ClearFailureState(string statePath, Action? log = null) + { + DeleteFileIfExists(statePath, log); + } + + public static TimeSpan CalculateFailureBackoff( + UpdaterDownloadIdentity identity, + string failureClass, + int attemptCount + ) + { + if (attemptCount < 1) + { + attemptCount = 1; + } + + int exponent = Math.Min(attemptCount - 1, 10); + double multiplier = Math.Pow(2, exponent); + TimeSpan delay = TimeSpan.FromTicks((long)(BaseFailureBackoff.Ticks * multiplier)); + if (delay > MaxFailureBackoff) + { + delay = MaxFailureBackoff; + } + + double jitterRatio = GetStableJitterRatio(identity, failureClass, attemptCount); + TimeSpan jitter = TimeSpan.FromTicks((long)(delay.Ticks * jitterRatio)); + TimeSpan jitteredDelay = delay + jitter; + return jitteredDelay > MaxFailureBackoff ? MaxFailureBackoff : jitteredDelay; + } + + private static bool TryConfigureResume( + UpdaterInstallerDownloadMetadata? metadata, + long partialLength, + out RangeHeaderValue range, + out RangeConditionHeaderValue? ifRange + ) + { + range = new RangeHeaderValue(partialLength, null); + ifRange = null; + if (metadata is null) + { + return false; + } + + if ( + !string.IsNullOrWhiteSpace(metadata.ETag) + && EntityTagHeaderValue.TryParse(metadata.ETag, out EntityTagHeaderValue? entityTag) + ) + { + ifRange = new RangeConditionHeaderValue(entityTag); + return true; + } + + if (metadata.LastModified is not null) + { + ifRange = new RangeConditionHeaderValue(metadata.LastModified.Value); + return true; + } + + return false; + } + + private static bool IsValidPartialResponse(HttpResponseMessage response, long expectedStart) + { + ContentRangeHeaderValue? contentRange = response.Content.Headers.ContentRange; + return contentRange?.From == expectedStart; + } + + private static bool IsResumeValidatorCompatible( + UpdaterInstallerDownloadMetadata? metadata, + HttpResponseMessage response + ) + { + if (metadata is null) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(metadata.ETag) && response.Headers.ETag is not null) + { + return string.Equals( + metadata.ETag, + response.Headers.ETag.ToString(), + StringComparison.Ordinal + ); + } + + if (metadata.LastModified is not null && response.Content.Headers.LastModified is not null) + { + return metadata.LastModified.Value.Equals(response.Content.Headers.LastModified.Value); + } + + return true; + } + + private static UpdaterInstallerDownloadMetadata CreateMetadata( + UpdaterDownloadIdentity identity, + HttpResponseMessage response, + bool partialResponse + ) + { + long? totalLength = partialResponse + ? response.Content.Headers.ContentRange?.Length + : response.Content.Headers.ContentLength; + return new UpdaterInstallerDownloadMetadata + { + Version = identity.Version, + ExpectedHash = identity.ExpectedHash, + Url = identity.Url, + ETag = response.Headers.ETag?.ToString() ?? "", + LastModified = response.Content.Headers.LastModified, + TotalLength = totalLength, + UpdatedUtc = DateTime.UtcNow, + }; + } + + private static bool IsCompatible( + UpdaterInstallerDownloadMetadata? metadata, + UpdaterDownloadIdentity identity + ) + { + return metadata is not null + && string.Equals(metadata.Version, identity.Version, StringComparison.OrdinalIgnoreCase) + && string.Equals( + metadata.ExpectedHash, + identity.ExpectedHash, + StringComparison.OrdinalIgnoreCase + ) + && string.Equals(metadata.Url, identity.Url, StringComparison.Ordinal); + } + + private static bool IsCompatible( + UpdaterDownloadFailureState state, + UpdaterDownloadIdentity identity + ) + { + return string.Equals(state.Version, identity.Version, StringComparison.OrdinalIgnoreCase) + && string.Equals( + state.ExpectedHash, + identity.ExpectedHash, + StringComparison.OrdinalIgnoreCase + ) + && string.Equals(state.Url, identity.Url, StringComparison.Ordinal); + } + + private static UpdaterInstallerDownloadMetadata? TryReadMetadata( + string metadataPath, + Action? log + ) + { + if (!File.Exists(metadataPath)) + { + return null; + } + + try + { + return DeserializeJson( + File.ReadAllText(metadataPath), + UpdaterDownloadJsonContext.Default.UpdaterInstallerDownloadMetadata + ); + } + catch (Exception ex) + { + log?.Invoke($"Could not read updater partial metadata; restarting download: {ex.Message}"); + return null; + } + } + + private static UpdaterDownloadFailureState? TryReadFailureState( + string statePath, + Action? log + ) + { + if (!File.Exists(statePath)) + { + return null; + } + + try + { + return DeserializeJson( + File.ReadAllText(statePath), + UpdaterDownloadJsonContext.Default.UpdaterDownloadFailureState + ); + } + catch (Exception ex) + { + log?.Invoke($"Ignoring unreadable updater download failure state: {ex.Message}"); + return null; + } + } + + private static void WriteMetadata( + string metadataPath, + UpdaterInstallerDownloadMetadata metadata, + Action? log + ) + { + try + { + WriteJsonFile( + metadataPath, + SerializeJson( + metadata, + UpdaterDownloadJsonContext.Default.UpdaterInstallerDownloadMetadata + ) + ); + } + catch (Exception ex) + { + log?.Invoke($"Could not write updater partial metadata: {ex.Message}"); + throw; + } + } + + private static void WriteFailureState(string statePath, UpdaterDownloadFailureState state) + { + WriteJsonFile( + statePath, + SerializeJson( + state, + UpdaterDownloadJsonContext.Default.UpdaterDownloadFailureState + ) + ); + } + + private static T? DeserializeJson(string json, JsonTypeInfo typeInfo) + { + return JsonSerializer.Deserialize(json, typeInfo); + } + + private static string SerializeJson(T value, JsonTypeInfo typeInfo) + { + return JsonSerializer.Serialize(value, typeInfo); + } + + private static void WriteJsonFile(string path, string json) + { + string temporaryPath = path + ".tmp"; + File.WriteAllText(temporaryPath, json); + File.Move(temporaryPath, path, overwrite: true); + } + + private static void DeleteFileIfExists(string path, Action? log) + { + if (!File.Exists(path)) + { + return; + } + + try + { + File.Delete(path); + } + catch (Exception ex) + { + log?.Invoke($"Could not delete '{path}': {ex.Message}"); + } + } + + private static double GetStableJitterRatio( + UpdaterDownloadIdentity identity, + string failureClass, + int attemptCount + ) + { + string key = + $"{identity.Version}|{identity.ExpectedHash}|{identity.Url}|{failureClass}|{attemptCount}"; + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + ushort value = BinaryPrimitives.ReadUInt16LittleEndian(hash); + return value / (double)ushort.MaxValue * MaxJitterRatio; + } +} + +[JsonSourceGenerationOptions(AllowTrailingCommas = true, WriteIndented = true)] +[JsonSerializable(typeof(UpdaterInstallerDownloadMetadata))] +[JsonSerializable(typeof(UpdaterDownloadFailureState))] +internal sealed partial class UpdaterDownloadJsonContext : JsonSerializerContext; diff --git a/src/UniGetUI/AutoUpdater.Helpers.cs b/src/UniGetUI/AutoUpdater.Helpers.cs index f56106dfed..5ba5cfa242 100644 --- a/src/UniGetUI/AutoUpdater.Helpers.cs +++ b/src/UniGetUI/AutoUpdater.Helpers.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; @@ -9,6 +10,8 @@ namespace UniGetUI; public partial class AutoUpdater { + private const string INSTALLER_VALIDATION_FAILURE_MARKER_SUFFIX = ".validation-failed"; + private const string REGISTRY_PATH = @"Software\Devolutions\UniGetUI"; private const string DEFAULT_PRODUCTINFO_URL = "https://devolutions.net/productinfo.json"; private const string DEFAULT_PRODUCTINFO_KEY = "Devolutions.UniGetUI"; @@ -151,6 +154,33 @@ internal static string NormalizeThumbprint(string thumbprint) return new string(normalized); } + internal static string GetInstallerValidationFailureMarkerPath(string installerPath) + { + return installerPath + INSTALLER_VALIDATION_FAILURE_MARKER_SUFFIX; + } + + private static void ClearInstallerValidationFailure(string markerPath) + { + DeleteFileIfExists(markerPath, "installer validation failure marker"); + } + + private static void DeleteFileIfExists(string path, string description) + { + if (!File.Exists(path)) + { + return; + } + + try + { + File.Delete(path); + } + catch (Exception ex) + { + Logger.Warn($"Could not delete {description} at '{path}': {ex.Message}"); + } + } + private static UpdaterOverrides LoadUpdaterOverrides() { using RegistryKey? key = Registry.LocalMachine.OpenSubKey(REGISTRY_PATH); diff --git a/src/UniGetUI/AutoUpdater.cs b/src/UniGetUI/AutoUpdater.cs index 70a02e69e8..84c62427df 100644 --- a/src/UniGetUI/AutoUpdater.cs +++ b/src/UniGetUI/AutoUpdater.cs @@ -296,14 +296,14 @@ public static async Task UpdateCheckLoop(Window window, InfoBar banner) Logger.Warn("User has disabled updates"); return; } - bool updateSucceeded = await CheckAndInstallUpdates( + await CheckAndInstallUpdates( window, banner, false, IsFirstLaunch ); IsFirstLaunch = false; - await Task.Delay(TimeSpan.FromMinutes(updateSucceeded ? 60 : 10)); + await Task.Delay(UpdaterDownloadEngine.DefaultAutomaticUpdateCheckInterval); } } @@ -351,6 +351,15 @@ public static async Task CheckAndInstallUpdates( CoreData.UniGetUIDataDirectory, "UniGetUI Updater.exe" ); + UpdaterDownloadIdentity installerIdentity = new( + updateCandidate.VersionName, + updateCandidate.InstallerHash, + updateCandidate.InstallerDownloadUrl + ); + string installerValidationFailureMarkerPath = + GetInstallerValidationFailureMarkerPath(InstallerPath); + string installerDownloadFailureStatePath = + UpdaterDownloadEngine.GetFailureStatePath(InstallerPath); if ( File.Exists(InstallerPath) @@ -362,6 +371,11 @@ public static async Task CheckAndInstallUpdates( && CheckInstallerSignerThumbprint(InstallerPath, updaterOverrides) ) { + ClearInstallerValidationFailure(installerValidationFailureMarkerPath); + UpdaterDownloadEngine.ClearFailureState( + installerDownloadFailureStatePath, + message => LogUpdateWarn(message) + ); LogUpdateInfo($"A cached valid installer was found, launching update process..."); return await PrepairToLaunchInstaller( InstallerPath, @@ -371,7 +385,31 @@ public static async Task CheckAndInstallUpdates( ); } - File.Delete(InstallerPath); + if (File.Exists(InstallerPath)) + { + LogUpdateWarn( + "Cached installer is invalid; it will be kept until a replacement is validated." + ); + } + + if ( + !ManualCheck + && UpdaterDownloadEngine.IsFailureBackoffActive( + installerDownloadFailureStatePath, + installerIdentity, + DateTime.UtcNow, + out TimeSpan remainingBackoff, + out UpdaterDownloadFailureState? failureState, + message => LogUpdateWarn(message) + ) + ) + { + LogUpdateWarn( + $"Skipping installer download for version {updateCandidate.VersionName}; previous {failureState?.FailureClass ?? "download"} failure is backed off for {remainingBackoff:g}." + ); + MarkAttemptFinished("installer download skipped by failure backoff"); + return true; + } ShowMessage_ThreadSafe( CoreTools.Translate( @@ -384,20 +422,46 @@ public static async Task CheckAndInstallUpdates( ); // Download the installer - await DownloadInstaller( - updateCandidate.InstallerDownloadUrl, - InstallerPath, - updaterOverrides - ); + string downloadedInstallerPath; + try + { + downloadedInstallerPath = await DownloadInstaller( + updateCandidate.InstallerDownloadUrl, + InstallerPath, + updaterOverrides, + installerIdentity + ); + } + catch (Exception ex) + { + UpdaterDownloadEngine.RecordFailure( + installerDownloadFailureStatePath, + installerIdentity, + "download", + DateTime.UtcNow, + message => LogUpdateWarn(message) + ); + LogUpdateWarn($"Installer download failed: {ex.Message}"); + throw; + } if ( await CheckInstallerHash( - InstallerPath, + downloadedInstallerPath, updateCandidate.InstallerHash, updaterOverrides - ) && CheckInstallerSignerThumbprint(InstallerPath, updaterOverrides) + ) && CheckInstallerSignerThumbprint(downloadedInstallerPath, updaterOverrides) ) { + UpdaterDownloadEngine.PromotePartialDownload( + InstallerPath, + message => LogUpdateWarn(message) + ); + ClearInstallerValidationFailure(installerValidationFailureMarkerPath); + UpdaterDownloadEngine.ClearFailureState( + installerDownloadFailureStatePath, + message => LogUpdateWarn(message) + ); LogUpdateInfo("The downloaded installer is valid, launching update process..."); return await PrepairToLaunchInstaller( InstallerPath, @@ -407,6 +471,18 @@ await CheckInstallerHash( ); } + UpdaterDownloadEngine.RecordFailure( + installerDownloadFailureStatePath, + installerIdentity, + "validation", + DateTime.UtcNow, + message => LogUpdateWarn(message) + ); + UpdaterDownloadEngine.DeletePartialDownload( + InstallerPath, + message => LogUpdateWarn(message) + ); + ShowMessage_ThreadSafe( CoreTools.Translate("The installer authenticity could not be verified."), CoreTools.Translate("The update process has been aborted."), @@ -415,7 +491,7 @@ await CheckInstallerHash( CreateViewLogButton() ); MarkAttemptFinished("authenticity verification failed"); - return false; + return !ManualCheck; } if (Verbose) @@ -628,10 +704,11 @@ UpdaterOverrides updaterOverrides /// /// Downloads the given installer to the given location /// - private static async Task DownloadInstaller( + private static async Task DownloadInstaller( string downloadUrl, string installerLocation, - UpdaterOverrides updaterOverrides + UpdaterOverrides updaterOverrides, + UpdaterDownloadIdentity identity ) { if (!IsSourceUrlAllowed(downloadUrl, updaterOverrides.AllowUnsafeUrls)) @@ -644,12 +721,17 @@ UpdaterOverrides updaterOverrides { client.Timeout = TimeSpan.FromSeconds(600); client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); - HttpResponseMessage result = await client.GetAsync(downloadUrl); - result.EnsureSuccessStatusCode(); - using FileStream fs = new(installerLocation, FileMode.OpenOrCreate); - await result.Content.CopyToAsync(fs); + using CancellationTokenSource downloadTimeout = new(TimeSpan.FromSeconds(600)); + UpdaterDownloadResult result = await UpdaterDownloadEngine.DownloadInstallerPartAsync( + client, + identity, + installerLocation, + message => LogUpdateDebug(message), + downloadTimeout.Token + ); + LogUpdateDebug("The download has finished successfully"); + return result.PartialPath; } - LogUpdateDebug("The download has finished successfully"); } ///