ILogger support added

This commit is contained in:
2026-04-06 19:40:21 +01:00
parent 8a8292d2c3
commit 11863088e4
22 changed files with 1323 additions and 127 deletions

View File

@@ -1,7 +1,10 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AByteProgress_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003F99_003Fc83c3b28_003FByteProgress_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultFileExtractors_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003Fe8_003F52aaf39a_003FDefaultFileExtractors_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003F5c_003Ff0b24cad_003FExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIGameInstaller_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003Fdf_003F3b38ca47_003FIGameInstaller_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInstallerEventType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003F4e_003F4bf1d82f_003FInstallerEventType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInstallerProgressChangedEventArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003Ffb_003F6d837772_003FInstallerProgressChangedEventArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIVersionMetadata_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003Ff2_003Fc2330846_003FIVersionMetadata_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIXboxGameAccountManager_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F101e700861b6410da498d4e79271a86112600_003Fc4_003F2054a44d_003FIXboxGameAccountManager_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJELoginHandlerBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2dce88ad0d604d86b0d410923cb59f4bb200_003Fb4_003F534fcf72_003FJELoginHandlerBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>

View File

@@ -1,28 +1,27 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Models;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
using AlayaCore.States;
using CmlLib.Core;
using CmlLib.Core.Installers;
namespace AlayaCore.Abstractions.Interfaces
{
public interface ILaunchDirector
{
Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default);
Task InstallOrUpdateAsync(CancellationToken cancellationToken = default,
EventHandler<InstallerProgressChangedEventArgs>? minecraftProgess = null,
EventHandler<ByteProgress>? byteProgress = null,
IProgress<InstallerProgressChangedEventArgs>? neoForgeProgress = null,
IProgress<ByteProgress>? neoForgeByteProgress = null);
Task LaunchAsync(CancellationToken cancellationToken = default);
bool CanRun { get; }
bool NeedsUpdating { get; }
bool IsUpdatingLauncher { get; }
LaunchPlan? CurrentPlan { get; }
Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default);
Task<InstallOrUpdateResult> InstallOrUpdateAsync(
CancellationToken cancellationToken = default,
IProgress<LauncherProgress>? progress = null);
Task LaunchAsync(CancellationToken cancellationToken = default);
}
}

View File

@@ -7,8 +7,8 @@ namespace AlayaCore.Abstractions.Interfaces.Services
{
public interface IManifestService
{
Task<ManifestModel> GetCoreManifestAsync(CancellationToken cancellationToken = default);
Task<ManifestModel?> GetLocalCoreManifestAsync(CancellationToken cancellationToken = default);
Task<ManifestModel> GetAlayaManifestAsync(CancellationToken cancellationToken = default);
Task<ManifestModel?> GetLocalAlayaManifestAsync(CancellationToken cancellationToken = default);
Task<InstalledModsManifestModel> GetInstalledModsManifestAsync(CancellationToken cancellationToken = default);

View File

@@ -1,12 +1,18 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Installation;
using AlayaCore.Models.Manifests;
using AlayaCore.Models.Progress;
namespace AlayaCore.Abstractions.Interfaces.Services
{
public interface IModService
{
Task ProcessModsAsync(ManifestModel manifest, InstallEnvironment environment, CancellationToken cancellationToken = default);
Task ProcessModsAsync(
ManifestModel manifest,
InstallEnvironment environment,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default);
}
}

View File

@@ -10,7 +10,12 @@ namespace AlayaCore.Abstractions.Interfaces.Services
GameOptions GameOptions { get; }
Task SetForceReinstallAsync(bool value, CancellationToken cancellationToken = default);
Task UpdateLaunchVersionAsync(string newVersion, CancellationToken cancellationToken = default);
Task SetMinimumRamMbAsync(int minimumRamMb, CancellationToken cancellationToken = default);
Task SetMaximumRamMbAsync(int maximumRamMb, CancellationToken cancellationToken = default);
Task SetResolutionAsync(int screenWidth, int screenHeight, CancellationToken cancellationToken = default);
Task SetFullscreenAsync(bool fullscreen, CancellationToken cancellationToken = default);
Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default);
Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default);

View File

@@ -8,10 +8,13 @@ using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
using AlayaCore.States;
using AlayaCore.Utilities.Enums;
using CmlLib.Core;
using CmlLib.Core.Installers;
using Microsoft.Extensions.Logging;
namespace AlayaCore
{
@@ -26,11 +29,14 @@ namespace AlayaCore
private readonly ISettingsService _settingsService;
private readonly IAuthService _authService;
private readonly LauncherOptions _options;
private readonly ILogger<LaunchDirector> _logger;
public bool CanRun { get; private set; }
public bool NeedsUpdating { get; private set; }
public LaunchPlan? CurrentPlan { get; private set; }
public bool IsUpdatingLauncher { get; private set; }
public LaunchDirector(
IManifestService manifestService,
IUpdateService updateService,
@@ -40,7 +46,8 @@ namespace AlayaCore
IGameInstallService gameInstallService,
ISettingsService settingsService,
IAuthService authService,
LauncherOptions options)
LauncherOptions options,
ILogger<LaunchDirector> logger)
{
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_updateService = updateService ?? throw new ArgumentNullException(nameof(updateService));
@@ -51,18 +58,23 @@ namespace AlayaCore
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_authService = authService ?? throw new ArgumentNullException(nameof(authService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Evaluating launcher state.");
bool launcherNeedsUpdate = await _updateService
.DoesLauncherNeedUpdating(cancellationToken)
.ConfigureAwait(false);
if (launcherNeedsUpdate)
{
_logger.LogInformation("Launcher update is required.");
LaunchPlan launcherUpdatePlan = new LaunchPlan(
launcherNeedsUpdate: true,
minecraftNeedsInstallOrUpdate: false,
@@ -106,25 +118,38 @@ namespace AlayaCore
modsNeedSync: modsNeedSync,
needAuthenticating: needAuthenticating);
_logger.LogInformation(
"Launcher evaluation completed. State: {State}, NeedAuthenticating: {NeedAuthenticating}, ForceReinstall: {ForceReinstall}, MinecraftNeedsInstallOrUpdate: {MinecraftNeedsInstallOrUpdate}, NeoForgeNeedsInstallOrUpdate: {NeoForgeNeedsInstallOrUpdate}, ModsNeedSync: {ModsNeedSync}",
plan.State,
needAuthenticating,
_options.ForceReinstall,
minecraftNeedsInstallOrUpdate,
neoforgeNeedsInstallOrUpdate,
modsNeedSync);
ApplyPlan(plan);
return plan;
}
public async Task InstallOrUpdateAsync(
public async Task<InstallOrUpdateResult> InstallOrUpdateAsync(
CancellationToken cancellationToken = default,
EventHandler<InstallerProgressChangedEventArgs>? minecraftProgress = null,
EventHandler<ByteProgress>? byteProgress = null,
IProgress<InstallerProgressChangedEventArgs>? neoForgeProgress = null,
IProgress<ByteProgress>? neoForgeByteProgress = null)
IProgress<LauncherProgress>? progress = null)
{
cancellationToken.ThrowIfCancellationRequested();
IsUpdatingLauncher = false;
_logger.LogInformation("Starting install or update workflow.");
ReportProgress(progress, LaunchState.Checking, "Checking launcher state...");
LaunchPlan plan = await EvaluateAsync(cancellationToken).ConfigureAwait(false);
while (!plan.CanRun)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Processing launch state {LaunchState}.", plan.State);
ManifestModel manifest;
InstallEnvironment environment;
@@ -132,6 +157,12 @@ namespace AlayaCore
{
case LaunchState.LauncherNeedsUpdate:
{
IsUpdatingLauncher = true;
_logger.LogWarning("Launcher updater handoff is beginning.");
ReportProgress(progress, LaunchState.LauncherNeedsUpdate, "Launching updater...");
LauncherManifestModel launcherManifest = await _manifestService
.GetLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false);
@@ -141,77 +172,115 @@ namespace AlayaCore
.ConfigureAwait(false);
ApplyPlan(plan);
return;
_logger.LogInformation("Updater launched successfully. Returning UpdaterLaunched result.");
return InstallOrUpdateResult.UpdaterLaunched;
}
case LaunchState.NeedAuthenticating:
{
_logger.LogInformation("Authentication is required.");
ReportProgress(progress, LaunchState.NeedAuthenticating, "Signing in...");
await _authService
.AuthenticateAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Authentication completed successfully.");
break;
}
case LaunchState.InstallMinecraft:
{
_logger.LogInformation("Minecraft installation or repair is required.");
ReportProgress(progress, LaunchState.InstallMinecraft, "Preparing Minecraft installation...");
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
EventHandler<InstallerProgressChangedEventArgs>? minecraftFileProgress =
CreateMinecraftFileProgressHandler(progress);
EventHandler<ByteProgress>? minecraftByteProgress =
CreateMinecraftByteProgressHandler(progress);
await _gameInstallService
.EnsureMinecraftInstalledAsync(
manifest,
environment,
cancellationToken,
minecraftProgress,
byteProgress)
minecraftFileProgress,
minecraftByteProgress)
.ConfigureAwait(false);
_logger.LogInformation("Minecraft installation or repair step completed.");
break;
}
case LaunchState.InstallNeoforge:
{
_logger.LogInformation("NeoForge installation or repair is required.");
ReportProgress(progress, LaunchState.InstallNeoforge, "Preparing NeoForge installation...");
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
IProgress<InstallerProgressChangedEventArgs>? neoForgeFileProgress =
CreateNeoForgeFileProgress(progress);
IProgress<ByteProgress>? neoForgeByteProgress =
CreateNeoForgeByteProgress(progress);
await _gameInstallService
.EnsureNeoForgeInstalledAsync(
manifest,
environment,
cancellationToken,
neoForgeProgress,
neoForgeFileProgress,
neoForgeByteProgress)
.ConfigureAwait(false);
_logger.LogInformation("NeoForge installation or repair step completed.");
break;
}
case LaunchState.SyncMods:
{
_logger.LogInformation("Mod synchronization is required.");
ReportProgress(progress, LaunchState.SyncMods, "Checking mod files...");
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
IProgress<DownloadProgress>? modProgress = CreateModProgress(progress);
await _modService
.ProcessModsAsync(manifest, environment, cancellationToken)
.ProcessModsAsync(manifest, environment, modProgress, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Mod synchronization step completed.");
break;
}
case LaunchState.Ready:
{
_logger.LogDebug("Launch state is Ready inside install loop.");
break;
}
default:
{
_logger.LogError("Unsupported launch state encountered: {LaunchState}.", plan.State);
throw new InvalidOperationException($"Unsupported launch state '{plan.State}'.");
}
}
@@ -221,6 +290,8 @@ namespace AlayaCore
if (_options.ForceReinstall)
{
_logger.LogInformation("Force reinstall flag was set. Resetting it after successful install/update workflow.");
await _settingsService
.SetForceReinstallAsync(false, cancellationToken)
.ConfigureAwait(false);
@@ -230,21 +301,34 @@ namespace AlayaCore
if (!plan.CanRun)
{
_logger.LogError(
"Install or update workflow completed, but launcher is still not runnable. Final state: {LaunchState}.",
plan.State);
throw new InvalidOperationException("Install/update completed, but the launcher is still not in a runnable state.");
}
ReportProgress(progress, LaunchState.Ready, "Launcher is ready.");
_logger.LogInformation("Install or update workflow completed successfully. Launcher is ready.");
return InstallOrUpdateResult.Ready;
}
public async Task LaunchAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Launch requested.");
if (CurrentPlan == null)
{
_logger.LogDebug("No current launch plan was available. Evaluating launcher state before launch.");
await EvaluateAsync(cancellationToken).ConfigureAwait(false);
}
if (!CanRun)
{
_logger.LogError("Launch was requested while the launcher was not in a runnable state.");
throw new InvalidOperationException("Launcher cannot run because installation or updates are still required.");
}
@@ -254,33 +338,56 @@ namespace AlayaCore
ManifestModel manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Starting game launch. MinecraftVersion: {MinecraftVersion}, NeoForgeVersion: {NeoForgeVersion}",
environment.MinecraftVersion,
environment.NeoforgedVersion);
await _gameLaunchService
.LaunchAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Game launch call completed.");
}
private async Task<ManifestModel> EnsureCurrentManifestAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Ensuring current Alaya manifest is available.");
ManifestModel? localManifest = await _manifestService
.GetLocalCoreManifestAsync(cancellationToken)
.GetLocalAlayaManifestAsync(cancellationToken)
.ConfigureAwait(false);
Version remoteVersion = await _manifestService
.GetRemoteCoreManifestVersionAsync(cancellationToken)
.ConfigureAwait(false);
if (localManifest == null)
{
_logger.LogInformation("No local Alaya manifest was found. Downloading remote manifest.");
}
else if (localManifest.AlayaVersion != remoteVersion)
{
_logger.LogInformation(
"Local Alaya manifest version {LocalVersion} differs from remote version {RemoteVersion}. Downloading updated manifest.",
localManifest.AlayaVersion,
remoteVersion);
}
if (localManifest == null || localManifest.AlayaVersion != remoteVersion)
{
localManifest = await _manifestService
.GetCoreManifestAsync(cancellationToken)
.GetAlayaManifestAsync(cancellationToken)
.ConfigureAwait(false);
}
if (localManifest == null)
{
_logger.LogError("Local Alaya manifest was still unavailable after refresh.");
throw new FileNotFoundException("Local core manifest was not found after refresh.");
}
_logger.LogDebug("Current Alaya manifest is available. Version: {AlayaVersion}", localManifest.AlayaVersion);
return localManifest;
}
@@ -336,6 +443,129 @@ namespace AlayaCore
CurrentPlan = plan ?? throw new ArgumentNullException(nameof(plan));
NeedsUpdating = plan.NeedsUpdating;
CanRun = plan.CanRun;
_logger.LogInformation(
"Applied launch plan. State: {LaunchState}, CanRun: {CanRun}, NeedsUpdating: {NeedsUpdating}, IsUpdatingLauncher: {IsUpdatingLauncher}",
plan.State,
CanRun,
NeedsUpdating,
IsUpdatingLauncher);
}
private static void ReportProgress(
IProgress<LauncherProgress>? progress,
LaunchState phase,
string statusMessage,
string? currentItemName = null,
long? bytesCompleted = null,
long? bytesTotal = null,
double? bytesPerSecond = null,
int? tasksCompleted = null,
int? tasksTotal = null)
{
progress?.Report(new LauncherProgress(
phase,
statusMessage,
currentItemName,
bytesCompleted,
bytesTotal,
bytesPerSecond,
tasksCompleted,
tasksTotal));
}
private static EventHandler<InstallerProgressChangedEventArgs>? CreateMinecraftFileProgressHandler(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return (_, args) =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallMinecraft,
statusMessage: args.EventType.ToString(),
currentItemName: args.Name,
tasksCompleted: args.ProgressedTasks,
tasksTotal: args.TotalTasks));
};
}
private static EventHandler<ByteProgress>? CreateMinecraftByteProgressHandler(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return (_, args) =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallMinecraft,
statusMessage: "Downloading Minecraft files...",
bytesCompleted: args.ProgressedBytes,
bytesTotal: args.TotalBytes));
};
}
private static IProgress<InstallerProgressChangedEventArgs>? CreateNeoForgeFileProgress(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return new Progress<InstallerProgressChangedEventArgs>(args =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallNeoforge,
statusMessage: args.EventType.ToString(),
currentItemName: args.Name,
tasksCompleted: args.ProgressedTasks,
tasksTotal: args.TotalTasks));
});
}
private static IProgress<ByteProgress>? CreateNeoForgeByteProgress(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return new Progress<ByteProgress>(args =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallNeoforge,
statusMessage: "Downloading NeoForge files...",
bytesCompleted: args.ProgressedBytes,
bytesTotal: args.TotalBytes));
});
}
private static IProgress<DownloadProgress>? CreateModProgress(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return new Progress<DownloadProgress>(downloadProgress =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.SyncMods,
statusMessage: downloadProgress.StatusMessage ?? "Downloading mod...",
currentItemName: downloadProgress.FileName,
bytesCompleted: downloadProgress.BytesDownloaded,
bytesTotal: downloadProgress.TotalBytes,
bytesPerSecond: downloadProgress.BytesPerSecond));
});
}
}
}

