Compare commits

...

12 Commits

Author SHA1 Message Date
6938635ee4 Added Retry Policy and Launcher Error.
Included ErrorHelper for mapping.
2026-04-06 20:47:13 +01:00
491a3d420d Added Retry Policy and Launcher Error.
Included ErrorHelper for mapping.
2026-04-06 20:47:00 +01:00
11863088e4 ILogger support added 2026-04-06 19:40:21 +01:00
8a8292d2c3 ILogger support added. 2026-04-06 19:40:08 +01:00
8521f695a9 Removed JavaService, and moved Java handling to CmlLib. 2026-04-06 14:50:33 +01:00
0415d6ad80 Removed JavaService, and moved Java handling to CmlLib. 2026-04-06 14:50:29 +01:00
7b66d8ceac Updated Filestore. Finished Auth and Install Services. Introduced GameOptions and BaseConfig. Adjusted Settings Service to reflect. 2026-04-06 11:41:07 +01:00
c148672c08 Updated Filestore. Finished Auth and Install Services. Introduced GameOptions and BaseConfig. Adjusted Settings Service to reflect. 2026-04-06 11:41:03 +01:00
823ccf4b87 Made Manifest Size source of truth.
- Updated ModService->Resolve... to return Uri.
- Removed ModrinthModInfoModel as no longer needed.
2026-04-04 21:21:50 +01:00
ef135b1164 Made Manifest Size source of truth.
- Updated ModService->Resolve... to return Uri.
- Removed ModrinthModInfoModel as no longer needed.
2026-04-04 21:20:56 +01:00
c48f670eab Switched to Modrinth Version_File with Hash rather than ID and Version.
Implemented SettingsService
2026-04-04 20:51:53 +01:00
3298299764 Switched to Modrinth Version_File with Hash rather than ID and Version.
Implemented SettingsService
2026-04-04 20:50:58 +01:00
68 changed files with 5378 additions and 956 deletions

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

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,27 @@
using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
using AlayaCore.States;
namespace AlayaCore.Abstractions.Interfaces namespace AlayaCore.Abstractions.Interfaces
{ {
public interface ILaunchDirector public interface ILaunchDirector
{ {
Task RunAsync(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

@@ -5,9 +5,9 @@ using AlayaCore.Models.Manifests;
namespace AlayaCore.Abstractions.Interfaces.Services namespace AlayaCore.Abstractions.Interfaces.Services
{ {
public interface IJavaService public interface IGameLaunchService
{ {
Task EnsureValidJavaInstalledAsync( Task LaunchAsync(
ManifestModel manifest, ManifestModel manifest,
InstallEnvironment environment, InstallEnvironment environment,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ namespace AlayaCore.Installation
{ {
public sealed class InstallEnvironment public sealed class InstallEnvironment
{ {
public OSPlatform OSPlatform { get; } public OSPlatform OsPlatform { get; }
public bool JavaInstalled { get; } public bool JavaInstalled { get; }
public string? JavaVersion { get; } public string? JavaVersion { get; }
public string? JavaPath { get; } public string? JavaPath { get; }
@@ -42,7 +42,7 @@ namespace AlayaCore.Installation
nameof(javaPath)); nameof(javaPath));
} }
OSPlatform = osPlatform; OsPlatform = osPlatform;
JavaInstalled = javaInstalled; JavaInstalled = javaInstalled;
JavaPath = javaInstalled ? javaPath : null; JavaPath = javaInstalled ? javaPath : null;
JavaVersion = javaInstalled ? javaVersion : null; JavaVersion = javaInstalled ? javaVersion : null;

View File

@@ -1,11 +1,20 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces; using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation; using AlayaCore.Installation;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
using AlayaCore.States;
using AlayaCore.Utilities.Enums;
using CmlLib.Core;
using CmlLib.Core.Installers;
using Microsoft.Extensions.Logging;
namespace AlayaCore namespace AlayaCore
{ {
@@ -14,106 +23,549 @@ namespace AlayaCore
private readonly IManifestService _manifestService; private readonly IManifestService _manifestService;
private readonly IUpdateService _updateService; private readonly IUpdateService _updateService;
private readonly IInstallStateService _installStateService; private readonly IInstallStateService _installStateService;
private readonly IJavaService _javaService;
private readonly IModService _modService; private readonly IModService _modService;
private readonly IGameLaunchService _gameLaunchService;
private readonly 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( public LaunchDirector(
IManifestService manifestService, IManifestService manifestService,
IUpdateService updateService, IUpdateService updateService,
IInstallStateService installStateService, IInstallStateService installStateService,
IJavaService javaService, IModService modService,
IModService modService) IGameLaunchService gameLaunchService,
IGameInstallService gameInstallService,
ISettingsService settingsService,
IAuthService authService,
LauncherOptions options,
ILogger<LaunchDirector> logger)
{ {
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService)); _manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_updateService = updateService ?? throw new ArgumentNullException(nameof(updateService)); _updateService = updateService ?? throw new ArgumentNullException(nameof(updateService));
_installStateService = installStateService ?? throw new ArgumentNullException(nameof(installStateService)); _installStateService = installStateService ?? throw new ArgumentNullException(nameof(installStateService));
_javaService = javaService ?? throw new ArgumentNullException(nameof(javaService));
_modService = modService ?? throw new ArgumentNullException(nameof(modService)); _modService = modService ?? throw new ArgumentNullException(nameof(modService));
_gameLaunchService = gameLaunchService ?? throw new ArgumentNullException(nameof(gameLaunchService));
_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 RunAsync(CancellationToken cancellationToken = default) public async Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Evaluating launcher state.");
bool launcherNeedsUpdate = await _updateService bool launcherNeedsUpdate = await _updateService
.DoesLauncherNeedUpdating(cancellationToken) .DoesLauncherNeedUpdating(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (launcherNeedsUpdate) if (launcherNeedsUpdate)
{ {
LauncherManifestModel launcherManifest = await _manifestService _logger.LogInformation("Launcher update is required.");
.GetLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false);
await _updateService LaunchPlan launcherUpdatePlan = new LaunchPlan(
.LaunchUpdaterAsync(launcherManifest, cancellationToken) launcherNeedsUpdate: true,
.ConfigureAwait(false); minecraftNeedsInstallOrUpdate: false,
neoforgeNeedsInstallOrUpdate: false,
modsNeedSync: false,
needAuthenticating: false);
return; ApplyPlan(launcherUpdatePlan);
return launcherUpdatePlan;
} }
ManifestModel manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
InstallEnvironment environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
bool needAuthenticating = !await _authService
.IsAuthenticatedAsync(cancellationToken)
.ConfigureAwait(false);
bool minecraftNeedsInstallOrUpdate =
_options.ForceReinstall ||
!environment.JavaInstalled ||
!environment.MinecraftInstalled ||
!string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase);
bool neoforgeNeedsInstallOrUpdate =
_options.ForceReinstall ||
!environment.NeoforgedInstalled ||
!string.Equals(environment.NeoforgedVersion, manifest.NeoforgedVersion, StringComparison.OrdinalIgnoreCase);
bool modsNeedSync =
_options.ForceReinstall ||
DoModsNeedSync(manifest, environment);
LaunchPlan plan = new LaunchPlan(
launcherNeedsUpdate: false,
minecraftNeedsInstallOrUpdate: minecraftNeedsInstallOrUpdate,
neoforgeNeedsInstallOrUpdate: neoforgeNeedsInstallOrUpdate,
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<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();
_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);
await _updateService
.LaunchUpdaterAsync(launcherManifest, cancellationToken)
.ConfigureAwait(false);
ApplyPlan(plan);
_logger.LogInformation("Updater launched successfully. Returning UpdaterLaunched result.");
return InstallOrUpdateResult.UpdaterLaunched;
}
case LaunchState.NeedAuthenticating:
{
_logger.LogInformation("Authentication is required.");
ReportProgress(progress, LaunchState.NeedAuthenticating, "Signing in...");
await _authService
.AuthenticateAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Authentication completed successfully.");
break;
}
case LaunchState.InstallMinecraft:
{
_logger.LogInformation("Minecraft installation or repair is required.");
ReportProgress(progress, LaunchState.InstallMinecraft, "Preparing Minecraft installation...");
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
EventHandler<InstallerProgressChangedEventArgs>? minecraftFileProgress =
CreateMinecraftFileProgressHandler(progress);
EventHandler<ByteProgress>? minecraftByteProgress =
CreateMinecraftByteProgressHandler(progress);
await _gameInstallService
.EnsureMinecraftInstalledAsync(
manifest,
environment,
cancellationToken,
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.");
}
InstallEnvironment environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
ManifestModel manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Starting game launch. MinecraftVersion: {MinecraftVersion}, NeoForgeVersion: {NeoForgeVersion}",
environment.MinecraftVersion,
environment.NeoforgedVersion);
await _gameLaunchService
.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 ManifestModel? localManifest = await _manifestService
.GetLocalCoreManifestAsync(cancellationToken) .GetLocalAlayaManifestAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
Version remoteVersion = await _manifestService Version remoteVersion = await _manifestService
.GetRemoteCoreManifestVersionAsync(cancellationToken) .GetRemoteCoreManifestVersionAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (localManifest == null)
{
_logger.LogInformation("No local Alaya manifest was found. Downloading remote manifest.");
}
else if (localManifest.AlayaVersion != remoteVersion)
{
_logger.LogInformation(
"Local Alaya manifest version {LocalVersion} differs from remote version {RemoteVersion}. Downloading updated manifest.",
localManifest.AlayaVersion,
remoteVersion);
}
if (localManifest == null || localManifest.AlayaVersion != remoteVersion) if (localManifest == null || localManifest.AlayaVersion != remoteVersion)
{ {
localManifest = await _manifestService localManifest = await _manifestService
.GetCoreManifestAsync(cancellationToken) .GetAlayaManifestAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
if (localManifest == null) if (localManifest == null)
{ {
_logger.LogError("Local Alaya manifest was still unavailable after refresh.");
throw new FileNotFoundException("Local core manifest was not found after refresh."); throw new FileNotFoundException("Local core manifest was not found after refresh.");
} }
await ProcessManifestAsync(localManifest, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Current Alaya manifest is available. Version: {AlayaVersion}", localManifest.AlayaVersion);
return localManifest;
} }
private async Task ProcessManifestAsync( private static bool DoModsNeedSync(ManifestModel manifest, InstallEnvironment environment)
ManifestModel manifest,
CancellationToken cancellationToken)
{ {
if (manifest == null) if (manifest == null)
{ {
throw new ArgumentNullException(nameof(manifest)); throw new ArgumentNullException(nameof(manifest));
} }
cancellationToken.ThrowIfCancellationRequested(); if (environment == null)
InstallEnvironment environment = await _installStateService
.GetCurrentEnvironmentAsync(cancellationToken)
.ConfigureAwait(false);
if (!environment.JavaInstalled || environment.JavaVersion != manifest.RequiredJavaVersion)
{ {
await _javaService throw new ArgumentNullException(nameof(environment));
.EnsureValidJavaInstalledAsync(manifest, environment, cancellationToken)
.ConfigureAwait(false);
} }
// Process Minecraft var requiredMods = manifest.Files
.Where(file => file.Type == FileType.Mod)
.ToList();
if (!environment.MinecraftInstalled || environment.MinecraftVersion != manifest.MinecraftVersion) var installedMods = environment.InstalledModsManifest.Mods;
if (requiredMods.Count != installedMods.Count)
{ {
return true;
} }
// Process Neoforge foreach (ModFileEntry requiredMod in requiredMods)
if (!environment.NeoforgedInstalled || environment.NeoforgedVersion != manifest.NeoforgedVersion)
{ {
ModFileEntry? installedMod = installedMods.FirstOrDefault(
mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
if (installedMod == null)
{
return true;
}
if (!string.Equals(installedMod.Sha512Hash, requiredMod.Sha512Hash, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (installedMod.Size != requiredMod.Size)
{
return true;
}
} }
// Process Mods return false;
}
await _modService.ProcessModsAsync(manifest, environment, cancellationToken).ConfigureAwait(false); private void ApplyPlan(LaunchPlan plan)
{
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

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

View File

@@ -0,0 +1,20 @@
using System;
using System.IO;
namespace AlayaCore.Models.Configuration
{
public sealed class LauncherUpdateServiceOptions
{
public LauncherUpdateServiceOptions(string alayaUpdaterPath, bool forceUpdate)
{
AlayaUpdaterPath = alayaUpdaterPath;
ForceUpdate = forceUpdate;
}
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 sealed class ManifestServiceOptions
{ {
public Uri CoreManifestUri { get; } public Uri AlayaManifestUri { get; }
public Uri LauncherManifestUri { get; } public Uri LauncherManifestUri { get; }
public string CoreManifestSha512Hash { get; } public string AlayaManifestSha512Hash { get; }
public string LauncherManifestSha512Hash { get; } public string LauncherManifestSha512Hash { get; }
public string ManifestDirectoryPath { get; }
public ManifestServiceOptions( public ManifestServiceOptions(
Uri coreManifestUri, Uri alayaManifestUri,
Uri launcherManifestUri, Uri launcherManifestUri,
string coreManifestSha512Hash, string alayaManifestSha512Hash,
string launcherManifestSha512Hash, string launcherManifestSha512Hash)
string manifestDirectoryPath)
{ {
CoreManifestUri = coreManifestUri ?? throw new ArgumentNullException(nameof(coreManifestUri)); AlayaManifestUri = alayaManifestUri ?? throw new ArgumentNullException(nameof(alayaManifestUri));
LauncherManifestUri = launcherManifestUri ?? throw new ArgumentNullException(nameof(launcherManifestUri)); LauncherManifestUri = launcherManifestUri ?? throw new ArgumentNullException(nameof(launcherManifestUri));
if (!CoreManifestUri.IsAbsoluteUri) if (!AlayaManifestUri.IsAbsoluteUri)
{ {
throw new ArgumentException("Core manifest URI must be absolute.", nameof(coreManifestUri)); throw new ArgumentException("Core manifest URI must be absolute.", nameof(alayaManifestUri));
} }
if (!LauncherManifestUri.IsAbsoluteUri) if (!LauncherManifestUri.IsAbsoluteUri)
@@ -30,9 +28,9 @@ namespace AlayaCore.Models.Configuration
throw new ArgumentException("Launcher manifest URI must be absolute.", nameof(launcherManifestUri)); throw new ArgumentException("Launcher manifest URI must be absolute.", nameof(launcherManifestUri));
} }
if (string.IsNullOrWhiteSpace(coreManifestSha512Hash)) if (string.IsNullOrWhiteSpace(alayaManifestSha512Hash))
{ {
throw new ArgumentException("Core manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(coreManifestSha512Hash)); throw new ArgumentException("Core manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(alayaManifestSha512Hash));
} }
if (string.IsNullOrWhiteSpace(launcherManifestSha512Hash)) if (string.IsNullOrWhiteSpace(launcherManifestSha512Hash))
@@ -40,14 +38,12 @@ namespace AlayaCore.Models.Configuration
throw new ArgumentException("Launcher manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(launcherManifestSha512Hash)); throw new ArgumentException("Launcher manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(launcherManifestSha512Hash));
} }
if (string.IsNullOrWhiteSpace(manifestDirectoryPath)) AlayaManifestSha512Hash = alayaManifestSha512Hash;
{
throw new ArgumentException("Manifest directory path cannot be null, empty, or whitespace.", nameof(manifestDirectoryPath));
}
CoreManifestSha512Hash = coreManifestSha512Hash;
LauncherManifestSha512Hash = launcherManifestSha512Hash; LauncherManifestSha512Hash = launcherManifestSha512Hash;
ManifestDirectoryPath = manifestDirectoryPath;
} }
public static ManifestServiceOptions Default { get; } = new ManifestServiceOptions(
new Uri("INSERT-ALAYA-URL", UriKind.Absolute),
new Uri("INSERT-LAUNCHER-URL", UriKind.Absolute), "INSERT-ALAYA-HASH", "INSERT-LAUNCHER-HASH");
} }
} }

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
namespace AlayaCore.Models.Configuration
{
public class UpdateServiceOptions
{
public string AlayaUpdaterPath { get; set; }
public bool ForceUpdate { get; set; }
}
}

View File

@@ -1,22 +0,0 @@
using System;
using Newtonsoft.Json;
namespace AlayaCore.Models.Manifests.DTO
{
[Serializable]
public class InstalledModEntryDto
{
[JsonProperty("name", Required = Required.Always)]
public string FileName { get; set; } = string.Empty;
[JsonProperty("version", Required = Required.Always)]
public string Version { get; set; } = string.Empty;
[JsonProperty("sha512Hash", Required = Required.Always)]
public string Sha512Hash { get; set; } = string.Empty;
[JsonProperty("size", Required = Required.Always)]
public long Size { get; set; }
}
}

View File

@@ -5,7 +5,12 @@ namespace AlayaCore.Models.Manifests.DTO
{ {
public class InstalledModsManifestDto public class InstalledModsManifestDto
{ {
public InstalledModsManifestDto(List<ModFileEntryDto> installedMods)
{
InstalledMods = installedMods;
}
[JsonProperty("installedMods")] [JsonProperty("installedMods")]
public List<InstalledModEntryDto> InstalledMods { get; private set; } public List<ModFileEntryDto> InstalledMods { get; private set; }
} }
} }

View File

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

View File

@@ -13,13 +13,10 @@ namespace AlayaCore.Models.Manifests.DTO
[JsonProperty("type", Required = Required.Always)] [JsonProperty("type", Required = Required.Always)]
public FileType Type { get; set; } public FileType Type { get; set; }
[JsonProperty("modrinthId", NullValueHandling = NullValueHandling.Ignore)]
public string? ModrinthId { get; set; }
[JsonProperty("modrinthVersionId", NullValueHandling = NullValueHandling.Ignore)]
public string? ModrinthVersionId { get; set; }
[JsonProperty("sha512Hash", Required = Required.Always)] [JsonProperty("sha512Hash", Required = Required.Always)]
public string Sha512Hash { get; set; } = string.Empty; public string Sha512Hash { get; set; } = string.Empty;
[JsonProperty("size", Required = Required.Always)]
public long Size { get; set; }
} }
} }

View File

@@ -1,43 +0,0 @@
using System;
namespace AlayaCore.Models.Manifests
{
[Serializable]
public sealed class InstalledModEntry
{
public string FileName { get; }
public string Version { get; }
public string Sha512Hash { get; }
public long Size { get; set; }
public InstalledModEntry(string fileName, string version, string sha512Hash, long size)
{
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("Name cannot be null, empty, or whitespace.", nameof(fileName));
}
if (string.IsNullOrWhiteSpace(version))
{
throw new ArgumentException("Version cannot be null, empty, or whitespace.", nameof(version));
}
if (string.IsNullOrWhiteSpace(sha512Hash))
{
throw new ArgumentException("SHA512Hash cannot be null, empty, or whitespace.", nameof(sha512Hash));
}
if (size == 0)
{
throw new ArgumentException("Size cannot be 0, empty, or whitespace.", nameof(size));
}
FileName = fileName;
Version = version;
Sha512Hash = sha512Hash;
Size = size;
}
}
}

View File

@@ -7,33 +7,33 @@ namespace AlayaCore.Models.Manifests
{ {
public sealed class InstalledModsManifestModel public sealed class InstalledModsManifestModel
{ {
public IReadOnlyList<InstalledModEntry> Mods { get; } public IReadOnlyList<ModFileEntry> Mods { get; }
public InstalledModsManifestModel() public InstalledModsManifestModel()
{ {
Mods = Array.Empty<InstalledModEntry>(); Mods = Array.Empty<ModFileEntry>();
} }
public InstalledModsManifestModel(IEnumerable<InstalledModEntry> mods) public InstalledModsManifestModel(IEnumerable<ModFileEntry> mods)
{ {
if (mods == null) if (mods == null)
{ {
throw new ArgumentNullException(nameof(mods)); throw new ArgumentNullException(nameof(mods));
} }
InstalledModEntry[] array = mods.ToArray(); ModFileEntry[] array = mods.ToArray();
if (array.Any(mod => mod == null)) if (array.Any(mod => mod == null))
{ {
throw new ArgumentException("Mods cannot contain null entries.", nameof(mods)); throw new ArgumentException("Mods cannot contain null entries.", nameof(mods));
} }
Mods = new ReadOnlyCollection<InstalledModEntry>(array); Mods = new ReadOnlyCollection<ModFileEntry>(array);
} }
public static InstalledModsManifestModel Empty() public static InstalledModsManifestModel Empty()
{ {
return new InstalledModsManifestModel(Array.Empty<InstalledModEntry>()); return new InstalledModsManifestModel(Array.Empty<ModFileEntry>());
} }
} }
} }

View File

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

View File

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

View File

@@ -8,22 +8,19 @@ namespace AlayaCore.Models.Manifests
{ {
public string FileName { get; } public string FileName { get; }
public FileType Type { get; } public FileType Type { get; }
public string? ModrinthId { get; }
public string? ModrinthVersionId { get; }
public string Sha512Hash { get; } public string Sha512Hash { get; }
public long Size { get; }
public ModFileEntry( public ModFileEntry(
string name, string fileName,
FileType type, FileType type,
string sha512Hash, string sha512Hash,
string? modrinthId = null, long size)
string? modrinthVersionId = null)
{ {
FileName = RequireNonEmpty(name, nameof(name)); FileName = RequireNonEmpty(fileName, nameof(fileName));
Type = type; Type = type;
Sha512Hash = RequireNonEmpty(sha512Hash, nameof(sha512Hash)); Sha512Hash = RequireSha512Hash(sha512Hash, nameof(sha512Hash));
ModrinthId = NormalizeOptional(modrinthId); Size = RequirePositiveSize(size, nameof(size));
ModrinthVersionId = NormalizeOptional(modrinthVersionId);
} }
private static string RequireNonEmpty(string value, string paramName) private static string RequireNonEmpty(string value, string paramName)
@@ -36,9 +33,29 @@ namespace AlayaCore.Models.Manifests
return value; return value;
} }
private static string? NormalizeOptional(string? value) private static string RequireSha512Hash(string value, string paramName)
{ {
return string.IsNullOrWhiteSpace(value) ? null : value; string normalized = RequireNonEmpty(value, paramName)
.Trim()
.Replace("-", string.Empty)
.ToLowerInvariant();
if (normalized.Length != 128)
{
throw new ArgumentException("SHA-512 hash must be 128 hexadecimal characters long.", paramName);
}
return normalized;
}
private static long RequirePositiveSize(long value, string paramName)
{
if (value <= 0)
{
throw new ArgumentOutOfRangeException(paramName, "Size must be greater than zero.");
}
return value;
} }
} }
} }

View File

@@ -1,19 +0,0 @@
using System;
using AlayaCore.Models.Manifests;
namespace AlayaCore.Models
{
public class ModrinthModInfoModel
{
public ModrinthModInfoModel(Uri modUrl, long size)
{
ModUrl = modUrl;
Size = size;
}
public Uri ModUrl { get; set; }
public long Size { get; set; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,381 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Progress;
using AlayaCore.Models.Results;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services
{
public sealed class HttpDownloadService : IDownloadService
{
private const int BUFFER_SIZE = 81920;
private readonly IHttpClient _httpClient;
private readonly IRetryPolicy _retryPolicy;
private readonly ILogger<HttpDownloadService> _logger;
public HttpDownloadService(
IHttpClient httpClient,
IRetryPolicy retryPolicy,
ILogger<HttpDownloadService> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DownloadResult> DownloadFileAsync(
Uri sourceUri,
string destinationPath,
string sha512Hash,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
if (sourceUri == null)
{
throw new ArgumentNullException(nameof(sourceUri));
}
if (!sourceUri.IsAbsoluteUri)
{
throw new ArgumentException("Source URI must be absolute.", nameof(sourceUri));
}
if (string.IsNullOrWhiteSpace(destinationPath))
{
throw new ArgumentException("Destination path cannot be null, empty, or whitespace.", nameof(destinationPath));
}
string normalizedExpectedHash = NormalizeHash(sha512Hash);
cancellationToken.ThrowIfCancellationRequested();
string fileName = Path.GetFileName(destinationPath);
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("Destination path must include a file name.", nameof(destinationPath));
}
_logger.LogInformation(
"Starting download workflow for {FileName} from {SourceUri} to {DestinationPath}.",
fileName,
sourceUri,
destinationPath);
EnsureDestinationDirectoryExists(destinationPath);
if (File.Exists(destinationPath) && VerifyFileHash(destinationPath, normalizedExpectedHash))
{
long existingLength = new FileInfo(destinationPath).Length;
_logger.LogInformation(
"Skipped download for {FileName} because the destination file already exists and passed SHA-512 verification.",
fileName);
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
bytesDownloaded: 0,
totalBytes: existingLength,
bytesPerSecond: null,
statusMessage: "File already present and valid."));
return new DownloadResult(
destinationPath,
DownloadOutcome.SkippedAlreadyValid,
hashVerified: true,
bytesDownloaded: 0);
}
bool destinationExisted = File.Exists(destinationPath);
string tempFilePath = destinationPath + ".download";
if (destinationExisted)
{
_logger.LogInformation(
"Destination file for {FileName} already existed but was not valid. A replacement download will be attempted.",
fileName);
}
DeleteFileIfExists(tempFilePath);
try
{
DownloadResult result = await _retryPolicy.ExecuteAsync(
async token =>
{
DeleteFileIfExists(tempFilePath);
using HttpResponseMessage response = await _httpClient.GetAsync(
sourceUri,
HttpCompletionOption.ResponseHeadersRead,
token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
long? totalBytes = response.Content.Headers.ContentLength;
long bytesDownloaded = 0;
_logger.LogInformation(
"Download response received for {FileName}. Content-Length: {TotalBytes}.",
fileName,
totalBytes);
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
bytesDownloaded: 0,
totalBytes: totalBytes,
bytesPerSecond: null,
statusMessage: "Starting download..."));
await using Stream responseStream = await response.Content
.ReadAsStreamAsync()
.ConfigureAwait(false);
await using FileStream fileStream = new FileStream(
tempFilePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
BUFFER_SIZE,
useAsync: true);
using SHA512 sha512 = SHA512.Create();
byte[] buffer = new byte[BUFFER_SIZE];
Stopwatch stopwatch = Stopwatch.StartNew();
_logger.LogDebug(
"Streaming download content for {FileName} into temporary file {TempFilePath}.",
fileName,
tempFilePath);
while (true)
{
int bytesRead = await responseStream.ReadAsync(
buffer,
0,
buffer.Length,
token).ConfigureAwait(false);
if (bytesRead == 0)
{
break;
}
await fileStream.WriteAsync(
buffer,
0,
bytesRead,
token).ConfigureAwait(false);
sha512.TransformBlock(buffer, 0, bytesRead, null, 0);
bytesDownloaded += bytesRead;
double? bytesPerSecond = null;
if (stopwatch.Elapsed.TotalSeconds > 0d)
{
bytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds;
}
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
bytesDownloaded: bytesDownloaded,
totalBytes: totalBytes,
bytesPerSecond: bytesPerSecond,
statusMessage: "Downloading file..."));
}
sha512.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
await fileStream.FlushAsync(token).ConfigureAwait(false);
string actualHash = ConvertToLowerHex(sha512.Hash);
if (!string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"Hash verification failed for downloaded file {FileName}. Expected SHA-512: {ExpectedHash}. Actual SHA-512: {ActualHash}.",
fileName,
normalizedExpectedHash,
actualHash);
throw new InvalidDataException(
$"Downloaded file hash mismatch. Expected '{normalizedExpectedHash}', got '{actualHash}'.");
}
_logger.LogInformation(
"Hash verification succeeded for {FileName}. Replacing destination file with downloaded content.",
fileName);
ReplaceDestinationFile(tempFilePath, destinationPath);
double? finalBytesPerSecond = null;
if (stopwatch.Elapsed.TotalSeconds > 0d)
{
finalBytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds;
}
progress?.Report(new DownloadProgress(
fileName: fileName,
destinationPath: destinationPath,
bytesDownloaded: bytesDownloaded,
totalBytes: totalBytes ?? bytesDownloaded,
bytesPerSecond: finalBytesPerSecond,
statusMessage: "Download complete."));
DownloadOutcome outcome = destinationExisted
? DownloadOutcome.ReplacedInvalid
: DownloadOutcome.Downloaded;
_logger.LogInformation(
"Download completed successfully for {FileName}. Outcome: {Outcome}. Bytes downloaded: {BytesDownloaded}.",
fileName,
outcome,
bytesDownloaded);
return new DownloadResult(
destinationPath,
outcome,
hashVerified: true,
bytesDownloaded: bytesDownloaded);
},
$"download:{fileName}",
cancellationToken).ConfigureAwait(false);
return result;
}
catch (OperationCanceledException)
{
_logger.LogWarning(
"Download for {FileName} was cancelled. Cleaning up temporary file {TempFilePath}.",
fileName,
tempFilePath);
DeleteFileIfExists(tempFilePath);
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Download failed for {FileName}. Cleaning up temporary file {TempFilePath}.",
fileName,
tempFilePath);
DeleteFileIfExists(tempFilePath);
throw;
}
}
public bool VerifyFileHash(string filePath, string sha512Hash)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("File path cannot be null, empty, or whitespace.", nameof(filePath));
}
string normalizedExpectedHash = NormalizeHash(sha512Hash);
if (!File.Exists(filePath))
{
_logger.LogDebug("Hash verification skipped because file does not exist at {FilePath}.", filePath);
return false;
}
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using SHA512 sha512 = SHA512.Create();
byte[] hashBytes = sha512.ComputeHash(fileStream);
string actualHash = ConvertToLowerHex(hashBytes);
bool matches = string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase);
if (matches)
{
_logger.LogDebug("SHA-512 verification succeeded for {FilePath}.", filePath);
}
else
{
_logger.LogWarning(
"SHA-512 verification failed for {FilePath}. Expected SHA-512: {ExpectedHash}. Actual SHA-512: {ActualHash}.",
filePath,
normalizedExpectedHash,
actualHash);
}
return matches;
}
private static void EnsureDestinationDirectoryExists(string destinationPath)
{
string? directoryPath = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
}
private static void ReplaceDestinationFile(string sourcePath, string destinationPath)
{
DeleteFileIfExists(destinationPath);
File.Move(sourcePath, destinationPath);
}
private static void DeleteFileIfExists(string path)
{
if (File.Exists(path))
{
File.Delete(path);
}
}
private static string NormalizeHash(string hash)
{
if (string.IsNullOrWhiteSpace(hash))
{
throw new ArgumentException("SHA-512 hash cannot be null, empty, or whitespace.", nameof(hash));
}
string normalized = hash.Trim().Replace("-", string.Empty).ToLowerInvariant();
if (normalized.Length != 128)
{
throw new ArgumentException("SHA-512 hash must be 128 hexadecimal characters long.", nameof(hash));
}
foreach (char c in normalized)
{
bool isHex =
(c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f');
if (!isHex)
{
throw new ArgumentException("SHA-512 hash contains invalid characters.", nameof(hash));
}
}
return normalized;
}
private static string ConvertToLowerHex(byte[]? hashBytes)
{
if (hashBytes == null || hashBytes.Length == 0)
{
throw new InvalidOperationException("Hash computation returned no data.");
}
return BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant();
}
}
}

