Compare commits

..

6 Commits

Author SHA1 Message Date
6938635ee4 Added Retry Policy and Launcher Error.
Included ErrorHelper for mapping.
2026-04-06 20:47:13 +01:00
491a3d420d Added Retry Policy and Launcher Error.
Included ErrorHelper for mapping.
2026-04-06 20:47:00 +01:00
11863088e4 ILogger support added 2026-04-06 19:40:21 +01:00
8a8292d2c3 ILogger support added. 2026-04-06 19:40:08 +01:00
8521f695a9 Removed JavaService, and moved Java handling to CmlLib. 2026-04-06 14:50:33 +01:00
0415d6ad80 Removed JavaService, and moved Java handling to CmlLib. 2026-04-06 14:50:29 +01:00
40 changed files with 2295 additions and 818 deletions

6
.idea/.idea.AlayaCore/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -1,3 +1,26 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AByteProgress_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003F99_003Fc83c3b28_003FByteProgress_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultFileExtractors_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003Fe8_003F52aaf39a_003FDefaultFileExtractors_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003F5c_003Ff0b24cad_003FExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIGameInstaller_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003Fdf_003F3b38ca47_003FIGameInstaller_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInstallerEventType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003F4e_003F4bf1d82f_003FInstallerEventType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInstallerProgressChangedEventArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003Ffb_003F6d837772_003FInstallerProgressChangedEventArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIVersionMetadata_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003Ff2_003Fc2330846_003FIVersionMetadata_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIXboxGameAccountManager_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F101e700861b6410da498d4e79271a86112600_003Fc4_003F2054a44d_003FIXboxGameAccountManager_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJELoginHandlerBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2dce88ad0d604d86b0d410923cb59f4bb200_003Fb4_003F534fcf72_003FJELoginHandlerBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJELoginHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2dce88ad0d604d86b0d410923cb59f4bb200_003Fcd_003Ffe209c30_003FJELoginHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMinecraftJavaPathResolver_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003F5f_003F3f4c19e9_003FMinecraftJavaPathResolver_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMinecraftLauncherParameters_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003F67_003Fd3bb8531_003FMinecraftLauncherParameters_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMinecraftLauncher_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003F93_003F1a552b3b_003FMinecraftLauncher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMinecraftPath_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F75bf69b9634b4f328866dbcd8b90304a2600_003F3c_003Fef2111c9_003FMinecraftPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMinecraftProcessBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003Fc0_003F15c03583_003FMinecraftProcessBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMsalClientHelper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fee6114ec878e41eeb911687986e3a3fd6600_003F4b_003F0b7d25f6_003FMsalClientHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMSession_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F75bf69b9634b4f328866dbcd8b90304a2600_003Fa0_003F9cf699df_003FMSession_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANeoForgeInstaller_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F16599bff24b948d2b1e1d928222947e1de00_003F39_003F21a3bcfd_003FNeoForgeInstaller_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANeoForgeInstallOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F16599bff24b948d2b1e1d928222947e1de00_003Fa3_003F837206c4_003FNeoForgeInstallOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOperatingSystem_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb5d933e666c84a3394cfc49363e3e5bdd2b08_003Fb9_003Fd737a5e2_003FOperatingSystem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOperatingSystem_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb5d933e666c84a3394cfc49363e3e5bdd2b08_003Fb9_003Fd737a5e2_003FOperatingSystem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AVersionConverter_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F346166506159999f4fec5f7c475ba964d2495ee825dd6e4c48dedef117f086_003FVersionConverter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AProcessWrapper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003F97_003F3a1ed5f7_003FProcessWrapper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APublicClientApplicationBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fe7dcae430a36a6030304d2dec7149bc9e40cb379e3a8e0efb762d5d19da5c_003FPublicClientApplicationBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARandom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb5d933e666c84a3394cfc49363e3e5bdd2b08_003F5f_003F5739d1f6_003FRandom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AVersionConverter_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F346166506159999f4fec5f7c475ba964d2495ee825dd6e4c48dedef117f086_003FVersionConverter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AVersionMetadataCollection_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F11957ce69cb24b8a8e3979c0980ffeb33a400_003Ff7_003Fb6ecd842_003FVersionMetadataCollection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

View File

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

View File

@@ -0,0 +1,19 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace AlayaCore.Abstractions.Interfaces.Policies
{
public interface IRetryPolicy
{
Task ExecuteAsync(
Func<CancellationToken, Task> operation,
string operationName,
CancellationToken cancellationToken = default);
Task<T> ExecuteAsync<T>(
Func<CancellationToken, Task<T>> operation,
string operationName,
CancellationToken cancellationToken = default);
}
}

View File

@@ -10,6 +10,7 @@ namespace AlayaCore.Abstractions.Interfaces.Services
{ {
public interface IGameInstallService public interface IGameInstallService
{ {
Task EnsureMinecraftInstalledAsync( Task EnsureMinecraftInstalledAsync(
ManifestModel manifest, ManifestModel manifest,
InstallEnvironment environment, InstallEnvironment environment,

View File

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

View File

@@ -7,8 +7,8 @@ namespace AlayaCore.Abstractions.Interfaces.Services
{ {
public interface IManifestService public interface IManifestService
{ {
Task<ManifestModel> GetCoreManifestAsync(CancellationToken cancellationToken = default); Task<ManifestModel> GetAlayaManifestAsync(CancellationToken cancellationToken = default);
Task<ManifestModel?> GetLocalCoreManifestAsync(CancellationToken cancellationToken = default); Task<ManifestModel?> GetLocalAlayaManifestAsync(CancellationToken cancellationToken = default);
Task<InstalledModsManifestModel> GetInstalledModsManifestAsync(CancellationToken cancellationToken = default); Task<InstalledModsManifestModel> GetInstalledModsManifestAsync(CancellationToken cancellationToken = default);
@@ -16,7 +16,7 @@ namespace AlayaCore.Abstractions.Interfaces.Services
Task<LauncherManifestModel?> GetLocalLauncherManifestAsync(CancellationToken cancellationToken = default); Task<LauncherManifestModel?> GetLocalLauncherManifestAsync(CancellationToken cancellationToken = default);
Task<string> GetRemoteLauncherManifestHashAsync(CancellationToken cancellationToken = default); Task<LauncherManifestModel> GetRemoteLauncherManifestAsync(CancellationToken cancellationToken = default);
Task<Version> GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default); Task<Version> GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default);
} }
} }

View File

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

View File

@@ -10,7 +10,12 @@ namespace AlayaCore.Abstractions.Interfaces.Services
GameOptions GameOptions { get; } GameOptions GameOptions { get; }
Task SetForceReinstallAsync(bool value, CancellationToken cancellationToken = default); Task SetForceReinstallAsync(bool value, CancellationToken cancellationToken = default);
Task UpdateLaunchVersionAsync(string newVersion, 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 SaveLauncherOptionsAsync(CancellationToken cancellationToken = default);
Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default); Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default);

View File

@@ -0,0 +1,58 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Clients;
namespace AlayaCore.Clients
{
public sealed class DefaultHttpClient : IHttpClient
{
private readonly HttpClient _httpClient;
private bool _disposed;
public DefaultHttpClient(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public Task<HttpResponseMessage> GetAsync(
Uri requestUri,
HttpCompletionOption completionOption,
CancellationToken cancellationToken = default)
{
if (requestUri == null)
{
throw new ArgumentNullException(nameof(requestUri));
}
if (!requestUri.IsAbsoluteUri)
{
throw new ArgumentException("Request URI must be absolute.", nameof(requestUri));
}
ThrowIfDisposed();
return _httpClient.GetAsync(requestUri, completionOption, cancellationToken);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_httpClient.Dispose();
_disposed = true;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(DefaultHttpClient));
}
}
}
}

View File

@@ -0,0 +1,19 @@
using System;
using AlayaCore.Utilities.Enums;
namespace AlayaCore.Errors
{
public sealed class LauncherError
{
public LauncherErrorType Type { get; }
public string Message { get; }
public Exception Exception { get; }
public LauncherError(LauncherErrorType type, string message, Exception exception)
{
Type = type;
Message = message;
Exception = exception;
}
}
}

View File

@@ -8,10 +8,13 @@ using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation; using AlayaCore.Installation;
using AlayaCore.Models.Configuration; using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
using AlayaCore.States; using AlayaCore.States;
using AlayaCore.Utilities.Enums; using AlayaCore.Utilities.Enums;
using CmlLib.Core; using CmlLib.Core;
using CmlLib.Core.Installers; using CmlLib.Core.Installers;
using Microsoft.Extensions.Logging;
namespace AlayaCore namespace AlayaCore
{ {
@@ -20,55 +23,60 @@ namespace AlayaCore
private readonly IManifestService _manifestService; private readonly IManifestService _manifestService;
private readonly IUpdateService _updateService; private readonly IUpdateService _updateService;
private readonly IInstallStateService _installStateService; private readonly IInstallStateService _installStateService;
private readonly IJavaService _javaService;
private readonly IModService _modService; private readonly IModService _modService;
private readonly IGameLaunchService _gameLaunchService; private readonly IGameLaunchService _gameLaunchService;
private readonly IGameInstallService _gameInstallService; private readonly IGameInstallService _gameInstallService;
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly IAuthService _authService; private readonly IAuthService _authService;
private readonly LauncherOptions _options; private readonly LauncherOptions _options;
private readonly ILogger<LaunchDirector> _logger;
public bool CanRun { get; private set; } public bool CanRun { get; private set; }
public bool NeedsUpdating { get; private set; } public bool NeedsUpdating { get; private set; }
public LaunchPlan? CurrentPlan { get; private set; } public LaunchPlan? CurrentPlan { get; private set; }
public bool IsUpdatingLauncher { get; private set; }
public LaunchDirector( public LaunchDirector(
IManifestService manifestService, IManifestService manifestService,
IUpdateService updateService, IUpdateService updateService,
IInstallStateService installStateService, IInstallStateService installStateService,
IJavaService javaService,
IModService modService, IModService modService,
IGameLaunchService gameLaunchService, IGameLaunchService gameLaunchService,
IGameInstallService gameInstallService, IGameInstallService gameInstallService,
ISettingsService settingsService, ISettingsService settingsService,
IAuthService authService, IAuthService authService,
LauncherOptions options) LauncherOptions options,
ILogger<LaunchDirector> logger)
{ {
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService)); _manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_updateService = updateService ?? throw new ArgumentNullException(nameof(updateService)); _updateService = updateService ?? throw new ArgumentNullException(nameof(updateService));
_installStateService = installStateService ?? throw new ArgumentNullException(nameof(installStateService)); _installStateService = installStateService ?? throw new ArgumentNullException(nameof(installStateService));
_javaService = javaService ?? throw new ArgumentNullException(nameof(javaService));
_modService = modService ?? throw new ArgumentNullException(nameof(modService)); _modService = modService ?? throw new ArgumentNullException(nameof(modService));
_gameLaunchService = gameLaunchService ?? throw new ArgumentNullException(nameof(gameLaunchService)); _gameLaunchService = gameLaunchService ?? throw new ArgumentNullException(nameof(gameLaunchService));
_gameInstallService = gameInstallService ?? throw new ArgumentNullException(nameof(gameInstallService)); _gameInstallService = gameInstallService ?? throw new ArgumentNullException(nameof(gameInstallService));
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_authService = authService ?? throw new ArgumentNullException(nameof(authService)); _authService = authService ?? throw new ArgumentNullException(nameof(authService));
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default) public async Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Evaluating launcher state.");
bool launcherNeedsUpdate = await _updateService bool launcherNeedsUpdate = await _updateService
.DoesLauncherNeedUpdating(cancellationToken) .DoesLauncherNeedUpdating(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (launcherNeedsUpdate) if (launcherNeedsUpdate)
{ {
_logger.LogInformation("Launcher update is required.");
LaunchPlan launcherUpdatePlan = new LaunchPlan( LaunchPlan launcherUpdatePlan = new LaunchPlan(
launcherNeedsUpdate: true, launcherNeedsUpdate: true,
javaNeedsInstallOrUpdate: false,
minecraftNeedsInstallOrUpdate: false, minecraftNeedsInstallOrUpdate: false,
neoforgeNeedsInstallOrUpdate: false, neoforgeNeedsInstallOrUpdate: false,
modsNeedSync: false, modsNeedSync: false,
@@ -84,17 +92,13 @@ namespace AlayaCore
.GetCurrentEnvironmentAsync(cancellationToken) .GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
bool javaNeedsInstallOrUpdate =
_options.ForceReinstall ||
!environment.JavaInstalled ||
!string.Equals(environment.JavaVersion, manifest.RequiredJavaVersion, StringComparison.OrdinalIgnoreCase);
bool needAuthenticating = !await _authService bool needAuthenticating = !await _authService
.IsAuthenticatedAsync(cancellationToken) .IsAuthenticatedAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
bool minecraftNeedsInstallOrUpdate = bool minecraftNeedsInstallOrUpdate =
_options.ForceReinstall || _options.ForceReinstall ||
!environment.JavaInstalled ||
!environment.MinecraftInstalled || !environment.MinecraftInstalled ||
!string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase); !string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase);
@@ -109,37 +113,56 @@ namespace AlayaCore
LaunchPlan plan = new LaunchPlan( LaunchPlan plan = new LaunchPlan(
launcherNeedsUpdate: false, launcherNeedsUpdate: false,
javaNeedsInstallOrUpdate: javaNeedsInstallOrUpdate,
minecraftNeedsInstallOrUpdate: minecraftNeedsInstallOrUpdate, minecraftNeedsInstallOrUpdate: minecraftNeedsInstallOrUpdate,
neoforgeNeedsInstallOrUpdate: neoforgeNeedsInstallOrUpdate, neoforgeNeedsInstallOrUpdate: neoforgeNeedsInstallOrUpdate,
modsNeedSync: modsNeedSync, modsNeedSync: modsNeedSync,
needAuthenticating: needAuthenticating); 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); ApplyPlan(plan);
return plan; return plan;
} }
public async Task InstallOrUpdateAsync(CancellationToken cancellationToken = default, public async Task<InstallOrUpdateResult> InstallOrUpdateAsync(
EventHandler<InstallerProgressChangedEventArgs>? minecraftProgess = null, CancellationToken cancellationToken = default,
EventHandler<ByteProgress>? byteProgress = null, IProgress<LauncherProgress>? progress = null)
IProgress<InstallerProgressChangedEventArgs>? neoForgeProgress = null,
IProgress<ByteProgress>? neoForgeByteProgress = null)
{ {
cancellationToken.ThrowIfCancellationRequested(); 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); LaunchPlan plan = await EvaluateAsync(cancellationToken).ConfigureAwait(false);
while (!plan.CanRun) while (!plan.CanRun)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
ManifestModel? manifest; _logger.LogInformation("Processing launch state {LaunchState}.", plan.State);
InstallEnvironment? environment;
ManifestModel manifest;
InstallEnvironment environment;
switch (plan.State) switch (plan.State)
{ {
case LaunchState.LauncherNeedsUpdate: case LaunchState.LauncherNeedsUpdate:
{ {
IsUpdatingLauncher = true;
_logger.LogWarning("Launcher updater handoff is beginning.");
ReportProgress(progress, LaunchState.LauncherNeedsUpdate, "Launching updater...");
LauncherManifestModel launcherManifest = await _manifestService LauncherManifestModel launcherManifest = await _manifestService
.GetLauncherManifestAsync(cancellationToken) .GetLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -149,78 +172,115 @@ namespace AlayaCore
.ConfigureAwait(false); .ConfigureAwait(false);
ApplyPlan(plan); ApplyPlan(plan);
return;
}
case LaunchState.InstallJava: _logger.LogInformation("Updater launched successfully. Returning UpdaterLaunched result.");
{ return InstallOrUpdateResult.UpdaterLaunched;
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
await _javaService
.EnsureValidJavaInstalledAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false);
break;
} }
case LaunchState.NeedAuthenticating: case LaunchState.NeedAuthenticating:
{ {
await _authService.AuthenticateAsync(cancellationToken); _logger.LogInformation("Authentication is required.");
ReportProgress(progress, LaunchState.NeedAuthenticating, "Signing in...");
await _authService
.AuthenticateAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Authentication completed successfully.");
break; break;
} }
case LaunchState.InstallMinecraft: case LaunchState.InstallMinecraft:
{ {
_logger.LogInformation("Minecraft installation or repair is required.");
ReportProgress(progress, LaunchState.InstallMinecraft, "Preparing Minecraft installation...");
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false); manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken) .GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
EventHandler<InstallerProgressChangedEventArgs>? minecraftFileProgress =
CreateMinecraftFileProgressHandler(progress);
EventHandler<ByteProgress>? minecraftByteProgress =
CreateMinecraftByteProgressHandler(progress);
await _gameInstallService await _gameInstallService
.EnsureMinecraftInstalledAsync(manifest, environment, cancellationToken, minecraftProgess, byteProgress) .EnsureMinecraftInstalledAsync(
manifest,
environment,
cancellationToken,
minecraftFileProgress,
minecraftByteProgress)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation("Minecraft installation or repair step completed.");
break; break;
} }
case LaunchState.InstallNeoforge: case LaunchState.InstallNeoforge:
{ {
_logger.LogInformation("NeoForge installation or repair is required.");
ReportProgress(progress, LaunchState.InstallNeoforge, "Preparing NeoForge installation...");
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false); manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken) .GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await _gameInstallService IProgress<InstallerProgressChangedEventArgs>? neoForgeFileProgress =
.EnsureNeoForgeInstalledAsync(manifest, environment, cancellationToken, neoForgeProgress, neoForgeByteProgress) CreateNeoForgeFileProgress(progress);
.ConfigureAwait(false);
IProgress<ByteProgress>? neoForgeByteProgress =
CreateNeoForgeByteProgress(progress);
await _gameInstallService
.EnsureNeoForgeInstalledAsync(
manifest,
environment,
cancellationToken,
neoForgeFileProgress,
neoForgeByteProgress)
.ConfigureAwait(false);
_logger.LogInformation("NeoForge installation or repair step completed.");
break; break;
} }
case LaunchState.SyncMods: case LaunchState.SyncMods:
{ {
_logger.LogInformation("Mod synchronization is required.");
ReportProgress(progress, LaunchState.SyncMods, "Checking mod files...");
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false); manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken) .GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
IProgress<DownloadProgress>? modProgress = CreateModProgress(progress);
await _modService await _modService
.ProcessModsAsync(manifest, environment, cancellationToken) .ProcessModsAsync(manifest, environment, modProgress, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation("Mod synchronization step completed.");
break; break;
} }
case LaunchState.Ready: case LaunchState.Ready:
{ {
_logger.LogDebug("Launch state is Ready inside install loop.");
break; break;
} }
default: default:
{ {
_logger.LogError("Unsupported launch state encountered: {LaunchState}.", plan.State);
throw new InvalidOperationException($"Unsupported launch state '{plan.State}'."); throw new InvalidOperationException($"Unsupported launch state '{plan.State}'.");
} }
} }
@@ -230,28 +290,45 @@ namespace AlayaCore
if (_options.ForceReinstall) if (_options.ForceReinstall)
{ {
await _settingsService.SetForceReinstallAsync(false, cancellationToken).ConfigureAwait(false); _logger.LogInformation("Force reinstall flag was set. Resetting it after successful install/update workflow.");
await _settingsService
.SetForceReinstallAsync(false, cancellationToken)
.ConfigureAwait(false);
} }
plan = await EvaluateAsync(cancellationToken).ConfigureAwait(false); plan = await EvaluateAsync(cancellationToken).ConfigureAwait(false);
if (!plan.CanRun) 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."); 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) public async Task LaunchAsync(CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Launch requested.");
if (CurrentPlan == null) if (CurrentPlan == null)
{ {
_logger.LogDebug("No current launch plan was available. Evaluating launcher state before launch.");
await EvaluateAsync(cancellationToken).ConfigureAwait(false); await EvaluateAsync(cancellationToken).ConfigureAwait(false);
} }
if (!CanRun) 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."); throw new InvalidOperationException("Launcher cannot run because installation or updates are still required.");
} }
@@ -261,33 +338,56 @@ namespace AlayaCore
ManifestModel manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false); ManifestModel manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Starting game launch. MinecraftVersion: {MinecraftVersion}, NeoForgeVersion: {NeoForgeVersion}",
environment.MinecraftVersion,
environment.NeoforgedVersion);
await _gameLaunchService await _gameLaunchService
.LaunchAsync(manifest, environment, cancellationToken) .LaunchAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation("Game launch call completed.");
} }
private async Task<ManifestModel> EnsureCurrentManifestAsync(CancellationToken cancellationToken) private async Task<ManifestModel> EnsureCurrentManifestAsync(CancellationToken cancellationToken)
{ {
_logger.LogDebug("Ensuring current Alaya manifest is available.");
ManifestModel? localManifest = await _manifestService ManifestModel? localManifest = await _manifestService
.GetLocalCoreManifestAsync(cancellationToken) .GetLocalAlayaManifestAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
Version remoteVersion = await _manifestService Version remoteVersion = await _manifestService
.GetRemoteCoreManifestVersionAsync(cancellationToken) .GetRemoteCoreManifestVersionAsync(cancellationToken)
.ConfigureAwait(false); .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) if (localManifest == null || localManifest.AlayaVersion != remoteVersion)
{ {
localManifest = await _manifestService localManifest = await _manifestService
.GetCoreManifestAsync(cancellationToken) .GetAlayaManifestAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
if (localManifest == null) if (localManifest == null)
{ {
_logger.LogError("Local Alaya manifest was still unavailable after refresh.");
throw new FileNotFoundException("Local core manifest was not found 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; return localManifest;
} }
@@ -343,6 +443,129 @@ namespace AlayaCore
CurrentPlan = plan ?? throw new ArgumentNullException(nameof(plan)); CurrentPlan = plan ?? throw new ArgumentNullException(nameof(plan));
NeedsUpdating = plan.NeedsUpdating; NeedsUpdating = plan.NeedsUpdating;
CanRun = plan.CanRun; CanRun = plan.CanRun;
_logger.LogInformation(
"Applied launch plan. State: {LaunchState}, CanRun: {CanRun}, NeedsUpdating: {NeedsUpdating}, IsUpdatingLauncher: {IsUpdatingLauncher}",
plan.State,
CanRun,
NeedsUpdating,
IsUpdatingLauncher);
}
private static void ReportProgress(
IProgress<LauncherProgress>? progress,
LaunchState phase,
string statusMessage,
string? currentItemName = null,
long? bytesCompleted = null,
long? bytesTotal = null,
double? bytesPerSecond = null,
int? tasksCompleted = null,
int? tasksTotal = null)
{
progress?.Report(new LauncherProgress(
phase,
statusMessage,
currentItemName,
bytesCompleted,
bytesTotal,
bytesPerSecond,
tasksCompleted,
tasksTotal));
}
private static EventHandler<InstallerProgressChangedEventArgs>? CreateMinecraftFileProgressHandler(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return (_, args) =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallMinecraft,
statusMessage: args.EventType.ToString(),
currentItemName: args.Name,
tasksCompleted: args.ProgressedTasks,
tasksTotal: args.TotalTasks));
};
}
private static EventHandler<ByteProgress>? CreateMinecraftByteProgressHandler(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return (_, args) =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallMinecraft,
statusMessage: "Downloading Minecraft files...",
bytesCompleted: args.ProgressedBytes,
bytesTotal: args.TotalBytes));
};
}
private static IProgress<InstallerProgressChangedEventArgs>? CreateNeoForgeFileProgress(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return new Progress<InstallerProgressChangedEventArgs>(args =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallNeoforge,
statusMessage: args.EventType.ToString(),
currentItemName: args.Name,
tasksCompleted: args.ProgressedTasks,
tasksTotal: args.TotalTasks));
});
}
private static IProgress<ByteProgress>? CreateNeoForgeByteProgress(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return new Progress<ByteProgress>(args =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallNeoforge,
statusMessage: "Downloading NeoForge files...",
bytesCompleted: args.ProgressedBytes,
bytesTotal: args.TotalBytes));
});
}
private static IProgress<DownloadProgress>? CreateModProgress(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return new Progress<DownloadProgress>(downloadProgress =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.SyncMods,
statusMessage: downloadProgress.StatusMessage ?? "Downloading mod...",
currentItemName: downloadProgress.FileName,
bytesCompleted: downloadProgress.BytesDownloaded,
bytesTotal: downloadProgress.TotalBytes,
bytesPerSecond: downloadProgress.BytesPerSecond));
});
} }
} }
} }