View File

@@ -2,29 +2,28 @@ using AlayaCore.Abstractions.Configuration;
namespace AlayaCore.Models.Configuration
{
public class GameOptions : BaseConfig
public sealed class GameOptions : BaseConfig
{
public override string FileName => "Game.json";
public string? LaunchVersion { get; set; }
public int MinimumRamMB { get; set; }
public int MaximumRamMB { get; set; }
public int ScreenWidth { get; set; }
public int ScreenHeight { get; set; }
public bool Fullscreen { get; set; }
public string? LaunchVersion { get; set; } = null;
public int MinimumRamMb { get; set; } = 1024;
public int MaximumRamMb { get; set; } = 2048;
public int ScreenWidth { get; set; } = 1920;
public int ScreenHeight { get; set; } = 1080;
public bool Fullscreen { get; set; } = false;
public static GameOptions Default { get; } = new GameOptions
{
LaunchVersion = null,
MinimumRamMB = 1024,
MaximumRamMB = 2048,
MinimumRamMb = 1024,
MaximumRamMb = 2048,
ScreenWidth = 1920,
ScreenHeight = 1080,
Fullscreen = false,
Fullscreen = false
};
}
}

View File

@@ -4,7 +4,7 @@ namespace AlayaCore.Models.Configuration
{
public sealed class LauncherOptions : BaseConfig
{
public bool ForceReinstall { get; set; }
public bool ForceReinstall { get; set; } = false;
public override string FileName => "Launcher.json";

View File

@@ -6,7 +6,7 @@ namespace AlayaCore.Models.Manifests
{
public Version? Version { get; }
public string Sha512Hash { get; }
public Uri DownloadUri { get; }
public Uri? DownloadUri { get; }
public LauncherManifestModel(
Version version,

View File

@@ -12,7 +12,7 @@ namespace AlayaCore.Models.Manifests
public string MinecraftVersion { get; }
public string NeoforgedVersion { get; }
public Uri ServerUrl { get; }
public Uri? ServerUrl { get; }
public int ServerPort { get; }
public IReadOnlyList<ModFileEntry> Files { get; }

View File

@@ -1,27 +1,62 @@
using System;
using AlayaCore.Utilities.Enums;
using AlayaCore.States;
namespace AlayaCore.Models.Progress
{
public sealed class LauncherProgress
{
public LauncherStage Stage { get; }
public LaunchState Phase { get; }
public string StatusMessage { get; }
public double? PercentComplete { get; }
public string? CurrentItemName { get; }
public long? BytesCompleted { get; }
public long? BytesTotal { get; }
public double? BytesPerSecond { get; }
public int? TasksCompleted { get; }
public int? TasksTotal { get; }
public double? PercentComplete
{
get
{
if (BytesCompleted.HasValue && BytesTotal.HasValue && BytesTotal.Value > 0)
{
return (double)BytesCompleted.Value / BytesTotal.Value * 100d;
}
if (TasksCompleted.HasValue && TasksTotal.HasValue && TasksTotal.Value > 0)
{
return (double)TasksCompleted.Value / TasksTotal.Value * 100d;
}
return null;
}
}
public LauncherProgress(
LauncherStage stage,
LaunchState phase,
string statusMessage,
double? percentComplete = null)
string? currentItemName = null,
long? bytesCompleted = null,
long? bytesTotal = null,
double? bytesPerSecond = null,
int? tasksCompleted = null,
int? tasksTotal = null)
{
if (string.IsNullOrWhiteSpace(statusMessage))
{
throw new ArgumentException("Status message cannot be null or empty.", nameof(statusMessage));
throw new System.ArgumentException("Status message cannot be null, empty, or whitespace.", nameof(statusMessage));
}
Stage = stage;
Phase = phase;
StatusMessage = statusMessage;
PercentComplete = percentComplete;
CurrentItemName = currentItemName;
BytesCompleted = bytesCompleted;
BytesTotal = bytesTotal;
BytesPerSecond = bytesPerSecond;
TasksCompleted = tasksCompleted;
TasksTotal = tasksTotal;
}
}
}

View File

@@ -2,6 +2,7 @@ namespace AlayaCore.Models.Results
{
public enum InstallOrUpdateResult
{
Ready,
UpdaterLaunched
}
}

View File

@@ -7,6 +7,7 @@ using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Utilities.Enums;
using CmlLib.Core.Auth;
using CmlLib.Core.Auth.Microsoft;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using XboxAuthNet.Game.Msal;
using XboxAuthNet.Game.Msal.OAuth;
@@ -16,14 +17,18 @@ namespace AlayaCore.Services
public sealed class AuthService : IAuthService
{
private readonly IFileStore _fileStore;
private readonly ILogger<AuthService> _logger;
private MSession? _session;
private IPublicClientApplication? _clientApp;
private JELoginHandler? _loginHandler;
public AuthService(IFileStore fileStore)
public AuthService(
IFileStore fileStore,
ILogger<AuthService> logger)
{
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default)
@@ -32,9 +37,12 @@ namespace AlayaCore.Services
if (_session != null && _session.CheckIsValid())
{
_logger.LogDebug("Authentication check succeeded using the cached in-memory session.");
return true;
}
_logger.LogDebug("No valid cached in-memory session was found. Attempting silent authentication check.");
try
{
JELoginHandler loginHandler = await BuildHandlerAsync().ConfigureAwait(false);
@@ -43,15 +51,28 @@ namespace AlayaCore.Services
.AuthenticateSilently(cancellationToken: cancellationToken)
.ConfigureAwait(false);
return _session != null && _session.CheckIsValid();
bool isAuthenticated = _session != null && _session.CheckIsValid();
if (isAuthenticated)
{
_logger.LogInformation("Silent authentication check succeeded.");
}
else
{
_logger.LogWarning("Silent authentication completed but did not produce a valid Minecraft session.");
}
return isAuthenticated;
}
catch (OperationCanceledException)
{
_logger.LogInformation("Authentication check was cancelled.");
throw;
}
catch
catch (Exception ex)
{
_session = null;
_logger.LogInformation(ex, "Silent authentication check failed. The user is not currently authenticated.");
return false;
}
}
@@ -60,27 +81,53 @@ namespace AlayaCore.Services
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Starting authentication flow.");
JELoginHandler loginHandler = await BuildHandlerAsync().ConfigureAwait(false);
try
{
_logger.LogDebug("Attempting silent authentication.");
_session = await loginHandler
.AuthenticateSilently(cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (_session != null && _session.CheckIsValid())
{
_logger.LogInformation("Silent authentication succeeded.");
}
else
{
_logger.LogWarning("Silent authentication completed but did not return a valid session.");
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("Authentication was cancelled during silent authentication.");
throw;
}
catch
catch (Exception ex)
{
_logger.LogInformation(ex, "Silent authentication failed. Falling back to interactive authentication.");
_session = await loginHandler
.AuthenticateInteractively(cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (_session != null && _session.CheckIsValid())
{
_logger.LogInformation("Interactive authentication succeeded.");
}
else
{
_logger.LogWarning("Interactive authentication completed but did not return a valid session.");
}
}
if (_session == null || !_session.CheckIsValid())
{
_logger.LogError("Authentication failed because no valid Minecraft session was produced.");
throw new InvalidOperationException("Authentication did not produce a valid Minecraft session.");
}
}
@@ -89,10 +136,14 @@ namespace AlayaCore.Services
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Signing out the current user.");
JELoginHandler loginHandler = await BuildHandlerAsync().ConfigureAwait(false);
await loginHandler.Signout(cancellationToken).ConfigureAwait(false);
_session = null;
_logger.LogInformation("Sign-out completed and cached session was cleared.");
}
public async Task<MSession> GetSessionAsync(CancellationToken cancellationToken = default)
@@ -101,16 +152,21 @@ namespace AlayaCore.Services
if (_session != null && _session.CheckIsValid())
{
_logger.LogDebug("Returning a valid cached Minecraft session.");
return _session;
}
_logger.LogDebug("No valid cached session was available. Attempting authentication before returning a session.");
await AuthenticateAsync(cancellationToken).ConfigureAwait(false);
if (_session == null || !_session.CheckIsValid())
{
_logger.LogError("A valid Minecraft session was not available after authentication.");
throw new InvalidOperationException("No valid Minecraft session is available.");
}
_logger.LogDebug("Returning Minecraft session obtained from authentication flow.");
return _session;
}
@@ -118,12 +174,15 @@ namespace AlayaCore.Services
{
if (_loginHandler != null)
{
_logger.LogDebug("Reusing existing JELoginHandler instance.");
return _loginHandler;
}
string accountDirectory = _fileStore.GetOrCreate(FolderLocation.Data);
string accountFilePath = Path.Combine(accountDirectory, "accounts.json");
_logger.LogInformation("Building MSAL client and login handler. Account cache path: {AccountFilePath}", accountFilePath);
_clientApp = await MsalClientHelper
.BuildApplicationWithCache("d91042d4-3eb5-43e4-b3ed-600e1d0760ff")
.ConfigureAwait(false);
@@ -133,6 +192,8 @@ namespace AlayaCore.Services
.WithAccountManager(accountFilePath)
.Build();
_logger.LogInformation("MSAL client and JELoginHandler were created successfully.");
return _loginHandler;
}
}

View File

@@ -12,25 +12,29 @@ using CmlLib.Core;
using CmlLib.Core.Installer.NeoForge;
using CmlLib.Core.Installer.NeoForge.Installers;
using CmlLib.Core.Installers;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services
{
public sealed class GameInstallService : IGameInstallService
{
private const string InstalledModsManifestFileName = "InstalledModsManifest.json";
private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
private readonly IFileStore _fileStore;
private readonly ISettingsService _settingsService;
private readonly ILogger<GameInstallService> _logger;
private AlayaPath? _gamePath;
private MinecraftLauncher? _minecraftLauncher;
public GameInstallService(
IFileStore fileStore,
ISettingsService settingsService)
ISettingsService settingsService,
ILogger<GameInstallService> logger)
{
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task EnsureMinecraftInstalledAsync(
@@ -54,15 +58,23 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion))
{
_logger.LogError("Minecraft installation could not start because the manifest did not contain a Minecraft version.");
throw new InvalidDataException("Minecraft version is missing.");
}
_logger.LogInformation(
"Ensuring Minecraft is installed. Required version: {RequiredVersion}, Installed: {Installed}, Current version: {CurrentVersion}",
manifest.MinecraftVersion,
environment.MinecraftInstalled,
environment.MinecraftVersion);
bool alreadyInstalled =
environment.MinecraftInstalled &&
string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase);
if (alreadyInstalled)
{
_logger.LogInformation("Minecraft version {MinecraftVersion} is already installed and matches the manifest.", manifest.MinecraftVersion);
return;
}
@@ -72,14 +84,23 @@ namespace AlayaCore.Services
if (versionMismatch)
{
_logger.LogWarning(
"Minecraft version mismatch detected. Installed version: {InstalledVersion}, Required version: {RequiredVersion}. Cleaning old install.",
environment.MinecraftVersion,
manifest.MinecraftVersion);
await CleanOldInstallAsync(cancellationToken).ConfigureAwait(false);
}
MinecraftLauncher launcher = GetOrCreateLauncher(minecraftProgress, byteProgress);
_logger.LogInformation("Starting Minecraft installation for version {MinecraftVersion}.", manifest.MinecraftVersion);
await launcher
.InstallAsync(manifest.MinecraftVersion, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Minecraft installation completed for version {MinecraftVersion}.", manifest.MinecraftVersion);
}
public async Task EnsureNeoForgeInstalledAsync(
@@ -103,20 +124,29 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion))
{
_logger.LogError("NeoForge installation could not start because the manifest did not contain a Minecraft version.");
throw new InvalidDataException("Minecraft version is missing.");
}
if (string.IsNullOrWhiteSpace(manifest.NeoforgedVersion))
{
_logger.LogError("NeoForge installation could not start because the manifest did not contain a NeoForge version.");
throw new InvalidDataException("NeoForge version is missing.");
}
_logger.LogInformation(
"Ensuring NeoForge is installed. Required version: {RequiredVersion}, Installed: {Installed}, Current version: {CurrentVersion}",
manifest.NeoforgedVersion,
environment.NeoforgedInstalled,
environment.NeoforgedVersion);
bool alreadyInstalled =
environment.NeoforgedInstalled &&
string.Equals(environment.NeoforgedVersion, manifest.NeoforgedVersion, StringComparison.OrdinalIgnoreCase);
if (alreadyInstalled)
{
_logger.LogInformation("NeoForge version {NeoForgeVersion} is already installed and matches the manifest.", manifest.NeoforgedVersion);
return;
}
@@ -126,6 +156,11 @@ namespace AlayaCore.Services
if (neoForgeMismatch)
{
_logger.LogWarning(
"NeoForge version mismatch detected. Installed version: {InstalledVersion}, Required version: {RequiredVersion}. Cleaning old install and returning control to the director.",
environment.NeoforgedVersion,
manifest.NeoforgedVersion);
await CleanOldInstallAsync(cancellationToken).ConfigureAwait(false);
return;
}
@@ -133,16 +168,27 @@ namespace AlayaCore.Services
if (!environment.MinecraftInstalled ||
!string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation(
"Minecraft base installation is missing or mismatched before NeoForge installation. Ensuring Minecraft version {MinecraftVersion} first.",
manifest.MinecraftVersion);
await EnsureMinecraftInstalledAsync(manifest, environment, cancellationToken).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(environment.JavaPath))
{
_logger.LogError("NeoForge installation cannot continue because no valid Java path was found.");
throw new InvalidOperationException("A valid Java installation is required before installing NeoForge.");
}
MinecraftLauncher launcher = GetOrCreateLauncher();
_logger.LogInformation(
"Starting NeoForge installation. Minecraft version: {MinecraftVersion}, NeoForge version: {NeoForgeVersion}, Java path: {JavaPath}",
manifest.MinecraftVersion,
manifest.NeoforgedVersion,
environment.JavaPath);
await InstallNeoForgeAsync(
launcher,
manifest,
@@ -151,9 +197,15 @@ namespace AlayaCore.Services
progress,
byteProgress).ConfigureAwait(false);
_logger.LogInformation(
"NeoForge installation completed. Verifying Minecraft files for version {MinecraftVersion}.",
manifest.MinecraftVersion);
await launcher
.InstallAsync(manifest.MinecraftVersion, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Minecraft file verification completed after NeoForge installation.");
}
public async Task VerifyFilesAsync(
@@ -169,14 +221,19 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion))
{
_logger.LogError("File verification could not start because the manifest did not contain a Minecraft version.");
throw new InvalidDataException("Minecraft version is missing.");
}
MinecraftLauncher launcher = GetOrCreateLauncher();
_logger.LogInformation("Verifying Minecraft files for version {MinecraftVersion}.", manifest.MinecraftVersion);
await launcher
.InstallAsync(manifest.MinecraftVersion, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Minecraft file verification completed for version {MinecraftVersion}.", manifest.MinecraftVersion);
}
private async Task CleanOldInstallAsync(CancellationToken cancellationToken)
@@ -185,26 +242,42 @@ namespace AlayaCore.Services
string gamePath = GetMinecraftPath();
_logger.LogInformation("Cleaning old game installation at path {GamePath}.", gamePath);
if (Directory.Exists(gamePath))
{
Directory.Delete(gamePath, recursive: true);
_logger.LogInformation("Deleted game directory {GamePath}.", gamePath);
}
else
{
_logger.LogDebug("Game directory {GamePath} did not exist. No game files needed deletion.", gamePath);
}
string installedModsManifestPath = Path.Combine(
_fileStore.Get(FolderLocation.Manifests),
InstalledModsManifestFileName);
INSTALLED_MODS_MANIFEST_FILE_NAME);
if (File.Exists(installedModsManifestPath))
{
File.Delete(installedModsManifestPath);
_logger.LogInformation("Deleted installed mods manifest at {InstalledModsManifestPath}.", installedModsManifestPath);
}
else
{
_logger.LogDebug("Installed mods manifest {InstalledModsManifestPath} did not exist.", installedModsManifestPath);
}
_gamePath = null;
_minecraftLauncher = null;
_logger.LogDebug("Cleared cached Minecraft launcher state.");
await _settingsService
.UpdateLaunchVersionAsync(string.Empty, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Cleared stored launch version after cleaning the old install.");
}
private MinecraftLauncher GetOrCreateLauncher(
@@ -213,20 +286,25 @@ namespace AlayaCore.Services
{
if (_minecraftLauncher != null)
{
_logger.LogDebug("Reusing existing MinecraftLauncher instance.");
return _minecraftLauncher;
}
_logger.LogInformation("Creating a new MinecraftLauncher instance.");
_gamePath = new AlayaPath(_fileStore);
_minecraftLauncher = new MinecraftLauncher(_gamePath);
if (byteProgress != null)
{
_minecraftLauncher.ByteProgressChanged += byteProgress;
_logger.LogDebug("Attached Minecraft byte progress handler.");
}
if (minecraftProgress != null)
{
_minecraftLauncher.FileProgressChanged += minecraftProgress;
_logger.LogDebug("Attached Minecraft file progress handler.");
}
return _minecraftLauncher;
@@ -245,6 +323,11 @@ namespace AlayaCore.Services
throw new ArgumentNullException(nameof(launcher));
}
_logger.LogDebug(
"Configuring NeoForge installer. Minecraft version: {MinecraftVersion}, NeoForge version: {NeoForgeVersion}",
manifest.MinecraftVersion,
manifest.NeoforgedVersion);
NeoForgeInstaller installer = new NeoForgeInstaller(launcher);
NeoForgeInstallOptions options = new NeoForgeInstallOptions
@@ -257,25 +340,33 @@ namespace AlayaCore.Services
if (progress != null)
{
options.FileProgress = progress;
_logger.LogDebug("Attached NeoForge file progress reporter.");
}
if (byteProgress != null)
{
options.ByteProgress = byteProgress;
_logger.LogDebug("Attached NeoForge byte progress reporter.");
}
string version = await installer
.Install(manifest.MinecraftVersion, manifest.NeoforgedVersion, options)
.ConfigureAwait(false);
_logger.LogInformation("NeoForge installer returned launch version {LaunchVersion}.", version);
await _settingsService
.UpdateLaunchVersionAsync(version, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Persisted launch version {LaunchVersion} to settings.", version);
}
private string GetMinecraftPath()
{
return _fileStore.GetOrCreate(FolderLocation.Game);
string gamePath = _fileStore.GetOrCreate(FolderLocation.Game);
_logger.LogDebug("Resolved Minecraft game path to {GamePath}.", gamePath);
return gamePath;
}
}
}

