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;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Models;
using AlayaCore.States;
namespace AlayaCore.Abstractions.Interfaces namespace AlayaCore.Abstractions.Interfaces
{ {
public interface ILaunchDirector 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 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 namespace AlayaCore.Abstractions.Interfaces.Services
{ {
public interface ISettingsService 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 sealed class InstallEnvironment
{ {
public OSPlatform OSPlatform { get; } public OSPlatform OsPlatform { get; }
public bool JavaInstalled { get; } public bool JavaInstalled { get; }
public string? JavaVersion { get; } public string? JavaVersion { get; }
public string? JavaPath { get; } public string? JavaPath { get; }
@@ -42,7 +42,7 @@ namespace AlayaCore.Installation
nameof(javaPath)); nameof(javaPath));
} }
OSPlatform = osPlatform; OsPlatform = osPlatform;
JavaInstalled = javaInstalled; JavaInstalled = javaInstalled;
JavaPath = javaInstalled ? javaPath : null; JavaPath = javaInstalled ? javaPath : null;
JavaVersion = javaInstalled ? javaVersion : null; JavaVersion = javaInstalled ? javaVersion : null;

View File

@@ -1,11 +1,14 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces; using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation; using AlayaCore.Installation;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.States;
namespace AlayaCore namespace AlayaCore
{ {
@@ -16,22 +19,34 @@ namespace AlayaCore
private readonly IInstallStateService _installStateService; private readonly IInstallStateService _installStateService;
private readonly IJavaService _javaService; private readonly IJavaService _javaService;
private readonly IModService _modService; 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( public LaunchDirector(
IManifestService manifestService, IManifestService manifestService,
IUpdateService updateService, IUpdateService updateService,
IInstallStateService installStateService, IInstallStateService installStateService,
IJavaService javaService, IJavaService javaService,
IModService modService) IModService modService,
IGameLaunchService gameLaunchService,
LauncherOptions options)
{ {
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService)); _manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_updateService = updateService ?? throw new ArgumentNullException(nameof(updateService)); _updateService = updateService ?? throw new ArgumentNullException(nameof(updateService));
_installStateService = installStateService ?? throw new ArgumentNullException(nameof(installStateService)); _installStateService = installStateService ?? throw new ArgumentNullException(nameof(installStateService));
_javaService = javaService ?? throw new ArgumentNullException(nameof(javaService)); _javaService = javaService ?? throw new ArgumentNullException(nameof(javaService));
_modService = modService ?? throw new ArgumentNullException(nameof(modService)); _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(); cancellationToken.ThrowIfCancellationRequested();
@@ -41,17 +56,164 @@ namespace AlayaCore
if (launcherNeedsUpdate) if (launcherNeedsUpdate)
{ {
LauncherManifestModel launcherManifest = await _manifestService LaunchPlan launcherUpdatePlan = new LaunchPlan(
.GetLauncherManifestAsync(cancellationToken) launcherNeedsUpdate: true,
.ConfigureAwait(false); javaNeedsInstallOrUpdate: false,
minecraftNeedsInstallOrUpdate: false,
neoforgeNeedsInstallOrUpdate: false,
modsNeedSync: false);
await _updateService ApplyPlan(launcherUpdatePlan);
.LaunchUpdaterAsync(launcherManifest, cancellationToken) return launcherUpdatePlan;
.ConfigureAwait(false);
return;
} }
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 ManifestModel? localManifest = await _manifestService
.GetLocalCoreManifestAsync(cancellationToken) .GetLocalCoreManifestAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -72,48 +234,61 @@ namespace AlayaCore
throw new FileNotFoundException("Local core manifest was not found after refresh."); throw new FileNotFoundException("Local core manifest was not found after refresh.");
} }
await ProcessManifestAsync(localManifest, cancellationToken).ConfigureAwait(false); return localManifest;
} }
private async Task ProcessManifestAsync( private static bool DoModsNeedSync(ManifestModel manifest, InstallEnvironment environment)
ManifestModel manifest,
CancellationToken cancellationToken)
{ {
if (manifest == null) if (manifest == null)
{ {
throw new ArgumentNullException(nameof(manifest)); throw new ArgumentNullException(nameof(manifest));
} }
cancellationToken.ThrowIfCancellationRequested(); if (environment == null)
InstallEnvironment environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
if (!environment.JavaInstalled || environment.JavaVersion != manifest.RequiredJavaVersion)
{ {
await _javaService throw new ArgumentNullException(nameof(environment));
.EnsureValidJavaInstalledAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false);
} }
// Process Minecraft
if (!environment.MinecraftInstalled || environment.MinecraftVersion != manifest.MinecraftVersion) var requiredMods = manifest.Files
{ .Where(file => file.Type == AlayaCore.Utilities.Enums.FileType.Mod)
.ToList();
}
// Process Neoforge
if (!environment.NeoforgedInstalled || environment.NeoforgedVersion != manifest.NeoforgedVersion) var installedMods = environment.InstalledModsManifest.Mods;
if (requiredMods.Count != installedMods.Count)
{ {
return true;
} }
// Process Mods foreach (ModFileEntry requiredMod in requiredMods)
{
await _modService.ProcessModsAsync(manifest, environment, cancellationToken).ConfigureAwait(false); 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 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 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 string AlayaUpdaterPath { get; set; }
public bool ForceUpdate { get; set; } public bool ForceUpdate { get; set; }
} }

View File

@@ -13,13 +13,10 @@ namespace AlayaCore.Models.Manifests.DTO
[JsonProperty("type", Required = Required.Always)] [JsonProperty("type", Required = Required.Always)]
public FileType Type { get; set; } 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)] [JsonProperty("sha512Hash", Required = Required.Always)]
public string Sha512Hash { get; set; } = string.Empty; 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 sealed class InstalledModEntry
{ {
public string FileName { get; } public string FileName { get; }
public string Version { get; }
public string Sha512Hash { get; } public string Sha512Hash { get; }
public long Size { get; }
public long Size { get; set; }
public InstalledModEntry(string fileName, string version, string sha512Hash, long size) public InstalledModEntry(string fileName, string sha512Hash, long size)
{ {
if (string.IsNullOrWhiteSpace(fileName)) if (string.IsNullOrWhiteSpace(fileName))
{ {
throw new ArgumentException("Name cannot be null, empty, or whitespace.", nameof(fileName)); throw new ArgumentException("File 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));
} }
if (string.IsNullOrWhiteSpace(sha512Hash)) 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; FileName = fileName;
Version = version;
Sha512Hash = sha512Hash; Sha512Hash = sha512Hash;
Size = size; Size = size;
} }

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ namespace AlayaCore.Services
{ {
public sealed class InstallationStateService : IInstallStateService 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; private readonly IManifestService _manifestService;
@@ -140,7 +140,7 @@ namespace AlayaCore.Services
string fullPath = Path.Combine( string fullPath = Path.Combine(
AppContext.BaseDirectory, AppContext.BaseDirectory,
"Java", "Java",
JavaRuntimeFolderName, JAVA_RUNTIME_FOLDER_NAME,
"bin", "bin",
executableName); executableName);

View File

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

View File

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

View File

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

View File

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