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">
<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_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.Tasks;
using AlayaCore.Models;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
using AlayaCore.States;
namespace AlayaCore.Abstractions.Interfaces
{
public interface ILaunchDirector
{
Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default);
Task InstallOrUpdateAsync(CancellationToken cancellationToken = default);
Task LaunchAsync(CancellationToken cancellationToken = default);
bool CanRun { get; }
bool NeedsUpdating { get; }
bool IsUpdatingLauncher { get; }
LaunchPlan? CurrentPlan { get; }
Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default);
Task<InstallOrUpdateResult> InstallOrUpdateAsync(
CancellationToken cancellationToken = default,
IProgress<LauncherProgress>? progress = null);
Task LaunchAsync(CancellationToken cancellationToken = default);
}
}

View File

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

View File

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

View File

@@ -7,11 +7,23 @@ namespace AlayaCore.Abstractions.Interfaces.Services
public interface ISettingsService
{
LauncherOptions LauncherOptions { get; }
GameOptions GameOptions { get; }
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 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>
<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="XboxAuthNet.Game.Msal" Version="0.1.3" />
</ItemGroup>
</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.Models.Configuration;
using AlayaCore.Models.Manifests;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
using AlayaCore.States;
using AlayaCore.Utilities.Enums;
using CmlLib.Core;
using CmlLib.Core.Installers;
using Microsoft.Extensions.Logging;
namespace AlayaCore
{
@@ -17,51 +23,64 @@ namespace AlayaCore
private readonly IManifestService _manifestService;
private readonly IUpdateService _updateService;
private readonly IInstallStateService _installStateService;
private readonly IJavaService _javaService;
private readonly IModService _modService;
private readonly IGameLaunchService _gameLaunchService;
private readonly IGameInstallService _gameInstallService;
private readonly ISettingsService _settingsService;
private readonly IAuthService _authService;
private readonly LauncherOptions _options;
private readonly ILogger<LaunchDirector> _logger;
public bool CanRun { get; private set; }
public bool NeedsUpdating { get; private set; }
public LaunchPlan? CurrentPlan { get; private set; }
public bool IsUpdatingLauncher { get; private set; }
public LaunchDirector(
IManifestService manifestService,
IUpdateService updateService,
IInstallStateService installStateService,
IJavaService javaService,
IModService modService,
IGameLaunchService gameLaunchService,
LauncherOptions options)
IGameInstallService gameInstallService,
ISettingsService settingsService,
IAuthService authService,
LauncherOptions options,
ILogger<LaunchDirector> logger)
{
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_updateService = updateService ?? throw new ArgumentNullException(nameof(updateService));
_installStateService = installStateService ?? throw new ArgumentNullException(nameof(installStateService));
_javaService = javaService ?? throw new ArgumentNullException(nameof(javaService));
_modService = modService ?? throw new ArgumentNullException(nameof(modService));
_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));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Evaluating launcher state.");
bool launcherNeedsUpdate = await _updateService
.DoesLauncherNeedUpdating(cancellationToken)
.ConfigureAwait(false);
if (launcherNeedsUpdate)
{
_logger.LogInformation("Launcher update is required.");
LaunchPlan launcherUpdatePlan = new LaunchPlan(
launcherNeedsUpdate: true,
javaNeedsInstallOrUpdate: false,
minecraftNeedsInstallOrUpdate: false,
neoforgeNeedsInstallOrUpdate: false,
modsNeedSync: false);
modsNeedSync: false,
needAuthenticating: false);
ApplyPlan(launcherUpdatePlan);
return launcherUpdatePlan;
@@ -73,13 +92,13 @@ namespace AlayaCore
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
bool javaNeedsInstallOrUpdate =
_options.ForceReinstall ||
!environment.JavaInstalled ||
!string.Equals(environment.JavaVersion, manifest.RequiredJavaVersion, StringComparison.OrdinalIgnoreCase);
bool needAuthenticating = !await _authService
.IsAuthenticatedAsync(cancellationToken)
.ConfigureAwait(false);
bool minecraftNeedsInstallOrUpdate =
_options.ForceReinstall ||
!environment.JavaInstalled ||
!environment.MinecraftInstalled ||
!string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase);
@@ -94,32 +113,56 @@ namespace AlayaCore
LaunchPlan plan = new LaunchPlan(
launcherNeedsUpdate: false,
javaNeedsInstallOrUpdate: javaNeedsInstallOrUpdate,
minecraftNeedsInstallOrUpdate: minecraftNeedsInstallOrUpdate,
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);
return plan;
}
public async Task InstallOrUpdateAsync(CancellationToken cancellationToken = default)
public async Task<InstallOrUpdateResult> InstallOrUpdateAsync(
CancellationToken cancellationToken = default,
IProgress<LauncherProgress>? progress = null)
{
cancellationToken.ThrowIfCancellationRequested();
IsUpdatingLauncher = false;
_logger.LogInformation("Starting install or update workflow.");
ReportProgress(progress, LaunchState.Checking, "Checking launcher state...");
LaunchPlan plan = await EvaluateAsync(cancellationToken).ConfigureAwait(false);
while (!plan.CanRun)
{
cancellationToken.ThrowIfCancellationRequested();
ManifestModel? manifest = null;
InstallEnvironment? environment = null;
_logger.LogInformation("Processing launch state {LaunchState}.", plan.State);
ManifestModel manifest;
InstallEnvironment environment;
switch (plan.State)
{
case LaunchState.LauncherNeedsUpdate:
{
IsUpdatingLauncher = true;
_logger.LogWarning("Launcher updater handoff is beginning.");
ReportProgress(progress, LaunchState.LauncherNeedsUpdate, "Launching updater...");
LauncherManifestModel launcherManifest = await _manifestService
.GetLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false);
@@ -129,75 +172,163 @@ namespace AlayaCore
.ConfigureAwait(false);
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
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
await _javaService
.EnsureValidJavaInstalledAsync(manifest, environment, cancellationToken)
ReportProgress(progress, LaunchState.NeedAuthenticating, "Signing in...");
await _authService
.AuthenticateAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Authentication completed successfully.");
break;
}
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:
{
throw new NotImplementedException("NeoForge install/update flow has not been implemented yet.");
}
ReportProgress(progress, LaunchState.InstallMinecraft, "Preparing Minecraft installation...");
case LaunchState.SyncMods:
{
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
await _modService
.ProcessModsAsync(manifest, environment, cancellationToken)
EventHandler<InstallerProgressChangedEventArgs>? minecraftFileProgress =
CreateMinecraftFileProgressHandler(progress);
EventHandler<ByteProgress>? minecraftByteProgress =
CreateMinecraftByteProgressHandler(progress);
await _gameInstallService
.EnsureMinecraftInstalledAsync(
manifest,
environment,
cancellationToken,
minecraftFileProgress,
minecraftByteProgress)
.ConfigureAwait(false);
_logger.LogInformation("Minecraft installation or repair step completed.");
break;
}
case LaunchState.InstallNeoforge:
{
_logger.LogInformation("NeoForge installation or repair is required.");
ReportProgress(progress, LaunchState.InstallNeoforge, "Preparing NeoForge installation...");
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
IProgress<InstallerProgressChangedEventArgs>? neoForgeFileProgress =
CreateNeoForgeFileProgress(progress);
IProgress<ByteProgress>? neoForgeByteProgress =
CreateNeoForgeByteProgress(progress);
await _gameInstallService
.EnsureNeoForgeInstalledAsync(
manifest,
environment,
cancellationToken,
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;
}
case LaunchState.Ready:
{
_logger.LogDebug("Launch state is Ready inside install loop.");
break;
}
default:
{
_logger.LogError("Unsupported launch state encountered: {LaunchState}.", plan.State);
throw new InvalidOperationException($"Unsupported launch state '{plan.State}'.");
}
}
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)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Launch requested.");
if (CurrentPlan == null)
{
_logger.LogDebug("No current launch plan was available. Evaluating launcher state before launch.");
await EvaluateAsync(cancellationToken).ConfigureAwait(false);
}
if (!CanRun)
{
_logger.LogError("Launch was requested while the launcher was not in a runnable state.");
throw new InvalidOperationException("Launcher cannot run because installation or updates are still required.");
}
@@ -207,33 +338,56 @@ namespace AlayaCore
ManifestModel manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Starting game launch. MinecraftVersion: {MinecraftVersion}, NeoForgeVersion: {NeoForgeVersion}",
environment.MinecraftVersion,
environment.NeoforgedVersion);
await _gameLaunchService
.LaunchAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Game launch call completed.");
}
private async Task<ManifestModel> EnsureCurrentManifestAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Ensuring current Alaya manifest is available.");
ManifestModel? localManifest = await _manifestService
.GetLocalCoreManifestAsync(cancellationToken)
.GetLocalAlayaManifestAsync(cancellationToken)
.ConfigureAwait(false);
Version remoteVersion = await _manifestService
.GetRemoteCoreManifestVersionAsync(cancellationToken)
.ConfigureAwait(false);
if (localManifest == null)
{
_logger.LogInformation("No local Alaya manifest was found. Downloading remote manifest.");
}
else if (localManifest.AlayaVersion != remoteVersion)
{
_logger.LogInformation(
"Local Alaya manifest version {LocalVersion} differs from remote version {RemoteVersion}. Downloading updated manifest.",
localManifest.AlayaVersion,
remoteVersion);
}
if (localManifest == null || localManifest.AlayaVersion != remoteVersion)
{
localManifest = await _manifestService
.GetCoreManifestAsync(cancellationToken)
.GetAlayaManifestAsync(cancellationToken)
.ConfigureAwait(false);
}
if (localManifest == null)
{
_logger.LogError("Local Alaya manifest was still unavailable after refresh.");
throw new FileNotFoundException("Local core manifest was not found after refresh.");
}
_logger.LogDebug("Current Alaya manifest is available. Version: {AlayaVersion}", localManifest.AlayaVersion);
return localManifest;
}
@@ -250,7 +404,7 @@ namespace AlayaCore
}
var requiredMods = manifest.Files
.Where(file => file.Type == AlayaCore.Utilities.Enums.FileType.Mod)
.Where(file => file.Type == FileType.Mod)
.ToList();
var installedMods = environment.InstalledModsManifest.Mods;
@@ -289,6 +443,129 @@ namespace AlayaCore
CurrentPlan = plan ?? throw new ArgumentNullException(nameof(plan));
NeedsUpdating = plan.NeedsUpdating;
CanRun = plan.CanRun;
_logger.LogInformation(
"Applied launch plan. State: {LaunchState}, CanRun: {CanRun}, NeedsUpdating: {NeedsUpdating}, IsUpdatingLauncher: {IsUpdatingLauncher}",
plan.State,
CanRun,
NeedsUpdating,
IsUpdatingLauncher);
}
private static void ReportProgress(
IProgress<LauncherProgress>? progress,
LaunchState phase,
string statusMessage,
string? currentItemName = null,
long? bytesCompleted = null,
long? bytesTotal = null,
double? bytesPerSecond = null,
int? tasksCompleted = null,
int? tasksTotal = null)
{
progress?.Report(new LauncherProgress(
phase,
statusMessage,
currentItemName,
bytesCompleted,
bytesTotal,
bytesPerSecond,
tasksCompleted,
tasksTotal));
}
private static EventHandler<InstallerProgressChangedEventArgs>? CreateMinecraftFileProgressHandler(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return (_, args) =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallMinecraft,
statusMessage: args.EventType.ToString(),
currentItemName: args.Name,
tasksCompleted: args.ProgressedTasks,
tasksTotal: args.TotalTasks));
};
}
private static EventHandler<ByteProgress>? CreateMinecraftByteProgressHandler(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return (_, args) =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallMinecraft,
statusMessage: "Downloading Minecraft files...",
bytesCompleted: args.ProgressedBytes,
bytesTotal: args.TotalBytes));
};
}
private static IProgress<InstallerProgressChangedEventArgs>? CreateNeoForgeFileProgress(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return new Progress<InstallerProgressChangedEventArgs>(args =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallNeoforge,
statusMessage: args.EventType.ToString(),
currentItemName: args.Name,
tasksCompleted: args.ProgressedTasks,
tasksTotal: args.TotalTasks));
});
}
private static IProgress<ByteProgress>? CreateNeoForgeByteProgress(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return new Progress<ByteProgress>(args =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.InstallNeoforge,
statusMessage: "Downloading NeoForge files...",
bytesCompleted: args.ProgressedBytes,
bytesTotal: args.TotalBytes));
});
}
private static IProgress<DownloadProgress>? CreateModProgress(
IProgress<LauncherProgress>? progress)
{
if (progress == null)
{
return null;
}
return new Progress<DownloadProgress>(downloadProgress =>
{
progress.Report(new LauncherProgress(
phase: LaunchState.SyncMods,
statusMessage: downloadProgress.StatusMessage ?? "Downloading mod...",
currentItemName: downloadProgress.FileName,
bytesCompleted: downloadProgress.BytesDownloaded,
bytesTotal: downloadProgress.TotalBytes,
bytesPerSecond: downloadProgress.BytesPerSecond));
});
}
}
}