View File

@@ -10,6 +10,7 @@ using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
using CmlLib.Core;
using CmlLib.Core.ProcessBuilder;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services
{
@@ -19,6 +20,7 @@ namespace AlayaCore.Services
private readonly IFileStore _fileStore;
private readonly ILaunchDirector _director;
private readonly GameOptions _gameOptions;
private readonly ILogger<GameLaunchService> _logger;
private MinecraftLauncher? _minecraftLauncher;
@@ -26,12 +28,14 @@ namespace AlayaCore.Services
IAuthService authService,
IFileStore fileStore,
ILaunchDirector director,
GameOptions gameOptions)
GameOptions gameOptions,
ILogger<GameLaunchService> logger)
{
_authService = authService ?? throw new ArgumentNullException(nameof(authService));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_director = director ?? throw new ArgumentNullException(nameof(director));
_gameOptions = gameOptions ?? throw new ArgumentNullException(nameof(gameOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task LaunchAsync(
@@ -41,6 +45,7 @@ namespace AlayaCore.Services
{
if (!_director.CanRun)
{
_logger.LogError("Game launch was requested while the launcher was not in a runnable state.");
throw new InvalidOperationException("The launcher is not in a runnable state.");
}
@@ -56,16 +61,27 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(_gameOptions.LaunchVersion))
{
_logger.LogError("Game launch could not start because GameOptions.LaunchVersion is not configured.");
throw new InvalidDataException("GameOptions.LaunchVersion is not configured.");
}
if (string.IsNullOrWhiteSpace(environment.JavaPath))
{
_logger.LogError("Game launch could not start because no valid Java path was available.");
throw new InvalidOperationException("A valid Java path is required to launch the game.");
}
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation(
"Starting game launch. LaunchVersion: {LaunchVersion}, JavaPath: {JavaPath}, Resolution: {Width}x{Height}, MinRamMb: {MinRamMb}, MaxRamMb: {MaxRamMb}",
_gameOptions.LaunchVersion,
environment.JavaPath,
_gameOptions.ScreenWidth,
_gameOptions.ScreenHeight,
_gameOptions.MinimumRamMb,
_gameOptions.MaximumRamMb);
MLaunchOption option = await BuildLaunchOptionsAsync(
manifest,
environment,
@@ -73,23 +89,39 @@ namespace AlayaCore.Services
MinecraftLauncher launcher = GetOrCreateLauncher();
_logger.LogInformation("Creating Minecraft process for launch version {LaunchVersion}.", _gameOptions.LaunchVersion);
var process = await launcher
.CreateProcessAsync(_gameOptions.LaunchVersion, option)
.ConfigureAwait(false);
_logger.LogInformation(
"Minecraft process was created successfully. ProcessId: {ProcessId}",
process.Id);
var processWrapper = new ProcessWrapper(process);
_logger.LogInformation("Starting Minecraft process.");
processWrapper.StartWithEvents();
_logger.LogInformation("Waiting for Minecraft process to exit.");
await processWrapper.WaitForExitTaskAsync().ConfigureAwait(false);
_logger.LogInformation(
"Minecraft process exited. ProcessId: {ProcessId}, ExitCode: {ExitCode}",
process.Id,
process.ExitCode);
}
private MinecraftLauncher GetOrCreateLauncher()
{
if (_minecraftLauncher != null)
{
_logger.LogDebug("Reusing existing MinecraftLauncher instance for game launch.");
return _minecraftLauncher;
}
_logger.LogInformation("Creating a new MinecraftLauncher instance for game launch.");
_minecraftLauncher = new MinecraftLauncher(new AlayaPath(_fileStore));
return _minecraftLauncher;
}
@@ -99,17 +131,40 @@ namespace AlayaCore.Services
InstallEnvironment environment,
CancellationToken cancellationToken)
{
if (manifest == null)
{
throw new ArgumentNullException(nameof(manifest));
}
if (environment == null)
{
throw new ArgumentNullException(nameof(environment));
}
if (manifest.ServerUrl == null)
{
_logger.LogError("Launch options could not be built because Manifest.ServerUrl is not configured.");
throw new InvalidDataException("Manifest ServerUrl is not configured.");
}
_logger.LogDebug(
"Building launch options. ServerHost: {ServerHost}, ServerPort: {ServerPort}, LaunchVersion: {LaunchVersion}",
manifest.ServerUrl.Host,
manifest.ServerPort,
_gameOptions.LaunchVersion);
var session = await _authService
.GetSessionAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug("A valid Minecraft session was acquired for launch.");
return new MLaunchOption
{
Session = await _authService.GetSessionAsync(cancellationToken).ConfigureAwait(false),
Session = session,
JavaPath = environment.JavaPath,
MinimumRamMb = _gameOptions.MinimumRamMB,
MaximumRamMb = _gameOptions.MaximumRamMB,
MinimumRamMb = _gameOptions.MinimumRamMb,
MaximumRamMb = _gameOptions.MaximumRamMb,
ScreenWidth = _gameOptions.ScreenWidth,
ScreenHeight = _gameOptions.ScreenHeight,
ServerIp = manifest.ServerUrl.Host,

View File

@@ -9,18 +9,23 @@ using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services
{
public sealed class HttpDownloadService : IDownloadService
{
private const int BufferSize = 81920;
private const int BUFFER_SIZE = 81920;
private readonly IHttpClient _httpClient;
private readonly ILogger<HttpDownloadService> _logger;
public HttpDownloadService(IHttpClient httpClient)
public HttpDownloadService(
IHttpClient httpClient,
ILogger<HttpDownloadService> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DownloadResult> DownloadFileAsync(
@@ -54,12 +59,22 @@ namespace AlayaCore.Services
throw new ArgumentException("Destination path must include a file name.", nameof(destinationPath));
}
_logger.LogInformation(
"Starting download workflow for {FileName} from {SourceUri} to {DestinationPath}.",
fileName,
sourceUri,
destinationPath);
EnsureDestinationDirectoryExists(destinationPath);
if (File.Exists(destinationPath) && VerifyFileHash(destinationPath, normalizedExpectedHash))
{
long existingLength = new FileInfo(destinationPath).Length;
_logger.LogInformation(
"Skipped download for {FileName} because the destination file already exists and passed SHA-512 verification.",
fileName);
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
@@ -78,6 +93,13 @@ namespace AlayaCore.Services
bool destinationExisted = File.Exists(destinationPath);
string tempFilePath = destinationPath + ".download";
if (destinationExisted)
{
_logger.LogInformation(
"Destination file for {FileName} already existed but was not valid. A replacement download will be attempted.",
fileName);
}
DeleteFileIfExists(tempFilePath);
try
@@ -92,6 +114,11 @@ namespace AlayaCore.Services
long? totalBytes = response.Content.Headers.ContentLength;
long bytesDownloaded = 0;
_logger.LogInformation(
"Download response received for {FileName}. Content-Length: {TotalBytes}.",
fileName,
totalBytes);
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
@@ -109,14 +136,16 @@ namespace AlayaCore.Services
FileMode.Create,
FileAccess.Write,
FileShare.None,
BufferSize,
BUFFER_SIZE,
useAsync: true);
using SHA512 sha512 = SHA512.Create();
byte[] buffer = new byte[BufferSize];
byte[] buffer = new byte[BUFFER_SIZE];
Stopwatch stopwatch = Stopwatch.StartNew();
_logger.LogDebug("Streaming download content for {FileName} into temporary file {TempFilePath}.", fileName, tempFilePath);
while (true)
{
int bytesRead = await responseStream.ReadAsync(
@@ -161,10 +190,20 @@ namespace AlayaCore.Services
string actualHash = ConvertToLowerHex(sha512.Hash);
if (!string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"Hash verification failed for downloaded file {FileName}. Expected SHA-512: {ExpectedHash}. Actual SHA-512: {ActualHash}.",
fileName,
normalizedExpectedHash,
actualHash);
throw new InvalidDataException(
$"Downloaded file hash mismatch. Expected '{normalizedExpectedHash}', got '{actualHash}'.");
}
_logger.LogInformation(
"Hash verification succeeded for {FileName}. Replacing destination file with downloaded content.",
fileName);
ReplaceDestinationFile(tempFilePath, destinationPath);
double? finalBytesPerSecond = null;
@@ -181,14 +220,40 @@ namespace AlayaCore.Services
bytesPerSecond: finalBytesPerSecond,
statusMessage: "Download complete."));
DownloadOutcome outcome = destinationExisted
? DownloadOutcome.ReplacedInvalid
: DownloadOutcome.Downloaded;
_logger.LogInformation(
"Download completed successfully for {FileName}. Outcome: {Outcome}. Bytes downloaded: {BytesDownloaded}.",
fileName,
outcome,
bytesDownloaded);
return new DownloadResult(
destinationPath,
destinationExisted ? DownloadOutcome.ReplacedInvalid : DownloadOutcome.Downloaded,
outcome,
hashVerified: true,
bytesDownloaded: bytesDownloaded);
}
catch
catch (OperationCanceledException)
{
_logger.LogWarning(
"Download for {FileName} was cancelled. Cleaning up temporary file {TempFilePath}.",
fileName,
tempFilePath);
DeleteFileIfExists(tempFilePath);
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Download failed for {FileName}. Cleaning up temporary file {TempFilePath}.",
fileName,
tempFilePath);
DeleteFileIfExists(tempFilePath);
throw;
}
@@ -205,6 +270,7 @@ namespace AlayaCore.Services
if (!File.Exists(filePath))
{
_logger.LogDebug("Hash verification skipped because file does not exist at {FilePath}.", filePath);
return false;
}
@@ -214,7 +280,22 @@ namespace AlayaCore.Services
byte[] hashBytes = sha512.ComputeHash(fileStream);
string actualHash = ConvertToLowerHex(hashBytes);
return string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase);
bool matches = string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase);
if (matches)
{
_logger.LogDebug("SHA-512 verification succeeded for {FilePath}.", filePath);
}
else
{
_logger.LogWarning(
"SHA-512 verification failed for {FilePath}. Expected SHA-512: {ExpectedHash}. Actual SHA-512: {ActualHash}.",
filePath,
normalizedExpectedHash,
actualHash);
}
return matches;
}
private static void EnsureDestinationDirectoryExists(string destinationPath)

View File

@@ -11,29 +11,35 @@ using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models.Manifests;
using AlayaCore.Utilities.Enums;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
namespace AlayaCore.Services
{
public sealed class InstallationStateService : IInstallStateService
{
private const string VersionsFolderName = "versions";
private const string VERSIONS_FOLDER_NAME = "versions";
private readonly IFileStore _fileStore;
private readonly IManifestService _manifestService;
private readonly ILogger<InstallationStateService> _logger;
public InstallationStateService(
IFileStore fileStore,
IManifestService manifestService)
IManifestService manifestService,
ILogger<InstallationStateService> logger)
{
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<InstallEnvironment> GetCurrentEnvironmentAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Building current installation environment state.");
OSPlatform platform = GetCurrentPlatform();
bool javaInstalled = TryGetJavaPath(out string? javaPath);
@@ -41,15 +47,20 @@ namespace AlayaCore.Services
if (javaInstalled)
{
_logger.LogDebug("Java runtime was detected at {JavaPath}. Attempting to read Java version.", javaPath);
javaVersion = GetJavaVersion(javaPath!);
}
else
{
_logger.LogInformation("No managed Java runtime was detected.");
}
InstalledVersionState versionState = GetInstalledVersionState();
InstalledModsManifestModel installedModsManifest =
await _manifestService.GetInstalledModsManifestAsync(cancellationToken).ConfigureAwait(false);
return new InstallEnvironment(
InstallEnvironment environment = new InstallEnvironment(
osPlatform: platform,
javaInstalled: javaInstalled,
javaPath: javaPath,
@@ -59,6 +70,19 @@ namespace AlayaCore.Services
neoforgedInstalled: !string.IsNullOrWhiteSpace(versionState.NeoForgeVersion),
neoforgedVersion: versionState.NeoForgeVersion,
installedModsManifest: installedModsManifest);
_logger.LogInformation(
"Installation environment resolved. Platform: {Platform}, JavaInstalled: {JavaInstalled}, JavaVersion: {JavaVersion}, MinecraftInstalled: {MinecraftInstalled}, MinecraftVersion: {MinecraftVersion}, NeoForgeInstalled: {NeoForgeInstalled}, NeoForgeVersion: {NeoForgeVersion}, InstalledModsCount: {InstalledModsCount}",
platform,
environment.JavaInstalled,
environment.JavaVersion,
environment.MinecraftInstalled,
environment.MinecraftVersion,
environment.NeoforgedInstalled,
environment.NeoforgedVersion,
environment.InstalledModsManifest.Mods.Count);
return environment;
}
private static OSPlatform GetCurrentPlatform()
@@ -81,7 +105,7 @@ namespace AlayaCore.Services
throw new PlatformNotSupportedException("The current operating system is not supported.");
}
private static string? GetJavaVersion(string javaPath)
private string? GetJavaVersion(string javaPath)
{
if (string.IsNullOrWhiteSpace(javaPath))
{
@@ -93,6 +117,8 @@ namespace AlayaCore.Services
throw new FileNotFoundException("Java executable was not found.", javaPath);
}
_logger.LogDebug("Reading Java version from executable at {JavaPath}.", javaPath);
using var process = new Process
{
StartInfo = new ProcessStartInfo
@@ -113,10 +139,26 @@ namespace AlayaCore.Services
if (process.ExitCode != 0 && string.IsNullOrWhiteSpace(standardError))
{
_logger.LogWarning(
"Java version check for {JavaPath} exited with code {ExitCode} and produced no version output.",
javaPath,
process.ExitCode);
return null;
}
return ParseJavaVersion(standardError);
string? version = ParseJavaVersion(standardError);
if (string.IsNullOrWhiteSpace(version))
{
_logger.LogWarning("Java version output from {JavaPath} could not be parsed.", javaPath);
}
else
{
_logger.LogDebug("Parsed Java version {JavaVersion} from {JavaPath}.", version, javaPath);
}
return version;
}
private static string? ParseJavaVersion(string processOutput)
@@ -144,10 +186,12 @@ namespace AlayaCore.Services
if (!File.Exists(fullPath))
{
_logger.LogDebug("Managed Java executable was not found at {JavaPath}.", fullPath);
javaPath = null;
return false;
}
_logger.LogDebug("Managed Java executable was found at {JavaPath}.", fullPath);
javaPath = fullPath;
return true;
}
@@ -156,8 +200,11 @@ namespace AlayaCore.Services
{
string versionsPath = GetVersionsPath();
_logger.LogDebug("Inspecting installed version metadata under {VersionsPath}.", versionsPath);
if (!Directory.Exists(versionsPath))
{
_logger.LogInformation("Versions directory does not exist at {VersionsPath}. No Minecraft or NeoForge installation was detected.", versionsPath);
return InstalledVersionState.Empty();
}
@@ -168,6 +215,7 @@ namespace AlayaCore.Services
if (versionDirectories.Length == 0)
{
_logger.LogInformation("Versions directory at {VersionsPath} was empty.", versionsPath);
return InstalledVersionState.Empty();
}
@@ -180,6 +228,7 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(versionFolderName))
{
_logger.LogDebug("Skipping version directory with an invalid folder name: {VersionDirectory}.", versionDirectory);
continue;
}
@@ -187,11 +236,13 @@ namespace AlayaCore.Services
if (!File.Exists(versionJsonPath))
{
_logger.LogDebug("Skipping version directory {VersionDirectory} because version metadata file {VersionJsonPath} was not found.", versionDirectory, versionJsonPath);
continue;
}
if (!TryLoadJson(versionJsonPath, out JObject? versionJson))
{
_logger.LogWarning("Skipping version metadata file {VersionJsonPath} because it could not be read or parsed.", versionJsonPath);
continue;
}
@@ -200,6 +251,7 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(id))
{
_logger.LogDebug("Skipping version metadata file {VersionJsonPath} because it did not contain a valid id.", versionJsonPath);
continue;
}
@@ -212,18 +264,32 @@ namespace AlayaCore.Services
minecraftVersion = inheritsFrom;
}
_logger.LogDebug(
"Detected NeoForge version metadata. Id: {NeoForgeVersion}, InheritsFrom: {MinecraftVersion}",
id,
inheritsFrom);
continue;
}
minecraftVersion ??= id;
_logger.LogDebug("Detected Minecraft version metadata. Id: {MinecraftVersion}", id);
}
_logger.LogInformation(
"Installed version state resolved. MinecraftVersion: {MinecraftVersion}, NeoForgeVersion: {NeoForgeVersion}",
minecraftVersion,
neoForgeVersion);
return new InstalledVersionState(minecraftVersion, neoForgeVersion);
}
private string GetVersionsPath()
{
return Path.Combine(_fileStore.Get(FolderLocation.Game), VersionsFolderName);
string versionsPath = Path.Combine(_fileStore.Get(FolderLocation.Game), VERSIONS_FOLDER_NAME);
_logger.LogDebug("Resolved versions path to {VersionsPath}.", versionsPath);
return versionsPath;
}
private static bool IsNeoForgeVersion(string? id, string? inheritsFrom)
@@ -242,7 +308,7 @@ namespace AlayaCore.Services
value.Contains("neoforged", StringComparison.OrdinalIgnoreCase);
}
private static bool TryLoadJson(string path, out JObject? jsonObject)
private bool TryLoadJson(string path, out JObject? jsonObject)
{
jsonObject = null;
@@ -251,20 +317,22 @@ namespace AlayaCore.Services
return false;
}
string json = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json))
{
return false;
}
try
{
string json = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogWarning("JSON file at {Path} was empty.", path);
return false;
}
jsonObject = JObject.Parse(json);
return true;
}
catch
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load or parse JSON file at {Path}.", path);
return false;
}
}

