Made Manifest Size source of truth.

- Updated ModService->Resolve... to return Uri.
- Removed ModrinthModInfoModel as no longer needed.
This commit is contained in:
2026-04-04 21:21:50 +01:00
parent ef135b1164
commit 823ccf4b87
6 changed files with 91 additions and 63 deletions

View File

@@ -262,7 +262,7 @@ namespace AlayaCore
foreach (ModFileEntry requiredMod in requiredMods) foreach (ModFileEntry requiredMod in requiredMods)
{ {
InstalledModEntry? installedMod = installedMods.FirstOrDefault( ModFileEntry? installedMod = installedMods.FirstOrDefault(
mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase)); mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
if (installedMod == null) if (installedMod == null)

View File

@@ -5,7 +5,12 @@ namespace AlayaCore.Models.Manifests.DTO
{ {
public class InstalledModsManifestDto public class InstalledModsManifestDto
{ {
public InstalledModsManifestDto(List<ModFileEntryDto> installedMods)
{
InstalledMods = installedMods;
}
[JsonProperty("installedMods")] [JsonProperty("installedMods")]
public List<InstalledModEntryDto> InstalledMods { get; private set; } public List<ModFileEntryDto> InstalledMods { get; private set; }
} }
} }

View File

@@ -7,33 +7,33 @@ namespace AlayaCore.Models.Manifests
{ {
public sealed class InstalledModsManifestModel public sealed class InstalledModsManifestModel
{ {
public IReadOnlyList<InstalledModEntry> Mods { get; } public IReadOnlyList<ModFileEntry> Mods { get; }
public InstalledModsManifestModel() public InstalledModsManifestModel()
{ {
Mods = Array.Empty<InstalledModEntry>(); Mods = Array.Empty<ModFileEntry>();
} }
public InstalledModsManifestModel(IEnumerable<InstalledModEntry> mods) public InstalledModsManifestModel(IEnumerable<ModFileEntry> mods)
{ {
if (mods == null) if (mods == null)
{ {
throw new ArgumentNullException(nameof(mods)); throw new ArgumentNullException(nameof(mods));
} }
InstalledModEntry[] array = mods.ToArray(); ModFileEntry[] array = mods.ToArray();
if (array.Any(mod => mod == null)) if (array.Any(mod => mod == null))
{ {
throw new ArgumentException("Mods cannot contain null entries.", nameof(mods)); throw new ArgumentException("Mods cannot contain null entries.", nameof(mods));
} }
Mods = new ReadOnlyCollection<InstalledModEntry>(array); Mods = new ReadOnlyCollection<ModFileEntry>(array);
} }
public static InstalledModsManifestModel Empty() public static InstalledModsManifestModel Empty()
{ {
return new InstalledModsManifestModel(Array.Empty<InstalledModEntry>()); return new InstalledModsManifestModel(Array.Empty<ModFileEntry>());
} }
} }
} }

View File

@@ -9,19 +9,18 @@ namespace AlayaCore.Models.Manifests
public string FileName { get; } public string FileName { get; }
public FileType Type { get; } public FileType Type { get; }
public string Sha512Hash { get; } public string Sha512Hash { get; }
public long Size { get; } public long Size { get; }
public ModFileEntry( public ModFileEntry(
string name, string fileName,
FileType type, FileType type,
string sha512Hash, string sha512Hash,
long size) long size)
{ {
FileName = RequireNonEmpty(name, nameof(name)); FileName = RequireNonEmpty(fileName, nameof(fileName));
Type = type; Type = type;
Sha512Hash = RequireNonEmpty(sha512Hash, nameof(sha512Hash)); Sha512Hash = RequireSha512Hash(sha512Hash, nameof(sha512Hash));
Size = size; Size = RequirePositiveSize(size, nameof(size));
} }
private static string RequireNonEmpty(string value, string paramName) private static string RequireNonEmpty(string value, string paramName)
@@ -34,9 +33,29 @@ namespace AlayaCore.Models.Manifests
return value; return value;
} }
private static string? NormalizeOptional(string? value) private static string RequireSha512Hash(string value, string paramName)
{ {
return string.IsNullOrWhiteSpace(value) ? null : value; string normalized = RequireNonEmpty(value, paramName)
.Trim()
.Replace("-", string.Empty)
.ToLowerInvariant();
if (normalized.Length != 128)
{
throw new ArgumentException("SHA-512 hash must be 128 hexadecimal characters long.", paramName);
}
return normalized;
}
private static long RequirePositiveSize(long value, string paramName)
{
if (value <= 0)
{
throw new ArgumentOutOfRangeException(paramName, "Size must be greater than zero.");
}
return value;
} }
} }
} }

View File