View File

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

View File

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

View File

@@ -1,3 +1,6 @@
using System;
using System.IO;
namespace AlayaCore.Models.Configuration namespace AlayaCore.Models.Configuration
{ {
public sealed class LauncherUpdateServiceOptions public sealed class LauncherUpdateServiceOptions
@@ -10,5 +13,8 @@ namespace AlayaCore.Models.Configuration
public string AlayaUpdaterPath { get; set; } public string AlayaUpdaterPath { get; set; }
public bool ForceUpdate { get; set; } public bool ForceUpdate { get; set; }
public static LauncherUpdateServiceOptions Default { get; } =
new LauncherUpdateServiceOptions(Path.Combine(AppContext.BaseDirectory, "Data", "Updater"), false);
} }
} }

View File

@@ -4,25 +4,23 @@ namespace AlayaCore.Models.Configuration
{ {
public sealed class ManifestServiceOptions public sealed class ManifestServiceOptions
{ {
public Uri CoreManifestUri { get; } public Uri AlayaManifestUri { get; }
public Uri LauncherManifestUri { get; } public Uri LauncherManifestUri { get; }
public string CoreManifestSha512Hash { get; } public string AlayaManifestSha512Hash { get; }
public string LauncherManifestSha512Hash { get; } public string LauncherManifestSha512Hash { get; }
public string ManifestDirectoryPath { get; }
public ManifestServiceOptions( public ManifestServiceOptions(
Uri coreManifestUri, Uri alayaManifestUri,
Uri launcherManifestUri, Uri launcherManifestUri,
string coreManifestSha512Hash, string alayaManifestSha512Hash,
string launcherManifestSha512Hash, string launcherManifestSha512Hash)
string manifestDirectoryPath)
{ {
CoreManifestUri = coreManifestUri ?? throw new ArgumentNullException(nameof(coreManifestUri)); AlayaManifestUri = alayaManifestUri ?? throw new ArgumentNullException(nameof(alayaManifestUri));
LauncherManifestUri = launcherManifestUri ?? throw new ArgumentNullException(nameof(launcherManifestUri)); LauncherManifestUri = launcherManifestUri ?? throw new ArgumentNullException(nameof(launcherManifestUri));
if (!CoreManifestUri.IsAbsoluteUri) if (!AlayaManifestUri.IsAbsoluteUri)
{ {
throw new ArgumentException("Core manifest URI must be absolute.", nameof(coreManifestUri)); throw new ArgumentException("Core manifest URI must be absolute.", nameof(alayaManifestUri));
} }
if (!LauncherManifestUri.IsAbsoluteUri) if (!LauncherManifestUri.IsAbsoluteUri)
@@ -30,9 +28,9 @@ namespace AlayaCore.Models.Configuration
throw new ArgumentException("Launcher manifest URI must be absolute.", nameof(launcherManifestUri)); throw new ArgumentException("Launcher manifest URI must be absolute.", nameof(launcherManifestUri));
} }
if (string.IsNullOrWhiteSpace(coreManifestSha512Hash)) if (string.IsNullOrWhiteSpace(alayaManifestSha512Hash))
{ {
throw new ArgumentException("Core manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(coreManifestSha512Hash)); throw new ArgumentException("Core manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(alayaManifestSha512Hash));
} }
if (string.IsNullOrWhiteSpace(launcherManifestSha512Hash)) if (string.IsNullOrWhiteSpace(launcherManifestSha512Hash))
@@ -40,14 +38,12 @@ namespace AlayaCore.Models.Configuration
throw new ArgumentException("Launcher manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(launcherManifestSha512Hash)); throw new ArgumentException("Launcher manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(launcherManifestSha512Hash));
} }
if (string.IsNullOrWhiteSpace(manifestDirectoryPath)) AlayaManifestSha512Hash = alayaManifestSha512Hash;
{
throw new ArgumentException("Manifest directory path cannot be null, empty, or whitespace.", nameof(manifestDirectoryPath));
}
CoreManifestSha512Hash = coreManifestSha512Hash;
LauncherManifestSha512Hash = launcherManifestSha512Hash; LauncherManifestSha512Hash = launcherManifestSha512Hash;
ManifestDirectoryPath = manifestDirectoryPath;
} }
public static ManifestServiceOptions Default { get; } = new ManifestServiceOptions(
new Uri("INSERT-ALAYA-URL", UriKind.Absolute),
new Uri("INSERT-LAUNCHER-URL", UriKind.Absolute), "INSERT-ALAYA-HASH", "INSERT-LAUNCHER-HASH");
} }
} }

View File

@@ -8,5 +8,8 @@ namespace AlayaCore.Models.Configuration
} }
public string BaseApiUrl { get; } public string BaseApiUrl { get; }
public static ModrinthConnectionOptions Default { get; } =
new ModrinthConnectionOptions("https://api.modrinth.com/v2/");
} }
} }

View File

@@ -0,0 +1,12 @@
namespace AlayaCore.Models.Configuration
{
public sealed class RetryPolicyOptions
{
public int MaxAttempts { get; set; } = 3;
public int BaseDelayMilliseconds { get; set; } = 500;
public double BackoffMultiplier { get; set; } = 2.0;
public int MaxDelayMilliseconds { get; set; } = 3000;
public static RetryPolicyOptions Default { get; } = new();
}
}

View File

@@ -19,6 +19,9 @@ namespace AlayaCore.Models.Manifests.DTO
[JsonProperty("javaUrl", Required = Required.Always)] [JsonProperty("javaUrl", Required = Required.Always)]
[JsonConverter(typeof(UriConverter))] [JsonConverter(typeof(UriConverter))]
public Uri RequiredJavaUrl { get; set; } = null!; public Uri RequiredJavaUrl { get; set; } = null!;
[JsonProperty("javaArchiveHash", Required = Required.Always)]
public string JavaArchiveHash { get; set; } = string.Empty;
[JsonProperty("minecraftVersion", Required = Required.Always)] [JsonProperty("minecraftVersion", Required = Required.Always)]
public string MinecraftVersion { get; set; } = string.Empty; public string MinecraftVersion { get; set; } = string.Empty;

