Switched to Modrinth Version_File with Hash rather than ID and Version.

Implemented SettingsService
This commit is contained in:
2026-04-04 20:51:53 +01:00
parent 3298299764
commit c48f670eab
19 changed files with 550 additions and 181 deletions

View File

@@ -1,10 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Models;
using AlayaCore.States;
namespace AlayaCore.Abstractions.Interfaces
{
public interface ILaunchDirector
{
Task RunAsync(CancellationToken cancellationToken = default);
Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default);
Task InstallOrUpdateAsync(CancellationToken cancellationToken = default);
Task LaunchAsync(CancellationToken cancellationToken = default);
bool CanRun { get; }
bool NeedsUpdating { get; }
LaunchPlan? CurrentPlan { get; }
}
}

View File

@@ -1,7 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Installation;
using AlayaCore.Models.Manifests;
namespace AlayaCore.Abstractions.Interfaces.Services
{
public interface IGameLaunchServer
public interface IGameLaunchService
{
Task LaunchAsync(
ManifestModel manifest,
InstallEnvironment environment,
CancellationToken cancellationToken = default);
}
}

View File

@@ -1,7 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Models.Configuration;
namespace AlayaCore.Abstractions.Interfaces.Services
{
public interface ISettingsService
{
LauncherOptions LauncherOptions { get; }
Task SetForceReinstallAsync(bool value, CancellationToken cancellationToken = default);
Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default);
Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default);
}
}

View File

@@ -6,7 +6,7 @@ namespace AlayaCore.Installation
{
public sealed class InstallEnvironment
{
public OSPlatform OSPlatform { get; }
public OSPlatform OsPlatform { get; }
public bool JavaInstalled { get; }
public string? JavaVersion { get; }
public string? JavaPath { get; }
@@ -42,7 +42,7 @@ namespace AlayaCore.Installation
nameof(javaPath));
}
OSPlatform = osPlatform;
OsPlatform = osPlatform;
JavaInstalled = javaInstalled;
JavaPath = javaInstalled ? javaPath : null;
JavaVersion = javaInstalled ? javaVersion : null;

View File

