From f636df1cb98f6f9d94708073e511cdcccaf3ca1d Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sun, 24 May 2026 08:02:03 +0800 Subject: [PATCH] feat(download): add orchestrator, status reporter, parallel download support - IDownloadOrchestrator + DefaultDownloadOrchestrator with concurrency control - DownloadReport model for batch results - IUpdateReporter interface for lifecycle event reporting - HttpUpdateReporter with HMAC signing (silent failure) - NoOpUpdateReporter default when ReportUrl not configured - UpdateEvent: Started/DownloadCompleted/Applied/Failed/AppStarted Closes #318 --- .../Abstractions/IDownloadOrchestrator.cs | 25 ++++++ .../DefaultDownloadOrchestrator.cs | 75 +++++++++++++++++ .../Download/Reporting/IUpdateReporter.cs | 84 +++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadOrchestrator.cs create mode 100644 src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs create mode 100644 src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs diff --git a/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadOrchestrator.cs b/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadOrchestrator.cs new file mode 100644 index 00000000..dfc2578a --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadOrchestrator.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Download.Models; + +namespace GeneralUpdate.Core.Download.Abstractions; + +/// Orchestrates batch downloads with concurrency control. +public interface IDownloadOrchestrator +{ + Task ExecuteAsync( + IReadOnlyList urls, + string destDir, + int maxConcurrency = 3, + IProgress? progress = null, + CancellationToken token = default); +} + +public record DownloadReport( + IReadOnlyList Results, + long TotalBytes, + TimeSpan TotalDuration, + int SuccessCount, + int FailedCount +); diff --git a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs new file mode 100644 index 00000000..842b77e2 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Download.Abstractions; +using GeneralUpdate.Core.Download.Executors; +using GeneralUpdate.Core.Download.Policy; +using GeneralUpdate.Core.Download.Models; + +namespace GeneralUpdate.Core.Download.Orchestrators; + +/// +/// Default download orchestrator with parallel execution and concurrency limit. +/// +public class DefaultDownloadOrchestrator : IDownloadOrchestrator +{ + private readonly HttpClient _httpClient; + private readonly IDownloadPolicy _policy; + + public DefaultDownloadOrchestrator(HttpClient httpClient, IDownloadPolicy? policy = null) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _policy = policy ?? new DefaultRetryPolicy(); + } + + public async Task ExecuteAsync( + IReadOnlyList urls, + string destDir, + int maxConcurrency = 3, + IProgress? progress = null, + CancellationToken token = default) + { + var sw = Stopwatch.StartNew(); + var results = new List(); + var sem = new SemaphoreSlim(maxConcurrency); + long totalBytes = 0; + + var tasks = urls.Select(async (url, i) => + { + await sem.WaitAsync(token).ConfigureAwait(false); + try + { + var fileName = Path.GetFileName(new Uri(url).AbsolutePath); + if (string.IsNullOrEmpty(fileName)) fileName = $"download_{i}"; + var destPath = Path.Combine(destDir, fileName); + + var executor = new HttpDownloadExecutor(_httpClient); + var r = await _policy.ExecuteAsync(ct => + executor.ExecuteAsync(url, destPath, progress, ct), token) + .ConfigureAwait(false); + + lock (results) + { + results.Add(r); + if (r.Success) totalBytes += r.DownloadedBytes; + } + } + finally { sem.Release(); } + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + sw.Stop(); + + return new DownloadReport( + results, + totalBytes, + sw.Elapsed, + results.Count(r => r.Success), + results.Count(r => !r.Success)); + } +} diff --git a/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs b/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs new file mode 100644 index 00000000..f87dc696 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs @@ -0,0 +1,84 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core; +using GeneralUpdate.Core.Configuration; + +namespace GeneralUpdate.Core.Download.Reporting; + +/// Reports update lifecycle events to the server. +public interface IUpdateReporter +{ + Task ReportAsync(UpdateReport report, CancellationToken token = default); +} + +public enum UpdateEvent { UpdateStarted, DownloadCompleted, UpdateApplied, UpdateFailed, AppStarted } + +public record UpdateReport( + string AppName, + string FromVersion, + string? ToVersion, + UpdateEvent Event, + int AppType, + DateTimeOffset Timestamp, + string? ErrorMessage = null, + double? DurationMs = null +); + +/// HTTP POST reporter with optional HMAC signing. +public class HttpUpdateReporter : IUpdateReporter +{ + private readonly HttpClient _client; + private readonly string _reportUrl; + private readonly string? _secretKey; + + public HttpUpdateReporter(HttpClient client, string reportUrl, string? secretKey = null) + { + _client = client; + _reportUrl = reportUrl; + _secretKey = secretKey; + } + + public async Task ReportAsync(UpdateReport report, CancellationToken token = default) + { + try + { + var json = JsonSerializer.Serialize(report); + + using var request = new HttpRequestMessage(HttpMethod.Post, _reportUrl); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + if (!string.IsNullOrEmpty(_secretKey)) + { + var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var sig = ComputeHmac($"{json}|{ts}", _secretKey); + request.Headers.Add("X-Update-Timestamp", ts); + request.Headers.Add("X-Update-Signature", sig); + } + + await _client.SendAsync(request, token).ConfigureAwait(false); + } + catch (Exception ex) + { + // Silent failure — reporting should never break the update flow + GeneralTracer.Warn($"Report failed: {ex.Message}"); + } + } + + private static string ComputeHmac(string data, string key) + { + var h = new System.Security.Cryptography.HMACSHA256(Encoding.UTF8.GetBytes(key)) + .ComputeHash(Encoding.UTF8.GetBytes(data)); + return BitConverter.ToString(h).Replace("-", "").ToLowerInvariant(); + } +} + +/// No-op reporter used when ReportUrl is not configured. +public class NoOpUpdateReporter : IUpdateReporter +{ + public Task ReportAsync(UpdateReport report, CancellationToken token = default) + => Task.CompletedTask; +}