From 11863088e4a9f9df2712351329ea636517bf0502 Mon Sep 17 00:00:00 2001 From: Ryan Macham Date: Mon, 6 Apr 2026 19:40:21 +0100 Subject: [PATCH] ILogger support added --- AlayaCore.sln.DotSettings.user | 3 + .../Interfaces/ILaunchDirector.cs | 27 +- .../Interfaces/Services/IManifestService.cs | 4 +- .../Interfaces/Services/IModService.cs | 8 +- .../Interfaces/Services/ISettingsService.cs | 5 + AlayaCore/LaunchDirector.cs | 256 ++++++++++++++- AlayaCore/Models/Configuration/GameOptions.cs | 29 +- .../Models/Configuration/LauncherOptions.cs | 2 +- .../Models/Manifests/LauncherManifestModel.cs | 2 +- AlayaCore/Models/Manifests/ManifestModel.cs | 2 +- AlayaCore/Models/Progress/LauncherProgress.cs | 53 +++- .../Models/Results/InstallOrUpdateResult.cs | 3 +- AlayaCore/Services/AuthService.cs | 69 ++++- AlayaCore/Services/GameInstallService.cs | 99 +++++- AlayaCore/Services/GameLaunchService.cs | 63 +++- AlayaCore/Services/HttpDownloadService.cs | 95 +++++- AlayaCore/Services/InstallStateService.cs | 98 +++++- AlayaCore/Services/LauncherUpdateService.cs | 40 ++- AlayaCore/Services/ManifestService.cs | 142 ++++++++- AlayaCore/Services/ModService.cs | 157 +++++++++- AlayaCore/Services/SettingsService.cs | 292 +++++++++++++++++- AlayaCore/States/LaunchPlan.cs | 1 + 22 files changed, 1323 insertions(+), 127 deletions(-) diff --git a/AlayaCore.sln.DotSettings.user b/AlayaCore.sln.DotSettings.user index c9dd86c..6be7083 100644 --- a/AlayaCore.sln.DotSettings.user +++ b/AlayaCore.sln.DotSettings.user @@ -1,7 +1,10 @@  + ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/AlayaCore/Abstractions/Interfaces/ILaunchDirector.cs b/AlayaCore/Abstractions/Interfaces/ILaunchDirector.cs index 63ec66d..a6dd88e 100644 --- a/AlayaCore/Abstractions/Interfaces/ILaunchDirector.cs +++ b/AlayaCore/Abstractions/Interfaces/ILaunchDirector.cs @@ -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 EvaluateAsync(CancellationToken cancellationToken = default); - - Task InstallOrUpdateAsync(CancellationToken cancellationToken = default, - EventHandler? minecraftProgess = null, - EventHandler? byteProgress = null, - IProgress? neoForgeProgress = null, - IProgress? neoForgeByteProgress = null); - - Task LaunchAsync(CancellationToken cancellationToken = default); - bool CanRun { get; } bool NeedsUpdating { get; } - + + bool IsUpdatingLauncher { get; } + LaunchPlan? CurrentPlan { get; } + + Task EvaluateAsync(CancellationToken cancellationToken = default); + + Task InstallOrUpdateAsync( + CancellationToken cancellationToken = default, + IProgress? progress = null); + + Task LaunchAsync(CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/AlayaCore/Abstractions/Interfaces/Services/IManifestService.cs b/AlayaCore/Abstractions/Interfaces/Services/IManifestService.cs index b60a74e..eef0ffe 100644 --- a/AlayaCore/Abstractions/Interfaces/Services/IManifestService.cs +++ b/AlayaCore/Abstractions/Interfaces/Services/IManifestService.cs @@ -7,8 +7,8 @@ namespace AlayaCore.Abstractions.Interfaces.Services { public interface IManifestService { - Task GetCoreManifestAsync(CancellationToken cancellationToken = default); - Task GetLocalCoreManifestAsync(CancellationToken cancellationToken = default); + Task GetAlayaManifestAsync(CancellationToken cancellationToken = default); + Task GetLocalAlayaManifestAsync(CancellationToken cancellationToken = default); Task GetInstalledModsManifestAsync(CancellationToken cancellationToken = default); diff --git a/AlayaCore/Abstractions/Interfaces/Services/IModService.cs b/AlayaCore/Abstractions/Interfaces/Services/IModService.cs index 06d420c..9a13238 100644 --- a/AlayaCore/Abstractions/Interfaces/Services/IModService.cs +++ b/AlayaCore/Abstractions/Interfaces/Services/IModService.cs @@ -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? progress = null, + CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/AlayaCore/Abstractions/Interfaces/Services/ISettingsService.cs b/AlayaCore/Abstractions/Interfaces/Services/ISettingsService.cs index a639588..6be4025 100644 --- a/AlayaCore/Abstractions/Interfaces/Services/ISettingsService.cs +++ b/AlayaCore/Abstractions/Interfaces/Services/ISettingsService.cs @@ -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); diff --git a/AlayaCore/LaunchDirector.cs b/AlayaCore/LaunchDirector.cs index 8e001e3..237e53e 100644 --- a/AlayaCore/LaunchDirector.cs +++ b/AlayaCore/LaunchDirector.cs @@ -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 _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 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 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 InstallOrUpdateAsync( CancellationToken cancellationToken = default, - EventHandler? minecraftProgress = null, - EventHandler? byteProgress = null, - IProgress? neoForgeProgress = null, - IProgress? neoForgeByteProgress = null) + IProgress? 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? minecraftFileProgress = + CreateMinecraftFileProgressHandler(progress); + + EventHandler? 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? neoForgeFileProgress = + CreateNeoForgeFileProgress(progress); + + IProgress? 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? 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 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? 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? CreateMinecraftFileProgressHandler( + IProgress? 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? CreateMinecraftByteProgressHandler( + IProgress? 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? CreateNeoForgeFileProgress( + IProgress? progress) + { + if (progress == null) + { + return null; + } + + return new Progress(args => + { + progress.Report(new LauncherProgress( + phase: LaunchState.InstallNeoforge, + statusMessage: args.EventType.ToString(), + currentItemName: args.Name, + tasksCompleted: args.ProgressedTasks, + tasksTotal: args.TotalTasks)); + }); + } + + private static IProgress? CreateNeoForgeByteProgress( + IProgress? progress) + { + if (progress == null) + { + return null; + } + + return new Progress(args => + { + progress.Report(new LauncherProgress( + phase: LaunchState.InstallNeoforge, + statusMessage: "Downloading NeoForge files...", + bytesCompleted: args.ProgressedBytes, + bytesTotal: args.TotalBytes)); + }); + } + + private static IProgress? CreateModProgress( + IProgress? progress) + { + if (progress == null) + { + return null; + } + + return new Progress(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)); + }); } } } \ No newline at end of file diff --git a/AlayaCore/Models/Configuration/GameOptions.cs b/AlayaCore/Models/Configuration/GameOptions.cs index 3e2fe93..a359364 100644 --- a/AlayaCore/Models/Configuration/GameOptions.cs +++ b/AlayaCore/Models/Configuration/GameOptions.cs @@ -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 }; - } } \ No newline at end of file diff --git a/AlayaCore/Models/Configuration/LauncherOptions.cs b/AlayaCore/Models/Configuration/LauncherOptions.cs index 092b855..3470524 100644 --- a/AlayaCore/Models/Configuration/LauncherOptions.cs +++ b/AlayaCore/Models/Configuration/LauncherOptions.cs @@ -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"; diff --git a/AlayaCore/Models/Manifests/LauncherManifestModel.cs b/AlayaCore/Models/Manifests/LauncherManifestModel.cs index d6b4034..30154d0 100644 --- a/AlayaCore/Models/Manifests/LauncherManifestModel.cs +++ b/AlayaCore/Models/Manifests/LauncherManifestModel.cs @@ -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, diff --git a/AlayaCore/Models/Manifests/ManifestModel.cs b/AlayaCore/Models/Manifests/ManifestModel.cs index 40df915..7384a14 100644 --- a/AlayaCore/Models/Manifests/ManifestModel.cs +++ b/AlayaCore/Models/Manifests/ManifestModel.cs @@ -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 Files { get; } diff --git a/AlayaCore/Models/Progress/LauncherProgress.cs b/AlayaCore/Models/Progress/LauncherProgress.cs index 75fde90..428975f 100644 --- a/AlayaCore/Models/Progress/LauncherProgress.cs +++ b/AlayaCore/Models/Progress/LauncherProgress.cs @@ -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; } } } \ No newline at end of file diff --git a/AlayaCore/Models/Results/InstallOrUpdateResult.cs b/AlayaCore/Models/Results/InstallOrUpdateResult.cs index a6cc21f..a0c6cf8 100644 --- a/AlayaCore/Models/Results/InstallOrUpdateResult.cs +++ b/AlayaCore/Models/Results/InstallOrUpdateResult.cs @@ -2,6 +2,7 @@ namespace AlayaCore.Models.Results { public enum InstallOrUpdateResult { - + Ready, + UpdaterLaunched } } \ No newline at end of file diff --git a/AlayaCore/Services/AuthService.cs b/AlayaCore/Services/AuthService.cs index 3700e99..4a23ba5 100644 --- a/AlayaCore/Services/AuthService.cs +++ b/AlayaCore/Services/AuthService.cs @@ -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 _logger; private MSession? _session; private IPublicClientApplication? _clientApp; private JELoginHandler? _loginHandler; - public AuthService(IFileStore fileStore) + public AuthService( + IFileStore fileStore, + ILogger logger) { _fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task 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 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; } } diff --git a/AlayaCore/Services/GameInstallService.cs b/AlayaCore/Services/GameInstallService.cs index db1aaef..6f370e8 100644 --- a/AlayaCore/Services/GameInstallService.cs +++ b/AlayaCore/Services/GameInstallService.cs @@ -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 _logger; private AlayaPath? _gamePath; private MinecraftLauncher? _minecraftLauncher; public GameInstallService( IFileStore fileStore, - ISettingsService settingsService) + ISettingsService settingsService, + ILogger 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; } } } \ No newline at end of file diff --git a/AlayaCore/Services/GameLaunchService.cs b/AlayaCore/Services/GameLaunchService.cs index 8b71bd4..691e9f1 100644 --- a/AlayaCore/Services/GameLaunchService.cs +++ b/AlayaCore/Services/GameLaunchService.cs @@ -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 _logger; private MinecraftLauncher? _minecraftLauncher; @@ -26,12 +28,14 @@ namespace AlayaCore.Services IAuthService authService, IFileStore fileStore, ILaunchDirector director, - GameOptions gameOptions) + GameOptions gameOptions, + ILogger 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, diff --git a/AlayaCore/Services/HttpDownloadService.cs b/AlayaCore/Services/HttpDownloadService.cs index 7a1a65b..d88d090 100644 --- a/AlayaCore/Services/HttpDownloadService.cs +++ b/AlayaCore/Services/HttpDownloadService.cs @@ -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 _logger; - public HttpDownloadService(IHttpClient httpClient) + public HttpDownloadService( + IHttpClient httpClient, + ILogger logger) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task 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) diff --git a/AlayaCore/Services/InstallStateService.cs b/AlayaCore/Services/InstallStateService.cs index 621c714..cf5ce02 100644 --- a/AlayaCore/Services/InstallStateService.cs +++ b/AlayaCore/Services/InstallStateService.cs @@ -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 _logger; public InstallationStateService( IFileStore fileStore, - IManifestService manifestService) + IManifestService manifestService, + ILogger 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 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; } } diff --git a/AlayaCore/Services/LauncherUpdateService.cs b/AlayaCore/Services/LauncherUpdateService.cs index e736241..888efad 100644 --- a/AlayaCore/Services/LauncherUpdateService.cs +++ b/AlayaCore/Services/LauncherUpdateService.cs @@ -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 _logger; public LauncherUpdateService( IManifestService manifestService, - LauncherUpdateServiceOptions options) + LauncherUpdateServiceOptions options, + ILogger 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 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); } diff --git a/AlayaCore/Services/ManifestService.cs b/AlayaCore/Services/ManifestService.cs index 3f249f3..8ff7332 100644 --- a/AlayaCore/Services/ManifestService.cs +++ b/AlayaCore/Services/ManifestService.cs @@ -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 _logger; public ManifestService( IDownloadService downloadService, IHttpClient httpClient, IFileStore fileStore, - ManifestServiceOptions options) + ManifestServiceOptions options, + ILogger 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 GetCoreManifestAsync(CancellationToken cancellationToken = default) + public Task GetAlayaManifestAsync(CancellationToken cancellationToken = default) { string destinationPath = GetCoreManifestPath(); + _logger.LogInformation( + "Downloading and loading Alaya manifest from {ManifestUri} to {DestinationPath}.", + _options.CoreManifestUri, + destinationPath); + return DownloadAndLoadManifestAsync( _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 GetLauncherManifestAsync(CancellationToken cancellationToken = default) { string destinationPath = GetLauncherManifestPath(); + _logger.LogInformation( + "Downloading and loading launcher manifest from {ManifestUri} to {DestinationPath}.", + _options.LauncherManifestUri, + destinationPath); + return DownloadAndLoadManifestAsync( _options.LauncherManifestUri, destinationPath, @@ -89,24 +118,34 @@ namespace AlayaCore.Services cancellationToken); } - public Task GetLocalCoreManifestAsync(CancellationToken cancellationToken = default) + public Task GetLocalAlayaManifestAsync(CancellationToken cancellationToken = default) { + string path = GetCoreManifestPath(); + + _logger.LogDebug("Loading local Alaya manifest from {ManifestPath}.", path); + return LoadLocalManifestAsync( - GetCoreManifestPath(), + path, static dto => dto.ToModel(), cancellationToken); } public Task GetLocalLauncherManifestAsync(CancellationToken cancellationToken = default) { + string path = GetLauncherManifestPath(); + + _logger.LogDebug("Loading local launcher manifest from {ManifestPath}.", path); + return LoadLocalManifestAsync( - GetLauncherManifestPath(), + path, static dto => dto.ToModel(), cancellationToken); } public async Task GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default) { + _logger.LogDebug("Fetching remote Alaya manifest version from {ManifestUri}.", _options.CoreManifestUri); + ManifestModel remoteManifest = await GetRemoteManifestAsync( _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 GetRemoteLauncherManifestAsync( CancellationToken cancellationToken = default) { + _logger.LogDebug("Fetching remote launcher manifest from {ManifestUri}.", _options.LauncherManifestUri); + LauncherManifestModel remoteManifest = await GetRemoteManifestAsync( _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 LoadLocalManifestAsync( @@ -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( + 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 DownloadAndLoadManifestAsync( @@ -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( + 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 GetRemoteManifestAsync( @@ -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( + 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( + private TModel? DeserializeAndMapManifest( string json, string sourceName, Func 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); diff --git a/AlayaCore/Services/ModService.cs b/AlayaCore/Services/ModService.cs index 50de224..20e7713 100644 --- a/AlayaCore/Services/ModService.cs +++ b/AlayaCore/Services/ModService.cs @@ -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 _logger; public ModService( IDownloadService downloadService, ModrinthConnectionOptions options, IHttpClient httpClient, - IFileStore fileStore) + IFileStore fileStore, + ILogger 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? progress = null, CancellationToken cancellationToken = default) { if (manifest == null) @@ -62,9 +68,15 @@ namespace AlayaCore.Services List 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 finalInstalledMods = new List(); @@ -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("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("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("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 requiredMods) + private void RemoveStaleMods( + IEnumerable 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); } } } \ No newline at end of file diff --git a/AlayaCore/Services/SettingsService.cs b/AlayaCore/Services/SettingsService.cs index 973b4ca..16e3c4e 100644 --- a/AlayaCore/Services/SettingsService.cs +++ b/AlayaCore/Services/SettingsService.cs @@ -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 _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 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.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.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( @@ -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 LoadAsync( @@ -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(json); + T? result = JsonConvert.DeserializeObject(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; } } } \ No newline at end of file diff --git a/AlayaCore/States/LaunchPlan.cs b/AlayaCore/States/LaunchPlan.cs index cbbe5d7..068dab8 100644 --- a/AlayaCore/States/LaunchPlan.cs +++ b/AlayaCore/States/LaunchPlan.cs @@ -2,6 +2,7 @@ namespace AlayaCore.States { public enum LaunchState { + Checking, Ready, LauncherNeedsUpdate, NeedAuthenticating,