@@ -1,11 +1,14 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
using AlayaCore.States;
namespace AlayaCore
{
@@ -16,22 +19,34 @@ namespace AlayaCore
private readonly IInstallStateService _installStateService;
private readonly IJavaService _javaService;
private readonly IModService _modService;
private readonly IGameLaunchService _gameLaunchService;
private readonly LauncherOptions _options;
public bool CanRun { get; private set; }
public bool NeedsUpdating { get; private set; }
public LaunchPlan? CurrentPlan { get; private set; }
public LaunchDirector(
IManifestService manifestService,
IUpdateService updateService,
IInstallStateService installStateService,
IJavaService javaService,
IModService modService)
IModService modService,
IGameLaunchService gameLaunchService,
LauncherOptions options)
{
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_updateService = updateService ?? throw new ArgumentNullException(nameof(updateService));
_installStateService = installStateService ?? throw new ArgumentNullException(nameof(installStateService));
_javaService = javaService ?? throw new ArgumentNullException(nameof(javaService));
_modService = modService ?? throw new ArgumentNullException(nameof(modService));
_gameLaunchService = gameLaunchService ?? throw new ArgumentNullException(nameof(gameLaunchService));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public async Task RunAsync(CancellationToken cancellationToken = default)
public async Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -41,17 +56,164 @@ namespace AlayaCore
if (launcherNeedsUpdate)
{
LauncherManifestModel launcherManifest = await _manifestService
.GetLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false);
LaunchPlan launcherUpdatePlan = new LaunchPlan(
launcherNeedsUpdate: true,
javaNeedsInstallOrUpdate: false,
minecraftNeedsInstallOrUpdate: false,
neoforgeNeedsInstallOrUpdate: false,
modsNeedSync: false);
await _updateService
.LaunchUpdaterAsync(launcherManifest, cancellationToken)
.ConfigureAwait(false);
return;
ApplyPlan(launcherUpdatePlan);
return launcherUpdatePlan;
}
ManifestModel manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
InstallEnvironment environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
bool javaNeedsInstallOrUpdate =
_options.ForceReinstall ||
!environment.JavaInstalled ||
!string.Equals(environment.JavaVersion, manifest.RequiredJavaVersion, StringComparison.OrdinalIgnoreCase);
bool minecraftNeedsInstallOrUpdate =
_options.ForceReinstall ||
!environment.MinecraftInstalled ||
!string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase);
bool neoforgeNeedsInstallOrUpdate =
_options.ForceReinstall ||
!environment.NeoforgedInstalled ||
!string.Equals(environment.NeoforgedVersion, manifest.NeoforgedVersion, StringComparison.OrdinalIgnoreCase);
bool modsNeedSync =
_options.ForceReinstall ||
DoModsNeedSync(manifest, environment);
LaunchPlan plan = new LaunchPlan(
launcherNeedsUpdate: false,
javaNeedsInstallOrUpdate: javaNeedsInstallOrUpdate,
minecraftNeedsInstallOrUpdate: minecraftNeedsInstallOrUpdate,
neoforgeNeedsInstallOrUpdate: neoforgeNeedsInstallOrUpdate,
modsNeedSync: modsNeedSync);
ApplyPlan(plan);
return plan;
}
public async Task InstallOrUpdateAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
LaunchPlan plan = await EvaluateAsync(cancellationToken).ConfigureAwait(false);
while (!plan.CanRun)
{
cancellationToken.ThrowIfCancellationRequested();
ManifestModel? manifest = null;
InstallEnvironment? environment = null;
switch (plan.State)
{
case LaunchState.LauncherNeedsUpdate:
{
LauncherManifestModel launcherManifest = await _manifestService
.GetLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false);
await _updateService
.LaunchUpdaterAsync(launcherManifest, cancellationToken)
.ConfigureAwait(false);
ApplyPlan(plan);
return;
}
case LaunchState.InstallJava:
{
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
await _javaService
.EnsureValidJavaInstalledAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false);
break;
}
case LaunchState.InstallMinecraft:
{
throw new NotImplementedException("Minecraft install/update flow has not been implemented yet.");
}
case LaunchState.InstallNeoforge:
{
throw new NotImplementedException("NeoForge install/update flow has not been implemented yet.");
}
case LaunchState.SyncMods:
{
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
await _modService
.ProcessModsAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false);
break;
}
case LaunchState.Ready:
{
break;
}
default:
{
throw new InvalidOperationException($"Unsupported launch state '{plan.State}'.");
}
}
plan = await EvaluateAsync(cancellationToken).ConfigureAwait(false);
}
}
public async Task LaunchAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (CurrentPlan == null)
{
await EvaluateAsync(cancellationToken).ConfigureAwait(false);
}
if (!CanRun)
{
throw new InvalidOperationException("Launcher cannot run because installation or updates are still required.");
}
InstallEnvironment environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
ManifestModel manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
await _gameLaunchService
.LaunchAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false);
}
private async Task<ManifestModel> EnsureCurrentManifestAsync(CancellationToken cancellationToken)
{
ManifestModel? localManifest = await _manifestService
.GetLocalCoreManifestAsync(cancellationToken)
.ConfigureAwait(false);
@@ -72,48 +234,61 @@ namespace AlayaCore
throw new FileNotFoundException("Local core manifest was not found after refresh.");
}
await ProcessManifestAsync(localManifest, cancellationToken).ConfigureAwait(false);
return localManifest;
}
private async Task ProcessManifestAsync(
ManifestModel manifest,
CancellationToken cancellationToken)
private static bool DoModsNeedSync(ManifestModel manifest, InstallEnvironment environment)
{
if (manifest == null)
{
throw new ArgumentNullException(nameof(manifest));
}
cancellationToken.ThrowIfCancellationRequested();
InstallEnvironment environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
if (!environment.JavaInstalled || environment.JavaVersion != manifest.RequiredJavaVersion)
if (environment == null)
{
await _javaService
.EnsureValidJavaInstalledAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false);
throw new ArgumentNullException(nameof(environment));
}
// Process Minecraft
if (!environment.MinecraftInstalled || environment.MinecraftVersion != manifest.MinecraftVersion)
{
}
// Process Neoforge
var requiredMods = manifest.Files
.Where(file => file.Type == AlayaCore.Utilities.Enums.FileType.Mod)
.ToList();
if (!environment.NeoforgedInstalled || environment.NeoforgedVersion != manifest.NeoforgedVersion)
var installedMods = environment.InstalledModsManifest.Mods;
if (requiredMods.Count != installedMods.Count)
{
return true;
}
// Process Mods
await _modService.ProcessModsAsync(manifest, environment, cancellationToken).ConfigureAwait(false);
foreach (ModFileEntry requiredMod in requiredMods)
{
InstalledModEntry? installedMod = installedMods.FirstOrDefault(
mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
if (installedMod == null)
{
return true;
}
if (!string.Equals(installedMod.Sha512Hash, requiredMod.Sha512Hash, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (installedMod.Size != requiredMod.Size)
{
return true;
}
}
return false;
}
private void ApplyPlan(LaunchPlan plan)
{
CurrentPlan = plan ?? throw new ArgumentNullException(nameof(plan));
NeedsUpdating = plan.NeedsUpdating;
CanRun = plan.CanRun;
}
}
}