View File

@@ -4,9 +4,9 @@ namespace AlayaCore.Models.Manifests
{ {
public sealed class LauncherManifestModel public sealed class LauncherManifestModel
{ {
public Version Version { get; } public Version? Version { get; }
public string Sha512Hash { get; } public string Sha512Hash { get; }
public Uri DownloadUri { get; } public Uri? DownloadUri { get; }
public LauncherManifestModel( public LauncherManifestModel(
Version version, Version version,

View File

@@ -9,19 +9,15 @@ namespace AlayaCore.Models.Manifests
public sealed class ManifestModel public sealed class ManifestModel
{ {
public Version AlayaVersion { get; } public Version AlayaVersion { get; }
public string RequiredJavaVersion { get; }
public Uri RequiredJavaBaseUrl { get; }
public string MinecraftVersion { get; } public string MinecraftVersion { get; }
public string NeoforgedVersion { get; } public string NeoforgedVersion { get; }
public Uri ServerUrl { get; } public Uri? ServerUrl { get; }
public int ServerPort { get; } public int ServerPort { get; }
public IReadOnlyList<ModFileEntry> Files { get; } public IReadOnlyList<ModFileEntry> Files { get; }
public ManifestModel( public ManifestModel(
Version alayaVersion, Version alayaVersion,
string requiredJavaVersion,
Uri requiredJavaUrl,
string minecraftVersion, string minecraftVersion,
string neoforgedVersion, string neoforgedVersion,
Uri serverUrl, Uri serverUrl,
@@ -29,8 +25,6 @@ namespace AlayaCore.Models.Manifests
IEnumerable<ModFileEntry> files) IEnumerable<ModFileEntry> files)
{ {
AlayaVersion = alayaVersion ?? throw new ArgumentNullException(nameof(alayaVersion)); AlayaVersion = alayaVersion ?? throw new ArgumentNullException(nameof(alayaVersion));
RequiredJavaVersion = RequireNonEmpty(requiredJavaVersion, nameof(requiredJavaVersion));
RequiredJavaBaseUrl = requiredJavaUrl ?? throw new ArgumentNullException(nameof(requiredJavaUrl));
MinecraftVersion = RequireNonEmpty(minecraftVersion, nameof(minecraftVersion)); MinecraftVersion = RequireNonEmpty(minecraftVersion, nameof(minecraftVersion));

View File

@@ -1,27 +1,62 @@
using System; using AlayaCore.States;
using AlayaCore.Utilities.Enums;
namespace AlayaCore.Models.Progress namespace AlayaCore.Models.Progress
{ {
public sealed class LauncherProgress public sealed class LauncherProgress
{ {
public LauncherStage Stage { get; } public LaunchState Phase { get; }
public string StatusMessage { 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( public LauncherProgress(
LauncherStage stage, LaunchState phase,
string statusMessage, 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)) 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; StatusMessage = statusMessage;
PercentComplete = percentComplete; CurrentItemName = currentItemName;
BytesCompleted = bytesCompleted;
BytesTotal = bytesTotal;
BytesPerSecond = bytesPerSecond;
TasksCompleted = tasksCompleted;
TasksTotal = tasksTotal;
} }
} }
} }

View File

@@ -0,0 +1,8 @@
namespace AlayaCore.Models.Results
{
public enum InstallOrUpdateResult
{
Ready,
UpdaterLaunched
}
}

View File

@@ -0,0 +1,216 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Models.Configuration;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services
{
public sealed class RetryPolicy : IRetryPolicy
{
private readonly RetryPolicyOptions _options;
private readonly ILogger<RetryPolicy> _logger;
private static readonly Random _random = new Random();
public RetryPolicy(
RetryPolicyOptions options,
ILogger<RetryPolicy> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task ExecuteAsync(
Func<CancellationToken, Task> operation,
string operationName,
CancellationToken cancellationToken = default)
{
if (operation == null)
{
throw new ArgumentNullException(nameof(operation));
}
if (string.IsNullOrWhiteSpace(operationName))
{
throw new ArgumentException("Operation name cannot be null, empty, or whitespace.", nameof(operationName));
}
await ExecuteAsync<object?>(
async token =>
{
await operation(token).ConfigureAwait(false);
return null;
},
operationName,
cancellationToken).ConfigureAwait(false);
}
public async Task<T> ExecuteAsync<T>(
Func<CancellationToken, Task<T>> operation,
string operationName,
CancellationToken cancellationToken = default)
{
if (operation == null)
{
throw new ArgumentNullException(nameof(operation));
}
if (string.IsNullOrWhiteSpace(operationName))
{
throw new ArgumentException("Operation name cannot be null, empty, or whitespace.", nameof(operationName));
}
ValidateOptions();
Exception? lastException = null;
for (int attempt = 1; attempt <= _options.MaxAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (attempt > 1)
{
_logger.LogInformation(
"Retrying operation {OperationName}. Attempt {Attempt} of {MaxAttempts}.",
operationName,
attempt,
_options.MaxAttempts);
}
return await operation(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested && IsRetryable(ex))
{
lastException = ex;
if (attempt == _options.MaxAttempts)
{
_logger.LogError(
ex,
"Operation {OperationName} failed after {MaxAttempts} attempts due to repeated timeout or cancellation-like transient failures.",
operationName,
_options.MaxAttempts);
throw;
}
TimeSpan delay = CalculateDelay(attempt);
_logger.LogWarning(
ex,
"Operation {OperationName} timed out or was transiently cancelled on attempt {Attempt} of {MaxAttempts}. Retrying after {DelayMs}ms.",
operationName,
attempt,
_options.MaxAttempts,
(int)delay.TotalMilliseconds);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
_logger.LogInformation(
"Operation {OperationName} was cancelled by the caller.",
operationName);
throw;
}
catch (Exception ex) when (IsRetryable(ex))
{
lastException = ex;
if (attempt == _options.MaxAttempts)
{
_logger.LogError(
ex,
"Operation {OperationName} failed after {MaxAttempts} attempts.",
operationName,
_options.MaxAttempts);
throw;
}
TimeSpan delay = CalculateDelay(attempt);
_logger.LogWarning(
ex,
"Operation {OperationName} failed with a transient error on attempt {Attempt} of {MaxAttempts}. Retrying after {DelayMs}ms.",
operationName,
attempt,
_options.MaxAttempts,
(int)delay.TotalMilliseconds);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Operation {OperationName} failed with a non-retryable error on attempt {Attempt}.",
operationName,
attempt);
throw;
}
}
throw new InvalidOperationException(
$"Retry policy exited unexpectedly for operation '{operationName}'.",
lastException);
}
private void ValidateOptions()
{
if (_options.MaxAttempts <= 0)
{
throw new InvalidOperationException("RetryPolicyOptions.MaxAttempts must be greater than zero.");
}
if (_options.BaseDelayMilliseconds < 0)
{
throw new InvalidOperationException("RetryPolicyOptions.BaseDelayMilliseconds cannot be negative.");
}
if (_options.BackoffMultiplier < 1d)
{
throw new InvalidOperationException("RetryPolicyOptions.BackoffMultiplier must be greater than or equal to 1.");
}
if (_options.MaxDelayMilliseconds < 0)
{
throw new InvalidOperationException("RetryPolicyOptions.MaxDelayMilliseconds cannot be negative.");
}
}
private static bool IsRetryable(Exception exception)
{
return exception switch
{
HttpRequestException => true,
IOException => true,
TaskCanceledException => true,
InvalidDataException => false,
ArgumentException => false,
InvalidOperationException => false,
_ => false
};
}
private TimeSpan CalculateDelay(int attempt)
{
double exponentialDelay = _options.BaseDelayMilliseconds *
Math.Pow(_options.BackoffMultiplier, attempt - 1);
double cappedDelay = Math.Min(exponentialDelay, _options.MaxDelayMilliseconds);
int jitterMilliseconds = _random.Next(0, 150);
return TimeSpan.FromMilliseconds(cappedDelay + jitterMilliseconds);
}
}
}

View File

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

View File

@@ -12,34 +12,36 @@ using CmlLib.Core;
using CmlLib.Core.Installer.NeoForge; using CmlLib.Core.Installer.NeoForge;
using CmlLib.Core.Installer.NeoForge.Installers; using CmlLib.Core.Installer.NeoForge.Installers;
using CmlLib.Core.Installers; using CmlLib.Core.Installers;
using CmlLib.Core.Java; using Microsoft.Extensions.Logging;
using CmlLib.Core.VersionMetadata;
namespace AlayaCore.Services namespace AlayaCore.Services
{ {
public sealed class GameInstallService : IGameInstallService 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 IFileStore _fileStore;
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly ILogger<GameInstallService> _logger;
private AlayaPath? _gamePath; private AlayaPath? _gamePath;
private MinecraftLauncher? _minecraftLauncher; private MinecraftLauncher? _minecraftLauncher;
public GameInstallService( public GameInstallService(
IFileStore fileStore, IFileStore fileStore,
ISettingsService settingsService) ISettingsService settingsService,
ILogger<GameInstallService> logger)
{ {
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore)); _fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task EnsureMinecraftInstalledAsync( public async Task EnsureMinecraftInstalledAsync(
ManifestModel manifest, ManifestModel manifest,
InstallEnvironment environment, InstallEnvironment environment,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
EventHandler<InstallerProgressChangedEventArgs>? minecraftProgess = null, EventHandler<InstallerProgressChangedEventArgs>? minecraftProgress = null,
EventHandler<ByteProgress>? byteProgress = null) EventHandler<ByteProgress>? byteProgress = null)
{ {
if (manifest == null) if (manifest == null)
@@ -56,15 +58,23 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion)) 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."); 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 = bool alreadyInstalled =
environment.MinecraftInstalled && environment.MinecraftInstalled &&
string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase); string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase);
if (alreadyInstalled) if (alreadyInstalled)
{ {
_logger.LogInformation("Minecraft version {MinecraftVersion} is already installed and matches the manifest.", manifest.MinecraftVersion);
return; return;
} }
@@ -74,14 +84,23 @@ namespace AlayaCore.Services
if (versionMismatch) 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); await CleanOldInstallAsync(cancellationToken).ConfigureAwait(false);
} }
MinecraftLauncher launcher = GetOrCreateLauncher(minecraftProgess, byteProgress); MinecraftLauncher launcher = GetOrCreateLauncher(minecraftProgress, byteProgress);
_logger.LogInformation("Starting Minecraft installation for version {MinecraftVersion}.", manifest.MinecraftVersion);
await launcher await launcher
.InstallAsync(manifest.MinecraftVersion, cancellationToken) .InstallAsync(manifest.MinecraftVersion, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation("Minecraft installation completed for version {MinecraftVersion}.", manifest.MinecraftVersion);
} }
public async Task EnsureNeoForgeInstalledAsync( public async Task EnsureNeoForgeInstalledAsync(
@@ -105,46 +124,88 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion)) 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."); throw new InvalidDataException("Minecraft version is missing.");
} }
if (string.IsNullOrWhiteSpace(manifest.NeoforgedVersion)) 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."); 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 = bool alreadyInstalled =
environment.NeoforgedInstalled && environment.NeoforgedInstalled &&
string.Equals(environment.NeoforgedVersion, manifest.NeoforgedVersion, StringComparison.OrdinalIgnoreCase); string.Equals(environment.NeoforgedVersion, manifest.NeoforgedVersion, StringComparison.OrdinalIgnoreCase);
if (alreadyInstalled) if (alreadyInstalled)
{ {
_logger.LogInformation("NeoForge version {NeoForgeVersion} is already installed and matches the manifest.", manifest.NeoforgedVersion);
return;
}
bool neoForgeMismatch =
environment.NeoforgedInstalled &&
!string.Equals(environment.NeoforgedVersion, manifest.NeoforgedVersion, StringComparison.OrdinalIgnoreCase);
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; return;
} }
if (!environment.MinecraftInstalled || if (!environment.MinecraftInstalled ||
!string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase)) !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); await EnsureMinecraftInstalledAsync(manifest, environment, cancellationToken).ConfigureAwait(false);
} }
if (string.IsNullOrWhiteSpace(environment.JavaPath)) 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."); throw new InvalidOperationException("A valid Java installation is required before installing NeoForge.");
} }
MinecraftLauncher launcher = GetOrCreateLauncher(); MinecraftLauncher launcher = GetOrCreateLauncher();
await DownloadAndInstallNeoForgeAsync( _logger.LogInformation(
"Starting NeoForge installation. Minecraft version: {MinecraftVersion}, NeoForge version: {NeoForgeVersion}, Java path: {JavaPath}",
manifest.MinecraftVersion,
manifest.NeoforgedVersion,
environment.JavaPath);
await InstallNeoForgeAsync(
launcher, launcher,
manifest, manifest,
environment, environment,
cancellationToken, cancellationToken,
progress, byteProgress).ConfigureAwait(false); progress,
byteProgress).ConfigureAwait(false);
_logger.LogInformation(
"NeoForge installation completed. Verifying Minecraft files for version {MinecraftVersion}.",
manifest.MinecraftVersion);
await launcher await launcher
.InstallAsync(manifest.MinecraftVersion, cancellationToken) .InstallAsync(manifest.MinecraftVersion, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation("Minecraft file verification completed after NeoForge installation.");
} }
public async Task VerifyFilesAsync( public async Task VerifyFilesAsync(
@@ -160,14 +221,19 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion)) 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."); throw new InvalidDataException("Minecraft version is missing.");
} }
MinecraftLauncher launcher = GetOrCreateLauncher(); MinecraftLauncher launcher = GetOrCreateLauncher();
_logger.LogInformation("Verifying Minecraft files for version {MinecraftVersion}.", manifest.MinecraftVersion);
await launcher await launcher
.InstallAsync(manifest.MinecraftVersion, cancellationToken) .InstallAsync(manifest.MinecraftVersion, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation("Minecraft file verification completed for version {MinecraftVersion}.", manifest.MinecraftVersion);
} }
private async Task CleanOldInstallAsync(CancellationToken cancellationToken) private async Task CleanOldInstallAsync(CancellationToken cancellationToken)
@@ -176,48 +242,75 @@ namespace AlayaCore.Services
string gamePath = GetMinecraftPath(); string gamePath = GetMinecraftPath();
_logger.LogInformation("Cleaning old game installation at path {GamePath}.", gamePath);
if (Directory.Exists(gamePath)) if (Directory.Exists(gamePath))
{ {
Directory.Delete(gamePath, recursive: true); 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( string installedModsManifestPath = Path.Combine(
_fileStore.Get(FolderLocation.Manifests), _fileStore.Get(FolderLocation.Manifests),
InstalledModsManifestFileName); INSTALLED_MODS_MANIFEST_FILE_NAME);
if (File.Exists(installedModsManifestPath)) if (File.Exists(installedModsManifestPath))
{ {
File.Delete(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; _gamePath = null;
_minecraftLauncher = null; _minecraftLauncher = null;
await _settingsService.UpdateLaunchVersionAsync(string.Empty, cancellationToken).ConfigureAwait(false); _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(EventHandler<InstallerProgressChangedEventArgs>? minecraftProgess = null, private MinecraftLauncher GetOrCreateLauncher(
EventHandler<InstallerProgressChangedEventArgs>? minecraftProgress = null,
EventHandler<ByteProgress>? byteProgress = null) EventHandler<ByteProgress>? byteProgress = null)
{ {
if (_minecraftLauncher != null) if (_minecraftLauncher != null)
{ {
_logger.LogDebug("Reusing existing MinecraftLauncher instance.");
return _minecraftLauncher; return _minecraftLauncher;
} }
_logger.LogInformation("Creating a new MinecraftLauncher instance.");
_gamePath = new AlayaPath(_fileStore); _gamePath = new AlayaPath(_fileStore);
_minecraftLauncher = new MinecraftLauncher(_gamePath); _minecraftLauncher = new MinecraftLauncher(_gamePath);
if(byteProgress != null) if (byteProgress != null)
{
_minecraftLauncher.ByteProgressChanged += byteProgress; _minecraftLauncher.ByteProgressChanged += byteProgress;
_logger.LogDebug("Attached Minecraft byte progress handler.");
if(minecraftProgess != null) }
_minecraftLauncher.FileProgressChanged += minecraftProgess;
if (minecraftProgress != null)
{
_minecraftLauncher.FileProgressChanged += minecraftProgress;
_logger.LogDebug("Attached Minecraft file progress handler.");
}
return _minecraftLauncher; return _minecraftLauncher;
} }
private async Task DownloadAndInstallNeoForgeAsync( private async Task InstallNeoForgeAsync(
MinecraftLauncher launcher, MinecraftLauncher launcher,
ManifestModel manifest, ManifestModel manifest,
InstallEnvironment environment, InstallEnvironment environment,
@@ -230,15 +323,10 @@ namespace AlayaCore.Services
throw new ArgumentNullException(nameof(launcher)); throw new ArgumentNullException(nameof(launcher));
} }
bool neoForgeMismatch = _logger.LogDebug(
environment.NeoforgedInstalled && "Configuring NeoForge installer. Minecraft version: {MinecraftVersion}, NeoForge version: {NeoForgeVersion}",
!string.Equals(environment.NeoforgedVersion, manifest.NeoforgedVersion, StringComparison.OrdinalIgnoreCase); manifest.MinecraftVersion,
manifest.NeoforgedVersion);
if (neoForgeMismatch)
{
await CleanOldInstallAsync(cancellationToken).ConfigureAwait(false);
return;
}
NeoForgeInstaller installer = new NeoForgeInstaller(launcher); NeoForgeInstaller installer = new NeoForgeInstaller(launcher);
@@ -248,25 +336,37 @@ namespace AlayaCore.Services
JavaPath = environment.JavaPath, JavaPath = environment.JavaPath,
SkipIfAlreadyInstalled = true SkipIfAlreadyInstalled = true
}; };
if(progress != null) if (progress != null)
{
options.FileProgress = progress; options.FileProgress = progress;
_logger.LogDebug("Attached NeoForge file progress reporter.");
if(byteProgress != null) }
if (byteProgress != null)
{
options.ByteProgress = byteProgress; options.ByteProgress = byteProgress;
_logger.LogDebug("Attached NeoForge byte progress reporter.");
}
string version = await installer string version = await installer
.Install(manifest.MinecraftVersion, manifest.NeoforgedVersion, options) .Install(manifest.MinecraftVersion, manifest.NeoforgedVersion, options)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation("NeoForge installer returned launch version {LaunchVersion}.", version);
await _settingsService await _settingsService
.UpdateLaunchVersionAsync(version, cancellationToken) .UpdateLaunchVersionAsync(version, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation("Persisted launch version {LaunchVersion} to settings.", version);
} }
private string GetMinecraftPath() private string GetMinecraftPath()
{ {
return _fileStore.GetOrCreate(FolderLocation.Game); string gamePath = _fileStore.GetOrCreate(FolderLocation.Game);
_logger.LogDebug("Resolved Minecraft game path to {GamePath}.", gamePath);
return gamePath;
} }
} }
} }

