Compare commits
4 Commits
8521f695a9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6938635ee4 | |||
| 491a3d420d | |||
| 11863088e4 | |||
| 8a8292d2c3 |
@@ -1,7 +1,10 @@
|
||||
<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>
|
||||
@@ -18,5 +21,6 @@
|
||||
<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_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>
|
||||
@@ -11,7 +11,5 @@ namespace AlayaCore.Abstractions.Interfaces.Clients
|
||||
Uri uri,
|
||||
HttpCompletionOption completionOption,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
HttpClient HttpClient { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,27 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AlayaCore.Models;
|
||||
using AlayaCore.Models.Progress;
|
||||
using AlayaCore.Models.Results;
|
||||
using AlayaCore.States;
|
||||
using CmlLib.Core;
|
||||
using CmlLib.Core.Installers;
|
||||
|
||||
namespace AlayaCore.Abstractions.Interfaces
|
||||
{
|
||||
public interface ILaunchDirector
|
||||
{
|
||||
Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task InstallOrUpdateAsync(CancellationToken cancellationToken = default,
|
||||
EventHandler<InstallerProgressChangedEventArgs>? minecraftProgess = null,
|
||||
EventHandler<ByteProgress>? byteProgress = null,
|
||||
IProgress<InstallerProgressChangedEventArgs>? neoForgeProgress = null,
|
||||
IProgress<ByteProgress>? neoForgeByteProgress = null);
|
||||
|
||||
Task LaunchAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
bool CanRun { get; }
|
||||
bool 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);
|
||||
}
|
||||
}
|
||||
19
AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs
Normal file
19
AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ namespace AlayaCore.Abstractions.Interfaces.Services
|
||||
{
|
||||
public interface IManifestService
|
||||
{
|
||||
Task<ManifestModel> GetCoreManifestAsync(CancellationToken cancellationToken = default);
|
||||
Task<ManifestModel?> GetLocalCoreManifestAsync(CancellationToken cancellationToken = default);
|
||||
Task<ManifestModel> GetAlayaManifestAsync(CancellationToken cancellationToken = default);
|
||||
Task<ManifestModel?> GetLocalAlayaManifestAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<InstalledModsManifestModel> GetInstalledModsManifestAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AlayaCore.Installation;
|
||||
using AlayaCore.Models.Manifests;
|
||||
using AlayaCore.Models.Progress;
|
||||
|
||||
namespace AlayaCore.Abstractions.Interfaces.Services
|
||||
{
|
||||
public interface IModService
|
||||
{
|
||||
Task ProcessModsAsync(ManifestModel manifest, InstallEnvironment environment, CancellationToken cancellationToken = default);
|
||||
Task ProcessModsAsync(
|
||||
ManifestModel manifest,
|
||||
InstallEnvironment environment,
|
||||
IProgress<DownloadProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,12 @@ namespace AlayaCore.Abstractions.Interfaces.Services
|
||||
GameOptions GameOptions { get; }
|
||||
|
||||
Task SetForceReinstallAsync(bool value, CancellationToken cancellationToken = default);
|
||||
|
||||
Task UpdateLaunchVersionAsync(string newVersion, CancellationToken cancellationToken = default);
|
||||
Task SetMinimumRamMbAsync(int minimumRamMb, CancellationToken cancellationToken = default);
|
||||
Task SetMaximumRamMbAsync(int maximumRamMb, CancellationToken cancellationToken = default);
|
||||
Task SetResolutionAsync(int screenWidth, int screenHeight, CancellationToken cancellationToken = default);
|
||||
Task SetFullscreenAsync(bool fullscreen, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default);
|
||||
Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
58
AlayaCore/Clients/DefaultHttpClient.cs
Normal file
58
AlayaCore/Clients/DefaultHttpClient.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
AlayaCore/Errors/LauncherError.cs
Normal file
19
AlayaCore/Errors/LauncherError.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,13 @@ using AlayaCore.Abstractions.Interfaces.Services;
|
||||
using AlayaCore.Installation;
|
||||
using AlayaCore.Models.Configuration;
|
||||
using AlayaCore.Models.Manifests;
|
||||
using AlayaCore.Models.Progress;
|
||||
using AlayaCore.Models.Results;
|
||||
using AlayaCore.States;
|
||||
using AlayaCore.Utilities.Enums;
|
||||
using CmlLib.Core;
|
||||
using CmlLib.Core.Installers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AlayaCore
|
||||
{
|
||||
@@ -26,11 +29,14 @@ namespace AlayaCore
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IAuthService _authService;
|
||||
private readonly LauncherOptions _options;
|
||||
private readonly ILogger<LaunchDirector> _logger;
|
||||
|
||||
public bool CanRun { get; private set; }
|
||||
public bool NeedsUpdating { get; private set; }
|
||||
public LaunchPlan? CurrentPlan { get; private set; }
|
||||
|
||||
public bool IsUpdatingLauncher { get; private set; }
|
||||
|
||||
public LaunchDirector(
|
||||
IManifestService manifestService,
|
||||
IUpdateService updateService,
|
||||
@@ -40,7 +46,8 @@ namespace AlayaCore
|
||||
IGameInstallService gameInstallService,
|
||||
ISettingsService settingsService,
|
||||
IAuthService authService,
|
||||
LauncherOptions options)
|
||||
LauncherOptions options,
|
||||
ILogger<LaunchDirector> logger)
|
||||
{
|
||||
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
|
||||
_updateService = updateService ?? throw new ArgumentNullException(nameof(updateService));
|
||||
@@ -51,18 +58,23 @@ namespace AlayaCore
|
||||
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
|
||||
_authService = authService ?? throw new ArgumentNullException(nameof(authService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<LaunchPlan> EvaluateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogInformation("Evaluating launcher state.");
|
||||
|
||||
bool launcherNeedsUpdate = await _updateService
|
||||
.DoesLauncherNeedUpdating(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (launcherNeedsUpdate)
|
||||
{
|
||||
_logger.LogInformation("Launcher update is required.");
|
||||
|
||||
LaunchPlan launcherUpdatePlan = new LaunchPlan(
|
||||
launcherNeedsUpdate: true,
|
||||
minecraftNeedsInstallOrUpdate: false,
|
||||
@@ -106,25 +118,38 @@ namespace AlayaCore
|
||||
modsNeedSync: modsNeedSync,
|
||||
needAuthenticating: needAuthenticating);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Launcher evaluation completed. State: {State}, NeedAuthenticating: {NeedAuthenticating}, ForceReinstall: {ForceReinstall}, MinecraftNeedsInstallOrUpdate: {MinecraftNeedsInstallOrUpdate}, NeoForgeNeedsInstallOrUpdate: {NeoForgeNeedsInstallOrUpdate}, ModsNeedSync: {ModsNeedSync}",
|
||||
plan.State,
|
||||
needAuthenticating,
|
||||
_options.ForceReinstall,
|
||||
minecraftNeedsInstallOrUpdate,
|
||||
neoforgeNeedsInstallOrUpdate,
|
||||
modsNeedSync);
|
||||
|
||||
ApplyPlan(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
public async Task InstallOrUpdateAsync(
|
||||
public async Task<InstallOrUpdateResult> InstallOrUpdateAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
EventHandler<InstallerProgressChangedEventArgs>? minecraftProgress = null,
|
||||
EventHandler<ByteProgress>? byteProgress = null,
|
||||
IProgress<InstallerProgressChangedEventArgs>? neoForgeProgress = null,
|
||||
IProgress<ByteProgress>? neoForgeByteProgress = null)
|
||||
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;
|
||||
|
||||
@@ -132,6 +157,12 @@ namespace AlayaCore
|
||||
{
|
||||
case LaunchState.LauncherNeedsUpdate:
|
||||
{
|
||||
IsUpdatingLauncher = true;
|
||||
|
||||
_logger.LogWarning("Launcher updater handoff is beginning.");
|
||||
|
||||
ReportProgress(progress, LaunchState.LauncherNeedsUpdate, "Launching updater...");
|
||||
|
||||
LauncherManifestModel launcherManifest = await _manifestService
|
||||
.GetLauncherManifestAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -141,77 +172,115 @@ namespace AlayaCore
|
||||
.ConfigureAwait(false);
|
||||
|
||||
ApplyPlan(plan);
|
||||
return;
|
||||
|
||||
_logger.LogInformation("Updater launched successfully. Returning UpdaterLaunched result.");
|
||||
return InstallOrUpdateResult.UpdaterLaunched;
|
||||
}
|
||||
|
||||
case LaunchState.NeedAuthenticating:
|
||||
{
|
||||
_logger.LogInformation("Authentication is required.");
|
||||
|
||||
ReportProgress(progress, LaunchState.NeedAuthenticating, "Signing in...");
|
||||
|
||||
await _authService
|
||||
.AuthenticateAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Authentication completed successfully.");
|
||||
break;
|
||||
}
|
||||
|
||||
case LaunchState.InstallMinecraft:
|
||||
{
|
||||
_logger.LogInformation("Minecraft installation or repair is required.");
|
||||
|
||||
ReportProgress(progress, LaunchState.InstallMinecraft, "Preparing Minecraft installation...");
|
||||
|
||||
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
|
||||
environment = await _installStateService
|
||||
.GetCurrentEnvironmentAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
EventHandler<InstallerProgressChangedEventArgs>? minecraftFileProgress =
|
||||
CreateMinecraftFileProgressHandler(progress);
|
||||
|
||||
EventHandler<ByteProgress>? minecraftByteProgress =
|
||||
CreateMinecraftByteProgressHandler(progress);
|
||||
|
||||
await _gameInstallService
|
||||
.EnsureMinecraftInstalledAsync(
|
||||
manifest,
|
||||
environment,
|
||||
cancellationToken,
|
||||
minecraftProgress,
|
||||
byteProgress)
|
||||
minecraftFileProgress,
|
||||
minecraftByteProgress)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Minecraft installation or repair step completed.");
|
||||
break;
|
||||
}
|
||||
|
||||
case LaunchState.InstallNeoforge:
|
||||
{
|
||||
_logger.LogInformation("NeoForge installation or repair is required.");
|
||||
|
||||
ReportProgress(progress, LaunchState.InstallNeoforge, "Preparing NeoForge installation...");
|
||||
|
||||
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
|
||||
environment = await _installStateService
|
||||
.GetCurrentEnvironmentAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
IProgress<InstallerProgressChangedEventArgs>? neoForgeFileProgress =
|
||||
CreateNeoForgeFileProgress(progress);
|
||||
|
||||
IProgress<ByteProgress>? neoForgeByteProgress =
|
||||
CreateNeoForgeByteProgress(progress);
|
||||
|
||||
await _gameInstallService
|
||||
.EnsureNeoForgeInstalledAsync(
|
||||
manifest,
|
||||
environment,
|
||||
cancellationToken,
|
||||
neoForgeProgress,
|
||||
neoForgeFileProgress,
|
||||
neoForgeByteProgress)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("NeoForge installation or repair step completed.");
|
||||
break;
|
||||
}
|
||||
|
||||
case LaunchState.SyncMods:
|
||||
{
|
||||
_logger.LogInformation("Mod synchronization is required.");
|
||||
|
||||
ReportProgress(progress, LaunchState.SyncMods, "Checking mod files...");
|
||||
|
||||
manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
|
||||
environment = await _installStateService
|
||||
.GetCurrentEnvironmentAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
IProgress<DownloadProgress>? modProgress = CreateModProgress(progress);
|
||||
|
||||
await _modService
|
||||
.ProcessModsAsync(manifest, environment, cancellationToken)
|
||||
.ProcessModsAsync(manifest, environment, modProgress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Mod synchronization step completed.");
|
||||
break;
|
||||
}
|
||||
|
||||
case LaunchState.Ready:
|
||||
{
|
||||
_logger.LogDebug("Launch state is Ready inside install loop.");
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
_logger.LogError("Unsupported launch state encountered: {LaunchState}.", plan.State);
|
||||
throw new InvalidOperationException($"Unsupported launch state '{plan.State}'.");
|
||||
}
|
||||
}
|
||||
@@ -221,6 +290,8 @@ namespace AlayaCore
|
||||
|
||||
if (_options.ForceReinstall)
|
||||
{
|
||||
_logger.LogInformation("Force reinstall flag was set. Resetting it after successful install/update workflow.");
|
||||
|
||||
await _settingsService
|
||||
.SetForceReinstallAsync(false, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -230,21 +301,34 @@ namespace AlayaCore
|
||||
|
||||
if (!plan.CanRun)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Install or update workflow completed, but launcher is still not runnable. Final state: {LaunchState}.",
|
||||
plan.State);
|
||||
|
||||
throw new InvalidOperationException("Install/update completed, but the launcher is still not in a runnable state.");
|
||||
}
|
||||
|
||||
ReportProgress(progress, LaunchState.Ready, "Launcher is ready.");
|
||||
|
||||
_logger.LogInformation("Install or update workflow completed successfully. Launcher is ready.");
|
||||
return InstallOrUpdateResult.Ready;
|
||||
}
|
||||
|
||||
public async Task LaunchAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogInformation("Launch requested.");
|
||||
|
||||
if (CurrentPlan == null)
|
||||
{
|
||||
_logger.LogDebug("No current launch plan was available. Evaluating launcher state before launch.");
|
||||
await EvaluateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!CanRun)
|
||||
{
|
||||
_logger.LogError("Launch was requested while the launcher was not in a runnable state.");
|
||||
throw new InvalidOperationException("Launcher cannot run because installation or updates are still required.");
|
||||
}
|
||||
|
||||
@@ -254,33 +338,56 @@ namespace AlayaCore
|
||||
|
||||
ManifestModel manifest = await EnsureCurrentManifestAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting game launch. MinecraftVersion: {MinecraftVersion}, NeoForgeVersion: {NeoForgeVersion}",
|
||||
environment.MinecraftVersion,
|
||||
environment.NeoforgedVersion);
|
||||
|
||||
await _gameLaunchService
|
||||
.LaunchAsync(manifest, environment, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Game launch call completed.");
|
||||
}
|
||||
|
||||
private async Task<ManifestModel> EnsureCurrentManifestAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Ensuring current Alaya manifest is available.");
|
||||
|
||||
ManifestModel? localManifest = await _manifestService
|
||||
.GetLocalCoreManifestAsync(cancellationToken)
|
||||
.GetLocalAlayaManifestAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Version remoteVersion = await _manifestService
|
||||
.GetRemoteCoreManifestVersionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (localManifest == null)
|
||||
{
|
||||
_logger.LogInformation("No local Alaya manifest was found. Downloading remote manifest.");
|
||||
}
|
||||
else if (localManifest.AlayaVersion != remoteVersion)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Local Alaya manifest version {LocalVersion} differs from remote version {RemoteVersion}. Downloading updated manifest.",
|
||||
localManifest.AlayaVersion,
|
||||
remoteVersion);
|
||||
}
|
||||
|
||||
if (localManifest == null || localManifest.AlayaVersion != remoteVersion)
|
||||
{
|
||||
localManifest = await _manifestService
|
||||
.GetCoreManifestAsync(cancellationToken)
|
||||
.GetAlayaManifestAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (localManifest == null)
|
||||
{
|
||||
_logger.LogError("Local Alaya manifest was still unavailable after refresh.");
|
||||
throw new FileNotFoundException("Local core manifest was not found after refresh.");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Current Alaya manifest is available. Version: {AlayaVersion}", localManifest.AlayaVersion);
|
||||
return localManifest;
|
||||
}
|
||||
|
||||
@@ -336,6 +443,129 @@ namespace AlayaCore
|
||||
CurrentPlan = plan ?? throw new ArgumentNullException(nameof(plan));
|
||||
NeedsUpdating = plan.NeedsUpdating;
|
||||
CanRun = plan.CanRun;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Applied launch plan. State: {LaunchState}, CanRun: {CanRun}, NeedsUpdating: {NeedsUpdating}, IsUpdatingLauncher: {IsUpdatingLauncher}",
|
||||
plan.State,
|
||||
CanRun,
|
||||
NeedsUpdating,
|
||||
IsUpdatingLauncher);
|
||||
}
|
||||
|
||||
private static void ReportProgress(
|
||||
IProgress<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));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,28 @@ using AlayaCore.Abstractions.Configuration;
|
||||
|
||||
namespace AlayaCore.Models.Configuration
|
||||
{
|
||||
public class GameOptions : BaseConfig
|
||||
public sealed class GameOptions : BaseConfig
|
||||
{
|
||||
public override string FileName => "Game.json";
|
||||
|
||||
public string? LaunchVersion { get; set; }
|
||||
public string? LaunchVersion { get; set; } = null;
|
||||
|
||||
public int MinimumRamMB { get; set; }
|
||||
public int MaximumRamMB { get; set; }
|
||||
public int MinimumRamMb { get; set; } = 1024;
|
||||
public int MaximumRamMb { get; set; } = 2048;
|
||||
|
||||
public int ScreenWidth { get; set; }
|
||||
public int ScreenHeight { get; set; }
|
||||
public int ScreenWidth { get; set; } = 1920;
|
||||
public int ScreenHeight { get; set; } = 1080;
|
||||
|
||||
public bool Fullscreen { get; set; }
|
||||
public bool Fullscreen { get; set; } = false;
|
||||
|
||||
public static GameOptions Default { get; } = new GameOptions
|
||||
{
|
||||
LaunchVersion = null,
|
||||
MinimumRamMB = 1024,
|
||||
MaximumRamMB = 2048,
|
||||
MinimumRamMb = 1024,
|
||||
MaximumRamMb = 2048,
|
||||
ScreenWidth = 1920,
|
||||
ScreenHeight = 1080,
|
||||
Fullscreen = false,
|
||||
Fullscreen = false
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace AlayaCore.Models.Configuration
|
||||
{
|
||||
public sealed class LauncherOptions : BaseConfig
|
||||
{
|
||||
public bool ForceReinstall { get; set; }
|
||||
public bool ForceReinstall { get; set; } = false;
|
||||
|
||||
public override string FileName => "Launcher.json";
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AlayaCore.Models.Configuration
|
||||
{
|
||||
public sealed class LauncherUpdateServiceOptions
|
||||
@@ -10,5 +13,8 @@ namespace AlayaCore.Models.Configuration
|
||||
|
||||
public string AlayaUpdaterPath { get; set; }
|
||||
public bool ForceUpdate { get; set; }
|
||||
|
||||
public static LauncherUpdateServiceOptions Default { get; } =
|
||||
new LauncherUpdateServiceOptions(Path.Combine(AppContext.BaseDirectory, "Data", "Updater"), false);
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,23 @@ namespace AlayaCore.Models.Configuration
|
||||
{
|
||||
public sealed class ManifestServiceOptions
|
||||
{
|
||||
public Uri CoreManifestUri { get; }
|
||||
public Uri AlayaManifestUri { get; }
|
||||
public Uri LauncherManifestUri { get; }
|
||||
public string CoreManifestSha512Hash { get; }
|
||||
public string AlayaManifestSha512Hash { get; }
|
||||
public string LauncherManifestSha512Hash { get; }
|
||||
public string ManifestDirectoryPath { get; }
|
||||
|
||||
public ManifestServiceOptions(
|
||||
Uri coreManifestUri,
|
||||
Uri alayaManifestUri,
|
||||
Uri launcherManifestUri,
|
||||
string coreManifestSha512Hash,
|
||||
string launcherManifestSha512Hash,
|
||||
string manifestDirectoryPath)
|
||||
string alayaManifestSha512Hash,
|
||||
string launcherManifestSha512Hash)
|
||||
{
|
||||
CoreManifestUri = coreManifestUri ?? throw new ArgumentNullException(nameof(coreManifestUri));
|
||||
AlayaManifestUri = alayaManifestUri ?? throw new ArgumentNullException(nameof(alayaManifestUri));
|
||||
LauncherManifestUri = launcherManifestUri ?? throw new ArgumentNullException(nameof(launcherManifestUri));
|
||||
|
||||
if (!CoreManifestUri.IsAbsoluteUri)
|
||||
if (!AlayaManifestUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException("Core manifest URI must be absolute.", nameof(coreManifestUri));
|
||||
throw new ArgumentException("Core manifest URI must be absolute.", nameof(alayaManifestUri));
|
||||
}
|
||||
|
||||
if (!LauncherManifestUri.IsAbsoluteUri)
|
||||
@@ -30,9 +28,9 @@ namespace AlayaCore.Models.Configuration
|
||||
throw new ArgumentException("Launcher manifest URI must be absolute.", nameof(launcherManifestUri));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(coreManifestSha512Hash))
|
||||
if (string.IsNullOrWhiteSpace(alayaManifestSha512Hash))
|
||||
{
|
||||
throw new ArgumentException("Core manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(coreManifestSha512Hash));
|
||||
throw new ArgumentException("Core manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(alayaManifestSha512Hash));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(launcherManifestSha512Hash))
|
||||
@@ -40,14 +38,12 @@ namespace AlayaCore.Models.Configuration
|
||||
throw new ArgumentException("Launcher manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(launcherManifestSha512Hash));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifestDirectoryPath))
|
||||
{
|
||||
throw new ArgumentException("Manifest directory path cannot be null, empty, or whitespace.", nameof(manifestDirectoryPath));
|
||||
}
|
||||
|
||||
CoreManifestSha512Hash = coreManifestSha512Hash;
|
||||
AlayaManifestSha512Hash = alayaManifestSha512Hash;
|
||||
LauncherManifestSha512Hash = launcherManifestSha512Hash;
|
||||
ManifestDirectoryPath = manifestDirectoryPath;
|
||||
}
|
||||
|
||||
public static ManifestServiceOptions Default { get; } = new ManifestServiceOptions(
|
||||
new Uri("INSERT-ALAYA-URL", UriKind.Absolute),
|
||||
new Uri("INSERT-LAUNCHER-URL", UriKind.Absolute), "INSERT-ALAYA-HASH", "INSERT-LAUNCHER-HASH");
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,8 @@ namespace AlayaCore.Models.Configuration
|
||||
}
|
||||
|
||||
public string BaseApiUrl { get; }
|
||||
|
||||
public static ModrinthConnectionOptions Default { get; } =
|
||||
new ModrinthConnectionOptions("https://api.modrinth.com/v2/");
|
||||
}
|
||||
}
|
||||
12
AlayaCore/Models/Configuration/RetryPolicyOptions.cs
Normal file
12
AlayaCore/Models/Configuration/RetryPolicyOptions.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace AlayaCore.Models.Manifests
|
||||
{
|
||||
public Version? Version { get; }
|
||||
public string Sha512Hash { get; }
|
||||
public Uri DownloadUri { get; }
|
||||
public Uri? DownloadUri { get; }
|
||||
|
||||
public LauncherManifestModel(
|
||||
Version version,
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace AlayaCore.Models.Manifests
|
||||
public string MinecraftVersion { get; }
|
||||
public string NeoforgedVersion { get; }
|
||||
|
||||
public Uri ServerUrl { get; }
|
||||
public Uri? ServerUrl { get; }
|
||||
public int ServerPort { get; }
|
||||
public IReadOnlyList<ModFileEntry> Files { get; }
|
||||
|
||||
|
||||
@@ -1,27 +1,62 @@
|
||||
using System;
|
||||
using AlayaCore.Utilities.Enums;
|
||||
using AlayaCore.States;
|
||||
|
||||
namespace AlayaCore.Models.Progress
|
||||
{
|
||||
public sealed class LauncherProgress
|
||||
{
|
||||
public LauncherStage Stage { get; }
|
||||
public LaunchState Phase { get; }
|
||||
public string StatusMessage { get; }
|
||||
public double? PercentComplete { get; }
|
||||
public string? CurrentItemName { get; }
|
||||
|
||||
public long? BytesCompleted { get; }
|
||||
public long? BytesTotal { get; }
|
||||
public double? BytesPerSecond { get; }
|
||||
|
||||
public int? TasksCompleted { get; }
|
||||
public int? TasksTotal { get; }
|
||||
|
||||
public double? PercentComplete
|
||||
{
|
||||
get
|
||||
{
|
||||
if (BytesCompleted.HasValue && BytesTotal.HasValue && BytesTotal.Value > 0)
|
||||
{
|
||||
return (double)BytesCompleted.Value / BytesTotal.Value * 100d;
|
||||
}
|
||||
|
||||
if (TasksCompleted.HasValue && TasksTotal.HasValue && TasksTotal.Value > 0)
|
||||
{
|
||||
return (double)TasksCompleted.Value / TasksTotal.Value * 100d;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public LauncherProgress(
|
||||
LauncherStage stage,
|
||||
LaunchState phase,
|
||||
string statusMessage,
|
||||
double? percentComplete = null)
|
||||
string? currentItemName = null,
|
||||
long? bytesCompleted = null,
|
||||
long? bytesTotal = null,
|
||||
double? bytesPerSecond = null,
|
||||
int? tasksCompleted = null,
|
||||
int? tasksTotal = null)
|
||||
{
|
||||
|
||||
if (string.IsNullOrWhiteSpace(statusMessage))
|
||||
{
|
||||
throw new ArgumentException("Status message cannot be null or empty.", nameof(statusMessage));
|
||||
throw new System.ArgumentException("Status message cannot be null, empty, or whitespace.", nameof(statusMessage));
|
||||
}
|
||||
|
||||
Stage = stage;
|
||||
Phase = phase;
|
||||
StatusMessage = statusMessage;
|
||||
PercentComplete = percentComplete;
|
||||
CurrentItemName = currentItemName;
|
||||
BytesCompleted = bytesCompleted;
|
||||
BytesTotal = bytesTotal;
|
||||
BytesPerSecond = bytesPerSecond;
|
||||
TasksCompleted = tasksCompleted;
|
||||
TasksTotal = tasksTotal;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
AlayaCore/Models/Results/InstallOrUpdateResult.cs
Normal file
8
AlayaCore/Models/Results/InstallOrUpdateResult.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace AlayaCore.Models.Results
|
||||
{
|
||||
public enum InstallOrUpdateResult
|
||||
{
|
||||
Ready,
|
||||
UpdaterLaunched
|
||||
}
|
||||
}
|
||||
216
AlayaCore/Policies/RetryPolicy.cs
Normal file
216
AlayaCore/Policies/RetryPolicy.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using AlayaCore.Abstractions.Interfaces.Services;
|
||||
using AlayaCore.Utilities.Enums;
|
||||
using CmlLib.Core.Auth;
|
||||
using CmlLib.Core.Auth.Microsoft;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Identity.Client;
|
||||
using XboxAuthNet.Game.Msal;
|
||||
using XboxAuthNet.Game.Msal.OAuth;
|
||||
@@ -16,14 +17,18 @@ namespace AlayaCore.Services
|
||||
public sealed class AuthService : IAuthService
|
||||
{
|
||||
private readonly IFileStore _fileStore;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
|
||||
private MSession? _session;
|
||||
private IPublicClientApplication? _clientApp;
|
||||
private JELoginHandler? _loginHandler;
|
||||
|
||||
public AuthService(IFileStore fileStore)
|
||||
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)
|
||||
@@ -32,9 +37,12 @@ namespace AlayaCore.Services
|
||||
|
||||
if (_session != null && _session.CheckIsValid())
|
||||
{
|
||||
_logger.LogDebug("Authentication check succeeded using the cached in-memory session.");
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogDebug("No valid cached in-memory session was found. Attempting silent authentication check.");
|
||||
|
||||
try
|
||||
{
|
||||
JELoginHandler loginHandler = await BuildHandlerAsync().ConfigureAwait(false);
|
||||
@@ -43,15 +51,28 @@ namespace AlayaCore.Services
|
||||
.AuthenticateSilently(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return _session != null && _session.CheckIsValid();
|
||||
bool isAuthenticated = _session != null && _session.CheckIsValid();
|
||||
|
||||
if (isAuthenticated)
|
||||
{
|
||||
_logger.LogInformation("Silent authentication check succeeded.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Silent authentication completed but did not produce a valid Minecraft session.");
|
||||
}
|
||||
|
||||
return isAuthenticated;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Authentication check was cancelled.");
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
_session = null;
|
||||
_logger.LogInformation(ex, "Silent authentication check failed. The user is not currently authenticated.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -60,27 +81,53 @@ namespace AlayaCore.Services
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogInformation("Starting authentication flow.");
|
||||
|
||||
JELoginHandler loginHandler = await BuildHandlerAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Attempting silent authentication.");
|
||||
|
||||
_session = await loginHandler
|
||||
.AuthenticateSilently(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (_session != null && _session.CheckIsValid())
|
||||
{
|
||||
_logger.LogInformation("Silent authentication succeeded.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Silent authentication completed but did not return a valid session.");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Authentication was cancelled during silent authentication.");
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogInformation(ex, "Silent authentication failed. Falling back to interactive authentication.");
|
||||
|
||||
_session = await loginHandler
|
||||
.AuthenticateInteractively(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (_session != null && _session.CheckIsValid())
|
||||
{
|
||||
_logger.LogInformation("Interactive authentication succeeded.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Interactive authentication completed but did not return a valid session.");
|
||||
}
|
||||
}
|
||||
|
||||
if (_session == null || !_session.CheckIsValid())
|
||||
{
|
||||
_logger.LogError("Authentication failed because no valid Minecraft session was produced.");
|
||||
throw new InvalidOperationException("Authentication did not produce a valid Minecraft session.");
|
||||
}
|
||||
}
|
||||
@@ -89,10 +136,14 @@ namespace AlayaCore.Services
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogInformation("Signing out the current user.");
|
||||
|
||||
JELoginHandler loginHandler = await BuildHandlerAsync().ConfigureAwait(false);
|
||||
|
||||
await loginHandler.Signout(cancellationToken).ConfigureAwait(false);
|
||||
_session = null;
|
||||
|
||||
_logger.LogInformation("Sign-out completed and cached session was cleared.");
|
||||
}
|
||||
|
||||
public async Task<MSession> GetSessionAsync(CancellationToken cancellationToken = default)
|
||||
@@ -101,16 +152,21 @@ namespace AlayaCore.Services
|
||||
|
||||
if (_session != null && _session.CheckIsValid())
|
||||
{
|
||||
_logger.LogDebug("Returning a valid cached Minecraft session.");
|
||||
return _session;
|
||||
}
|
||||
|
||||
_logger.LogDebug("No valid cached session was available. Attempting authentication before returning a session.");
|
||||
|
||||
await AuthenticateAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (_session == null || !_session.CheckIsValid())
|
||||
{
|
||||
_logger.LogError("A valid Minecraft session was not available after authentication.");
|
||||
throw new InvalidOperationException("No valid Minecraft session is available.");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Returning Minecraft session obtained from authentication flow.");
|
||||
return _session;
|
||||
}
|
||||
|
||||
@@ -118,12 +174,15 @@ namespace AlayaCore.Services
|
||||
{
|
||||
if (_loginHandler != null)
|
||||
{
|
||||
_logger.LogDebug("Reusing existing JELoginHandler instance.");
|
||||
return _loginHandler;
|
||||
}
|
||||
|
||||
string accountDirectory = _fileStore.GetOrCreate(FolderLocation.Data);
|
||||
string accountFilePath = Path.Combine(accountDirectory, "accounts.json");
|
||||
|
||||
_logger.LogInformation("Building MSAL client and login handler. Account cache path: {AccountFilePath}", accountFilePath);
|
||||
|
||||
_clientApp = await MsalClientHelper
|
||||
.BuildApplicationWithCache("d91042d4-3eb5-43e4-b3ed-600e1d0760ff")
|
||||
.ConfigureAwait(false);
|
||||
@@ -133,6 +192,8 @@ namespace AlayaCore.Services
|
||||
.WithAccountManager(accountFilePath)
|
||||
.Build();
|
||||
|
||||
_logger.LogInformation("MSAL client and JELoginHandler were created successfully.");
|
||||
|
||||
return _loginHandler;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,25 +12,29 @@ using CmlLib.Core;
|
||||
using CmlLib.Core.Installer.NeoForge;
|
||||
using CmlLib.Core.Installer.NeoForge.Installers;
|
||||
using CmlLib.Core.Installers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AlayaCore.Services
|
||||
{
|
||||
public sealed class GameInstallService : IGameInstallService
|
||||
{
|
||||
private const string InstalledModsManifestFileName = "InstalledModsManifest.json";
|
||||
private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
|
||||
|
||||
private readonly IFileStore _fileStore;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly ILogger<GameInstallService> _logger;
|
||||
|
||||
private AlayaPath? _gamePath;
|
||||
private MinecraftLauncher? _minecraftLauncher;
|
||||
|
||||
public GameInstallService(
|
||||
IFileStore fileStore,
|
||||
ISettingsService settingsService)
|
||||
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(
|
||||
@@ -54,15 +58,23 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion))
|
||||
{
|
||||
_logger.LogError("Minecraft installation could not start because the manifest did not contain a Minecraft version.");
|
||||
throw new InvalidDataException("Minecraft version is missing.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ensuring Minecraft is installed. Required version: {RequiredVersion}, Installed: {Installed}, Current version: {CurrentVersion}",
|
||||
manifest.MinecraftVersion,
|
||||
environment.MinecraftInstalled,
|
||||
environment.MinecraftVersion);
|
||||
|
||||
bool alreadyInstalled =
|
||||
environment.MinecraftInstalled &&
|
||||
string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (alreadyInstalled)
|
||||
{
|
||||
_logger.LogInformation("Minecraft version {MinecraftVersion} is already installed and matches the manifest.", manifest.MinecraftVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,14 +84,23 @@ namespace AlayaCore.Services
|
||||
|
||||
if (versionMismatch)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Minecraft version mismatch detected. Installed version: {InstalledVersion}, Required version: {RequiredVersion}. Cleaning old install.",
|
||||
environment.MinecraftVersion,
|
||||
manifest.MinecraftVersion);
|
||||
|
||||
await CleanOldInstallAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
MinecraftLauncher launcher = GetOrCreateLauncher(minecraftProgress, byteProgress);
|
||||
|
||||
_logger.LogInformation("Starting Minecraft installation for version {MinecraftVersion}.", manifest.MinecraftVersion);
|
||||
|
||||
await launcher
|
||||
.InstallAsync(manifest.MinecraftVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Minecraft installation completed for version {MinecraftVersion}.", manifest.MinecraftVersion);
|
||||
}
|
||||
|
||||
public async Task EnsureNeoForgeInstalledAsync(
|
||||
@@ -103,20 +124,29 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion))
|
||||
{
|
||||
_logger.LogError("NeoForge installation could not start because the manifest did not contain a Minecraft version.");
|
||||
throw new InvalidDataException("Minecraft version is missing.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.NeoforgedVersion))
|
||||
{
|
||||
_logger.LogError("NeoForge installation could not start because the manifest did not contain a NeoForge version.");
|
||||
throw new InvalidDataException("NeoForge version is missing.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ensuring NeoForge is installed. Required version: {RequiredVersion}, Installed: {Installed}, Current version: {CurrentVersion}",
|
||||
manifest.NeoforgedVersion,
|
||||
environment.NeoforgedInstalled,
|
||||
environment.NeoforgedVersion);
|
||||
|
||||
bool alreadyInstalled =
|
||||
environment.NeoforgedInstalled &&
|
||||
string.Equals(environment.NeoforgedVersion, manifest.NeoforgedVersion, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (alreadyInstalled)
|
||||
{
|
||||
_logger.LogInformation("NeoForge version {NeoForgeVersion} is already installed and matches the manifest.", manifest.NeoforgedVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,6 +156,11 @@ namespace AlayaCore.Services
|
||||
|
||||
if (neoForgeMismatch)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"NeoForge version mismatch detected. Installed version: {InstalledVersion}, Required version: {RequiredVersion}. Cleaning old install and returning control to the director.",
|
||||
environment.NeoforgedVersion,
|
||||
manifest.NeoforgedVersion);
|
||||
|
||||
await CleanOldInstallAsync(cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
@@ -133,16 +168,27 @@ namespace AlayaCore.Services
|
||||
if (!environment.MinecraftInstalled ||
|
||||
!string.Equals(environment.MinecraftVersion, manifest.MinecraftVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Minecraft base installation is missing or mismatched before NeoForge installation. Ensuring Minecraft version {MinecraftVersion} first.",
|
||||
manifest.MinecraftVersion);
|
||||
|
||||
await EnsureMinecraftInstalledAsync(manifest, environment, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(environment.JavaPath))
|
||||
{
|
||||
_logger.LogError("NeoForge installation cannot continue because no valid Java path was found.");
|
||||
throw new InvalidOperationException("A valid Java installation is required before installing NeoForge.");
|
||||
}
|
||||
|
||||
MinecraftLauncher launcher = GetOrCreateLauncher();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting NeoForge installation. Minecraft version: {MinecraftVersion}, NeoForge version: {NeoForgeVersion}, Java path: {JavaPath}",
|
||||
manifest.MinecraftVersion,
|
||||
manifest.NeoforgedVersion,
|
||||
environment.JavaPath);
|
||||
|
||||
await InstallNeoForgeAsync(
|
||||
launcher,
|
||||
manifest,
|
||||
@@ -151,9 +197,15 @@ namespace AlayaCore.Services
|
||||
progress,
|
||||
byteProgress).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"NeoForge installation completed. Verifying Minecraft files for version {MinecraftVersion}.",
|
||||
manifest.MinecraftVersion);
|
||||
|
||||
await launcher
|
||||
.InstallAsync(manifest.MinecraftVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Minecraft file verification completed after NeoForge installation.");
|
||||
}
|
||||
|
||||
public async Task VerifyFilesAsync(
|
||||
@@ -169,14 +221,19 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.MinecraftVersion))
|
||||
{
|
||||
_logger.LogError("File verification could not start because the manifest did not contain a Minecraft version.");
|
||||
throw new InvalidDataException("Minecraft version is missing.");
|
||||
}
|
||||
|
||||
MinecraftLauncher launcher = GetOrCreateLauncher();
|
||||
|
||||
_logger.LogInformation("Verifying Minecraft files for version {MinecraftVersion}.", manifest.MinecraftVersion);
|
||||
|
||||
await launcher
|
||||
.InstallAsync(manifest.MinecraftVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Minecraft file verification completed for version {MinecraftVersion}.", manifest.MinecraftVersion);
|
||||
}
|
||||
|
||||
private async Task CleanOldInstallAsync(CancellationToken cancellationToken)
|
||||
@@ -185,26 +242,42 @@ namespace AlayaCore.Services
|
||||
|
||||
string gamePath = GetMinecraftPath();
|
||||
|
||||
_logger.LogInformation("Cleaning old game installation at path {GamePath}.", gamePath);
|
||||
|
||||
if (Directory.Exists(gamePath))
|
||||
{
|
||||
Directory.Delete(gamePath, recursive: true);
|
||||
_logger.LogInformation("Deleted game directory {GamePath}.", gamePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Game directory {GamePath} did not exist. No game files needed deletion.", gamePath);
|
||||
}
|
||||
|
||||
string installedModsManifestPath = Path.Combine(
|
||||
_fileStore.Get(FolderLocation.Manifests),
|
||||
InstalledModsManifestFileName);
|
||||
INSTALLED_MODS_MANIFEST_FILE_NAME);
|
||||
|
||||
if (File.Exists(installedModsManifestPath))
|
||||
{
|
||||
File.Delete(installedModsManifestPath);
|
||||
_logger.LogInformation("Deleted installed mods manifest at {InstalledModsManifestPath}.", installedModsManifestPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Installed mods manifest {InstalledModsManifestPath} did not exist.", installedModsManifestPath);
|
||||
}
|
||||
|
||||
_gamePath = null;
|
||||
_minecraftLauncher = null;
|
||||
|
||||
_logger.LogDebug("Cleared cached Minecraft launcher state.");
|
||||
|
||||
await _settingsService
|
||||
.UpdateLaunchVersionAsync(string.Empty, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Cleared stored launch version after cleaning the old install.");
|
||||
}
|
||||
|
||||
private MinecraftLauncher GetOrCreateLauncher(
|
||||
@@ -213,20 +286,25 @@ namespace AlayaCore.Services
|
||||
{
|
||||
if (_minecraftLauncher != null)
|
||||
{
|
||||
_logger.LogDebug("Reusing existing MinecraftLauncher instance.");
|
||||
return _minecraftLauncher;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Creating a new MinecraftLauncher instance.");
|
||||
|
||||
_gamePath = new AlayaPath(_fileStore);
|
||||
_minecraftLauncher = new MinecraftLauncher(_gamePath);
|
||||
|
||||
if (byteProgress != null)
|
||||
{
|
||||
_minecraftLauncher.ByteProgressChanged += byteProgress;
|
||||
_logger.LogDebug("Attached Minecraft byte progress handler.");
|
||||
}
|
||||
|
||||
if (minecraftProgress != null)
|
||||
{
|
||||
_minecraftLauncher.FileProgressChanged += minecraftProgress;
|
||||
_logger.LogDebug("Attached Minecraft file progress handler.");
|
||||
}
|
||||
|
||||
return _minecraftLauncher;
|
||||
@@ -245,6 +323,11 @@ namespace AlayaCore.Services
|
||||
throw new ArgumentNullException(nameof(launcher));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Configuring NeoForge installer. Minecraft version: {MinecraftVersion}, NeoForge version: {NeoForgeVersion}",
|
||||
manifest.MinecraftVersion,
|
||||
manifest.NeoforgedVersion);
|
||||
|
||||
NeoForgeInstaller installer = new NeoForgeInstaller(launcher);
|
||||
|
||||
NeoForgeInstallOptions options = new NeoForgeInstallOptions
|
||||
@@ -257,25 +340,33 @@ namespace AlayaCore.Services
|
||||
if (progress != null)
|
||||
{
|
||||
options.FileProgress = progress;
|
||||
_logger.LogDebug("Attached NeoForge file progress reporter.");
|
||||
}
|
||||
|
||||
if (byteProgress != null)
|
||||
{
|
||||
options.ByteProgress = byteProgress;
|
||||
_logger.LogDebug("Attached NeoForge byte progress reporter.");
|
||||
}
|
||||
|
||||
string version = await installer
|
||||
.Install(manifest.MinecraftVersion, manifest.NeoforgedVersion, options)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("NeoForge installer returned launch version {LaunchVersion}.", version);
|
||||
|
||||
await _settingsService
|
||||
.UpdateLaunchVersionAsync(version, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Persisted launch version {LaunchVersion} to settings.", version);
|
||||
}
|
||||
|
||||
private string GetMinecraftPath()
|
||||
{
|
||||
return _fileStore.GetOrCreate(FolderLocation.Game);
|
||||
string gamePath = _fileStore.GetOrCreate(FolderLocation.Game);
|
||||
_logger.LogDebug("Resolved Minecraft game path to {GamePath}.", gamePath);
|
||||
return gamePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ using AlayaCore.Models.Configuration;
|
||||
using AlayaCore.Models.Manifests;
|
||||
using CmlLib.Core;
|
||||
using CmlLib.Core.ProcessBuilder;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AlayaCore.Services
|
||||
{
|
||||
@@ -19,6 +20,7 @@ namespace AlayaCore.Services
|
||||
private readonly IFileStore _fileStore;
|
||||
private readonly ILaunchDirector _director;
|
||||
private readonly GameOptions _gameOptions;
|
||||
private readonly ILogger<GameLaunchService> _logger;
|
||||
|
||||
private MinecraftLauncher? _minecraftLauncher;
|
||||
|
||||
@@ -26,12 +28,14 @@ namespace AlayaCore.Services
|
||||
IAuthService authService,
|
||||
IFileStore fileStore,
|
||||
ILaunchDirector director,
|
||||
GameOptions gameOptions)
|
||||
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(
|
||||
@@ -41,6 +45,7 @@ namespace AlayaCore.Services
|
||||
{
|
||||
if (!_director.CanRun)
|
||||
{
|
||||
_logger.LogError("Game launch was requested while the launcher was not in a runnable state.");
|
||||
throw new InvalidOperationException("The launcher is not in a runnable state.");
|
||||
}
|
||||
|
||||
@@ -56,16 +61,27 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_gameOptions.LaunchVersion))
|
||||
{
|
||||
_logger.LogError("Game launch could not start because GameOptions.LaunchVersion is not configured.");
|
||||
throw new InvalidDataException("GameOptions.LaunchVersion is not configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(environment.JavaPath))
|
||||
{
|
||||
_logger.LogError("Game launch could not start because no valid Java path was available.");
|
||||
throw new InvalidOperationException("A valid Java path is required to launch the game.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting game launch. LaunchVersion: {LaunchVersion}, JavaPath: {JavaPath}, Resolution: {Width}x{Height}, MinRamMb: {MinRamMb}, MaxRamMb: {MaxRamMb}",
|
||||
_gameOptions.LaunchVersion,
|
||||
environment.JavaPath,
|
||||
_gameOptions.ScreenWidth,
|
||||
_gameOptions.ScreenHeight,
|
||||
_gameOptions.MinimumRamMb,
|
||||
_gameOptions.MaximumRamMb);
|
||||
|
||||
MLaunchOption option = await BuildLaunchOptionsAsync(
|
||||
manifest,
|
||||
environment,
|
||||
@@ -73,23 +89,39 @@ namespace AlayaCore.Services
|
||||
|
||||
MinecraftLauncher launcher = GetOrCreateLauncher();
|
||||
|
||||
_logger.LogInformation("Creating Minecraft process for launch version {LaunchVersion}.", _gameOptions.LaunchVersion);
|
||||
|
||||
var process = await launcher
|
||||
.CreateProcessAsync(_gameOptions.LaunchVersion, option)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Minecraft process was created successfully. ProcessId: {ProcessId}",
|
||||
process.Id);
|
||||
|
||||
var processWrapper = new ProcessWrapper(process);
|
||||
|
||||
_logger.LogInformation("Starting Minecraft process.");
|
||||
processWrapper.StartWithEvents();
|
||||
|
||||
_logger.LogInformation("Waiting for Minecraft process to exit.");
|
||||
await processWrapper.WaitForExitTaskAsync().ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Minecraft process exited. ProcessId: {ProcessId}, ExitCode: {ExitCode}",
|
||||
process.Id,
|
||||
process.ExitCode);
|
||||
}
|
||||
|
||||
private MinecraftLauncher GetOrCreateLauncher()
|
||||
{
|
||||
if (_minecraftLauncher != null)
|
||||
{
|
||||
_logger.LogDebug("Reusing existing MinecraftLauncher instance for game launch.");
|
||||
return _minecraftLauncher;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Creating a new MinecraftLauncher instance for game launch.");
|
||||
_minecraftLauncher = new MinecraftLauncher(new AlayaPath(_fileStore));
|
||||
return _minecraftLauncher;
|
||||
}
|
||||
@@ -99,17 +131,40 @@ namespace AlayaCore.Services
|
||||
InstallEnvironment environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (manifest == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(manifest));
|
||||
}
|
||||
|
||||
if (environment == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(environment));
|
||||
}
|
||||
|
||||
if (manifest.ServerUrl == null)
|
||||
{
|
||||
_logger.LogError("Launch options could not be built because Manifest.ServerUrl is not configured.");
|
||||
throw new InvalidDataException("Manifest ServerUrl is not configured.");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Building launch options. ServerHost: {ServerHost}, ServerPort: {ServerPort}, LaunchVersion: {LaunchVersion}",
|
||||
manifest.ServerUrl.Host,
|
||||
manifest.ServerPort,
|
||||
_gameOptions.LaunchVersion);
|
||||
|
||||
var session = await _authService
|
||||
.GetSessionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("A valid Minecraft session was acquired for launch.");
|
||||
|
||||
return new MLaunchOption
|
||||
{
|
||||
Session = await _authService.GetSessionAsync(cancellationToken).ConfigureAwait(false),
|
||||
Session = session,
|
||||
JavaPath = environment.JavaPath,
|
||||
MinimumRamMb = _gameOptions.MinimumRamMB,
|
||||
MaximumRamMb = _gameOptions.MaximumRamMB,
|
||||
MinimumRamMb = _gameOptions.MinimumRamMb,
|
||||
MaximumRamMb = _gameOptions.MaximumRamMb,
|
||||
ScreenWidth = _gameOptions.ScreenWidth,
|
||||
ScreenHeight = _gameOptions.ScreenHeight,
|
||||
ServerIp = manifest.ServerUrl.Host,
|
||||
|
||||
@@ -6,21 +6,30 @@ 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 BufferSize = 81920;
|
||||
private const int BUFFER_SIZE = 81920;
|
||||
|
||||
private readonly IHttpClient _httpClient;
|
||||
private readonly IRetryPolicy _retryPolicy;
|
||||
private readonly ILogger<HttpDownloadService> _logger;
|
||||
|
||||
public HttpDownloadService(IHttpClient httpClient)
|
||||
public HttpDownloadService(
|
||||
IHttpClient httpClient,
|
||||
IRetryPolicy retryPolicy,
|
||||
ILogger<HttpDownloadService> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DownloadResult> DownloadFileAsync(
|
||||
@@ -54,12 +63,22 @@ namespace AlayaCore.Services
|
||||
throw new ArgumentException("Destination path must include a file name.", nameof(destinationPath));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting download workflow for {FileName} from {SourceUri} to {DestinationPath}.",
|
||||
fileName,
|
||||
sourceUri,
|
||||
destinationPath);
|
||||
|
||||
EnsureDestinationDirectoryExists(destinationPath);
|
||||
|
||||
if (File.Exists(destinationPath) && VerifyFileHash(destinationPath, normalizedExpectedHash))
|
||||
{
|
||||
long existingLength = new FileInfo(destinationPath).Length;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Skipped download for {FileName} because the destination file already exists and passed SHA-512 verification.",
|
||||
fileName);
|
||||
|
||||
progress?.Report(new DownloadProgress(
|
||||
fileName: fileName,
|
||||
destinationPath: destinationPath,
|
||||
@@ -78,117 +97,180 @@ namespace AlayaCore.Services
|
||||
bool destinationExisted = File.Exists(destinationPath);
|
||||
string tempFilePath = destinationPath + ".download";
|
||||
|
||||
if (destinationExisted)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Destination file for {FileName} already existed but was not valid. A replacement download will be attempted.",
|
||||
fileName);
|
||||
}
|
||||
|
||||
DeleteFileIfExists(tempFilePath);
|
||||
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||
sourceUri,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
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);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
long? totalBytes = response.Content.Headers.ContentLength;
|
||||
long bytesDownloaded = 0;
|
||||
|
||||
progress?.Report(new DownloadProgress(
|
||||
fileName: fileName,
|
||||
destinationPath: destinationPath,
|
||||
bytesDownloaded: 0,
|
||||
totalBytes: totalBytes,
|
||||
bytesPerSecond: null,
|
||||
statusMessage: "Starting download..."));
|
||||
|
||||
await using Stream responseStream = await response.Content
|
||||
.ReadAsStreamAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using FileStream fileStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
BufferSize,
|
||||
useAsync: true);
|
||||
|
||||
using SHA512 sha512 = SHA512.Create();
|
||||
|
||||
byte[] buffer = new byte[BufferSize];
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
while (true)
|
||||
{
|
||||
int bytesRead = await responseStream.ReadAsync(
|
||||
buffer,
|
||||
0,
|
||||
buffer.Length,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await fileStream.WriteAsync(
|
||||
buffer,
|
||||
0,
|
||||
bytesRead,
|
||||
cancellationToken).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(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
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);
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Download for {FileName} was cancelled. Cleaning up temporary file {TempFilePath}.",
|
||||
fileName,
|
||||
tempFilePath);
|
||||
|
||||
DeleteFileIfExists(tempFilePath);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Download failed for {FileName}. Cleaning up temporary file {TempFilePath}.",
|
||||
fileName,
|
||||
tempFilePath);
|
||||
|
||||
DeleteFileIfExists(tempFilePath);
|
||||
throw;
|
||||
}
|
||||
@@ -205,6 +287,7 @@ namespace AlayaCore.Services
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
_logger.LogDebug("Hash verification skipped because file does not exist at {FilePath}.", filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -214,7 +297,22 @@ namespace AlayaCore.Services
|
||||
byte[] hashBytes = sha512.ComputeHash(fileStream);
|
||||
string actualHash = ConvertToLowerHex(hashBytes);
|
||||
|
||||
return string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase);
|
||||
bool matches = string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (matches)
|
||||
{
|
||||
_logger.LogDebug("SHA-512 verification succeeded for {FilePath}.", filePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SHA-512 verification failed for {FilePath}. Expected SHA-512: {ExpectedHash}. Actual SHA-512: {ActualHash}.",
|
||||
filePath,
|
||||
normalizedExpectedHash,
|
||||
actualHash);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
private static void EnsureDestinationDirectoryExists(string destinationPath)
|
||||
|
||||
@@ -11,29 +11,35 @@ using AlayaCore.Abstractions.Interfaces.Services;
|
||||
using AlayaCore.Installation;
|
||||
using AlayaCore.Models.Manifests;
|
||||
using AlayaCore.Utilities.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AlayaCore.Services
|
||||
{
|
||||
public sealed class InstallationStateService : IInstallStateService
|
||||
{
|
||||
private const string VersionsFolderName = "versions";
|
||||
private const string VERSIONS_FOLDER_NAME = "versions";
|
||||
|
||||
private readonly IFileStore _fileStore;
|
||||
private readonly IManifestService _manifestService;
|
||||
private readonly ILogger<InstallationStateService> _logger;
|
||||
|
||||
public InstallationStateService(
|
||||
IFileStore fileStore,
|
||||
IManifestService manifestService)
|
||||
IManifestService manifestService,
|
||||
ILogger<InstallationStateService> logger)
|
||||
{
|
||||
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
|
||||
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<InstallEnvironment> GetCurrentEnvironmentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogDebug("Building current installation environment state.");
|
||||
|
||||
OSPlatform platform = GetCurrentPlatform();
|
||||
|
||||
bool javaInstalled = TryGetJavaPath(out string? javaPath);
|
||||
@@ -41,15 +47,20 @@ namespace AlayaCore.Services
|
||||
|
||||
if (javaInstalled)
|
||||
{
|
||||
_logger.LogDebug("Java runtime was detected at {JavaPath}. Attempting to read Java version.", javaPath);
|
||||
javaVersion = GetJavaVersion(javaPath!);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("No managed Java runtime was detected.");
|
||||
}
|
||||
|
||||
InstalledVersionState versionState = GetInstalledVersionState();
|
||||
|
||||
InstalledModsManifestModel installedModsManifest =
|
||||
await _manifestService.GetInstalledModsManifestAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new InstallEnvironment(
|
||||
InstallEnvironment environment = new InstallEnvironment(
|
||||
osPlatform: platform,
|
||||
javaInstalled: javaInstalled,
|
||||
javaPath: javaPath,
|
||||
@@ -59,6 +70,19 @@ namespace AlayaCore.Services
|
||||
neoforgedInstalled: !string.IsNullOrWhiteSpace(versionState.NeoForgeVersion),
|
||||
neoforgedVersion: versionState.NeoForgeVersion,
|
||||
installedModsManifest: installedModsManifest);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Installation environment resolved. Platform: {Platform}, JavaInstalled: {JavaInstalled}, JavaVersion: {JavaVersion}, MinecraftInstalled: {MinecraftInstalled}, MinecraftVersion: {MinecraftVersion}, NeoForgeInstalled: {NeoForgeInstalled}, NeoForgeVersion: {NeoForgeVersion}, InstalledModsCount: {InstalledModsCount}",
|
||||
platform,
|
||||
environment.JavaInstalled,
|
||||
environment.JavaVersion,
|
||||
environment.MinecraftInstalled,
|
||||
environment.MinecraftVersion,
|
||||
environment.NeoforgedInstalled,
|
||||
environment.NeoforgedVersion,
|
||||
environment.InstalledModsManifest.Mods.Count);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
private static OSPlatform GetCurrentPlatform()
|
||||
@@ -81,7 +105,7 @@ namespace AlayaCore.Services
|
||||
throw new PlatformNotSupportedException("The current operating system is not supported.");
|
||||
}
|
||||
|
||||
private static string? GetJavaVersion(string javaPath)
|
||||
private string? GetJavaVersion(string javaPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(javaPath))
|
||||
{
|
||||
@@ -93,6 +117,8 @@ namespace AlayaCore.Services
|
||||
throw new FileNotFoundException("Java executable was not found.", javaPath);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Reading Java version from executable at {JavaPath}.", javaPath);
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
@@ -113,10 +139,26 @@ namespace AlayaCore.Services
|
||||
|
||||
if (process.ExitCode != 0 && string.IsNullOrWhiteSpace(standardError))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Java version check for {JavaPath} exited with code {ExitCode} and produced no version output.",
|
||||
javaPath,
|
||||
process.ExitCode);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParseJavaVersion(standardError);
|
||||
string? version = ParseJavaVersion(standardError);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
_logger.LogWarning("Java version output from {JavaPath} could not be parsed.", javaPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Parsed Java version {JavaVersion} from {JavaPath}.", version, javaPath);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
private static string? ParseJavaVersion(string processOutput)
|
||||
@@ -144,10 +186,12 @@ namespace AlayaCore.Services
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogDebug("Managed Java executable was not found at {JavaPath}.", fullPath);
|
||||
javaPath = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Managed Java executable was found at {JavaPath}.", fullPath);
|
||||
javaPath = fullPath;
|
||||
return true;
|
||||
}
|
||||
@@ -156,8 +200,11 @@ namespace AlayaCore.Services
|
||||
{
|
||||
string versionsPath = GetVersionsPath();
|
||||
|
||||
_logger.LogDebug("Inspecting installed version metadata under {VersionsPath}.", versionsPath);
|
||||
|
||||
if (!Directory.Exists(versionsPath))
|
||||
{
|
||||
_logger.LogInformation("Versions directory does not exist at {VersionsPath}. No Minecraft or NeoForge installation was detected.", versionsPath);
|
||||
return InstalledVersionState.Empty();
|
||||
}
|
||||
|
||||
@@ -168,6 +215,7 @@ namespace AlayaCore.Services
|
||||
|
||||
if (versionDirectories.Length == 0)
|
||||
{
|
||||
_logger.LogInformation("Versions directory at {VersionsPath} was empty.", versionsPath);
|
||||
return InstalledVersionState.Empty();
|
||||
}
|
||||
|
||||
@@ -180,6 +228,7 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(versionFolderName))
|
||||
{
|
||||
_logger.LogDebug("Skipping version directory with an invalid folder name: {VersionDirectory}.", versionDirectory);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -187,11 +236,13 @@ namespace AlayaCore.Services
|
||||
|
||||
if (!File.Exists(versionJsonPath))
|
||||
{
|
||||
_logger.LogDebug("Skipping version directory {VersionDirectory} because version metadata file {VersionJsonPath} was not found.", versionDirectory, versionJsonPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryLoadJson(versionJsonPath, out JObject? versionJson))
|
||||
{
|
||||
_logger.LogWarning("Skipping version metadata file {VersionJsonPath} because it could not be read or parsed.", versionJsonPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -200,6 +251,7 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
_logger.LogDebug("Skipping version metadata file {VersionJsonPath} because it did not contain a valid id.", versionJsonPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -212,18 +264,32 @@ namespace AlayaCore.Services
|
||||
minecraftVersion = inheritsFrom;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Detected NeoForge version metadata. Id: {NeoForgeVersion}, InheritsFrom: {MinecraftVersion}",
|
||||
id,
|
||||
inheritsFrom);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
minecraftVersion ??= id;
|
||||
|
||||
_logger.LogDebug("Detected Minecraft version metadata. Id: {MinecraftVersion}", id);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Installed version state resolved. MinecraftVersion: {MinecraftVersion}, NeoForgeVersion: {NeoForgeVersion}",
|
||||
minecraftVersion,
|
||||
neoForgeVersion);
|
||||
|
||||
return new InstalledVersionState(minecraftVersion, neoForgeVersion);
|
||||
}
|
||||
|
||||
private string GetVersionsPath()
|
||||
{
|
||||
return Path.Combine(_fileStore.Get(FolderLocation.Game), VersionsFolderName);
|
||||
string versionsPath = Path.Combine(_fileStore.Get(FolderLocation.Game), VERSIONS_FOLDER_NAME);
|
||||
_logger.LogDebug("Resolved versions path to {VersionsPath}.", versionsPath);
|
||||
return versionsPath;
|
||||
}
|
||||
|
||||
private static bool IsNeoForgeVersion(string? id, string? inheritsFrom)
|
||||
@@ -242,7 +308,7 @@ namespace AlayaCore.Services
|
||||
value.Contains("neoforged", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryLoadJson(string path, out JObject? jsonObject)
|
||||
private bool TryLoadJson(string path, out JObject? jsonObject)
|
||||
{
|
||||
jsonObject = null;
|
||||
|
||||
@@ -251,20 +317,22 @@ namespace AlayaCore.Services
|
||||
return false;
|
||||
}
|
||||
|
||||
string json = File.ReadAllText(path);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(path);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
_logger.LogWarning("JSON file at {Path} was empty.", path);
|
||||
return false;
|
||||
}
|
||||
|
||||
jsonObject = JObject.Parse(json);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load or parse JSON file at {Path}.", path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
||||
using AlayaCore.Abstractions.Interfaces.Services;
|
||||
using AlayaCore.Models.Configuration;
|
||||
using AlayaCore.Models.Manifests;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AlayaCore.Services
|
||||
{
|
||||
@@ -13,40 +14,49 @@ namespace AlayaCore.Services
|
||||
{
|
||||
private readonly IManifestService _manifestService;
|
||||
private readonly LauncherUpdateServiceOptions _options;
|
||||
private readonly ILogger<LauncherUpdateService> _logger;
|
||||
|
||||
public LauncherUpdateService(
|
||||
IManifestService manifestService,
|
||||
LauncherUpdateServiceOptions options)
|
||||
LauncherUpdateServiceOptions options,
|
||||
ILogger<LauncherUpdateService> logger)
|
||||
{
|
||||
_manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> DoesLauncherNeedUpdating(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_options.ForceUpdate)
|
||||
{
|
||||
_logger.LogWarning("Launcher update check is being forced by configuration.");
|
||||
return true;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogDebug("Checking whether the launcher requires an update.");
|
||||
|
||||
LauncherManifestModel? localManifest = await _manifestService
|
||||
.GetLocalLauncherManifestAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (localManifest == null)
|
||||
{
|
||||
_logger.LogInformation("No local launcher manifest was found. The launcher will be treated as requiring an update.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (localManifest.Version == null)
|
||||
{
|
||||
_logger.LogWarning("Local launcher manifest did not contain a valid version. The launcher will be treated as requiring an update.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(localManifest.Sha512Hash))
|
||||
{
|
||||
_logger.LogWarning("Local launcher manifest did not contain a valid SHA-512 hash. The launcher will be treated as requiring an update.");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -56,16 +66,19 @@ namespace AlayaCore.Services
|
||||
|
||||
if (remoteManifest == null)
|
||||
{
|
||||
_logger.LogError("Remote launcher manifest could not be loaded.");
|
||||
throw new InvalidOperationException("Remote launcher manifest could not be loaded.");
|
||||
}
|
||||
|
||||
if (remoteManifest.Version == null)
|
||||
{
|
||||
_logger.LogError("Remote launcher manifest did not contain a valid version.");
|
||||
throw new InvalidOperationException("Remote launcher manifest returned an invalid version.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remoteManifest.Sha512Hash))
|
||||
{
|
||||
_logger.LogError("Remote launcher manifest did not contain a valid SHA-512 hash.");
|
||||
throw new InvalidOperationException("Remote launcher manifest returned an invalid SHA-512 hash.");
|
||||
}
|
||||
|
||||
@@ -75,6 +88,13 @@ namespace AlayaCore.Services
|
||||
remoteManifest.Sha512Hash.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Launcher update check complete. LocalVersion: {LocalVersion}, RemoteVersion: {RemoteVersion}, VersionMismatch: {VersionMismatch}, HashMismatch: {HashMismatch}",
|
||||
localManifest.Version,
|
||||
remoteManifest.Version,
|
||||
versionMismatch,
|
||||
hashMismatch);
|
||||
|
||||
return versionMismatch || hashMismatch;
|
||||
}
|
||||
|
||||
@@ -91,11 +111,13 @@ namespace AlayaCore.Services
|
||||
|
||||
if (newManifest.DownloadUri == null)
|
||||
{
|
||||
_logger.LogError("Updater launch failed because the launcher manifest did not contain a download URI.");
|
||||
throw new InvalidOperationException("Launcher manifest does not contain a download URI.");
|
||||
}
|
||||
|
||||
if (!newManifest.DownloadUri.IsAbsoluteUri)
|
||||
{
|
||||
_logger.LogError("Updater launch failed because the launcher download URI was not absolute. DownloadUri: {DownloadUri}", newManifest.DownloadUri);
|
||||
throw new InvalidOperationException("Launcher download URI must be absolute.");
|
||||
}
|
||||
|
||||
@@ -103,16 +125,19 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(updaterPath))
|
||||
{
|
||||
_logger.LogError("Updater launch failed because the updater path was not configured.");
|
||||
throw new InvalidOperationException("Updater path is not configured.");
|
||||
}
|
||||
|
||||
if (!Path.IsPathFullyQualified(updaterPath))
|
||||
{
|
||||
_logger.LogError("Updater launch failed because the updater path was not absolute. UpdaterPath: {UpdaterPath}", updaterPath);
|
||||
throw new InvalidOperationException("Updater path must be absolute.");
|
||||
}
|
||||
|
||||
if (!File.Exists(updaterPath))
|
||||
{
|
||||
_logger.LogError("Updater launch failed because the updater executable was not found at {UpdaterPath}.", updaterPath);
|
||||
throw new FileNotFoundException("Alaya updater program was not found.", updaterPath);
|
||||
}
|
||||
|
||||
@@ -130,17 +155,30 @@ namespace AlayaCore.Services
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Launching updater process. UpdaterPath: {UpdaterPath}, WorkingDirectory: {WorkingDirectory}, DownloadUri: {DownloadUri}",
|
||||
updaterPath,
|
||||
workingDirectory,
|
||||
newManifest.DownloadUri.AbsoluteUri);
|
||||
|
||||
try
|
||||
{
|
||||
Process? process = Process.Start(startInfo);
|
||||
|
||||
if (process == null)
|
||||
{
|
||||
_logger.LogError("Updater process start returned null for executable {UpdaterPath}.", updaterPath);
|
||||
throw new InvalidOperationException("Failed to start updater process.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updater process launched successfully. ProcessId: {ProcessId}, UpdaterPath: {UpdaterPath}",
|
||||
process.Id,
|
||||
updaterPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to launch updater process from {UpdaterPath}.", updaterPath);
|
||||
throw new InvalidOperationException("Failed to launch updater process.", ex);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,47 +5,60 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AlayaCore.Abstractions.Interfaces;
|
||||
using AlayaCore.Abstractions.Interfaces.Clients;
|
||||
using AlayaCore.Abstractions.Interfaces.Policies;
|
||||
using AlayaCore.Abstractions.Interfaces.Services;
|
||||
using AlayaCore.Models.Configuration;
|
||||
using AlayaCore.Models.Manifests;
|
||||
using AlayaCore.Models.Manifests.DTO;
|
||||
using AlayaCore.Utilities.Enums;
|
||||
using AlayaCore.Utilities.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AlayaCore.Services
|
||||
{
|
||||
public sealed class ManifestService : IManifestService
|
||||
{
|
||||
private const string CoreManifestFileName = "CoreManifest.json";
|
||||
private const string LauncherManifestFileName = "LauncherManifest.json";
|
||||
private const string InstalledModsManifestFileName = "InstalledModsManifest.json";
|
||||
private const string ALAYA_MANIFEST_FILE_NAME = "AlayaManifest.json";
|
||||
private const string LAUNCHER_MANIFEST_FILE_NAME = "LauncherManifest.json";
|
||||
private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
|
||||
|
||||
private readonly IDownloadService _downloadService;
|
||||
private readonly IHttpClient _httpClient;
|
||||
private readonly IFileStore _fileStore;
|
||||
private readonly ManifestServiceOptions _options;
|
||||
private readonly IRetryPolicy _retryPolicy;
|
||||
private readonly ILogger<ManifestService> _logger;
|
||||
|
||||
public ManifestService(
|
||||
IDownloadService downloadService,
|
||||
IHttpClient httpClient,
|
||||
IFileStore fileStore,
|
||||
ManifestServiceOptions options)
|
||||
ManifestServiceOptions options,
|
||||
IRetryPolicy retryPolicy,
|
||||
ILogger<ManifestService> logger)
|
||||
{
|
||||
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<ManifestModel> GetCoreManifestAsync(CancellationToken cancellationToken = default)
|
||||
public Task<ManifestModel> GetAlayaManifestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
string destinationPath = GetCoreManifestPath();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Downloading and loading Alaya manifest from {ManifestUri} to {DestinationPath}.",
|
||||
_options.AlayaManifestUri,
|
||||
destinationPath);
|
||||
|
||||
return DownloadAndLoadManifestAsync<ManifestDto, ManifestModel>(
|
||||
_options.CoreManifestUri,
|
||||
_options.AlayaManifestUri,
|
||||
destinationPath,
|
||||
_options.CoreManifestSha512Hash,
|
||||
_options.AlayaManifestSha512Hash,
|
||||
static dto => dto.ToModel(),
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -55,8 +68,11 @@ namespace AlayaCore.Services
|
||||
{
|
||||
string path = GetInstalledModsManifestPath();
|
||||
|
||||
_logger.LogDebug("Loading installed mods manifest from {ManifestPath}.", path);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
_logger.LogInformation("Installed mods manifest was not found at {ManifestPath}. Returning an empty manifest.", path);
|
||||
return InstalledModsManifestModel.Empty();
|
||||
}
|
||||
|
||||
@@ -64,6 +80,7 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
_logger.LogWarning("Installed mods manifest at {ManifestPath} was empty. Returning an empty manifest.", path);
|
||||
return InstalledModsManifestModel.Empty();
|
||||
}
|
||||
|
||||
@@ -74,13 +91,29 @@ namespace AlayaCore.Services
|
||||
static dto => dto.ToModel(),
|
||||
swallowDeserializationErrors: true);
|
||||
|
||||
return manifest ?? InstalledModsManifestModel.Empty();
|
||||
if (manifest == null)
|
||||
{
|
||||
_logger.LogWarning("Installed mods manifest at {ManifestPath} could not be deserialized. Returning an empty manifest.", path);
|
||||
return InstalledModsManifestModel.Empty();
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded installed mods manifest from {ManifestPath}. Mod count: {ModCount}",
|
||||
path,
|
||||
manifest.Mods.Count);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public Task<LauncherManifestModel> GetLauncherManifestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
string destinationPath = GetLauncherManifestPath();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Downloading and loading launcher manifest from {ManifestUri} to {DestinationPath}.",
|
||||
_options.LauncherManifestUri,
|
||||
destinationPath);
|
||||
|
||||
return DownloadAndLoadManifestAsync<LauncherManifestDto, LauncherManifestModel>(
|
||||
_options.LauncherManifestUri,
|
||||
destinationPath,
|
||||
@@ -89,41 +122,59 @@ namespace AlayaCore.Services
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ManifestModel?> GetLocalCoreManifestAsync(CancellationToken cancellationToken = default)
|
||||
public Task<ManifestModel?> GetLocalAlayaManifestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
string path = GetCoreManifestPath();
|
||||
|
||||
_logger.LogDebug("Loading local Alaya manifest from {ManifestPath}.", path);
|
||||
|
||||
return LoadLocalManifestAsync<ManifestDto, ManifestModel>(
|
||||
GetCoreManifestPath(),
|
||||
path,
|
||||
static dto => dto.ToModel(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<LauncherManifestModel?> GetLocalLauncherManifestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
string path = GetLauncherManifestPath();
|
||||
|
||||
_logger.LogDebug("Loading local launcher manifest from {ManifestPath}.", path);
|
||||
|
||||
return LoadLocalManifestAsync<LauncherManifestDto, LauncherManifestModel>(
|
||||
GetLauncherManifestPath(),
|
||||
path,
|
||||
static dto => dto.ToModel(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Version> GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug("Fetching remote Alaya manifest version from {ManifestUri}.", _options.AlayaManifestUri);
|
||||
|
||||
ManifestModel remoteManifest = await GetRemoteManifestAsync<ManifestDto, ManifestModel>(
|
||||
_options.CoreManifestUri,
|
||||
_options.AlayaManifestUri,
|
||||
static dto => dto.ToModel(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (remoteManifest.AlayaVersion == null)
|
||||
{
|
||||
_logger.LogError("Remote Alaya manifest from {ManifestUri} did not contain a valid version.", _options.AlayaManifestUri);
|
||||
throw new InvalidDataException(
|
||||
$"Remote core manifest from '{_options.CoreManifestUri}' does not contain a valid version.");
|
||||
$"Remote core manifest from '{_options.AlayaManifestUri}' does not contain a valid version.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Fetched remote Alaya manifest version {RemoteVersion} from {ManifestUri}.",
|
||||
remoteManifest.AlayaVersion,
|
||||
_options.AlayaManifestUri);
|
||||
|
||||
return remoteManifest.AlayaVersion;
|
||||
}
|
||||
|
||||
public async Task<LauncherManifestModel> GetRemoteLauncherManifestAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug("Fetching remote launcher manifest from {ManifestUri}.", _options.LauncherManifestUri);
|
||||
|
||||
LauncherManifestModel remoteManifest = await GetRemoteManifestAsync<LauncherManifestDto, LauncherManifestModel>(
|
||||
_options.LauncherManifestUri,
|
||||
static dto => dto.ToModel(),
|
||||
@@ -131,32 +182,45 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remoteManifest.Sha512Hash))
|
||||
{
|
||||
_logger.LogError("Remote launcher manifest from {ManifestUri} did not contain a valid SHA-512 hash.", _options.LauncherManifestUri);
|
||||
throw new InvalidDataException(
|
||||
$"Remote launcher manifest from '{_options.LauncherManifestUri}' does not contain a valid SHA-512 hash.");
|
||||
}
|
||||
|
||||
if (remoteManifest.DownloadUri == null || !remoteManifest.DownloadUri.IsAbsoluteUri)
|
||||
{
|
||||
_logger.LogError("Remote launcher manifest from {ManifestUri} did not contain a valid download URI.", _options.LauncherManifestUri);
|
||||
throw new InvalidDataException(
|
||||
$"Remote launcher manifest from '{_options.LauncherManifestUri}' does not contain a valid download URI.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Fetched remote launcher manifest. Version: {Version}, DownloadUri: {DownloadUri}",
|
||||
remoteManifest.Version,
|
||||
remoteManifest.DownloadUri);
|
||||
|
||||
return remoteManifest;
|
||||
}
|
||||
|
||||
public string GetLauncherManifestPath()
|
||||
{
|
||||
return Path.Combine(_fileStore.Get(FolderLocation.Manifests), LauncherManifestFileName);
|
||||
string path = Path.Combine(_fileStore.Get(FolderLocation.Manifests), LAUNCHER_MANIFEST_FILE_NAME);
|
||||
_logger.LogDebug("Resolved launcher manifest path to {ManifestPath}.", path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public string GetCoreManifestPath()
|
||||
{
|
||||
return Path.Combine(_fileStore.Get(FolderLocation.Manifests), CoreManifestFileName);
|
||||
string path = Path.Combine(_fileStore.Get(FolderLocation.Manifests), ALAYA_MANIFEST_FILE_NAME);
|
||||
_logger.LogDebug("Resolved Alaya manifest path to {ManifestPath}.", path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public string GetInstalledModsManifestPath()
|
||||
{
|
||||
return Path.Combine(_fileStore.Get(FolderLocation.Manifests), InstalledModsManifestFileName);
|
||||
string path = Path.Combine(_fileStore.Get(FolderLocation.Manifests), INSTALLED_MODS_MANIFEST_FILE_NAME);
|
||||
_logger.LogDebug("Resolved installed mods manifest path to {ManifestPath}.", path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private async Task<TModel?> LoadLocalManifestAsync<TDto, TModel>(
|
||||
@@ -177,6 +241,7 @@ namespace AlayaCore.Services
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
_logger.LogInformation("Local manifest was not found at {ManifestPath}.", path);
|
||||
return default;
|
||||
}
|
||||
|
||||
@@ -184,14 +249,26 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
_logger.LogWarning("Local manifest at {ManifestPath} was empty.", path);
|
||||
return default;
|
||||
}
|
||||
|
||||
return DeserializeAndMapManifest<TDto, TModel>(
|
||||
TModel? model = DeserializeAndMapManifest(
|
||||
json,
|
||||
path,
|
||||
map,
|
||||
swallowDeserializationErrors: true);
|
||||
|
||||
if (model == null)
|
||||
{
|
||||
_logger.LogWarning("Local manifest at {ManifestPath} could not be deserialized or mapped.", path);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Successfully loaded local manifest from {ManifestPath}.", path);
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private async Task<TModel> DownloadAndLoadManifestAsync<TDto, TModel>(
|
||||
@@ -231,14 +308,26 @@ namespace AlayaCore.Services
|
||||
|
||||
EnsureDirectoryExists(destinationPath);
|
||||
|
||||
await _downloadService.DownloadFileAsync(
|
||||
_logger.LogInformation(
|
||||
"Downloading manifest from {ManifestUri} to {DestinationPath}.",
|
||||
manifestUri,
|
||||
destinationPath,
|
||||
sha512Hash,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
destinationPath);
|
||||
|
||||
await _retryPolicy.ExecuteAsync(
|
||||
async token =>
|
||||
{
|
||||
await _downloadService.DownloadFileAsync(
|
||||
manifestUri,
|
||||
destinationPath,
|
||||
sha512Hash,
|
||||
cancellationToken: token).ConfigureAwait(false);
|
||||
},
|
||||
$"manifest-download:{Path.GetFileName(destinationPath)}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!File.Exists(destinationPath))
|
||||
{
|
||||
_logger.LogError("Manifest file was not found after download at {DestinationPath}.", destinationPath);
|
||||
throw new FileNotFoundException("Manifest file was not found after download.", destinationPath);
|
||||
}
|
||||
|
||||
@@ -246,14 +335,22 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
_logger.LogError("Downloaded manifest file at {DestinationPath} was empty.", destinationPath);
|
||||
throw new InvalidDataException($"Manifest file '{destinationPath}' was empty.");
|
||||
}
|
||||
|
||||
return DeserializeAndMapManifest<TDto, TModel>(
|
||||
TModel model = DeserializeAndMapManifest(
|
||||
json,
|
||||
destinationPath,
|
||||
map,
|
||||
swallowDeserializationErrors: false)!;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Downloaded and loaded manifest successfully from {ManifestUri} into {DestinationPath}.",
|
||||
manifestUri,
|
||||
destinationPath);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private async Task<TModel> GetRemoteManifestAsync<TDto, TModel>(
|
||||
@@ -279,29 +376,42 @@ namespace AlayaCore.Services
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||
manifestUri,
|
||||
HttpCompletionOption.ResponseContentRead,
|
||||
_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);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Remote manifest response from '{manifestUri}' was empty.");
|
||||
}
|
||||
|
||||
return DeserializeAndMapManifest<TDto, TModel>(
|
||||
json,
|
||||
manifestUri.ToString(),
|
||||
map,
|
||||
swallowDeserializationErrors: false)!;
|
||||
}
|
||||
|
||||
private static TModel? DeserializeAndMapManifest<TDto, TModel>(
|
||||
private TModel? DeserializeAndMapManifest<TDto, TModel>(
|
||||
string json,
|
||||
string sourceName,
|
||||
Func<TDto, TModel> map,
|
||||
@@ -312,9 +422,11 @@ namespace AlayaCore.Services
|
||||
{
|
||||
if (swallowDeserializationErrors)
|
||||
{
|
||||
_logger.LogWarning("Manifest source {SourceName} was empty and deserialization errors were configured to be swallowed.", sourceName);
|
||||
return default;
|
||||
}
|
||||
|
||||
_logger.LogError("Manifest source {SourceName} was empty.", sourceName);
|
||||
throw new InvalidDataException($"Manifest source '{sourceName}' was empty.");
|
||||
}
|
||||
|
||||
@@ -337,9 +449,11 @@ namespace AlayaCore.Services
|
||||
{
|
||||
if (swallowDeserializationErrors)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize manifest source {SourceName} to {DtoType}. Returning default because deserialization errors are being swallowed.", sourceName, typeof(TDto).Name);
|
||||
return default;
|
||||
}
|
||||
|
||||
_logger.LogError(ex, "Failed to deserialize manifest source {SourceName} to {DtoType}.", sourceName, typeof(TDto).Name);
|
||||
throw new JsonSerializationException(
|
||||
$"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.",
|
||||
ex);
|
||||
@@ -349,24 +463,34 @@ namespace AlayaCore.Services
|
||||
{
|
||||
if (swallowDeserializationErrors)
|
||||
{
|
||||
_logger.LogWarning("Deserialization of manifest source {SourceName} to {DtoType} returned null. Returning default because deserialization errors are being swallowed.", sourceName, typeof(TDto).Name);
|
||||
return default;
|
||||
}
|
||||
|
||||
_logger.LogError("Deserialization of manifest source {SourceName} to {DtoType} returned null.", sourceName, typeof(TDto).Name);
|
||||
throw new JsonSerializationException(
|
||||
$"Failed to deserialize manifest source '{sourceName}' to {typeof(TDto).Name}.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return map(dto);
|
||||
TModel model = map(dto);
|
||||
_logger.LogDebug(
|
||||
"Mapped manifest source {SourceName} from {DtoType} to {ModelType}.",
|
||||
sourceName,
|
||||
typeof(TDto).Name,
|
||||
typeof(TModel).Name);
|
||||
return model;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
if (swallowDeserializationErrors)
|
||||
{
|
||||
_logger.LogWarning(ex, "Manifest source {SourceName} was deserialized but could not be mapped to {ModelType}. Returning default because mapping errors are being swallowed.", sourceName, typeof(TModel).Name);
|
||||
return default;
|
||||
}
|
||||
|
||||
_logger.LogError(ex, "Manifest source {SourceName} was deserialized but could not be mapped to {ModelType}.", sourceName, typeof(TModel).Name);
|
||||
throw new InvalidDataException(
|
||||
$"Manifest source '{sourceName}' was deserialized but could not be mapped to {typeof(TModel).Name}.",
|
||||
ex);
|
||||
|
||||
@@ -7,14 +7,17 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AlayaCore.Abstractions.Interfaces;
|
||||
using AlayaCore.Abstractions.Interfaces.Clients;
|
||||
using AlayaCore.Abstractions.Interfaces.Policies;
|
||||
using AlayaCore.Abstractions.Interfaces.Services;
|
||||
using AlayaCore.Installation;
|
||||
using AlayaCore.Models;
|
||||
using AlayaCore.Models.Configuration;
|
||||
using AlayaCore.Models.Manifests;
|
||||
using AlayaCore.Models.Manifests.DTO;
|
||||
using AlayaCore.Models.Progress;
|
||||
using AlayaCore.Utilities.Enums;
|
||||
using AlayaCore.Utilities.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
@@ -28,22 +31,29 @@ namespace AlayaCore.Services
|
||||
private readonly ModrinthConnectionOptions _options;
|
||||
private readonly IHttpClient _httpClient;
|
||||
private readonly IFileStore _fileStore;
|
||||
private readonly IRetryPolicy _retryPolicy;
|
||||
private readonly ILogger<ModService> _logger;
|
||||
|
||||
public ModService(
|
||||
IDownloadService downloadService,
|
||||
ModrinthConnectionOptions options,
|
||||
IHttpClient httpClient,
|
||||
IFileStore fileStore)
|
||||
IFileStore fileStore,
|
||||
IRetryPolicy retryPolicy,
|
||||
ILogger<ModService> logger)
|
||||
{
|
||||
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
|
||||
_retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task ProcessModsAsync(
|
||||
ManifestModel manifest,
|
||||
InstallEnvironment environment,
|
||||
IProgress<DownloadProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (manifest == null)
|
||||
@@ -62,9 +72,15 @@ namespace AlayaCore.Services
|
||||
|
||||
List<ModFileEntry> requiredMods = manifest.Files
|
||||
.Where(file => file.Type == FileType.Mod)
|
||||
.OrderBy(file => file.FileName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
RemoveStaleMods(requiredMods);
|
||||
_logger.LogInformation(
|
||||
"Starting mod sync. RequiredMods: {RequiredModCount}, InstalledModsManifestEntries: {InstalledModCount}",
|
||||
requiredMods.Count,
|
||||
installedMods.Count);
|
||||
|
||||
RemoveStaleMods(requiredMods, cancellationToken);
|
||||
|
||||
List<ModFileEntry> finalInstalledMods = new List<ModFileEntry>();
|
||||
|
||||
@@ -72,6 +88,12 @@ namespace AlayaCore.Services
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Processing required mod {FileName}. Expected SHA-512: {Sha512Hash}, Expected Size: {Size}",
|
||||
requiredMod.FileName,
|
||||
requiredMod.Sha512Hash,
|
||||
requiredMod.Size);
|
||||
|
||||
ModFileEntry? installedMod = installedMods.FirstOrDefault(
|
||||
mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -85,18 +107,50 @@ namespace AlayaCore.Services
|
||||
|
||||
if (isValidInstalledMod)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Mod {FileName} is already installed and valid. Skipping download.",
|
||||
requiredMod.FileName);
|
||||
|
||||
finalInstalledMods.Add(installedMod!);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (installedMod == null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Mod {FileName} is missing locally and will be downloaded.",
|
||||
requiredMod.FileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Mod {FileName} is present but invalid or outdated. Stored SHA-512: {InstalledHash}, Expected SHA-512: {RequiredHash}, Stored Size: {InstalledSize}, Expected Size: {RequiredSize}",
|
||||
requiredMod.FileName,
|
||||
installedMod.Sha512Hash,
|
||||
requiredMod.Sha512Hash,
|
||||
installedMod.Size,
|
||||
requiredMod.Size);
|
||||
}
|
||||
|
||||
Uri modUri = await ResolveModUrlAsync(requiredMod, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Downloading mod {FileName} from {ModUri} to {DestinationPath}.",
|
||||
requiredMod.FileName,
|
||||
modUri,
|
||||
destinationPath);
|
||||
|
||||
await _downloadService.DownloadFileAsync(
|
||||
modUri,
|
||||
destinationPath,
|
||||
requiredMod.Sha512Hash,
|
||||
progress,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Download completed successfully for mod {FileName}.",
|
||||
requiredMod.FileName);
|
||||
|
||||
finalInstalledMods.Add(new ModFileEntry(
|
||||
requiredMod.FileName,
|
||||
requiredMod.Type,
|
||||
@@ -105,6 +159,10 @@ namespace AlayaCore.Services
|
||||
}
|
||||
|
||||
await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Mod sync completed successfully. Final installed mod count: {InstalledModCount}",
|
||||
finalInstalledMods.Count);
|
||||
}
|
||||
|
||||
private static bool IsInstalledModUpToDate(
|
||||
@@ -145,90 +203,156 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fileEntry.Sha512Hash))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Failed to resolve mod URL because mod {FileName} did not contain a SHA-512 hash.",
|
||||
fileEntry.FileName);
|
||||
|
||||
throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a SHA-512 hash.");
|
||||
}
|
||||
|
||||
string versionEndpoint = BuildVersionEndpoint(fileEntry.Sha512Hash);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||
new Uri(versionEndpoint, UriKind.Absolute),
|
||||
HttpCompletionOption.ResponseContentRead,
|
||||
_logger.LogDebug(
|
||||
"Resolving mod URL for {FileName} using Modrinth endpoint {VersionEndpoint}.",
|
||||
fileEntry.FileName,
|
||||
versionEndpoint);
|
||||
|
||||
return await _retryPolicy.ExecuteAsync(
|
||||
async token =>
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||
new Uri(versionEndpoint, UriKind.Absolute),
|
||||
HttpCompletionOption.ResponseContentRead,
|
||||
token).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Mod metadata response for {FileName} from {VersionEndpoint} was empty.",
|
||||
fileEntry.FileName,
|
||||
versionEndpoint);
|
||||
|
||||
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty.");
|
||||
}
|
||||
|
||||
JObject jsonObject = JObject.Parse(json);
|
||||
|
||||
JArray? filesArray = jsonObject["files"] as JArray;
|
||||
if (filesArray == null || filesArray.Count == 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Mod metadata response for {FileName} did not contain any files.",
|
||||
fileEntry.FileName);
|
||||
|
||||
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files.");
|
||||
}
|
||||
|
||||
JObject? selectedFile = filesArray
|
||||
.OfType<JObject>()
|
||||
.FirstOrDefault(file => file.Value<bool?>("primary") == true)
|
||||
?? filesArray.OfType<JObject>().FirstOrDefault();
|
||||
|
||||
if (selectedFile == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Mod metadata response for {FileName} did not contain a usable file entry.",
|
||||
fileEntry.FileName);
|
||||
|
||||
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry.");
|
||||
}
|
||||
|
||||
JObject? hashesObject = selectedFile["hashes"] as JObject;
|
||||
if (hashesObject == null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Mod metadata response for {FileName} did not contain a hashes object.",
|
||||
fileEntry.FileName);
|
||||
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata response for '{fileEntry.FileName}' did not contain a hashes object.");
|
||||
}
|
||||
|
||||
string? remoteSha512Hash = hashesObject.Value<string>("sha512");
|
||||
if (string.IsNullOrWhiteSpace(remoteSha512Hash))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Mod metadata response for {FileName} did not contain a valid SHA-512 hash.",
|
||||
fileEntry.FileName);
|
||||
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash.");
|
||||
}
|
||||
|
||||
if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Mod metadata hash mismatch for {FileName}. Remote SHA-512: {RemoteHash}, Required SHA-512: {RequiredHash}",
|
||||
fileEntry.FileName,
|
||||
remoteSha512Hash,
|
||||
fileEntry.Sha512Hash);
|
||||
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash.");
|
||||
}
|
||||
|
||||
long? size = selectedFile.Value<long?>("size");
|
||||
if (!size.HasValue || size.Value <= 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Mod metadata response for {FileName} did not contain a valid file size.",
|
||||
fileEntry.FileName);
|
||||
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
|
||||
}
|
||||
|
||||
if (size.Value != fileEntry.Size)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Mod metadata size mismatch for {FileName}. Remote Size: {RemoteSize}, Required Size: {RequiredSize}",
|
||||
fileEntry.FileName,
|
||||
size.Value,
|
||||
fileEntry.Size);
|
||||
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size.");
|
||||
}
|
||||
|
||||
string? modUrl = selectedFile.Value<string>("url");
|
||||
if (string.IsNullOrWhiteSpace(modUrl))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Mod metadata response for {FileName} did not contain a valid file URL.",
|
||||
fileEntry.FileName);
|
||||
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Mod metadata response for {FileName} contained an invalid file URL: {ModUrl}",
|
||||
fileEntry.FileName,
|
||||
modUrl);
|
||||
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL.");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Resolved download URL for mod {FileName} to {ModUrl}.",
|
||||
fileEntry.FileName,
|
||||
result);
|
||||
|
||||
return result;
|
||||
},
|
||||
$"mod-metadata:{fileEntry.FileName}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty.");
|
||||
}
|
||||
|
||||
JObject jsonObject = JObject.Parse(json);
|
||||
|
||||
JArray? filesArray = jsonObject["files"] as JArray;
|
||||
if (filesArray == null || filesArray.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files.");
|
||||
}
|
||||
|
||||
JObject? selectedFile = filesArray
|
||||
.OfType<JObject>()
|
||||
.FirstOrDefault(file => file.Value<bool?>("primary") == true)
|
||||
?? filesArray.OfType<JObject>().FirstOrDefault();
|
||||
|
||||
if (selectedFile == null)
|
||||
{
|
||||
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry.");
|
||||
}
|
||||
|
||||
JObject? hashesObject = selectedFile["hashes"] as JObject;
|
||||
if (hashesObject == null)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata response for '{fileEntry.FileName}' did not contain a hashes object.");
|
||||
}
|
||||
|
||||
string? remoteSha512Hash = hashesObject.Value<string>("sha512");
|
||||
if (string.IsNullOrWhiteSpace(remoteSha512Hash))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash.");
|
||||
}
|
||||
|
||||
if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash.");
|
||||
}
|
||||
|
||||
long? size = selectedFile.Value<long?>("size");
|
||||
if (!size.HasValue || size.Value <= 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
|
||||
}
|
||||
|
||||
if (size.Value != fileEntry.Size)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size.");
|
||||
}
|
||||
|
||||
string? modUrl = selectedFile.Value<string>("url");
|
||||
if (string.IsNullOrWhiteSpace(modUrl))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string BuildVersionEndpoint(string sha512Hash)
|
||||
@@ -252,26 +376,37 @@ namespace AlayaCore.Services
|
||||
}
|
||||
|
||||
string modsDirectory = GetModsDirectoryPath();
|
||||
string destinationPath = Path.Combine(modsDirectory, fileEntry.FileName);
|
||||
|
||||
return Path.Combine(modsDirectory, fileEntry.FileName);
|
||||
_logger.LogDebug(
|
||||
"Resolved destination path for mod {FileName} to {DestinationPath}.",
|
||||
fileEntry.FileName,
|
||||
destinationPath);
|
||||
|
||||
return destinationPath;
|
||||
}
|
||||
|
||||
private string GetModsDirectoryPath()
|
||||
{
|
||||
return _fileStore.GetOrCreate(FolderLocation.Mods);
|
||||
string modsDirectory = _fileStore.GetOrCreate(FolderLocation.Mods);
|
||||
_logger.LogDebug("Resolved mods directory to {ModsDirectory}.", modsDirectory);
|
||||
return modsDirectory;
|
||||
}
|
||||
|
||||
private void RemoveStaleMods(IEnumerable<ModFileEntry> requiredMods)
|
||||
private void RemoveStaleMods(
|
||||
IEnumerable<ModFileEntry> requiredMods,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (requiredMods == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(requiredMods));
|
||||
}
|
||||
|
||||
string modsDirectory = GetModsDirectoryPath();
|
||||
string modsDirectory = _fileStore.Get(FolderLocation.Mods);
|
||||
|
||||
if (!Directory.Exists(modsDirectory))
|
||||
{
|
||||
_logger.LogDebug("Mods directory {ModsDirectory} does not exist. No stale mods need removal.", modsDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,10 +417,17 @@ namespace AlayaCore.Services
|
||||
|
||||
foreach (string filePath in Directory.GetFiles(modsDirectory))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
string fileName = Path.GetFileName(filePath);
|
||||
|
||||
if (!requiredFileNames.Contains(fileName))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Removing stale mod file {FileName} at {FilePath}.",
|
||||
fileName,
|
||||
filePath);
|
||||
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
@@ -312,14 +454,25 @@ namespace AlayaCore.Services
|
||||
|
||||
string json = JsonConvert.SerializeObject(dto, Formatting.Indented);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Writing installed mods manifest to temporary path {TemporaryManifestPath}. EntryCount: {EntryCount}",
|
||||
temporaryManifestPath,
|
||||
entries.Count);
|
||||
|
||||
await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
_logger.LogDebug("Deleting previous installed mods manifest at {ManifestPath}.", manifestPath);
|
||||
File.Delete(manifestPath);
|
||||
}
|
||||
|
||||
File.Move(temporaryManifestPath, manifestPath);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Installed mods manifest updated successfully at {ManifestPath}. EntryCount: {EntryCount}",
|
||||
manifestPath,
|
||||
entries.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using AlayaCore.Abstractions.Interfaces;
|
||||
using AlayaCore.Abstractions.Interfaces.Services;
|
||||
using AlayaCore.Models.Configuration;
|
||||
using AlayaCore.Utilities.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AlayaCore.Services
|
||||
@@ -14,6 +15,7 @@ namespace AlayaCore.Services
|
||||
public sealed class SettingsService : ISettingsService
|
||||
{
|
||||
private readonly IFileStore _fileStore;
|
||||
private readonly ILogger<SettingsService> _logger;
|
||||
|
||||
public LauncherOptions LauncherOptions { get; }
|
||||
public GameOptions GameOptions { get; }
|
||||
@@ -21,84 +23,326 @@ namespace AlayaCore.Services
|
||||
public SettingsService(
|
||||
LauncherOptions launcherOptions,
|
||||
GameOptions gameOptions,
|
||||
IFileStore fileStore)
|
||||
IFileStore fileStore,
|
||||
ILogger<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)
|
||||
{
|
||||
GameOptions.LaunchVersion = newVersion ?? string.Empty;
|
||||
string? normalizedVersion = string.IsNullOrWhiteSpace(newVersion)
|
||||
? null
|
||||
: newVersion.Trim();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updating game option LaunchVersion from {CurrentLaunchVersion} to {NewLaunchVersion}.",
|
||||
GameOptions.LaunchVersion,
|
||||
normalizedVersion);
|
||||
|
||||
GameOptions.LaunchVersion = normalizedVersion;
|
||||
|
||||
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Game option LaunchVersion was updated and saved successfully.");
|
||||
}
|
||||
|
||||
public async Task SetMinimumRamMbAsync(int minimumRamMb, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (minimumRamMb <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(minimumRamMb), "Minimum RAM must be greater than zero.");
|
||||
}
|
||||
|
||||
if (GameOptions.MaximumRamMb > 0 && minimumRamMb > GameOptions.MaximumRamMb)
|
||||
{
|
||||
throw new ArgumentException("Minimum RAM cannot be greater than maximum RAM.", nameof(minimumRamMb));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updating game option MinimumRamMb from {CurrentMinimumRamMb} to {NewMinimumRamMb}.",
|
||||
GameOptions.MinimumRamMb,
|
||||
minimumRamMb);
|
||||
|
||||
GameOptions.MinimumRamMb = minimumRamMb;
|
||||
|
||||
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Game option MinimumRamMb was updated and saved successfully.");
|
||||
}
|
||||
|
||||
public async Task SetMaximumRamMbAsync(int maximumRamMb, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (maximumRamMb <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maximumRamMb), "Maximum RAM must be greater than zero.");
|
||||
}
|
||||
|
||||
if (GameOptions.MinimumRamMb > 0 && maximumRamMb < GameOptions.MinimumRamMb)
|
||||
{
|
||||
throw new ArgumentException("Maximum RAM cannot be less than minimum RAM.", nameof(maximumRamMb));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updating game option MaximumRamMb from {CurrentMaximumRamMb} to {NewMaximumRamMb}.",
|
||||
GameOptions.MaximumRamMb,
|
||||
maximumRamMb);
|
||||
|
||||
GameOptions.MaximumRamMb = maximumRamMb;
|
||||
|
||||
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Game option MaximumRamMb was updated and saved successfully.");
|
||||
}
|
||||
|
||||
public async Task SetResolutionAsync(int screenWidth, int screenHeight, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (screenWidth <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(screenWidth), "Screen width must be greater than zero.");
|
||||
}
|
||||
|
||||
if (screenHeight <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(screenHeight), "Screen height must be greater than zero.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updating game resolution from {CurrentWidth}x{CurrentHeight} to {NewWidth}x{NewHeight}.",
|
||||
GameOptions.ScreenWidth,
|
||||
GameOptions.ScreenHeight,
|
||||
screenWidth,
|
||||
screenHeight);
|
||||
|
||||
GameOptions.ScreenWidth = screenWidth;
|
||||
GameOptions.ScreenHeight = screenHeight;
|
||||
|
||||
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Game resolution was updated and saved successfully.");
|
||||
}
|
||||
|
||||
public async Task SetFullscreenAsync(bool fullscreen, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Updating game option Fullscreen from {CurrentFullscreen} to {NewFullscreen}.",
|
||||
GameOptions.Fullscreen,
|
||||
fullscreen);
|
||||
|
||||
GameOptions.Fullscreen = fullscreen;
|
||||
|
||||
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Game option Fullscreen was updated and saved successfully.");
|
||||
}
|
||||
|
||||
public async Task SaveLauncherOptionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug("Saving launcher options to disk.");
|
||||
|
||||
await SaveAsync(
|
||||
LauncherOptions.FileName,
|
||||
LauncherOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Launcher options were saved successfully.");
|
||||
}
|
||||
|
||||
public async Task LoadLauncherOptionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug("Loading launcher options from disk.");
|
||||
|
||||
LauncherOptions? loadedOptions = await LoadAsync<LauncherOptions>(
|
||||
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;
|
||||
}
|
||||
|
||||
GameOptions.LaunchVersion = loadedOptions.LaunchVersion;
|
||||
GameOptions.MinimumRamMB = loadedOptions.MinimumRamMB;
|
||||
GameOptions.MaximumRamMB = loadedOptions.MaximumRamMB;
|
||||
GameOptions.ScreenWidth = loadedOptions.ScreenWidth;
|
||||
GameOptions.ScreenHeight = loadedOptions.ScreenHeight;
|
||||
GameOptions.Fullscreen = loadedOptions.Fullscreen;
|
||||
ApplyGameOptions(loadedOptions);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Game options were loaded successfully. LaunchVersion: {LaunchVersion}, MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, Resolution: {ScreenWidth}x{ScreenHeight}, Fullscreen: {Fullscreen}",
|
||||
GameOptions.LaunchVersion,
|
||||
GameOptions.MinimumRamMb,
|
||||
GameOptions.MaximumRamMb,
|
||||
GameOptions.ScreenWidth,
|
||||
GameOptions.ScreenHeight,
|
||||
GameOptions.Fullscreen);
|
||||
}
|
||||
|
||||
public async Task LoadAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Loading all settings from disk.");
|
||||
|
||||
await LoadLauncherOptionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await LoadGameOptionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("All settings were loaded successfully.");
|
||||
}
|
||||
|
||||
public async Task SaveAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Saving all settings to disk.");
|
||||
|
||||
await SaveLauncherOptionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await SaveGameOptionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("All settings were saved successfully.");
|
||||
}
|
||||
|
||||
private void ApplyGameOptions(GameOptions source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(source));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Applying game options from source. LaunchVersion: {LaunchVersion}, MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, Resolution: {ScreenWidth}x{ScreenHeight}, Fullscreen: {Fullscreen}",
|
||||
source.LaunchVersion,
|
||||
source.MinimumRamMb,
|
||||
source.MaximumRamMb,
|
||||
source.ScreenWidth,
|
||||
source.ScreenHeight,
|
||||
source.Fullscreen);
|
||||
|
||||
GameOptions.LaunchVersion = string.IsNullOrWhiteSpace(source.LaunchVersion)
|
||||
? null
|
||||
: source.LaunchVersion.Trim();
|
||||
|
||||
GameOptions.MinimumRamMb = source.MinimumRamMb > 0
|
||||
? source.MinimumRamMb
|
||||
: GameOptions.Default.MinimumRamMb;
|
||||
|
||||
GameOptions.MaximumRamMb = source.MaximumRamMb > 0
|
||||
? source.MaximumRamMb
|
||||
: GameOptions.Default.MaximumRamMb;
|
||||
|
||||
GameOptions.ScreenWidth = source.ScreenWidth > 0
|
||||
? source.ScreenWidth
|
||||
: GameOptions.Default.ScreenWidth;
|
||||
|
||||
GameOptions.ScreenHeight = source.ScreenHeight > 0
|
||||
? source.ScreenHeight
|
||||
: GameOptions.Default.ScreenHeight;
|
||||
|
||||
GameOptions.Fullscreen = source.Fullscreen;
|
||||
|
||||
ValidateGameOptions(GameOptions);
|
||||
|
||||
_logger.LogDebug("Game options were applied successfully.");
|
||||
}
|
||||
|
||||
private void ValidateGameOptions(GameOptions options)
|
||||
{
|
||||
if (options == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Validating game options. MinimumRamMb: {MinimumRamMb}, MaximumRamMb: {MaximumRamMb}, ScreenWidth: {ScreenWidth}, ScreenHeight: {ScreenHeight}",
|
||||
options.MinimumRamMb,
|
||||
options.MaximumRamMb,
|
||||
options.ScreenWidth,
|
||||
options.ScreenHeight);
|
||||
|
||||
if (options.MinimumRamMb <= 0)
|
||||
{
|
||||
_logger.LogError("Game options validation failed because MinimumRamMb was {MinimumRamMb}.", options.MinimumRamMb);
|
||||
throw new InvalidDataException("Minimum RAM must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.MaximumRamMb <= 0)
|
||||
{
|
||||
_logger.LogError("Game options validation failed because MaximumRamMb was {MaximumRamMb}.", options.MaximumRamMb);
|
||||
throw new InvalidDataException("Maximum RAM must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.MinimumRamMb > options.MaximumRamMb)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Game options validation failed because MinimumRamMb {MinimumRamMb} was greater than MaximumRamMb {MaximumRamMb}.",
|
||||
options.MinimumRamMb,
|
||||
options.MaximumRamMb);
|
||||
|
||||
throw new InvalidDataException("Minimum RAM cannot be greater than maximum RAM.");
|
||||
}
|
||||
|
||||
if (options.ScreenWidth <= 0)
|
||||
{
|
||||
_logger.LogError("Game options validation failed because ScreenWidth was {ScreenWidth}.", options.ScreenWidth);
|
||||
throw new InvalidDataException("Screen width must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.ScreenHeight <= 0)
|
||||
{
|
||||
_logger.LogError("Game options validation failed because ScreenHeight was {ScreenHeight}.", options.ScreenHeight);
|
||||
throw new InvalidDataException("Screen height must be greater than zero.");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Game options validation completed successfully.");
|
||||
}
|
||||
|
||||
private async Task SaveAsync<T>(
|
||||
@@ -123,6 +367,7 @@ namespace AlayaCore.Services
|
||||
|
||||
if (string.IsNullOrWhiteSpace(directoryPath))
|
||||
{
|
||||
_logger.LogError("Could not resolve the settings directory path for file {FileName}.", fileName);
|
||||
throw new InvalidOperationException("Could not resolve the settings directory path.");
|
||||
}
|
||||
|
||||
@@ -131,14 +376,23 @@ namespace AlayaCore.Services
|
||||
string temporaryPath = fullPath + ".tmp";
|
||||
string json = JsonConvert.SerializeObject(value, Formatting.Indented);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Writing settings file {FileName} to temporary path {TemporaryPath} before replacing {FullPath}.",
|
||||
fileName,
|
||||
temporaryPath,
|
||||
fullPath);
|
||||
|
||||
await File.WriteAllTextAsync(temporaryPath, json, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogDebug("Deleting existing settings file at {FullPath}.", fullPath);
|
||||
File.Delete(fullPath);
|
||||
}
|
||||
|
||||
File.Move(temporaryPath, fullPath);
|
||||
|
||||
_logger.LogInformation("Settings file {FileName} was saved successfully to {FullPath}.", fileName, fullPath);
|
||||
}
|
||||
|
||||
private async Task<T?> LoadAsync<T>(
|
||||
@@ -156,22 +410,38 @@ namespace AlayaCore.Services
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogInformation("Settings file {FileName} was not found at {FullPath}.", fileName, fullPath);
|
||||
return default;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loading settings file {FileName} from {FullPath}.", fileName, fullPath);
|
||||
|
||||
string json = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
_logger.LogWarning("Settings file {FileName} at {FullPath} was empty.", fileName, fullPath);
|
||||
return default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonConvert.DeserializeObject<T>(json);
|
||||
T? result = JsonConvert.DeserializeObject<T>(json);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
_logger.LogWarning("Deserializing settings file {FileName} at {FullPath} returned null.", fileName, fullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Settings file {FileName} was deserialized successfully.", fileName);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize settings file {FileName} at {FullPath} to {TypeName}.", fileName, fullPath, typeof(T).Name);
|
||||
throw new InvalidDataException(
|
||||
$"Failed to deserialize settings file '{fullPath}' to {typeof(T).Name}.",
|
||||
ex);
|
||||
@@ -180,7 +450,9 @@ namespace AlayaCore.Services
|
||||
|
||||
private string GetFullPath(string fileName)
|
||||
{
|
||||
return Path.Combine(_fileStore.GetOrCreate(FolderLocation.Config), fileName);
|
||||
string fullPath = Path.Combine(_fileStore.GetOrCreate(FolderLocation.Config), fileName);
|
||||
_logger.LogDebug("Resolved settings file path for {FileName} to {FullPath}.", fileName, fullPath);
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ namespace AlayaCore.States
|
||||
{
|
||||
public enum LaunchState
|
||||
{
|
||||
Checking,
|
||||
Ready,
|
||||
LauncherNeedsUpdate,
|
||||
NeedAuthenticating,
|
||||
|
||||
16
AlayaCore/Utilities/Enums/LauncherErrorType.cs
Normal file
16
AlayaCore/Utilities/Enums/LauncherErrorType.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace AlayaCore.Utilities.Enums
|
||||
{
|
||||
public enum LauncherErrorType
|
||||
{
|
||||
Unknown,
|
||||
Network,
|
||||
Manifest,
|
||||
Authentication,
|
||||
Download,
|
||||
Installation,
|
||||
Update,
|
||||
Launch,
|
||||
Configuration,
|
||||
Cancelled
|
||||
}
|
||||
}
|
||||
56
AlayaCore/Utilities/Helpers/ErrorHelper.cs
Normal file
56
AlayaCore/Utilities/Helpers/ErrorHelper.cs
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
21
AlayaCore/Utilities/Helpers/OptionsHelper.cs
Normal file
21
AlayaCore/Utilities/Helpers/OptionsHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user