Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions Emerald.CoreX.Tests/Installers/ModLoaderRouterTests.cs
Original file line number Diff line number Diff line change
@@ -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<InstallCall> Calls { get; } = [];
public string ResultVersion { get; set; } = "resolved-version";

public LauncherVersionType Type { get; } = type;

public Task<List<LoaderInfo>> GetVersionsAsync(string mcVersion)
=> Task.FromResult(new List<LoaderInfo>());

public Task<string> 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);
}
22 changes: 22 additions & 0 deletions Emerald.CoreX.Tests/Services/AccountServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<EAccount>>(SettingsKeys.MinecraftAccounts);
Assert.NotNull(storedAccounts);
Assert.Contains(storedAccounts!, account => account.Type == AccountType.Offline && account.Name == "Alpha");
}

[Fact]
public async Task SignInElyByAccountAsync_WithoutMicrosoftAccount_UsesBrowserOAuth()
{
Expand Down
10 changes: 6 additions & 4 deletions Emerald.CoreX/Core.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,15 @@
IStoreSharedContentSettingsService storeSharedContentSettingsService) : ObservableObject
{
public const string GamesFolderName = "Instances";
public MinecraftLauncher Launcher { get; set; }

Check warning on line 70 in Emerald.CoreX/Core.cs

View workflow job for this annotation

GitHub Actions / Windows Signed Bundle and MSIX

Non-nullable property 'Launcher' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public IGlobalGameSettingsService GlobalGameSettingsService => globalGameSettingsService;

public event EventHandler? VersionsRefreshed;

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<Versions.Version> VanillaVersions = new();

Expand Down Expand Up @@ -242,10 +243,11 @@
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)
{
Expand Down
39 changes: 22 additions & 17 deletions Emerald.CoreX/Game.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -186,7 +187,13 @@ public async Task InstallVersionOrThrow(bool isOffline = false, bool showFilePro

try
{
string? ver = await Ioc.Default.GetService<Installers.ModLoaderRouter>().RouteAndInitializeAsync(Path, Version);
var modLoaderRouter = Ioc.Default.GetService<Installers.ModLoaderRouter>()
?? 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)
Expand All @@ -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()
Expand Down Expand Up @@ -277,6 +270,12 @@ await Launcher.InstallAsync(
}
}

private async Task<bool> IsVersionAvailableLocallyAsync(string version)
{
var versions = await Launcher.GetAllVersionsAsync();
return versions.Any(candidate => string.Equals(candidate.Name, version, StringComparison.Ordinal));
}

public async Task<Process> BuildProcess(
string version,
CmlLib.Core.Auth.MSession session,
Expand Down Expand Up @@ -314,6 +313,12 @@ public async Task<Process> 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);
}
Expand Down
17 changes: 13 additions & 4 deletions Emerald.CoreX/Installers/ModLoaderRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,26 @@ namespace Emerald.CoreX.Installers;
public class ModLoaderRouter
{
public readonly IEnumerable<IModLoaderInstaller> Installers;
public ModLoaderRouter()

public ModLoaderRouter(IEnumerable<IModLoaderInstaller>? installers = null)
{
Installers = Ioc.Default.GetServices<IModLoaderInstaller>();
Installers = installers ?? Ioc.Default.GetServices<IModLoaderInstaller>();
}

public async Task<string?> RouteAndInitializeAsync(MinecraftPath path, Versions.Version version)
public async Task<string?> 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);
}
}
4 changes: 3 additions & 1 deletion Emerald.CoreX/Runtime/GameRuntimeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
65 changes: 65 additions & 0 deletions Emerald.CoreX/Services/AccountService.Authentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,71 @@ await _uiDispatcher.InvokeAsync(() =>
return authenticationResult;
}

public async Task<GameAuthenticationResult> 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
Expand Down
1 change: 1 addition & 0 deletions Emerald.CoreX/Services/IAccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Task SignInElyByAccountAsync(
CancellationToken cancellationToken = default);
Task RemoveAccountAsync(EAccount account);
Task<GameAuthenticationResult> AuthenticateAccountAsync(EAccount account);
Task<GameAuthenticationResult> AuthenticateLaunchAccountAsync(EAccount account, bool useOfflineFallback);
EAccount? GetMostRecentlyUsedAccount();
EAccount? GetSelectedAccount();
void SetSelectedAccount(EAccount? account);
Expand Down
3 changes: 3 additions & 0 deletions Emerald/Strings/en/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,9 @@
<data name="OfflineMode" xml:space="preserve">
<value>Offline Mode</value>
</data>
<data name="OfflineModeDescription" xml:space="preserve">
<value>Emerald is using local Minecraft metadata. Installed games can still launch, but adding or repairing versions may need internet access.</value>
</data>
<data name="Options" xml:space="preserve">
<value>Options</value>
</data>
Expand Down
11 changes: 10 additions & 1 deletion Emerald/ViewModels/GamesPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(() =>
{
Expand Down
Loading
Loading