View File

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services
{
@@ -13,40 +14,49 @@ namespace AlayaCore.Services
{
private readonly IManifestService _manifestService;
private readonly LauncherUpdateServiceOptions _options;
private readonly ILogger<LauncherUpdateService> _logger;
public LauncherUpdateService(
IManifestService manifestService,
LauncherUpdateServiceOptions options)
LauncherUpdateServiceOptions options,
ILogger<LauncherUpdateService> logger)
{
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> DoesLauncherNeedUpdating(CancellationToken cancellationToken = default)
{
if (_options.ForceUpdate)
{
_logger.LogWarning("Launcher update check is being forced by configuration.");
return true;
}
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Checking whether the launcher requires an update.");
LauncherManifestModel? localManifest = await _manifestService
.GetLocalLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false);
if (localManifest == null)
{
_logger.LogInformation("No local launcher manifest was found. The launcher will be treated as requiring an update.");
return true;
}
if (localManifest.Version == null)
{
_logger.LogWarning("Local launcher manifest did not contain a valid version. The launcher will be treated as requiring an update.");
return true;
}
if (string.IsNullOrWhiteSpace(localManifest.Sha512Hash))
{
_logger.LogWarning("Local launcher manifest did not contain a valid SHA-512 hash. The launcher will be treated as requiring an update.");
return true;
}
@@ -56,16 +66,19 @@ namespace AlayaCore.Services
if (remoteManifest == null)
{
_logger.LogError("Remote launcher manifest could not be loaded.");
throw new InvalidOperationException("Remote launcher manifest could not be loaded.");
}
if (remoteManifest.Version == null)
{
_logger.LogError("Remote launcher manifest did not contain a valid version.");
throw new InvalidOperationException("Remote launcher manifest returned an invalid version.");
}
if (string.IsNullOrWhiteSpace(remoteManifest.Sha512Hash))
{
_logger.LogError("Remote launcher manifest did not contain a valid SHA-512 hash.");
throw new InvalidOperationException("Remote launcher manifest returned an invalid SHA-512 hash.");
}
@@ -75,6 +88,13 @@ namespace AlayaCore.Services
remoteManifest.Sha512Hash.Trim(),
StringComparison.OrdinalIgnoreCase);
_logger.LogInformation(
"Launcher update check complete. LocalVersion: {LocalVersion}, RemoteVersion: {RemoteVersion}, VersionMismatch: {VersionMismatch}, HashMismatch: {HashMismatch}",
localManifest.Version,
remoteManifest.Version,
versionMismatch,
hashMismatch);
return versionMismatch || hashMismatch;
}
@@ -91,11 +111,13 @@ namespace AlayaCore.Services
if (newManifest.DownloadUri == null)
{
_logger.LogError("Updater launch failed because the launcher manifest did not contain a download URI.");
throw new InvalidOperationException("Launcher manifest does not contain a download URI.");
}
if (!newManifest.DownloadUri.IsAbsoluteUri)
{
_logger.LogError("Updater launch failed because the launcher download URI was not absolute. DownloadUri: {DownloadUri}", newManifest.DownloadUri);
throw new InvalidOperationException("Launcher download URI must be absolute.");
}
@@ -103,16 +125,19 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(updaterPath))
{
_logger.LogError("Updater launch failed because the updater path was not configured.");
throw new InvalidOperationException("Updater path is not configured.");
}
if (!Path.IsPathFullyQualified(updaterPath))
{
_logger.LogError("Updater launch failed because the updater path was not absolute. UpdaterPath: {UpdaterPath}", updaterPath);
throw new InvalidOperationException("Updater path must be absolute.");
}
if (!File.Exists(updaterPath))
{
_logger.LogError("Updater launch failed because the updater executable was not found at {UpdaterPath}.", updaterPath);
throw new FileNotFoundException("Alaya updater program was not found.", updaterPath);
}
@@ -130,17 +155,30 @@ namespace AlayaCore.Services
CreateNoWindow = true
};
_logger.LogInformation(
"Launching updater process. UpdaterPath: {UpdaterPath}, WorkingDirectory: {WorkingDirectory}, DownloadUri: {DownloadUri}",
updaterPath,
workingDirectory,
newManifest.DownloadUri.AbsoluteUri);
try
{
Process? process = Process.Start(startInfo);
if (process == null)
{
_logger.LogError("Updater process start returned null for executable {UpdaterPath}.", updaterPath);
throw new InvalidOperationException("Failed to start updater process.");
}
_logger.LogInformation(
"Updater process launched successfully. ProcessId: {ProcessId}, UpdaterPath: {UpdaterPath}",
process.Id,
updaterPath);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to launch updater process from {UpdaterPath}.", updaterPath);
throw new InvalidOperationException("Failed to launch updater process.", ex);
}