View File

@@ -1,31 +1,45 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation; using AlayaCore.Installation;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.Utilities.Enums;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
namespace AlayaCore.Services namespace AlayaCore.Services
{ {
public sealed class InstallationStateService : IInstallStateService public sealed class InstallationStateService : IInstallStateService
{ {
private const string JavaRuntimeFolderName = "java-runtime-epsilon"; private const string VERSIONS_FOLDER_NAME = "versions";
private readonly IFileStore _fileStore;
private readonly IManifestService _manifestService; private readonly IManifestService _manifestService;
private readonly ILogger<InstallationStateService> _logger;
public InstallationStateService(IManifestService manifestService) public InstallationStateService(
IFileStore fileStore,
IManifestService manifestService,
ILogger<InstallationStateService> logger)
{ {
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService)); _manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task<InstallEnvironment> GetCurrentEnvironmentAsync(CancellationToken cancellationToken = default) public async Task<InstallEnvironment> GetCurrentEnvironmentAsync(CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Building current installation environment state.");
OSPlatform platform = GetCurrentPlatform(); OSPlatform platform = GetCurrentPlatform();
bool javaInstalled = TryGetJavaPath(out string? javaPath); bool javaInstalled = TryGetJavaPath(out string? javaPath);
@@ -33,30 +47,42 @@ namespace AlayaCore.Services
if (javaInstalled) if (javaInstalled)
{ {
_logger.LogDebug("Java runtime was detected at {JavaPath}. Attempting to read Java version.", javaPath);
javaVersion = GetJavaVersion(javaPath!); javaVersion = GetJavaVersion(javaPath!);
} }
else
{
_logger.LogInformation("No managed Java runtime was detected.");
}
bool minecraftInstalled = IsMinecraftInstalled(); InstalledVersionState versionState = GetInstalledVersionState();
string? minecraftVersion = null;
bool neoforgeInstalled = IsNeoforgeInstalled();
string? neoforgeVersion = null;
InstalledModsManifestModel installedModsManifest = InstalledModsManifestModel installedModsManifest =
await _manifestService.GetInstalledModsManifestAsync(cancellationToken).ConfigureAwait(false) await _manifestService.GetInstalledModsManifestAsync(cancellationToken).ConfigureAwait(false);
?? new InstalledModsManifestModel();
return new InstallEnvironment( InstallEnvironment environment = new InstallEnvironment(
osPlatform: platform, osPlatform: platform,
javaInstalled: javaInstalled, javaInstalled: javaInstalled,
javaPath: javaPath, javaPath: javaPath,
javaVersion: javaVersion, javaVersion: javaVersion,
minecraftInstalled: minecraftInstalled, minecraftInstalled: !string.IsNullOrWhiteSpace(versionState.MinecraftVersion),
minecraftVersion: minecraftVersion, minecraftVersion: versionState.MinecraftVersion,
neoforgedInstalled: neoforgeInstalled, neoforgedInstalled: !string.IsNullOrWhiteSpace(versionState.NeoForgeVersion),
neoforgedVersion: neoforgeVersion, neoforgedVersion: versionState.NeoForgeVersion,
installedModsManifest: installedModsManifest); installedModsManifest: installedModsManifest);
_logger.LogInformation(
"Installation environment resolved. Platform: {Platform}, JavaInstalled: {JavaInstalled}, JavaVersion: {JavaVersion}, MinecraftInstalled: {MinecraftInstalled}, MinecraftVersion: {MinecraftVersion}, NeoForgeInstalled: {NeoForgeInstalled}, NeoForgeVersion: {NeoForgeVersion}, InstalledModsCount: {InstalledModsCount}",
platform,
environment.JavaInstalled,
environment.JavaVersion,
environment.MinecraftInstalled,
environment.MinecraftVersion,
environment.NeoforgedInstalled,
environment.NeoforgedVersion,
environment.InstalledModsManifest.Mods.Count);
return environment;
} }
private static OSPlatform GetCurrentPlatform() private static OSPlatform GetCurrentPlatform()
@@ -79,7 +105,7 @@ namespace AlayaCore.Services
throw new PlatformNotSupportedException("The current operating system is not supported."); throw new PlatformNotSupportedException("The current operating system is not supported.");
} }
private static string? GetJavaVersion(string javaPath) private string? GetJavaVersion(string javaPath)
{ {
if (string.IsNullOrWhiteSpace(javaPath)) if (string.IsNullOrWhiteSpace(javaPath))
{ {
@@ -91,6 +117,8 @@ namespace AlayaCore.Services
throw new FileNotFoundException("Java executable was not found.", javaPath); throw new FileNotFoundException("Java executable was not found.", javaPath);
} }
_logger.LogDebug("Reading Java version from executable at {JavaPath}.", javaPath);
using var process = new Process using var process = new Process
{ {
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
@@ -111,10 +139,26 @@ namespace AlayaCore.Services
if (process.ExitCode != 0 && string.IsNullOrWhiteSpace(standardError)) if (process.ExitCode != 0 && string.IsNullOrWhiteSpace(standardError))
{ {
_logger.LogWarning(
"Java version check for {JavaPath} exited with code {ExitCode} and produced no version output.",
javaPath,
process.ExitCode);
return null; return null;
} }
return ParseJavaVersion(standardError); string? version = ParseJavaVersion(standardError);
if (string.IsNullOrWhiteSpace(version))
{
_logger.LogWarning("Java version output from {JavaPath} could not be parsed.", javaPath);
}
else
{
_logger.LogDebug("Parsed Java version {JavaVersion} from {JavaPath}.", version, javaPath);
}
return version;
} }
private static string? ParseJavaVersion(string processOutput) private static string? ParseJavaVersion(string processOutput)
@@ -131,37 +175,188 @@ namespace AlayaCore.Services
: null; : null;
} }
private static bool TryGetJavaPath(out string? javaPath) private bool TryGetJavaPath(out string? javaPath)
{ {
string executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) string executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "java.exe" ? "javaw.exe"
: "java"; : "java";
string fullPath = Path.Combine( string runtimePath = _fileStore.Get(FolderLocation.JavaRuntime);
AppContext.BaseDirectory, string fullPath = Path.Combine(runtimePath, "bin", executableName);
"Java",
JavaRuntimeFolderName,
"bin",
executableName);
if (!File.Exists(fullPath)) if (!File.Exists(fullPath))
{ {
_logger.LogDebug("Managed Java executable was not found at {JavaPath}.", fullPath);
javaPath = null; javaPath = null;
return false; return false;
} }
_logger.LogDebug("Managed Java executable was found at {JavaPath}.", fullPath);
javaPath = fullPath; javaPath = fullPath;
return true; return true;
} }
private static bool IsMinecraftInstalled() private InstalledVersionState GetInstalledVersionState()
{ {
return true; string versionsPath = GetVersionsPath();
_logger.LogDebug("Inspecting installed version metadata under {VersionsPath}.", versionsPath);
if (!Directory.Exists(versionsPath))
{
_logger.LogInformation("Versions directory does not exist at {VersionsPath}. No Minecraft or NeoForge installation was detected.", versionsPath);
return InstalledVersionState.Empty();
}
string[] versionDirectories = Directory
.GetDirectories(versionsPath)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (versionDirectories.Length == 0)
{
_logger.LogInformation("Versions directory at {VersionsPath} was empty.", versionsPath);
return InstalledVersionState.Empty();
}
string? minecraftVersion = null;
string? neoForgeVersion = null;
foreach (string versionDirectory in versionDirectories)
{
string? versionFolderName = Path.GetFileName(versionDirectory);
if (string.IsNullOrWhiteSpace(versionFolderName))
{
_logger.LogDebug("Skipping version directory with an invalid folder name: {VersionDirectory}.", versionDirectory);
continue;
}
string versionJsonPath = Path.Combine(versionDirectory, $"{versionFolderName}.json");
if (!File.Exists(versionJsonPath))
{
_logger.LogDebug("Skipping version directory {VersionDirectory} because version metadata file {VersionJsonPath} was not found.", versionDirectory, versionJsonPath);
continue;
}
if (!TryLoadJson(versionJsonPath, out JObject? versionJson))
{
_logger.LogWarning("Skipping version metadata file {VersionJsonPath} because it could not be read or parsed.", versionJsonPath);
continue;
}
string? id = versionJson.Value<string>("id");
string? inheritsFrom = versionJson.Value<string>("inheritsFrom");
if (string.IsNullOrWhiteSpace(id))
{
_logger.LogDebug("Skipping version metadata file {VersionJsonPath} because it did not contain a valid id.", versionJsonPath);
continue;
}
if (IsNeoForgeVersion(id, inheritsFrom))
{
neoForgeVersion ??= id;
if (string.IsNullOrWhiteSpace(minecraftVersion) && !string.IsNullOrWhiteSpace(inheritsFrom))
{
minecraftVersion = inheritsFrom;
}
_logger.LogDebug(
"Detected NeoForge version metadata. Id: {NeoForgeVersion}, InheritsFrom: {MinecraftVersion}",
id,
inheritsFrom);
continue;
}
minecraftVersion ??= id;
_logger.LogDebug("Detected Minecraft version metadata. Id: {MinecraftVersion}", id);
}
_logger.LogInformation(
"Installed version state resolved. MinecraftVersion: {MinecraftVersion}, NeoForgeVersion: {NeoForgeVersion}",
minecraftVersion,
neoForgeVersion);
return new InstalledVersionState(minecraftVersion, neoForgeVersion);
} }
private static bool IsNeoforgeInstalled() private string GetVersionsPath()
{ {
return true; string versionsPath = Path.Combine(_fileStore.Get(FolderLocation.Game), VERSIONS_FOLDER_NAME);
_logger.LogDebug("Resolved versions path to {VersionsPath}.", versionsPath);
return versionsPath;
}
private static bool IsNeoForgeVersion(string? id, string? inheritsFrom)
{
return ContainsNeoForgeMarker(id) || ContainsNeoForgeMarker(inheritsFrom);
}
private static bool ContainsNeoForgeMarker(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
return value.Contains("neoforge", StringComparison.OrdinalIgnoreCase) ||
value.Contains("neoforged", StringComparison.OrdinalIgnoreCase);
}
private bool TryLoadJson(string path, out JObject? jsonObject)
{
jsonObject = null;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return false;
}
try
{
string json = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogWarning("JSON file at {Path} was empty.", path);
return false;
}
jsonObject = JObject.Parse(json);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load or parse JSON file at {Path}.", path);
return false;
}
}
private sealed class InstalledVersionState
{
public string? MinecraftVersion { get; }
public string? NeoForgeVersion { get; }
public InstalledVersionState(string? minecraftVersion, string? neoForgeVersion)
{
MinecraftVersion = Normalize(minecraftVersion);
NeoForgeVersion = Normalize(neoForgeVersion);
}
public static InstalledVersionState Empty()
{
return new InstalledVersionState(null, null);
}
private static string? Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
} }
} }
} }

