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;