diff --git a/src/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs b/src/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs index 78a24482..67cc0b49 100644 --- a/src/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs +++ b/src/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs @@ -146,12 +146,12 @@ public async Task ExecuteAsync( using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); var (downloaded, elapsed) = await StreamDownloadAsync(stream, fs, totalBytes, existingBytes, - destPath, progress, sw, token).ConfigureAwait(false); + destPath, progress, sw, token, asset.VersionContext).ConfigureAwait(false); progress?.Report(new DownloadProgress( Path.GetFileName(destPath), downloaded, totalBytes > 0 ? totalBytes + existingBytes : null, - 100, DownloadStatus.Completed)); + 100, DownloadStatus.Completed, asset.VersionContext)); return new DownloadResult(asset, destPath, downloaded, elapsed, retries, true, null); } @@ -189,7 +189,8 @@ public async Task ExecuteAsync( /// internal static async Task<(long Downloaded, TimeSpan Elapsed)> StreamDownloadAsync( Stream source, Stream dest, long totalBytes, long existingBytes, - string destPath, IProgress? progress, Stopwatch sw, CancellationToken token) + string destPath, IProgress? progress, Stopwatch sw, CancellationToken token, + object? versionContext = null) { var buffer = new byte[8192]; long downloaded = existingBytes; @@ -209,7 +210,7 @@ public async Task ExecuteAsync( progress?.Report(new DownloadProgress( Path.GetFileName(destPath), downloaded, totalBytes > 0 ? totalBytes + existingBytes : null, - pct, DownloadStatus.Downloading)); + pct, DownloadStatus.Downloading, versionContext)); } } diff --git a/src/GeneralUpdate.Core/Download/Executors/OssDownloadExecutor.cs b/src/GeneralUpdate.Core/Download/Executors/OssDownloadExecutor.cs index 4e16957b..39b3333f 100644 --- a/src/GeneralUpdate.Core/Download/Executors/OssDownloadExecutor.cs +++ b/src/GeneralUpdate.Core/Download/Executors/OssDownloadExecutor.cs @@ -88,10 +88,11 @@ public async Task ExecuteAsync( using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); var (downloaded, elapsed) = await HttpDownloadExecutor.StreamDownloadAsync( - stream, fs, total, 0, destPath, progress, sw, token).ConfigureAwait(false); + stream, fs, total, 0, destPath, progress, sw, token, asset.VersionContext).ConfigureAwait(false); progress?.Report(new DownloadProgress( - Path.GetFileName(destPath), downloaded, total > 0 ? total : null, 100, DownloadStatus.Completed)); + Path.GetFileName(destPath), downloaded, total > 0 ? total : null, 100, + DownloadStatus.Completed, asset.VersionContext)); return new DownloadResult(asset, destPath, downloaded, elapsed, 0, true, null); } catch (Exception ex) when (ex is not OperationCanceledException) diff --git a/src/GeneralUpdate.Core/Download/Models/DownloadAsset.cs b/src/GeneralUpdate.Core/Download/Models/DownloadAsset.cs index 81f62170..d8460210 100644 --- a/src/GeneralUpdate.Core/Download/Models/DownloadAsset.cs +++ b/src/GeneralUpdate.Core/Download/Models/DownloadAsset.cs @@ -21,7 +21,8 @@ public record DownloadAsset( int RecordId = 0, int? AppType = null, string? AuthScheme = null, - string? AuthToken = null + string? AuthToken = null, + object? VersionContext = null ) { public string? UpdateLog { get; init; } diff --git a/src/GeneralUpdate.Core/Download/Models/DownloadProgress.cs b/src/GeneralUpdate.Core/Download/Models/DownloadProgress.cs index 26981728..1e15f8b0 100644 --- a/src/GeneralUpdate.Core/Download/Models/DownloadProgress.cs +++ b/src/GeneralUpdate.Core/Download/Models/DownloadProgress.cs @@ -11,7 +11,8 @@ public record DownloadProgress( long BytesDownloaded, long? TotalBytes, double Percentage, - DownloadStatus Status + DownloadStatus Status, + object? VersionContext = null ); public record DownloadResult( diff --git a/src/GeneralUpdate.Core/Download/Progress/DownloadProgressReporter.cs b/src/GeneralUpdate.Core/Download/Progress/DownloadProgressReporter.cs index 20245b98..c8d8a259 100644 --- a/src/GeneralUpdate.Core/Download/Progress/DownloadProgressReporter.cs +++ b/src/GeneralUpdate.Core/Download/Progress/DownloadProgressReporter.cs @@ -18,6 +18,9 @@ namespace GeneralUpdate.Core.Download.Progress; /// /// ProgressEventArgsFired on every progress report, /// containing the download percentage, bytes downloaded, and other information. +/// MultiDownloadStatisticsEventArgsFired on every progress report +/// with the asset name, byte counts and percentage. Unknown totals use zero; speed and remaining +/// time are unavailable in DownloadProgress and use an empty string and TimeSpan.Zero. /// MultiDownloadCompletedEventArgsFired when the download status is Completed. /// MultiDownloadErrorEventArgsFired when the download status is Failed. /// MultiAllDownloadCompletedEventArgsFired via the static DispatchAllCompleted method when all download tasks are finished. @@ -57,6 +60,11 @@ public void Report(Models.DownloadProgress value) // Fire progress event via EventManager EventManager.Instance.Dispatch(this, new ProgressEventArgs(value)); + var version = value.VersionContext ?? value.AssetName ?? "unknown"; + EventManager.Instance.Dispatch(this, + new MultiDownloadStatisticsEventArgs(version, + TimeSpan.Zero, string.Empty, value.TotalBytes ?? 0, + value.BytesDownloaded, value.Percentage)); if (value.Status == Models.DownloadStatus.Completed) { diff --git a/src/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs b/src/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs index ce7648de..e0510c85 100644 --- a/src/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs +++ b/src/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs @@ -182,7 +182,8 @@ private static DownloadAsset MapVersionEntry(VersionEntry v) RecordId: v.RecordId, AppType: v.AppType, AuthScheme: v.AuthScheme, - AuthToken: v.AuthToken + AuthToken: v.AuthToken, + VersionContext: v ) { UpdateLog = v.UpdateLog diff --git a/src/GeneralUpdate.Core/Strategy/ClientStrategy.cs b/src/GeneralUpdate.Core/Strategy/ClientStrategy.cs index 610269d2..7d3c87ea 100644 --- a/src/GeneralUpdate.Core/Strategy/ClientStrategy.cs +++ b/src/GeneralUpdate.Core/Strategy/ClientStrategy.cs @@ -573,9 +573,10 @@ private async Task ExecuteStandardWorkflowAsync() async Task ExecuteDownloadAsync(Download.Models.DownloadPlan plan) { + var progress = Download.Progress.DownloadProgressReporter.CreateEventBridge(); if (_orchestrator != null) { - return await _orchestrator.ExecuteAsync(plan, _configInfo.TempPath).ConfigureAwait(false); + return await _orchestrator.ExecuteAsync(plan, _configInfo.TempPath, progress: progress).ConfigureAwait(false); } else { @@ -583,7 +584,7 @@ private async Task ExecuteStandardWorkflowAsync() var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator( httpClient, orchOptions, _customDownloadPolicy, _customDownloadExecutor, _customDownloadPipelineFactory); - return await orchestrator.ExecuteAsync(plan, _configInfo.TempPath).ConfigureAwait(false); + return await orchestrator.ExecuteAsync(plan, _configInfo.TempPath, progress: progress).ConfigureAwait(false); } } diff --git a/src/GeneralUpdate.Core/Strategy/OssStrategy.cs b/src/GeneralUpdate.Core/Strategy/OssStrategy.cs index cde6a1de..04ef6306 100644 --- a/src/GeneralUpdate.Core/Strategy/OssStrategy.cs +++ b/src/GeneralUpdate.Core/Strategy/OssStrategy.cs @@ -520,9 +520,10 @@ private static bool IsOssUpgrade(string clientVersion, string serverVersion) private async Task DownloadAssetsAsync(List assets, string targetPath) { var plan = new DownloadPlan(assets, false); + var progress = Download.Progress.DownloadProgressReporter.CreateEventBridge(); if (DownloadOrchestrator != null) { - await DownloadOrchestrator.ExecuteAsync(plan, targetPath).ConfigureAwait(false); + await DownloadOrchestrator.ExecuteAsync(plan, targetPath, progress: progress).ConfigureAwait(false); } else { @@ -533,7 +534,7 @@ private async Task DownloadAssetsAsync(List assets, string target }; var orchestrator = new DefaultDownloadOrchestrator( Network.HttpClientProvider.Shared, options); - await orchestrator.ExecuteAsync(plan, targetPath).ConfigureAwait(false); + await orchestrator.ExecuteAsync(plan, targetPath, progress: progress).ConfigureAwait(false); } } diff --git a/tests/CoreTest/Download/DownloadProgressReporterTests.cs b/tests/CoreTest/Download/DownloadProgressReporterTests.cs index 0c44cda8..df86dc77 100644 --- a/tests/CoreTest/Download/DownloadProgressReporterTests.cs +++ b/tests/CoreTest/Download/DownloadProgressReporterTests.cs @@ -15,6 +15,47 @@ public void Dispose() #region Progress callback + [Theory] + [InlineData(1000L, 50.0, DownloadStatus.Downloading)] + [InlineData(null, -1.0, DownloadStatus.Downloading)] + [InlineData(500L, 100.0, DownloadStatus.Completed)] + public void Report_DispatchesStatisticsAlongsideProgress(long? totalBytes, double percentage, DownloadStatus status) + { + MultiDownloadStatisticsEventArgs? statistics = null; + ProgressEventArgs? progressArgs = null; + var completedCount = 0; + new GeneralUpdate.Core.GeneralUpdateBootstrap() + .AddListenerMultiDownloadStatistics((_, args) => statistics = args) + .AddListenerProgress((_, args) => progressArgs = args) + .AddListenerMultiDownloadCompleted((_, _) => completedCount++); + var progress = new DownloadProgress("asset.zip", 500, totalBytes, percentage, status); + + DownloadProgressReporter.CreateEventBridge().Report(progress); + + Assert.NotNull(statistics); + Assert.Equal("asset.zip", statistics.Version); + Assert.Equal(totalBytes ?? 0, statistics.TotalBytesToReceive); + Assert.Equal(500, statistics.BytesReceived); + Assert.Equal(percentage, statistics.ProgressPercentage); + Assert.Same(progress, progressArgs?.Progress); + Assert.Equal(status == DownloadStatus.Completed ? 1 : 0, completedCount); + } + + [Fact] + public void Report_StatisticsUsesVersionContextWhenProvided() + { + MultiDownloadStatisticsEventArgs? statistics = null; + new GeneralUpdate.Core.GeneralUpdateBootstrap() + .AddListenerMultiDownloadStatistics((_, args) => statistics = args); + var version = new GeneralUpdate.Core.Configuration.VersionEntry { Version = "1.2.3" }; + var progress = new DownloadProgress("asset.zip", 500, 1000, 50.0, DownloadStatus.Downloading, version); + + DownloadProgressReporter.CreateEventBridge().Report(progress); + + Assert.NotNull(statistics); + Assert.Same(version, statistics!.Version); + } + [Fact] public void Report_InvokesOnProgressCallback() { diff --git a/tests/CoreTest/Strategy/DownloadEventTests.cs b/tests/CoreTest/Strategy/DownloadEventTests.cs new file mode 100644 index 00000000..10b3a711 --- /dev/null +++ b/tests/CoreTest/Strategy/DownloadEventTests.cs @@ -0,0 +1,118 @@ +using System.Collections.Concurrent; +using System.Reflection; +using GeneralUpdate.Core; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download; +using GeneralUpdate.Core.Download.Abstractions; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Download.Orchestrators; +using GeneralUpdate.Core.Event; +using GeneralUpdate.Core.FileSystem; +using GeneralUpdate.Core.Strategy; +using Moq; + +namespace CoreTest.Strategy; + +[Collection("NonParallel_EventManager")] +public class DownloadEventTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), $"GU_DownloadEvents_{Guid.NewGuid():N}"); + private readonly ConcurrentQueue _statistics = new(); + private readonly ConcurrentQueue _completed = new(); + private readonly ConcurrentQueue _allCompleted = new(); + private readonly IBlackMatcher? _blackMatcher = StorageManager.BlackMatcher; + + public DownloadEventTests() + { + Directory.CreateDirectory(_directory); + new GeneralUpdateBootstrap() + .AddListenerMultiDownloadStatistics((_, args) => _statistics.Enqueue(args)) + .AddListenerMultiDownloadCompleted((_, args) => _completed.Enqueue(args)) + .AddListenerMultiAllDownloadCompleted((_, args) => _allCompleted.Enqueue(args)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ClientDownload_DefaultAndInjectedOrchestrators_NotifyListeners(bool injectOrchestrator) + { + var asset = new DownloadAsset("update.zip", "https://example.com/update.zip", + 1000, null, "2.0.0", AppType: (int)AppType.Upgrade); + var source = new Mock(); + source.Setup(s => s.ListAsync(It.IsAny())) + .ReturnsAsync(new DownloadSourceResult { Assets = new[] { asset }, HasUpgradeUpdate = true }); + var executor = CreateExecutor(); + using var httpClient = new HttpClient(); + var strategy = new ClientStrategy { DownloadSource = source.Object }; + strategy.SetOsStrategy(Mock.Of()); + if (injectOrchestrator) + strategy.SetOrchestrator(new DefaultDownloadOrchestrator(httpClient, + new DownloadOrchestratorOptions { VerifyChecksum = false }, executor: executor.Object)); + else + strategy.SetDownloadExecutor(executor.Object); + var context = new UpdateContext + { + UpdateUrl = "https://example.com/update", + ClientVersion = "1.0.0", + UpgradeClientVersion = "1.0.0", + MainAppName = "MainApp", + InstallPath = _directory, + BackupEnabled = false, + VerifyChecksum = false + }; + strategy.Create(context); + + await strategy.ExecuteAsync(); + + AssertDownloadEvents(); + } + + [Fact] + public async Task OssDownload_InjectedOrchestrator_NotifiesListeners() + { + var asset = new DownloadAsset("update.zip", "https://example.com/update.zip", 1000, null, "2.0.0"); + using var httpClient = new HttpClient(); + var strategy = new OssStrategy(AppType.OssUpgrade) + { + DownloadOrchestrator = new DefaultDownloadOrchestrator(httpClient, + new DownloadOrchestratorOptions { VerifyChecksum = false }, executor: CreateExecutor().Object) + }; + // Exercise the download phase without launching applications or exiting the test process. + var download = typeof(OssStrategy).GetMethod("DownloadAssetsAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + + await (Task)download.Invoke(strategy, new object[] { new List { asset }, _directory })!; + + AssertDownloadEvents(); + } + + private static Mock CreateExecutor() + { + var executor = new Mock(); + executor.Setup(e => e.ExecuteAsync(It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .Returns((DownloadAsset asset, string path, IProgress? progress, CancellationToken _) => + { + progress?.Report(new DownloadProgress(asset.Name, 500, 1000, 50, DownloadStatus.Downloading)); + progress?.Report(new DownloadProgress(asset.Name, 1000, 1000, 100, DownloadStatus.Completed)); + return Task.FromResult(new DownloadResult(asset, path, 1000, TimeSpan.Zero, 0, true, null)); + }); + return executor; + } + + private void AssertDownloadEvents() + { + var completed = Assert.Single(_completed); + Assert.True(completed.IsCompleted); + Assert.Equal("update.zip", completed.Version); + Assert.Contains(_statistics, s => s.BytesReceived == 500 && s.ProgressPercentage == 50); + Assert.Contains(_statistics, s => s.BytesReceived == 1000 && s.ProgressPercentage == 100); + Assert.True(Assert.Single(_allCompleted).IsAllDownloadCompleted); + } + + public void Dispose() + { + EventManager.Instance.Clear(); + StorageManager.BlackMatcher = _blackMatcher; + Directory.Delete(_directory, true); + } +}