View File

@@ -11,37 +11,46 @@ using AlayaCore.Models.Manifests;
using AlayaCore.Models.Manifests.DTO;
using AlayaCore.Utilities.Enums;
using AlayaCore.Utilities.Extensions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
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 ALAYA_MANIFEST_FILE_NAME = "AlayaManifest.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;
private readonly IFileStore _fileStore;
private readonly ManifestServiceOptions _options;
private readonly ILogger<ManifestService> _logger;
public ManifestService(
IDownloadService downloadService,
IHttpClient httpClient,
IFileStore fileStore,
ManifestServiceOptions options)
ManifestServiceOptions options,
ILogger<ManifestService> logger)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<ManifestModel> GetCoreManifestAsync(CancellationToken cancellationToken = default)
public Task<ManifestModel> GetAlayaManifestAsync(CancellationToken cancellationToken = default)
{
string destinationPath = GetCoreManifestPath();
_logger.LogInformation(
"Downloading and loading Alaya manifest from {ManifestUri} to {DestinationPath}.",
_options.CoreManifestUri,
destinationPath);
return DownloadAndLoadManifestAsync<ManifestDto, ManifestModel>(
_options.CoreManifestUri,
destinationPath,
@@ -55,8 +64,11 @@ namespace AlayaCore.Services
{
string path = GetInstalledModsManifestPath();
_logger.LogDebug("Loading installed mods manifest from {ManifestPath}.", path);
if (!File.Exists(path))
{
_logger.LogInformation("Installed mods manifest was not found at {ManifestPath}. Returning an empty manifest.", path);
return InstalledModsManifestModel.Empty();
}
@@ -64,6 +76,7 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogWarning("Installed mods manifest at {ManifestPath} was empty. Returning an empty manifest.", path);
return InstalledModsManifestModel.Empty();
}
@@ -74,13 +87,29 @@ namespace AlayaCore.Services
static dto => dto.ToModel(),
swallowDeserializationErrors: true);
return manifest ?? InstalledModsManifestModel.Empty();
if (manifest == null)
{
_logger.LogWarning("Installed mods manifest at {ManifestPath} could not be deserialized. Returning an empty manifest.", path);
return InstalledModsManifestModel.Empty();
}
_logger.LogInformation(
"Loaded installed mods manifest from {ManifestPath}. Mod count: {ModCount}",
path,
manifest.Mods.Count);
return manifest;
}
public Task<LauncherManifestModel> GetLauncherManifestAsync(CancellationToken cancellationToken = default)
{
string destinationPath = GetLauncherManifestPath();
_logger.LogInformation(
"Downloading and loading launcher manifest from {ManifestUri} to {DestinationPath}.",
_options.LauncherManifestUri,
destinationPath);
return DownloadAndLoadManifestAsync<LauncherManifestDto, LauncherManifestModel>(
_options.LauncherManifestUri,
destinationPath,
@@ -89,24 +118,34 @@ namespace AlayaCore.Services
cancellationToken);
}
public Task<ManifestModel?> GetLocalCoreManifestAsync(CancellationToken cancellationToken = default)
public Task<ManifestModel?> GetLocalAlayaManifestAsync(CancellationToken cancellationToken = default)
{
string path = GetCoreManifestPath();
_logger.LogDebug("Loading local Alaya manifest from {ManifestPath}.", path);
return LoadLocalManifestAsync<ManifestDto, ManifestModel>(
GetCoreManifestPath(),
path,
static dto => dto.ToModel(),
cancellationToken);
}
public Task<LauncherManifestModel?> GetLocalLauncherManifestAsync(CancellationToken cancellationToken = default)
{
string path = GetLauncherManifestPath();
_logger.LogDebug("Loading local launcher manifest from {ManifestPath}.", path);
return LoadLocalManifestAsync<LauncherManifestDto, LauncherManifestModel>(
GetLauncherManifestPath(),
path,
static dto => dto.ToModel(),
cancellationToken);
}
public async Task<Version> GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Fetching remote Alaya manifest version from {ManifestUri}.", _options.CoreManifestUri);
ManifestModel remoteManifest = await GetRemoteManifestAsync<ManifestDto, ManifestModel>(
_options.CoreManifestUri,
static dto => dto.ToModel(),
@@ -114,16 +153,24 @@ namespace AlayaCore.Services
if (remoteManifest.AlayaVersion == null)
{
_logger.LogError("Remote Alaya manifest from {ManifestUri} did not contain a valid version.", _options.CoreManifestUri);
throw new InvalidDataException(
$"Remote core manifest from '{_options.CoreManifestUri}' does not contain a valid version.");
}
_logger.LogInformation(
"Fetched remote Alaya manifest version {RemoteVersion} from {ManifestUri}.",
remoteManifest.AlayaVersion,
_options.CoreManifestUri);
return remoteManifest.AlayaVersion;
}
public async Task<LauncherManifestModel> GetRemoteLauncherManifestAsync(
CancellationToken cancellationToken = default)
{
_logger.LogDebug("Fetching remote launcher manifest from {ManifestUri}.", _options.LauncherManifestUri);
LauncherManifestModel remoteManifest = await GetRemoteManifestAsync<LauncherManifestDto, LauncherManifestModel>(
_options.LauncherManifestUri,
static dto => dto.ToModel(),
@@ -131,32 +178,45 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(remoteManifest.Sha512Hash))
{
_logger.LogError("Remote launcher manifest from {ManifestUri} did not contain a valid SHA-512 hash.", _options.LauncherManifestUri);
throw new InvalidDataException(
$"Remote launcher manifest from '{_options.LauncherManifestUri}' does not contain a valid SHA-512 hash.");
}
if (remoteManifest.DownloadUri == null || !remoteManifest.DownloadUri.IsAbsoluteUri)
{
_logger.LogError("Remote launcher manifest from {ManifestUri} did not contain a valid download URI.", _options.LauncherManifestUri);
throw new InvalidDataException(
$"Remote launcher manifest from '{_options.LauncherManifestUri}' does not contain a valid download URI.");
}
_logger.LogInformation(
"Fetched remote launcher manifest. Version: {Version}, DownloadUri: {DownloadUri}",
remoteManifest.Version,
remoteManifest.DownloadUri);
return remoteManifest;
}
public string GetLauncherManifestPath()
{
return Path.Combine(_fileStore.Get(FolderLocation.Manifests), LauncherManifestFileName);
string path = Path.Combine(_fileStore.Get(FolderLocation.Manifests), LAUNCHER_MANIFEST_FILE_NAME);
_logger.LogDebug("Resolved launcher manifest path to {ManifestPath}.", path);
return path;
}
public string GetCoreManifestPath()
{
return Path.Combine(_fileStore.Get(FolderLocation.Manifests), CoreManifestFileName);
string path = Path.Combine(_fileStore.Get(FolderLocation.Manifests), ALAYA_MANIFEST_FILE_NAME);
_logger.LogDebug("Resolved Alaya manifest path to {ManifestPath}.", path);
return path;
}
public string GetInstalledModsManifestPath()
{
return Path.Combine(_fileStore.Get(FolderLocation.Manifests), InstalledModsManifestFileName);
string path = Path.Combine(_fileStore.Get(FolderLocation.Manifests), INSTALLED_MODS_MANIFEST_FILE_NAME);
_logger.LogDebug("Resolved installed mods manifest path to {ManifestPath}.", path);
return path;
}
private async Task<TModel?> LoadLocalManifestAsync<TDto, TModel>(
@@ -177,6 +237,7 @@ namespace AlayaCore.Services
if (!File.Exists(path))
{
_logger.LogInformation("Local manifest was not found at {ManifestPath}.", path);
return default;
}
@@ -184,14 +245,26 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogWarning("Local manifest at {ManifestPath} was empty.", path);
return default;
}
return DeserializeAndMapManifest<TDto, TModel>(
TModel? model = DeserializeAndMapManifest(
json,
path,
map,
swallowDeserializationErrors: true);
if (model == null)
{
_logger.LogWarning("Local manifest at {ManifestPath} could not be deserialized or mapped.", path);
}
else
{
_logger.LogDebug("Successfully loaded local manifest from {ManifestPath}.", path);
}
return model;
}
private async Task<TModel> DownloadAndLoadManifestAsync<TDto, TModel>(
@@ -231,6 +304,11 @@ namespace AlayaCore.Services
EnsureDirectoryExists(destinationPath);
_logger.LogInformation(
"Downloading manifest from {ManifestUri} to {DestinationPath}.",
manifestUri,
destinationPath);
await _downloadService.DownloadFileAsync(
manifestUri,
destinationPath,
@@ -239,6 +317,7 @@ namespace AlayaCore.Services
if (!File.Exists(destinationPath))
{
_logger.LogError("Manifest file was not found after download at {DestinationPath}.", destinationPath);
throw new FileNotFoundException("Manifest file was not found after download.", destinationPath);
}
@@ -246,14 +325,22 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogError("Downloaded manifest file at {DestinationPath} was empty.", destinationPath);
throw new InvalidDataException($"Manifest file '{destinationPath}' was empty.");
}
return DeserializeAndMapManifest<TDto, TModel>(
TModel model = DeserializeAndMapManifest(
json,
destinationPath,
map,
swallowDeserializationErrors: false)!;
_logger.LogInformation(
"Downloaded and loaded manifest successfully from {ManifestUri} into {DestinationPath}.",
manifestUri,
destinationPath);
return model;
}
private async Task<TModel> GetRemoteManifestAsync<TDto, TModel>(
@@ -279,6 +366,8 @@ namespace AlayaCore.Services
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Fetching remote manifest from {ManifestUri}.", manifestUri);
using HttpResponseMessage response = await _httpClient.GetAsync(
manifestUri,
HttpCompletionOption.ResponseContentRead,
@@ -290,18 +379,23 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogError("Remote manifest response from {ManifestUri} was empty.", manifestUri);
throw new InvalidDataException(
$"Remote manifest response from '{manifestUri}' was empty.");
}
return DeserializeAndMapManifest<TDto, TModel>(
TModel model = DeserializeAndMapManifest(
json,
manifestUri.ToString(),
map,
swallowDeserializationErrors: false)!;
_logger.LogDebug("Successfully fetched and mapped remote manifest from {ManifestUri}.", manifestUri);
return model;
}
private static TModel? DeserializeAndMapManifest<TDto, TModel>(
private TModel? DeserializeAndMapManifest<TDto, TModel>(
string json,
string sourceName,
Func<TDto, TModel> map,
@@ -312,9 +406,11 @@ namespace AlayaCore.Services
{
if (swallowDeserializationErrors)
{
_logger.LogWarning("Manifest source {SourceName} was empty and deserialization errors were configured to be swallowed.", sourceName);
return default;
}
_logger.LogError("Manifest source {SourceName} was empty.", sourceName);
throw new InvalidDataException($"Manifest source '{sourceName}' was empty.");
}
@@ -337,9 +433,11 @@ namespace AlayaCore.Services
{
if (swallowDeserializationErrors)
{
_logger.LogWarning(ex, "Failed to deserialize manifest source {SourceName} to {DtoType}. Returning default because deserialization errors are being swallowed.", sourceName, typeof(TDto).Name);
return default;
}
_logger.LogError(ex, "Failed to deserialize manifest source {SourceName} to {DtoType}.", sourceName, typeof(TDto).Name);
throw new JsonSerializationException(
$"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.",
ex);
@@ -349,24 +447,34 @@ namespace AlayaCore.Services
{
if (swallowDeserializationErrors)
{
_logger.LogWarning("Deserialization of manifest source {SourceName} to {DtoType} returned null. Returning default because deserialization errors are being swallowed.", sourceName, typeof(TDto).Name);
return default;
}
_logger.LogError("Deserialization of manifest source {SourceName} to {DtoType} returned null.", sourceName, typeof(TDto).Name);
throw new JsonSerializationException(
$"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.");
}
try
{
return map(dto);
TModel model = map(dto);
_logger.LogDebug(
"Mapped manifest source {SourceName} from {DtoType} to {ModelType}.",
sourceName,
typeof(TDto).Name,
typeof(TModel).Name);
return model;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
if (swallowDeserializationErrors)
{
_logger.LogWarning(ex, "Manifest source {SourceName} was deserialized but could not be mapped to {ModelType}. Returning default because mapping errors are being swallowed.", sourceName, typeof(TModel).Name);
return default;
}
_logger.LogError(ex, "Manifest source {SourceName} was deserialized but could not be mapped to {ModelType}.", sourceName, typeof(TModel).Name);
throw new InvalidDataException(
$"Manifest source '{sourceName}' was deserialized but could not be mapped to {typeof(TModel).Name}.",
ex);

View File

@@ -13,8 +13,10 @@ using AlayaCore.Models;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
using AlayaCore.Models.Manifests.DTO;
using AlayaCore.Models.Progress;
using AlayaCore.Utilities.Enums;
using AlayaCore.Utilities.Extensions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -28,22 +30,26 @@ namespace AlayaCore.Services
private readonly ModrinthConnectionOptions _options;
private readonly IHttpClient _httpClient;
private readonly IFileStore _fileStore;
private readonly ILogger<ModService> _logger;
public ModService(
IDownloadService downloadService,
ModrinthConnectionOptions options,
IHttpClient httpClient,
IFileStore fileStore)
IFileStore fileStore,
ILogger<ModService> logger)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task ProcessModsAsync(
ManifestModel manifest,
InstallEnvironment environment,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
if (manifest == null)
@@ -62,9 +68,15 @@ namespace AlayaCore.Services
List<ModFileEntry> requiredMods = manifest.Files
.Where(file => file.Type == FileType.Mod)
.OrderBy(file => file.FileName, StringComparer.OrdinalIgnoreCase)
.ToList();
RemoveStaleMods(requiredMods);
_logger.LogInformation(
"Starting mod sync. RequiredMods: {RequiredModCount}, InstalledModsManifestEntries: {InstalledModCount}",
requiredMods.Count,
installedMods.Count);
RemoveStaleMods(requiredMods, cancellationToken);
List<ModFileEntry> finalInstalledMods = new List<ModFileEntry>();
@@ -72,6 +84,12 @@ namespace AlayaCore.Services
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug(
"Processing required mod {FileName}. Expected SHA-512: {Sha512Hash}, Expected Size: {Size}",
requiredMod.FileName,
requiredMod.Sha512Hash,
requiredMod.Size);
ModFileEntry? installedMod = installedMods.FirstOrDefault(
mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
@@ -85,18 +103,50 @@ namespace AlayaCore.Services
if (isValidInstalledMod)
{
_logger.LogInformation(
"Mod {FileName} is already installed and valid. Skipping download.",
requiredMod.FileName);
finalInstalledMods.Add(installedMod!);
continue;
}
if (installedMod == null)
{
_logger.LogInformation(
"Mod {FileName} is missing locally and will be downloaded.",
requiredMod.FileName);
}
else
{
_logger.LogWarning(
"Mod {FileName} is present but invalid or outdated. Stored SHA-512: {InstalledHash}, Expected SHA-512: {RequiredHash}, Stored Size: {InstalledSize}, Expected Size: {RequiredSize}",
requiredMod.FileName,
installedMod.Sha512Hash,
requiredMod.Sha512Hash,
installedMod.Size,
requiredMod.Size);
}
Uri modUri = await ResolveModUrlAsync(requiredMod, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Downloading mod {FileName} from {ModUri} to {DestinationPath}.",
requiredMod.FileName,
modUri,
destinationPath);
await _downloadService.DownloadFileAsync(
modUri,
destinationPath,
requiredMod.Sha512Hash,
progress,
cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Download completed successfully for mod {FileName}.",
requiredMod.FileName);
finalInstalledMods.Add(new ModFileEntry(
requiredMod.FileName,
requiredMod.Type,
@@ -105,6 +155,10 @@ namespace AlayaCore.Services
}
await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Mod sync completed successfully. Final installed mod count: {InstalledModCount}",
finalInstalledMods.Count);
}
private static bool IsInstalledModUpToDate(
@@ -145,11 +199,20 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(fileEntry.Sha512Hash))
{
_logger.LogError(
"Failed to resolve mod URL because mod {FileName} did not contain a SHA-512 hash.",
fileEntry.FileName);
throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a SHA-512 hash.");
}
string versionEndpoint = BuildVersionEndpoint(fileEntry.Sha512Hash);
_logger.LogDebug(
"Resolving mod URL for {FileName} using Modrinth endpoint {VersionEndpoint}.",
fileEntry.FileName,
versionEndpoint);
using HttpResponseMessage response = await _httpClient.GetAsync(
new Uri(versionEndpoint, UriKind.Absolute),
HttpCompletionOption.ResponseContentRead,
@@ -161,6 +224,11 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogError(
"Mod metadata response for {FileName} from {VersionEndpoint} was empty.",
fileEntry.FileName,
versionEndpoint);
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty.");
}
@@ -169,6 +237,10 @@ namespace AlayaCore.Services
JArray? filesArray = jsonObject["files"] as JArray;
if (filesArray == null || filesArray.Count == 0)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain any files.",
fileEntry.FileName);
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files.");
}
@@ -179,12 +251,20 @@ namespace AlayaCore.Services
if (selectedFile == null)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a usable file entry.",
fileEntry.FileName);
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry.");
}
JObject? hashesObject = selectedFile["hashes"] as JObject;
if (hashesObject == null)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a hashes object.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a hashes object.");
}
@@ -192,12 +272,22 @@ namespace AlayaCore.Services
string? remoteSha512Hash = hashesObject.Value<string>("sha512");
if (string.IsNullOrWhiteSpace(remoteSha512Hash))
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid SHA-512 hash.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash.");
}
if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"Mod metadata hash mismatch for {FileName}. Remote SHA-512: {RemoteHash}, Required SHA-512: {RequiredHash}",
fileEntry.FileName,
remoteSha512Hash,
fileEntry.Sha512Hash);
throw new InvalidDataException(
$"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash.");
}
@@ -205,12 +295,22 @@ namespace AlayaCore.Services
long? size = selectedFile.Value<long?>("size");
if (!size.HasValue || size.Value <= 0)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid file size.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
}
if (size.Value != fileEntry.Size)
{
_logger.LogError(
"Mod metadata size mismatch for {FileName}. Remote Size: {RemoteSize}, Required Size: {RequiredSize}",
fileEntry.FileName,
size.Value,
fileEntry.Size);
throw new InvalidDataException(
$"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size.");
}
@@ -218,16 +318,30 @@ namespace AlayaCore.Services
string? modUrl = selectedFile.Value<string>("url");
if (string.IsNullOrWhiteSpace(modUrl))
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid file URL.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL.");
}
if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
{
_logger.LogError(
"Mod metadata response for {FileName} contained an invalid file URL: {ModUrl}",
fileEntry.FileName,
modUrl);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL.");
}
_logger.LogDebug(
"Resolved download URL for mod {FileName} to {ModUrl}.",
fileEntry.FileName,
result);
return result;
}
@@ -252,26 +366,37 @@ namespace AlayaCore.Services
}
string modsDirectory = GetModsDirectoryPath();
string destinationPath = Path.Combine(modsDirectory, fileEntry.FileName);
return Path.Combine(modsDirectory, fileEntry.FileName);
_logger.LogDebug(
"Resolved destination path for mod {FileName} to {DestinationPath}.",
fileEntry.FileName,
destinationPath);
return destinationPath;
}
private string GetModsDirectoryPath()
{
return _fileStore.GetOrCreate(FolderLocation.Mods);
string modsDirectory = _fileStore.GetOrCreate(FolderLocation.Mods);
_logger.LogDebug("Resolved mods directory to {ModsDirectory}.", modsDirectory);
return modsDirectory;
}
private void RemoveStaleMods(IEnumerable<ModFileEntry> requiredMods)
private void RemoveStaleMods(
IEnumerable<ModFileEntry> requiredMods,
CancellationToken cancellationToken)
{
if (requiredMods == null)
{
throw new ArgumentNullException(nameof(requiredMods));
}
string modsDirectory = GetModsDirectoryPath();
string modsDirectory = _fileStore.Get(FolderLocation.Mods);
if (!Directory.Exists(modsDirectory))
{
_logger.LogDebug("Mods directory {ModsDirectory} does not exist. No stale mods need removal.", modsDirectory);
return;
}
@@ -282,10 +407,17 @@ namespace AlayaCore.Services
foreach (string filePath in Directory.GetFiles(modsDirectory))
{
cancellationToken.ThrowIfCancellationRequested();
string fileName = Path.GetFileName(filePath);
if (!requiredFileNames.Contains(fileName))
{
_logger.LogInformation(
"Removing stale mod file {FileName} at {FilePath}.",
fileName,
filePath);
File.Delete(filePath);
}
}
@@ -312,14 +444,25 @@ namespace AlayaCore.Services
string json = JsonConvert.SerializeObject(dto, Formatting.Indented);
_logger.LogDebug(
"Writing installed mods manifest to temporary path {TemporaryManifestPath}. EntryCount: {EntryCount}",
temporaryManifestPath,
entries.Count);
await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false);
if (File.Exists(manifestPath))
{
_logger.LogDebug("Deleting previous installed mods manifest at {ManifestPath}.", manifestPath);
File.Delete(manifestPath);
}
File.Move(temporaryManifestPath, manifestPath);
_logger.LogInformation(
"Installed mods manifest updated successfully at {ManifestPath}. EntryCount: {EntryCount}",
manifestPath,
entries.Count);
}
}
}

