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
@@ -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;

/// <summary>Orchestrates batch downloads with concurrency control.</summary>
public interface IDownloadOrchestrator
{
Task<DownloadReport> ExecuteAsync(
IReadOnlyList<string> urls,
string destDir,
int maxConcurrency = 3,
IProgress<DownloadProgress>? progress = null,
CancellationToken token = default);
}

public record DownloadReport(
IReadOnlyList<DownloadResult> Results,
long TotalBytes,
TimeSpan TotalDuration,
int SuccessCount,
int FailedCount
);
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Default download orchestrator with parallel execution and concurrency limit.
/// </summary>
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<DownloadReport> ExecuteAsync(
IReadOnlyList<string> urls,
string destDir,
int maxConcurrency = 3,
IProgress<DownloadProgress>? progress = null,
CancellationToken token = default)
{
var sw = Stopwatch.StartNew();
var results = new List<DownloadResult>();
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));
}
}
84 changes: 84 additions & 0 deletions src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Reports update lifecycle events to the server.</summary>
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
);

/// <summary>HTTP POST reporter with optional HMAC signing.</summary>
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();
}
}

/// <summary>No-op reporter used when ReportUrl is not configured.</summary>
public class NoOpUpdateReporter : IUpdateReporter
{
public Task ReportAsync(UpdateReport report, CancellationToken token = default)
=> Task.CompletedTask;
}
Loading