View File

@@ -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
{
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
{
public sealed class LauncherUpdateServiceOptions
@@ -10,5 +13,8 @@ namespace AlayaCore.Models.Configuration
public string AlayaUpdaterPath { 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 Uri CoreManifestUri { get; }
public Uri AlayaManifestUri { get; }
public Uri LauncherManifestUri { get; }
public string CoreManifestSha512Hash { get; }
public string AlayaManifestSha512Hash { get; }
public string LauncherManifestSha512Hash { get; }
public string ManifestDirectoryPath { get; }
public ManifestServiceOptions(
Uri coreManifestUri,
Uri alayaManifestUri,
Uri launcherManifestUri,
string coreManifestSha512Hash,
string launcherManifestSha512Hash,
string manifestDirectoryPath)
string alayaManifestSha512Hash,
string launcherManifestSha512Hash)
{
CoreManifestUri = coreManifestUri ?? throw new ArgumentNullException(nameof(coreManifestUri));
AlayaManifestUri = alayaManifestUri ?? throw new ArgumentNullException(nameof(alayaManifestUri));
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)
@@ -30,9 +28,9 @@ namespace AlayaCore.Models.Configuration
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))
@@ -40,14 +38,12 @@ namespace AlayaCore.Models.Configuration
throw new ArgumentException("Launcher manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(launcherManifestSha512Hash));
}
if (string.IsNullOrWhiteSpace(manifestDirectoryPath))
{
throw new ArgumentException("Manifest directory path cannot be null, empty, or whitespace.", nameof(manifestDirectoryPath));
}
CoreManifestSha512Hash = coreManifestSha512Hash;
AlayaManifestSha512Hash = alayaManifestSha512Hash;
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 static ModrinthConnectionOptions Default { get; } =
new ModrinthConnectionOptions("https://api.modrinth.com/v2/");
}
}

