Compare commits

..

8 Commits

59 changed files with 4884 additions and 819 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

@@ -0,0 +1,7 @@
namespace AlayaCore.Abstractions.Configuration
{
public abstract class BaseConfig
{
public abstract string FileName { get; }
}
}

View File

@@ -0,0 +1,12 @@
using AlayaCore.Utilities.Enums;
namespace AlayaCore.Abstractions.Interfaces
{
public interface IFileStore
{
string Get(FolderLocation location);
string GetOrCreate(FolderLocation location);
string Combine(FolderLocation location, params string[] paths);
bool Exists(FolderLocation location);
}
}

View File

@@ -1,21 +1,27 @@
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;
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);
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

@@ -0,0 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
using CmlLib.Core.Auth;
namespace AlayaCore.Abstractions.Interfaces.Services
{
public interface IAuthService
{
Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default);
Task AuthenticateAsync(CancellationToken cancellationToken = default);
Task SignOutAsync(CancellationToken cancellationToken = default);
Task<MSession> GetSessionAsync(CancellationToken cancellationToken = default);
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Installation;
using AlayaCore.Models.Manifests;
using CmlLib.Core;
using CmlLib.Core.Installers;
namespace AlayaCore.Abstractions.Interfaces.Services
{
public interface IGameInstallService
{
Task EnsureMinecraftInstalledAsync(
ManifestModel manifest,
InstallEnvironment environment,
CancellationToken cancellationToken = default,
EventHandler<InstallerProgressChangedEventArgs>? minecraftProgess = null,
EventHandler<ByteProgress>? byteProgress = null);
Task EnsureNeoForgeInstalledAsync(
ManifestModel manifest,
InstallEnvironment environment,
CancellationToken cancellationToken = default,
IProgress<InstallerProgressChangedEventArgs>? progress = null,
IProgress<ByteProgress>? byteProgress = null);
Task VerifyFilesAsync(
ManifestModel manifest,
CancellationToken cancellationToken = default);
}
}

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

@@ -7,11 +7,23 @@ namespace AlayaCore.Abstractions.Interfaces.Services
public interface ISettingsService public interface ISettingsService
{ {
LauncherOptions LauncherOptions { get; } LauncherOptions LauncherOptions { get; }
GameOptions GameOptions { get; }
Task SetForceReinstallAsync(bool value, CancellationToken cancellationToken = default); Task SetForceReinstallAsync(bool value, CancellationToken cancellationToken = default);
Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default); Task UpdateLaunchVersionAsync(string newVersion, CancellationToken cancellationToken = default);
Task SetMinimumRamMbAsync(int minimumRamMb, CancellationToken cancellationToken = default);
Task SetMaximumRamMbAsync(int maximumRamMb, CancellationToken cancellationToken = default);
Task SetResolutionAsync(int screenWidth, int screenHeight, CancellationToken cancellationToken = default);
Task SetFullscreenAsync(bool fullscreen, CancellationToken cancellationToken = default);
Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default);
Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default); Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default);
Task SaveGameOptionsAsync(CancellationToken cancellationToken = default);
Task LoadGameOptionsAsync(CancellationToken cancellationToken = default);
Task LoadAllAsync(CancellationToken cancellationToken = default);
Task SaveAllAsync(CancellationToken cancellationToken = default);
} }
} }

View File

@@ -7,7 +7,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CmlLib.Core" Version="4.0.6" />
<PackageReference Include="CmlLib.Core.Auth.Microsoft" Version="3.3.1" />
<PackageReference Include="CmlLib.Core.Installer.NeoForge" Version="4.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="XboxAuthNet.Game.Msal" Version="0.1.3" />
</ItemGroup> </ItemGroup>
</Project> </Project>

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,7 +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 CmlLib.Core;
using CmlLib.Core.Installers;
using Microsoft.Extensions.Logging;
namespace AlayaCore namespace AlayaCore
{ {
@@ -17,51 +23,64 @@ 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 ISettingsService _settingsService;
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,
LauncherOptions options) IGameInstallService gameInstallService,
ISettingsService settingsService,
IAuthService authService,
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));
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_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,
needAuthenticating: false);
ApplyPlan(launcherUpdatePlan); ApplyPlan(launcherUpdatePlan);
return launcherUpdatePlan; return launcherUpdatePlan;
@@ -73,13 +92,13 @@ namespace AlayaCore
.GetCurrentEnvironmentAsync(cancellationToken) .GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
bool javaNeedsInstallOrUpdate = bool needAuthenticating = !await _authService
_options.ForceReinstall || .IsAuthenticatedAsync(cancellationToken)
!environment.JavaInstalled || .ConfigureAwait(false);
!string.Equals(environment.JavaVersion, manifest.RequiredJavaVersion, StringComparison.OrdinalIgnoreCase);
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);
@@ -94,32 +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);
_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(
CancellationToken cancellationToken = default,
IProgress<LauncherProgress>? progress = 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 = null; _logger.LogInformation("Processing launch state {LaunchState}.", plan.State);
InstallEnvironment? environment = null;
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);
@@ -129,75 +172,163 @@ namespace AlayaCore
.ConfigureAwait(false); .ConfigureAwait(false);
ApplyPlan(plan); ApplyPlan(plan);
return;
_logger.LogInformation("Updater launched successfully. Returning UpdaterLaunched result.");
return InstallOrUpdateResult.UpdaterLaunched;
} }
case LaunchState.InstallJava: case LaunchState.NeedAuthenticating:
{ {
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation("Authentication is required.");
environment = await _installStateService ReportProgress(progress, LaunchState.NeedAuthenticating, "Signing in...");
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false); await _authService
.AuthenticateAsync(cancellationToken)
await _javaService
.EnsureValidJavaInstalledAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation("Authentication completed successfully.");
break; break;
} }
case LaunchState.InstallMinecraft: case LaunchState.InstallMinecraft:
{ {
throw new NotImplementedException("Minecraft install/update flow has not been implemented yet."); _logger.LogInformation("Minecraft installation or repair is required.");
}
case LaunchState.InstallNeoforge: ReportProgress(progress, LaunchState.InstallMinecraft, "Preparing Minecraft installation...");
{
throw new NotImplementedException("NeoForge install/update flow has not been implemented yet.");
}
case LaunchState.SyncMods:
{
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 _modService EventHandler<InstallerProgressChangedEventArgs>? minecraftFileProgress =
.ProcessModsAsync(manifest, environment, cancellationToken) CreateMinecraftFileProgressHandler(progress);
EventHandler<ByteProgress>? minecraftByteProgress =
CreateMinecraftByteProgressHandler(progress);
await _gameInstallService
.EnsureMinecraftInstalledAsync(
manifest,
environment,
cancellationToken,
minecraftFileProgress,
minecraftByteProgress)
.ConfigureAwait(false); .ConfigureAwait(false);
_logger.LogInformation("Minecraft installation or repair step completed.");
break;
}
case LaunchState.InstallNeoforge:
{
_logger.LogInformation("NeoForge installation or repair is required.");
ReportProgress(progress, LaunchState.InstallNeoforge, "Preparing NeoForge installation...");
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
IProgress<InstallerProgressChangedEventArgs>? neoForgeFileProgress =
CreateNeoForgeFileProgress(progress);
IProgress<ByteProgress>? neoForgeByteProgress =
CreateNeoForgeByteProgress(progress);
await _gameInstallService
.EnsureNeoForgeInstalledAsync(
manifest,
environment,
cancellationToken,
neoForgeFileProgress,
neoForgeByteProgress)
.ConfigureAwait(false);
_logger.LogInformation("NeoForge installation or repair step completed.");
break;
}
case LaunchState.SyncMods:
{
_logger.LogInformation("Mod synchronization is required.");
ReportProgress(progress, LaunchState.SyncMods, "Checking mod files...");
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
IProgress<DownloadProgress>? modProgress = CreateModProgress(progress);
await _modService
.ProcessModsAsync(manifest, environment, modProgress, cancellationToken)
.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}'.");
} }
} }
plan = await EvaluateAsync(cancellationToken).ConfigureAwait(false); plan = await EvaluateAsync(cancellationToken).ConfigureAwait(false);
} }
if (_options.ForceReinstall)
{
_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);
if (!plan.CanRun)
{
_logger.LogError(
"Install or update workflow completed, but launcher is still not runnable. Final state: {LaunchState}.",
plan.State);
throw new InvalidOperationException("Install/update completed, but the launcher is still not in a runnable state.");
}
ReportProgress(progress, LaunchState.Ready, "Launcher is ready.");
_logger.LogInformation("Install or update workflow completed successfully. Launcher is ready.");
return InstallOrUpdateResult.Ready;
} }
public async Task LaunchAsync(CancellationToken cancellationToken = default) 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.");
} }
@@ -207,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;
} }
@@ -250,7 +404,7 @@ namespace AlayaCore
} }
var requiredMods = manifest.Files var requiredMods = manifest.Files
.Where(file => file.Type == AlayaCore.Utilities.Enums.FileType.Mod) .Where(file => file.Type == FileType.Mod)
.ToList(); .ToList();
var installedMods = environment.InstalledModsManifest.Mods; var installedMods = environment.InstalledModsManifest.Mods;
@@ -289,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

@@ -0,0 +1,27 @@
using System;
using System.IO;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Utilities.Enums;
using CmlLib.Core;
namespace AlayaCore.Models
{
public class AlayaPath : MinecraftPath
{
public AlayaPath(IFileStore fileStore)
{
BasePath = NormalizePath(fileStore.Get(FolderLocation.Game));
Library = NormalizePath(BasePath + "/libraries");
Versions = NormalizePath(BasePath + "/versions");
Resource = NormalizePath(BasePath + "/resources");
Runtime = NormalizePath(fileStore.GetOrCreate(FolderLocation.JavaRuntime));
Assets = NormalizePath(BasePath + "/assets");
CreateDirs();
}
}
}

View File

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

View File

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

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

