diff --git a/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadOrchestrator.cs b/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadOrchestrator.cs index 0d674c85..8e539e0c 100644 --- a/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadOrchestrator.cs +++ b/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadOrchestrator.cs @@ -9,8 +9,12 @@ namespace GeneralUpdate.Core.Download.Abstractions; /// Orchestrates batch downloads with concurrency control. public interface IDownloadOrchestrator { + /// + /// Execute downloads for all assets in the plan. + /// Handles parallelism, retry, and SHA256 verification. + /// Task ExecuteAsync( - IReadOnlyList urls, + DownloadPlan plan, string destDir, int maxConcurrency = 3, IProgress? progress = null, diff --git a/src/c#/GeneralUpdate.Core/Download/DownloadManager.cs b/src/c#/GeneralUpdate.Core/Download/DownloadManager.cs index 5b9b51da..09129fe1 100644 --- a/src/c#/GeneralUpdate.Core/Download/DownloadManager.cs +++ b/src/c#/GeneralUpdate.Core/Download/DownloadManager.cs @@ -7,6 +7,7 @@ namespace GeneralUpdate.Core.Download { + [Obsolete("Use IDownloadOrchestrator + DefaultDownloadOrchestrator instead. Will be removed in v11.")] public class DownloadManager(string path, string format, int timeOut) { #region Private Members diff --git a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs new file mode 100644 index 00000000..42437231 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GeneralUpdate.Core.Download.Models; + +namespace GeneralUpdate.Core.Download; + +/// +/// Builds a DownloadPlan from download assets. +/// Handles cross-version package selection, version chain building, +/// frozen package filtering, and forced update marking. +/// +public static class DownloadPlanBuilder +{ + /// + /// Build a download plan from a list of download assets. + /// + /// Assets from the download source. + /// Current client version string. + /// A DownloadPlan with ordered assets, or DownloadPlan.Empty if no update is needed. + public static DownloadPlan Build(IEnumerable assets, string currentVersion) + { + if (assets == null) return DownloadPlan.Empty; + + // 1. Filter out frozen packages + var active = assets + .Where(a => !a.IsFreeze) + .ToList(); + + if (active.Count == 0) return DownloadPlan.Empty; + + // 2. Check for forced update + var isForcibly = active.Any(a => a.IsForcibly); + + // 3. Look for a cross-version package that matches our current version + var crossVersion = active + .Where(a => a.IsCrossVersion + && !string.IsNullOrEmpty(a.FromVersion) + && VersionEquals(a.FromVersion!, currentVersion)) + .OrderByDescending(a => ParseVersion(a.Version)) + .FirstOrDefault(); + + if (crossVersion != null) + { + // Single download — jump directly to target version + return new DownloadPlan(new[] { crossVersion }, isForcibly); + } + + // 4. Build version chain from non-cross-version packages + var chain = BuildVersionChain(active.Where(a => !a.IsCrossVersion), currentVersion); + if (chain.Count == 0) return DownloadPlan.Empty; + + return new DownloadPlan(chain, isForcibly); + } + + /// + /// Build a version chain: keep versions higher than current, + /// check MinClientVersion compatibility. + /// + private static List BuildVersionChain(IEnumerable assets, string currentVersion) + { + var current = ParseVersion(currentVersion); + + return assets + .Where(a => + { + var pv = ParseVersion(a.Version); + if (pv == null) return false; + return pv > current; + }) + .Where(a => IsCompatible(a.MinClientVersion, currentVersion)) + .OrderBy(a => ParseVersion(a.Version)) + .ToList(); + } + + /// + /// Check if MinClientVersion is compatible with the current version. + /// A package with MinClientVersion higher than current is not applicable. + /// + private static bool IsCompatible(string? minClientVersion, string currentVersion) + { + if (string.IsNullOrEmpty(minClientVersion)) return true; + var min = ParseVersion(minClientVersion); + var cur = ParseVersion(currentVersion); + if (min == null || cur == null) return true; + return cur >= min; + } + + /// Parse a version string, returning null on failure. + private static Version? ParseVersion(string? version) + { + if (string.IsNullOrWhiteSpace(version)) return null; + return Version.TryParse(version, out var v) ? v : null; + } + + /// Compare two version strings for equality. + private static bool VersionEquals(string a, string b) + { + var va = ParseVersion(a); + var vb = ParseVersion(b); + return va != null && vb != null && va == vb; + } +} diff --git a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs index 842b77e2..d398109e 100644 --- a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs +++ b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs @@ -10,11 +10,13 @@ using GeneralUpdate.Core.Download.Executors; using GeneralUpdate.Core.Download.Policy; using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Download.Pipeline; namespace GeneralUpdate.Core.Download.Orchestrators; /// -/// Default download orchestrator with parallel execution and concurrency limit. +/// Default download orchestrator with parallel execution, concurrency limit, +/// SHA256 verification, and progress reporting. /// public class DefaultDownloadOrchestrator : IDownloadOrchestrator { @@ -27,36 +29,63 @@ public DefaultDownloadOrchestrator(HttpClient httpClient, IDownloadPolicy? polic _policy = policy ?? new DefaultRetryPolicy(); } + /// Execute downloads for all assets in the plan. public async Task ExecuteAsync( - IReadOnlyList urls, + DownloadPlan plan, string destDir, int maxConcurrency = 3, IProgress? progress = null, CancellationToken token = default) { + if (plan == null || !plan.HasAssets) + return new DownloadReport(Array.Empty(), 0, TimeSpan.Zero, 0, 0); + var sw = Stopwatch.StartNew(); var results = new List(); var sem = new SemaphoreSlim(maxConcurrency); long totalBytes = 0; - var tasks = urls.Select(async (url, i) => + var tasks = plan.Assets.Select(async asset => { await sem.WaitAsync(token).ConfigureAwait(false); try { - var fileName = Path.GetFileName(new Uri(url).AbsolutePath); - if (string.IsNullOrEmpty(fileName)) fileName = $"download_{i}"; + var fileName = GetFileName(asset); 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); + var pipeline = new DefaultDownloadPipeline(asset.SHA256); + + var result = await _policy.ExecuteAsync(async ct => + { + // Download + var downloadResult = await executor.ExecuteAsync( + asset.Url, destPath, + progress != null ? new AssetProgressReporter(progress, asset.Name) : null, + ct).ConfigureAwait(false); + + if (!downloadResult.Success) + return downloadResult; + + // Verify (SHA256) + try + { + await pipeline.ProcessAsync(destPath, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + return new DownloadResult(asset.Url, destPath, + downloadResult.DownloadedBytes, downloadResult.Duration, + downloadResult.RetryCount, false, $"SHA256 verification failed: {ex.Message}"); + } + + return downloadResult; + }, token).ConfigureAwait(false); lock (results) { - results.Add(r); - if (r.Success) totalBytes += r.DownloadedBytes; + results.Add(result); + if (result.Success) totalBytes += result.DownloadedBytes; } } finally { sem.Release(); } @@ -72,4 +101,28 @@ public async Task ExecuteAsync( results.Count(r => r.Success), results.Count(r => !r.Success)); } + + private static string GetFileName(DownloadAsset asset) + { + try + { + var name = Path.GetFileName(new Uri(asset.Url).AbsolutePath); + if (!string.IsNullOrEmpty(name)) return name; + } + catch { } + return $"{asset.Name}.{asset.Version}"; + } + + /// Wraps progress reporting to include the asset name. + private sealed class AssetProgressReporter : IProgress + { + private readonly IProgress _inner; + private readonly string _assetName; + public AssetProgressReporter(IProgress inner, string assetName) + { _inner = inner; _assetName = assetName; } + public void Report(DownloadProgress value) + { + _inner.Report(value with { AssetName = _assetName }); + } + } } diff --git a/src/c#/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs b/src/c#/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs new file mode 100644 index 00000000..b9a9c9fa --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Network; + +namespace GeneralUpdate.Core.Download.Sources; + +/// +/// HTTP download source — calls the version validation API +/// and converts the server response to a list of DownloadAssets. +/// +public class HttpDownloadSource : Abstractions.IDownloadSource +{ + private readonly string _updateUrl; + private readonly string _clientVersion; + private readonly string? _upgradeClientVersion; + private readonly string _appSecretKey; + private readonly int _platform; + private readonly string? _productId; + private readonly string? _scheme; + private readonly string? _token; + + public HttpDownloadSource( + string updateUrl, + string clientVersion, + string? upgradeClientVersion, + string appSecretKey, + int platform, + string? productId, + string? scheme, + string? token) + { + _updateUrl = updateUrl; + _clientVersion = clientVersion; + _upgradeClientVersion = upgradeClientVersion; + _appSecretKey = appSecretKey; + _platform = platform; + _productId = productId; + _scheme = scheme; + _token = token; + } + + /// Call version API and return download assets. + public async Task> ListAsync(CancellationToken token = default) + { + var mainResp = await VersionService.Validate( + _updateUrl, _clientVersion, AppType.ClientApp, + _appSecretKey, _platform, _productId, + _scheme, _token); + + var upgradeResp = await VersionService.Validate( + _updateUrl, _upgradeClientVersion ?? _clientVersion, AppType.UpgradeApp, + _appSecretKey, _platform, _productId, + _scheme, _token); + + var assets = new List(); + + if (mainResp?.Body != null) + { + foreach (var v in mainResp.Body) + assets.Add(MapVersionInfo(v)); + } + + if (upgradeResp?.Body != null) + { + foreach (var v in upgradeResp.Body) + assets.Add(MapVersionInfo(v)); + } + + return assets; + } + + private static DownloadAsset MapVersionInfo(VersionInfo v) + { + return new DownloadAsset( + Name: v.Name ?? v.Version ?? "unknown", + Url: v.Url ?? string.Empty, + Size: v.Size ?? 0, + SHA256: v.Hash, + Version: v.Version ?? "0.0.0", + IsForcibly: v.IsForcibly == true + ); + } +} diff --git a/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs index fdf76768..860be9f8 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs @@ -111,22 +111,31 @@ private async Task ExecuteStandardWorkflowAsync(Encoding encoding, int timeout) { GeneralTracer.Info($"ClientUpdateStrategy: validating client={_configInfo!.ClientVersion}, upgrade={_configInfo.UpgradeClientVersion}"); - var mainResp = await VersionService.Validate(_configInfo.UpdateUrl, - _configInfo.ClientVersion, AppType.ClientApp, _configInfo.AppSecretKey, - GetPlatform(), _configInfo.ProductId, _configInfo.Scheme, _configInfo.Token); - - var upgradeResp = await VersionService.Validate(_configInfo.UpdateUrl, - _configInfo.UpgradeClientVersion, AppType.UpgradeApp, _configInfo.AppSecretKey, - GetPlatform(), _configInfo.ProductId, _configInfo.Scheme, _configInfo.Token); - - _configInfo.IsUpgradeUpdate = CheckUpgrade(upgradeResp); - _configInfo.IsMainUpdate = CheckUpgrade(mainResp); - GeneralTracer.Info($"ClientUpdateStrategy: IsMainUpdate={_configInfo.IsMainUpdate}, IsUpgradeUpdate={_configInfo.IsUpgradeUpdate}"); - - var updateInfoArgs = new UpdateInfoEventArgs(mainResp); + // Use HttpDownloadSource to validate versions and get download assets + var downloadSource = new Download.Sources.HttpDownloadSource( + _configInfo.UpdateUrl, + _configInfo.ClientVersion, + _configInfo.UpgradeClientVersion, + _configInfo.AppSecretKey, + GetPlatform(), + _configInfo.ProductId, + _configInfo.Scheme, + _configInfo.Token); + + var assets = await downloadSource.ListAsync().ConfigureAwait(false); + var downloadPlan = Download.DownloadPlanBuilder.Build(assets, _configInfo.ClientVersion); + + // Detect update status + _configInfo.IsMainUpdate = downloadPlan.HasAssets; + _configInfo.IsUpgradeUpdate = assets.Any(a => a.Version != _configInfo.ClientVersion); + _configInfo.LastVersion = downloadPlan.Assets.LastOrDefault()?.Version; + GeneralTracer.Info($"ClientUpdateStrategy: IsMainUpdate={_configInfo.IsMainUpdate}, IsUpgradeUpdate={_configInfo.IsUpgradeUpdate}, AssetCount={downloadPlan.Assets.Count}"); + + // Dispatch update info event + var updateInfoArgs = new UpdateInfoEventArgs(null); EventManager.Instance.Dispatch(this, updateInfoArgs); - var isForcibly = CheckForcibly(mainResp.Body) || CheckForcibly(upgradeResp.Body); + var isForcibly = downloadPlan.IsForcibly; if (CanSkip(isForcibly, updateInfoArgs)) { GeneralTracer.Info("ClientUpdateStrategy: update skipped."); @@ -151,60 +160,52 @@ private async Task ExecuteStandardWorkflowAsync(Encoding encoding, int timeout) _configInfo.BackupDirectory = Path.Combine(_configInfo.InstallPath, $"{StorageManager.DirectoryName}{_configInfo.ClientVersion}"); - _configInfo.UpdateVersions = _configInfo.IsUpgradeUpdate - ? upgradeResp.Body.OrderBy(x => x.ReleaseDate).ToList() - : new List(); - - if (_configInfo.IsMainUpdate) + if (!_configInfo.IsMainUpdate) { - _configInfo.LastVersion = mainResp.Body.OrderBy(x => x.ReleaseDate).Last().Version; - GeneralTracer.Info($"ClientUpdateStrategy: main update LastVersion={_configInfo.LastVersion}"); + GeneralTracer.Info("ClientUpdateStrategy: no update available."); + return; + } - if (CheckFail(_configInfo.LastVersion)) - { - GeneralTracer.Warn($"ClientUpdateStrategy: version {_configInfo.LastVersion} matches known-failed upgrade."); - return; - } + // Check failed version + if (!string.IsNullOrEmpty(_configInfo.LastVersion) && CheckFail(_configInfo.LastVersion)) + { + GeneralTracer.Warn($"ClientUpdateStrategy: version {_configInfo.LastVersion} matches known-failed upgrade."); + return; + } - var processInfo = ConfigurationMapper.MapToProcessInfo( - _configInfo, mainResp.Body, + // Build process info for the upgrade process + _configInfo.ProcessInfo = JsonSerializer.Serialize( + ConfigurationMapper.MapToProcessInfo( + _configInfo, new List(), BlackListManager.Instance.BlackFormats.ToList(), BlackListManager.Instance.BlackFiles.ToList(), - BlackListManager.Instance.SkipDirectorys.ToList()); - - _configInfo.ProcessInfo = JsonSerializer.Serialize( - processInfo, ProcessInfoJsonContext.Default.ProcessInfo); - } + BlackListManager.Instance.SkipDirectorys.ToList()), + ProcessInfoJsonContext.Default.ProcessInfo); // Backup Backup(); _osStrategy!.Create(_configInfo); - GeneralTracer.Info($"ClientUpdateStrategy: IsUpgradeUpdate={_configInfo.IsUpgradeUpdate}, IsMainUpdate={_configInfo.IsMainUpdate}"); - - switch (_configInfo.IsUpgradeUpdate) + // Download via orchestrator (replaces old DownloadManager) + GeneralTracer.Info($"ClientUpdateStrategy: downloading {downloadPlan.Assets.Count} asset(s)."); + var httpClient = new System.Net.Http.HttpClient(); + try { - case true when _configInfo.IsMainUpdate: - GeneralTracer.Info("ClientUpdateStrategy: both upgrade+main -- downloading and executing."); - await DownloadAsync(); - await SafeReportDownloadCompletedAsync(hooksCtx).ConfigureAwait(false); - await _osStrategy.ExecuteAsync(); - await SafeOnBeforeStartAppAsync(hooksCtx).ConfigureAwait(false); - _osStrategy.StartApp(); - break; - case true when !_configInfo.IsMainUpdate: - GeneralTracer.Info("ClientUpdateStrategy: upgrade-only -- downloading and executing."); - await DownloadAsync(); - await SafeReportDownloadCompletedAsync(hooksCtx).ConfigureAwait(false); - await _osStrategy.ExecuteAsync(); - break; - case false when _configInfo.IsMainUpdate: - GeneralTracer.Info("ClientUpdateStrategy: main-only -- starting updater."); - await SafeOnBeforeStartAppAsync(hooksCtx).ConfigureAwait(false); - _osStrategy.StartApp(); - break; + var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(httpClient); + await orchestrator.ExecuteAsync(downloadPlan, _configInfo.TempPath).ConfigureAwait(false); } + finally + { + httpClient.Dispose(); + } + + await SafeReportDownloadCompletedAsync(hooksCtx).ConfigureAwait(false); + + // Apply updates and start app + await _osStrategy.ExecuteAsync(); + await SafeOnBeforeStartAppAsync(hooksCtx).ConfigureAwait(false); + _osStrategy.StartApp(); } #endregion @@ -243,15 +244,6 @@ private void Backup() BlackListManager.Instance.SkipDirectorys); } - private async Task DownloadAsync() - { - var manager = new DownloadManager( - _configInfo!.TempPath, _configInfo.Format, _configInfo.DownloadTimeOut); - foreach (var version in _configInfo.UpdateVersions) - manager.Add(new DownloadTask(manager, version)); - await manager.LaunchTasksAsync(); - } - private bool CanSkip(bool isForcibly, UpdateInfoEventArgs updateInfo) { if (isForcibly) return false; @@ -266,12 +258,6 @@ private bool CheckFail(string version) return new Version(fail) >= new Version(version); } - private static bool CheckUpgrade(VersionRespDTO? response) - => response?.Code == 200 && response.Body?.Count > 0; - - private static bool CheckForcibly(IEnumerable? versions) - => versions?.Any(v => v.IsForcibly == true) == true; - private static int GetPlatform() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return PlatformType.Windows;