View File

@@ -8,10 +8,9 @@ using AlayaCore.Installation;
using AlayaCore.Models; using AlayaCore.Models;
using AlayaCore.Models.Configuration; using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.Utilities.Enums;
using CmlLib.Core; using CmlLib.Core;
using CmlLib.Core.Auth;
using CmlLib.Core.ProcessBuilder; using CmlLib.Core.ProcessBuilder;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services namespace AlayaCore.Services
{ {
@@ -21,20 +20,22 @@ namespace AlayaCore.Services
private readonly IFileStore _fileStore; private readonly IFileStore _fileStore;
private readonly ILaunchDirector _director; private readonly ILaunchDirector _director;
private readonly GameOptions _gameOptions; private readonly GameOptions _gameOptions;
private readonly ILogger<GameLaunchService> _logger;
private AlayaPath? _gamePath;
private MinecraftLauncher? _minecraftLauncher; private MinecraftLauncher? _minecraftLauncher;
public GameLaunchService( public GameLaunchService(
IAuthService authService, IAuthService authService,
IFileStore fileStore, IFileStore fileStore,
ILaunchDirector director, ILaunchDirector director,
GameOptions gameOptions) GameOptions gameOptions,
ILogger<GameLaunchService> logger)
{ {
_authService = authService ?? throw new ArgumentNullException(nameof(authService)); _authService = authService ?? throw new ArgumentNullException(nameof(authService));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore)); _fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_director = director ?? throw new ArgumentNullException(nameof(director)); _director = director ?? throw new ArgumentNullException(nameof(director));
_gameOptions = gameOptions ?? throw new ArgumentNullException(nameof(gameOptions)); _gameOptions = gameOptions ?? throw new ArgumentNullException(nameof(gameOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task LaunchAsync( public async Task LaunchAsync(
@@ -44,6 +45,7 @@ namespace AlayaCore.Services
{ {
if (!_director.CanRun) 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."); throw new InvalidOperationException("The launcher is not in a runnable state.");
} }
@@ -59,60 +61,110 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(_gameOptions.LaunchVersion)) 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."); throw new InvalidDataException("GameOptions.LaunchVersion is not configured.");
} }
if (string.IsNullOrWhiteSpace(environment.JavaPath)) 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."); throw new InvalidOperationException("A valid Java path is required to launch the game.");
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
MLaunchOption option = await BuildLaunchOptions(manifest, environment); _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,
cancellationToken).ConfigureAwait(false);
MinecraftLauncher launcher = GetOrCreateLauncher(); MinecraftLauncher launcher = GetOrCreateLauncher();
_logger.LogInformation("Creating Minecraft process for launch version {LaunchVersion}.", _gameOptions.LaunchVersion);
var process = await launcher var process = await launcher
.CreateProcessAsync(_gameOptions.LaunchVersion, option) .CreateProcessAsync(_gameOptions.LaunchVersion, option)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation(
"Minecraft process was created successfully. ProcessId: {ProcessId}",
process.Id);
var processWrapper = new ProcessWrapper(process); var processWrapper = new ProcessWrapper(process);
_logger.LogInformation("Starting Minecraft process.");
processWrapper.StartWithEvents(); processWrapper.StartWithEvents();
_logger.LogInformation("Waiting for Minecraft process to exit.");
await processWrapper.WaitForExitTaskAsync().ConfigureAwait(false); await processWrapper.WaitForExitTaskAsync().ConfigureAwait(false);
_logger.LogInformation(
"Minecraft process exited. ProcessId: {ProcessId}, ExitCode: {ExitCode}",
process.Id,
process.ExitCode);
} }
private MinecraftLauncher GetOrCreateLauncher() private MinecraftLauncher GetOrCreateLauncher()
{ {
if (_minecraftLauncher != null) if (_minecraftLauncher != null)
{ {
_logger.LogDebug("Reusing existing MinecraftLauncher instance for game launch.");
return _minecraftLauncher; return _minecraftLauncher;
} }
_gamePath = new AlayaPath(_fileStore); _logger.LogInformation("Creating a new MinecraftLauncher instance for game launch.");
_minecraftLauncher = new MinecraftLauncher(_gamePath); _minecraftLauncher = new MinecraftLauncher(new AlayaPath(_fileStore));
return _minecraftLauncher; return _minecraftLauncher;
} }
private string GetMinecraftPath() private async Task<MLaunchOption> BuildLaunchOptionsAsync(
ManifestModel manifest,
InstallEnvironment environment,
CancellationToken cancellationToken)
{ {
return _fileStore.GetOrCreate(FolderLocation.Game); if (manifest == null)
} {
throw new ArgumentNullException(nameof(manifest));
}
if (environment == null)
{
throw new ArgumentNullException(nameof(environment));
}
private async Task<MLaunchOption> BuildLaunchOptions(ManifestModel manifest, InstallEnvironment environment)
{
if (manifest.ServerUrl == null) if (manifest.ServerUrl == null)
{ {
throw new InvalidDataException("Manifest Server Url is not configured."); _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 return new MLaunchOption
{ {
Session = await _authService.GetSessionAsync(), Session = session,
JavaPath = environment.JavaPath, JavaPath = environment.JavaPath,
MinimumRamMb = _gameOptions.MinimumRamMB, MinimumRamMb = _gameOptions.MinimumRamMb,
MaximumRamMb = _gameOptions.MaximumRamMB, MaximumRamMb = _gameOptions.MaximumRamMb,
ScreenWidth = _gameOptions.ScreenWidth, ScreenWidth = _gameOptions.ScreenWidth,
ScreenHeight = _gameOptions.ScreenHeight, ScreenHeight = _gameOptions.ScreenHeight,
ServerIp = manifest.ServerUrl.Host, ServerIp = manifest.ServerUrl.Host,

View File

@@ -6,9 +6,11 @@ using System.Security.Cryptography;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Clients; using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Progress; using AlayaCore.Models.Progress;
using AlayaCore.Models.Results; using AlayaCore.Models.Results;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services namespace AlayaCore.Services
{ {
@@ -17,10 +19,17 @@ namespace AlayaCore.Services
private const int BUFFER_SIZE = 81920; private const int BUFFER_SIZE = 81920;
private readonly IHttpClient _httpClient; private readonly IHttpClient _httpClient;
private readonly IRetryPolicy _retryPolicy;
private readonly ILogger<HttpDownloadService> _logger;
public HttpDownloadService(IHttpClient httpClient) public HttpDownloadService(
IHttpClient httpClient,
IRetryPolicy retryPolicy,
ILogger<HttpDownloadService> logger)
{ {
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task<DownloadResult> DownloadFileAsync( public async Task<DownloadResult> DownloadFileAsync(
@@ -45,31 +54,35 @@ namespace AlayaCore.Services
throw new ArgumentException("Destination path cannot be null, empty, or whitespace.", nameof(destinationPath)); throw new ArgumentException("Destination path cannot be null, empty, or whitespace.", nameof(destinationPath));
} }
if (string.IsNullOrWhiteSpace(sha512Hash)) string normalizedExpectedHash = NormalizeHash(sha512Hash);
{
throw new ArgumentException("SHA-512 hash cannot be null, empty, or whitespace.", nameof(sha512Hash));
}
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
string normalizedExpectedHash = NormalizeHash(sha512Hash);
string fileName = Path.GetFileName(destinationPath); string fileName = Path.GetFileName(destinationPath);
if (string.IsNullOrWhiteSpace(fileName)) if (string.IsNullOrWhiteSpace(fileName))
{ {
throw new ArgumentException("Destination path must include a file name.", nameof(destinationPath)); 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); EnsureDestinationDirectoryExists(destinationPath);
if (File.Exists(destinationPath) && VerifyFileHash(destinationPath, normalizedExpectedHash)) if (File.Exists(destinationPath) && VerifyFileHash(destinationPath, normalizedExpectedHash))
{ {
long existingLength = new FileInfo(destinationPath).Length; 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( progress?.Report(new DownloadProgress(
fileName: fileName, fileName: fileName,
destinationPath: destinationPath, destinationPath: destinationPath,
bytesDownloaded: existingLength, bytesDownloaded: 0,
totalBytes: existingLength, totalBytes: existingLength,
bytesPerSecond: null, bytesPerSecond: null,
statusMessage: "File already present and valid.")); statusMessage: "File already present and valid."));
@@ -84,114 +97,180 @@ namespace AlayaCore.Services
bool destinationExisted = File.Exists(destinationPath); bool destinationExisted = File.Exists(destinationPath);
string tempFilePath = destinationPath + ".download"; 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); DeleteFileIfExists(tempFilePath);
try try
{ {
using HttpResponseMessage response = await _httpClient.GetAsync( DownloadResult result = await _retryPolicy.ExecuteAsync(
sourceUri, async token =>
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
long? totalBytes = response.Content.Headers.ContentLength;
long bytesDownloaded = 0;
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
bytesDownloaded: 0,
totalBytes: totalBytes,
bytesPerSecond: null,
statusMessage: "Starting download..."));
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
await using FileStream fileStream = new FileStream(
tempFilePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
BUFFER_SIZE,
useAsync: true);
using SHA512 sha512 = SHA512.Create();
byte[] buffer = new byte[BUFFER_SIZE];
Stopwatch stopwatch = Stopwatch.StartNew();
while (true)
{
int bytesRead = await responseStream.ReadAsync(
buffer,
0,
buffer.Length,
cancellationToken);
if (bytesRead == 0)
{ {
break; DeleteFileIfExists(tempFilePath);
}
await fileStream.WriteAsync( using HttpResponseMessage response = await _httpClient.GetAsync(
buffer, sourceUri,
0, HttpCompletionOption.ResponseHeadersRead,
bytesRead, token).ConfigureAwait(false);
cancellationToken);
sha512.TransformBlock(buffer, 0, bytesRead, null, 0); response.EnsureSuccessStatusCode();
bytesDownloaded += bytesRead; long? totalBytes = response.Content.Headers.ContentLength;
long bytesDownloaded = 0;
double? bytesPerSecond = null; _logger.LogInformation(
if (stopwatch.Elapsed.TotalSeconds > 0d) "Download response received for {FileName}. Content-Length: {TotalBytes}.",
{ fileName,
bytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds; totalBytes);
}
progress?.Report(new DownloadProgress( progress?.Report(new DownloadProgress(
fileName: fileName, fileName: fileName,
destinationPath: destinationPath, destinationPath: destinationPath,
bytesDownloaded: bytesDownloaded, bytesDownloaded: 0,
totalBytes: totalBytes, totalBytes: totalBytes,
bytesPerSecond: bytesPerSecond, bytesPerSecond: null,
statusMessage: "Downloading file...")); statusMessage: "Starting download..."));
}
sha512.TransformFinalBlock(Array.Empty<byte>(), 0, 0); await using Stream responseStream = await response.Content
await fileStream.FlushAsync(cancellationToken); .ReadAsStreamAsync()
.ConfigureAwait(false);
string actualHash = ConvertToLowerHex(sha512.Hash); await using FileStream fileStream = new FileStream(
tempFilePath,
if (!string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase)) FileMode.Create,
{ FileAccess.Write,
throw new InvalidDataException( FileShare.None,
$"Downloaded file hash mismatch. Expected '{normalizedExpectedHash}', got '{actualHash}'."); BUFFER_SIZE,
} useAsync: true);
ReplaceDestinationFile(tempFilePath, destinationPath); using SHA512 sha512 = SHA512.Create();
double? finalBytesPerSecond = null; byte[] buffer = new byte[BUFFER_SIZE];
if (stopwatch.Elapsed.TotalSeconds > 0d) Stopwatch stopwatch = Stopwatch.StartNew();
{
finalBytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds;
}
progress?.Report(new DownloadProgress( _logger.LogDebug(
fileName: fileName, "Streaming download content for {FileName} into temporary file {TempFilePath}.",
destinationPath: destinationPath, fileName,
bytesDownloaded: bytesDownloaded, tempFilePath);
totalBytes: totalBytes ?? bytesDownloaded,
bytesPerSecond: finalBytesPerSecond,
statusMessage: "Download complete."));
return new DownloadResult( while (true)
destinationPath, {
destinationExisted ? DownloadOutcome.ReplacedInvalid : DownloadOutcome.Downloaded, int bytesRead = await responseStream.ReadAsync(
hashVerified: true, buffer,
bytesDownloaded: bytesDownloaded); 0,
buffer.Length,
token).ConfigureAwait(false);
if (bytesRead == 0)
{
break;
}
await fileStream.WriteAsync(
buffer,
0,
bytesRead,
token).ConfigureAwait(false);
sha512.TransformBlock(buffer, 0, bytesRead, null, 0);
bytesDownloaded += bytesRead;
double? bytesPerSecond = null;
if (stopwatch.Elapsed.TotalSeconds > 0d)
{
bytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds;
}
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
bytesDownloaded: bytesDownloaded,
totalBytes: totalBytes,
bytesPerSecond: bytesPerSecond,
statusMessage: "Downloading file..."));
}
sha512.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
await fileStream.FlushAsync(token).ConfigureAwait(false);
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;
if (stopwatch.Elapsed.TotalSeconds > 0d)
{
finalBytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds;
}
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
bytesDownloaded: bytesDownloaded,
totalBytes: totalBytes ?? bytesDownloaded,
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,
outcome,
hashVerified: true,
bytesDownloaded: bytesDownloaded);
},
$"download:{fileName}",
cancellationToken).ConfigureAwait(false);
return result;
} }
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); DeleteFileIfExists(tempFilePath);
throw; throw;
} }
@@ -204,30 +283,42 @@ namespace AlayaCore.Services
throw new ArgumentException("File path cannot be null, empty, or whitespace.", nameof(filePath)); throw new ArgumentException("File path cannot be null, empty, or whitespace.", nameof(filePath));
} }
if (string.IsNullOrWhiteSpace(sha512Hash)) string normalizedExpectedHash = NormalizeHash(sha512Hash);
{
throw new ArgumentException("SHA-512 hash cannot be null, empty, or whitespace.", nameof(sha512Hash));
}
if (!File.Exists(filePath)) if (!File.Exists(filePath))
{ {
_logger.LogDebug("Hash verification skipped because file does not exist at {FilePath}.", filePath);
return false; return false;
} }
string normalizedExpectedHash = NormalizeHash(sha512Hash);
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using SHA512 sha512 = SHA512.Create(); using SHA512 sha512 = SHA512.Create();
byte[] hashBytes = sha512.ComputeHash(fileStream); byte[] hashBytes = sha512.ComputeHash(fileStream);
string actualHash = ConvertToLowerHex(hashBytes); 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) private static void EnsureDestinationDirectoryExists(string destinationPath)
{ {
string? directoryPath = Path.GetDirectoryName(destinationPath); string? directoryPath = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(directoryPath)) if (!string.IsNullOrWhiteSpace(directoryPath))
{ {
Directory.CreateDirectory(directoryPath); Directory.CreateDirectory(directoryPath);
@@ -250,7 +341,31 @@ namespace AlayaCore.Services
private static string NormalizeHash(string hash) private static string NormalizeHash(string hash)
{ {
return hash.Trim().Replace("-", string.Empty).ToLowerInvariant(); if (string.IsNullOrWhiteSpace(hash))
{
throw new ArgumentException("SHA-512 hash cannot be null, empty, or whitespace.", nameof(hash));
}
string normalized = hash.Trim().Replace("-", string.Empty).ToLowerInvariant();
if (normalized.Length != 128)
{
throw new ArgumentException("SHA-512 hash must be 128 hexadecimal characters long.", nameof(hash));
}
foreach (char c in normalized)
{
bool isHex =
(c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f');
if (!isHex)
{
throw new ArgumentException("SHA-512 hash contains invalid characters.", nameof(hash));
}
}
return normalized;
} }
private static string ConvertToLowerHex(byte[]? hashBytes) private static string ConvertToLowerHex(byte[]? hashBytes)

View File

@@ -11,29 +11,35 @@ using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation; using AlayaCore.Installation;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.Utilities.Enums; using AlayaCore.Utilities.Enums;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace AlayaCore.Services namespace AlayaCore.Services
{ {
public sealed class InstallationStateService : IInstallStateService public sealed class InstallationStateService : IInstallStateService
{ {
private const string VersionsFolderName = "versions"; private const string VERSIONS_FOLDER_NAME = "versions";
private readonly IFileStore _fileStore; private readonly IFileStore _fileStore;
private readonly IManifestService _manifestService; private readonly IManifestService _manifestService;
private readonly ILogger<InstallationStateService> _logger;
public InstallationStateService( public InstallationStateService(
IFileStore fileStore, IFileStore fileStore,
IManifestService manifestService) IManifestService manifestService,
ILogger<InstallationStateService> logger)
{ {
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore)); _fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService)); _manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task<InstallEnvironment> GetCurrentEnvironmentAsync(CancellationToken cancellationToken = default) public async Task<InstallEnvironment> GetCurrentEnvironmentAsync(CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Building current installation environment state.");
OSPlatform platform = GetCurrentPlatform(); OSPlatform platform = GetCurrentPlatform();
bool javaInstalled = TryGetJavaPath(out string? javaPath); bool javaInstalled = TryGetJavaPath(out string? javaPath);
@@ -41,15 +47,20 @@ namespace AlayaCore.Services
if (javaInstalled) if (javaInstalled)
{ {
_logger.LogDebug("Java runtime was detected at {JavaPath}. Attempting to read Java version.", javaPath);
javaVersion = GetJavaVersion(javaPath!); javaVersion = GetJavaVersion(javaPath!);
} }
else
{
_logger.LogInformation("No managed Java runtime was detected.");
}
InstalledVersionState versionState = GetInstalledVersionState(); InstalledVersionState versionState = GetInstalledVersionState();
InstalledModsManifestModel installedModsManifest = InstalledModsManifestModel installedModsManifest =
await _manifestService.GetInstalledModsManifestAsync(cancellationToken).ConfigureAwait(false); await _manifestService.GetInstalledModsManifestAsync(cancellationToken).ConfigureAwait(false);
return new InstallEnvironment( InstallEnvironment environment = new InstallEnvironment(
osPlatform: platform, osPlatform: platform,
javaInstalled: javaInstalled, javaInstalled: javaInstalled,
javaPath: javaPath, javaPath: javaPath,
@@ -59,6 +70,19 @@ namespace AlayaCore.Services
neoforgedInstalled: !string.IsNullOrWhiteSpace(versionState.NeoForgeVersion), neoforgedInstalled: !string.IsNullOrWhiteSpace(versionState.NeoForgeVersion),
neoforgedVersion: versionState.NeoForgeVersion, neoforgedVersion: versionState.NeoForgeVersion,
installedModsManifest: installedModsManifest); 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() private static OSPlatform GetCurrentPlatform()
@@ -81,7 +105,7 @@ namespace AlayaCore.Services
throw new PlatformNotSupportedException("The current operating system is not supported."); 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)) if (string.IsNullOrWhiteSpace(javaPath))
{ {
@@ -93,6 +117,8 @@ namespace AlayaCore.Services
throw new FileNotFoundException("Java executable was not found.", javaPath); throw new FileNotFoundException("Java executable was not found.", javaPath);
} }
_logger.LogDebug("Reading Java version from executable at {JavaPath}.", javaPath);
using var process = new Process using var process = new Process
{ {
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
@@ -113,10 +139,26 @@ namespace AlayaCore.Services
if (process.ExitCode != 0 && string.IsNullOrWhiteSpace(standardError)) 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 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) private static string? ParseJavaVersion(string processOutput)
@@ -137,17 +179,19 @@ namespace AlayaCore.Services
{ {
string executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) string executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "javaw.exe" ? "javaw.exe"
: "javaw"; : "java";
string runtimePath = _fileStore.GetOrCreate(FolderLocation.JavaRuntime); string runtimePath = _fileStore.Get(FolderLocation.JavaRuntime);
string fullPath = Path.Combine(runtimePath, "bin", executableName); string fullPath = Path.Combine(runtimePath, "bin", executableName);
if (!File.Exists(fullPath)) if (!File.Exists(fullPath))
{ {
_logger.LogDebug("Managed Java executable was not found at {JavaPath}.", fullPath);
javaPath = null; javaPath = null;
return false; return false;
} }
_logger.LogDebug("Managed Java executable was found at {JavaPath}.", fullPath);
javaPath = fullPath; javaPath = fullPath;
return true; return true;
} }
@@ -156,15 +200,22 @@ namespace AlayaCore.Services
{ {
string versionsPath = GetVersionsPath(); string versionsPath = GetVersionsPath();
_logger.LogDebug("Inspecting installed version metadata under {VersionsPath}.", versionsPath);
if (!Directory.Exists(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(); return InstalledVersionState.Empty();
} }
string[] versionDirectories = Directory.GetDirectories(versionsPath); string[] versionDirectories = Directory
.GetDirectories(versionsPath)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (versionDirectories.Length == 0) if (versionDirectories.Length == 0)
{ {
_logger.LogInformation("Versions directory at {VersionsPath} was empty.", versionsPath);
return InstalledVersionState.Empty(); return InstalledVersionState.Empty();
} }
@@ -177,6 +228,7 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(versionFolderName)) if (string.IsNullOrWhiteSpace(versionFolderName))
{ {
_logger.LogDebug("Skipping version directory with an invalid folder name: {VersionDirectory}.", versionDirectory);
continue; continue;
} }
@@ -184,43 +236,60 @@ namespace AlayaCore.Services
if (!File.Exists(versionJsonPath)) if (!File.Exists(versionJsonPath))
{ {
_logger.LogDebug("Skipping version directory {VersionDirectory} because version metadata file {VersionJsonPath} was not found.", versionDirectory, versionJsonPath);
continue; continue;
} }
JObject versionJson = LoadJson(versionJsonPath); if (!TryLoadJson(versionJsonPath, out JObject? versionJson))
{
_logger.LogWarning("Skipping version metadata file {VersionJsonPath} because it could not be read or parsed.", versionJsonPath);
continue;
}
string? id = versionJson.Value<string>("id"); string? id = versionJson.Value<string>("id");
string? inheritsFrom = versionJson.Value<string>("inheritsFrom"); string? inheritsFrom = versionJson.Value<string>("inheritsFrom");
if (string.IsNullOrWhiteSpace(id)) if (string.IsNullOrWhiteSpace(id))
{ {
_logger.LogDebug("Skipping version metadata file {VersionJsonPath} because it did not contain a valid id.", versionJsonPath);
continue; continue;
} }
if (IsNeoForgeVersion(id, inheritsFrom)) if (IsNeoForgeVersion(id, inheritsFrom))
{ {
neoForgeVersion = id; neoForgeVersion ??= id;
if (string.IsNullOrWhiteSpace(minecraftVersion) && !string.IsNullOrWhiteSpace(inheritsFrom)) if (string.IsNullOrWhiteSpace(minecraftVersion) && !string.IsNullOrWhiteSpace(inheritsFrom))
{ {
minecraftVersion = inheritsFrom; minecraftVersion = inheritsFrom;
} }
_logger.LogDebug(
"Detected NeoForge version metadata. Id: {NeoForgeVersion}, InheritsFrom: {MinecraftVersion}",
id,
inheritsFrom);
continue; continue;
} }
if (string.IsNullOrWhiteSpace(minecraftVersion)) minecraftVersion ??= id;
{
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); return new InstalledVersionState(minecraftVersion, neoForgeVersion);
} }
private string GetVersionsPath() private string GetVersionsPath()
{ {
return Path.Combine(_fileStore.GetOrCreate(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) private static bool IsNeoForgeVersion(string? id, string? inheritsFrom)
@@ -239,21 +308,33 @@ namespace AlayaCore.Services
value.Contains("neoforged", StringComparison.OrdinalIgnoreCase); value.Contains("neoforged", StringComparison.OrdinalIgnoreCase);
} }
private static JObject LoadJson(string path) private bool TryLoadJson(string path, out JObject? jsonObject)
{ {
if (string.IsNullOrWhiteSpace(path)) jsonObject = null;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{ {
throw new ArgumentException("Path cannot be null, empty, or whitespace.", nameof(path)); return false;
} }
string json = File.ReadAllText(path); try
if (string.IsNullOrWhiteSpace(json))
{ {
throw new InvalidDataException($"File '{path}' was empty."); string json = File.ReadAllText(path);
}
return JObject.Parse(json); if (string.IsNullOrWhiteSpace(json))
{
_logger.LogWarning("JSON file at {Path} was empty.", path);
return false;
}
jsonObject = JObject.Parse(json);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load or parse JSON file at {Path}.", path);
return false;
}
} }
private sealed class InstalledVersionState private sealed class InstalledVersionState

View File

@@ -1,317 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models.Manifests;
using AlayaCore.Utilities.Enums;
namespace AlayaCore.Services
{
public sealed class JavaService : IJavaService
{
private const string JavaArchiveHashPlaceholder = "REPLACE_WITH_MANIFEST_HASH_SUPPORT";
private const string BaseUrl = "https://aka.ms/download-jdk/";
private readonly IDownloadService _downloadService;
private readonly IFileStore _fileStore;
public JavaService(IDownloadService downloadService, IFileStore fileStore)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
}
public async Task EnsureValidJavaInstalledAsync(
ManifestModel manifest,
InstallEnvironment environment,
CancellationToken cancellationToken = default)
{
if (manifest == null)
{
throw new ArgumentNullException(nameof(manifest));
}
if (environment == null)
{
throw new ArgumentNullException(nameof(environment));
}
cancellationToken.ThrowIfCancellationRequested();
string installDirectory = GetJavaInstallDirectory();
if (environment.JavaInstalled &&
Directory.Exists(installDirectory) &&
string.Equals(environment.JavaVersion, manifest.RequiredJavaVersion, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (Directory.Exists(installDirectory))
{
await RemoveDirectoryAsync(installDirectory, cancellationToken).ConfigureAwait(false);
}
Uri downloadUri = GetPlatformSpecificJavaUri(manifest.RequiredJavaVersion);
string downloadPath = GetJavaDownloadPath(downloadUri);
Directory.CreateDirectory(Path.GetDirectoryName(downloadPath) ?? AppContext.BaseDirectory);
Directory.CreateDirectory(installDirectory);
await DownloadJavaAsync(
downloadUri,
downloadPath,
cancellationToken).ConfigureAwait(false);
await InstallJavaAsync(
downloadPath,
installDirectory,
cancellationToken).ConfigureAwait(false);
}
public Uri GetPlatformSpecificJavaUri(string javaVersion)
{
if (string.IsNullOrWhiteSpace(javaVersion))
{
throw new ArgumentException("Java version must be provided.", nameof(javaVersion));
}
string os = GetOsSegment();
string arch = GetArchitectureSegment();
string extension = GetFileExtension(os);
string fileName = $"microsoft-jdk-{javaVersion}-{os}-{arch}.{extension}";
return new Uri($"{BaseUrl}{fileName}");
}
private string GetJavaInstallDirectory()
{
return _fileStore.GetOrCreate(FolderLocation.Java);
}
private string GetJavaDownloadPath(Uri downloadUri)
{
if (downloadUri == null)
{
throw new ArgumentNullException(nameof(downloadUri));
}
string fileName = Path.GetFileName(downloadUri.AbsolutePath);
if (string.IsNullOrWhiteSpace(fileName))
{
throw new InvalidOperationException("Could not determine Java archive file name from download URI.");
}
return Path.Combine(_fileStore.GetOrCreate(FolderLocation.Downloads), fileName);
}
private static string GetOsSegment()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return "windows";
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return "linux";
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return "macos";
}
throw new PlatformNotSupportedException("Unsupported operating system.");
}
private static string GetArchitectureSegment()
{
return RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "x64",
Architecture.Arm64 => "aarch64",
Architecture.X86 => throw new NotSupportedException("X86 is not supported."),
Architecture.Arm => throw new NotSupportedException("Arm32 is not supported."),
_ => throw new PlatformNotSupportedException(
$"Unsupported architecture: {RuntimeInformation.OSArchitecture}")
};
}
private static string GetFileExtension(string os)
{
return os switch
{
"windows" => "zip",
"linux" => "tar.gz",
"macos" => "tar.gz",
_ => throw new PlatformNotSupportedException($"Unsupported OS: {os}")
};
}
private static Task RemoveDirectoryAsync(
string directoryPath,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(directoryPath))
{
throw new ArgumentException("Directory path cannot be null, empty, or whitespace.", nameof(directoryPath));
}
cancellationToken.ThrowIfCancellationRequested();
if (Directory.Exists(directoryPath))
{
Directory.Delete(directoryPath, recursive: true);
}
return Task.CompletedTask;
}
private async Task DownloadJavaAsync(
Uri javaUri,
string destinationPath,
CancellationToken cancellationToken = default)
{
if (javaUri == null)
{
throw new ArgumentNullException(nameof(javaUri));
}
if (!javaUri.IsAbsoluteUri)
{
throw new ArgumentException("Java download URI must be absolute.", nameof(javaUri));
}
if (string.IsNullOrWhiteSpace(destinationPath))
{
throw new ArgumentException("Destination path cannot be null, empty, or whitespace.", nameof(destinationPath));
}
cancellationToken.ThrowIfCancellationRequested();
string directory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
if (File.Exists(destinationPath))
{
File.Delete(destinationPath);
}
await _downloadService.DownloadFileAsync(
javaUri,
destinationPath,
JavaArchiveHashPlaceholder,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task InstallJavaAsync(
string archivePath,
string installDirectory,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(archivePath))
{
throw new ArgumentException("Archive path cannot be null, empty, or whitespace.", nameof(archivePath));
}
if (string.IsNullOrWhiteSpace(installDirectory))
{
throw new ArgumentException("Install directory cannot be null, empty, or whitespace.", nameof(installDirectory));
}
cancellationToken.ThrowIfCancellationRequested();
if (!File.Exists(archivePath))
{
throw new FileNotFoundException("Java archive was not found.", archivePath);
}
Directory.CreateDirectory(installDirectory);
if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
ZipFile.ExtractToDirectory(archivePath, installDirectory);
return;
}
if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
{
await ExtractTarGzAsync(archivePath, installDirectory, cancellationToken).ConfigureAwait(false);
return;
}
throw new NotSupportedException(
$"Unsupported Java archive format: {Path.GetFileName(archivePath)}");
}
private static async Task ExtractTarGzAsync(
string archivePath,
string destinationDirectory,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(archivePath))
{
throw new ArgumentException("Archive path cannot be null, empty, or whitespace.", nameof(archivePath));
}
if (string.IsNullOrWhiteSpace(destinationDirectory))
{
throw new ArgumentException("Destination directory cannot be null, empty, or whitespace.", nameof(destinationDirectory));
}
Directory.CreateDirectory(destinationDirectory);
var startInfo = new ProcessStartInfo
{
FileName = "tar",
Arguments = $"-xzf \"{archivePath}\" -C \"{destinationDirectory}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = startInfo };
await using (cancellationToken.Register(() =>
{
try
{
if (!process.HasExited)
{
process.Kill();
}
}
catch
{
// Ignore race conditions if the process exits while cancellation is being handled.
}
}))
{
process.Start();
string standardOutput = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
string standardError = await process.StandardError.ReadToEndAsync().ConfigureAwait(false);
await Task.Run(() => process.WaitForExit(), cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"tar extraction failed with exit code {process.ExitCode}. Error: {standardError}. Output: {standardOutput}");
}
}
}
}
}

View File

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Configuration; using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services namespace AlayaCore.Services
{ {
@@ -13,97 +14,171 @@ namespace AlayaCore.Services
{ {
private readonly IManifestService _manifestService; private readonly IManifestService _manifestService;
private readonly LauncherUpdateServiceOptions _options; private readonly LauncherUpdateServiceOptions _options;
private readonly ILogger<LauncherUpdateService> _logger;
public LauncherUpdateService( public LauncherUpdateService(
IManifestService manifestService, IManifestService manifestService,
LauncherUpdateServiceOptions options) LauncherUpdateServiceOptions options,
ILogger<LauncherUpdateService> logger)
{ {
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService)); _manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task<bool> DoesLauncherNeedUpdating(CancellationToken cancellationToken = default) public async Task<bool> DoesLauncherNeedUpdating(CancellationToken cancellationToken = default)
{ {
if (_options.ForceUpdate) if (_options.ForceUpdate)
{ {
_logger.LogWarning("Launcher update check is being forced by configuration.");
return true; return true;
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Checking whether the launcher requires an update.");
LauncherManifestModel? localManifest = await _manifestService LauncherManifestModel? localManifest = await _manifestService
.GetLocalLauncherManifestAsync(cancellationToken) .GetLocalLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (localManifest == null) 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; return true;
} }
if (string.IsNullOrWhiteSpace(localManifest.Sha512Hash)) 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; return true;
} }
string remoteHash = await _manifestService LauncherManifestModel remoteManifest = await _manifestService
.GetRemoteLauncherManifestHashAsync(cancellationToken) .GetRemoteLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(remoteHash)) 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."); throw new InvalidOperationException("Remote launcher manifest returned an invalid SHA-512 hash.");
} }
return !string.Equals( bool versionMismatch = localManifest.Version != remoteManifest.Version;
bool hashMismatch = !string.Equals(
localManifest.Sha512Hash.Trim(), localManifest.Sha512Hash.Trim(),
remoteHash.Trim(), remoteManifest.Sha512Hash.Trim(),
StringComparison.OrdinalIgnoreCase); 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;
} }
public Task LaunchUpdaterAsync(LauncherManifestModel newManifest, CancellationToken cancellationToken = default) public Task LaunchUpdaterAsync(
LauncherManifestModel newManifest,
CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (newManifest == null)
{
throw new ArgumentNullException(nameof(newManifest));
}
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.");
}
string updaterPath = _options.AlayaUpdaterPath; string updaterPath = _options.AlayaUpdaterPath;
if (string.IsNullOrWhiteSpace(updaterPath)) if (string.IsNullOrWhiteSpace(updaterPath))
{ {
_logger.LogError("Updater launch failed because the updater path was not configured.");
throw new InvalidOperationException("Updater path is not configured."); throw new InvalidOperationException("Updater path is not configured.");
} }
if (!Path.IsPathFullyQualified(updaterPath)) 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."); throw new InvalidOperationException("Updater path must be absolute.");
} }
if (!File.Exists(updaterPath)) 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); throw new FileNotFoundException("Alaya updater program was not found.", updaterPath);
} }
string workingDirectory = Path.GetDirectoryName(updaterPath) string workingDirectory = Path.GetDirectoryName(updaterPath)
?? throw new InvalidOperationException("Updater working directory could not be resolved."); ?? throw new InvalidOperationException("Updater working directory could not be resolved.");
string arguments = $"-url \"{newManifest.DownloadUri.AbsoluteUri}\"";
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
FileName = updaterPath, FileName = updaterPath,
WorkingDirectory = workingDirectory, WorkingDirectory = workingDirectory,
Arguments = $@"-url {newManifest.DownloadUri}", Arguments = arguments,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true CreateNoWindow = true
}; };
_logger.LogInformation(
"Launching updater process. UpdaterPath: {UpdaterPath}, WorkingDirectory: {WorkingDirectory}, DownloadUri: {DownloadUri}",
updaterPath,
workingDirectory,
newManifest.DownloadUri.AbsoluteUri);
try try
{ {
Process? process = Process.Start(startInfo); Process? process = Process.Start(startInfo);
if (process == null) if (process == null)
{ {
_logger.LogError("Updater process start returned null for executable {UpdaterPath}.", updaterPath);
throw new InvalidOperationException("Failed to start updater process."); 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 OperationCanceledException)) 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); throw new InvalidOperationException("Failed to launch updater process.", ex);
} }