@@ -20,19 +20,21 @@ namespace AlayaCore.Models.Manifests.DTO
[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;
[JsonProperty("minecraftUrl", Required = Required.Always)]
[JsonConverter(typeof(UriConverter))]
public Uri MinecraftUrl { get; set; } = null!;
[JsonProperty("neoforgedVersion", Required = Required.Always)] [JsonProperty("neoforgedVersion", Required = Required.Always)]
public string NeoforgedVersion { get; set; } = string.Empty; public string NeoforgedVersion { get; set; } = string.Empty;
[JsonProperty("neoforgedUrl", Required = Required.Always)] [JsonProperty("serverUrl", Required = Required.Always)]
[JsonConverter(typeof(UriConverter))] [JsonConverter(typeof(UriConverter))]
public Uri NeoforgedUrl { get; set; } = null!; public Uri ServerUrl { get; set; } = null!;
[JsonProperty("serverPort", Required = Required.Always)]
public int ServerPort { get; }
[JsonProperty("files", Required = Required.Always)] [JsonProperty("files", Required = Required.Always)]
public List<ModFileEntryDto> Files { get; set; } = new List<ModFileEntryDto>(); public List<ModFileEntryDto> Files { get; set; } = new List<ModFileEntryDto>();

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,33 +9,26 @@ 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 RequiredJavaUrl { get; }
public string MinecraftVersion { get; } public string MinecraftVersion { get; }
public Uri MinecraftUrl { get; }
public string NeoforgedVersion { get; } public string NeoforgedVersion { get; }
public Uri NeoforgedUrl { get; }
public Uri? ServerUrl { 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,
Uri minecraftUrl,
string neoforgedVersion, string neoforgedVersion,
Uri neoforgedUrl, Uri serverUrl,
int serverPort,
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));
RequiredJavaUrl = requiredJavaUrl ?? throw new ArgumentNullException(nameof(requiredJavaUrl));
MinecraftVersion = RequireNonEmpty(minecraftVersion, nameof(minecraftVersion)); MinecraftVersion = RequireNonEmpty(minecraftVersion, nameof(minecraftVersion));
MinecraftUrl = minecraftUrl ?? throw new ArgumentNullException(nameof(minecraftUrl));
NeoforgedVersion = RequireNonEmpty(neoforgedVersion, nameof(neoforgedVersion)); NeoforgedVersion = RequireNonEmpty(neoforgedVersion, nameof(neoforgedVersion));
NeoforgedUrl = neoforgedUrl ?? throw new ArgumentNullException(nameof(neoforgedUrl));
if (files == null) if (files == null)
{ {

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

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

View File

@@ -1,266 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
namespace AlayaCore.Services
{
public sealed class DownloadService : IDownloadService
{
private const int BUFFER_SIZE = 81920;
private readonly IHttpClient _httpClient;
public DownloadService(IHttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public async Task<DownloadResult> DownloadFileAsync(
Uri sourceUri,
string destinationPath,
string sha512Hash,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
if (sourceUri == null)
{
throw new ArgumentNullException(nameof(sourceUri));
}
if (!sourceUri.IsAbsoluteUri)
{
throw new ArgumentException("Source URI must be absolute.", nameof(sourceUri));
}
if (string.IsNullOrWhiteSpace(destinationPath))
{
throw new ArgumentException("Destination path cannot be null, empty, or whitespace.", nameof(destinationPath));
}
if (string.IsNullOrWhiteSpace(sha512Hash))
{
throw new ArgumentException("SHA-512 hash cannot be null, empty, or whitespace.", nameof(sha512Hash));
}
cancellationToken.ThrowIfCancellationRequested();
string normalizedExpectedHash = NormalizeHash(sha512Hash);
string fileName = Path.GetFileName(destinationPath);
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("Destination path must include a file name.", nameof(destinationPath));
}
EnsureDestinationDirectoryExists(destinationPath);
if (File.Exists(destinationPath) && VerifyFileHash(destinationPath, normalizedExpectedHash))
{
long existingLength = new FileInfo(destinationPath).Length;
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
bytesDownloaded: existingLength,
totalBytes: existingLength,
bytesPerSecond: null,
statusMessage: "File already present and valid."));
return new DownloadResult(
destinationPath,
DownloadOutcome.SkippedAlreadyValid,
hashVerified: true,
bytesDownloaded: 0);
}
bool destinationExisted = File.Exists(destinationPath);
string tempFilePath = destinationPath + ".download";
DeleteFileIfExists(tempFilePath);
try
{
using HttpResponseMessage response = await _httpClient.GetAsync(
sourceUri,
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..."));
using Stream responseStream = await response.Content.ReadAsStreamAsync();
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;
}
await fileStream.WriteAsync(
buffer,
0,
bytesRead,
cancellationToken);
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(cancellationToken);
string actualHash = ConvertToLowerHex(sha512.Hash);
if (!string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"Downloaded file hash mismatch. Expected '{normalizedExpectedHash}', got '{actualHash}'.");
}
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."));
return new DownloadResult(
destinationPath,
destinationExisted ? DownloadOutcome.ReplacedInvalid : DownloadOutcome.Downloaded,
hashVerified: true,
bytesDownloaded: bytesDownloaded);
}
catch
{
DeleteFileIfExists(tempFilePath);
throw;
}
}
public bool VerifyFileHash(string filePath, string sha512Hash)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("File path cannot be null, empty, or whitespace.", nameof(filePath));
}
if (string.IsNullOrWhiteSpace(sha512Hash))
{
throw new ArgumentException("SHA-512 hash cannot be null, empty, or whitespace.", nameof(sha512Hash));
}
if (!File.Exists(filePath))
{
return false;
}
string normalizedExpectedHash = NormalizeHash(sha512Hash);
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using SHA512 sha512 = SHA512.Create();
byte[] hashBytes = sha512.ComputeHash(fileStream);
string actualHash = ConvertToLowerHex(hashBytes);
return string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase);
}
private static void EnsureDestinationDirectoryExists(string destinationPath)
{
string? directoryPath = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
}
private static void ReplaceDestinationFile(string sourcePath, string destinationPath)
{
DeleteFileIfExists(destinationPath);
File.Move(sourcePath, destinationPath);
}
private static void DeleteFileIfExists(string path)
{
if (File.Exists(path))
{
File.Delete(path);
}
}
private static string NormalizeHash(string hash)
{
return hash.Trim().Replace("-", string.Empty).ToLowerInvariant();
}
private static string ConvertToLowerHex(byte[]? hashBytes)
{
if (hashBytes == null || hashBytes.Length == 0)
{
throw new InvalidOperationException("Hash computation returned no data.");
}
return BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant();
}
}
}

View File

