diff --git a/Emerald.CoreX.Tests/Installers/ModLoaderRouterTests.cs b/Emerald.CoreX.Tests/Installers/ModLoaderRouterTests.cs new file mode 100644 index 00000000..9dd6b1a0 --- /dev/null +++ b/Emerald.CoreX.Tests/Installers/ModLoaderRouterTests.cs @@ -0,0 +1,82 @@ +using CmlLib.Core; +using Emerald.CoreX.Installers; +using Xunit; +using LauncherVersion = Emerald.CoreX.Versions.Version; +using LauncherVersionType = Emerald.CoreX.Versions.Type; + +namespace Emerald.CoreX.Tests.Installers; + +public sealed class ModLoaderRouterTests +{ + [Fact] + public async Task RouteAndInitializeAsync_OfflineWithInstalledVersion_ReturnsInstalledVersionWithoutInstaller() + { + var installer = new RecordingInstaller(LauncherVersionType.Fabric); + var router = new ModLoaderRouter([installer]); + var version = new LauncherVersion + { + Type = LauncherVersionType.Fabric, + BasedOn = "1.21.4", + ModVersion = "0.16.10", + RealVersion = "fabric-loader-0.16.10-1.21.4" + }; + + var resolved = await router.RouteAndInitializeAsync( + new MinecraftPath("/tmp/emerald-router-test"), + version, + online: false, + installedVersion: version.RealVersion); + + Assert.Equal(version.RealVersion, resolved); + Assert.Empty(installer.Calls); + } + + [Fact] + public async Task RouteAndInitializeAsync_OfflineWithoutInstalledVersion_PassesOfflineFlagToInstaller() + { + var installer = new RecordingInstaller(LauncherVersionType.Fabric) + { + ResultVersion = "fabric-loader-0.16.10-1.21.4" + }; + var router = new ModLoaderRouter([installer]); + + var resolved = await router.RouteAndInitializeAsync( + new MinecraftPath("/tmp/emerald-router-test"), + new LauncherVersion + { + Type = LauncherVersionType.Fabric, + BasedOn = "1.21.4", + ModVersion = "0.16.10" + }, + online: false); + + Assert.Equal("fabric-loader-0.16.10-1.21.4", resolved); + var call = Assert.Single(installer.Calls); + Assert.False(call.Online); + Assert.Equal("1.21.4", call.MinecraftVersion); + Assert.Equal("0.16.10", call.ModVersion); + } + + private sealed class RecordingInstaller(LauncherVersionType type) : IModLoaderInstaller + { + public List Calls { get; } = []; + public string ResultVersion { get; set; } = "resolved-version"; + + public LauncherVersionType Type { get; } = type; + + public Task> GetVersionsAsync(string mcVersion) + => Task.FromResult(new List()); + + public Task InstallAsync( + MinecraftPath path, + string mcversion, + string? modversion = null, + bool online = true) + { + Calls.Add(new InstallCall(mcversion, modversion, online)); + return Task.FromResult(ResultVersion); + } + } + + private sealed record InstallCall(string MinecraftVersion, string? ModVersion, bool Online); +} diff --git a/Emerald.CoreX.Tests/Services/AccountServiceTests.cs b/Emerald.CoreX.Tests/Services/AccountServiceTests.cs index 89db4bef..cc8ae712 100644 --- a/Emerald.CoreX.Tests/Services/AccountServiceTests.cs +++ b/Emerald.CoreX.Tests/Services/AccountServiceTests.cs @@ -64,6 +64,28 @@ public async Task AuthenticateAccountAsync_OfflineWithoutMicrosoftAccount_Authen Assert.Equal("Alpha", result.Session.Username); } + [Fact] + public async Task AuthenticateLaunchAccountAsync_OfflineFallback_CreatesOfflineAccountFromSelectedAccountName() + { + var baseSettingsService = new InMemoryBaseSettingsService(); + var microsoftClient = new FakeMicrosoftAccountClient(); + var service = CreateService(baseSettingsService, microsoftClient); + var microsoft = new EAccount("Alpha", AccountType.Microsoft, "alpha-uuid", "ms-alpha"); + service.Accounts.Add(microsoft); + service.SetSelectedAccount(microsoft); + + var result = await service.AuthenticateLaunchAccountAsync(microsoft, useOfflineFallback: true); + + Assert.Equal("Alpha", result.Session.Username); + Assert.Empty(microsoftClient.AuthenticatedIdentifiers); + var offline = Assert.Single(service.Accounts, account => account.Type == AccountType.Offline && account.Name == "Alpha"); + Assert.NotEqual(microsoft.UniqueId, offline.UniqueId); + + var storedAccounts = baseSettingsService.Peek>(SettingsKeys.MinecraftAccounts); + Assert.NotNull(storedAccounts); + Assert.Contains(storedAccounts!, account => account.Type == AccountType.Offline && account.Name == "Alpha"); + } + [Fact] public async Task SignInElyByAccountAsync_WithoutMicrosoftAccount_UsesBrowserOAuth() { diff --git a/Emerald.CoreX/Core.cs b/Emerald.CoreX/Core.cs index d081d117..513265c7 100644 --- a/Emerald.CoreX/Core.cs +++ b/Emerald.CoreX/Core.cs @@ -74,7 +74,8 @@ public partial class Core( public bool IsRunning { get; set; } = false; public MinecraftPath? BasePath { get; private set; } = null; - public bool IsOfflineMode { get; private set; } = false; + [ObservableProperty] + private bool _isOfflineMode = false; public readonly ObservableCollection VanillaVersions = new(); @@ -242,10 +243,11 @@ public async Task InitializeAndRefresh(MinecraftPath? basePath = null) IsOfflineMode = false; _notify.Complete(not.Id, true); } - catch (HttpRequestException) + catch (HttpRequestException ex) { - IsOfflineMode = false; - _notify.Complete(not.Id, true,"OfflineMode"); + _logger.LogWarning(ex, "Failed to load vanilla Minecraft versions; continuing in offline mode."); + IsOfflineMode = true; + _notify.Complete(not.Id, true, "OfflineMode"); } catch (Exception ex) { diff --git a/Emerald.CoreX/Game.cs b/Emerald.CoreX/Game.cs index fd112e5a..e8aa1ec6 100644 --- a/Emerald.CoreX/Game.cs +++ b/Emerald.CoreX/Game.cs @@ -30,6 +30,7 @@ public partial class Game : ObservableObject public MinecraftPath Path { get; private set; } public string? SharedMinecraftBasePath => _sharedMinecraftBasePath; + public bool IsLauncherOfflineMode => _launcherOfflineMode; [ObservableProperty] private bool _usesCustomGameSettings; @@ -186,7 +187,13 @@ public async Task InstallVersionOrThrow(bool isOffline = false, bool showFilePro try { - string? ver = await Ioc.Default.GetService().RouteAndInitializeAsync(Path, Version); + var modLoaderRouter = Ioc.Default.GetService() + ?? throw new InvalidOperationException("Mod loader router service is not available."); + string? ver = await modLoaderRouter.RouteAndInitializeAsync( + Path, + Version, + online: !isOffline, + installedVersion: Version.RealVersion); _logger.LogInformation("Version initialization completed. Version: {Version}", ver); if (ver == null) @@ -204,29 +211,15 @@ public async Task InstallVersionOrThrow(bool isOffline = false, bool showFilePro if (isOffline) { _logger.LogDebug("Validating version {Version} against the local offline manifest cache.", ver); - var vers = await Launcher.GetAllVersionsAsync(); - var mver = vers.Where(x => x.Name == ver).First(); - if (mver == null) + if (!await IsVersionAvailableLocallyAsync(ver)) { _logger.LogWarning("Version {Version} not found in offline mode. Can't proceed installation.", ver); - throw new NullReferenceException($"Version {ver} not found in offline mode. Can't proceed installation."); + throw new InvalidOperationException($"Version {ver} not found in offline mode. Can't proceed installation."); } } Version.RealVersion = ver; - if (isOffline) - { - _logger.LogDebug("Rechecking offline version {Version} before install.", ver); - var vers = await Launcher.GetAllVersionsAsync(); - var mver = vers.Where(x => x.Name == ver).First(); - if (mver == null) - { - _logger.LogWarning("Version {Version} not found in offline mode. Can't proceed installation.", ver); - throw new NullReferenceException($"Version {ver} not found in offline mode. Can't proceed installation."); - } - } - (string Files, string bytes, double prog, double? progbytes) prog = (string.Empty, string.Empty, 0, null); void UpdateProg() @@ -277,6 +270,12 @@ await Launcher.InstallAsync( } } + private async Task IsVersionAvailableLocallyAsync(string version) + { + var versions = await Launcher.GetAllVersionsAsync(); + return versions.Any(candidate => string.Equals(candidate.Name, version, StringComparison.Ordinal)); + } + public async Task BuildProcess( string version, CmlLib.Core.Auth.MSession session, @@ -314,6 +313,12 @@ public async Task BuildProcess( validation.Version); } + _logger.LogDebug( + "Verifying launch files for {Version} before building the process. OfflineMode: {OfflineMode}.", + version, + _launcherOfflineMode); + await Launcher.InstallAsync(version); + _logger.LogDebug("Preparing launch options for {Version}. FullScreen: {FullScreen}. DockName: {DockName}.", version, EffectiveSettings.FullScreen, EffectiveSettings.DockName); return await Launcher.BuildProcessAsync(version, launchOpt); } diff --git a/Emerald.CoreX/Installers/ModLoaderRouter.cs b/Emerald.CoreX/Installers/ModLoaderRouter.cs index 59a981d1..b3300fcd 100644 --- a/Emerald.CoreX/Installers/ModLoaderRouter.cs +++ b/Emerald.CoreX/Installers/ModLoaderRouter.cs @@ -11,17 +11,26 @@ namespace Emerald.CoreX.Installers; public class ModLoaderRouter { public readonly IEnumerable Installers; - public ModLoaderRouter() + + public ModLoaderRouter(IEnumerable? installers = null) { - Installers = Ioc.Default.GetServices(); + Installers = installers ?? Ioc.Default.GetServices(); } - public async Task RouteAndInitializeAsync(MinecraftPath path, Versions.Version version) + public async Task RouteAndInitializeAsync( + MinecraftPath path, + Versions.Version version, + bool online = true, + string? installedVersion = null) { + if (!online && !string.IsNullOrWhiteSpace(installedVersion)) + { + return installedVersion; + } if (version.Type == Versions.Type.Vanilla) return version.BasedOn; - return await Installers.First(x=> x.Type == version.Type).InstallAsync(path, version.BasedOn, version.ModVersion); + return await Installers.First(x=> x.Type == version.Type).InstallAsync(path, version.BasedOn, version.ModVersion, online); } } diff --git a/Emerald.CoreX/Runtime/GameRuntimeService.cs b/Emerald.CoreX/Runtime/GameRuntimeService.cs index 8da9bdd7..9e7abbac 100644 --- a/Emerald.CoreX/Runtime/GameRuntimeService.cs +++ b/Emerald.CoreX/Runtime/GameRuntimeService.cs @@ -265,7 +265,9 @@ private async Task StartRuntimeProcessAsync( ActiveSessionRuntime runtime) { _logger.LogDebug("Authenticating launch account for {GameName}.", game.Version.DisplayName); - var authenticationResult = await _accountService.AuthenticateAccountAsync(account); + var authenticationResult = await _accountService.AuthenticateLaunchAccountAsync( + account, + game.IsLauncherOfflineMode); ThrowIfLaunchCancelled(runtime); var process = await game.BuildProcess( diff --git a/Emerald.CoreX/Services/AccountService.Authentication.cs b/Emerald.CoreX/Services/AccountService.Authentication.cs index 2a632ead..d9f894de 100644 --- a/Emerald.CoreX/Services/AccountService.Authentication.cs +++ b/Emerald.CoreX/Services/AccountService.Authentication.cs @@ -91,6 +91,71 @@ await _uiDispatcher.InvokeAsync(() => return authenticationResult; } + public async Task AuthenticateLaunchAccountAsync(EAccount account, bool useOfflineFallback) + { + if (!useOfflineFallback || account.Type == AccountType.Offline) + { + return await AuthenticateAccountAsync(account).ConfigureAwait(false); + } + + var (offlineAccount, created) = EnsureOfflineLaunchAccount(account); + _logger.LogInformation( + "Using offline launch account '{OfflineName}' for selected {AccountType} account '{SelectedName}'. Created: {Created}.", + offlineAccount.Name, + account.Type, + account.Name, + created); + + if (created) + { + _notificationService?.Info( + "OfflineMode", + $"Created offline account '{offlineAccount.Name}' for this launch."); + } + + return await AuthenticateAccountAsync(offlineAccount).ConfigureAwait(false); + } + + private (EAccount Account, bool Created) EnsureOfflineLaunchAccount(EAccount sourceAccount) + { + var username = string.IsNullOrWhiteSpace(sourceAccount.Name) + ? "Player" + : sourceAccount.Name.Trim(); + EAccount? offlineAccount = null; + var created = false; + + _gate.Wait(); + try + { + _uiDispatcher.Invoke(() => + { + offlineAccount = _accounts.FirstOrDefault(candidate => + candidate.Type == AccountType.Offline && + candidate.Name.Equals(username, StringComparison.OrdinalIgnoreCase)); + + if (offlineAccount is not null) + { + return; + } + + offlineAccount = new EAccount(username, AccountType.Offline); + _accounts.Add(offlineAccount); + created = true; + }); + } + finally + { + _gate.Release(); + } + + if (created) + { + PersistAccounts(); + } + + return (offlineAccount!, created); + } + private IAccountAuthenticationProvider GetAuthenticationProvider(AccountType accountType) => _authenticationProviders.TryGetValue(accountType, out var provider) ? provider diff --git a/Emerald.CoreX/Services/IAccountService.cs b/Emerald.CoreX/Services/IAccountService.cs index cfc12750..c4fedee5 100644 --- a/Emerald.CoreX/Services/IAccountService.cs +++ b/Emerald.CoreX/Services/IAccountService.cs @@ -25,6 +25,7 @@ Task SignInElyByAccountAsync( CancellationToken cancellationToken = default); Task RemoveAccountAsync(EAccount account); Task AuthenticateAccountAsync(EAccount account); + Task AuthenticateLaunchAccountAsync(EAccount account, bool useOfflineFallback); EAccount? GetMostRecentlyUsedAccount(); EAccount? GetSelectedAccount(); void SetSelectedAccount(EAccount? account); diff --git a/Emerald/Strings/en/Resources.resw b/Emerald/Strings/en/Resources.resw index cf14319e..3ebce052 100644 --- a/Emerald/Strings/en/Resources.resw +++ b/Emerald/Strings/en/Resources.resw @@ -741,6 +741,9 @@ Offline Mode + + Emerald is using local Minecraft metadata. Installed games can still launch, but adding or repairing versions may need internet access. + Options diff --git a/Emerald/ViewModels/GamesPageViewModel.cs b/Emerald/ViewModels/GamesPageViewModel.cs index 863089cd..7a03eba1 100644 --- a/Emerald/ViewModels/GamesPageViewModel.cs +++ b/Emerald/ViewModels/GamesPageViewModel.cs @@ -288,6 +288,8 @@ public bool CanCreateGame public bool ShowNoGamesMessage => !IsLoading && FilteredGames.Count == 0; + public bool IsOfflineMode => _core.IsOfflineMode; + public bool HasModpackProbe => ModpackProbe != null; public string ModpackMinecraftVersion => ModpackProbe?.MinecraftVersion ?? string.Empty; @@ -402,7 +404,14 @@ public GamesPageViewModel( ModpackSortOptions.Add(new SearchSortOptionItem(SearchSortOptions.Newest, "Newest")); SelectedModpackSortOption = ModpackSortOptions.FirstOrDefault(); - _core.PropertyChanged += (_, _) => _dispatcherQueue.TryEnqueue(() => this.OnPropertyChanged()); + _core.PropertyChanged += (_, e) => _dispatcherQueue.TryEnqueue(() => + { + this.OnPropertyChanged(); + if (e.PropertyName == nameof(Core.IsOfflineMode)) + { + OnPropertyChanged(nameof(IsOfflineMode)); + } + }); _core.VersionsRefreshed += (_, _) => _dispatcherQueue.TryEnqueue(UpdateAvailableVersions); Games.CollectionChanged += (_, _) => _dispatcherQueue.TryEnqueue(() => { diff --git a/Emerald/Views/GamesPage.xaml b/Emerald/Views/GamesPage.xaml index a670a40e..3b478e61 100644 --- a/Emerald/Views/GamesPage.xaml +++ b/Emerald/Views/GamesPage.xaml @@ -150,18 +150,50 @@ + - + + + + + + + + + + + + + - +