diff --git a/AlayaCore/Abstractions/Interfaces/ILaunchDirector.cs b/AlayaCore/Abstractions/Interfaces/ILaunchDirector.cs index 9204965..d7da1ae 100644 --- a/AlayaCore/Abstractions/Interfaces/ILaunchDirector.cs +++ b/AlayaCore/Abstractions/Interfaces/ILaunchDirector.cs @@ -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 EvaluateAsync(CancellationToken cancellationToken = default); + + Task InstallOrUpdateAsync(CancellationToken cancellationToken = default); + + Task LaunchAsync(CancellationToken cancellationToken = default); + + bool CanRun { get; } + bool NeedsUpdating { get; } + + LaunchPlan? CurrentPlan { get; } } } \ No newline at end of file diff --git a/AlayaCore/Abstractions/Interfaces/Services/IGameLaunchService.cs b/AlayaCore/Abstractions/Interfaces/Services/IGameLaunchService.cs index 0a5ca54..f4234ef 100644 --- a/AlayaCore/Abstractions/Interfaces/Services/IGameLaunchService.cs +++ b/AlayaCore/Abstractions/Interfaces/Services/IGameLaunchService.cs @@ -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); } } \ No newline at end of file diff --git a/AlayaCore/Abstractions/Interfaces/Services/ISettingsService.cs b/AlayaCore/Abstractions/Interfaces/Services/ISettingsService.cs index ef0aaa6..c2e7860 100644 --- a/AlayaCore/Abstractions/Interfaces/Services/ISettingsService.cs +++ b/AlayaCore/Abstractions/Interfaces/Services/ISettingsService.cs @@ -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); } } \ No newline at end of file diff --git a/AlayaCore/Installation/InstallEnvironment.cs b/AlayaCore/Installation/InstallEnvironment.cs index c7f69a3..9df8f6c 100644 --- a/AlayaCore/Installation/InstallEnvironment.cs +++ b/AlayaCore/Installation/InstallEnvironment.cs @@ -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; diff --git a/AlayaCore/LaunchDirector.cs b/AlayaCore/LaunchDirector.cs index 93da6db..144f45a 100644 --- a/AlayaCore/LaunchDirector.cs +++ b/AlayaCore/LaunchDirector.cs @@ -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 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 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; } } } \ No newline at end of file diff --git a/AlayaCore/Models/Configuration/LauncherOptions.cs b/AlayaCore/Models/Configuration/LauncherOptions.cs index 4e1c50f..84cd186 100644 --- a/AlayaCore/Models/Configuration/LauncherOptions.cs +++ b/AlayaCore/Models/Configuration/LauncherOptions.cs @@ -1,7 +1,7 @@ namespace AlayaCore.Models.Configuration { - public class LauncherOptions + public sealed class LauncherOptions { - + public bool ForceReinstall { get; set; } } } \ No newline at end of file diff --git a/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs b/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs index 4615dcd..3e942b1 100644 --- a/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs +++ b/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs @@ -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; } } diff --git a/AlayaCore/Models/Manifests/DTO/ModFileEntryDto.cs b/AlayaCore/Models/Manifests/DTO/ModFileEntryDto.cs index 7afd5d8..403980b 100644 --- a/AlayaCore/Models/Manifests/DTO/ModFileEntryDto.cs +++ b/AlayaCore/Models/Manifests/DTO/ModFileEntryDto.cs @@ -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; } } } \ No newline at end of file diff --git a/AlayaCore/Models/Manifests/InstalledModEntry.cs b/AlayaCore/Models/Manifests/InstalledModEntry.cs index c2d654b..f38097c 100644 --- a/AlayaCore/Models/Manifests/InstalledModEntry.cs +++ b/AlayaCore/Models/Manifests/InstalledModEntry.cs @@ -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; } diff --git a/AlayaCore/Models/Manifests/ModFileEntry.cs b/AlayaCore/Models/Manifests/ModFileEntry.cs index 62b029c..e34ebb8 100644 --- a/AlayaCore/Models/Manifests/ModFileEntry.cs +++ b/AlayaCore/Models/Manifests/ModFileEntry.cs @@ -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) diff --git a/AlayaCore/Services/DownloadService.cs b/AlayaCore/Services/DownloadService.cs index e4379b9..c703dc2 100644 --- a/AlayaCore/Services/DownloadService.cs +++ b/AlayaCore/Services/DownloadService.cs @@ -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( diff --git a/AlayaCore/Services/InstallStateService.cs b/AlayaCore/Services/InstallStateService.cs index 85a8b74..98a1c29 100644 --- a/AlayaCore/Services/InstallStateService.cs +++ b/AlayaCore/Services/InstallStateService.cs @@ -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); diff --git a/AlayaCore/Services/JavaService.cs b/AlayaCore/Services/JavaService.cs index cf5ab8a..12aaa1a 100644 --- a/AlayaCore/Services/JavaService.cs +++ b/AlayaCore/Services/JavaService.cs @@ -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); } diff --git a/AlayaCore/Services/LauncherUpdateService.cs b/AlayaCore/Services/LauncherUpdateService.cs index 107745a..06a6a31 100644 --- a/AlayaCore/Services/LauncherUpdateService.cs +++ b/AlayaCore/Services/LauncherUpdateService.cs @@ -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)); diff --git a/AlayaCore/Services/ManifestService.cs b/AlayaCore/Services/ManifestService.cs index 4ee5baf..a9b7ca4 100644 --- a/AlayaCore/Services/ManifestService.cs +++ b/AlayaCore/Services/ManifestService.cs @@ -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 LoadLocalManifestAsync( diff --git a/AlayaCore/Services/ModService.cs b/AlayaCore/Services/ModService.cs index a23890e..6965d21 100644 --- a/AlayaCore/Services/ModService.cs +++ b/AlayaCore/Services/ModService.cs @@ -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 missingOrOutdatedMods = new List(); List finalInstalledMods = new List(); 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 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("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("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("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 requiredMods, - CancellationToken cancellationToken = default) + private static void RemoveStaleMods(IEnumerable 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 installedMods, CancellationToken cancellationToken = default) { @@ -308,13 +289,12 @@ namespace AlayaCore.Services } List 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); } } } \ No newline at end of file diff --git a/AlayaCore/Services/SettingsService.cs b/AlayaCore/Services/SettingsService.cs index ae0e45f..7a2b08b 100644 --- a/AlayaCore/Services/SettingsService.cs +++ b/AlayaCore/Services/SettingsService.cs @@ -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( + LauncherSettingsFileName, + cancellationToken).ConfigureAwait(false); + + if (loadedOptions == null) + { + return; + } + + LauncherOptions.ForceReinstall = loadedOptions.ForceReinstall; + } + + private async Task SaveAsync( + 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 LoadAsync( + 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(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); + } } } \ No newline at end of file diff --git a/AlayaCore/States/LaunchPlan.cs b/AlayaCore/States/LaunchPlan.cs index 78c3e6e..9eb87e9 100644 --- a/AlayaCore/States/LaunchPlan.cs +++ b/AlayaCore/States/LaunchPlan.cs @@ -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); + } } } \ No newline at end of file diff --git a/AlayaCore/Utilities/Extensions/MappingExtensions.cs b/AlayaCore/Utilities/Extensions/MappingExtensions.cs index 1328b3f..fd7a64c 100644 --- a/AlayaCore/Utilities/Extensions/MappingExtensions.cs +++ b/AlayaCore/Utilities/Extensions/MappingExtensions.cs @@ -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 }; } }