@@ -0,0 +1,372 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models;
using AlayaCore.Models.Manifests;
using AlayaCore.Utilities.Enums;
using CmlLib.Core;
using CmlLib.Core.Installer.NeoForge;
using CmlLib.Core.Installer.NeoForge.Installers;
using CmlLib.Core.Installers;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services
{
public sealed class GameInstallService : IGameInstallService
{
private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
private readonly IFileStore _fileStore;
private readonly ISettingsService _settingsService;
private readonly ILogger<GameInstallService> _logger;
private AlayaPath? _gamePath;
private MinecraftLauncher? _minecraftLauncher;
public GameInstallService(
IFileStore fileStore,
ISettingsService settingsService,
ILogger<GameInstallService> logger)
{
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task EnsureMinecraftInstalledAsync(
ManifestModel manifest,
InstallEnvironment environment,
CancellationToken cancellationToken = default,
EventHandler<InstallerProgressChangedEventArgs>? minecraftProgress = null,
EventHandler<ByteProgress>? byteProgress = null)
{
if (manifest == null)
{
throw new ArgumentNullException(nameof(manifest));
}
if (environment == null)
{
throw new ArgumentNullException(nameof(environment));
}
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion))
{
_logger.LogError("Minecraft installation could not start because the manifest did not contain a Minecraft version.");
throw new InvalidDataException("Minecraft version is missing.");
}
_logger.LogInformation(
"Ensuring Minecraft is installed. Required version: {RequiredVersion}, Installed: {Installed}, Current version: {CurrentVersion}",
manifest.MinecraftVersion,
environment.MinecraftInstalled,
environment.MinecraftVersion);
bool alreadyInstalled =
environment.MinecraftInstalled &&
string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase);
if (alreadyInstalled)
{
_logger.LogInformation("Minecraft version {MinecraftVersion} is already installed and matches the manifest.", manifest.MinecraftVersion);
return;
}
bool versionMismatch =
environment.MinecraftInstalled &&
!string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase);
if (versionMismatch)
{
_logger.LogWarning(
"Minecraft version mismatch detected. Installed version: {InstalledVersion}, Required version: {RequiredVersion}. Cleaning old install.",
environment.MinecraftVersion,
manifest.MinecraftVersion);
await CleanOldInstallAsync(cancellationToken).ConfigureAwait(false);
}
MinecraftLauncher launcher = GetOrCreateLauncher(minecraftProgress, byteProgress);
_logger.LogInformation("Starting Minecraft installation for version {MinecraftVersion}.", manifest.MinecraftVersion);
await launcher
.InstallAsync(manifest.MinecraftVersion, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Minecraft installation completed for version {MinecraftVersion}.", manifest.MinecraftVersion);
}
public async Task EnsureNeoForgeInstalledAsync(
ManifestModel manifest,
InstallEnvironment environment,
CancellationToken cancellationToken = default,
IProgress<InstallerProgressChangedEventArgs>? progress = null,
IProgress<ByteProgress>? byteProgress = null)
{
if (manifest == null)
{
throw new ArgumentNullException(nameof(manifest));
}
if (environment == null)
{
throw new ArgumentNullException(nameof(environment));
}
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion))
{
_logger.LogError("NeoForge installation could not start because the manifest did not contain a Minecraft version.");
throw new InvalidDataException("Minecraft version is missing.");
}
if (string.IsNullOrWhiteSpace(manifest.NeoforgedVersion))
{
_logger.LogError("NeoForge installation could not start because the manifest did not contain a NeoForge version.");
throw new InvalidDataException("NeoForge version is missing.");
}
_logger.LogInformation(
"Ensuring NeoForge is installed. Required version: {RequiredVersion}, Installed: {Installed}, Current version: {CurrentVersion}",
manifest.NeoforgedVersion,
environment.NeoforgedInstalled,
environment.NeoforgedVersion);
bool alreadyInstalled =
environment.NeoforgedInstalled &&
string.Equals(environment.NeoforgedVersion, manifest.NeoforgedVersion, StringComparison.OrdinalIgnoreCase);
if (alreadyInstalled)
{
_logger.LogInformation("NeoForge version {NeoForgeVersion} is already installed and matches the manifest.", manifest.NeoforgedVersion);
return;
}
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;
}
if (!environment.MinecraftInstalled ||
!string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation(
"Minecraft base installation is missing or mismatched before NeoForge installation. Ensuring Minecraft version {MinecraftVersion} first.",
manifest.MinecraftVersion);
await EnsureMinecraftInstalledAsync(manifest, environment, cancellationToken).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(environment.JavaPath))
{
_logger.LogError("NeoForge installation cannot continue because no valid Java path was found.");
throw new InvalidOperationException("A valid Java installation is required before installing NeoForge.");
}
MinecraftLauncher launcher = GetOrCreateLauncher();
_logger.LogInformation(
"Starting NeoForge installation. Minecraft version: {MinecraftVersion}, NeoForge version: {NeoForgeVersion}, Java path: {JavaPath}",
manifest.MinecraftVersion,
manifest.NeoforgedVersion,
environment.JavaPath);
await InstallNeoForgeAsync(
launcher,
manifest,
environment,
cancellationToken,
progress,
byteProgress).ConfigureAwait(false);
_logger.LogInformation(
"NeoForge installation completed. Verifying Minecraft files for version {MinecraftVersion}.",
manifest.MinecraftVersion);
await launcher
.InstallAsync(manifest.MinecraftVersion, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Minecraft file verification completed after NeoForge installation.");
}
public async Task VerifyFilesAsync(
ManifestModel manifest,
CancellationToken cancellationToken = default)
{
if (manifest == null)
{
throw new ArgumentNullException(nameof(manifest));
}
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion))
{
_logger.LogError("File verification could not start because the manifest did not contain a Minecraft version.");
throw new InvalidDataException("Minecraft version is missing.");
}
MinecraftLauncher launcher = GetOrCreateLauncher();
_logger.LogInformation("Verifying Minecraft files for version {MinecraftVersion}.", manifest.MinecraftVersion);
await launcher
.InstallAsync(manifest.MinecraftVersion, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Minecraft file verification completed for version {MinecraftVersion}.", manifest.MinecraftVersion);
}
private async Task CleanOldInstallAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
string gamePath = GetMinecraftPath();
_logger.LogInformation("Cleaning old game installation at path {GamePath}.", gamePath);
if (Directory.Exists(gamePath))
{
Directory.Delete(gamePath, recursive: true);
_logger.LogInformation("Deleted game directory {GamePath}.", gamePath);
}
else
{
_logger.LogDebug("Game directory {GamePath} did not exist. No game files needed deletion.", gamePath);
}
string installedModsManifestPath = Path.Combine(
_fileStore.Get(FolderLocation.Manifests),
INSTALLED_MODS_MANIFEST_FILE_NAME);
if (File.Exists(installedModsManifestPath))
{
File.Delete(installedModsManifestPath);
_logger.LogInformation("Deleted installed mods manifest at {InstalledModsManifestPath}.", installedModsManifestPath);
}
else
{
_logger.LogDebug("Installed mods manifest {InstalledModsManifestPath} did not exist.", installedModsManifestPath);
}
_gamePath = null;
_minecraftLauncher = null;
_logger.LogDebug("Cleared cached Minecraft launcher state.");
await _settingsService
.UpdateLaunchVersionAsync(string.Empty, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Cleared stored launch version after cleaning the old install.");
}
private MinecraftLauncher GetOrCreateLauncher(
EventHandler<InstallerProgressChangedEventArgs>? minecraftProgress = null,
EventHandler<ByteProgress>? byteProgress = null)
{
if (_minecraftLauncher != null)
{
_logger.LogDebug("Reusing existing MinecraftLauncher instance.");
return _minecraftLauncher;
}
_logger.LogInformation("Creating a new MinecraftLauncher instance.");
_gamePath = new AlayaPath(_fileStore);
_minecraftLauncher = new MinecraftLauncher(_gamePath);
if (byteProgress != null)
{
_minecraftLauncher.ByteProgressChanged += byteProgress;
_logger.LogDebug("Attached Minecraft byte progress handler.");
}
if (minecraftProgress != null)
{
_minecraftLauncher.FileProgressChanged += minecraftProgress;
_logger.LogDebug("Attached Minecraft file progress handler.");
}
return _minecraftLauncher;
}
private async Task InstallNeoForgeAsync(
MinecraftLauncher launcher,
ManifestModel manifest,
InstallEnvironment environment,
CancellationToken cancellationToken,
IProgress<InstallerProgressChangedEventArgs>? progress = null,
IProgress<ByteProgress>? byteProgress = null)
{
if (launcher == null)
{
throw new ArgumentNullException(nameof(launcher));
}
_logger.LogDebug(
"Configuring NeoForge installer. Minecraft version: {MinecraftVersion}, NeoForge version: {NeoForgeVersion}",
manifest.MinecraftVersion,
manifest.NeoforgedVersion);
NeoForgeInstaller installer = new NeoForgeInstaller(launcher);
NeoForgeInstallOptions options = new NeoForgeInstallOptions
{
CancellationToken = cancellationToken,
JavaPath = environment.JavaPath,
SkipIfAlreadyInstalled = true
};
if (progress != null)
{
options.FileProgress = progress;
_logger.LogDebug("Attached NeoForge file progress reporter.");
}
if (byteProgress != null)
{
options.ByteProgress = byteProgress;
_logger.LogDebug("Attached NeoForge byte progress reporter.");
}
string version = await installer
.Install(manifest.MinecraftVersion, manifest.NeoforgedVersion, options)
.ConfigureAwait(false);
_logger.LogInformation("NeoForge installer returned launch version {LaunchVersion}.", version);
await _settingsService
.UpdateLaunchVersionAsync(version, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Persisted launch version {LaunchVersion} to settings.", version);
}
private string GetMinecraftPath()
{
string gamePath = _fileStore.GetOrCreate(FolderLocation.Game);
_logger.LogDebug("Resolved Minecraft game path to {GamePath}.", gamePath);
return gamePath;
}
}
}

View File