View File

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

View File

@@ -19,20 +19,22 @@ namespace AlayaCore.Models.Manifests.DTO
[JsonProperty("javaUrl", Required = Required.Always)]
[JsonConverter(typeof(UriConverter))]
public Uri RequiredJavaUrl { get; set; } = null!;
[JsonProperty("javaArchiveHash", Required = Required.Always)]
public string JavaArchiveHash { get; set; } = string.Empty;
[JsonProperty("minecraftVersion", Required = Required.Always)]
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)]
public string NeoforgedVersion { get; set; } = string.Empty;
[JsonProperty("neoforgedUrl", Required = Required.Always)]
[JsonProperty("serverUrl", Required = Required.Always)]
[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)]
public List<ModFileEntryDto> Files { get; set; } = new List<ModFileEntryDto>();

View File

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

View File

@@ -9,33 +9,26 @@ namespace AlayaCore.Models.Manifests
public sealed class ManifestModel
{
public Version AlayaVersion { get; }
public string RequiredJavaVersion { get; }
public Uri RequiredJavaUrl { get; }
public string MinecraftVersion { get; }
public Uri MinecraftUrl { get; }
public string NeoforgedVersion { get; }
public Uri NeoforgedUrl { get; }
public Uri? ServerUrl { get; }
public int ServerPort { get; }
public IReadOnlyList<ModFileEntry> Files { get; }
public ManifestModel(
Version alayaVersion,
string requiredJavaVersion,
Uri requiredJavaUrl,
string minecraftVersion,
Uri minecraftUrl,
string neoforgedVersion,
Uri neoforgedUrl,
Uri serverUrl,
int serverPort,
IEnumerable<ModFileEntry> files)
{
AlayaVersion = alayaVersion ?? throw new ArgumentNullException(nameof(alayaVersion));
RequiredJavaVersion = RequireNonEmpty(requiredJavaVersion, nameof(requiredJavaVersion));
RequiredJavaUrl = requiredJavaUrl ?? throw new ArgumentNullException(nameof(requiredJavaUrl));
MinecraftVersion = RequireNonEmpty(minecraftVersion, nameof(minecraftVersion));
MinecraftUrl = minecraftUrl ?? throw new ArgumentNullException(nameof(minecraftUrl));
NeoforgedVersion = RequireNonEmpty(neoforgedVersion, nameof(neoforgedVersion));
NeoforgedUrl = neoforgedUrl ?? throw new ArgumentNullException(nameof(neoforgedUrl));
if (files == null)
{

View File

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

View File

@@ -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.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models.Manifests;
using AlayaCore.Utilities.Enums;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
namespace AlayaCore.Services
{
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 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));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<InstallEnvironment> GetCurrentEnvironmentAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Building current installation environment state.");
OSPlatform platform = GetCurrentPlatform();
bool javaInstalled = TryGetJavaPath(out string? javaPath);
@@ -33,30 +47,42 @@ namespace AlayaCore.Services
if (javaInstalled)
{
_logger.LogDebug("Java runtime was detected at {JavaPath}. Attempting to read Java version.", javaPath);
javaVersion = GetJavaVersion(javaPath!);
}
else
{
_logger.LogInformation("No managed Java runtime was detected.");
}
InstalledVersionState versionState = GetInstalledVersionState();
bool minecraftInstalled = IsMinecraftInstalled();
string? minecraftVersion = null;
bool neoforgeInstalled = IsNeoforgeInstalled();
string? neoforgeVersion = null;
InstalledModsManifestModel installedModsManifest =
await _manifestService.GetInstalledModsManifestAsync(cancellationToken).ConfigureAwait(false)
?? new InstalledModsManifestModel();
await _manifestService.GetInstalledModsManifestAsync(cancellationToken).ConfigureAwait(false);
return new InstallEnvironment(
InstallEnvironment environment = new InstallEnvironment(
osPlatform: platform,
javaInstalled: javaInstalled,
javaPath: javaPath,
javaVersion: javaVersion,
minecraftInstalled: minecraftInstalled,
minecraftVersion: minecraftVersion,
neoforgedInstalled: neoforgeInstalled,
neoforgedVersion: neoforgeVersion,
minecraftInstalled: !string.IsNullOrWhiteSpace(versionState.MinecraftVersion),
minecraftVersion: versionState.MinecraftVersion,
neoforgedInstalled: !string.IsNullOrWhiteSpace(versionState.NeoForgeVersion),
neoforgedVersion: versionState.NeoForgeVersion,
installedModsManifest: installedModsManifest);
_logger.LogInformation(
"Installation environment resolved. Platform: {Platform}, JavaInstalled: {JavaInstalled}, JavaVersion: {JavaVersion}, MinecraftInstalled: {MinecraftInstalled}, MinecraftVersion: {MinecraftVersion}, NeoForgeInstalled: {NeoForgeInstalled}, NeoForgeVersion: {NeoForgeVersion}, InstalledModsCount: {InstalledModsCount}",
platform,
environment.JavaInstalled,
environment.JavaVersion,
environment.MinecraftInstalled,
environment.MinecraftVersion,
environment.NeoforgedInstalled,
environment.NeoforgedVersion,
environment.InstalledModsManifest.Mods.Count);
return environment;
}
private static OSPlatform GetCurrentPlatform()
@@ -79,7 +105,7 @@ namespace AlayaCore.Services
throw new PlatformNotSupportedException("The current operating system is not supported.");
}
private static string? GetJavaVersion(string javaPath)
private string? GetJavaVersion(string javaPath)
{
if (string.IsNullOrWhiteSpace(javaPath))
{
@@ -91,6 +117,8 @@ namespace AlayaCore.Services
throw new FileNotFoundException("Java executable was not found.", javaPath);
}
_logger.LogDebug("Reading Java version from executable at {JavaPath}.", javaPath);
using var process = new Process
{
StartInfo = new ProcessStartInfo
@@ -111,10 +139,26 @@ namespace AlayaCore.Services
if (process.ExitCode != 0 && string.IsNullOrWhiteSpace(standardError))
{
_logger.LogWarning(
"Java version check for {JavaPath} exited with code {ExitCode} and produced no version output.",
javaPath,
process.ExitCode);
return null;
}
return ParseJavaVersion(standardError);
string? version = ParseJavaVersion(standardError);
if (string.IsNullOrWhiteSpace(version))
{
_logger.LogWarning("Java version output from {JavaPath} could not be parsed.", javaPath);
}
else
{
_logger.LogDebug("Parsed Java version {JavaVersion} from {JavaPath}.", version, javaPath);
}
return version;
}
private static string? ParseJavaVersion(string processOutput)
@@ -131,37 +175,188 @@ namespace AlayaCore.Services
: null;
}
private static bool TryGetJavaPath(out string? javaPath)
private bool TryGetJavaPath(out string? javaPath)
{
string executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "java.exe"
? "javaw.exe"
: "java";
string fullPath = Path.Combine(
AppContext.BaseDirectory,
"Java",
JAVA_RUNTIME_FOLDER_NAME,
"bin",
executableName);
string runtimePath = _fileStore.Get(FolderLocation.JavaRuntime);
string fullPath = Path.Combine(runtimePath, "bin", executableName);
if (!File.Exists(fullPath))
{
_logger.LogDebug("Managed Java executable was not found at {JavaPath}.", fullPath);
javaPath = null;
return false;
}
_logger.LogDebug("Managed Java executable was found at {JavaPath}.", fullPath);
javaPath = fullPath;
return true;
}
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.Models.Configuration;
using AlayaCore.Models.Manifests;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services
{
@@ -13,97 +14,171 @@ namespace AlayaCore.Services
{
private readonly IManifestService _manifestService;
private readonly LauncherUpdateServiceOptions _options;
private readonly ILogger<LauncherUpdateService> _logger;
public LauncherUpdateService(
IManifestService manifestService,
LauncherUpdateServiceOptions options)
LauncherUpdateServiceOptions options,
ILogger<LauncherUpdateService> logger)
{
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> DoesLauncherNeedUpdating(CancellationToken cancellationToken = default)
{
if (_options.ForceUpdate)
{
_logger.LogWarning("Launcher update check is being forced by configuration.");
return true;
}
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Checking whether the launcher requires an update.");
LauncherManifestModel? localManifest = await _manifestService
.GetLocalLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false);
if (localManifest == null)
{
_logger.LogInformation("No local launcher manifest was found. The launcher will be treated as requiring an update.");
return true;
}
if (localManifest.Version == null)
{
_logger.LogWarning("Local launcher manifest did not contain a valid version. The launcher will be treated as requiring an update.");
return true;
}
if (string.IsNullOrWhiteSpace(localManifest.Sha512Hash))
{
_logger.LogWarning("Local launcher manifest did not contain a valid SHA-512 hash. The launcher will be treated as requiring an update.");
return true;
}
string remoteHash = await _manifestService
.GetRemoteLauncherManifestHashAsync(cancellationToken)
LauncherManifestModel remoteManifest = await _manifestService
.GetRemoteLauncherManifestAsync(cancellationToken)
.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.");
}
return !string.Equals(
bool versionMismatch = localManifest.Version != remoteManifest.Version;
bool hashMismatch = !string.Equals(
localManifest.Sha512Hash.Trim(),
remoteHash.Trim(),
remoteManifest.Sha512Hash.Trim(),
StringComparison.OrdinalIgnoreCase);
_logger.LogInformation(
"Launcher update check complete. LocalVersion: {LocalVersion}, RemoteVersion: {RemoteVersion}, VersionMismatch: {VersionMismatch}, HashMismatch: {HashMismatch}",
localManifest.Version,
remoteManifest.Version,
versionMismatch,
hashMismatch);
return versionMismatch || hashMismatch;
}
public Task LaunchUpdaterAsync(LauncherManifestModel newManifest, CancellationToken cancellationToken = default)
public Task LaunchUpdaterAsync(
LauncherManifestModel newManifest,
CancellationToken cancellationToken = default)
{
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;
if (string.IsNullOrWhiteSpace(updaterPath))
{
_logger.LogError("Updater launch failed because the updater path was not configured.");
throw new InvalidOperationException("Updater path is not configured.");
}
if (!Path.IsPathFullyQualified(updaterPath))
{
_logger.LogError("Updater launch failed because the updater path was not absolute. UpdaterPath: {UpdaterPath}", updaterPath);
throw new InvalidOperationException("Updater path must be absolute.");
}
if (!File.Exists(updaterPath))
{
_logger.LogError("Updater launch failed because the updater executable was not found at {UpdaterPath}.", updaterPath);
throw new FileNotFoundException("Alaya updater program was not found.", updaterPath);
}
string workingDirectory = Path.GetDirectoryName(updaterPath)
?? throw new InvalidOperationException("Updater working directory could not be resolved.");
string arguments = $"-url \"{newManifest.DownloadUri.AbsoluteUri}\"";
var startInfo = new ProcessStartInfo
{
FileName = updaterPath,
WorkingDirectory = workingDirectory,
Arguments = $@"-url {newManifest.DownloadUri}",
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = true
};
_logger.LogInformation(
"Launching updater process. UpdaterPath: {UpdaterPath}, WorkingDirectory: {WorkingDirectory}, DownloadUri: {DownloadUri}",
updaterPath,
workingDirectory,
newManifest.DownloadUri.AbsoluteUri);
try
{
Process? process = Process.Start(startInfo);
if (process == null)
{
_logger.LogError("Updater process start returned null for executable {UpdaterPath}.", updaterPath);
throw new InvalidOperationException("Failed to start updater process.");
}
_logger.LogInformation(
"Updater process launched successfully. ProcessId: {ProcessId}, UpdaterPath: {UpdaterPath}",
process.Id,
updaterPath);
}
catch (Exception ex) when (!(ex is 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);
}

View File

@@ -3,54 +3,76 @@ using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
using AlayaCore.Models.Manifests.DTO;
using AlayaCore.Utilities.Enums;
using AlayaCore.Utilities.Extensions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AlayaCore.Services
{
public sealed class ManifestService : IManifestService
{
private const string 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 INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
private readonly IDownloadService _downloadService;
private readonly IHttpClient _httpClient;
private readonly IFileStore _fileStore;
private readonly ManifestServiceOptions _options;
private readonly IRetryPolicy _retryPolicy;
private readonly ILogger<ManifestService> _logger;
public ManifestService(
IDownloadService downloadService,
IHttpClient httpClient,
ManifestServiceOptions options)
IFileStore fileStore,
ManifestServiceOptions options,
IRetryPolicy retryPolicy,
ILogger<ManifestService> logger)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_options = options ?? throw new ArgumentNullException(nameof(options));
_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();
_logger.LogInformation(
"Downloading and loading Alaya manifest from {ManifestUri} to {DestinationPath}.",
_options.AlayaManifestUri,
destinationPath);
return DownloadAndLoadManifestAsync<ManifestDto, ManifestModel>(
_options.CoreManifestUri,
_options.AlayaManifestUri,
destinationPath,
_options.CoreManifestSha512Hash,
_options.AlayaManifestSha512Hash,
static dto => dto.ToModel(),
cancellationToken);
}
public async Task<InstalledModsManifestModel> GetInstalledModsManifestAsync(CancellationToken cancellationToken = default)
public async Task<InstalledModsManifestModel> GetInstalledModsManifestAsync(
CancellationToken cancellationToken = default)
{
string path = GetInstalledModsManifestPath();
_logger.LogDebug("Loading installed mods manifest from {ManifestPath}.", path);
if (!File.Exists(path))
{
_logger.LogInformation("Installed mods manifest was not found at {ManifestPath}. Returning an empty manifest.", path);
return InstalledModsManifestModel.Empty();
}
@@ -58,22 +80,40 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogWarning("Installed mods manifest at {ManifestPath} was empty. Returning an empty manifest.", path);
return InstalledModsManifestModel.Empty();
}
InstalledModsManifestModel? manifest = DeserializeAndMapManifest<InstalledModsManifestDto, InstalledModsManifestModel>(
json,
path,
static dto => dto.ToModel(),
swallowDeserializationErrors: true);
InstalledModsManifestModel? manifest =
DeserializeAndMapManifest<InstalledModsManifestDto, InstalledModsManifestModel>(
json,
path,
static dto => dto.ToModel(),
swallowDeserializationErrors: true);
return manifest ?? InstalledModsManifestModel.Empty();
if (manifest == null)
{
_logger.LogWarning("Installed mods manifest at {ManifestPath} could not be deserialized. Returning an empty manifest.", path);
return InstalledModsManifestModel.Empty();
}
_logger.LogInformation(
"Loaded installed mods manifest from {ManifestPath}. Mod count: {ModCount}",
path,
manifest.Mods.Count);
return manifest;
}
public Task<LauncherManifestModel> GetLauncherManifestAsync(CancellationToken cancellationToken = default)
{
string destinationPath = GetLauncherManifestPath();
_logger.LogInformation(
"Downloading and loading launcher manifest from {ManifestUri} to {DestinationPath}.",
_options.LauncherManifestUri,
destinationPath);
return DownloadAndLoadManifestAsync<LauncherManifestDto, LauncherManifestModel>(
_options.LauncherManifestUri,
destinationPath,
@@ -82,103 +122,105 @@ namespace AlayaCore.Services
cancellationToken);
}
public Task<ManifestModel?> GetLocalCoreManifestAsync(CancellationToken cancellationToken = default)
public Task<ManifestModel?> GetLocalAlayaManifestAsync(CancellationToken cancellationToken = default)
{
string path = GetCoreManifestPath();
_logger.LogDebug("Loading local Alaya manifest from {ManifestPath}.", path);
return LoadLocalManifestAsync<ManifestDto, ManifestModel>(
GetCoreManifestPath(),
path,
static dto => dto.ToModel(),
cancellationToken);
}
public Task<LauncherManifestModel?> GetLocalLauncherManifestAsync(CancellationToken cancellationToken = default)
{
string path = GetLauncherManifestPath();
_logger.LogDebug("Loading local launcher manifest from {ManifestPath}.", path);
return LoadLocalManifestAsync<LauncherManifestDto, LauncherManifestModel>(
GetLauncherManifestPath(),
path,
static dto => dto.ToModel(),
cancellationToken);
}
public async Task<Version> GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Fetching remote Alaya manifest version from {ManifestUri}.", _options.AlayaManifestUri);
using HttpResponseMessage response = await _httpClient.GetAsync(
_options.CoreManifestUri,
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(),
ManifestModel remoteManifest = await GetRemoteManifestAsync<ManifestDto, ManifestModel>(
_options.AlayaManifestUri,
static dto => dto.ToModel(),
swallowDeserializationErrors: false)!;
cancellationToken).ConfigureAwait(false);
if (remoteManifest.AlayaVersion == null)
{
_logger.LogError("Remote Alaya manifest from {ManifestUri} did not contain a valid version.", _options.AlayaManifestUri);
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;
}
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,
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(),
swallowDeserializationErrors: false)!;
cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(remoteManifest.Sha512Hash))
{
_logger.LogError("Remote launcher manifest from {ManifestUri} did not contain a valid SHA-512 hash.", _options.LauncherManifestUri);
throw new InvalidDataException(
$"Remote launcher manifest from '{_options.LauncherManifestUri}' does not contain a valid SHA-512 hash.");
}
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()
{
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()
{
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()
{
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>(
@@ -199,6 +241,7 @@ namespace AlayaCore.Services
if (!File.Exists(path))
{
_logger.LogInformation("Local manifest was not found at {ManifestPath}.", path);
return default;
}
@@ -206,14 +249,26 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogWarning("Local manifest at {ManifestPath} was empty.", path);
return default;
}
return DeserializeAndMapManifest<TDto, TModel>(
TModel? model = DeserializeAndMapManifest(
json,
path,
map,
swallowDeserializationErrors: true);
if (model == null)
{
_logger.LogWarning("Local manifest at {ManifestPath} could not be deserialized or mapped.", path);
}
else
{
_logger.LogDebug("Successfully loaded local manifest from {ManifestPath}.", path);
}
return model;
}
private async Task<TModel> DownloadAndLoadManifestAsync<TDto, TModel>(
@@ -253,14 +308,26 @@ namespace AlayaCore.Services
EnsureDirectoryExists(destinationPath);
await _downloadService.DownloadFileAsync(
_logger.LogInformation(
"Downloading manifest from {ManifestUri} to {DestinationPath}.",
manifestUri,
destinationPath,
sha512Hash,
cancellationToken: cancellationToken).ConfigureAwait(false);
destinationPath);
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))
{
_logger.LogError("Manifest file was not found after download at {DestinationPath}.", destinationPath);
throw new FileNotFoundException("Manifest file was not found after download.", destinationPath);
}
@@ -268,17 +335,83 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogError("Downloaded manifest file at {DestinationPath} was empty.", destinationPath);
throw new InvalidDataException($"Manifest file '{destinationPath}' was empty.");
}
return DeserializeAndMapManifest<TDto, TModel>(
TModel model = DeserializeAndMapManifest(
json,
destinationPath,
map,
swallowDeserializationErrors: false)!;
_logger.LogInformation(
"Downloaded and loaded manifest successfully from {ManifestUri} into {DestinationPath}.",
manifestUri,
destinationPath);
return model;
}
private 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 sourceName,
Func<TDto, TModel> map,
@@ -289,9 +422,11 @@ namespace AlayaCore.Services
{
if (swallowDeserializationErrors)
{
_logger.LogWarning("Manifest source {SourceName} was empty and deserialization errors were configured to be swallowed.", sourceName);
return default;
}
_logger.LogError("Manifest source {SourceName} was empty.", sourceName);
throw new InvalidDataException($"Manifest source '{sourceName}' was empty.");
}
@@ -314,9 +449,11 @@ namespace AlayaCore.Services
{
if (swallowDeserializationErrors)
{
_logger.LogWarning(ex, "Failed to deserialize manifest source {SourceName} to {DtoType}. Returning default because deserialization errors are being swallowed.", sourceName, typeof(TDto).Name);
return default;
}
_logger.LogError(ex, "Failed to deserialize manifest source {SourceName} to {DtoType}.", sourceName, typeof(TDto).Name);
throw new JsonSerializationException(
$"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.",
ex);
@@ -326,24 +463,34 @@ namespace AlayaCore.Services
{
if (swallowDeserializationErrors)
{
_logger.LogWarning("Deserialization of manifest source {SourceName} to {DtoType} returned null. Returning default because deserialization errors are being swallowed.", sourceName, typeof(TDto).Name);
return default;
}
_logger.LogError("Deserialization of manifest source {SourceName} to {DtoType} returned null.", sourceName, typeof(TDto).Name);
throw new JsonSerializationException(
$"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.");
}
try
{
return map(dto);
TModel model = map(dto);
_logger.LogDebug(
"Mapped manifest source {SourceName} from {DtoType} to {ModelType}.",
sourceName,
typeof(TDto).Name,
typeof(TModel).Name);
return model;
}
catch (Exception ex) when (!(ex is OperationCanceledException))
catch (Exception ex) when (ex is not OperationCanceledException)
{
if (swallowDeserializationErrors)
{
_logger.LogWarning(ex, "Manifest source {SourceName} was deserialized but could not be mapped to {ModelType}. Returning default because mapping errors are being swallowed.", sourceName, typeof(TModel).Name);
return default;
}
_logger.LogError(ex, "Manifest source {SourceName} was deserialized but could not be mapped to {ModelType}.", sourceName, typeof(TModel).Name);
throw new InvalidDataException(
$"Manifest source '{sourceName}' was deserialized but could not be mapped to {typeof(TModel).Name}.",
ex);

View File

@@ -5,15 +5,19 @@ using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
using AlayaCore.Models.Manifests.DTO;
using AlayaCore.Models.Progress;
using AlayaCore.Utilities.Enums;
using AlayaCore.Utilities.Extensions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -21,28 +25,35 @@ namespace AlayaCore.Services
{
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 ModrinthConnectionOptions _options;
private readonly ManifestServiceOptions _manifestOptions;
private readonly IHttpClient _httpClient;
private readonly IFileStore _fileStore;
private readonly IRetryPolicy _retryPolicy;
private readonly ILogger<ModService> _logger;
public ModService(
IDownloadService downloadService,
ModrinthConnectionOptions options,
ManifestServiceOptions manifestOptions,
IHttpClient httpClient)
IHttpClient httpClient,
IFileStore fileStore,
IRetryPolicy retryPolicy,
ILogger<ModService> logger)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_manifestOptions = manifestOptions ?? throw new ArgumentNullException(nameof(manifestOptions));
_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(
ManifestModel manifest,
InstallEnvironment environment,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
if (manifest == null)
@@ -61,9 +72,15 @@ namespace AlayaCore.Services
List<ModFileEntry> requiredMods = manifest.Files
.Where(file => file.Type == FileType.Mod)
.OrderBy(file => file.FileName, StringComparer.OrdinalIgnoreCase)
.ToList();
RemoveStaleMods(requiredMods);
_logger.LogInformation(
"Starting mod sync. RequiredMods: {RequiredModCount}, InstalledModsManifestEntries: {InstalledModCount}",
requiredMods.Count,
installedMods.Count);
RemoveStaleMods(requiredMods, cancellationToken);
List<ModFileEntry> finalInstalledMods = new List<ModFileEntry>();
@@ -71,6 +88,12 @@ namespace AlayaCore.Services
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug(
"Processing required mod {FileName}. Expected SHA-512: {Sha512Hash}, Expected Size: {Size}",
requiredMod.FileName,
requiredMod.Sha512Hash,
requiredMod.Size);
ModFileEntry? installedMod = installedMods.FirstOrDefault(
mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
@@ -84,18 +107,50 @@ namespace AlayaCore.Services
if (isValidInstalledMod)
{
_logger.LogInformation(
"Mod {FileName} is already installed and valid. Skipping download.",
requiredMod.FileName);
finalInstalledMods.Add(installedMod!);
continue;
}
if (installedMod == null)
{
_logger.LogInformation(
"Mod {FileName} is missing locally and will be downloaded.",
requiredMod.FileName);
}
else
{
_logger.LogWarning(
"Mod {FileName} is present but invalid or outdated. Stored SHA-512: {InstalledHash}, Expected SHA-512: {RequiredHash}, Stored Size: {InstalledSize}, Expected Size: {RequiredSize}",
requiredMod.FileName,
installedMod.Sha512Hash,
requiredMod.Sha512Hash,
installedMod.Size,
requiredMod.Size);
}
Uri modUri = await ResolveModUrlAsync(requiredMod, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Downloading mod {FileName} from {ModUri} to {DestinationPath}.",
requiredMod.FileName,
modUri,
destinationPath);
await _downloadService.DownloadFileAsync(
modUri,
destinationPath,
requiredMod.Sha512Hash,
progress,
cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Download completed successfully for mod {FileName}.",
requiredMod.FileName);
finalInstalledMods.Add(new ModFileEntry(
requiredMod.FileName,
requiredMod.Type,
@@ -104,6 +159,10 @@ namespace AlayaCore.Services
}
await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Mod sync completed successfully. Final installed mod count: {InstalledModCount}",
finalInstalledMods.Count);
}
private static bool IsInstalledModUpToDate(
@@ -144,90 +203,156 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(fileEntry.Sha512Hash))
{
_logger.LogError(
"Failed to resolve mod URL because mod {FileName} did not contain a SHA-512 hash.",
fileEntry.FileName);
throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a SHA-512 hash.");
}
string versionEndpoint = BuildVersionEndpoint(fileEntry.Sha512Hash);
using HttpResponseMessage response = await _httpClient.GetAsync(
new Uri(versionEndpoint, UriKind.Absolute),
HttpCompletionOption.ResponseContentRead,
_logger.LogDebug(
"Resolving mod URL for {FileName} using Modrinth endpoint {VersionEndpoint}.",
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);
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)
@@ -238,7 +363,7 @@ namespace AlayaCore.Services
return $"{baseUrl}/version_file/{sha512Hash}";
}
private static string GetModDestinationPath(ModFileEntry fileEntry)
private string GetModDestinationPath(ModFileEntry fileEntry)
{
if (fileEntry == null)
{
@@ -251,26 +376,37 @@ namespace AlayaCore.Services
}
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)
{
throw new ArgumentNullException(nameof(requiredMods));
}
string modsDirectory = GetModsDirectoryPath();
string modsDirectory = _fileStore.Get(FolderLocation.Mods);
if (!Directory.Exists(modsDirectory))
{
_logger.LogDebug("Mods directory {ModsDirectory} does not exist. No stale mods need removal.", modsDirectory);
return;
}
@@ -281,10 +417,17 @@ namespace AlayaCore.Services
foreach (string filePath in Directory.GetFiles(modsDirectory))
{
cancellationToken.ThrowIfCancellationRequested();
string fileName = Path.GetFileName(filePath);
if (!requiredFileNames.Contains(fileName))
{
_logger.LogInformation(
"Removing stale mod file {FileName} at {FilePath}.",
fileName,
filePath);
File.Delete(filePath);
}
}
@@ -302,24 +445,34 @@ namespace AlayaCore.Services
List<ModFileEntry> entries = installedMods.ToList();
InstalledModsManifestModel manifest = new InstalledModsManifestModel(entries);
string manifestsDirectory = _manifestOptions.ManifestDirectoryPath;
Directory.CreateDirectory(manifestsDirectory);
string manifestsDirectory = _fileStore.GetOrCreate(FolderLocation.Manifests);
string manifestPath = Path.Combine(manifestsDirectory, InstalledModsManifestFileName);
string manifestPath = Path.Combine(manifestsDirectory, INSTALLED_MODS_MANIFEST_FILE_NAME);
string temporaryManifestPath = manifestPath + ".tmp";
InstalledModsManifestDto dto = manifest.ToDto();
string json = JsonConvert.SerializeObject(dto, Formatting.Indented);
_logger.LogDebug(
"Writing installed mods manifest to temporary path {TemporaryManifestPath}. EntryCount: {EntryCount}",
temporaryManifestPath,
entries.Count);
await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false);
if (File.Exists(manifestPath))
{
_logger.LogDebug("Deleting previous installed mods manifest at {ManifestPath}.", manifestPath);
File.Delete(manifestPath);
}
File.Move(temporaryManifestPath, manifestPath);
_logger.LogInformation(
"Installed mods manifest updated successfully at {ManifestPath}. EntryCount: {EntryCount}",
manifestPath,
entries.Count);
}
}
}