View File

@@ -5,19 +5,21 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces; using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Clients; using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Configuration; using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.Models.Manifests.DTO; using AlayaCore.Models.Manifests.DTO;
using AlayaCore.Utilities.Enums; using AlayaCore.Utilities.Enums;
using AlayaCore.Utilities.Extensions; using AlayaCore.Utilities.Extensions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace AlayaCore.Services namespace AlayaCore.Services
{ {
public sealed class ManifestService : IManifestService public sealed class ManifestService : IManifestService
{ {
private const string CORE_MANIFEST_FILE_NAME = "CoreManifest.json"; private const string ALAYA_MANIFEST_FILE_NAME = "AlayaManifest.json";
private const string LAUNCHER_MANIFEST_FILE_NAME = "LauncherManifest.json"; private const string LAUNCHER_MANIFEST_FILE_NAME = "LauncherManifest.json";
private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json"; private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
@@ -25,37 +27,52 @@ namespace AlayaCore.Services
private readonly IHttpClient _httpClient; private readonly IHttpClient _httpClient;
private readonly IFileStore _fileStore; private readonly IFileStore _fileStore;
private readonly ManifestServiceOptions _options; private readonly ManifestServiceOptions _options;
private readonly IRetryPolicy _retryPolicy;
private readonly ILogger<ManifestService> _logger;
public ManifestService( public ManifestService(
IDownloadService downloadService, IDownloadService downloadService,
IHttpClient httpClient, IHttpClient httpClient,
IFileStore fileStore, IFileStore fileStore,
ManifestServiceOptions options) ManifestServiceOptions options,
IRetryPolicy retryPolicy,
ILogger<ManifestService> logger)
{ {
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService)); _downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore)); _fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public Task<ManifestModel> GetCoreManifestAsync(CancellationToken cancellationToken = default) public Task<ManifestModel> GetAlayaManifestAsync(CancellationToken cancellationToken = default)
{ {
string destinationPath = GetCoreManifestPath(); string destinationPath = GetCoreManifestPath();
_logger.LogInformation(
"Downloading and loading Alaya manifest from {ManifestUri} to {DestinationPath}.",
_options.AlayaManifestUri,
destinationPath);
return DownloadAndLoadManifestAsync<ManifestDto, ManifestModel>( return DownloadAndLoadManifestAsync<ManifestDto, ManifestModel>(
_options.CoreManifestUri, _options.AlayaManifestUri,
destinationPath, destinationPath,
_options.CoreManifestSha512Hash, _options.AlayaManifestSha512Hash,
static dto => dto.ToModel(), static dto => dto.ToModel(),
cancellationToken); cancellationToken);
} }
public async Task<InstalledModsManifestModel> GetInstalledModsManifestAsync(CancellationToken cancellationToken = default) public async Task<InstalledModsManifestModel> GetInstalledModsManifestAsync(
CancellationToken cancellationToken = default)
{ {
string path = GetInstalledModsManifestPath(); string path = GetInstalledModsManifestPath();
_logger.LogDebug("Loading installed mods manifest from {ManifestPath}.", path);
if (!File.Exists(path)) if (!File.Exists(path))
{ {
_logger.LogInformation("Installed mods manifest was not found at {ManifestPath}. Returning an empty manifest.", path);
return InstalledModsManifestModel.Empty(); return InstalledModsManifestModel.Empty();
} }
@@ -63,22 +80,40 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
{ {
_logger.LogWarning("Installed mods manifest at {ManifestPath} was empty. Returning an empty manifest.", path);
return InstalledModsManifestModel.Empty(); return InstalledModsManifestModel.Empty();
} }
InstalledModsManifestModel? manifest = DeserializeAndMapManifest<InstalledModsManifestDto, InstalledModsManifestModel>( InstalledModsManifestModel? manifest =
json, DeserializeAndMapManifest<InstalledModsManifestDto, InstalledModsManifestModel>(
path, json,
static dto => dto.ToModel(), path,
swallowDeserializationErrors: true); static dto => dto.ToModel(),
swallowDeserializationErrors: true);
return manifest ?? InstalledModsManifestModel.Empty(); if (manifest == null)
{
_logger.LogWarning("Installed mods manifest at {ManifestPath} could not be deserialized. Returning an empty manifest.", path);
return InstalledModsManifestModel.Empty();
}
_logger.LogInformation(
"Loaded installed mods manifest from {ManifestPath}. Mod count: {ModCount}",
path,
manifest.Mods.Count);
return manifest;
} }
public Task<LauncherManifestModel> GetLauncherManifestAsync(CancellationToken cancellationToken = default) public Task<LauncherManifestModel> GetLauncherManifestAsync(CancellationToken cancellationToken = default)
{ {
string destinationPath = GetLauncherManifestPath(); string destinationPath = GetLauncherManifestPath();
_logger.LogInformation(
"Downloading and loading launcher manifest from {ManifestUri} to {DestinationPath}.",
_options.LauncherManifestUri,
destinationPath);
return DownloadAndLoadManifestAsync<LauncherManifestDto, LauncherManifestModel>( return DownloadAndLoadManifestAsync<LauncherManifestDto, LauncherManifestModel>(
_options.LauncherManifestUri, _options.LauncherManifestUri,
destinationPath, destinationPath,
@@ -87,103 +122,105 @@ namespace AlayaCore.Services
cancellationToken); cancellationToken);
} }
public Task<ManifestModel?> GetLocalCoreManifestAsync(CancellationToken cancellationToken = default) public Task<ManifestModel?> GetLocalAlayaManifestAsync(CancellationToken cancellationToken = default)
{ {
string path = GetCoreManifestPath();
_logger.LogDebug("Loading local Alaya manifest from {ManifestPath}.", path);
return LoadLocalManifestAsync<ManifestDto, ManifestModel>( return LoadLocalManifestAsync<ManifestDto, ManifestModel>(
GetCoreManifestPath(), path,
static dto => dto.ToModel(), static dto => dto.ToModel(),
cancellationToken); cancellationToken);
} }
public Task<LauncherManifestModel?> GetLocalLauncherManifestAsync(CancellationToken cancellationToken = default) public Task<LauncherManifestModel?> GetLocalLauncherManifestAsync(CancellationToken cancellationToken = default)
{ {
string path = GetLauncherManifestPath();
_logger.LogDebug("Loading local launcher manifest from {ManifestPath}.", path);
return LoadLocalManifestAsync<LauncherManifestDto, LauncherManifestModel>( return LoadLocalManifestAsync<LauncherManifestDto, LauncherManifestModel>(
GetLauncherManifestPath(), path,
static dto => dto.ToModel(), static dto => dto.ToModel(),
cancellationToken); cancellationToken);
} }
public async Task<Version> GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default) public async Task<Version> GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); _logger.LogDebug("Fetching remote Alaya manifest version from {ManifestUri}.", _options.AlayaManifestUri);
using HttpResponseMessage response = await _httpClient.GetAsync( ManifestModel remoteManifest = await GetRemoteManifestAsync<ManifestDto, ManifestModel>(
_options.CoreManifestUri, _options.AlayaManifestUri,
HttpCompletionOption.ResponseContentRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
throw new InvalidDataException(
$"Remote core manifest response from '{_options.CoreManifestUri}' was empty.");
}
ManifestModel remoteManifest = DeserializeAndMapManifest<ManifestDto, ManifestModel>(
json,
_options.CoreManifestUri.ToString(),
static dto => dto.ToModel(), static dto => dto.ToModel(),
swallowDeserializationErrors: false)!; cancellationToken).ConfigureAwait(false);
if (remoteManifest.AlayaVersion == null) if (remoteManifest.AlayaVersion == null)
{ {
_logger.LogError("Remote Alaya manifest from {ManifestUri} did not contain a valid version.", _options.AlayaManifestUri);
throw new InvalidDataException( throw new InvalidDataException(
$"Remote core manifest from '{_options.CoreManifestUri}' does not contain a valid version."); $"Remote core manifest from '{_options.AlayaManifestUri}' does not contain a valid version.");
} }
_logger.LogInformation(
"Fetched remote Alaya manifest version {RemoteVersion} from {ManifestUri}.",
remoteManifest.AlayaVersion,
_options.AlayaManifestUri);
return remoteManifest.AlayaVersion; return remoteManifest.AlayaVersion;
} }
public async Task<string> GetRemoteLauncherManifestHashAsync(CancellationToken cancellationToken = default) public async Task<LauncherManifestModel> GetRemoteLauncherManifestAsync(
CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); _logger.LogDebug("Fetching remote launcher manifest from {ManifestUri}.", _options.LauncherManifestUri);
using HttpResponseMessage response = await _httpClient.GetAsync( LauncherManifestModel remoteManifest = await GetRemoteManifestAsync<LauncherManifestDto, LauncherManifestModel>(
_options.LauncherManifestUri, _options.LauncherManifestUri,
HttpCompletionOption.ResponseContentRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
throw new InvalidDataException(
$"Remote launcher manifest response from '{_options.LauncherManifestUri}' was empty.");
}
LauncherManifestModel remoteManifest = DeserializeAndMapManifest<LauncherManifestDto, LauncherManifestModel>(
json,
_options.LauncherManifestUri.ToString(),
static dto => dto.ToModel(), static dto => dto.ToModel(),
swallowDeserializationErrors: false)!; cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(remoteManifest.Sha512Hash)) 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( throw new InvalidDataException(
$"Remote launcher manifest from '{_options.LauncherManifestUri}' does not contain a valid SHA-512 hash."); $"Remote launcher manifest from '{_options.LauncherManifestUri}' does not contain a valid SHA-512 hash.");
} }
return remoteManifest.Sha512Hash.Trim(); 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() public string GetLauncherManifestPath()
{ {
return Path.Combine(_fileStore.GetOrCreate(FolderLocation.Manifests), LAUNCHER_MANIFEST_FILE_NAME); 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() public string GetCoreManifestPath()
{ {
return Path.Combine(_fileStore.GetOrCreate(FolderLocation.Manifests), CORE_MANIFEST_FILE_NAME); 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() public string GetInstalledModsManifestPath()
{ {
return Path.Combine(_fileStore.GetOrCreate(FolderLocation.Manifests), INSTALLED_MODS_MANIFEST_FILE_NAME); string path = Path.Combine(_fileStore.Get(FolderLocation.Manifests), INSTALLED_MODS_MANIFEST_FILE_NAME);
_logger.LogDebug("Resolved installed mods manifest path to {ManifestPath}.", path);
return path;
} }
private async Task<TModel?> LoadLocalManifestAsync<TDto, TModel>( private async Task<TModel?> LoadLocalManifestAsync<TDto, TModel>(
@@ -204,6 +241,7 @@ namespace AlayaCore.Services
if (!File.Exists(path)) if (!File.Exists(path))
{ {
_logger.LogInformation("Local manifest was not found at {ManifestPath}.", path);
return default; return default;
} }
@@ -211,14 +249,26 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
{ {
_logger.LogWarning("Local manifest at {ManifestPath} was empty.", path);
return default; return default;
} }
return DeserializeAndMapManifest<TDto, TModel>( TModel? model = DeserializeAndMapManifest(
json, json,
path, path,
map, map,
swallowDeserializationErrors: true); swallowDeserializationErrors: true);
if (model == null)
{
_logger.LogWarning("Local manifest at {ManifestPath} could not be deserialized or mapped.", path);
}
else
{
_logger.LogDebug("Successfully loaded local manifest from {ManifestPath}.", path);
}
return model;
} }
private async Task<TModel> DownloadAndLoadManifestAsync<TDto, TModel>( private async Task<TModel> DownloadAndLoadManifestAsync<TDto, TModel>(
@@ -258,14 +308,26 @@ namespace AlayaCore.Services
EnsureDirectoryExists(destinationPath); EnsureDirectoryExists(destinationPath);
await _downloadService.DownloadFileAsync( _logger.LogInformation(
"Downloading manifest from {ManifestUri} to {DestinationPath}.",
manifestUri, manifestUri,
destinationPath, destinationPath);
sha512Hash,
cancellationToken: cancellationToken).ConfigureAwait(false); await _retryPolicy.ExecuteAsync(
async token =>
{
await _downloadService.DownloadFileAsync(
manifestUri,
destinationPath,
sha512Hash,
cancellationToken: token).ConfigureAwait(false);
},
$"manifest-download:{Path.GetFileName(destinationPath)}",
cancellationToken).ConfigureAwait(false);
if (!File.Exists(destinationPath)) 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); throw new FileNotFoundException("Manifest file was not found after download.", destinationPath);
} }
@@ -273,17 +335,83 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
{ {
_logger.LogError("Downloaded manifest file at {DestinationPath} was empty.", destinationPath);
throw new InvalidDataException($"Manifest file '{destinationPath}' was empty."); throw new InvalidDataException($"Manifest file '{destinationPath}' was empty.");
} }
return DeserializeAndMapManifest<TDto, TModel>( TModel model = DeserializeAndMapManifest(
json, json,
destinationPath, destinationPath,
map, map,
swallowDeserializationErrors: false)!; swallowDeserializationErrors: false)!;
_logger.LogInformation(
"Downloaded and loaded manifest successfully from {ManifestUri} into {DestinationPath}.",
manifestUri,
destinationPath);
return model;
} }
private static TModel? DeserializeAndMapManifest<TDto, TModel>( private async Task<TModel> GetRemoteManifestAsync<TDto, TModel>(
Uri manifestUri,
Func<TDto, TModel> map,
CancellationToken cancellationToken)
where TDto : class
{
if (manifestUri == null)
{
throw new ArgumentNullException(nameof(manifestUri));
}
if (!manifestUri.IsAbsoluteUri)
{
throw new ArgumentException("Manifest URI must be absolute.", nameof(manifestUri));
}
if (map == null)
{
throw new ArgumentNullException(nameof(map));
}
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Fetching remote manifest from {ManifestUri}.", manifestUri);
return await _retryPolicy.ExecuteAsync(
async token =>
{
using HttpResponseMessage response = await _httpClient.GetAsync(
manifestUri,
HttpCompletionOption.ResponseContentRead,
token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogError("Remote manifest response from {ManifestUri} was empty.", manifestUri);
throw new InvalidDataException(
$"Remote manifest response from '{manifestUri}' was empty.");
}
TModel model = DeserializeAndMapManifest(
json,
manifestUri.ToString(),
map,
swallowDeserializationErrors: false)!;
_logger.LogDebug("Successfully fetched and mapped remote manifest from {ManifestUri}.", manifestUri);
return model;
},
$"manifest-fetch:{manifestUri.AbsolutePath}",
cancellationToken).ConfigureAwait(false);
}
private TModel? DeserializeAndMapManifest<TDto, TModel>(
string json, string json,
string sourceName, string sourceName,
Func<TDto, TModel> map, Func<TDto, TModel> map,
@@ -294,9 +422,11 @@ namespace AlayaCore.Services
{ {
if (swallowDeserializationErrors) if (swallowDeserializationErrors)
{ {
_logger.LogWarning("Manifest source {SourceName} was empty and deserialization errors were configured to be swallowed.", sourceName);
return default; return default;
} }
_logger.LogError("Manifest source {SourceName} was empty.", sourceName);
throw new InvalidDataException($"Manifest source '{sourceName}' was empty."); throw new InvalidDataException($"Manifest source '{sourceName}' was empty.");
} }
@@ -319,9 +449,11 @@ namespace AlayaCore.Services
{ {
if (swallowDeserializationErrors) 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; return default;
} }
_logger.LogError(ex, "Failed to deserialize manifest source {SourceName} to {DtoType}.", sourceName, typeof(TDto).Name);
throw new JsonSerializationException( throw new JsonSerializationException(
$"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.", $"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.",
ex); ex);
@@ -331,24 +463,34 @@ namespace AlayaCore.Services
{ {
if (swallowDeserializationErrors) 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; return default;
} }
_logger.LogError("Deserialization of manifest source {SourceName} to {DtoType} returned null.", sourceName, typeof(TDto).Name);
throw new JsonSerializationException( throw new JsonSerializationException(
$"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}."); $"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.");
} }
try 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 OperationCanceledException)) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
if (swallowDeserializationErrors) 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; return default;
} }
_logger.LogError(ex, "Manifest source {SourceName} was deserialized but could not be mapped to {ModelType}.", sourceName, typeof(TModel).Name);
throw new InvalidDataException( throw new InvalidDataException(
$"Manifest source '{sourceName}' was deserialized but could not be mapped to {typeof(TModel).Name}.", $"Manifest source '{sourceName}' was deserialized but could not be mapped to {typeof(TModel).Name}.",
ex); ex);