View File

@@ -7,6 +7,7 @@ using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Configuration;
using AlayaCore.Utilities.Enums;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AlayaCore.Services
@@ -14,6 +15,7 @@ namespace AlayaCore.Services
public sealed class SettingsService : ISettingsService
{
private readonly IFileStore _fileStore;
private readonly ILogger<SettingsService> _logger;
public LauncherOptions LauncherOptions { get; }
public GameOptions GameOptions { get; }
@@ -21,84 +23,326 @@ namespace AlayaCore.Services
public SettingsService(
LauncherOptions launcherOptions,
GameOptions gameOptions,
IFileStore fileStore)
IFileStore fileStore,
ILogger<SettingsService> logger)
{
LauncherOptions = launcherOptions ?? throw new ArgumentNullException(nameof(launcherOptions));
GameOptions = gameOptions ?? throw new ArgumentNullException(nameof(gameOptions));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task SetForceReinstallAsync(bool value, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Updating launcher option ForceReinstall to {ForceReinstall}.", value);
LauncherOptions.ForceReinstall = value;
await SaveLauncherOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Launcher option ForceReinstall was updated and saved successfully.");
}
public async Task UpdateLaunchVersionAsync(string newVersion, CancellationToken cancellationToken = default)
{
GameOptions.LaunchVersion = newVersion ?? string.Empty;
string? normalizedVersion = string.IsNullOrWhiteSpace(newVersion)
? null
: newVersion.Trim();
_logger.LogInformation(
"Updating game option LaunchVersion from {CurrentLaunchVersion} to {NewLaunchVersion}.",
GameOptions.LaunchVersion,
normalizedVersion);
GameOptions.LaunchVersion = normalizedVersion;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option LaunchVersion was updated and saved successfully.");
}
public async Task SetMinimumRamMbAsync(int minimumRamMb, CancellationToken cancellationToken = default)
{
if (minimumRamMb <= 0)
{
throw new ArgumentOutOfRangeException(nameof(minimumRamMb), "Minimum RAM must be greater than zero.");
}
if (GameOptions.MaximumRamMb > 0 && minimumRamMb > GameOptions.MaximumRamMb)
{
throw new ArgumentException("Minimum RAM cannot be greater than maximum RAM.", nameof(minimumRamMb));
}
_logger.LogInformation(
"Updating game option MinimumRamMb from {CurrentMinimumRamMb} to {NewMinimumRamMb}.",
GameOptions.MinimumRamMb,
minimumRamMb);
GameOptions.MinimumRamMb = minimumRamMb;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option MinimumRamMb was updated and saved successfully.");
}
public async Task SetMaximumRamMbAsync(int maximumRamMb, CancellationToken cancellationToken = default)
{
if (maximumRamMb <= 0)
{
throw new ArgumentOutOfRangeException(nameof(maximumRamMb), "Maximum RAM must be greater than zero.");
}
if (GameOptions.MinimumRamMb > 0 && maximumRamMb < GameOptions.MinimumRamMb)
{
throw new ArgumentException("Maximum RAM cannot be less than minimum RAM.", nameof(maximumRamMb));
}
_logger.LogInformation(
"Updating game option MaximumRamMb from {CurrentMaximumRamMb} to {NewMaximumRamMb}.",
GameOptions.MaximumRamMb,
maximumRamMb);
GameOptions.MaximumRamMb = maximumRamMb;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option MaximumRamMb was updated and saved successfully.");
}
public async Task SetResolutionAsync(int screenWidth, int screenHeight, CancellationToken cancellationToken = default)
{
if (screenWidth <= 0)
{
throw new ArgumentOutOfRangeException(nameof(screenWidth), "Screen width must be greater than zero.");
}
if (screenHeight <= 0)
{
throw new ArgumentOutOfRangeException(nameof(screenHeight), "Screen height must be greater than zero.");
}
_logger.LogInformation(
"Updating game resolution from {CurrentWidth}x{CurrentHeight} to {NewWidth}x{NewHeight}.",
GameOptions.ScreenWidth,
GameOptions.ScreenHeight,
screenWidth,
screenHeight);
GameOptions.ScreenWidth = screenWidth;
GameOptions.ScreenHeight = screenHeight;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game resolution was updated and saved successfully.");
}
public async Task SetFullscreenAsync(bool fullscreen, CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Updating game option Fullscreen from {CurrentFullscreen} to {NewFullscreen}.",
GameOptions.Fullscreen,
fullscreen);
GameOptions.Fullscreen = fullscreen;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option Fullscreen was updated and saved successfully.");
}
public async Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Saving launcher options to disk.");
await SaveAsync(
LauncherOptions.FileName,
LauncherOptions,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Launcher options were saved successfully.");
}
public async Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Loading launcher options from disk.");
LauncherOptions? loadedOptions = await LoadAsync<LauncherOptions>(
LauncherOptions.FileName,
cancellationToken).ConfigureAwait(false);
if (loadedOptions == null)
{
_logger.LogInformation("No launcher options file was found or it was empty. Existing in-memory launcher options will be kept.");
return;
}
LauncherOptions.ForceReinstall = loadedOptions.ForceReinstall;
_logger.LogInformation(
"Launcher options were loaded successfully. ForceReinstall: {ForceReinstall}",
LauncherOptions.ForceReinstall);
}
public async Task SaveGameOptionsAsync(CancellationToken cancellationToken = default)
{
ValidateGameOptions(GameOptions);
_logger.LogDebug(
"Saving game options to disk. LaunchVersion: {LaunchVersion}, MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, Resolution: {ScreenWidth}x{ScreenHeight}, Fullscreen: {Fullscreen}",
GameOptions.LaunchVersion,
GameOptions.MinimumRamMb,
GameOptions.MaximumRamMb,
GameOptions.ScreenWidth,
GameOptions.ScreenHeight,
GameOptions.Fullscreen);
await SaveAsync(
GameOptions.FileName,
GameOptions,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game options were saved successfully.");
}
public async Task LoadGameOptionsAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Loading game options from disk.");
GameOptions? loadedOptions = await LoadAsync<GameOptions>(
GameOptions.FileName,
cancellationToken).ConfigureAwait(false);
if (loadedOptions == null)
{
_logger.LogInformation("No game options file was found or it was empty. Applying default game options.");
ApplyGameOptions(GameOptions.Default);
return;
}
GameOptions.LaunchVersion = loadedOptions.LaunchVersion;
GameOptions.MinimumRamMB = loadedOptions.MinimumRamMB;
GameOptions.MaximumRamMB = loadedOptions.MaximumRamMB;
GameOptions.ScreenWidth = loadedOptions.ScreenWidth;
GameOptions.ScreenHeight = loadedOptions.ScreenHeight;
GameOptions.Fullscreen = loadedOptions.Fullscreen;
ApplyGameOptions(loadedOptions);
_logger.LogInformation(
"Game options were loaded successfully. LaunchVersion: {LaunchVersion}, MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, Resolution: {ScreenWidth}x{ScreenHeight}, Fullscreen: {Fullscreen}",
GameOptions.LaunchVersion,
GameOptions.MinimumRamMb,
GameOptions.MaximumRamMb,
GameOptions.ScreenWidth,
GameOptions.ScreenHeight,
GameOptions.Fullscreen);
}
public async Task LoadAllAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Loading all settings from disk.");
await LoadLauncherOptionsAsync(cancellationToken).ConfigureAwait(false);
await LoadGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("All settings were loaded successfully.");
}
public async Task SaveAllAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Saving all settings to disk.");
await SaveLauncherOptionsAsync(cancellationToken).ConfigureAwait(false);
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("All settings were saved successfully.");
}
private void ApplyGameOptions(GameOptions source)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}
_logger.LogDebug(
"Applying game options from source. LaunchVersion: {LaunchVersion}, MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, Resolution: {ScreenWidth}x{ScreenHeight}, Fullscreen: {Fullscreen}",
source.LaunchVersion,
source.MinimumRamMb,
source.MaximumRamMb,
source.ScreenWidth,
source.ScreenHeight,
source.Fullscreen);
GameOptions.LaunchVersion = string.IsNullOrWhiteSpace(source.LaunchVersion)
? null
: source.LaunchVersion.Trim();
GameOptions.MinimumRamMb = source.MinimumRamMb > 0
? source.MinimumRamMb
: GameOptions.Default.MinimumRamMb;
GameOptions.MaximumRamMb = source.MaximumRamMb > 0
? source.MaximumRamMb
: GameOptions.Default.MaximumRamMb;
GameOptions.ScreenWidth = source.ScreenWidth > 0
? source.ScreenWidth
: GameOptions.Default.ScreenWidth;
GameOptions.ScreenHeight = source.ScreenHeight > 0
? source.ScreenHeight
: GameOptions.Default.ScreenHeight;
GameOptions.Fullscreen = source.Fullscreen;
ValidateGameOptions(GameOptions);
_logger.LogDebug("Game options were applied successfully.");
}
private void ValidateGameOptions(GameOptions options)
{
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
_logger.LogDebug(
"Validating game options. MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, ScreenWidth: {ScreenWidth}, ScreenHeight: {ScreenHeight}",
options.MinimumRamMb,
options.MaximumRamMb,
options.ScreenWidth,
options.ScreenHeight);
if (options.MinimumRamMb <= 0)
{
_logger.LogError("Game options validation failed because MinimumRamMb was {MinimumRamMb}.", options.MinimumRamMb);
throw new InvalidDataException("Minimum RAM must be greater than zero.");
}
if (options.MaximumRamMb <= 0)
{
_logger.LogError("Game options validation failed because MaximumRamMb was {MaximumRamMb}.", options.MaximumRamMb);
throw new InvalidDataException("Maximum RAM must be greater than zero.");
}
if (options.MinimumRamMb > options.MaximumRamMb)
{
_logger.LogError(
"Game options validation failed because MinimumRamMb {MinimumRamMb} was greater than MaximumRamMb {MaximumRamMb}.",
options.MinimumRamMb,
options.MaximumRamMb);
throw new InvalidDataException("Minimum RAM cannot be greater than maximum RAM.");
}
if (options.ScreenWidth <= 0)
{
_logger.LogError("Game options validation failed because ScreenWidth was {ScreenWidth}.", options.ScreenWidth);
throw new InvalidDataException("Screen width must be greater than zero.");
}
if (options.ScreenHeight <= 0)
{
_logger.LogError("Game options validation failed because ScreenHeight was {ScreenHeight}.", options.ScreenHeight);
throw new InvalidDataException("Screen height must be greater than zero.");
}
_logger.LogDebug("Game options validation completed successfully.");
}
private async Task SaveAsync<T>(
@@ -123,6 +367,7 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(directoryPath))
{
_logger.LogError("Could not resolve the settings directory path for file {FileName}.", fileName);
throw new InvalidOperationException("Could not resolve the settings directory path.");
}
@@ -131,14 +376,23 @@ namespace AlayaCore.Services
string temporaryPath = fullPath + ".tmp";
string json = JsonConvert.SerializeObject(value, Formatting.Indented);
_logger.LogDebug(
"Writing settings file {FileName} to temporary path {TemporaryPath} before replacing {FullPath}.",
fileName,
temporaryPath,
fullPath);
await File.WriteAllTextAsync(temporaryPath, json, cancellationToken).ConfigureAwait(false);
if (File.Exists(fullPath))
{
_logger.LogDebug("Deleting existing settings file at {FullPath}.", fullPath);
File.Delete(fullPath);
}
File.Move(temporaryPath, fullPath);
_logger.LogInformation("Settings file {FileName} was saved successfully to {FullPath}.", fileName, fullPath);
}
private async Task<T?> LoadAsync<T>(
@@ -156,22 +410,38 @@ namespace AlayaCore.Services
if (!File.Exists(fullPath))
{
_logger.LogInformation("Settings file {FileName} was not found at {FullPath}.", fileName, fullPath);
return default;
}
_logger.LogDebug("Loading settings file {FileName} from {FullPath}.", fileName, fullPath);
string json = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogWarning("Settings file {FileName} at {FullPath} was empty.", fileName, fullPath);
return default;
}
try
{
return JsonConvert.DeserializeObject<T>(json);
T? result = JsonConvert.DeserializeObject<T>(json);
if (result == null)
{
_logger.LogWarning("Deserializing settings file {FileName} at {FullPath} returned null.", fileName, fullPath);
}
else
{
_logger.LogDebug("Settings file {FileName} was deserialized successfully.", fileName);
}
return result;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to deserialize settings file {FileName} at {FullPath} to {TypeName}.", fileName, fullPath, typeof(T).Name);
throw new InvalidDataException(
$"Failed to deserialize settings file '{fullPath}' to {typeof(T).Name}.",
ex);
@@ -180,7 +450,9 @@ namespace AlayaCore.Services
private string GetFullPath(string fileName)
{
return Path.Combine(_fileStore.GetOrCreate(FolderLocation.Config), fileName);
string fullPath = Path.Combine(_fileStore.GetOrCreate(FolderLocation.Config), fileName);
_logger.LogDebug("Resolved settings file path for {FileName} to {FullPath}.", fileName, fullPath);
return fullPath;
}
}
}

View File

@@ -2,6 +2,7 @@ namespace AlayaCore.States
{
public enum LaunchState
{
Checking,
Ready,
LauncherNeedsUpdate,
NeedAuthenticating,