View File

@@ -1,187 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models.Manifests;
namespace AlayaCore.Services
{
public sealed class JavaService : IJavaService
{
private const string DownloadFileName = "java-runtime.download";
private const string JavaInstallFolderName = "Java";
private const string JavaArchiveHashPlaceholder = "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, JavaInstallFolderName);
}
private static string GetJavaDownloadPath()
{
return Path.Combine(AppContext.BaseDirectory, "Temp", DownloadFileName);
}
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,
JavaArchiveHashPlaceholder,
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,104 +6,179 @@ using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Models.Configuration; using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using Microsoft.Extensions.Logging;
namespace AlayaCore.Services namespace AlayaCore.Services
{ {
public sealed class LauncherUpdateService : IUpdateService public sealed class LauncherUpdateService : IUpdateService
{ {
private readonly IManifestService _manifestService; private readonly IManifestService _manifestService;
private readonly UpdateServiceOptions _options; private readonly LauncherUpdateServiceOptions _options;
private readonly ILogger<LauncherUpdateService> _logger;
public LauncherUpdateService( public LauncherUpdateService(
IManifestService manifestService, IManifestService manifestService,
UpdateServiceOptions options) LauncherUpdateServiceOptions options,
ILogger<LauncherUpdateService> logger)
{ {
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService)); _manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task<bool> DoesLauncherNeedUpdating(CancellationToken cancellationToken = default) public async Task<bool> DoesLauncherNeedUpdating(CancellationToken cancellationToken = default)
{ {
if (_options.ForceUpdate) if (_options.ForceUpdate)
{ {
_logger.LogWarning("Launcher update check is being forced by configuration.");
return true; return true;
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Checking whether the launcher requires an update.");
LauncherManifestModel? localManifest = await _manifestService LauncherManifestModel? localManifest = await _manifestService
.GetLocalLauncherManifestAsync(cancellationToken) .GetLocalLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (localManifest == null) if (localManifest == null)
{ {
_logger.LogInformation("No local launcher manifest was found. The launcher will be treated as requiring an update.");
return true;
}
if (localManifest.Version == null)
{
_logger.LogWarning("Local launcher manifest did not contain a valid version. The launcher will be treated as requiring an update.");
return true; return true;
} }
if (string.IsNullOrWhiteSpace(localManifest.Sha512Hash)) if (string.IsNullOrWhiteSpace(localManifest.Sha512Hash))
{ {
_logger.LogWarning("Local launcher manifest did not contain a valid SHA-512 hash. The launcher will be treated as requiring an update.");
return true; return true;
} }
string remoteHash = await _manifestService LauncherManifestModel remoteManifest = await _manifestService
.GetRemoteLauncherManifestHashAsync(cancellationToken) .GetRemoteLauncherManifestAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(remoteHash)) if (remoteManifest == null)
{ {
_logger.LogError("Remote launcher manifest could not be loaded.");
throw new InvalidOperationException("Remote launcher manifest could not be loaded.");
}
if (remoteManifest.Version == null)
{
_logger.LogError("Remote launcher manifest did not contain a valid version.");
throw new InvalidOperationException("Remote launcher manifest returned an invalid version.");
}
if (string.IsNullOrWhiteSpace(remoteManifest.Sha512Hash))
{
_logger.LogError("Remote launcher manifest did not contain a valid SHA-512 hash.");
throw new InvalidOperationException("Remote launcher manifest returned an invalid SHA-512 hash."); throw new InvalidOperationException("Remote launcher manifest returned an invalid SHA-512 hash.");
} }
return !string.Equals( bool versionMismatch = localManifest.Version != remoteManifest.Version;
bool hashMismatch = !string.Equals(
localManifest.Sha512Hash.Trim(), localManifest.Sha512Hash.Trim(),
remoteHash.Trim(), remoteManifest.Sha512Hash.Trim(),
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
_logger.LogInformation(
"Launcher update check complete. LocalVersion: {LocalVersion}, RemoteVersion: {RemoteVersion}, VersionMismatch: {VersionMismatch}, HashMismatch: {HashMismatch}",
localManifest.Version,
remoteManifest.Version,
versionMismatch,
hashMismatch);
return versionMismatch || hashMismatch;
} }
public Task LaunchUpdaterAsync(LauncherManifestModel newManifest, CancellationToken cancellationToken = default) public Task LaunchUpdaterAsync(
LauncherManifestModel newManifest,
CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (newManifest == null)
{
throw new ArgumentNullException(nameof(newManifest));
}
if (newManifest.DownloadUri == null)
{
_logger.LogError("Updater launch failed because the launcher manifest did not contain a download URI.");
throw new InvalidOperationException("Launcher manifest does not contain a download URI.");
}
if (!newManifest.DownloadUri.IsAbsoluteUri)
{
_logger.LogError("Updater launch failed because the launcher download URI was not absolute. DownloadUri: {DownloadUri}", newManifest.DownloadUri);
throw new InvalidOperationException("Launcher download URI must be absolute.");
}
string updaterPath = _options.AlayaUpdaterPath; string updaterPath = _options.AlayaUpdaterPath;
if (string.IsNullOrWhiteSpace(updaterPath)) if (string.IsNullOrWhiteSpace(updaterPath))
{ {
_logger.LogError("Updater launch failed because the updater path was not configured.");
throw new InvalidOperationException("Updater path is not configured."); throw new InvalidOperationException("Updater path is not configured.");
} }
if (!Path.IsPathFullyQualified(updaterPath)) if (!Path.IsPathFullyQualified(updaterPath))
{ {
_logger.LogError("Updater launch failed because the updater path was not absolute. UpdaterPath: {UpdaterPath}", updaterPath);
throw new InvalidOperationException("Updater path must be absolute."); throw new InvalidOperationException("Updater path must be absolute.");
} }
if (!File.Exists(updaterPath)) if (!File.Exists(updaterPath))
{ {
_logger.LogError("Updater launch failed because the updater executable was not found at {UpdaterPath}.", updaterPath);
throw new FileNotFoundException("Alaya updater program was not found.", updaterPath); throw new FileNotFoundException("Alaya updater program was not found.", updaterPath);
} }
string workingDirectory = Path.GetDirectoryName(updaterPath) string workingDirectory = Path.GetDirectoryName(updaterPath)
?? throw new InvalidOperationException("Updater working directory could not be resolved."); ?? throw new InvalidOperationException("Updater working directory could not be resolved.");
string arguments = $"-url \"{newManifest.DownloadUri.AbsoluteUri}\"";
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
FileName = updaterPath, FileName = updaterPath,
WorkingDirectory = workingDirectory, WorkingDirectory = workingDirectory,
Arguments = $@"-url {newManifest.DownloadUri}", Arguments = arguments,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true CreateNoWindow = true
}; };
_logger.LogInformation(
"Launching updater process. UpdaterPath: {UpdaterPath}, WorkingDirectory: {WorkingDirectory}, DownloadUri: {DownloadUri}",
updaterPath,
workingDirectory,
newManifest.DownloadUri.AbsoluteUri);
try try
{ {
Process? process = Process.Start(startInfo); Process? process = Process.Start(startInfo);
if (process == null) if (process == null)
{ {
_logger.LogError("Updater process start returned null for executable {UpdaterPath}.", updaterPath);
throw new InvalidOperationException("Failed to start updater process."); throw new InvalidOperationException("Failed to start updater process.");
} }
_logger.LogInformation(
"Updater process launched successfully. ProcessId: {ProcessId}, UpdaterPath: {UpdaterPath}",
process.Id,
updaterPath);
} }
catch (Exception ex) when (!(ex is OperationCanceledException)) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
_logger.LogError(ex, "Failed to launch updater process from {UpdaterPath}.", updaterPath);
throw new InvalidOperationException("Failed to launch updater process.", ex); throw new InvalidOperationException("Failed to launch updater process.", ex);
} }

View File

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

View File

@@ -5,13 +5,19 @@ using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Clients; using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation; using AlayaCore.Installation;
using AlayaCore.Models; using AlayaCore.Models;
using AlayaCore.Models.Configuration; using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests; using AlayaCore.Models.Manifests;
using AlayaCore.Models.Manifests.DTO;
using AlayaCore.Models.Progress;
using AlayaCore.Utilities.Enums; using AlayaCore.Utilities.Enums;
using AlayaCore.Utilities.Extensions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -19,25 +25,35 @@ namespace AlayaCore.Services
{ {
public sealed class ModService : IModService public sealed class ModService : IModService
{ {
private const string InstalledModsManifestFileName = "InstalledModsManifest.json"; private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
private readonly IDownloadService _downloadService; private readonly IDownloadService _downloadService;
private readonly ModrinthConnectionOptions _options; private readonly ModrinthConnectionOptions _options;
private readonly IHttpClient _httpClient; private readonly IHttpClient _httpClient;
private readonly IFileStore _fileStore;
private readonly IRetryPolicy _retryPolicy;
private readonly ILogger<ModService> _logger;
public ModService( public ModService(
IDownloadService downloadService, IDownloadService downloadService,
ModrinthConnectionOptions options, ModrinthConnectionOptions options,
IHttpClient httpClient) IHttpClient httpClient,
IFileStore fileStore,
IRetryPolicy retryPolicy,
ILogger<ModService> logger)
{ {
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService)); _downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
public async Task ProcessModsAsync( public async Task ProcessModsAsync(
ManifestModel manifest, ManifestModel manifest,
InstallEnvironment environment, InstallEnvironment environment,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (manifest == null) if (manifest == null)
@@ -52,79 +68,105 @@ namespace AlayaCore.Services
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<InstalledModEntry> installedMods = environment.InstalledModsManifest.Mods; IReadOnlyList<ModFileEntry> installedMods = environment.InstalledModsManifest.Mods;
List<ModFileEntry> requiredMods = manifest.Files List<ModFileEntry> requiredMods = manifest.Files
.Where(file => file.Type == FileType.Mod) .Where(file => file.Type == FileType.Mod)
.OrderBy(file => file.FileName, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
await RemoveStaleModsAsync(requiredMods, cancellationToken).ConfigureAwait(false); _logger.LogInformation(
"Starting mod sync. RequiredMods: {RequiredModCount}, InstalledModsManifestEntries: {InstalledModCount}",
requiredMods.Count,
installedMods.Count);
List<ModFileEntry> missingOrOutdatedMods = new List<ModFileEntry>(); RemoveStaleMods(requiredMods, cancellationToken);
List<InstalledModEntry> finalInstalledMods = new List<InstalledModEntry>();
List<ModFileEntry> finalInstalledMods = new List<ModFileEntry>();
foreach (ModFileEntry requiredMod in requiredMods) foreach (ModFileEntry requiredMod in requiredMods)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
InstalledModEntry? installedMod = installedMods.FirstOrDefault( _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)); mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
string destinationPath = GetModDestinationPath(requiredMod); string destinationPath = GetModDestinationPath(requiredMod);
bool isValidInstalledMod =
installedMod != null &&
IsInstalledModUpToDate(installedMod, requiredMod) &&
File.Exists(destinationPath) &&
_downloadService.VerifyFileHash(destinationPath, requiredMod.Sha512Hash);
if (isValidInstalledMod)
{
_logger.LogInformation(
"Mod {FileName} is already installed and valid. Skipping download.",
requiredMod.FileName);
finalInstalledMods.Add(installedMod!);
continue;
}
if (installedMod == null) if (installedMod == null)
{ {
missingOrOutdatedMods.Add(requiredMod); _logger.LogInformation(
continue; "Mod {FileName} is missing locally and will be downloaded.",
requiredMod.FileName);
} }
else
if (!IsInstalledModUpToDate(installedMod, requiredMod))
{ {
missingOrOutdatedMods.Add(requiredMod); _logger.LogWarning(
continue; "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);
} }
if (!File.Exists(destinationPath)) Uri modUri = await ResolveModUrlAsync(requiredMod, cancellationToken).ConfigureAwait(false);
{
missingOrOutdatedMods.Add(requiredMod);
continue;
}
bool hashMatches = _downloadService.VerifyFileHash(destinationPath, requiredMod.Sha512Hash); _logger.LogInformation(
if (!hashMatches) "Downloading mod {FileName} from {ModUri} to {DestinationPath}.",
{ requiredMod.FileName,
missingOrOutdatedMods.Add(requiredMod); modUri,
continue; destinationPath);
}
finalInstalledMods.Add(installedMod);
}
foreach (ModFileEntry mod in missingOrOutdatedMods)
{
cancellationToken.ThrowIfCancellationRequested();
ModrinthModInfoModel model = await ResolveModUrlAsync(mod, cancellationToken).ConfigureAwait(false);
string destinationPath = GetModDestinationPath(mod);
await _downloadService.DownloadFileAsync( await _downloadService.DownloadFileAsync(
model.ModUrl, modUri,
destinationPath, destinationPath,
mod.Sha512Hash, requiredMod.Sha512Hash,
progress,
cancellationToken: cancellationToken).ConfigureAwait(false); cancellationToken: cancellationToken).ConfigureAwait(false);
finalInstalledMods.Add(new InstalledModEntry( _logger.LogInformation(
mod.FileName, "Download completed successfully for mod {FileName}.",
mod.ModrinthVersionId ?? string.Empty, requiredMod.FileName);
mod.Sha512Hash,
model.Size)); finalInstalledMods.Add(new ModFileEntry(
requiredMod.FileName,
requiredMod.Type,
requiredMod.Sha512Hash,
requiredMod.Size));
} }
await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false); await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Mod sync completed successfully. Final installed mod count: {InstalledModCount}",
finalInstalledMods.Count);
} }
private static bool IsInstalledModUpToDate( private static bool IsInstalledModUpToDate(
InstalledModEntry installedMod, ModFileEntry installedMod,
ModFileEntry requiredMod) ModFileEntry requiredMod)
{ {
if (installedMod == null) if (installedMod == null)
@@ -137,27 +179,20 @@ namespace AlayaCore.Services
throw new ArgumentNullException(nameof(requiredMod)); throw new ArgumentNullException(nameof(requiredMod));
} }
if (string.IsNullOrWhiteSpace(requiredMod.ModrinthVersionId))
{
return false;
}
if (string.IsNullOrWhiteSpace(requiredMod.Sha512Hash)) if (string.IsNullOrWhiteSpace(requiredMod.Sha512Hash))
{ {
return false; return false;
} }
return string.Equals( return string.Equals(
installedMod.Version,
requiredMod.ModrinthVersionId,
StringComparison.OrdinalIgnoreCase)
&& string.Equals(
installedMod.Sha512Hash, installedMod.Sha512Hash,
requiredMod.Sha512Hash, requiredMod.Sha512Hash,
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase)
&& installedMod.Size == requiredMod.Size
&& installedMod.Type == requiredMod.Type;
} }
private async Task<ModrinthModInfoModel> ResolveModUrlAsync( private async Task<Uri> ResolveModUrlAsync(
ModFileEntry fileEntry, ModFileEntry fileEntry,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -166,79 +201,169 @@ namespace AlayaCore.Services
throw new ArgumentNullException(nameof(fileEntry)); throw new ArgumentNullException(nameof(fileEntry));
} }
if (string.IsNullOrWhiteSpace(fileEntry.ModrinthId)) if (string.IsNullOrWhiteSpace(fileEntry.Sha512Hash))
{ {
throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a Modrinth ID."); _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.");
} }
if (string.IsNullOrWhiteSpace(fileEntry.ModrinthVersionId)) string versionEndpoint = BuildVersionEndpoint(fileEntry.Sha512Hash);
{
throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a Modrinth version ID.");
}
string versionEndpoint = BuildVersionEndpoint(fileEntry.ModrinthId, fileEntry.ModrinthVersionId); _logger.LogDebug(
"Resolving mod URL for {FileName} using Modrinth endpoint {VersionEndpoint}.",
fileEntry.FileName,
versionEndpoint);
using HttpResponseMessage response = await _httpClient.GetAsync( return await _retryPolicy.ExecuteAsync(
new Uri(versionEndpoint, UriKind.Absolute), async token =>
HttpCompletionOption.ResponseContentRead, {
using HttpResponseMessage response = await _httpClient.GetAsync(
new Uri(versionEndpoint, UriKind.Absolute),
HttpCompletionOption.ResponseContentRead,
token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogError(
"Mod metadata response for {FileName} from {VersionEndpoint} was empty.",
fileEntry.FileName,
versionEndpoint);
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty.");
}
JObject jsonObject = JObject.Parse(json);
JArray? filesArray = jsonObject["files"] as JArray;
if (filesArray == null || filesArray.Count == 0)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain any files.",
fileEntry.FileName);
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files.");
}
JObject? selectedFile = filesArray
.OfType<JObject>()
.FirstOrDefault(file => file.Value<bool?>("primary") == true)
?? filesArray.OfType<JObject>().FirstOrDefault();
if (selectedFile == null)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a usable file entry.",
fileEntry.FileName);
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry.");
}
JObject? hashesObject = selectedFile["hashes"] as JObject;
if (hashesObject == null)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a hashes object.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a hashes object.");
}
string? remoteSha512Hash = hashesObject.Value<string>("sha512");
if (string.IsNullOrWhiteSpace(remoteSha512Hash))
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid SHA-512 hash.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash.");
}
if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"Mod metadata hash mismatch for {FileName}. Remote SHA-512: {RemoteHash}, Required SHA-512: {RequiredHash}",
fileEntry.FileName,
remoteSha512Hash,
fileEntry.Sha512Hash);
throw new InvalidDataException(
$"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash.");
}
long? size = selectedFile.Value<long?>("size");
if (!size.HasValue || size.Value <= 0)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid file size.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
}
if (size.Value != fileEntry.Size)
{
_logger.LogError(
"Mod metadata size mismatch for {FileName}. Remote Size: {RemoteSize}, Required Size: {RequiredSize}",
fileEntry.FileName,
size.Value,
fileEntry.Size);
throw new InvalidDataException(
$"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size.");
}
string? modUrl = selectedFile.Value<string>("url");
if (string.IsNullOrWhiteSpace(modUrl))
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid file URL.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL.");
}
if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
{
_logger.LogError(
"Mod metadata response for {FileName} contained an invalid file URL: {ModUrl}",
fileEntry.FileName,
modUrl);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL.");
}
_logger.LogDebug(
"Resolved download URL for mod {FileName} to {ModUrl}.",
fileEntry.FileName,
result);
return result;
},
$"mod-metadata:{fileEntry.FileName}",
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
throw new InvalidDataException($"The Modrinth version 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 Modrinth version 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 Modrinth version response for '{fileEntry.FileName}' did not contain a usable file entry.");
}
string? modUrl = selectedFile.Value<string>("url");
if (string.IsNullOrWhiteSpace(modUrl))
{
throw new InvalidDataException($"The Modrinth version response for '{fileEntry.FileName}' did not contain a valid file URL.");
}
if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
{
throw new InvalidDataException($"The Modrinth version response for '{fileEntry.FileName}' contained an invalid file URL.");
}
long? size = selectedFile.Value<long?>("size");
if (!size.HasValue || size.Value <= 0)
{
throw new InvalidDataException($"The Modrinth version response for '{fileEntry.FileName}' did not contain a valid file size.");
}
return new ModrinthModInfoModel(result, size.Value);
} }
private string BuildVersionEndpoint(string modrinthId, string modrinthVersionId) private string BuildVersionEndpoint(string sha512Hash)
{ {
string baseUrl = _options.BaseApiUrl?.TrimEnd('/') string baseUrl = _options.BaseApiUrl?.TrimEnd('/')
?? throw new InvalidOperationException("Modrinth base API URL is not configured."); ?? throw new InvalidOperationException("Modrinth base API URL is not configured.");
return $"{baseUrl}/project/{modrinthId}/version/{modrinthVersionId}"; return $"{baseUrl}/version_file/{sha512Hash}";
} }
private static string GetModDestinationPath(ModFileEntry fileEntry) private string GetModDestinationPath(ModFileEntry fileEntry)
{ {
if (fileEntry == null) if (fileEntry == null)
{ {
@@ -251,30 +376,37 @@ namespace AlayaCore.Services
} }
string modsDirectory = GetModsDirectoryPath(); string modsDirectory = GetModsDirectoryPath();
Directory.CreateDirectory(modsDirectory); string destinationPath = Path.Combine(modsDirectory, fileEntry.FileName);
return Path.Combine(modsDirectory, fileEntry.FileName); _logger.LogDebug(
"Resolved destination path for mod {FileName} to {DestinationPath}.",
fileEntry.FileName,
destinationPath);
return destinationPath;
} }
private static string GetModsDirectoryPath() private string GetModsDirectoryPath()
{ {
return Path.Combine(AppContext.BaseDirectory, "Game", "mods"); string modsDirectory = _fileStore.GetOrCreate(FolderLocation.Mods);
_logger.LogDebug("Resolved mods directory to {ModsDirectory}.", modsDirectory);
return modsDirectory;
} }
private static async Task RemoveStaleModsAsync( private void RemoveStaleMods(
IEnumerable<ModFileEntry> requiredMods, IEnumerable<ModFileEntry> requiredMods,
CancellationToken cancellationToken = default) CancellationToken cancellationToken)
{ {
if (requiredMods == null) if (requiredMods == null)
{ {
throw new ArgumentNullException(nameof(requiredMods)); throw new ArgumentNullException(nameof(requiredMods));
} }
cancellationToken.ThrowIfCancellationRequested(); string modsDirectory = _fileStore.Get(FolderLocation.Mods);
string modsDirectory = GetModsDirectoryPath();
if (!Directory.Exists(modsDirectory)) if (!Directory.Exists(modsDirectory))
{ {
_logger.LogDebug("Mods directory {ModsDirectory} does not exist. No stale mods need removal.", modsDirectory);
return; return;
} }
@@ -291,15 +423,18 @@ namespace AlayaCore.Services
if (!requiredFileNames.Contains(fileName)) if (!requiredFileNames.Contains(fileName))
{ {
_logger.LogInformation(
"Removing stale mod file {FileName} at {FilePath}.",
fileName,
filePath);
File.Delete(filePath); File.Delete(filePath);
} }
} }
await Task.CompletedTask;
} }
private static async Task FlushInstalledModsManifestAsync( private async Task FlushInstalledModsManifestAsync(
IEnumerable<InstalledModEntry> installedMods, IEnumerable<ModFileEntry> installedMods,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (installedMods == null) if (installedMods == null)
@@ -307,33 +442,37 @@ namespace AlayaCore.Services
throw new ArgumentNullException(nameof(installedMods)); throw new ArgumentNullException(nameof(installedMods));
} }
List<InstalledModEntry> entries = installedMods.ToList(); List<ModFileEntry> entries = installedMods.ToList();
InstalledModsManifestModel manifest = new InstalledModsManifestModel(entries); InstalledModsManifestModel manifest = new InstalledModsManifestModel(entries);
string manifestsDirectory = Path.Combine(AppContext.BaseDirectory, "Manifests"); string manifestsDirectory = _fileStore.GetOrCreate(FolderLocation.Manifests);
Directory.CreateDirectory(manifestsDirectory);
string manifestPath = Path.Combine(manifestsDirectory, InstalledModsManifestFileName); string manifestPath = Path.Combine(manifestsDirectory, INSTALLED_MODS_MANIFEST_FILE_NAME);
string temporaryManifestPath = manifestPath + ".tmp"; string temporaryManifestPath = manifestPath + ".tmp";
string json = JsonConvert.SerializeObject( InstalledModsManifestDto dto = manifest.ToDto();
new
{ string json = JsonConvert.SerializeObject(dto, Formatting.Indented);
mods = manifest.Mods.Select(mod => new
{ _logger.LogDebug(
fileName = mod.FileName, "Writing installed mods manifest to temporary path {TemporaryManifestPath}. EntryCount: {EntryCount}",
version = mod.Version, temporaryManifestPath,
sha512Hash = mod.Sha512Hash, entries.Count);
size = mod.Size
})
},
Formatting.Indented);
await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false); await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false);
File.Copy(temporaryManifestPath, manifestPath, overwrite: true); if (File.Exists(manifestPath))
File.Delete(temporaryManifestPath); {
_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

@@ -0,0 +1,458 @@
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 readonly IFileStore _fileStore;
private readonly ILogger<SettingsService> _logger;
public LauncherOptions LauncherOptions { get; }
public GameOptions GameOptions { get; }
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(
LauncherOptions.FileName,
LauncherOptions,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Launcher options were saved successfully.");
}
public async Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Loading launcher options from disk.");
LauncherOptions? loadedOptions = await LoadAsync<LauncherOptions>(
LauncherOptions.FileName,
cancellationToken).ConfigureAwait(false);
if (loadedOptions == null)
{
_logger.LogInformation("No launcher options file was found or it was empty. Existing in-memory launcher options will be kept.");
return;
}
LauncherOptions.ForceReinstall = loadedOptions.ForceReinstall;
_logger.LogInformation(
"Launcher options were loaded successfully. ForceReinstall: {ForceReinstall}",
LauncherOptions.ForceReinstall);
}
public async Task SaveGameOptionsAsync(CancellationToken cancellationToken = default)
{
ValidateGameOptions(GameOptions);
_logger.LogDebug(
"Saving game options to disk. LaunchVersion: {LaunchVersion}, MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, Resolution: {ScreenWidth}x{ScreenHeight}, Fullscreen: {Fullscreen}",
GameOptions.LaunchVersion,
GameOptions.MinimumRamMb,
GameOptions.MaximumRamMb,
GameOptions.ScreenWidth,
GameOptions.ScreenHeight,
GameOptions.Fullscreen);
await SaveAsync(
GameOptions.FileName,
GameOptions,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Game options were saved successfully.");
}
public async Task LoadGameOptionsAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Loading game options from disk.");
GameOptions? loadedOptions = await LoadAsync<GameOptions>(
GameOptions.FileName,
cancellationToken).ConfigureAwait(false);
if (loadedOptions == null)
{
_logger.LogInformation("No game options file was found or it was empty. Applying default game options.");
ApplyGameOptions(GameOptions.Default);
return;
}
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) where T : BaseConfig
{
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("File name cannot be null, empty, or whitespace.", nameof(fileName));
}
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
cancellationToken.ThrowIfCancellationRequested();
string fullPath = GetFullPath(fileName);
string? directoryPath = Path.GetDirectoryName(fullPath);
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.");
}
Directory.CreateDirectory(directoryPath);
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) where T : BaseConfig
{
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentException("File name cannot be null, empty, or whitespace.", nameof(fileName));
}
cancellationToken.ThrowIfCancellationRequested();
string fullPath = GetFullPath(fileName);
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
{
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 string GetFullPath(string 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

@@ -0,0 +1,79 @@
namespace AlayaCore.States
{
public enum LaunchState
{
Checking,
Ready,
LauncherNeedsUpdate,
NeedAuthenticating,
InstallMinecraft,
InstallNeoforge,
SyncMods
}
public sealed class LaunchPlan
{
public bool LauncherNeedsUpdate { 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 NeedsUpdating =>
LauncherNeedsUpdate ||
MinecraftNeedsInstallOrUpdate ||
NeoforgeNeedsInstallOrUpdate ||
ModsNeedSync;
public bool NeedsAttention =>
NeedsUpdating || NeedAuthenticating;
public LaunchPlan(
bool launcherNeedsUpdate,
bool minecraftNeedsInstallOrUpdate,
bool neoforgeNeedsInstallOrUpdate,
bool modsNeedSync,
bool needAuthenticating)
{
LauncherNeedsUpdate = launcherNeedsUpdate;
MinecraftNeedsInstallOrUpdate = minecraftNeedsInstallOrUpdate;
NeoforgeNeedsInstallOrUpdate = neoforgeNeedsInstallOrUpdate;
ModsNeedSync = modsNeedSync;
NeedAuthenticating = needAuthenticating;
}
private LaunchState ComputeState()
{
if (LauncherNeedsUpdate)
return LaunchState.LauncherNeedsUpdate;
if (NeedAuthenticating)
return LaunchState.NeedAuthenticating;
if (MinecraftNeedsInstallOrUpdate)
return LaunchState.InstallMinecraft;
if (NeoforgeNeedsInstallOrUpdate)
return LaunchState.InstallNeoforge;
if (ModsNeedSync)
return LaunchState.SyncMods;
return LaunchState.Ready;
}
public static LaunchPlan EmptyReady()
{
return new LaunchPlan(
launcherNeedsUpdate: false,
minecraftNeedsInstallOrUpdate: false,
neoforgeNeedsInstallOrUpdate: false,
modsNeedSync: false,
needAuthenticating: false);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -15,17 +15,17 @@ namespace AlayaCore.Utilities.Extensions
} }
return new InstalledModsManifestModel( return new InstalledModsManifestModel(
dto.InstalledMods?.Select(mod => mod.ToModel()) ?? Array.Empty<InstalledModEntry>()); dto.InstalledMods?.Select(mod => mod.ToModel()) ?? Array.Empty<ModFileEntry>());
} }
public static InstalledModEntry ToModel(this InstalledModEntryDto dto) public static InstalledModsManifestDto ToDto(this InstalledModsManifestModel model)
{ {
if (dto == null) if (model == null)
{ {
throw new ArgumentNullException(nameof(dto)); throw new ArgumentNullException(nameof(model));
} }
return new InstalledModEntry(dto.FileName, dto.Version, dto.Sha512Hash, dto.Size); return new InstalledModsManifestDto(model.Mods.Select(mod => mod.ToDto()).ToList());
} }
public static LauncherManifestModel ToModel(this LauncherManifestDto dto) public static LauncherManifestModel ToModel(this LauncherManifestDto dto)
@@ -47,12 +47,10 @@ namespace AlayaCore.Utilities.Extensions
return new ManifestModel( return new ManifestModel(
dto.AlayaVersion, dto.AlayaVersion,
dto.RequiredJavaVersion,
dto.RequiredJavaUrl,
dto.MinecraftVersion, dto.MinecraftVersion,
dto.MinecraftUrl,
dto.NeoforgedVersion, dto.NeoforgedVersion,
dto.NeoforgedUrl, dto.ServerUrl,
dto.ServerPort,
dto.Files?.Select(file => file.ToModel()) ?? Array.Empty<ModFileEntry>()); dto.Files?.Select(file => file.ToModel()) ?? Array.Empty<ModFileEntry>());
} }
@@ -67,8 +65,7 @@ namespace AlayaCore.Utilities.Extensions
dto.Name, dto.Name,
dto.Type, dto.Type,
dto.Sha512Hash, dto.Sha512Hash,
dto.ModrinthId, dto.Size);
dto.ModrinthVersionId);
} }
public static ManifestDto ToDto(this ManifestModel model) public static ManifestDto ToDto(this ManifestModel model)
@@ -81,12 +78,8 @@ namespace AlayaCore.Utilities.Extensions
return new ManifestDto return new ManifestDto
{ {
AlayaVersion = model.AlayaVersion, AlayaVersion = model.AlayaVersion,
RequiredJavaVersion = model.RequiredJavaVersion,
RequiredJavaUrl = model.RequiredJavaUrl,
MinecraftVersion = model.MinecraftVersion, MinecraftVersion = model.MinecraftVersion,
MinecraftUrl = model.MinecraftUrl,
NeoforgedVersion = model.NeoforgedVersion, NeoforgedVersion = model.NeoforgedVersion,
NeoforgedUrl = model.NeoforgedUrl,
Files = model.Files.Select(file => file.ToDto()).ToList() Files = model.Files.Select(file => file.ToDto()).ToList()
}; };
} }
@@ -103,8 +96,7 @@ namespace AlayaCore.Utilities.Extensions
Name = model.FileName, Name = model.FileName,
Type = model.Type, Type = model.Type,
Sha512Hash = model.Sha512Hash, Sha512Hash = model.Sha512Hash,
ModrinthId = model.ModrinthId, Size = model.Size
ModrinthVersionId = model.ModrinthVersionId
}; };
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
02d72db7e7301cd87568f68f39a003b2484df3c699c3ceed1d9bd04f25f6e3dd c63b3bf6f0fd998ece1d911c58e61eb584840e2e19d535b62f67f54b1510e49e

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1 +1 @@
17752277078247393 17754069520908862

View File

@@ -1 +1 @@
17752277078247393 17754069520908862