View File

@@ -1,7 +1,7 @@
namespace AlayaCore.Models.Configuration
{
public class LauncherOptions
public sealed class LauncherOptions
{
public bool ForceReinstall { get; set; }
}
}

View File

@@ -1,7 +1,13 @@
namespace AlayaCore.Models.Configuration
{
public class UpdateServiceOptions
public sealed class LauncherUpdateServiceOptions
{
public LauncherUpdateServiceOptions(string alayaUpdaterPath, bool forceUpdate)
{
AlayaUpdaterPath = alayaUpdaterPath;
ForceUpdate = forceUpdate;
}
public string AlayaUpdaterPath { get; set; }
public bool ForceUpdate { get; set; }
}

View File

@@ -13,13 +13,10 @@ namespace AlayaCore.Models.Manifests.DTO
[JsonProperty("type", Required = Required.Always)]
public FileType Type { get; set; }
[JsonProperty("modrinthId", NullValueHandling = NullValueHandling.Ignore)]
public string? ModrinthId { get; set; }
[JsonProperty("modrinthVersionId", NullValueHandling = NullValueHandling.Ignore)]
public string? ModrinthVersionId { get; set; }
[JsonProperty("sha512Hash", Required = Required.Always)]
public string Sha512Hash { get; set; } = string.Empty;
[JsonProperty("size", Required = Required.Always)]
public long Size { get; set; }
}
}

View File

@@ -6,36 +6,27 @@ namespace AlayaCore.Models.Manifests
public sealed class InstalledModEntry
{
public string FileName { get; }
public string Version { get; }
public string Sha512Hash { get; }
public long Size { get; set; }
public long Size { get; }
public InstalledModEntry(string fileName, string version, string sha512Hash, long size)
public InstalledModEntry(string fileName, string sha512Hash, long size)
{
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("Name cannot be null, empty, or whitespace.", nameof(fileName));
}
if (string.IsNullOrWhiteSpace(version))
{
throw new ArgumentException("Version cannot be null, empty, or whitespace.", nameof(version));
throw new ArgumentException("File name cannot be null, empty, or whitespace.", nameof(fileName));
}
if (string.IsNullOrWhiteSpace(sha512Hash))
{
throw new ArgumentException("SHA512Hash cannot be null, empty, or whitespace.", nameof(sha512Hash));
throw new ArgumentException("SHA-512 hash cannot be null, empty, or whitespace.", nameof(sha512Hash));
}
if (size == 0)
if (size <= 0)
{
throw new ArgumentException("Size cannot be 0, empty, or whitespace.", nameof(size));
throw new ArgumentOutOfRangeException(nameof(size), "Size must be greater than zero.");
}
FileName = fileName;
Version = version;
Sha512Hash = sha512Hash;
Size = size;
}

View File