View File

@@ -2,55 +2,353 @@ using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Configuration;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Configuration;
using AlayaCore.Utilities.Enums;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AlayaCore.Services
{
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 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));
GameOptions = gameOptions ?? throw new ArgumentNullException(nameof(gameOptions));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task SetForceReinstallAsync(bool value, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Updating launcher option ForceReinstall to {ForceReinstall}.", value);
LauncherOptions.ForceReinstall = value;
await SaveLauncherOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Launcher option ForceReinstall was updated and saved successfully.");
}
public async Task UpdateLaunchVersionAsync(string newVersion, CancellationToken cancellationToken = default)
{
string? normalizedVersion = string.IsNullOrWhiteSpace(newVersion)
? null
: newVersion.Trim();
_logger.LogInformation(
"Updating game option LaunchVersion from {CurrentLaunchVersion} to {NewLaunchVersion}.",
GameOptions.LaunchVersion,
normalizedVersion);
GameOptions.LaunchVersion = normalizedVersion;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option LaunchVersion was updated and saved successfully.");
}
public async Task SetMinimumRamMbAsync(int minimumRamMb, CancellationToken cancellationToken = default)
{
if (minimumRamMb <= 0)
{
throw new ArgumentOutOfRangeException(nameof(minimumRamMb), "Minimum RAM must be greater than zero.");
}
if (GameOptions.MaximumRamMb > 0 && minimumRamMb > GameOptions.MaximumRamMb)
{
throw new ArgumentException("Minimum RAM cannot be greater than maximum RAM.", nameof(minimumRamMb));
}
_logger.LogInformation(
"Updating game option MinimumRamMb from {CurrentMinimumRamMb} to {NewMinimumRamMb}.",
GameOptions.MinimumRamMb,
minimumRamMb);
GameOptions.MinimumRamMb = minimumRamMb;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option MinimumRamMb was updated and saved successfully.");
}
public async Task SetMaximumRamMbAsync(int maximumRamMb, CancellationToken cancellationToken = default)
{
if (maximumRamMb <= 0)
{
throw new ArgumentOutOfRangeException(nameof(maximumRamMb), "Maximum RAM must be greater than zero.");
}
if (GameOptions.MinimumRamMb > 0 && maximumRamMb < GameOptions.MinimumRamMb)
{
throw new ArgumentException("Maximum RAM cannot be less than minimum RAM.", nameof(maximumRamMb));
}
_logger.LogInformation(
"Updating game option MaximumRamMb from {CurrentMaximumRamMb} to {NewMaximumRamMb}.",
GameOptions.MaximumRamMb,
maximumRamMb);
GameOptions.MaximumRamMb = maximumRamMb;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option MaximumRamMb was updated and saved successfully.");
}
public async Task SetResolutionAsync(int screenWidth, int screenHeight, CancellationToken cancellationToken = default)
{
if (screenWidth <= 0)
{
throw new ArgumentOutOfRangeException(nameof(screenWidth), "Screen width must be greater than zero.");
}
if (screenHeight <= 0)
{
throw new ArgumentOutOfRangeException(nameof(screenHeight), "Screen height must be greater than zero.");
}
_logger.LogInformation(
"Updating game resolution from {CurrentWidth}x{CurrentHeight} to {NewWidth}x{NewHeight}.",
GameOptions.ScreenWidth,
GameOptions.ScreenHeight,
screenWidth,
screenHeight);
GameOptions.ScreenWidth = screenWidth;
GameOptions.ScreenHeight = screenHeight;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game resolution was updated and saved successfully.");
}
public async Task SetFullscreenAsync(bool fullscreen, CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Updating game option Fullscreen from {CurrentFullscreen} to {NewFullscreen}.",
GameOptions.Fullscreen,
fullscreen);
GameOptions.Fullscreen = fullscreen;
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game option Fullscreen was updated and saved successfully.");
}
public async Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Saving launcher options to disk.");
await SaveAsync(
LauncherSettingsFileName,
LauncherOptions.FileName,
LauncherOptions,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Launcher options were saved successfully.");
}
public async Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Loading launcher options from disk.");
LauncherOptions? loadedOptions = await LoadAsync<LauncherOptions>(
LauncherSettingsFileName,
LauncherOptions.FileName,
cancellationToken).ConfigureAwait(false);
if (loadedOptions == null)
{
_logger.LogInformation("No launcher options file was found or it was empty. Existing in-memory launcher options will be kept.");
return;
}
LauncherOptions.ForceReinstall = loadedOptions.ForceReinstall;
_logger.LogInformation(
"Launcher options were loaded successfully. ForceReinstall: {ForceReinstall}",
LauncherOptions.ForceReinstall);
}
public async Task SaveGameOptionsAsync(CancellationToken cancellationToken = default)
{
ValidateGameOptions(GameOptions);
_logger.LogDebug(
"Saving game options to disk. LaunchVersion: {LaunchVersion}, MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, Resolution: {ScreenWidth}x{ScreenHeight}, Fullscreen: {Fullscreen}",
GameOptions.LaunchVersion,
GameOptions.MinimumRamMb,
GameOptions.MaximumRamMb,
GameOptions.ScreenWidth,
GameOptions.ScreenHeight,
GameOptions.Fullscreen);
await SaveAsync(
GameOptions.FileName,
GameOptions,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game options were saved successfully.");
}
public async Task LoadGameOptionsAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Loading game options from disk.");
GameOptions? loadedOptions = await LoadAsync<GameOptions>(
GameOptions.FileName,
cancellationToken).ConfigureAwait(false);
if (loadedOptions == null)
{
_logger.LogInformation("No game options file was found or it was empty. Applying default game options.");
ApplyGameOptions(GameOptions.Default);
return;
}
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>(
string fileName,
T value,
CancellationToken cancellationToken)
CancellationToken cancellationToken) where T : BaseConfig
{
if (string.IsNullOrWhiteSpace(fileName))
{
@@ -69,6 +367,7 @@ namespace AlayaCore.Services
if (string.IsNullOrWhiteSpace(directoryPath))
{
_logger.LogError("Could not resolve the settings directory path for file {FileName}.", fileName);
throw new InvalidOperationException("Could not resolve the settings directory path.");
}
@@ -77,19 +376,28 @@ namespace AlayaCore.Services
string temporaryPath = fullPath + ".tmp";
string json = JsonConvert.SerializeObject(value, Formatting.Indented);
_logger.LogDebug(
"Writing settings file {FileName} to temporary path {TemporaryPath} before replacing {FullPath}.",
fileName,
temporaryPath,
fullPath);
await File.WriteAllTextAsync(temporaryPath, json, cancellationToken).ConfigureAwait(false);
if (File.Exists(fullPath))
{
_logger.LogDebug("Deleting existing settings file at {FullPath}.", fullPath);
File.Delete(fullPath);
}
File.Move(temporaryPath, fullPath);
_logger.LogInformation("Settings file {FileName} was saved successfully to {FullPath}.", fileName, fullPath);
}
private async Task<T?> LoadAsync<T>(
string fileName,
CancellationToken cancellationToken)
CancellationToken cancellationToken) where T : BaseConfig
{
if (string.IsNullOrWhiteSpace(fileName))
{
@@ -102,31 +410,49 @@ namespace AlayaCore.Services
if (!File.Exists(fullPath))
{
_logger.LogInformation("Settings file {FileName} was not found at {FullPath}.", fileName, fullPath);
return default;
}
_logger.LogDebug("Loading settings file {FileName} from {FullPath}.", fileName, fullPath);
string json = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogWarning("Settings file {FileName} at {FullPath} was empty.", fileName, fullPath);
return default;
}
try
{
return JsonConvert.DeserializeObject<T>(json);
T? result = JsonConvert.DeserializeObject<T>(json);
if (result == null)
{
_logger.LogWarning("Deserializing settings file {FileName} at {FullPath} returned null.", fileName, fullPath);
}
else
{
_logger.LogDebug("Settings file {FileName} was deserialized successfully.", fileName);
}
return result;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to deserialize settings file {FileName} at {FullPath} to {TypeName}.", fileName, fullPath, typeof(T).Name);
throw new InvalidDataException(
$"Failed to deserialize settings file '{fullPath}' to {typeof(T).Name}.",
ex);
}
}
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
{
Checking,
Ready,
LauncherNeedsUpdate,
InstallJava,
NeedAuthenticating,
InstallMinecraft,
InstallNeoforge,
SyncMods
@@ -13,41 +14,45 @@ namespace AlayaCore.States
public sealed class LaunchPlan
{
public bool LauncherNeedsUpdate { get; }
public bool JavaNeedsInstallOrUpdate { get; }
public bool MinecraftNeedsInstallOrUpdate { get; }
public bool NeoforgeNeedsInstallOrUpdate { get; }
public bool ModsNeedSync { get; }
public bool NeedAuthenticating { get; }
public LaunchState State => ComputeState();
public bool CanRun =>
State == LaunchState.Ready;
public bool CanRun => State == LaunchState.Ready;
public bool NeedsUpdating =>
State != LaunchState.Ready;
LauncherNeedsUpdate ||
MinecraftNeedsInstallOrUpdate ||
NeoforgeNeedsInstallOrUpdate ||
ModsNeedSync;
public bool NeedsAttention =>
NeedsUpdating || NeedAuthenticating;
public LaunchPlan(
bool launcherNeedsUpdate,
bool javaNeedsInstallOrUpdate,
bool minecraftNeedsInstallOrUpdate,
bool neoforgeNeedsInstallOrUpdate,
bool modsNeedSync)
bool modsNeedSync,
bool needAuthenticating)
{
LauncherNeedsUpdate = launcherNeedsUpdate;
JavaNeedsInstallOrUpdate = javaNeedsInstallOrUpdate;
MinecraftNeedsInstallOrUpdate = minecraftNeedsInstallOrUpdate;
NeoforgeNeedsInstallOrUpdate = neoforgeNeedsInstallOrUpdate;
ModsNeedSync = modsNeedSync;
NeedAuthenticating = needAuthenticating;
}
private LaunchState ComputeState()
{
// Priority order matters a LOT here
if (LauncherNeedsUpdate)
return LaunchState.LauncherNeedsUpdate;
if (JavaNeedsInstallOrUpdate)
return LaunchState.InstallJava;
if (NeedAuthenticating)
return LaunchState.NeedAuthenticating;
if (MinecraftNeedsInstallOrUpdate)
return LaunchState.InstallMinecraft;
@@ -65,10 +70,10 @@ namespace AlayaCore.States
{
return new LaunchPlan(
launcherNeedsUpdate: false,
javaNeedsInstallOrUpdate: false,
minecraftNeedsInstallOrUpdate: false,
neoforgeNeedsInstallOrUpdate: false,
modsNeedSync: false);
modsNeedSync: false,
needAuthenticating: false);
}
}
}