View File

@@ -7,14 +7,17 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces; using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Clients; using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation; using AlayaCore.Installation;
using AlayaCore.Models; using AlayaCore.Models;
using AlayaCore.Models.Configuration; using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.Models.Manifests.DTO; using AlayaCore.Models.Manifests.DTO;
using AlayaCore.Models.Progress;
using AlayaCore.Utilities.Enums; using AlayaCore.Utilities.Enums;
using AlayaCore.Utilities.Extensions; using AlayaCore.Utilities.Extensions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -28,22 +31,29 @@ namespace AlayaCore.Services
private readonly ModrinthConnectionOptions _options; private readonly ModrinthConnectionOptions _options;
private readonly IHttpClient _httpClient; private readonly IHttpClient _httpClient;
private readonly IFileStore _fileStore; private readonly IFileStore _fileStore;
private readonly IRetryPolicy _retryPolicy;
private readonly ILogger<ModService> _logger;
public ModService( public ModService(
IDownloadService downloadService, IDownloadService downloadService,
ModrinthConnectionOptions options, ModrinthConnectionOptions options,
IHttpClient httpClient, IHttpClient httpClient,
IFileStore fileStore) IFileStore fileStore,
IRetryPolicy retryPolicy,
ILogger<ModService> logger)
{ {
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService)); _downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore)); _fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task ProcessModsAsync( public async Task ProcessModsAsync(
ManifestModel manifest, ManifestModel manifest,
InstallEnvironment environment, InstallEnvironment environment,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (manifest == null) if (manifest == null)
@@ -62,9 +72,15 @@ namespace AlayaCore.Services
List<ModFileEntry> requiredMods = manifest.Files List<ModFileEntry> requiredMods = manifest.Files
.Where(file => file.Type == FileType.Mod) .Where(file => file.Type == FileType.Mod)
.OrderBy(file => file.FileName, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
RemoveStaleMods(requiredMods); _logger.LogInformation(
"Starting mod sync. RequiredMods: {RequiredModCount}, InstalledModsManifestEntries: {InstalledModCount}",
requiredMods.Count,
installedMods.Count);
RemoveStaleMods(requiredMods, cancellationToken);
List<ModFileEntry> finalInstalledMods = new List<ModFileEntry>(); List<ModFileEntry> finalInstalledMods = new List<ModFileEntry>();
@@ -72,6 +88,12 @@ namespace AlayaCore.Services
{ {
cancellationToken.ThrowIfCancellationRequested(); 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( ModFileEntry? installedMod = installedMods.FirstOrDefault(
mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase)); mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
@@ -85,18 +107,50 @@ namespace AlayaCore.Services
if (isValidInstalledMod) if (isValidInstalledMod)
{ {
_logger.LogInformation(
"Mod {FileName} is already installed and valid. Skipping download.",
requiredMod.FileName);
finalInstalledMods.Add(installedMod!); finalInstalledMods.Add(installedMod!);
continue; 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); Uri modUri = await ResolveModUrlAsync(requiredMod, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Downloading mod {FileName} from {ModUri} to {DestinationPath}.",
requiredMod.FileName,
modUri,
destinationPath);
await _downloadService.DownloadFileAsync( await _downloadService.DownloadFileAsync(
modUri, modUri,
destinationPath, destinationPath,
requiredMod.Sha512Hash, requiredMod.Sha512Hash,
progress,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Download completed successfully for mod {FileName}.",
requiredMod.FileName);
finalInstalledMods.Add(new ModFileEntry( finalInstalledMods.Add(new ModFileEntry(
requiredMod.FileName, requiredMod.FileName,
requiredMod.Type, requiredMod.Type,
@@ -105,6 +159,10 @@ namespace AlayaCore.Services
} }
await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false); await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Mod sync completed successfully. Final installed mod count: {InstalledModCount}",
finalInstalledMods.Count);
} }
private static bool IsInstalledModUpToDate( private static bool IsInstalledModUpToDate(
@@ -145,90 +203,156 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(fileEntry.Sha512Hash)) 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."); throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a SHA-512 hash.");
} }
string versionEndpoint = BuildVersionEndpoint(fileEntry.Sha512Hash); string versionEndpoint = BuildVersionEndpoint(fileEntry.Sha512Hash);
using HttpResponseMessage response = await _httpClient.GetAsync( _logger.LogDebug(
new Uri(versionEndpoint, UriKind.Absolute), "Resolving mod URL for {FileName} using Modrinth endpoint {VersionEndpoint}.",
HttpCompletionOption.ResponseContentRead, fileEntry.FileName,
versionEndpoint);
return await _retryPolicy.ExecuteAsync(
async token =>
{
using HttpResponseMessage response = await _httpClient.GetAsync(
new Uri(versionEndpoint, UriKind.Absolute),
HttpCompletionOption.ResponseContentRead,
token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
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.");
}
JObject jsonObject = JObject.Parse(json);
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.");
}
JObject? selectedFile = filesArray
.OfType<JObject>()
.FirstOrDefault(file => file.Value<bool?>("primary") == true)
?? filesArray.OfType<JObject>().FirstOrDefault();
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.");
}
string? remoteSha512Hash = hashesObject.Value<string>("sha512");
if (string.IsNullOrWhiteSpace(remoteSha512Hash))
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid SHA-512 hash.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash.");
}
if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"Mod metadata hash mismatch for {FileName}. Remote SHA-512: {RemoteHash}, Required SHA-512: {RequiredHash}",
fileEntry.FileName,
remoteSha512Hash,
fileEntry.Sha512Hash);
throw new InvalidDataException(
$"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash.");
}
long? size = selectedFile.Value<long?>("size");
if (!size.HasValue || size.Value <= 0)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid file size.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
}
if (size.Value != fileEntry.Size)
{
_logger.LogError(
"Mod metadata size mismatch for {FileName}. Remote Size: {RemoteSize}, Required Size: {RequiredSize}",
fileEntry.FileName,
size.Value,
fileEntry.Size);
throw new InvalidDataException(
$"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size.");
}
string? modUrl = selectedFile.Value<string>("url");
if (string.IsNullOrWhiteSpace(modUrl))
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid file URL.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL.");
}
if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
{
_logger.LogError(
"Mod metadata response for {FileName} contained an invalid file URL: {ModUrl}",
fileEntry.FileName,
modUrl);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL.");
}
_logger.LogDebug(
"Resolved download URL for mod {FileName} to {ModUrl}.",
fileEntry.FileName,
result);
return result;
},
$"mod-metadata:{fileEntry.FileName}",
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty.");
}
JObject jsonObject = JObject.Parse(json);
JArray? filesArray = jsonObject["files"] as JArray;
if (filesArray == null || filesArray.Count == 0)
{
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files.");
}
JObject? selectedFile = filesArray
.OfType<JObject>()
.FirstOrDefault(file => file.Value<bool?>("primary") == true)
?? filesArray.OfType<JObject>().FirstOrDefault();
if (selectedFile == null)
{
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry.");
}
JObject? hashesObject = selectedFile["hashes"] as JObject;
if (hashesObject == null)
{
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a hashes object.");
}
string? remoteSha512Hash = hashesObject.Value<string>("sha512");
if (string.IsNullOrWhiteSpace(remoteSha512Hash))
{
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash.");
}
if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash.");
}
long? size = selectedFile.Value<long?>("size");
if (!size.HasValue || size.Value <= 0)
{
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
}
if (size.Value != fileEntry.Size)
{
throw new InvalidDataException(
$"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size.");
}
string? modUrl = selectedFile.Value<string>("url");
if (string.IsNullOrWhiteSpace(modUrl))
{
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL.");
}
if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
{
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL.");
}
return result;
} }
private string BuildVersionEndpoint(string sha512Hash) private string BuildVersionEndpoint(string sha512Hash)
@@ -252,26 +376,37 @@ namespace AlayaCore.Services
} }
string modsDirectory = GetModsDirectoryPath(); 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() private string GetModsDirectoryPath()
{ {
return _fileStore.GetOrCreate(FolderLocation.Mods); string modsDirectory = _fileStore.GetOrCreate(FolderLocation.Mods);
_logger.LogDebug("Resolved mods directory to {ModsDirectory}.", modsDirectory);
return modsDirectory;
} }
private void RemoveStaleMods(IEnumerable<ModFileEntry> requiredMods) private void RemoveStaleMods(
IEnumerable<ModFileEntry> requiredMods,
CancellationToken cancellationToken)
{ {
if (requiredMods == null) if (requiredMods == null)
{ {
throw new ArgumentNullException(nameof(requiredMods)); throw new ArgumentNullException(nameof(requiredMods));
} }
string modsDirectory = GetModsDirectoryPath(); string modsDirectory = _fileStore.Get(FolderLocation.Mods);
if (!Directory.Exists(modsDirectory)) if (!Directory.Exists(modsDirectory))
{ {
_logger.LogDebug("Mods directory {ModsDirectory} does not exist. No stale mods need removal.", modsDirectory);
return; return;
} }
@@ -282,10 +417,17 @@ namespace AlayaCore.Services
foreach (string filePath in Directory.GetFiles(modsDirectory)) foreach (string filePath in Directory.GetFiles(modsDirectory))
{ {
cancellationToken.ThrowIfCancellationRequested();
string fileName = Path.GetFileName(filePath); string fileName = Path.GetFileName(filePath);
if (!requiredFileNames.Contains(fileName)) if (!requiredFileNames.Contains(fileName))
{ {
_logger.LogInformation(
"Removing stale mod file {FileName} at {FilePath}.",
fileName,
filePath);
File.Delete(filePath); File.Delete(filePath);
} }
} }
@@ -312,14 +454,25 @@ namespace AlayaCore.Services
string json = JsonConvert.SerializeObject(dto, Formatting.Indented); 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); await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false);
if (File.Exists(manifestPath)) if (File.Exists(manifestPath))
{ {
_logger.LogDebug("Deleting previous installed mods manifest at {ManifestPath}.", manifestPath);
File.Delete(manifestPath); File.Delete(manifestPath);
} }
File.Move(temporaryManifestPath, manifestPath); File.Move(temporaryManifestPath, manifestPath);
_logger.LogInformation(
"Installed mods manifest updated successfully at {ManifestPath}. EntryCount: {EntryCount}",
manifestPath,
entries.Count);
} }
} }
} }