@@ -8,22 +8,20 @@ namespace AlayaCore.Models.Manifests
{
public string FileName { get; }
public FileType Type { get; }
public string? ModrinthId { get; }
public string? ModrinthVersionId { get; }
public string Sha512Hash { get; }
public long Size { get; }
public ModFileEntry(
string name,
FileType type,
string sha512Hash,
string? modrinthId = null,
string? modrinthVersionId = null)
long size)
{
FileName = RequireNonEmpty(name, nameof(name));
Type = type;
Sha512Hash = RequireNonEmpty(sha512Hash, nameof(sha512Hash));
ModrinthId = NormalizeOptional(modrinthId);
ModrinthVersionId = NormalizeOptional(modrinthVersionId);
Size = size;
}
private static string RequireNonEmpty(string value, string paramName)

View File

@@ -161,6 +161,7 @@ namespace AlayaCore.Services
await fileStream.FlushAsync(cancellationToken);
string actualHash = ConvertToLowerHex(sha512.Hash);
if (!string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(

View File

@@ -13,7 +13,7 @@ namespace AlayaCore.Services
{
public sealed class InstallationStateService : IInstallStateService
{
private const string JavaRuntimeFolderName = "java-runtime-epsilon";
private const string JAVA_RUNTIME_FOLDER_NAME = "java-runtime-epsilon";
private readonly IManifestService _manifestService;
@@ -140,7 +140,7 @@ namespace AlayaCore.Services
string fullPath = Path.Combine(
AppContext.BaseDirectory,
"Java",
JavaRuntimeFolderName,
JAVA_RUNTIME_FOLDER_NAME,
"bin",
executableName);

View File

@@ -10,9 +10,9 @@ namespace AlayaCore.Services
{
public sealed class JavaService : IJavaService
{
private const string DownloadFileName = "java-runtime.download";
private const string JavaInstallFolderName = "Java";
private const string JavaArchiveHashPlaceholder = "REPLACE_WITH_MANIFEST_HASH_SUPPORT";
private const string DOWNLOAD_FILE_NAME = "java-runtime.download";
private const string JAVA_INSTALL_FOLDER_NAME = "Java";
private const string JAVA_ARCHIVE_HASH_PLACEHOLDER = "REPLACE_WITH_MANIFEST_HASH_SUPPORT";
private readonly IDownloadService _downloadService;
@@ -69,12 +69,12 @@ namespace AlayaCore.Services
private static string GetJavaInstallDirectory()
{
return Path.Combine(AppContext.BaseDirectory, JavaInstallFolderName);
return Path.Combine(AppContext.BaseDirectory, JAVA_INSTALL_FOLDER_NAME);
}
private static string GetJavaDownloadPath()
{
return Path.Combine(AppContext.BaseDirectory, "Temp", DownloadFileName);
return Path.Combine(AppContext.BaseDirectory, "Temp", DOWNLOAD_FILE_NAME);
}
private static string ResolveJavaRootFolder(string javaExecutablePath)
@@ -144,7 +144,7 @@ namespace AlayaCore.Services
await _downloadService.DownloadFileAsync(
javaUri,
destinationPath,
JavaArchiveHashPlaceholder,
JAVA_ARCHIVE_HASH_PLACEHOLDER,
cancellationToken: cancellationToken).ConfigureAwait(false);
}

View File

@@ -12,11 +12,11 @@ namespace AlayaCore.Services
public sealed class LauncherUpdateService : IUpdateService
{
private readonly IManifestService _manifestService;
private readonly UpdateServiceOptions _options;
private readonly LauncherUpdateServiceOptions _options;
public LauncherUpdateService(
IManifestService manifestService,
UpdateServiceOptions options)
LauncherUpdateServiceOptions options)
{
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_options = options ?? throw new ArgumentNullException(nameof(options));

View File

@@ -15,9 +15,9 @@ namespace AlayaCore.Services
{
public sealed class ManifestService : IManifestService
{
private const string CoreManifestFileName = "CoreManifest.json";
private const string LauncherManifestFileName = "LauncherManifest.json";
private const string InstalledModsManifestFileName = "InstalledModsManifest.json";
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;
@@ -168,17 +168,17 @@ namespace AlayaCore.Services
public string GetLauncherManifestPath()
{
return Path.Combine(_options.ManifestDirectoryPath, LauncherManifestFileName);
return Path.Combine(_options.ManifestDirectoryPath, LAUNCHER_MANIFEST_FILE_NAME);
}
public string GetCoreManifestPath()
{
return Path.Combine(_options.ManifestDirectoryPath, CoreManifestFileName);
return Path.Combine(_options.ManifestDirectoryPath, CORE_MANIFEST_FILE_NAME);
}
public string GetInstalledModsManifestPath()
{
return Path.Combine(_options.ManifestDirectoryPath, InstalledModsManifestFileName);
return Path.Combine(_options.ManifestDirectoryPath, INSTALLED_MODS_MANIFEST_FILE_NAME);
}
private async Task<TModel?> LoadLocalManifestAsync<TDto, TModel>(

View File

@@ -19,19 +19,22 @@ namespace AlayaCore.Services
{
public sealed class ModService : IModService
{
private const string InstalledModsManifestFileName = "InstalledModsManifest.json";
private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
private readonly IDownloadService _downloadService;
private readonly ModrinthConnectionOptions _options;
private readonly ManifestServiceOptions _manifestOptions;
private readonly IHttpClient _httpClient;
public ModService(
IDownloadService downloadService,
ModrinthConnectionOptions options,
ManifestServiceOptions manifestOptions,
IHttpClient httpClient)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_manifestOptions = manifestOptions ?? throw new ArgumentNullException(nameof(manifestOptions));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
@@ -58,9 +61,8 @@ namespace AlayaCore.Services
.Where(file => file.Type == FileType.Mod)
.ToList();
await RemoveStaleModsAsync(requiredMods, cancellationToken).ConfigureAwait(false);
RemoveStaleMods(requiredMods);
List<ModFileEntry> missingOrOutdatedMods = new List<ModFileEntry>();
List<InstalledModEntry> finalInstalledMods = new List<InstalledModEntry>();
foreach (ModFileEntry requiredMod in requiredMods)
@@ -72,52 +74,30 @@ namespace AlayaCore.Services
string destinationPath = GetModDestinationPath(requiredMod);
if (installedMod == null)
bool isValidInstalledMod =
installedMod != null &&
IsInstalledModUpToDate(installedMod, requiredMod) &&
File.Exists(destinationPath) &&
_downloadService.VerifyFileHash(destinationPath, requiredMod.Sha512Hash);
if (isValidInstalledMod)
{
missingOrOutdatedMods.Add(requiredMod);
finalInstalledMods.Add(installedMod!);
continue;
}
if (!IsInstalledModUpToDate(installedMod, requiredMod))
{
missingOrOutdatedMods.Add(requiredMod);
continue;
}
if (!File.Exists(destinationPath))
{
missingOrOutdatedMods.Add(requiredMod);
continue;
}
bool hashMatches = _downloadService.VerifyFileHash(destinationPath, requiredMod.Sha512Hash);
if (!hashMatches)
{
missingOrOutdatedMods.Add(requiredMod);
continue;
}
finalInstalledMods.Add(installedMod);
}
foreach (ModFileEntry mod in missingOrOutdatedMods)
{
cancellationToken.ThrowIfCancellationRequested();
ModrinthModInfoModel model = await ResolveModUrlAsync(mod, cancellationToken).ConfigureAwait(false);
string destinationPath = GetModDestinationPath(mod);
ModrinthModInfoModel modInfo = await ResolveModUrlAsync(requiredMod, cancellationToken).ConfigureAwait(false);
await _downloadService.DownloadFileAsync(
model.ModUrl,
modInfo.ModUrl,
destinationPath,
mod.Sha512Hash,
requiredMod.Sha512Hash,
cancellationToken: cancellationToken).ConfigureAwait(false);
finalInstalledMods.Add(new InstalledModEntry(
mod.FileName,
mod.ModrinthVersionId ?? string.Empty,
mod.Sha512Hash,
model.Size));
requiredMod.FileName,
requiredMod.Sha512Hash,
modInfo.Size));
}
await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false);
@@ -137,24 +117,16 @@ namespace AlayaCore.Services
throw new ArgumentNullException(nameof(requiredMod));
}
if (string.IsNullOrWhiteSpace(requiredMod.ModrinthVersionId))
{
return false;
}
if (string.IsNullOrWhiteSpace(requiredMod.Sha512Hash))
{
return false;
}
return string.Equals(
installedMod.Version,
requiredMod.ModrinthVersionId,
StringComparison.OrdinalIgnoreCase)
&& string.Equals(
installedMod.Sha512Hash,
requiredMod.Sha512Hash,
StringComparison.OrdinalIgnoreCase);
StringComparison.OrdinalIgnoreCase)
&& installedMod.Size == requiredMod.Size;
}
private async Task<ModrinthModInfoModel> ResolveModUrlAsync(
@@ -166,17 +138,12 @@ namespace AlayaCore.Services
throw new ArgumentNullException(nameof(fileEntry));
}
if (string.IsNullOrWhiteSpace(fileEntry.ModrinthId))
if (string.IsNullOrWhiteSpace(fileEntry.Sha512Hash))
{
throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a Modrinth ID.");
throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a SHA-512 hash.");
}
if (string.IsNullOrWhiteSpace(fileEntry.ModrinthVersionId))
{
throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a Modrinth version ID.");
}
string versionEndpoint = BuildVersionEndpoint(fileEntry.ModrinthId, fileEntry.ModrinthVersionId);
string versionEndpoint = BuildVersionEndpoint(fileEntry.Sha512Hash);
using HttpResponseMessage response = await _httpClient.GetAsync(
new Uri(versionEndpoint, UriKind.Absolute),
@@ -189,7 +156,7 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json))
{
throw new InvalidDataException($"The Modrinth version response for '{fileEntry.FileName}' was empty.");
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty.");
}
JObject jsonObject = JObject.Parse(json);
@@ -197,7 +164,7 @@ namespace AlayaCore.Services
JArray? filesArray = jsonObject["files"] as JArray;
if (filesArray == null || filesArray.Count == 0)
{
throw new InvalidDataException($"The Modrinth version response for '{fileEntry.FileName}' did not contain any files.");
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files.");
}
JObject? selectedFile = filesArray
@@ -207,35 +174,57 @@ namespace AlayaCore.Services
if (selectedFile == null)
{
throw new InvalidDataException($"The Modrinth version 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;
if (hashesObject == null)
{
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))
{
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))
{
throw new InvalidDataException(
$"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash.");
}
string? modUrl = selectedFile.Value<string>("url");
if (string.IsNullOrWhiteSpace(modUrl))
{
throw new InvalidDataException($"The Modrinth version 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))
{
throw new InvalidDataException($"The Modrinth version 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");
if (!size.HasValue || size.Value <= 0)
{
throw new InvalidDataException($"The Modrinth version response for '{fileEntry.FileName}' did not contain a valid file size.");
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 modrinthId, string modrinthVersionId)
private string BuildVersionEndpoint(string sha512Hash)
{
string baseUrl = _options.BaseApiUrl?.TrimEnd('/')
?? throw new InvalidOperationException("Modrinth base API URL is not configured.");
return $"{baseUrl}/project/{modrinthId}/version/{modrinthVersionId}";
return $"{baseUrl}/version_file/{sha512Hash}";
}
private static string GetModDestinationPath(ModFileEntry fileEntry)
@@ -261,17 +250,13 @@ namespace AlayaCore.Services
return Path.Combine(AppContext.BaseDirectory, "Game", "mods");
}
private static async Task RemoveStaleModsAsync(
IEnumerable<ModFileEntry> requiredMods,
CancellationToken cancellationToken = default)
private static void RemoveStaleMods(IEnumerable<ModFileEntry> requiredMods)
{
if (requiredMods == null)
{
throw new ArgumentNullException(nameof(requiredMods));
}
cancellationToken.ThrowIfCancellationRequested();
string modsDirectory = GetModsDirectoryPath();
if (!Directory.Exists(modsDirectory))
{
@@ -285,8 +270,6 @@ namespace AlayaCore.Services
foreach (string filePath in Directory.GetFiles(modsDirectory))
{
cancellationToken.ThrowIfCancellationRequested();
string fileName = Path.GetFileName(filePath);
if (!requiredFileNames.Contains(fileName))
@@ -294,11 +277,9 @@ namespace AlayaCore.Services
File.Delete(filePath);
}
}
await Task.CompletedTask;
}
private static async Task FlushInstalledModsManifestAsync(
private async Task FlushInstalledModsManifestAsync(
IEnumerable<InstalledModEntry> installedMods,
CancellationToken cancellationToken = default)
{
@@ -308,13 +289,12 @@ namespace AlayaCore.Services
}
List<InstalledModEntry> entries = installedMods.ToList();
InstalledModsManifestModel manifest = new InstalledModsManifestModel(entries);
string manifestsDirectory = Path.Combine(AppContext.BaseDirectory, "Manifests");
string manifestsDirectory = _manifestOptions.ManifestDirectoryPath;
Directory.CreateDirectory(manifestsDirectory);
string manifestPath = Path.Combine(manifestsDirectory, InstalledModsManifestFileName);
string manifestPath = Path.Combine(manifestsDirectory, INSTALLED_MODS_MANIFEST_FILE_NAME);
string temporaryManifestPath = manifestPath + ".tmp";
string json = JsonConvert.SerializeObject(
@@ -323,7 +303,6 @@ namespace AlayaCore.Services
mods = manifest.Mods.Select(mod => new
{
fileName = mod.FileName,
version = mod.Version,
sha512Hash = mod.Sha512Hash,
size = mod.Size
})
@@ -332,8 +311,12 @@ namespace AlayaCore.Services
await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false);
File.Copy(temporaryManifestPath, manifestPath, overwrite: true);
File.Delete(temporaryManifestPath);
if (File.Exists(manifestPath))
{
File.Delete(manifestPath);
}
File.Move(temporaryManifestPath, manifestPath);
}
}
}

View File

@@ -1,7 +1,132 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Configuration;
using Newtonsoft.Json;
namespace AlayaCore.Services
{
public class SettingsService
public sealed class SettingsService : ISettingsService
{
private const string LauncherSettingsFileName = "Launcher.json";
public LauncherOptions LauncherOptions { get; }
public SettingsService(LauncherOptions launcherOptions)
{
LauncherOptions = launcherOptions ?? throw new ArgumentNullException(nameof(launcherOptions));
}
public async Task SetForceReinstallAsync(bool value, CancellationToken cancellationToken = default)
{
LauncherOptions.ForceReinstall = value;
await SaveLauncherOptionsAsync(cancellationToken).ConfigureAwait(false);
}
public async Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default)
{
await SaveAsync(
LauncherSettingsFileName,
LauncherOptions,
cancellationToken).ConfigureAwait(false);
}
public async Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default)
{
LauncherOptions? loadedOptions = await LoadAsync<LauncherOptions>(
LauncherSettingsFileName,
cancellationToken).ConfigureAwait(false);
if (loadedOptions == null)
{
return;
}
LauncherOptions.ForceReinstall = loadedOptions.ForceReinstall;
}
private async Task SaveAsync<T>(
string fileName,
T value,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("File name cannot be null, empty, or whitespace.", nameof(fileName));
}
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
cancellationToken.ThrowIfCancellationRequested();
string fullPath = GetFullPath(fileName);
string? directoryPath = Path.GetDirectoryName(fullPath);
if (string.IsNullOrWhiteSpace(directoryPath))
{
throw new InvalidOperationException("Could not resolve the settings directory path.");
}
Directory.CreateDirectory(directoryPath);
string temporaryPath = fullPath + ".tmp";
string json = JsonConvert.SerializeObject(value, Formatting.Indented);
await File.WriteAllTextAsync(temporaryPath, json, cancellationToken).ConfigureAwait(false);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
File.Move(temporaryPath, fullPath);
}
private async Task<T?> LoadAsync<T>(
string fileName,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("File name cannot be null, empty, or whitespace.", nameof(fileName));
}
cancellationToken.ThrowIfCancellationRequested();
string fullPath = GetFullPath(fileName);
if (!File.Exists(fullPath))
{
return default;
}
string json = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
return JsonConvert.DeserializeObject<T>(json);
}
catch (JsonException ex)
{
throw new InvalidDataException(
$"Failed to deserialize settings file '{fullPath}' to {typeof(T).Name}.",
ex);
}
}
private static string GetFullPath(string fileName)
{
return Path.Combine(AppContext.BaseDirectory, "Config", fileName);
}
}
}