@@ -11,7 +11,9 @@ using AlayaCore.Installation;
using AlayaCore.Models; using AlayaCore.Models;
using AlayaCore.Models.Configuration; using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.Models.Manifests.DTO;
using AlayaCore.Utilities.Enums; using AlayaCore.Utilities.Enums;
using AlayaCore.Utilities.Extensions;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -19,7 +21,7 @@ namespace AlayaCore.Services
{ {
public sealed class ModService : IModService public sealed class ModService : IModService
{ {
private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json"; private const string InstalledModsManifestFileName = "InstalledModsManifest.json";
private readonly IDownloadService _downloadService; private readonly IDownloadService _downloadService;
private readonly ModrinthConnectionOptions _options; private readonly ModrinthConnectionOptions _options;
@@ -55,7 +57,7 @@ namespace AlayaCore.Services
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<InstalledModEntry> installedMods = environment.InstalledModsManifest.Mods; IReadOnlyList<ModFileEntry> installedMods = environment.InstalledModsManifest.Mods;
List<ModFileEntry> requiredMods = manifest.Files List<ModFileEntry> requiredMods = manifest.Files
.Where(file => file.Type == FileType.Mod) .Where(file => file.Type == FileType.Mod)
@@ -63,13 +65,13 @@ namespace AlayaCore.Services
RemoveStaleMods(requiredMods); RemoveStaleMods(requiredMods);
List<InstalledModEntry> finalInstalledMods = new List<InstalledModEntry>(); List<ModFileEntry> finalInstalledMods = new List<ModFileEntry>();
foreach (ModFileEntry requiredMod in requiredMods) foreach (ModFileEntry requiredMod in requiredMods)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
InstalledModEntry? installedMod = installedMods.FirstOrDefault( ModFileEntry? installedMod = installedMods.FirstOrDefault(
mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase)); mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
string destinationPath = GetModDestinationPath(requiredMod); string destinationPath = GetModDestinationPath(requiredMod);
@@ -86,25 +88,26 @@ namespace AlayaCore.Services
continue; continue;
} }
ModrinthModInfoModel modInfo = await ResolveModUrlAsync(requiredMod, cancellationToken).ConfigureAwait(false); Uri modUri = await ResolveModUrlAsync(requiredMod, cancellationToken).ConfigureAwait(false);
await _downloadService.DownloadFileAsync( await _downloadService.DownloadFileAsync(
modInfo.ModUrl, modUri,
destinationPath, destinationPath,
requiredMod.Sha512Hash, requiredMod.Sha512Hash,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
finalInstalledMods.Add(new InstalledModEntry( finalInstalledMods.Add(new ModFileEntry(
requiredMod.FileName, requiredMod.FileName,
requiredMod.Type,
requiredMod.Sha512Hash, requiredMod.Sha512Hash,
modInfo.Size)); requiredMod.Size));
} }
await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false); await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false);
} }
private static bool IsInstalledModUpToDate( private static bool IsInstalledModUpToDate(
InstalledModEntry installedMod, ModFileEntry installedMod,
ModFileEntry requiredMod) ModFileEntry requiredMod)
{ {
if (installedMod == null) if (installedMod == null)
@@ -126,10 +129,11 @@ namespace AlayaCore.Services
installedMod.Sha512Hash, installedMod.Sha512Hash,
requiredMod.Sha512Hash, requiredMod.Sha512Hash,
StringComparison.OrdinalIgnoreCase) StringComparison.OrdinalIgnoreCase)
&& installedMod.Size == requiredMod.Size; && installedMod.Size == requiredMod.Size
&& installedMod.Type == requiredMod.Type;
} }
private async Task<ModrinthModInfoModel> ResolveModUrlAsync( private async Task<Uri> ResolveModUrlAsync(
ModFileEntry fileEntry, ModFileEntry fileEntry,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -168,17 +172,16 @@ namespace AlayaCore.Services
} }
JObject? selectedFile = filesArray JObject? selectedFile = filesArray
.OfType<JObject>() .OfType<JObject>()
.FirstOrDefault(file => file.Value<bool?>("primary") == true) .FirstOrDefault(file => file.Value<bool?>("primary") == true)
?? filesArray.OfType<JObject>().FirstOrDefault(); ?? filesArray.OfType<JObject>().FirstOrDefault();
if (selectedFile == null) if (selectedFile == null)
{ {
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry."); throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry.");
} }
JObject? hashesObject = selectedFile["hashes"] as JObject; JObject? hashesObject = selectedFile["hashes"] as JObject;
if (hashesObject == null) if (hashesObject == null)
{ {
throw new InvalidDataException( throw new InvalidDataException(
@@ -186,7 +189,6 @@ namespace AlayaCore.Services
} }
string? remoteSha512Hash = hashesObject.Value<string>("sha512"); string? remoteSha512Hash = hashesObject.Value<string>("sha512");
if (string.IsNullOrWhiteSpace(remoteSha512Hash)) if (string.IsNullOrWhiteSpace(remoteSha512Hash))
{ {
throw new InvalidDataException( throw new InvalidDataException(
@@ -199,30 +201,39 @@ namespace AlayaCore.Services
$"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash."); $"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)
{
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
}
if (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"); string? modUrl = selectedFile.Value<string>("url");
if (string.IsNullOrWhiteSpace(modUrl)) if (string.IsNullOrWhiteSpace(modUrl))
{ {
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL."); 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)) if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
{ {
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL."); throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL.");
} }
long? size = selectedFile.Value<long?>("size"); return result;
if (!size.HasValue || size.Value <= 0)
{
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
}
return new ModrinthModInfoModel(result, size.Value);
} }
private string BuildVersionEndpoint(string sha512Hash) private string BuildVersionEndpoint(string sha512Hash)
{ {
string baseUrl = _options.BaseApiUrl?.TrimEnd('/') string baseUrl = _options.BaseApiUrl?.TrimEnd('/')
?? throw new InvalidOperationException("Modrinth base API URL is not configured."); ?? throw new InvalidOperationException("Modrinth base API URL is not configured.");
return $"{baseUrl}/version_file/{sha512Hash}"; return $"{baseUrl}/version_file/{sha512Hash}";
} }
@@ -280,7 +291,7 @@ namespace AlayaCore.Services
} }
private async Task FlushInstalledModsManifestAsync( private async Task FlushInstalledModsManifestAsync(
IEnumerable<InstalledModEntry> installedMods, IEnumerable<ModFileEntry> installedMods,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (installedMods == null) if (installedMods == null)
@@ -288,26 +299,18 @@ namespace AlayaCore.Services
throw new ArgumentNullException(nameof(installedMods)); throw new ArgumentNullException(nameof(installedMods));
} }
List<InstalledModEntry> entries = installedMods.ToList(); List<ModFileEntry> entries = installedMods.ToList();
InstalledModsManifestModel manifest = new InstalledModsManifestModel(entries); InstalledModsManifestModel manifest = new InstalledModsManifestModel(entries);
string manifestsDirectory = _manifestOptions.ManifestDirectoryPath; string manifestsDirectory = _manifestOptions.ManifestDirectoryPath;
Directory.CreateDirectory(manifestsDirectory); Directory.CreateDirectory(manifestsDirectory);
string manifestPath = Path.Combine(manifestsDirectory, INSTALLED_MODS_MANIFEST_FILE_NAME); string manifestPath = Path.Combine(manifestsDirectory, InstalledModsManifestFileName);
string temporaryManifestPath = manifestPath + ".tmp"; string temporaryManifestPath = manifestPath + ".tmp";
string json = JsonConvert.SerializeObject( InstalledModsManifestDto dto = manifest.ToDto();
new
{ string json = JsonConvert.SerializeObject(dto, Formatting.Indented);
mods = manifest.Mods.Select(mod => new
{
fileName = mod.FileName,
sha512Hash = mod.Sha512Hash,
size = mod.Size
})
},
Formatting.Indented);
await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false); await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false);

View File

@@ -15,17 +15,17 @@ namespace AlayaCore.Utilities.Extensions
} }
return new InstalledModsManifestModel( return new InstalledModsManifestModel(
dto.InstalledMods?.Select(mod => mod.ToModel()) ?? Array.Empty<InstalledModEntry>()); dto.InstalledMods?.Select(mod => mod.ToModel()) ?? Array.Empty<ModFileEntry>());
} }
public static InstalledModEntry ToModel(this InstalledModEntryDto dto) public static InstalledModsManifestDto ToDto(this InstalledModsManifestModel model)
{ {
if (dto == null) if (model == null)
{ {
throw new ArgumentNullException(nameof(dto)); throw new ArgumentNullException(nameof(model));
} }
return new InstalledModEntry(dto.FileName, dto.Sha512Hash, dto.Size); return new InstalledModsManifestDto(model.Mods.Select(mod => mod.ToDto()).ToList());
} }
public static LauncherManifestModel ToModel(this LauncherManifestDto dto) public static LauncherManifestModel ToModel(this LauncherManifestDto dto)
@@ -101,7 +101,8 @@ namespace AlayaCore.Utilities.Extensions
{ {
Name = model.FileName, Name = model.FileName,
Type = model.Type, Type = model.Type,
Sha512Hash = model.Sha512Hash Sha512Hash = model.Sha512Hash,
Size = model.Size
}; };
} }
} }