478 lines
19 KiB
C#
478 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
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;
|
|
using AlayaCore.Models.Configuration;
|
|
using AlayaCore.Models.Manifests;
|
|
using AlayaCore.Models.Manifests.DTO;
|
|
using AlayaCore.Models.Progress;
|
|
using AlayaCore.Utilities.Enums;
|
|
using AlayaCore.Utilities.Extensions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace AlayaCore.Services
|
|
{
|
|
public sealed class ModService : IModService
|
|
{
|
|
private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
|
|
|
|
private readonly IDownloadService _downloadService;
|
|
private readonly ModrinthConnectionOptions _options;
|
|
private readonly IHttpClient _httpClient;
|
|
private readonly IFileStore _fileStore;
|
|
private readonly IRetryPolicy _retryPolicy;
|
|
private readonly ILogger<ModService> _logger;
|
|
|
|
public ModService(
|
|
IDownloadService downloadService,
|
|
ModrinthConnectionOptions options,
|
|
IHttpClient httpClient,
|
|
IFileStore fileStore,
|
|
IRetryPolicy retryPolicy,
|
|
ILogger<ModService> 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));
|
|
}
|
|
|
|
public async Task ProcessModsAsync(
|
|
ManifestModel manifest,
|
|
InstallEnvironment environment,
|
|
IProgress<DownloadProgress>? progress = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (manifest == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(manifest));
|
|
}
|
|
|
|
if (environment == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(environment));
|
|
}
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
IReadOnlyList<ModFileEntry> installedMods = environment.InstalledModsManifest.Mods;
|
|
|
|
List<ModFileEntry> requiredMods = manifest.Files
|
|
.Where(file => file.Type == FileType.Mod)
|
|
.OrderBy(file => file.FileName, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
_logger.LogInformation(
|
|
"Starting mod sync. RequiredMods: {RequiredModCount}, InstalledModsManifestEntries: {InstalledModCount}",
|
|
requiredMods.Count,
|
|
installedMods.Count);
|
|
|
|
RemoveStaleMods(requiredMods, cancellationToken);
|
|
|
|
List<ModFileEntry> finalInstalledMods = new List<ModFileEntry>();
|
|
|
|
foreach (ModFileEntry requiredMod in requiredMods)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
_logger.LogDebug(
|
|
"Processing required mod {FileName}. Expected SHA-512: {Sha512Hash}, Expected Size: {Size}",
|
|
requiredMod.FileName,
|
|
requiredMod.Sha512Hash,
|
|
requiredMod.Size);
|
|
|
|
ModFileEntry? installedMod = installedMods.FirstOrDefault(
|
|
mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
|
|
|
|
string destinationPath = GetModDestinationPath(requiredMod);
|
|
|
|
bool isValidInstalledMod =
|
|
installedMod != null &&
|
|
IsInstalledModUpToDate(installedMod, requiredMod) &&
|
|
File.Exists(destinationPath) &&
|
|
_downloadService.VerifyFileHash(destinationPath, requiredMod.Sha512Hash);
|
|
|
|
if (isValidInstalledMod)
|
|
{
|
|
_logger.LogInformation(
|
|
"Mod {FileName} is already installed and valid. Skipping download.",
|
|
requiredMod.FileName);
|
|
|
|
finalInstalledMods.Add(installedMod!);
|
|
continue;
|
|
}
|
|
|
|
if (installedMod == null)
|
|
{
|
|
_logger.LogInformation(
|
|
"Mod {FileName} is missing locally and will be downloaded.",
|
|
requiredMod.FileName);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"Mod {FileName} is present but invalid or outdated. Stored SHA-512: {InstalledHash}, Expected SHA-512: {RequiredHash}, Stored Size: {InstalledSize}, Expected Size: {RequiredSize}",
|
|
requiredMod.FileName,
|
|
installedMod.Sha512Hash,
|
|
requiredMod.Sha512Hash,
|
|
installedMod.Size,
|
|
requiredMod.Size);
|
|
}
|
|
|
|
Uri modUri = await ResolveModUrlAsync(requiredMod, cancellationToken).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation(
|
|
"Downloading mod {FileName} from {ModUri} to {DestinationPath}.",
|
|
requiredMod.FileName,
|
|
modUri,
|
|
destinationPath);
|
|
|
|
await _downloadService.DownloadFileAsync(
|
|
modUri,
|
|
destinationPath,
|
|
requiredMod.Sha512Hash,
|
|
progress,
|
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation(
|
|
"Download completed successfully for mod {FileName}.",
|
|
requiredMod.FileName);
|
|
|
|
finalInstalledMods.Add(new ModFileEntry(
|
|
requiredMod.FileName,
|
|
requiredMod.Type,
|
|
requiredMod.Sha512Hash,
|
|
requiredMod.Size));
|
|
}
|
|
|
|
await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation(
|
|
"Mod sync completed successfully. Final installed mod count: {InstalledModCount}",
|
|
finalInstalledMods.Count);
|
|
}
|
|
|
|
private static bool IsInstalledModUpToDate(
|
|
ModFileEntry installedMod,
|
|
ModFileEntry requiredMod)
|
|
{
|
|
if (installedMod == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(installedMod));
|
|
}
|
|
|
|
if (requiredMod == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(requiredMod));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(requiredMod.Sha512Hash))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return string.Equals(
|
|
installedMod.Sha512Hash,
|
|
requiredMod.Sha512Hash,
|
|
StringComparison.OrdinalIgnoreCase)
|
|
&& installedMod.Size == requiredMod.Size
|
|
&& installedMod.Type == requiredMod.Type;
|
|
}
|
|
|
|
private async Task<Uri> ResolveModUrlAsync(
|
|
ModFileEntry fileEntry,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (fileEntry == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(fileEntry));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(fileEntry.Sha512Hash))
|
|
{
|
|
_logger.LogError(
|
|
"Failed to resolve mod URL because mod {FileName} did not contain a SHA-512 hash.",
|
|
fileEntry.FileName);
|
|
|
|
throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a SHA-512 hash.");
|
|
}
|
|
|
|
string versionEndpoint = BuildVersionEndpoint(fileEntry.Sha512Hash);
|
|
|
|
_logger.LogDebug(
|
|
"Resolving mod URL for {FileName} using Modrinth endpoint {VersionEndpoint}.",
|
|
fileEntry.FileName,
|
|
versionEndpoint);
|
|
|
|
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<JObject>()
|
|
.FirstOrDefault(file => file.Value<bool?>("primary") == true)
|
|
?? filesArray.OfType<JObject>().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<string>("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<long?>("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<string>("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);
|
|
}
|
|
|
|
private string BuildVersionEndpoint(string sha512Hash)
|
|
{
|
|
string baseUrl = _options.BaseApiUrl?.TrimEnd('/')
|
|
?? throw new InvalidOperationException("Modrinth base API URL is not configured.");
|
|
|
|
return $"{baseUrl}/version_file/{sha512Hash}";
|
|
}
|
|
|
|
private string GetModDestinationPath(ModFileEntry fileEntry)
|
|
{
|
|
if (fileEntry == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(fileEntry));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(fileEntry.FileName))
|
|
{
|
|
throw new ArgumentException("Mod file name cannot be null, empty, or whitespace.", nameof(fileEntry));
|
|
}
|
|
|
|
string modsDirectory = GetModsDirectoryPath();
|
|
string destinationPath = Path.Combine(modsDirectory, fileEntry.FileName);
|
|
|
|
_logger.LogDebug(
|
|
"Resolved destination path for mod {FileName} to {DestinationPath}.",
|
|
fileEntry.FileName,
|
|
destinationPath);
|
|
|
|
return destinationPath;
|
|
}
|
|
|
|
private string GetModsDirectoryPath()
|
|
{
|
|
string modsDirectory = _fileStore.GetOrCreate(FolderLocation.Mods);
|
|
_logger.LogDebug("Resolved mods directory to {ModsDirectory}.", modsDirectory);
|
|
return modsDirectory;
|
|
}
|
|
|
|
private void RemoveStaleMods(
|
|
IEnumerable<ModFileEntry> requiredMods,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (requiredMods == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(requiredMods));
|
|
}
|
|
|
|
string modsDirectory = _fileStore.Get(FolderLocation.Mods);
|
|
|
|
if (!Directory.Exists(modsDirectory))
|
|
{
|
|
_logger.LogDebug("Mods directory {ModsDirectory} does not exist. No stale mods need removal.", modsDirectory);
|
|
return;
|
|
}
|
|
|
|
HashSet<string> requiredFileNames = requiredMods
|
|
.Where(mod => !string.IsNullOrWhiteSpace(mod.FileName))
|
|
.Select(mod => mod.FileName)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (string filePath in Directory.GetFiles(modsDirectory))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
string fileName = Path.GetFileName(filePath);
|
|
|
|
if (!requiredFileNames.Contains(fileName))
|
|
{
|
|
_logger.LogInformation(
|
|
"Removing stale mod file {FileName} at {FilePath}.",
|
|
fileName,
|
|
filePath);
|
|
|
|
File.Delete(filePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task FlushInstalledModsManifestAsync(
|
|
IEnumerable<ModFileEntry> installedMods,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (installedMods == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(installedMods));
|
|
}
|
|
|
|
List<ModFileEntry> entries = installedMods.ToList();
|
|
InstalledModsManifestModel manifest = new InstalledModsManifestModel(entries);
|
|
|
|
string manifestsDirectory = _fileStore.GetOrCreate(FolderLocation.Manifests);
|
|
|
|
string manifestPath = Path.Combine(manifestsDirectory, INSTALLED_MODS_MANIFEST_FILE_NAME);
|
|
string temporaryManifestPath = manifestPath + ".tmp";
|
|
|
|
InstalledModsManifestDto dto = manifest.ToDto();
|
|
|
|
string json = JsonConvert.SerializeObject(dto, Formatting.Indented);
|
|
|
|
_logger.LogDebug(
|
|
"Writing installed mods manifest to temporary path {TemporaryManifestPath}. EntryCount: {EntryCount}",
|
|
temporaryManifestPath,
|
|
entries.Count);
|
|
|
|
await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (File.Exists(manifestPath))
|
|
{
|
|
_logger.LogDebug("Deleting previous installed mods manifest at {ManifestPath}.", manifestPath);
|
|
File.Delete(manifestPath);
|
|
}
|
|
|
|
File.Move(temporaryManifestPath, manifestPath);
|
|
|
|
_logger.LogInformation(
|
|
"Installed mods manifest updated successfully at {ManifestPath}. EntryCount: {EntryCount}",
|
|
manifestPath,
|
|
entries.Count);
|
|
}
|
|
}
|
|
} |