View File

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

View File

@@ -2,10 +2,10 @@ namespace AlayaCore.States
{ {
public enum LaunchState public enum LaunchState
{ {
Checking,
Ready, Ready,
LauncherNeedsUpdate, LauncherNeedsUpdate,
NeedAuthenticating, NeedAuthenticating,
InstallJava,
InstallMinecraft, InstallMinecraft,
InstallNeoforge, InstallNeoforge,
SyncMods SyncMods
@@ -14,7 +14,6 @@ namespace AlayaCore.States
public sealed class LaunchPlan public sealed class LaunchPlan
{ {
public bool LauncherNeedsUpdate { get; } public bool LauncherNeedsUpdate { get; }
public bool JavaNeedsInstallOrUpdate { get; }
public bool MinecraftNeedsInstallOrUpdate { get; } public bool MinecraftNeedsInstallOrUpdate { get; }
public bool NeoforgeNeedsInstallOrUpdate { get; } public bool NeoforgeNeedsInstallOrUpdate { get; }
public bool ModsNeedSync { get; } public bool ModsNeedSync { get; }
@@ -26,7 +25,6 @@ namespace AlayaCore.States
public bool NeedsUpdating => public bool NeedsUpdating =>
LauncherNeedsUpdate || LauncherNeedsUpdate ||
JavaNeedsInstallOrUpdate ||
MinecraftNeedsInstallOrUpdate || MinecraftNeedsInstallOrUpdate ||
NeoforgeNeedsInstallOrUpdate || NeoforgeNeedsInstallOrUpdate ||
ModsNeedSync; ModsNeedSync;
@@ -36,14 +34,12 @@ namespace AlayaCore.States
public LaunchPlan( public LaunchPlan(
bool launcherNeedsUpdate, bool launcherNeedsUpdate,
bool javaNeedsInstallOrUpdate,
bool minecraftNeedsInstallOrUpdate, bool minecraftNeedsInstallOrUpdate,
bool neoforgeNeedsInstallOrUpdate, bool neoforgeNeedsInstallOrUpdate,
bool modsNeedSync, bool modsNeedSync,
bool needAuthenticating) bool needAuthenticating)
{ {
LauncherNeedsUpdate = launcherNeedsUpdate; LauncherNeedsUpdate = launcherNeedsUpdate;
JavaNeedsInstallOrUpdate = javaNeedsInstallOrUpdate;
MinecraftNeedsInstallOrUpdate = minecraftNeedsInstallOrUpdate; MinecraftNeedsInstallOrUpdate = minecraftNeedsInstallOrUpdate;
NeoforgeNeedsInstallOrUpdate = neoforgeNeedsInstallOrUpdate; NeoforgeNeedsInstallOrUpdate = neoforgeNeedsInstallOrUpdate;
ModsNeedSync = modsNeedSync; ModsNeedSync = modsNeedSync;
@@ -58,9 +54,6 @@ namespace AlayaCore.States
if (NeedAuthenticating) if (NeedAuthenticating)
return LaunchState.NeedAuthenticating; return LaunchState.NeedAuthenticating;
if (JavaNeedsInstallOrUpdate)
return LaunchState.InstallJava;
if (MinecraftNeedsInstallOrUpdate) if (MinecraftNeedsInstallOrUpdate)
return LaunchState.InstallMinecraft; return LaunchState.InstallMinecraft;
@@ -77,7 +70,6 @@ namespace AlayaCore.States
{ {
return new LaunchPlan( return new LaunchPlan(
launcherNeedsUpdate: false, launcherNeedsUpdate: false,
javaNeedsInstallOrUpdate: false,
minecraftNeedsInstallOrUpdate: false, minecraftNeedsInstallOrUpdate: false,
neoforgeNeedsInstallOrUpdate: false, neoforgeNeedsInstallOrUpdate: false,
modsNeedSync: false, modsNeedSync: false,

View File

@@ -0,0 +1,16 @@
namespace AlayaCore.Utilities.Enums
{
public enum LauncherErrorType
{
Unknown,
Network,
Manifest,
Authentication,
Download,
Installation,
Update,
Launch,
Configuration,
Cancelled
}
}

View File

@@ -47,8 +47,6 @@ namespace AlayaCore.Utilities.Extensions
return new ManifestModel( return new ManifestModel(
dto.AlayaVersion, dto.AlayaVersion,
dto.RequiredJavaVersion,
dto.RequiredJavaUrl,
dto.MinecraftVersion, dto.MinecraftVersion,
dto.NeoforgedVersion, dto.NeoforgedVersion,
dto.ServerUrl, dto.ServerUrl,
@@ -80,8 +78,6 @@ namespace AlayaCore.Utilities.Extensions
return new ManifestDto return new ManifestDto
{ {
AlayaVersion = model.AlayaVersion, AlayaVersion = model.AlayaVersion,
RequiredJavaVersion = model.RequiredJavaVersion,
RequiredJavaUrl = model.RequiredJavaBaseUrl,
MinecraftVersion = model.MinecraftVersion, MinecraftVersion = model.MinecraftVersion,
NeoforgedVersion = model.NeoforgedVersion, NeoforgedVersion = model.NeoforgedVersion,
Files = model.Files.Select(file => file.ToDto()).ToList() Files = model.Files.Select(file => file.ToDto()).ToList()

View File

@@ -0,0 +1,56 @@
using System;
using System.IO;
using System.Net.Http;
using AlayaCore.Utilities.Enums;
namespace AlayaCore.Errors
{
public static class ErrorHelper
{
public static LauncherError Map(Exception exception)
{
if (exception == null)
{
throw new ArgumentNullException(nameof(exception));
}
return exception switch
{
OperationCanceledException => new LauncherError(
LauncherErrorType.Cancelled,
"The operation was cancelled.",
exception),
HttpRequestException => new LauncherError(
LauncherErrorType.Network,
"A network error occurred.",
exception),
IOException => new LauncherError(
LauncherErrorType.Network,
"A file or network I/O error occurred.",
exception),
InvalidDataException => new LauncherError(
LauncherErrorType.Manifest,
"Invalid or corrupt data was encountered.",
exception),
ArgumentException => new LauncherError(
LauncherErrorType.Configuration,
"Invalid configuration or input.",
exception),
InvalidOperationException => new LauncherError(
LauncherErrorType.Launch,
"The operation could not be completed due to an invalid state.",
exception),
_ => new LauncherError(
LauncherErrorType.Unknown,
"An unexpected error occurred.",
exception)
};
}
}
}

View File

@@ -0,0 +1,21 @@
using AlayaCore.Models.Configuration;
namespace AlayaCore.Utilities.Helpers
{
public static class OptionsHelper
{
public static (LauncherUpdateServiceOptions LaunchUpdater, LauncherOptions Launcher,
GameOptions Game, ManifestServiceOptions Manifest, ModrinthConnectionOptions Modrinth,
RetryPolicyOptions RetryPolicy) GetDefaultOptions()
{
LauncherUpdateServiceOptions lso = LauncherUpdateServiceOptions.Default;
LauncherOptions lo = LauncherOptions.Default;
GameOptions go = GameOptions.Default;
ManifestServiceOptions mso = ManifestServiceOptions.Default;
ModrinthConnectionOptions mco = ModrinthConnectionOptions.Default;
RetryPolicyOptions rpo = RetryPolicyOptions.Default;
return (lso, lo, go, mso, mco, rpo);
}
}
}

View File

@@ -10,7 +10,7 @@ namespace AlayaCore.Utilities.Stores
{ {
private static readonly string BaseDirectoryPath = AppContext.BaseDirectory; private static readonly string BaseDirectoryPath = AppContext.BaseDirectory;
private static readonly string JavaDirectoryPath = Path.Combine(BaseDirectoryPath, "Java"); private static readonly string JavaDirectoryPath = Path.Combine(BaseDirectoryPath, "Java");
private static readonly string JavaRuntimeDirectoryPath = Path.Combine(JavaDirectoryPath, "java-runtime-epsilon"); private static readonly string JavaRuntimeDirectoryPath = Path.Combine(JavaDirectoryPath, "runtime");
private static readonly string GameDirectoryPath = Path.Combine(BaseDirectoryPath, "Game"); private static readonly string GameDirectoryPath = Path.Combine(BaseDirectoryPath, "Game");
private static readonly string ModsDirectoryPath = Path.Combine(GameDirectoryPath, "mods"); private static readonly string ModsDirectoryPath = Path.Combine(GameDirectoryPath, "mods");
private static readonly string ResourcePacksDirectoryPath = Path.Combine(GameDirectoryPath, "resourcepacks"); private static readonly string ResourcePacksDirectoryPath = Path.Combine(GameDirectoryPath, "resourcepacks");
@@ -36,6 +36,26 @@ namespace AlayaCore.Utilities.Stores
{ FolderLocation.Data, DataDirectoryPath} { FolderLocation.Data, DataDirectoryPath}
}; };
public LocalFileStore()
{
CreateDirectories();
}
public void CreateDirectories()
{
Directory.CreateDirectory(BaseDirectoryPath);
Directory.CreateDirectory(JavaDirectoryPath);
Directory.CreateDirectory(JavaRuntimeDirectoryPath);
Directory.CreateDirectory(GameDirectoryPath);
Directory.CreateDirectory(ModsDirectoryPath);
Directory.CreateDirectory(ResourcePacksDirectoryPath);
Directory.CreateDirectory(ConfigDirectoryPath);
Directory.CreateDirectory(DownloadsDirectoryPath);
Directory.CreateDirectory(ManifestsDirectoryPath);
Directory.CreateDirectory(PluginsDirectoryPath);
Directory.CreateDirectory(DataDirectoryPath);
}
public string Get(FolderLocation location) public string Get(FolderLocation location)
{ {
if (!Folders.TryGetValue(location, out string? path)) if (!Folders.TryGetValue(location, out string? path))