diff --git a/AlayaCore.sln.DotSettings.user b/AlayaCore.sln.DotSettings.user
index 6be7083..2a3f04b 100644
--- a/AlayaCore.sln.DotSettings.user
+++ b/AlayaCore.sln.DotSettings.user
@@ -21,5 +21,6 @@
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
\ No newline at end of file
diff --git a/AlayaCore/Abstractions/Interfaces/Clients/IHttpClient.cs b/AlayaCore/Abstractions/Interfaces/Clients/IHttpClient.cs
index 42d7bd0..b34bb34 100644
--- a/AlayaCore/Abstractions/Interfaces/Clients/IHttpClient.cs
+++ b/AlayaCore/Abstractions/Interfaces/Clients/IHttpClient.cs
@@ -11,7 +11,5 @@ namespace AlayaCore.Abstractions.Interfaces.Clients
Uri uri,
HttpCompletionOption completionOption,
CancellationToken cancellationToken);
-
- HttpClient HttpClient { get; }
}
}
\ No newline at end of file
diff --git a/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs b/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs
index bc792f1..efdbb2a 100644
--- a/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs
+++ b/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs
@@ -1,7 +1,19 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
namespace AlayaCore.Abstractions.Interfaces.Policies
{
- public class IRetryPolicy
+ public interface IRetryPolicy
{
-
+ Task ExecuteAsync(
+ Func operation,
+ string operationName,
+ CancellationToken cancellationToken = default);
+
+ Task ExecuteAsync(
+ Func> operation,
+ string operationName,
+ CancellationToken cancellationToken = default);
}
}
\ No newline at end of file
diff --git a/AlayaCore/Clients/DefaultHttpClient.cs b/AlayaCore/Clients/DefaultHttpClient.cs
index c7d762e..121f943 100644
--- a/AlayaCore/Clients/DefaultHttpClient.cs
+++ b/AlayaCore/Clients/DefaultHttpClient.cs
@@ -6,7 +6,7 @@ using AlayaCore.Abstractions.Interfaces.Clients;
namespace AlayaCore.Clients
{
- public sealed class DefaultHttpClient : IHttpClient, IDisposable
+ public sealed class DefaultHttpClient : IHttpClient
{
private readonly HttpClient _httpClient;
private bool _disposed;
diff --git a/AlayaCore/Errors/LauncherError.cs b/AlayaCore/Errors/LauncherError.cs
index 22235f2..fd2397c 100644
--- a/AlayaCore/Errors/LauncherError.cs
+++ b/AlayaCore/Errors/LauncherError.cs
@@ -1,7 +1,19 @@
+using System;
+using AlayaCore.Utilities.Enums;
+
namespace AlayaCore.Errors
{
- public class LauncherError
+ public sealed class LauncherError
{
-
+ public LauncherErrorType Type { get; }
+ public string Message { get; }
+ public Exception Exception { get; }
+
+ public LauncherError(LauncherErrorType type, string message, Exception exception)
+ {
+ Type = type;
+ Message = message;
+ Exception = exception;
+ }
}
}
\ No newline at end of file
diff --git a/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs b/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs
index 3e942b1..047f966 100644
--- a/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs
+++ b/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs
@@ -1,3 +1,6 @@
+using System;
+using System.IO;
+
namespace AlayaCore.Models.Configuration
{
public sealed class LauncherUpdateServiceOptions
@@ -10,5 +13,8 @@ namespace AlayaCore.Models.Configuration
public string AlayaUpdaterPath { get; set; }
public bool ForceUpdate { get; set; }
+
+ public static LauncherUpdateServiceOptions Default { get; } =
+ new LauncherUpdateServiceOptions(Path.Combine(AppContext.BaseDirectory, "Data", "Updater"), false);
}
}
\ No newline at end of file
diff --git a/AlayaCore/Models/Configuration/ManifestServiceOptions.cs b/AlayaCore/Models/Configuration/ManifestServiceOptions.cs
index 3fb79df..79f38b6 100644
--- a/AlayaCore/Models/Configuration/ManifestServiceOptions.cs
+++ b/AlayaCore/Models/Configuration/ManifestServiceOptions.cs
@@ -4,25 +4,23 @@ namespace AlayaCore.Models.Configuration
{
public sealed class ManifestServiceOptions
{
- public Uri CoreManifestUri { get; }
+ public Uri AlayaManifestUri { get; }
public Uri LauncherManifestUri { get; }
- public string CoreManifestSha512Hash { get; }
+ public string AlayaManifestSha512Hash { get; }
public string LauncherManifestSha512Hash { get; }
- public string ManifestDirectoryPath { get; }
public ManifestServiceOptions(
- Uri coreManifestUri,
+ Uri alayaManifestUri,
Uri launcherManifestUri,
- string coreManifestSha512Hash,
- string launcherManifestSha512Hash,
- string manifestDirectoryPath)
+ string alayaManifestSha512Hash,
+ string launcherManifestSha512Hash)
{
- CoreManifestUri = coreManifestUri ?? throw new ArgumentNullException(nameof(coreManifestUri));
+ AlayaManifestUri = alayaManifestUri ?? throw new ArgumentNullException(nameof(alayaManifestUri));
LauncherManifestUri = launcherManifestUri ?? throw new ArgumentNullException(nameof(launcherManifestUri));
- if (!CoreManifestUri.IsAbsoluteUri)
+ if (!AlayaManifestUri.IsAbsoluteUri)
{
- throw new ArgumentException("Core manifest URI must be absolute.", nameof(coreManifestUri));
+ throw new ArgumentException("Core manifest URI must be absolute.", nameof(alayaManifestUri));
}
if (!LauncherManifestUri.IsAbsoluteUri)
@@ -30,9 +28,9 @@ namespace AlayaCore.Models.Configuration
throw new ArgumentException("Launcher manifest URI must be absolute.", nameof(launcherManifestUri));
}
- if (string.IsNullOrWhiteSpace(coreManifestSha512Hash))
+ if (string.IsNullOrWhiteSpace(alayaManifestSha512Hash))
{
- throw new ArgumentException("Core manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(coreManifestSha512Hash));
+ throw new ArgumentException("Core manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(alayaManifestSha512Hash));
}
if (string.IsNullOrWhiteSpace(launcherManifestSha512Hash))
@@ -40,14 +38,12 @@ namespace AlayaCore.Models.Configuration
throw new ArgumentException("Launcher manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(launcherManifestSha512Hash));
}
- if (string.IsNullOrWhiteSpace(manifestDirectoryPath))
- {
- throw new ArgumentException("Manifest directory path cannot be null, empty, or whitespace.", nameof(manifestDirectoryPath));
- }
-
- CoreManifestSha512Hash = coreManifestSha512Hash;
+ AlayaManifestSha512Hash = alayaManifestSha512Hash;
LauncherManifestSha512Hash = launcherManifestSha512Hash;
- ManifestDirectoryPath = manifestDirectoryPath;
}
+
+ public static ManifestServiceOptions Default { get; } = new ManifestServiceOptions(
+ new Uri("INSERT-ALAYA-URL", UriKind.Absolute),
+ new Uri("INSERT-LAUNCHER-URL", UriKind.Absolute), "INSERT-ALAYA-HASH", "INSERT-LAUNCHER-HASH");
}
}
\ No newline at end of file
diff --git a/AlayaCore/Models/Configuration/ModrinthConnectionOptions.cs b/AlayaCore/Models/Configuration/ModrinthConnectionOptions.cs
index 075fe0d..d128566 100644
--- a/AlayaCore/Models/Configuration/ModrinthConnectionOptions.cs
+++ b/AlayaCore/Models/Configuration/ModrinthConnectionOptions.cs
@@ -8,5 +8,8 @@ namespace AlayaCore.Models.Configuration
}
public string BaseApiUrl { get; }
+
+ public static ModrinthConnectionOptions Default { get; } =
+ new ModrinthConnectionOptions("https://api.modrinth.com/v2/");
}
}
\ No newline at end of file
diff --git a/AlayaCore/Models/Configuration/RetryPolicyOptions.cs b/AlayaCore/Models/Configuration/RetryPolicyOptions.cs
index d751583..a970967 100644
--- a/AlayaCore/Models/Configuration/RetryPolicyOptions.cs
+++ b/AlayaCore/Models/Configuration/RetryPolicyOptions.cs
@@ -1,7 +1,12 @@
namespace AlayaCore.Models.Configuration
{
- public class RetryPolicyOptions
+ public sealed class RetryPolicyOptions
{
+ public int MaxAttempts { get; set; } = 3;
+ public int BaseDelayMilliseconds { get; set; } = 500;
+ public double BackoffMultiplier { get; set; } = 2.0;
+ public int MaxDelayMilliseconds { get; set; } = 3000;
+ public static RetryPolicyOptions Default { get; } = new();
}
}
\ No newline at end of file
diff --git a/AlayaCore/Policies/RetryPolicy.cs b/AlayaCore/Policies/RetryPolicy.cs
index 8a7654d..f581a68 100644
--- a/AlayaCore/Policies/RetryPolicy.cs
+++ b/AlayaCore/Policies/RetryPolicy.cs
@@ -7,13 +7,14 @@ using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Models.Configuration;
using Microsoft.Extensions.Logging;
-namespace AlayaCore.Models.Policies
+namespace AlayaCore.Services
{
public sealed class RetryPolicy : IRetryPolicy
{
private readonly RetryPolicyOptions _options;
private readonly ILogger _logger;
- private readonly Random _random;
+
+ private static readonly Random _random = new Random();
public RetryPolicy(
RetryPolicyOptions options,
@@ -21,7 +22,6 @@ namespace AlayaCore.Models.Policies
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
- _random = new Random();
}
public async Task ExecuteAsync(
@@ -64,10 +64,7 @@ namespace AlayaCore.Models.Policies
throw new ArgumentException("Operation name cannot be null, empty, or whitespace.", nameof(operationName));
}
- if (_options.MaxAttempts <= 0)
- {
- throw new InvalidOperationException("RetryPolicyOptions.MaxAttempts must be greater than zero.");
- }
+ ValidateOptions();
Exception? lastException = null;
@@ -88,12 +85,38 @@ namespace AlayaCore.Models.Policies
return await operation(cancellationToken).ConfigureAwait(false);
}
+ catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested && IsRetryable(ex))
+ {
+ lastException = ex;
+
+ if (attempt == _options.MaxAttempts)
+ {
+ _logger.LogError(
+ ex,
+ "Operation {OperationName} failed after {MaxAttempts} attempts due to repeated timeout or cancellation-like transient failures.",
+ operationName,
+ _options.MaxAttempts);
+
+ throw;
+ }
+
+ TimeSpan delay = CalculateDelay(attempt);
+
+ _logger.LogWarning(
+ ex,
+ "Operation {OperationName} timed out or was transiently cancelled on attempt {Attempt} of {MaxAttempts}. Retrying after {DelayMs}ms.",
+ operationName,
+ attempt,
+ _options.MaxAttempts,
+ (int)delay.TotalMilliseconds);
+
+ await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ }
catch (OperationCanceledException)
{
_logger.LogInformation(
- "Operation {OperationName} was cancelled during attempt {Attempt}.",
- operationName,
- attempt);
+ "Operation {OperationName} was cancelled by the caller.",
+ operationName);
throw;
}
@@ -141,7 +164,30 @@ namespace AlayaCore.Models.Policies
lastException);
}
- private bool IsRetryable(Exception exception)
+ private void ValidateOptions()
+ {
+ if (_options.MaxAttempts <= 0)
+ {
+ throw new InvalidOperationException("RetryPolicyOptions.MaxAttempts must be greater than zero.");
+ }
+
+ if (_options.BaseDelayMilliseconds < 0)
+ {
+ throw new InvalidOperationException("RetryPolicyOptions.BaseDelayMilliseconds cannot be negative.");
+ }
+
+ if (_options.BackoffMultiplier < 1d)
+ {
+ throw new InvalidOperationException("RetryPolicyOptions.BackoffMultiplier must be greater than or equal to 1.");
+ }
+
+ if (_options.MaxDelayMilliseconds < 0)
+ {
+ throw new InvalidOperationException("RetryPolicyOptions.MaxDelayMilliseconds cannot be negative.");
+ }
+ }
+
+ private static bool IsRetryable(Exception exception)
{
return exception switch
{
@@ -157,11 +203,14 @@ namespace AlayaCore.Models.Policies
private TimeSpan CalculateDelay(int attempt)
{
- double exponentialDelay = _options.BaseDelayMilliseconds * Math.Pow(_options.BackoffMultiplier, attempt - 1);
+ double exponentialDelay = _options.BaseDelayMilliseconds *
+ Math.Pow(_options.BackoffMultiplier, attempt - 1);
+
double cappedDelay = Math.Min(exponentialDelay, _options.MaxDelayMilliseconds);
- int jitter = _random.Next(0, 150);
- return TimeSpan.FromMilliseconds(cappedDelay + jitter);
+ int jitterMilliseconds = _random.Next(0, 150);
+
+ return TimeSpan.FromMilliseconds(cappedDelay + jitterMilliseconds);
}
}
}
\ No newline at end of file
diff --git a/AlayaCore/Services/HttpDownloadService.cs b/AlayaCore/Services/HttpDownloadService.cs
index d88d090..ea3a466 100644
--- a/AlayaCore/Services/HttpDownloadService.cs
+++ b/AlayaCore/Services/HttpDownloadService.cs
@@ -6,6 +6,7 @@ using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Clients;
+using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
@@ -18,13 +19,16 @@ namespace AlayaCore.Services
private const int BUFFER_SIZE = 81920;
private readonly IHttpClient _httpClient;
+ private readonly IRetryPolicy _retryPolicy;
private readonly ILogger _logger;
public HttpDownloadService(
IHttpClient httpClient,
+ IRetryPolicy retryPolicy,
ILogger logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -104,137 +108,150 @@ namespace AlayaCore.Services
try
{
- using HttpResponseMessage response = await _httpClient.GetAsync(
- sourceUri,
- HttpCompletionOption.ResponseHeadersRead,
+ DownloadResult result = await _retryPolicy.ExecuteAsync(
+ async token =>
+ {
+ DeleteFileIfExists(tempFilePath);
+
+ using HttpResponseMessage response = await _httpClient.GetAsync(
+ sourceUri,
+ HttpCompletionOption.ResponseHeadersRead,
+ token).ConfigureAwait(false);
+
+ response.EnsureSuccessStatusCode();
+
+ long? totalBytes = response.Content.Headers.ContentLength;
+ long bytesDownloaded = 0;
+
+ _logger.LogInformation(
+ "Download response received for {FileName}. Content-Length: {TotalBytes}.",
+ fileName,
+ totalBytes);
+
+ progress?.Report(new DownloadProgress(
+ fileName: fileName,
+ destinationPath: destinationPath,
+ bytesDownloaded: 0,
+ totalBytes: totalBytes,
+ bytesPerSecond: null,
+ statusMessage: "Starting download..."));
+
+ await using Stream responseStream = await response.Content
+ .ReadAsStreamAsync()
+ .ConfigureAwait(false);
+
+ await using FileStream fileStream = new FileStream(
+ tempFilePath,
+ FileMode.Create,
+ FileAccess.Write,
+ FileShare.None,
+ BUFFER_SIZE,
+ useAsync: true);
+
+ using SHA512 sha512 = SHA512.Create();
+
+ byte[] buffer = new byte[BUFFER_SIZE];
+ Stopwatch stopwatch = Stopwatch.StartNew();
+
+ _logger.LogDebug(
+ "Streaming download content for {FileName} into temporary file {TempFilePath}.",
+ fileName,
+ tempFilePath);
+
+ while (true)
+ {
+ int bytesRead = await responseStream.ReadAsync(
+ buffer,
+ 0,
+ buffer.Length,
+ token).ConfigureAwait(false);
+
+ if (bytesRead == 0)
+ {
+ break;
+ }
+
+ await fileStream.WriteAsync(
+ buffer,
+ 0,
+ bytesRead,
+ token).ConfigureAwait(false);
+
+ sha512.TransformBlock(buffer, 0, bytesRead, null, 0);
+
+ bytesDownloaded += bytesRead;
+
+ double? bytesPerSecond = null;
+ if (stopwatch.Elapsed.TotalSeconds > 0d)
+ {
+ bytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds;
+ }
+
+ progress?.Report(new DownloadProgress(
+ fileName: fileName,
+ destinationPath: destinationPath,
+ bytesDownloaded: bytesDownloaded,
+ totalBytes: totalBytes,
+ bytesPerSecond: bytesPerSecond,
+ statusMessage: "Downloading file..."));
+ }
+
+ sha512.TransformFinalBlock(Array.Empty(), 0, 0);
+ await fileStream.FlushAsync(token).ConfigureAwait(false);
+
+ string actualHash = ConvertToLowerHex(sha512.Hash);
+ if (!string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogError(
+ "Hash verification failed for downloaded file {FileName}. Expected SHA-512: {ExpectedHash}. Actual SHA-512: {ActualHash}.",
+ fileName,
+ normalizedExpectedHash,
+ actualHash);
+
+ throw new InvalidDataException(
+ $"Downloaded file hash mismatch. Expected '{normalizedExpectedHash}', got '{actualHash}'.");
+ }
+
+ _logger.LogInformation(
+ "Hash verification succeeded for {FileName}. Replacing destination file with downloaded content.",
+ fileName);
+
+ ReplaceDestinationFile(tempFilePath, destinationPath);
+
+ double? finalBytesPerSecond = null;
+ if (stopwatch.Elapsed.TotalSeconds > 0d)
+ {
+ finalBytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds;
+ }
+
+ progress?.Report(new DownloadProgress(
+ fileName: fileName,
+ destinationPath: destinationPath,
+ bytesDownloaded: bytesDownloaded,
+ totalBytes: totalBytes ?? bytesDownloaded,
+ bytesPerSecond: finalBytesPerSecond,
+ statusMessage: "Download complete."));
+
+ DownloadOutcome outcome = destinationExisted
+ ? DownloadOutcome.ReplacedInvalid
+ : DownloadOutcome.Downloaded;
+
+ _logger.LogInformation(
+ "Download completed successfully for {FileName}. Outcome: {Outcome}. Bytes downloaded: {BytesDownloaded}.",
+ fileName,
+ outcome,
+ bytesDownloaded);
+
+ return new DownloadResult(
+ destinationPath,
+ outcome,
+ hashVerified: true,
+ bytesDownloaded: bytesDownloaded);
+ },
+ $"download:{fileName}",
cancellationToken).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
-
- long? totalBytes = response.Content.Headers.ContentLength;
- long bytesDownloaded = 0;
-
- _logger.LogInformation(
- "Download response received for {FileName}. Content-Length: {TotalBytes}.",
- fileName,
- totalBytes);
-
- progress?.Report(new DownloadProgress(
- fileName: fileName,
- destinationPath: destinationPath,
- bytesDownloaded: 0,
- totalBytes: totalBytes,
- bytesPerSecond: null,
- statusMessage: "Starting download..."));
-
- await using Stream responseStream = await response.Content
- .ReadAsStreamAsync()
- .ConfigureAwait(false);
-
- await using FileStream fileStream = new FileStream(
- tempFilePath,
- FileMode.Create,
- FileAccess.Write,
- FileShare.None,
- BUFFER_SIZE,
- useAsync: true);
-
- using SHA512 sha512 = SHA512.Create();
-
- byte[] buffer = new byte[BUFFER_SIZE];
- Stopwatch stopwatch = Stopwatch.StartNew();
-
- _logger.LogDebug("Streaming download content for {FileName} into temporary file {TempFilePath}.", fileName, tempFilePath);
-
- while (true)
- {
- int bytesRead = await responseStream.ReadAsync(
- buffer,
- 0,
- buffer.Length,
- cancellationToken).ConfigureAwait(false);
-
- if (bytesRead == 0)
- {
- break;
- }
-
- await fileStream.WriteAsync(
- buffer,
- 0,
- bytesRead,
- cancellationToken).ConfigureAwait(false);
-
- sha512.TransformBlock(buffer, 0, bytesRead, null, 0);
-
- bytesDownloaded += bytesRead;
-
- double? bytesPerSecond = null;
- if (stopwatch.Elapsed.TotalSeconds > 0d)
- {
- bytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds;
- }
-
- progress?.Report(new DownloadProgress(
- fileName: fileName,
- destinationPath: destinationPath,
- bytesDownloaded: bytesDownloaded,
- totalBytes: totalBytes,
- bytesPerSecond: bytesPerSecond,
- statusMessage: "Downloading file..."));
- }
-
- sha512.TransformFinalBlock(Array.Empty(), 0, 0);
- await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false);
-
- string actualHash = ConvertToLowerHex(sha512.Hash);
- if (!string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogError(
- "Hash verification failed for downloaded file {FileName}. Expected SHA-512: {ExpectedHash}. Actual SHA-512: {ActualHash}.",
- fileName,
- normalizedExpectedHash,
- actualHash);
-
- throw new InvalidDataException(
- $"Downloaded file hash mismatch. Expected '{normalizedExpectedHash}', got '{actualHash}'.");
- }
-
- _logger.LogInformation(
- "Hash verification succeeded for {FileName}. Replacing destination file with downloaded content.",
- fileName);
-
- ReplaceDestinationFile(tempFilePath, destinationPath);
-
- double? finalBytesPerSecond = null;
- if (stopwatch.Elapsed.TotalSeconds > 0d)
- {
- finalBytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds;
- }
-
- progress?.Report(new DownloadProgress(
- fileName: fileName,
- destinationPath: destinationPath,
- bytesDownloaded: bytesDownloaded,
- totalBytes: totalBytes ?? bytesDownloaded,
- bytesPerSecond: finalBytesPerSecond,
- statusMessage: "Download complete."));
-
- DownloadOutcome outcome = destinationExisted
- ? DownloadOutcome.ReplacedInvalid
- : DownloadOutcome.Downloaded;
-
- _logger.LogInformation(
- "Download completed successfully for {FileName}. Outcome: {Outcome}. Bytes downloaded: {BytesDownloaded}.",
- fileName,
- outcome,
- bytesDownloaded);
-
- return new DownloadResult(
- destinationPath,
- outcome,
- hashVerified: true,
- bytesDownloaded: bytesDownloaded);
+ return result;
}
catch (OperationCanceledException)
{
diff --git a/AlayaCore/Services/ManifestService.cs b/AlayaCore/Services/ManifestService.cs
index 8ff7332..365b89c 100644
--- a/AlayaCore/Services/ManifestService.cs
+++ b/AlayaCore/Services/ManifestService.cs
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Clients;
+using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
@@ -26,6 +27,7 @@ namespace AlayaCore.Services
private readonly IHttpClient _httpClient;
private readonly IFileStore _fileStore;
private readonly ManifestServiceOptions _options;
+ private readonly IRetryPolicy _retryPolicy;
private readonly ILogger _logger;
public ManifestService(
@@ -33,12 +35,14 @@ namespace AlayaCore.Services
IHttpClient httpClient,
IFileStore fileStore,
ManifestServiceOptions options,
+ IRetryPolicy retryPolicy,
ILogger logger)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_options = options ?? throw new ArgumentNullException(nameof(options));
+ _retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -48,13 +52,13 @@ namespace AlayaCore.Services
_logger.LogInformation(
"Downloading and loading Alaya manifest from {ManifestUri} to {DestinationPath}.",
- _options.CoreManifestUri,
+ _options.AlayaManifestUri,
destinationPath);
return DownloadAndLoadManifestAsync(
- _options.CoreManifestUri,
+ _options.AlayaManifestUri,
destinationPath,
- _options.CoreManifestSha512Hash,
+ _options.AlayaManifestSha512Hash,
static dto => dto.ToModel(),
cancellationToken);
}
@@ -144,24 +148,24 @@ namespace AlayaCore.Services
public async Task GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default)
{
- _logger.LogDebug("Fetching remote Alaya manifest version from {ManifestUri}.", _options.CoreManifestUri);
+ _logger.LogDebug("Fetching remote Alaya manifest version from {ManifestUri}.", _options.AlayaManifestUri);
ManifestModel remoteManifest = await GetRemoteManifestAsync(
- _options.CoreManifestUri,
+ _options.AlayaManifestUri,
static dto => dto.ToModel(),
cancellationToken).ConfigureAwait(false);
if (remoteManifest.AlayaVersion == null)
{
- _logger.LogError("Remote Alaya manifest from {ManifestUri} did not contain a valid version.", _options.CoreManifestUri);
+ _logger.LogError("Remote Alaya manifest from {ManifestUri} did not contain a valid version.", _options.AlayaManifestUri);
throw new InvalidDataException(
- $"Remote core manifest from '{_options.CoreManifestUri}' does not contain a valid version.");
+ $"Remote core manifest from '{_options.AlayaManifestUri}' does not contain a valid version.");
}
_logger.LogInformation(
"Fetched remote Alaya manifest version {RemoteVersion} from {ManifestUri}.",
remoteManifest.AlayaVersion,
- _options.CoreManifestUri);
+ _options.AlayaManifestUri);
return remoteManifest.AlayaVersion;
}
@@ -309,11 +313,17 @@ namespace AlayaCore.Services
manifestUri,
destinationPath);
- await _downloadService.DownloadFileAsync(
- manifestUri,
- destinationPath,
- sha512Hash,
- cancellationToken: cancellationToken).ConfigureAwait(false);
+ await _retryPolicy.ExecuteAsync(
+ async token =>
+ {
+ await _downloadService.DownloadFileAsync(
+ manifestUri,
+ destinationPath,
+ sha512Hash,
+ cancellationToken: token).ConfigureAwait(false);
+ },
+ $"manifest-download:{Path.GetFileName(destinationPath)}",
+ cancellationToken).ConfigureAwait(false);
if (!File.Exists(destinationPath))
{
@@ -368,31 +378,37 @@ namespace AlayaCore.Services
_logger.LogDebug("Fetching remote manifest from {ManifestUri}.", manifestUri);
- using HttpResponseMessage response = await _httpClient.GetAsync(
- manifestUri,
- HttpCompletionOption.ResponseContentRead,
+ return await _retryPolicy.ExecuteAsync(
+ async token =>
+ {
+ using HttpResponseMessage response = await _httpClient.GetAsync(
+ manifestUri,
+ HttpCompletionOption.ResponseContentRead,
+ token).ConfigureAwait(false);
+
+ response.EnsureSuccessStatusCode();
+
+ string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ _logger.LogError("Remote manifest response from {ManifestUri} was empty.", manifestUri);
+ throw new InvalidDataException(
+ $"Remote manifest response from '{manifestUri}' was empty.");
+ }
+
+ TModel model = DeserializeAndMapManifest(
+ json,
+ manifestUri.ToString(),
+ map,
+ swallowDeserializationErrors: false)!;
+
+ _logger.LogDebug("Successfully fetched and mapped remote manifest from {ManifestUri}.", manifestUri);
+
+ return model;
+ },
+ $"manifest-fetch:{manifestUri.AbsolutePath}",
cancellationToken).ConfigureAwait(false);
-
- response.EnsureSuccessStatusCode();
-
- string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
-
- if (string.IsNullOrWhiteSpace(json))
- {
- _logger.LogError("Remote manifest response from {ManifestUri} was empty.", manifestUri);
- throw new InvalidDataException(
- $"Remote manifest response from '{manifestUri}' was empty.");
- }
-
- TModel model = DeserializeAndMapManifest(
- json,
- manifestUri.ToString(),
- map,
- swallowDeserializationErrors: false)!;
-
- _logger.LogDebug("Successfully fetched and mapped remote manifest from {ManifestUri}.", manifestUri);
-
- return model;
}
private TModel? DeserializeAndMapManifest(
diff --git a/AlayaCore/Services/ModService.cs b/AlayaCore/Services/ModService.cs
index 20e7713..97657e0 100644
--- a/AlayaCore/Services/ModService.cs
+++ b/AlayaCore/Services/ModService.cs
@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Clients;
+using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models;
@@ -30,6 +31,7 @@ namespace AlayaCore.Services
private readonly ModrinthConnectionOptions _options;
private readonly IHttpClient _httpClient;
private readonly IFileStore _fileStore;
+ private readonly IRetryPolicy _retryPolicy;
private readonly ILogger _logger;
public ModService(
@@ -37,12 +39,14 @@ namespace AlayaCore.Services
ModrinthConnectionOptions options,
IHttpClient httpClient,
IFileStore fileStore,
+ IRetryPolicy retryPolicy,
ILogger logger)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
+ _retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -213,136 +217,142 @@ namespace AlayaCore.Services
fileEntry.FileName,
versionEndpoint);
- using HttpResponseMessage response = await _httpClient.GetAsync(
- new Uri(versionEndpoint, UriKind.Absolute),
- HttpCompletionOption.ResponseContentRead,
+ return await _retryPolicy.ExecuteAsync(
+ async token =>
+ {
+ using HttpResponseMessage response = await _httpClient.GetAsync(
+ new Uri(versionEndpoint, UriKind.Absolute),
+ HttpCompletionOption.ResponseContentRead,
+ token).ConfigureAwait(false);
+
+ response.EnsureSuccessStatusCode();
+
+ string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ _logger.LogError(
+ "Mod metadata response for {FileName} from {VersionEndpoint} was empty.",
+ fileEntry.FileName,
+ versionEndpoint);
+
+ throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty.");
+ }
+
+ JObject jsonObject = JObject.Parse(json);
+
+ JArray? filesArray = jsonObject["files"] as JArray;
+ if (filesArray == null || filesArray.Count == 0)
+ {
+ _logger.LogError(
+ "Mod metadata response for {FileName} did not contain any files.",
+ fileEntry.FileName);
+
+ throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files.");
+ }
+
+ JObject? selectedFile = filesArray
+ .OfType()
+ .FirstOrDefault(file => file.Value("primary") == true)
+ ?? filesArray.OfType().FirstOrDefault();
+
+ if (selectedFile == null)
+ {
+ _logger.LogError(
+ "Mod metadata response for {FileName} did not contain a usable file entry.",
+ fileEntry.FileName);
+
+ throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry.");
+ }
+
+ JObject? hashesObject = selectedFile["hashes"] as JObject;
+ if (hashesObject == null)
+ {
+ _logger.LogError(
+ "Mod metadata response for {FileName} did not contain a hashes object.",
+ fileEntry.FileName);
+
+ throw new InvalidDataException(
+ $"The mod metadata response for '{fileEntry.FileName}' did not contain a hashes object.");
+ }
+
+ string? remoteSha512Hash = hashesObject.Value("sha512");
+ if (string.IsNullOrWhiteSpace(remoteSha512Hash))
+ {
+ _logger.LogError(
+ "Mod metadata response for {FileName} did not contain a valid SHA-512 hash.",
+ fileEntry.FileName);
+
+ throw new InvalidDataException(
+ $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash.");
+ }
+
+ if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogError(
+ "Mod metadata hash mismatch for {FileName}. Remote SHA-512: {RemoteHash}, Required SHA-512: {RequiredHash}",
+ fileEntry.FileName,
+ remoteSha512Hash,
+ fileEntry.Sha512Hash);
+
+ throw new InvalidDataException(
+ $"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash.");
+ }
+
+ long? size = selectedFile.Value("size");
+ if (!size.HasValue || size.Value <= 0)
+ {
+ _logger.LogError(
+ "Mod metadata response for {FileName} did not contain a valid file size.",
+ fileEntry.FileName);
+
+ throw new InvalidDataException(
+ $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
+ }
+
+ if (size.Value != fileEntry.Size)
+ {
+ _logger.LogError(
+ "Mod metadata size mismatch for {FileName}. Remote Size: {RemoteSize}, Required Size: {RequiredSize}",
+ fileEntry.FileName,
+ size.Value,
+ fileEntry.Size);
+
+ throw new InvalidDataException(
+ $"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size.");
+ }
+
+ string? modUrl = selectedFile.Value("url");
+ if (string.IsNullOrWhiteSpace(modUrl))
+ {
+ _logger.LogError(
+ "Mod metadata response for {FileName} did not contain a valid file URL.",
+ fileEntry.FileName);
+
+ throw new InvalidDataException(
+ $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL.");
+ }
+
+ if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
+ {
+ _logger.LogError(
+ "Mod metadata response for {FileName} contained an invalid file URL: {ModUrl}",
+ fileEntry.FileName,
+ modUrl);
+
+ throw new InvalidDataException(
+ $"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL.");
+ }
+
+ _logger.LogDebug(
+ "Resolved download URL for mod {FileName} to {ModUrl}.",
+ fileEntry.FileName,
+ result);
+
+ return result;
+ },
+ $"mod-metadata:{fileEntry.FileName}",
cancellationToken).ConfigureAwait(false);
-
- response.EnsureSuccessStatusCode();
-
- string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
-
- if (string.IsNullOrWhiteSpace(json))
- {
- _logger.LogError(
- "Mod metadata response for {FileName} from {VersionEndpoint} was empty.",
- fileEntry.FileName,
- versionEndpoint);
-
- throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty.");
- }
-
- JObject jsonObject = JObject.Parse(json);
-
- JArray? filesArray = jsonObject["files"] as JArray;
- if (filesArray == null || filesArray.Count == 0)
- {
- _logger.LogError(
- "Mod metadata response for {FileName} did not contain any files.",
- fileEntry.FileName);
-
- throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files.");
- }
-
- JObject? selectedFile = filesArray
- .OfType()
- .FirstOrDefault(file => file.Value("primary") == true)
- ?? filesArray.OfType().FirstOrDefault();
-
- if (selectedFile == null)
- {
- _logger.LogError(
- "Mod metadata response for {FileName} did not contain a usable file entry.",
- fileEntry.FileName);
-
- throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry.");
- }
-
- JObject? hashesObject = selectedFile["hashes"] as JObject;
- if (hashesObject == null)
- {
- _logger.LogError(
- "Mod metadata response for {FileName} did not contain a hashes object.",
- fileEntry.FileName);
-
- throw new InvalidDataException(
- $"The mod metadata response for '{fileEntry.FileName}' did not contain a hashes object.");
- }
-
- string? remoteSha512Hash = hashesObject.Value("sha512");
- if (string.IsNullOrWhiteSpace(remoteSha512Hash))
- {
- _logger.LogError(
- "Mod metadata response for {FileName} did not contain a valid SHA-512 hash.",
- fileEntry.FileName);
-
- throw new InvalidDataException(
- $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash.");
- }
-
- if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogError(
- "Mod metadata hash mismatch for {FileName}. Remote SHA-512: {RemoteHash}, Required SHA-512: {RequiredHash}",
- fileEntry.FileName,
- remoteSha512Hash,
- fileEntry.Sha512Hash);
-
- throw new InvalidDataException(
- $"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash.");
- }
-
- long? size = selectedFile.Value("size");
- if (!size.HasValue || size.Value <= 0)
- {
- _logger.LogError(
- "Mod metadata response for {FileName} did not contain a valid file size.",
- fileEntry.FileName);
-
- throw new InvalidDataException(
- $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
- }
-
- if (size.Value != fileEntry.Size)
- {
- _logger.LogError(
- "Mod metadata size mismatch for {FileName}. Remote Size: {RemoteSize}, Required Size: {RequiredSize}",
- fileEntry.FileName,
- size.Value,
- fileEntry.Size);
-
- throw new InvalidDataException(
- $"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size.");
- }
-
- string? modUrl = selectedFile.Value("url");
- if (string.IsNullOrWhiteSpace(modUrl))
- {
- _logger.LogError(
- "Mod metadata response for {FileName} did not contain a valid file URL.",
- fileEntry.FileName);
-
- throw new InvalidDataException(
- $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL.");
- }
-
- if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
- {
- _logger.LogError(
- "Mod metadata response for {FileName} contained an invalid file URL: {ModUrl}",
- fileEntry.FileName,
- modUrl);
-
- throw new InvalidDataException(
- $"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL.");
- }
-
- _logger.LogDebug(
- "Resolved download URL for mod {FileName} to {ModUrl}.",
- fileEntry.FileName,
- result);
-
- return result;
}
private string BuildVersionEndpoint(string sha512Hash)
diff --git a/AlayaCore/Utilities/Enums/LauncherErrorType.cs b/AlayaCore/Utilities/Enums/LauncherErrorType.cs
index dffa93c..c901cee 100644
--- a/AlayaCore/Utilities/Enums/LauncherErrorType.cs
+++ b/AlayaCore/Utilities/Enums/LauncherErrorType.cs
@@ -2,6 +2,15 @@ namespace AlayaCore.Utilities.Enums
{
public enum LauncherErrorType
{
-
+ Unknown,
+ Network,
+ Manifest,
+ Authentication,
+ Download,
+ Installation,
+ Update,
+ Launch,
+ Configuration,
+ Cancelled
}
}
\ No newline at end of file
diff --git a/AlayaCore/Utilities/Helpers/ErrorHelper.cs b/AlayaCore/Utilities/Helpers/ErrorHelper.cs
index 8ba56dd..2a4c941 100644
--- a/AlayaCore/Utilities/Helpers/ErrorHelper.cs
+++ b/AlayaCore/Utilities/Helpers/ErrorHelper.cs
@@ -1,7 +1,56 @@
-namespace AlayaCore.Utilities.Helpers
+using System;
+using System.IO;
+using System.Net.Http;
+using AlayaCore.Utilities.Enums;
+
+namespace AlayaCore.Errors
{
- public class ErrorHelper
+ public static class ErrorHelper
{
-
+ public static LauncherError Map(Exception exception)
+ {
+ if (exception == null)
+ {
+ throw new ArgumentNullException(nameof(exception));
+ }
+
+ return exception switch
+ {
+ OperationCanceledException => new LauncherError(
+ LauncherErrorType.Cancelled,
+ "The operation was cancelled.",
+ exception),
+
+ HttpRequestException => new LauncherError(
+ LauncherErrorType.Network,
+ "A network error occurred.",
+ exception),
+
+ IOException => new LauncherError(
+ LauncherErrorType.Network,
+ "A file or network I/O error occurred.",
+ exception),
+
+ InvalidDataException => new LauncherError(
+ LauncherErrorType.Manifest,
+ "Invalid or corrupt data was encountered.",
+ exception),
+
+ ArgumentException => new LauncherError(
+ LauncherErrorType.Configuration,
+ "Invalid configuration or input.",
+ exception),
+
+ InvalidOperationException => new LauncherError(
+ LauncherErrorType.Launch,
+ "The operation could not be completed due to an invalid state.",
+ exception),
+
+ _ => new LauncherError(
+ LauncherErrorType.Unknown,
+ "An unexpected error occurred.",
+ exception)
+ };
+ }
}
}
\ No newline at end of file
diff --git a/AlayaCore/Utilities/Helpers/OptionsHelper.cs b/AlayaCore/Utilities/Helpers/OptionsHelper.cs
index bdff83a..ac796b8 100644
--- a/AlayaCore/Utilities/Helpers/OptionsHelper.cs
+++ b/AlayaCore/Utilities/Helpers/OptionsHelper.cs
@@ -1,7 +1,21 @@
+using AlayaCore.Models.Configuration;
+
namespace AlayaCore.Utilities.Helpers
{
- public class OptionsHelper
+ public static class OptionsHelper
{
-
+ public static (LauncherUpdateServiceOptions LaunchUpdater, LauncherOptions Launcher,
+ GameOptions Game, ManifestServiceOptions Manifest, ModrinthConnectionOptions Modrinth,
+ RetryPolicyOptions RetryPolicy) GetDefaultOptions()
+ {
+ LauncherUpdateServiceOptions lso = LauncherUpdateServiceOptions.Default;
+ LauncherOptions lo = LauncherOptions.Default;
+ GameOptions go = GameOptions.Default;
+ ManifestServiceOptions mso = ManifestServiceOptions.Default;
+ ModrinthConnectionOptions mco = ModrinthConnectionOptions.Default;
+ RetryPolicyOptions rpo = RetryPolicyOptions.Default;
+
+ return (lso, lo, go, mso, mco, rpo);
+ }
}
}
\ No newline at end of file