Files
AlayaCore/AlayaCore/Services/ManifestService.cs
2026-04-04 20:51:53 +01:00

365 lines
13 KiB
C#

using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
using AlayaCore.Models.Manifests.DTO;
using AlayaCore.Utilities.Extensions;
using Newtonsoft.Json;
namespace AlayaCore.Services
{
public sealed class ManifestService : IManifestService
{
private const string CORE_MANIFEST_FILE_NAME = "CoreManifest.json";
private const string LAUNCHER_MANIFEST_FILE_NAME = "LauncherManifest.json";
private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
private readonly IDownloadService _downloadService;
private readonly IHttpClient _httpClient;
private readonly ManifestServiceOptions _options;
public ManifestService(
IDownloadService downloadService,
IHttpClient httpClient,
ManifestServiceOptions options)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public Task<ManifestModel> GetCoreManifestAsync(CancellationToken cancellationToken = default)
{
string destinationPath = GetCoreManifestPath();
return DownloadAndLoadManifestAsync<ManifestDto, ManifestModel>(
_options.CoreManifestUri,
destinationPath,
_options.CoreManifestSha512Hash,
static dto => dto.ToModel(),
cancellationToken);
}
public async Task<InstalledModsManifestModel> GetInstalledModsManifestAsync(CancellationToken cancellationToken = default)
{
string path = GetInstalledModsManifestPath();
if (!File.Exists(path))
{
return InstalledModsManifestModel.Empty();
}
string json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
return InstalledModsManifestModel.Empty();
}
InstalledModsManifestModel? manifest = DeserializeAndMapManifest<InstalledModsManifestDto, InstalledModsManifestModel>(
json,
path,
static dto => dto.ToModel(),
swallowDeserializationErrors: true);
return manifest ?? InstalledModsManifestModel.Empty();
}
public Task<LauncherManifestModel> GetLauncherManifestAsync(CancellationToken cancellationToken = default)
{
string destinationPath = GetLauncherManifestPath();
return DownloadAndLoadManifestAsync<LauncherManifestDto, LauncherManifestModel>(
_options.LauncherManifestUri,
destinationPath,
_options.LauncherManifestSha512Hash,
static dto => dto.ToModel(),
cancellationToken);
}
public Task<ManifestModel?> GetLocalCoreManifestAsync(CancellationToken cancellationToken = default)
{
return LoadLocalManifestAsync<ManifestDto, ManifestModel>(
GetCoreManifestPath(),
static dto => dto.ToModel(),
cancellationToken);
}
public Task<LauncherManifestModel?> GetLocalLauncherManifestAsync(CancellationToken cancellationToken = default)
{
return LoadLocalManifestAsync<LauncherManifestDto, LauncherManifestModel>(
GetLauncherManifestPath(),
static dto => dto.ToModel(),
cancellationToken);
}
public async Task<Version> GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using HttpResponseMessage response = await _httpClient.GetAsync(
_options.CoreManifestUri,
HttpCompletionOption.ResponseContentRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
throw new InvalidDataException(
$"Remote core manifest response from '{_options.CoreManifestUri}' was empty.");
}
ManifestModel remoteManifest = DeserializeAndMapManifest<ManifestDto, ManifestModel>(
json,
_options.CoreManifestUri.ToString(),
static dto => dto.ToModel(),
swallowDeserializationErrors: false)!;
if (remoteManifest.AlayaVersion == null)
{
throw new InvalidDataException(
$"Remote core manifest from '{_options.CoreManifestUri}' does not contain a valid version.");
}
return remoteManifest.AlayaVersion;
}
public async Task<string> GetRemoteLauncherManifestHashAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using HttpResponseMessage response = await _httpClient.GetAsync(
_options.LauncherManifestUri,
HttpCompletionOption.ResponseContentRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
throw new InvalidDataException(
$"Remote launcher manifest response from '{_options.LauncherManifestUri}' was empty.");
}
LauncherManifestModel remoteManifest = DeserializeAndMapManifest<LauncherManifestDto, LauncherManifestModel>(
json,
_options.LauncherManifestUri.ToString(),
static dto => dto.ToModel(),
swallowDeserializationErrors: false)!;
if (string.IsNullOrWhiteSpace(remoteManifest.Sha512Hash))
{
throw new InvalidDataException(
$"Remote launcher manifest from '{_options.LauncherManifestUri}' does not contain a valid SHA-512 hash.");
}
return remoteManifest.Sha512Hash.Trim();
}
public string GetLauncherManifestPath()
{
return Path.Combine(_options.ManifestDirectoryPath, LAUNCHER_MANIFEST_FILE_NAME);
}
public string GetCoreManifestPath()
{
return Path.Combine(_options.ManifestDirectoryPath, CORE_MANIFEST_FILE_NAME);
}
public string GetInstalledModsManifestPath()
{
return Path.Combine(_options.ManifestDirectoryPath, INSTALLED_MODS_MANIFEST_FILE_NAME);
}
private async Task<TModel?> LoadLocalManifestAsync<TDto, TModel>(
string path,
Func<TDto, TModel> map,
CancellationToken cancellationToken)
where TDto : class
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Manifest path cannot be null, empty, or whitespace.", nameof(path));
}
if (map == null)
{
throw new ArgumentNullException(nameof(map));
}
if (!File.Exists(path))
{
return default;
}
string json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
return DeserializeAndMapManifest<TDto, TModel>(
json,
path,
map,
swallowDeserializationErrors: true);
}
private async Task<TModel> DownloadAndLoadManifestAsync<TDto, TModel>(
Uri manifestUri,
string destinationPath,
string sha512Hash,
Func<TDto, TModel> map,
CancellationToken cancellationToken)
where TDto : class
{
if (manifestUri == null)
{
throw new ArgumentNullException(nameof(manifestUri));
}
if (!manifestUri.IsAbsoluteUri)
{
throw new ArgumentException("Manifest URI must be absolute.", nameof(manifestUri));
}
if (string.IsNullOrWhiteSpace(destinationPath))
{
throw new ArgumentException("Destination path cannot be null, empty, or whitespace.", nameof(destinationPath));
}
if (string.IsNullOrWhiteSpace(sha512Hash))
{
throw new ArgumentException("SHA-512 hash cannot be null, empty, or whitespace.", nameof(sha512Hash));
}
if (map == null)
{
throw new ArgumentNullException(nameof(map));
}
cancellationToken.ThrowIfCancellationRequested();
EnsureDirectoryExists(destinationPath);
await _downloadService.DownloadFileAsync(
manifestUri,
destinationPath,
sha512Hash,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!File.Exists(destinationPath))
{
throw new FileNotFoundException("Manifest file was not found after download.", destinationPath);
}
string json = await File.ReadAllTextAsync(destinationPath, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
throw new InvalidDataException($"Manifest file '{destinationPath}' was empty.");
}
return DeserializeAndMapManifest<TDto, TModel>(
json,
destinationPath,
map,
swallowDeserializationErrors: false)!;
}
private static TModel? DeserializeAndMapManifest<TDto, TModel>(
string json,
string sourceName,
Func<TDto, TModel> map,
bool swallowDeserializationErrors)
where TDto : class
{
if (string.IsNullOrWhiteSpace(json))
{
if (swallowDeserializationErrors)
{
return default;
}
throw new InvalidDataException($"Manifest source '{sourceName}' was empty.");
}
if (string.IsNullOrWhiteSpace(sourceName))
{
throw new ArgumentException("Source name cannot be null, empty, or whitespace.", nameof(sourceName));
}
if (map == null)
{
throw new ArgumentNullException(nameof(map));
}
TDto? dto;
try
{
dto = JsonConvert.DeserializeObject<TDto>(json);
}
catch (JsonException ex)
{
if (swallowDeserializationErrors)
{
return default;
}
throw new JsonSerializationException(
$"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.",
ex);
}
if (dto == null)
{
if (swallowDeserializationErrors)
{
return default;
}
throw new JsonSerializationException(
$"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.");
}
try
{
return map(dto);
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
if (swallowDeserializationErrors)
{
return default;
}
throw new InvalidDataException(
$"Manifest source '{sourceName}' was deserialized but could not be mapped to {typeof(TModel).Name}.",
ex);
}
}
private static void EnsureDirectoryExists(string filePath)
{
string? directoryPath = Path.GetDirectoryName(filePath);
if (string.IsNullOrWhiteSpace(directoryPath))
{
throw new InvalidOperationException("A valid destination directory is required.");
}
Directory.CreateDirectory(directoryPath);
}
}
}