Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ namespace GeneralUpdate.Core.Download.Abstractions;
/// <summary>Orchestrates batch downloads with concurrency control.</summary>
public interface IDownloadOrchestrator
{
/// <summary>
/// Execute downloads for all assets in the plan.
/// Handles parallelism, retry, and SHA256 verification.
/// </summary>
Task<DownloadReport> ExecuteAsync(
IReadOnlyList<string> urls,
DownloadPlan plan,
string destDir,
int maxConcurrency = 3,
IProgress<DownloadProgress>? progress = null,
Expand Down
1 change: 1 addition & 0 deletions src/c#/GeneralUpdate.Core/Download/DownloadManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GeneralUpdate.Core.Download.Models;

namespace GeneralUpdate.Core.Download;

/// <summary>
/// Builds a DownloadPlan from download assets.
/// Handles cross-version package selection, version chain building,
/// frozen package filtering, and forced update marking.
/// </summary>
public static class DownloadPlanBuilder
{
/// <summary>
/// Build a download plan from a list of download assets.
/// </summary>
/// <param name="assets">Assets from the download source.</param>
/// <param name="currentVersion">Current client version string.</param>
/// <returns>A DownloadPlan with ordered assets, or DownloadPlan.Empty if no update is needed.</returns>
public static DownloadPlan Build(IEnumerable<DownloadAsset> 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);
}

/// <summary>
/// Build a version chain: keep versions higher than current,
/// check MinClientVersion compatibility.
/// </summary>
private static List<DownloadAsset> BuildVersionChain(IEnumerable<DownloadAsset> assets, string currentVersion)
{
var current = ParseVersion(currentVersion);

return assets
.Where(a =>
{
var pv = ParseVersion(a.Version);
if (pv == null) return false;
return pv > current;
})
Comment on lines +60 to +70
.Where(a => IsCompatible(a.MinClientVersion, currentVersion))
.OrderBy(a => ParseVersion(a.Version))
.ToList();
}

/// <summary>
/// Check if MinClientVersion is compatible with the current version.
/// A package with MinClientVersion higher than current is not applicable.
/// </summary>
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;
}

/// <summary>Parse a version string, returning null on failure.</summary>
private static Version? ParseVersion(string? version)
{
if (string.IsNullOrWhiteSpace(version)) return null;
return Version.TryParse(version, out var v) ? v : null;
}

/// <summary>Compare two version strings for equality.</summary>
private static bool VersionEquals(string a, string b)
{
var va = ParseVersion(a);
var vb = ParseVersion(b);
return va != null && vb != null && va == vb;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Default download orchestrator with parallel execution and concurrency limit.
/// Default download orchestrator with parallel execution, concurrency limit,
/// SHA256 verification, and progress reporting.
/// </summary>
public class DefaultDownloadOrchestrator : IDownloadOrchestrator
{
Expand All @@ -27,36 +29,63 @@ public DefaultDownloadOrchestrator(HttpClient httpClient, IDownloadPolicy? polic
_policy = policy ?? new DefaultRetryPolicy();
}

/// <summary>Execute downloads for all assets in the plan.</summary>
public async Task<DownloadReport> ExecuteAsync(
IReadOnlyList<string> urls,
DownloadPlan plan,
string destDir,
int maxConcurrency = 3,
IProgress<DownloadProgress>? progress = null,
CancellationToken token = default)
{
if (plan == null || !plan.HasAssets)
return new DownloadReport(Array.Empty<DownloadResult>(), 0, TimeSpan.Zero, 0, 0);

var sw = Stopwatch.StartNew();
var results = new List<DownloadResult>();
var sem = new SemaphoreSlim(maxConcurrency);
long totalBytes = 0;
Comment on lines 43 to 46

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(); }
Expand All @@ -72,4 +101,28 @@ public async Task<DownloadReport> 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}";
}

/// <summary>Wraps progress reporting to include the asset name.</summary>
private sealed class AssetProgressReporter : IProgress<DownloadProgress>
{
private readonly IProgress<DownloadProgress> _inner;
private readonly string _assetName;
public AssetProgressReporter(IProgress<DownloadProgress> inner, string assetName)
{ _inner = inner; _assetName = assetName; }
public void Report(DownloadProgress value)
{
_inner.Report(value with { AssetName = _assetName });
}
}
}
87 changes: 87 additions & 0 deletions src/c#/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// HTTP download source — calls the version validation API
/// and converts the server response to a list of DownloadAssets.
/// </summary>
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;
}

/// <summary>Call version API and return download assets.</summary>
public async Task<IReadOnlyList<DownloadAsset>> 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<DownloadAsset>();

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
);
Comment on lines +76 to +85
}
}
Loading