View File

@@ -1,5 +1,4 @@
using System;
using System.Globalization;
using Newtonsoft.Json;
namespace AlayaCore.Utilities.Converters
@@ -12,9 +11,9 @@ namespace AlayaCore.Utilities.Converters
{
writer.WriteNull();
}
else if (value is Uri)
else if (value is Uri uri)
{
writer.WriteValue(((Uri)value).AbsoluteUri);
writer.WriteValue(uri.AbsoluteUri);
}
else
{
@@ -33,8 +32,7 @@ namespace AlayaCore.Utilities.Converters
{
try
{
Uri uri = new Uri((string)reader.Value!);
return uri;
return new Uri((string)reader.Value!, UriKind.Absolute);
}
catch (Exception ex)
{
@@ -47,7 +45,7 @@ namespace AlayaCore.Utilities.Converters
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(
dto.AlayaVersion,
dto.RequiredJavaVersion,
dto.RequiredJavaUrl,
dto.MinecraftVersion,
dto.MinecraftUrl,
dto.NeoforgedVersion,
dto.NeoforgedUrl,
dto.ServerUrl,
dto.ServerPort,
dto.Files?.Select(file => file.ToModel()) ?? Array.Empty<ModFileEntry>());
}
@@ -80,12 +78,8 @@ namespace AlayaCore.Utilities.Extensions
return new ManifestDto
{
AlayaVersion = model.AlayaVersion,
RequiredJavaVersion = model.RequiredJavaVersion,
RequiredJavaUrl = model.RequiredJavaUrl,
MinecraftVersion = model.MinecraftVersion,
MinecraftUrl = model.MinecraftUrl,
NeoforgedVersion = model.NeoforgedVersion,
NeoforgedUrl = model.NeoforgedUrl,
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": {
"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": [

View File

@@ -1,2 +1,7 @@
<?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.AssemblyConfigurationAttribute("Debug")]
[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.AssemblyTitleAttribute("AlayaCore")]
[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,
"dgSpecHash": "iqHG7z//q/I=",
"dgSpecHash": "pXFuC0zr6Zg=",
"success": true,
"projectFilePath": "/Users/ryanmacham/Documents/Coding/AlayaCore/AlayaCore/AlayaCore.csproj",
"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/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"
],
"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