@@ -0,0 +1,176 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
using CmlLib.Core;
using CmlLib.Core.ProcessBuilder;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services
{
public sealed class GameLaunchService : IGameLaunchService
{
private readonly IAuthService _authService;
private readonly IFileStore _fileStore;
private readonly ILaunchDirector _director;
private readonly GameOptions _gameOptions;
private readonly ILogger<GameLaunchService> _logger;
private MinecraftLauncher? _minecraftLauncher;
public GameLaunchService(
IAuthService authService,
IFileStore fileStore,
ILaunchDirector director,
GameOptions gameOptions,
ILogger<GameLaunchService> logger)
{
_authService = authService ?? throw new ArgumentNullException(nameof(authService));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_director = director ?? throw new ArgumentNullException(nameof(director));
_gameOptions = gameOptions ?? throw new ArgumentNullException(nameof(gameOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task LaunchAsync(
ManifestModel manifest,
InstallEnvironment environment,
CancellationToken cancellationToken = default)
{
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.");
}
if (manifest == null)
{
throw new ArgumentNullException(nameof(manifest));
}
if (environment == null)
{
throw new ArgumentNullException(nameof(environment));
}
if (string.IsNullOrWhiteSpace(_gameOptions.LaunchVersion))
{
_logger.LogError("Game launch could not start because GameOptions.LaunchVersion is not configured.");
throw new InvalidDataException("GameOptions.LaunchVersion is not configured.");
}
if (string.IsNullOrWhiteSpace(environment.JavaPath))
{
_logger.LogError("Game launch could not start because no valid Java path was available.");
throw new InvalidOperationException("A valid Java path is required to launch the game.");
}
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation(
"Starting game launch. LaunchVersion: {LaunchVersion}, JavaPath: {JavaPath}, Resolution: {Width}x{Height}, MinRamMb: {MinRamMb}, MaxRamMb: {MaxRamMb}",
_gameOptions.LaunchVersion,
environment.JavaPath,
_gameOptions.ScreenWidth,
_gameOptions.ScreenHeight,
_gameOptions.MinimumRamMb,
_gameOptions.MaximumRamMb);
MLaunchOption option = await BuildLaunchOptionsAsync(
manifest,
environment,
cancellationToken).ConfigureAwait(false);
MinecraftLauncher launcher = GetOrCreateLauncher();
_logger.LogInformation("Creating Minecraft process for launch version {LaunchVersion}.", _gameOptions.LaunchVersion);
var process = await launcher
.CreateProcessAsync(_gameOptions.LaunchVersion, option)
.ConfigureAwait(false);
_logger.LogInformation(
"Minecraft process was created successfully. ProcessId: {ProcessId}",
process.Id);
var processWrapper = new ProcessWrapper(process);
_logger.LogInformation("Starting Minecraft process.");
processWrapper.StartWithEvents();
_logger.LogInformation("Waiting for Minecraft process to exit.");
await processWrapper.WaitForExitTaskAsync().ConfigureAwait(false);
_logger.LogInformation(
"Minecraft process exited. ProcessId: {ProcessId}, ExitCode: {ExitCode}",
process.Id,
process.ExitCode);
}
private MinecraftLauncher GetOrCreateLauncher()
{
if (_minecraftLauncher != null)
{
_logger.LogDebug("Reusing existing MinecraftLauncher instance for game launch.");
return _minecraftLauncher;
}
_logger.LogInformation("Creating a new MinecraftLauncher instance for game launch.");
_minecraftLauncher = new MinecraftLauncher(new AlayaPath(_fileStore));
return _minecraftLauncher;
}
private async Task<MLaunchOption> BuildLaunchOptionsAsync(
ManifestModel manifest,
InstallEnvironment environment,
CancellationToken cancellationToken)
{
if (manifest == null)
{
throw new ArgumentNullException(nameof(manifest));
}
if (environment == null)
{
throw new ArgumentNullException(nameof(environment));
}
if (manifest.ServerUrl == null)
{
_logger.LogError("Launch options could not be built because Manifest.ServerUrl is not configured.");
throw new InvalidDataException("Manifest ServerUrl is not configured.");
}
_logger.LogDebug(
"Building launch options. ServerHost: {ServerHost}, ServerPort: {ServerPort}, LaunchVersion: {LaunchVersion}",
manifest.ServerUrl.Host,
manifest.ServerPort,
_gameOptions.LaunchVersion);
var session = await _authService
.GetSessionAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug("A valid Minecraft session was acquired for launch.");
return new MLaunchOption
{
Session = session,
JavaPath = environment.JavaPath,
MinimumRamMb = _gameOptions.MinimumRamMb,
MaximumRamMb = _gameOptions.MaximumRamMb,
ScreenWidth = _gameOptions.ScreenWidth,
ScreenHeight = _gameOptions.ScreenHeight,
ServerIp = manifest.ServerUrl.Host,
ServerPort = manifest.ServerPort,
DockName = "AlayaCraft"
};
}
}
}

View File

@@ -0,0 +1,381 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services
{
public sealed class HttpDownloadService : IDownloadService
{
private const int BUFFER_SIZE = 81920;
private readonly IHttpClient _httpClient;
private readonly IRetryPolicy _retryPolicy;
private readonly ILogger<HttpDownloadService> _logger;
public HttpDownloadService(
IHttpClient httpClient,
IRetryPolicy retryPolicy,
ILogger<HttpDownloadService> logger)
{
_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(
Uri sourceUri,
string destinationPath,
string sha512Hash,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
if (sourceUri == null)
{
throw new ArgumentNullException(nameof(sourceUri));
}
if (!sourceUri.IsAbsoluteUri)
{
throw new ArgumentException("Source URI must be absolute.", nameof(sourceUri));
}
if (string.IsNullOrWhiteSpace(destinationPath))
{
throw new ArgumentException("Destination path cannot be null, empty, or whitespace.", nameof(destinationPath));
}
string normalizedExpectedHash = NormalizeHash(sha512Hash);
cancellationToken.ThrowIfCancellationRequested();
string fileName = Path.GetFileName(destinationPath);
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("Destination path must include a file name.", nameof(destinationPath));
}
_logger.LogInformation(
"Starting download workflow for {FileName} from {SourceUri} to {DestinationPath}.",
fileName,
sourceUri,
destinationPath);
EnsureDestinationDirectoryExists(destinationPath);
if (File.Exists(destinationPath) && VerifyFileHash(destinationPath, normalizedExpectedHash))
{
long existingLength = new FileInfo(destinationPath).Length;
_logger.LogInformation(
"Skipped download for {FileName} because the destination file already exists and passed SHA-512 verification.",
fileName);
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
bytesDownloaded: 0,
totalBytes: existingLength,
bytesPerSecond: null,
statusMessage: "File already present and valid."));
return new DownloadResult(
destinationPath,
DownloadOutcome.SkippedAlreadyValid,
hashVerified: true,
bytesDownloaded: 0);
}
bool destinationExisted = File.Exists(destinationPath);
string tempFilePath = destinationPath + ".download";
if (destinationExisted)
{
_logger.LogInformation(
"Destination file for {FileName} already existed but was not valid. A replacement download will be attempted.",
fileName);
}
DeleteFileIfExists(tempFilePath);
try
{
DownloadResult result = await _retryPolicy.ExecuteAsync(
async token =>
{
DeleteFileIfExists(tempFilePath);
using HttpResponseMessage response = await _httpClient.GetAsync(
sourceUri,
HttpCompletionOption.ResponseHeadersRead,
token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
long? totalBytes = response.Content.Headers.ContentLength;
long bytesDownloaded = 0;
_logger.LogInformation(
"Download response received for {FileName}. Content-Length: {TotalBytes}.",
fileName,
totalBytes);
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
bytesDownloaded: 0,
totalBytes: totalBytes,
bytesPerSecond: null,
statusMessage: "Starting download..."));
await using Stream responseStream = await response.Content
.ReadAsStreamAsync()
.ConfigureAwait(false);
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();
_logger.LogDebug(
"Streaming download content for {FileName} into temporary file {TempFilePath}.",
fileName,
tempFilePath);
while (true)
{
int bytesRead = await responseStream.ReadAsync(
buffer,
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 (OperationCanceledException)
{
_logger.LogWarning(
"Download for {FileName} was cancelled. Cleaning up temporary file {TempFilePath}.",
fileName,
tempFilePath);
DeleteFileIfExists(tempFilePath);
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Download failed for {FileName}. Cleaning up temporary file {TempFilePath}.",
fileName,
tempFilePath);
DeleteFileIfExists(tempFilePath);
throw;
}
}
public bool VerifyFileHash(string filePath, string sha512Hash)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("File path cannot be null, empty, or whitespace.", nameof(filePath));
}
string normalizedExpectedHash = NormalizeHash(sha512Hash);
if (!File.Exists(filePath))
{
_logger.LogDebug("Hash verification skipped because file does not exist at {FilePath}.", filePath);
return false;
}
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using SHA512 sha512 = SHA512.Create();
byte[] hashBytes = sha512.ComputeHash(fileStream);
string actualHash = ConvertToLowerHex(hashBytes);
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)
{
string? directoryPath = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
}
private static void ReplaceDestinationFile(string sourcePath, string destinationPath)
{
DeleteFileIfExists(destinationPath);
File.Move(sourcePath, destinationPath);
}
private static void DeleteFileIfExists(string path)
{
if (File.Exists(path))
{
File.Delete(path);
}
}
private static string NormalizeHash(string hash)
{
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)
{
if (hashBytes == null || hashBytes.Length == 0)
{
throw new InvalidOperationException("Hash computation returned no data.");
}
return BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant();
}
}
}

View File

@@ -1,31 +1,45 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation; using AlayaCore.Installation;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.Utilities.Enums;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
namespace AlayaCore.Services namespace AlayaCore.Services
{ {
public sealed class InstallationStateService : IInstallStateService public sealed class InstallationStateService : IInstallStateService
{ {
private const string JAVA_RUNTIME_FOLDER_NAME = "java-runtime-epsilon"; private const string VERSIONS_FOLDER_NAME = "versions";
private readonly IFileStore _fileStore;
private readonly IManifestService _manifestService; private readonly IManifestService _manifestService;
private readonly ILogger<InstallationStateService> _logger;
public InstallationStateService(IManifestService manifestService) public InstallationStateService(
IFileStore fileStore,
IManifestService manifestService,
ILogger<InstallationStateService> logger)
{ {
_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);
@@ -33,30 +47,42 @@ 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.");
}
bool minecraftInstalled = IsMinecraftInstalled(); InstalledVersionState versionState = GetInstalledVersionState();
string? minecraftVersion = null;
bool neoforgeInstalled = IsNeoforgeInstalled();
string? neoforgeVersion = null;
InstalledModsManifestModel installedModsManifest = InstalledModsManifestModel installedModsManifest =
await _manifestService.GetInstalledModsManifestAsync(cancellationToken).ConfigureAwait(false) await _manifestService.GetInstalledModsManifestAsync(cancellationToken).ConfigureAwait(false);
?? new InstalledModsManifestModel();
return new InstallEnvironment( InstallEnvironment environment = new InstallEnvironment(
osPlatform: platform, osPlatform: platform,
javaInstalled: javaInstalled, javaInstalled: javaInstalled,
javaPath: javaPath, javaPath: javaPath,
javaVersion: javaVersion, javaVersion: javaVersion,
minecraftInstalled: minecraftInstalled, minecraftInstalled: !string.IsNullOrWhiteSpace(versionState.MinecraftVersion),
minecraftVersion: minecraftVersion, minecraftVersion: versionState.MinecraftVersion,
neoforgedInstalled: neoforgeInstalled, neoforgedInstalled: !string.IsNullOrWhiteSpace(versionState.NeoForgeVersion),
neoforgedVersion: 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()
@@ -79,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))
{ {
@@ -91,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
@@ -111,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)
@@ -131,37 +175,188 @@ namespace AlayaCore.Services
: null; : null;
} }
private static bool TryGetJavaPath(out string? javaPath) private bool TryGetJavaPath(out string? javaPath)
{ {
string executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) string executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "java.exe" ? "javaw.exe"
: "java"; : "java";
string fullPath = Path.Combine( string runtimePath = _fileStore.Get(FolderLocation.JavaRuntime);
AppContext.BaseDirectory, string fullPath = Path.Combine(runtimePath, "bin", executableName);
"Java",
JAVA_RUNTIME_FOLDER_NAME,
"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;
} }
private static bool IsMinecraftInstalled() private InstalledVersionState GetInstalledVersionState()
{ {
return true; string versionsPath = GetVersionsPath();
_logger.LogDebug("Inspecting installed version metadata under {VersionsPath}.", versionsPath);
if (!Directory.Exists(versionsPath))
{
_logger.LogInformation("Versions directory does not exist at {VersionsPath}. No Minecraft or NeoForge installation was detected.", versionsPath);
return InstalledVersionState.Empty();
}
string[] versionDirectories = Directory
.GetDirectories(versionsPath)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (versionDirectories.Length == 0)
{
_logger.LogInformation("Versions directory at {VersionsPath} was empty.", versionsPath);
return InstalledVersionState.Empty();
}
string? minecraftVersion = null;
string? neoForgeVersion = null;
foreach (string versionDirectory in versionDirectories)
{
string? versionFolderName = Path.GetFileName(versionDirectory);
if (string.IsNullOrWhiteSpace(versionFolderName))
{
_logger.LogDebug("Skipping version directory with an invalid folder name: {VersionDirectory}.", versionDirectory);
continue;
}
string versionJsonPath = Path.Combine(versionDirectory, $"{versionFolderName}.json");
if (!File.Exists(versionJsonPath))
{
_logger.LogDebug("Skipping version directory {VersionDirectory} because version metadata file {VersionJsonPath} was not found.", versionDirectory, versionJsonPath);
continue;
}
if (!TryLoadJson(versionJsonPath, out JObject? versionJson))
{
_logger.LogWarning("Skipping version metadata file {VersionJsonPath} because it could not be read or parsed.", versionJsonPath);
continue;
}
string? id = versionJson.Value<string>("id");
string? inheritsFrom = versionJson.Value<string>("inheritsFrom");
if (string.IsNullOrWhiteSpace(id))
{
_logger.LogDebug("Skipping version metadata file {VersionJsonPath} because it did not contain a valid id.", versionJsonPath);
continue;
}
if (IsNeoForgeVersion(id, inheritsFrom))
{
neoForgeVersion ??= id;
if (string.IsNullOrWhiteSpace(minecraftVersion) && !string.IsNullOrWhiteSpace(inheritsFrom))
{
minecraftVersion = inheritsFrom;
}
_logger.LogDebug(
"Detected NeoForge version metadata. Id: {NeoForgeVersion}, InheritsFrom: {MinecraftVersion}",
id,
inheritsFrom);
continue;
}
minecraftVersion ??= id;
_logger.LogDebug("Detected Minecraft version metadata. Id: {MinecraftVersion}", id);
}
_logger.LogInformation(
"Installed version state resolved. MinecraftVersion: {MinecraftVersion}, NeoForgeVersion: {NeoForgeVersion}",
minecraftVersion,
neoForgeVersion);
return new InstalledVersionState(minecraftVersion, neoForgeVersion);
} }
private static bool IsNeoforgeInstalled() private string GetVersionsPath()
{ {
return true; 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)
{
return ContainsNeoForgeMarker(id) || ContainsNeoForgeMarker(inheritsFrom);
}
private static bool ContainsNeoForgeMarker(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
return value.Contains("neoforge", StringComparison.OrdinalIgnoreCase) ||
value.Contains("neoforged", StringComparison.OrdinalIgnoreCase);
}
private bool TryLoadJson(string path, out JObject? jsonObject)
{
jsonObject = null;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return false;
}
try
{
string json = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogWarning("JSON file at {Path} was empty.", path);
return false;
}
jsonObject = JObject.Parse(json);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load or parse JSON file at {Path}.", path);
return false;
}
}
private sealed class InstalledVersionState
{
public string? MinecraftVersion { get; }
public string? NeoForgeVersion { get; }
public InstalledVersionState(string? minecraftVersion, string? neoForgeVersion)
{
MinecraftVersion = Normalize(minecraftVersion);
NeoForgeVersion = Normalize(neoForgeVersion);
}
public static InstalledVersionState Empty()
{
return new InstalledVersionState(null, null);
}
private static string? Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
} }
} }
} }