View File

@@ -1,7 +1,74 @@
namespace AlayaCore.States
{
public class LaunchPlan
public enum LaunchState
{
Ready,
LauncherNeedsUpdate,
InstallJava,
InstallMinecraft,
InstallNeoforge,
SyncMods
}
public sealed class LaunchPlan
{
public bool LauncherNeedsUpdate { get; }
public bool JavaNeedsInstallOrUpdate { get; }
public bool MinecraftNeedsInstallOrUpdate { get; }
public bool NeoforgeNeedsInstallOrUpdate { get; }
public bool ModsNeedSync { get; }
public LaunchState State => ComputeState();
public bool CanRun =>
State == LaunchState.Ready;
public bool NeedsUpdating =>
State != LaunchState.Ready;
public LaunchPlan(
bool launcherNeedsUpdate,
bool javaNeedsInstallOrUpdate,
bool minecraftNeedsInstallOrUpdate,
bool neoforgeNeedsInstallOrUpdate,
bool modsNeedSync)
{
LauncherNeedsUpdate = launcherNeedsUpdate;
JavaNeedsInstallOrUpdate = javaNeedsInstallOrUpdate;
MinecraftNeedsInstallOrUpdate = minecraftNeedsInstallOrUpdate;
NeoforgeNeedsInstallOrUpdate = neoforgeNeedsInstallOrUpdate;
ModsNeedSync = modsNeedSync;
}
private LaunchState ComputeState()
{
// Priority order matters a LOT here
if (LauncherNeedsUpdate)
return LaunchState.LauncherNeedsUpdate;
if (JavaNeedsInstallOrUpdate)
return LaunchState.InstallJava;
if (MinecraftNeedsInstallOrUpdate)
return LaunchState.InstallMinecraft;
if (NeoforgeNeedsInstallOrUpdate)
return LaunchState.InstallNeoforge;
if (ModsNeedSync)
return LaunchState.SyncMods;
return LaunchState.Ready;
}
public static LaunchPlan EmptyReady()
{
return new LaunchPlan(
launcherNeedsUpdate: false,
javaNeedsInstallOrUpdate: false,
minecraftNeedsInstallOrUpdate: false,
neoforgeNeedsInstallOrUpdate: false,
modsNeedSync: false);
}
}
}

View File

@@ -25,7 +25,7 @@ namespace AlayaCore.Utilities.Extensions
throw new ArgumentNullException(nameof(dto));
}
return new InstalledModEntry(dto.FileName, dto.Version, dto.Sha512Hash, dto.Size);
return new InstalledModEntry(dto.FileName, dto.Sha512Hash, dto.Size);
}
public static LauncherManifestModel ToModel(this LauncherManifestDto dto)
@@ -67,8 +67,7 @@ namespace AlayaCore.Utilities.Extensions
dto.Name,
dto.Type,
dto.Sha512Hash,
dto.ModrinthId,
dto.ModrinthVersionId);
dto.Size);
}
public static ManifestDto ToDto(this ManifestModel model)
@@ -102,9 +101,7 @@ namespace AlayaCore.Utilities.Extensions
{
Name = model.FileName,
Type = model.Type,
Sha512Hash = model.Sha512Hash,
ModrinthId = model.ModrinthId,
ModrinthVersionId = model.ModrinthVersionId
Sha512Hash = model.Sha512Hash
};
}
}