View File

@@ -1,187 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models.Manifests;
namespace AlayaCore.Services
{
public sealed class JavaService : IJavaService
{
private const string DOWNLOAD_FILE_NAME = "java-runtime.download";
private const string JAVA_INSTALL_FOLDER_NAME = "Java";
private const string JAVA_ARCHIVE_HASH_PLACEHOLDER = "REPLACE_WITH_MANIFEST_HASH_SUPPORT";
private readonly IDownloadService _downloadService;
public JavaService(IDownloadService downloadService)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
}
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();
if (environment.JavaInstalled &&
string.Equals(environment.JavaVersion, manifest.RequiredJavaVersion, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (environment.JavaInstalled && !string.IsNullOrWhiteSpace(environment.JavaPath))
{
string javaRootFolder = ResolveJavaRootFolder(environment.JavaPath);
await RemoveJavaAsync(javaRootFolder, cancellationToken).ConfigureAwait(false);
}
string downloadPath = GetJavaDownloadPath();
string installDirectory = GetJavaInstallDirectory();
Directory.CreateDirectory(Path.GetDirectoryName(downloadPath) ?? AppContext.BaseDirectory);
Directory.CreateDirectory(installDirectory);
await DownloadJavaAsync(
manifest.RequiredJavaUrl,
downloadPath,
cancellationToken).ConfigureAwait(false);
await InstallJavaAsync(
downloadPath,
installDirectory,
cancellationToken).ConfigureAwait(false);
}
private static string GetJavaInstallDirectory()
{
return Path.Combine(AppContext.BaseDirectory, JAVA_INSTALL_FOLDER_NAME);
}
private static string GetJavaDownloadPath()
{
return Path.Combine(AppContext.BaseDirectory, "Temp", DOWNLOAD_FILE_NAME);
}
private static string ResolveJavaRootFolder(string javaExecutablePath)
{
if (string.IsNullOrWhiteSpace(javaExecutablePath))
{
throw new ArgumentException("Java executable path cannot be null, empty, or whitespace.", nameof(javaExecutablePath));
}
string? binFolder = Path.GetDirectoryName(javaExecutablePath);
if (string.IsNullOrWhiteSpace(binFolder))
{
throw new InvalidOperationException("Could not resolve the Java bin directory.");
}
string? javaRootFolder = Path.GetDirectoryName(binFolder);
if (string.IsNullOrWhiteSpace(javaRootFolder))
{
throw new InvalidOperationException("Could not resolve the Java installation directory.");
}
return javaRootFolder;
}
private static Task RemoveJavaAsync(
string oldJavaFolder,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(oldJavaFolder))
{
throw new ArgumentException("Old Java folder cannot be null, empty, or whitespace.", nameof(oldJavaFolder));
}
cancellationToken.ThrowIfCancellationRequested();
if (Directory.Exists(oldJavaFolder))
{
Directory.Delete(oldJavaFolder, 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();
// Replace this when your manifest includes a Java archive/runtime hash.
await _downloadService.DownloadFileAsync(
javaUri,
destinationPath,
JAVA_ARCHIVE_HASH_PLACEHOLDER,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static Task InstallJavaAsync(
string installerPath,
string installDirectory,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(installerPath))
{
throw new ArgumentException("Installer path cannot be null, empty, or whitespace.", nameof(installerPath));
}
if (string.IsNullOrWhiteSpace(installDirectory))
{
throw new ArgumentException("Install directory cannot be null, empty, or whitespace.", nameof(installDirectory));
}
cancellationToken.ThrowIfCancellationRequested();
if (!File.Exists(installerPath))
{
throw new FileNotFoundException("Java installer or archive was not found.", installerPath);
}
Directory.CreateDirectory(installDirectory);
// TODO:
// Implement this based on the actual Java package format.
//
// Examples:
// - If the file is a .zip, extract it into installDirectory
// - If it is a .tar.gz, unpack it appropriately
// - If it is an installer executable, launch it silently
//
// For now this is intentionally left explicit rather than guessing the wrong install strategy.
throw new NotImplementedException("InstallJavaAsync must be implemented for the actual Java package format.");
}
}
}

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

@@ -3,54 +3,76 @@ using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
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.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";
private readonly IDownloadService _downloadService; private readonly IDownloadService _downloadService;
private readonly IHttpClient _httpClient; private readonly IHttpClient _httpClient;
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,
ManifestServiceOptions options) IFileStore fileStore,
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));
_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();
} }
@@ -58,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,
@@ -82,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(_options.ManifestDirectoryPath, 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(_options.ManifestDirectoryPath, 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(_options.ManifestDirectoryPath, 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>(
@@ -199,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;
} }
@@ -206,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>(
@@ -253,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);
} }
@@ -268,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,
@@ -289,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.");
} }
@@ -314,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);
@@ -326,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

@@ -5,15 +5,19 @@ using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
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;
@@ -21,28 +25,35 @@ namespace AlayaCore.Services
{ {
public sealed class ModService : IModService public sealed class ModService : IModService
{ {
private const string InstalledModsManifestFileName = "InstalledModsManifest.json"; private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
private readonly IDownloadService _downloadService; private readonly IDownloadService _downloadService;
private readonly ModrinthConnectionOptions _options; private readonly ModrinthConnectionOptions _options;
private readonly ManifestServiceOptions _manifestOptions;
private readonly IHttpClient _httpClient; private readonly IHttpClient _httpClient;
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,
ManifestServiceOptions manifestOptions, IHttpClient httpClient,
IHttpClient httpClient) 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));
_manifestOptions = manifestOptions ?? throw new ArgumentNullException(nameof(manifestOptions));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_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)
@@ -61,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>();
@@ -71,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));
@@ -84,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,
@@ -104,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(
@@ -144,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)
@@ -238,7 +363,7 @@ namespace AlayaCore.Services
return $"{baseUrl}/version_file/{sha512Hash}"; return $"{baseUrl}/version_file/{sha512Hash}";
} }
private static string GetModDestinationPath(ModFileEntry fileEntry) private string GetModDestinationPath(ModFileEntry fileEntry)
{ {
if (fileEntry == null) if (fileEntry == null)
{ {
@@ -251,26 +376,37 @@ namespace AlayaCore.Services
} }
string modsDirectory = GetModsDirectoryPath(); string modsDirectory = GetModsDirectoryPath();
Directory.CreateDirectory(modsDirectory); 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 static string GetModsDirectoryPath() private string GetModsDirectoryPath()
{ {
return Path.Combine(AppContext.BaseDirectory, "Game", "mods"); string modsDirectory = _fileStore.GetOrCreate(FolderLocation.Mods);
_logger.LogDebug("Resolved mods directory to {ModsDirectory}.", modsDirectory);
return modsDirectory;
} }
private static 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;
} }
@@ -281,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);
} }
} }
@@ -302,24 +445,34 @@ namespace AlayaCore.Services
List<ModFileEntry> entries = installedMods.ToList(); List<ModFileEntry> entries = installedMods.ToList();
InstalledModsManifestModel manifest = new InstalledModsManifestModel(entries); InstalledModsManifestModel manifest = new InstalledModsManifestModel(entries);
string manifestsDirectory = _manifestOptions.ManifestDirectoryPath; string manifestsDirectory = _fileStore.GetOrCreate(FolderLocation.Manifests);
Directory.CreateDirectory(manifestsDirectory);
string manifestPath = Path.Combine(manifestsDirectory, InstalledModsManifestFileName); string manifestPath = Path.Combine(manifestsDirectory, INSTALLED_MODS_MANIFEST_FILE_NAME);
string temporaryManifestPath = manifestPath + ".tmp"; string temporaryManifestPath = manifestPath + ".tmp";
InstalledModsManifestDto dto = manifest.ToDto(); InstalledModsManifestDto dto = manifest.ToDto();
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

@@ -2,55 +2,353 @@ using System;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Abstractions.Configuration;
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 Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace AlayaCore.Services namespace AlayaCore.Services
{ {
public sealed class SettingsService : ISettingsService public sealed class SettingsService : ISettingsService
{ {
private const string LauncherSettingsFileName = "Launcher.json"; private readonly IFileStore _fileStore;
private readonly ILogger<SettingsService> _logger;
public LauncherOptions LauncherOptions { get; } public LauncherOptions LauncherOptions { get; }
public GameOptions GameOptions { get; }
public SettingsService(LauncherOptions launcherOptions) public SettingsService(
LauncherOptions launcherOptions,
GameOptions gameOptions,
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));
_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)
{
string? normalizedVersion = string.IsNullOrWhiteSpace(newVersion)
? null
: newVersion.Trim();
_logger.LogInformation(
"Updating game option LaunchVersion from {CurrentLaunchVersion} to {NewLaunchVersion}.",
GameOptions.LaunchVersion,
normalizedVersion);
GameOptions.LaunchVersion = normalizedVersion;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option LaunchVersion was updated and saved successfully.");
}
public async Task SetMinimumRamMbAsync(int minimumRamMb, CancellationToken cancellationToken = default)
{
if (minimumRamMb <= 0)
{
throw new ArgumentOutOfRangeException(nameof(minimumRamMb), "Minimum RAM must be greater than zero.");
}
if (GameOptions.MaximumRamMb > 0 && minimumRamMb > GameOptions.MaximumRamMb)
{
throw new ArgumentException("Minimum RAM cannot be greater than maximum RAM.", nameof(minimumRamMb));
}
_logger.LogInformation(
"Updating game option MinimumRamMb from {CurrentMinimumRamMb} to {NewMinimumRamMb}.",
GameOptions.MinimumRamMb,
minimumRamMb);
GameOptions.MinimumRamMb = minimumRamMb;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option MinimumRamMb was updated and saved successfully.");
}
public async Task SetMaximumRamMbAsync(int maximumRamMb, CancellationToken cancellationToken = default)
{
if (maximumRamMb <= 0)
{
throw new ArgumentOutOfRangeException(nameof(maximumRamMb), "Maximum RAM must be greater than zero.");
}
if (GameOptions.MinimumRamMb > 0 && maximumRamMb < GameOptions.MinimumRamMb)
{
throw new ArgumentException("Maximum RAM cannot be less than minimum RAM.", nameof(maximumRamMb));
}
_logger.LogInformation(
"Updating game option MaximumRamMb from {CurrentMaximumRamMb} to {NewMaximumRamMb}.",
GameOptions.MaximumRamMb,
maximumRamMb);
GameOptions.MaximumRamMb = maximumRamMb;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option MaximumRamMb was updated and saved successfully.");
}
public async Task SetResolutionAsync(int screenWidth, int screenHeight, CancellationToken cancellationToken = default)
{
if (screenWidth <= 0)
{
throw new ArgumentOutOfRangeException(nameof(screenWidth), "Screen width must be greater than zero.");
}
if (screenHeight <= 0)
{
throw new ArgumentOutOfRangeException(nameof(screenHeight), "Screen height must be greater than zero.");
}
_logger.LogInformation(
"Updating game resolution from {CurrentWidth}x{CurrentHeight} to {NewWidth}x{NewHeight}.",
GameOptions.ScreenWidth,
GameOptions.ScreenHeight,
screenWidth,
screenHeight);
GameOptions.ScreenWidth = screenWidth;
GameOptions.ScreenHeight = screenHeight;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game resolution was updated and saved successfully.");
}
public async Task SetFullscreenAsync(bool fullscreen, CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Updating game option Fullscreen from {CurrentFullscreen} to {NewFullscreen}.",
GameOptions.Fullscreen,
fullscreen);
GameOptions.Fullscreen = fullscreen;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option Fullscreen was updated and saved successfully.");
} }
public async Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default) public async Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default)
{ {
_logger.LogDebug("Saving launcher options to disk.");
await SaveAsync( await SaveAsync(
LauncherSettingsFileName, 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>(
LauncherSettingsFileName, 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)
{
ValidateGameOptions(GameOptions);
_logger.LogDebug(
"Saving game options to disk. LaunchVersion: {LaunchVersion}, MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, Resolution: {ScreenWidth}x{ScreenHeight}, Fullscreen: {Fullscreen}",
GameOptions.LaunchVersion,
GameOptions.MinimumRamMb,
GameOptions.MaximumRamMb,
GameOptions.ScreenWidth,
GameOptions.ScreenHeight,
GameOptions.Fullscreen);
await SaveAsync(
GameOptions.FileName,
GameOptions,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game options were saved successfully.");
}
public async Task LoadGameOptionsAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Loading game options from disk.");
GameOptions? loadedOptions = await LoadAsync<GameOptions>(
GameOptions.FileName,
cancellationToken).ConfigureAwait(false);
if (loadedOptions == null)
{
_logger.LogInformation("No game options file was found or it was empty. Applying default game options.");
ApplyGameOptions(GameOptions.Default);
return;
}
ApplyGameOptions(loadedOptions);
_logger.LogInformation(
"Game options were loaded successfully. LaunchVersion: {LaunchVersion}, MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, Resolution: {ScreenWidth}x{ScreenHeight}, Fullscreen: {Fullscreen}",
GameOptions.LaunchVersion,
GameOptions.MinimumRamMb,
GameOptions.MaximumRamMb,
GameOptions.ScreenWidth,
GameOptions.ScreenHeight,
GameOptions.Fullscreen);
}
public async Task LoadAllAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Loading all settings from disk.");
await LoadLauncherOptionsAsync(cancellationToken).ConfigureAwait(false);
await LoadGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("All settings were loaded successfully.");
}
public async Task SaveAllAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Saving all settings to disk.");
await SaveLauncherOptionsAsync(cancellationToken).ConfigureAwait(false);
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("All settings were saved successfully.");
}
private void ApplyGameOptions(GameOptions source)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}
_logger.LogDebug(
"Applying game options from source. LaunchVersion: {LaunchVersion}, MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, Resolution: {ScreenWidth}x{ScreenHeight}, Fullscreen: {Fullscreen}",
source.LaunchVersion,
source.MinimumRamMb,
source.MaximumRamMb,
source.ScreenWidth,
source.ScreenHeight,
source.Fullscreen);
GameOptions.LaunchVersion = string.IsNullOrWhiteSpace(source.LaunchVersion)
? null
: source.LaunchVersion.Trim();
GameOptions.MinimumRamMb = source.MinimumRamMb > 0
? source.MinimumRamMb
: GameOptions.Default.MinimumRamMb;
GameOptions.MaximumRamMb = source.MaximumRamMb > 0
? source.MaximumRamMb
: GameOptions.Default.MaximumRamMb;
GameOptions.ScreenWidth = source.ScreenWidth > 0
? source.ScreenWidth
: GameOptions.Default.ScreenWidth;
GameOptions.ScreenHeight = source.ScreenHeight > 0
? source.ScreenHeight
: GameOptions.Default.ScreenHeight;
GameOptions.Fullscreen = source.Fullscreen;
ValidateGameOptions(GameOptions);
_logger.LogDebug("Game options were applied successfully.");
}
private void ValidateGameOptions(GameOptions options)
{
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
_logger.LogDebug(
"Validating game options. MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, ScreenWidth: {ScreenWidth}, ScreenHeight: {ScreenHeight}",
options.MinimumRamMb,
options.MaximumRamMb,
options.ScreenWidth,
options.ScreenHeight);
if (options.MinimumRamMb <= 0)
{
_logger.LogError("Game options validation failed because MinimumRamMb was {MinimumRamMb}.", options.MinimumRamMb);
throw new InvalidDataException("Minimum RAM must be greater than zero.");
}
if (options.MaximumRamMb <= 0)
{
_logger.LogError("Game options validation failed because MaximumRamMb was {MaximumRamMb}.", options.MaximumRamMb);
throw new InvalidDataException("Maximum RAM must be greater than zero.");
}
if (options.MinimumRamMb > options.MaximumRamMb)
{
_logger.LogError(
"Game options validation failed because MinimumRamMb {MinimumRamMb} was greater than MaximumRamMb {MaximumRamMb}.",
options.MinimumRamMb,
options.MaximumRamMb);
throw new InvalidDataException("Minimum RAM cannot be greater than maximum RAM.");
}
if (options.ScreenWidth <= 0)
{
_logger.LogError("Game options validation failed because ScreenWidth was {ScreenWidth}.", options.ScreenWidth);
throw new InvalidDataException("Screen width must be greater than zero.");
}
if (options.ScreenHeight <= 0)
{
_logger.LogError("Game options validation failed because ScreenHeight was {ScreenHeight}.", options.ScreenHeight);
throw new InvalidDataException("Screen height must be greater than zero.");
}
_logger.LogDebug("Game options validation completed successfully.");
} }
private async Task SaveAsync<T>( private async Task SaveAsync<T>(
string fileName, string fileName,
T value, T value,
CancellationToken cancellationToken) CancellationToken cancellationToken) where T : BaseConfig
{ {
if (string.IsNullOrWhiteSpace(fileName)) if (string.IsNullOrWhiteSpace(fileName))
{ {
@@ -69,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.");
} }
@@ -77,19 +376,28 @@ 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>(
string fileName, string fileName,
CancellationToken cancellationToken) CancellationToken cancellationToken) where T : BaseConfig
{ {
if (string.IsNullOrWhiteSpace(fileName)) if (string.IsNullOrWhiteSpace(fileName))
{ {
@@ -102,31 +410,49 @@ 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);
} }
} }
private static string GetFullPath(string fileName) private string GetFullPath(string fileName)
{ {
return Path.Combine(AppContext.BaseDirectory, "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,9 +2,10 @@ namespace AlayaCore.States
{ {
public enum LaunchState public enum LaunchState
{ {
Checking,
Ready, Ready,
LauncherNeedsUpdate, LauncherNeedsUpdate,
InstallJava, NeedAuthenticating,
InstallMinecraft, InstallMinecraft,
InstallNeoforge, InstallNeoforge,
SyncMods SyncMods
@@ -13,41 +14,45 @@ 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; }
public bool NeedAuthenticating { get; }
public LaunchState State => ComputeState(); public LaunchState State => ComputeState();
public bool CanRun => public bool CanRun => State == LaunchState.Ready;
State == LaunchState.Ready;
public bool NeedsUpdating => public bool NeedsUpdating =>
State != LaunchState.Ready; LauncherNeedsUpdate ||
MinecraftNeedsInstallOrUpdate ||
NeoforgeNeedsInstallOrUpdate ||
ModsNeedSync;
public bool NeedsAttention =>
NeedsUpdating || NeedAuthenticating;
public LaunchPlan( public LaunchPlan(
bool launcherNeedsUpdate, bool launcherNeedsUpdate,
bool javaNeedsInstallOrUpdate,
bool minecraftNeedsInstallOrUpdate, bool minecraftNeedsInstallOrUpdate,
bool neoforgeNeedsInstallOrUpdate, bool neoforgeNeedsInstallOrUpdate,
bool modsNeedSync) bool modsNeedSync,
bool needAuthenticating)
{ {
LauncherNeedsUpdate = launcherNeedsUpdate; LauncherNeedsUpdate = launcherNeedsUpdate;
JavaNeedsInstallOrUpdate = javaNeedsInstallOrUpdate;
MinecraftNeedsInstallOrUpdate = minecraftNeedsInstallOrUpdate; MinecraftNeedsInstallOrUpdate = minecraftNeedsInstallOrUpdate;
NeoforgeNeedsInstallOrUpdate = neoforgeNeedsInstallOrUpdate; NeoforgeNeedsInstallOrUpdate = neoforgeNeedsInstallOrUpdate;
ModsNeedSync = modsNeedSync; ModsNeedSync = modsNeedSync;
NeedAuthenticating = needAuthenticating;
} }
private LaunchState ComputeState() private LaunchState ComputeState()
{ {
// Priority order matters a LOT here
if (LauncherNeedsUpdate) if (LauncherNeedsUpdate)
return LaunchState.LauncherNeedsUpdate; return LaunchState.LauncherNeedsUpdate;
if (JavaNeedsInstallOrUpdate) if (NeedAuthenticating)
return LaunchState.InstallJava; return LaunchState.NeedAuthenticating;
if (MinecraftNeedsInstallOrUpdate) if (MinecraftNeedsInstallOrUpdate)
return LaunchState.InstallMinecraft; return LaunchState.InstallMinecraft;
@@ -65,10 +70,10 @@ 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,
needAuthenticating: false);
} }
} }
} }

View File

@@ -1,5 +1,4 @@
using System; using System;
using System.Globalization;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace AlayaCore.Utilities.Converters namespace AlayaCore.Utilities.Converters
@@ -12,9 +11,9 @@ namespace AlayaCore.Utilities.Converters
{ {
writer.WriteNull(); writer.WriteNull();
} }
else if (value is Uri) else if (value is Uri uri)
{ {
writer.WriteValue(((Uri)value).AbsoluteUri); writer.WriteValue(uri.AbsoluteUri);
} }
else else
{ {
@@ -33,8 +32,7 @@ namespace AlayaCore.Utilities.Converters
{ {
try try
{ {
Uri uri = new Uri((string)reader.Value!); return new Uri((string)reader.Value!, UriKind.Absolute);
return uri;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -47,7 +45,7 @@ namespace AlayaCore.Utilities.Converters
public override bool CanConvert(Type objectType) public override bool CanConvert(Type objectType)
{ {
return objectType == typeof(Uri); return typeof(Uri).IsAssignableFrom(objectType);
} }
} }
} }

View File

@@ -0,0 +1,17 @@
namespace AlayaCore.Utilities.Enums
{
public enum FolderLocation
{
BaseDirectory,
Java,
JavaRuntime,
Game,
Mods,
ResourcePacks,
Config,
Downloads,
Manifests,
Plugins,
Data
}
}

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,12 +47,10 @@ namespace AlayaCore.Utilities.Extensions
return new ManifestModel( return new ManifestModel(
dto.AlayaVersion, dto.AlayaVersion,
dto.RequiredJavaVersion,
dto.RequiredJavaUrl,
dto.MinecraftVersion, dto.MinecraftVersion,
dto.MinecraftUrl,
dto.NeoforgedVersion, dto.NeoforgedVersion,
dto.NeoforgedUrl, dto.ServerUrl,
dto.ServerPort,
dto.Files?.Select(file => file.ToModel()) ?? Array.Empty<ModFileEntry>()); dto.Files?.Select(file => file.ToModel()) ?? Array.Empty<ModFileEntry>());
} }
@@ -80,12 +78,8 @@ namespace AlayaCore.Utilities.Extensions
return new ManifestDto return new ManifestDto
{ {
AlayaVersion = model.AlayaVersion, AlayaVersion = model.AlayaVersion,
RequiredJavaVersion = model.RequiredJavaVersion,
RequiredJavaUrl = model.RequiredJavaUrl,
MinecraftVersion = model.MinecraftVersion, MinecraftVersion = model.MinecraftVersion,
MinecraftUrl = model.MinecraftUrl,
NeoforgedVersion = model.NeoforgedVersion, NeoforgedVersion = model.NeoforgedVersion,
NeoforgedUrl = model.NeoforgedUrl,
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

@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.IO;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Utilities.Enums;
namespace AlayaCore.Utilities.Stores
{
public sealed class LocalFileStore : IFileStore
{
private static readonly string BaseDirectoryPath = AppContext.BaseDirectory;
private static readonly string JavaDirectoryPath = Path.Combine(BaseDirectoryPath, "Java");
private static readonly string JavaRuntimeDirectoryPath = Path.Combine(JavaDirectoryPath, "runtime");
private static readonly string GameDirectoryPath = Path.Combine(BaseDirectoryPath, "Game");
private static readonly string ModsDirectoryPath = Path.Combine(GameDirectoryPath, "mods");
private static readonly string ResourcePacksDirectoryPath = Path.Combine(GameDirectoryPath, "resourcepacks");
private static readonly string ConfigDirectoryPath = Path.Combine(BaseDirectoryPath, "Config");
private static readonly string DownloadsDirectoryPath = Path.Combine(BaseDirectoryPath, "Temp");
private static readonly string ManifestsDirectoryPath = Path.Combine(BaseDirectoryPath, "Manifests");
private static readonly string PluginsDirectoryPath = Path.Combine(GameDirectoryPath, "plugins");
private static readonly string DataDirectoryPath = Path.Combine(BaseDirectoryPath, "Data");
private static readonly IReadOnlyDictionary<FolderLocation, string> Folders =
new Dictionary<FolderLocation, string>
{
{ FolderLocation.BaseDirectory, BaseDirectoryPath },
{ FolderLocation.Java, JavaDirectoryPath },
{ FolderLocation.JavaRuntime, JavaRuntimeDirectoryPath },
{ FolderLocation.Game, GameDirectoryPath },
{ FolderLocation.Mods, ModsDirectoryPath },
{ FolderLocation.ResourcePacks, ResourcePacksDirectoryPath },
{ FolderLocation.Config, ConfigDirectoryPath },
{ FolderLocation.Downloads, DownloadsDirectoryPath },
{ FolderLocation.Manifests, ManifestsDirectoryPath},
{ FolderLocation.Plugins, PluginsDirectoryPath },
{ 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)
{
if (!Folders.TryGetValue(location, out string? path))
{
throw new ArgumentOutOfRangeException(nameof(location), location, "Unknown folder location.");
}
return path;
}
public string GetOrCreate(FolderLocation location)
{
string path = Get(location);
Directory.CreateDirectory(path);
return path;
}
public string Combine(FolderLocation location, params string[] paths)
{
if (paths == null)
{
throw new ArgumentNullException(nameof(paths));
}
string rootPath = Get(location);
if (paths.Length == 0)
{
return rootPath;
}
string[] combinedPaths = new string[paths.Length + 1];
combinedPaths[0] = rootPath;
for (int i = 0; i < paths.Length; i++)
{
if (string.IsNullOrWhiteSpace(paths[i]))
{
throw new ArgumentException("Path segments cannot be null, empty, or whitespace.", nameof(paths));
}
combinedPaths[i + 1] = paths[i];
}
return Path.Combine(combinedPaths);
}
public bool Exists(FolderLocation location)
{
return Directory.Exists(Get(location));
}
}
}

View File

@@ -44,9 +44,25 @@
"netstandard2.1": { "netstandard2.1": {
"targetAlias": "netstandard2.1", "targetAlias": "netstandard2.1",
"dependencies": { "dependencies": {
"CmlLib.Core": {
"target": "Package",
"version": "[4.0.6, )"
},
"CmlLib.Core.Auth.Microsoft": {
"target": "Package",
"version": "[3.3.1, )"
},
"CmlLib.Core.Installer.NeoForge": {
"target": "Package",
"version": "[4.0.0, )"
},
"Newtonsoft.Json": { "Newtonsoft.Json": {
"target": "Package", "target": "Package",
"version": "[13.0.4, )" "version": "[13.0.4, )"
},
"XboxAuthNet.Game.Msal": {
"target": "Package",
"version": "[0.1.3, )"
} }
}, },
"imports": [ "imports": [

View File

@@ -1,2 +1,7 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?> <?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" /> <Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Import Project="$(NuGetPackageRoot)system.text.json/8.0.5/buildTransitive/netstandard2.0/System.Text.Json.targets" Condition="Exists('$(NuGetPackageRoot)system.text.json/8.0.5/buildTransitive/netstandard2.0/System.Text.Json.targets')" />
<Import Project="$(NuGetPackageRoot)microsoft.extensions.logging.abstractions/8.0.1/buildTransitive/netstandard2.0/Microsoft.Extensions.Logging.Abstractions.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.logging.abstractions/8.0.1/buildTransitive/netstandard2.0/Microsoft.Extensions.Logging.Abstractions.targets')" />
</ImportGroup>
</Project>

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("AlayaCore")] [assembly: System.Reflection.AssemblyCompanyAttribute("AlayaCore")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+823ccf4b877ddf1fc1293f72c6b704d6a449ddaa")]
[assembly: System.Reflection.AssemblyProductAttribute("AlayaCore")] [assembly: System.Reflection.AssemblyProductAttribute("AlayaCore")]
[assembly: System.Reflection.AssemblyTitleAttribute("AlayaCore")] [assembly: System.Reflection.AssemblyTitleAttribute("AlayaCore")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -1 +1 @@
02d72db7e7301cd87568f68f39a003b2484df3c699c3ceed1d9bd04f25f6e3dd c63b3bf6f0fd998ece1d911c58e61eb584840e2e19d535b62f67f54b1510e49e

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,40 @@
{ {
"version": 2, "version": 2,
"dgSpecHash": "iqHG7z//q/I=", "dgSpecHash": "pXFuC0zr6Zg=",
"success": true, "success": true,
"projectFilePath": "/Users/ryanmacham/Documents/Coding/AlayaCore/AlayaCore/AlayaCore.csproj", "projectFilePath": "/Users/ryanmacham/Documents/Coding/AlayaCore/AlayaCore/AlayaCore.csproj",
"expectedPackageFiles": [ "expectedPackageFiles": [
"/Users/ryanmacham/.nuget/packages/cmllib.core/4.0.6/cmllib.core.4.0.6.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/cmllib.core.auth.microsoft/3.3.1/cmllib.core.auth.microsoft.3.3.1.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/cmllib.core.commons/4.0.0/cmllib.core.commons.4.0.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/cmllib.core.installer.neoforge/4.0.0/cmllib.core.installer.neoforge.4.0.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/htmlagilitypack/1.11.48/htmlagilitypack.1.11.48.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/lzma-sdk/19.0.0/lzma-sdk.19.0.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/microsoft.bcl.asyncinterfaces/8.0.0/microsoft.bcl.asyncinterfaces.8.0.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/8.0.1/microsoft.extensions.dependencyinjection.abstractions.8.0.1.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/microsoft.extensions.logging.abstractions/8.0.1/microsoft.extensions.logging.abstractions.8.0.1.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/microsoft.identity.client/4.61.3/microsoft.identity.client.4.61.3.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/microsoft.identity.client.extensions.msal/4.61.3/microsoft.identity.client.extensions.msal.4.61.3.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/microsoft.identitymodel.abstractions/6.35.0/microsoft.identitymodel.abstractions.6.35.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/newtonsoft.json/13.0.4/newtonsoft.json.13.0.4.nupkg.sha512", "/Users/ryanmacham/.nuget/packages/newtonsoft.json/13.0.4/newtonsoft.json.13.0.4.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/sharpziplib/1.4.2/sharpziplib.1.4.2.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.buffers/4.5.1/system.buffers.4.5.1.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.diagnostics.diagnosticsource/6.0.1/system.diagnostics.diagnosticsource.6.0.1.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.io.filesystem.accesscontrol/5.0.0/system.io.filesystem.accesscontrol.5.0.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.memory/4.5.5/system.memory.4.5.5.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.net.http.json/8.0.0/system.net.http.json.8.0.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.numerics.vectors/4.4.0/system.numerics.vectors.4.4.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.runtime.compilerservices.unsafe/6.0.0/system.runtime.compilerservices.unsafe.6.0.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.security.accesscontrol/5.0.0/system.security.accesscontrol.5.0.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.security.cryptography.protecteddata/4.5.0/system.security.cryptography.protecteddata.4.5.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.security.principal.windows/5.0.0/system.security.principal.windows.5.0.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.text.encodings.web/8.0.0/system.text.encodings.web.8.0.0.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.text.json/8.0.5/system.text.json.8.0.5.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.threading.tasks.dataflow/8.0.1/system.threading.tasks.dataflow.8.0.1.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/system.threading.tasks.extensions/4.5.4/system.threading.tasks.extensions.4.5.4.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/xboxauthnet/3.0.4/xboxauthnet.3.0.4.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/xboxauthnet.game/1.4.1/xboxauthnet.game.1.4.1.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/xboxauthnet.game.msal/0.1.3/xboxauthnet.game.msal.0.1.3.nupkg.sha512",
"/Users/ryanmacham/.nuget/packages/netstandard.library.ref/2.1.0/netstandard.library.ref.2.1.0.nupkg.sha512" "/Users/ryanmacham/.nuget/packages/netstandard.library.ref/2.1.0/netstandard.library.ref.2.1.0.nupkg.sha512"
], ],
"logs": [] "logs": []

View File

@@ -1 +1 @@
"restore":{"projectUniqueName":"/Users/ryanmacham/Documents/Coding/AlayaCore/AlayaCore/AlayaCore.csproj","projectName":"AlayaCore","projectPath":"/Users/ryanmacham/Documents/Coding/AlayaCore/AlayaCore/AlayaCore.csproj","packagesPath":"","outputPath":"/Users/ryanmacham/Documents/Coding/AlayaCore/AlayaCore/obj/","projectStyle":"PackageReference","originalTargetFrameworks":["netstandard2.1"],"sources":{"https://api.nuget.org/v3/index.json":{}},"frameworks":{"netstandard2.1":{"targetAlias":"netstandard2.1","projectReferences":{}}},"warningProperties":{"warnAsError":["NU1605"]},"restoreAuditProperties":{"enableAudit":"true","auditLevel":"low","auditMode":"direct"},"SdkAnalysisLevel":"10.0.200"}"frameworks":{"netstandard2.1":{"targetAlias":"netstandard2.1","dependencies":{"Newtonsoft.Json":{"target":"Package","version":"[13.0.4, )"}},"imports":["net461","net462","net47","net471","net472","net48","net481"],"assetTargetFallback":true,"warn":true,"downloadDependencies":[{"name":"NETStandard.Library.Ref","version":"[2.1.0, 2.1.0]"}],"frameworkReferences":{"NETStandard.Library":{"privateAssets":"all"}},"runtimeIdentifierGraphPath":"/usr/local/share/dotnet/sdk/10.0.201/RuntimeIdentifierGraph.json"}} "restore":{"projectUniqueName":"/Users/ryanmacham/Documents/Coding/AlayaCore/AlayaCore/AlayaCore.csproj","projectName":"AlayaCore","projectPath":"/Users/ryanmacham/Documents/Coding/AlayaCore/AlayaCore/AlayaCore.csproj","packagesPath":"","outputPath":"/Users/ryanmacham/Documents/Coding/AlayaCore/AlayaCore/obj/","projectStyle":"PackageReference","originalTargetFrameworks":["netstandard2.1"],"sources":{"https://api.nuget.org/v3/index.json":{}},"frameworks":{"netstandard2.1":{"targetAlias":"netstandard2.1","projectReferences":{}}},"warningProperties":{"warnAsError":["NU1605"]},"restoreAuditProperties":{"enableAudit":"true","auditLevel":"low","auditMode":"direct"},"SdkAnalysisLevel":"10.0.200"}"frameworks":{"netstandard2.1":{"targetAlias":"netstandard2.1","dependencies":{"CmlLib.Core":{"target":"Package","version":"[4.0.6, )"},"CmlLib.Core.Auth.Microsoft":{"target":"Package","version":"[3.3.1, )"},"CmlLib.Core.Installer.NeoForge":{"target":"Package","version":"[4.0.0, )"},"Newtonsoft.Json":{"target":"Package","version":"[13.0.4, )"},"XboxAuthNet.Game.Msal":{"target":"Package","version":"[0.1.3, )"}},"imports":["net461","net462","net47","net471","net472","net48","net481"],"assetTargetFallback":true,"warn":true,"downloadDependencies":[{"name":"NETStandard.Library.Ref","version":"[2.1.0, 2.1.0]"}],"frameworkReferences":{"NETStandard.Library":{"privateAssets":"all"}},"runtimeIdentifierGraphPath":"/usr/local/share/dotnet/sdk/10.0.201/RuntimeIdentifierGraph.json"}}

View File

@@ -1 +1 @@
17752277078247393 17754069520908862

View File

@@ -1 +1 @@
17752277078247393 17754069520908862