diff --git a/Nitrox.Launcher/Assets/Icons/folder.svg b/Nitrox.Launcher/Assets/Icons/folder.svg new file mode 100644 index 0000000000..8ffd69bf40 --- /dev/null +++ b/Nitrox.Launcher/Assets/Icons/folder.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Nitrox.Launcher/Assets/Icons/refresh.svg b/Nitrox.Launcher/Assets/Icons/refresh.svg new file mode 100644 index 0000000000..217c7be3f8 --- /dev/null +++ b/Nitrox.Launcher/Assets/Icons/refresh.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Nitrox.Launcher/Assets/Images/subnautica-bz-card.png b/Nitrox.Launcher/Assets/Images/subnautica-bz-card.png new file mode 100644 index 0000000000..03ed6efb25 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/subnautica-bz-card.png differ diff --git a/Nitrox.Launcher/Assets/Images/subnautica-card.png b/Nitrox.Launcher/Assets/Images/subnautica-card.png new file mode 100644 index 0000000000..0fc3aa06a6 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/subnautica-card.png differ diff --git a/Nitrox.Launcher/Assets/Images/tabs-icons/library.png b/Nitrox.Launcher/Assets/Images/tabs-icons/library.png new file mode 100644 index 0000000000..756fd72fa0 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/tabs-icons/library.png differ diff --git a/Nitrox.Launcher/Models/Design/NitroxAttached.cs b/Nitrox.Launcher/Models/Design/NitroxAttached.cs index 2a7dcf1e07..7486b7cc86 100644 --- a/Nitrox.Launcher/Models/Design/NitroxAttached.cs +++ b/Nitrox.Launcher/Models/Design/NitroxAttached.cs @@ -19,6 +19,7 @@ public class NitroxAttached : AvaloniaObject public static readonly AttachedProperty IsNumericInputProperty = AvaloniaProperty.RegisterAttached("IsNumericInput"); public static readonly AttachedProperty HasUserInteractedProperty = AvaloniaProperty.RegisterAttached("HasUserInteracted"); public static readonly AttachedProperty UseCustomTitleBarProperty = AvaloniaProperty.RegisterAttached("UseCustomTitleBar", true); + public static readonly AttachedProperty GapWidthProperty = AvaloniaProperty.RegisterAttached("GapWidth", 8); internal static readonly AsyncCommandButtonTagger AsyncCommandButtonTagger; static NitroxAttached() @@ -164,4 +165,8 @@ static void OnKeyDown(object sender, KeyEventArgs e) public static bool GetUseCustomTitleBar(Window window) => window.GetValue(UseCustomTitleBarProperty); public static void SetUseCustomTitleBar(Window window, bool value) => window.SetValue(UseCustomTitleBarProperty, value); + + public static double GetGapWidth(AvaloniaObject element) => element.GetValue(GapWidthProperty); + + public static void SetGapWidth(AvaloniaObject element, double value) => element.SetValue(GapWidthProperty, value); } diff --git a/Nitrox.Launcher/Models/Design/RecentServerEntry.cs b/Nitrox.Launcher/Models/Design/RecentServerEntry.cs new file mode 100644 index 0000000000..240f4bc807 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/RecentServerEntry.cs @@ -0,0 +1,76 @@ +using System; +using Avalonia.Collections; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using Nitrox.Launcher.Models.Utils; +using Nitrox.Model.Constants; +using Nitrox.Model.Core; + +namespace Nitrox.Launcher.Models.Design; + +public partial class RecentServerEntry : ObservableObject +{ + public static Bitmap DefaultServerIcon { get; } = AssetHelper.GetAssetFromStream("/Assets/Images/subnautica-icon.png", static stream => new Bitmap(stream)); + + [ObservableProperty] + public partial string LocalServerName { get; set; } = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ServerInfoTooltip))] + public partial string? RemoteHostServerName { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ServerInfoTooltip))] + [NotifyPropertyChangedFor(nameof(VersionMismatchTooltip))] + public partial string? NitroxVersion { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VersionMismatchTooltip))] + [NotifyPropertyChangedFor(nameof(ShowVersionMismatchWarning))] + public partial bool IsVersionCompatible { get; set; } = true; + + public string ServerIP { get; set; } = string.Empty; + + public int ServerPort { get; set; } = SubnauticaServerConstants.DEFAULT_PORT; + + [ObservableProperty] + public partial Bitmap ServerIcon { get; set; } = DefaultServerIcon; + + [ObservableProperty] + public partial bool IsOnline { get; set; } + + [ObservableProperty] + public partial bool IsStatusLoading { get; set; } + + [ObservableProperty] + public partial int PlayerCount { get; set; } + + [ObservableProperty] + public partial int MaxPlayers { get; set; } = SubnauticaServerConstants.DEFAULT_MAX_PLAYERS; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PlayerNamesTooltip))] + public partial AvaloniaList PlayerNames { get; set; } = []; + + public string? PlayerNamesTooltip => PlayerNames.Count == 0 ? null : string.Join(Environment.NewLine, PlayerNames); + + public string? ServerInfoTooltip + { + get + { + if (string.IsNullOrWhiteSpace(ServerIP)) + { + return null; + } + + string endpoint = $"IP: {ServerIP}{Environment.NewLine}Port: {ServerPort}"; + + return string.IsNullOrWhiteSpace(RemoteHostServerName) ? endpoint : $"Host server name: {RemoteHostServerName}{Environment.NewLine}{endpoint}"; + } + } + + public string? VersionMismatchTooltip => $"Server version: {NitroxVersion}{Environment.NewLine}Your version: {NitroxEnvironment.Version}"; + + + public bool ShowVersionMismatchWarning => !IsStatusLoading && !IsVersionCompatible; +} diff --git a/Nitrox.Launcher/Models/Extensions/ServiceCollectionExtensions.cs b/Nitrox.Launcher/Models/Extensions/ServiceCollectionExtensions.cs index 1ea1ccd90a..c1d77d9109 100644 --- a/Nitrox.Launcher/Models/Extensions/ServiceCollectionExtensions.cs +++ b/Nitrox.Launcher/Models/Extensions/ServiceCollectionExtensions.cs @@ -34,7 +34,9 @@ public static IServiceCollection AddAppServices(this IServiceCollection services .AddHostedSingletonService() .AddHostedSingletonService() .AddHostedSingletonService() + .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/Nitrox.Launcher/Models/Services/GameInstallationService.cs b/Nitrox.Launcher/Models/Services/GameInstallationService.cs new file mode 100644 index 0000000000..c8ba4399f7 --- /dev/null +++ b/Nitrox.Launcher/Models/Services/GameInstallationService.cs @@ -0,0 +1,463 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using Nitrox.Launcher.Models.Design; +using Nitrox.Model.Helper; +using Nitrox.Model.Logger; +using Nitrox.Model.Platforms.Discovery; +using Nitrox.Model.Platforms.Discovery.InstallationFinders.Core; +using Nitrox.Model.Platforms.Discovery.Models; +using Nitrox.Model.Platforms.OS.Shared; +using Nitrox.Model.Platforms.Store; +using Nitrox.Model.Platforms.Store.Interfaces; + +namespace Nitrox.Launcher.Models.Services; + +internal sealed partial class GameInstallationService : ObservableObject +{ + private static readonly JsonSerializerOptions jsonSerializerOptions = new(JsonSerializerDefaults.Web); + private bool hasLoggedInitialCacheSnapshot; + private readonly HashSet ignoredGamePaths = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock initialRefreshTaskLocker = new(); + private Task? initialRefreshTask; + + [ObservableProperty] + public partial KnownGame SelectedGame { get; set; } = new() { PathToGame = string.Empty, Platform = Platform.NONE }; + + public AvaloniaList InstalledGames { get; } = []; + + public Task EnsureInitialRefreshAsync(GameInfo gameInfo) + { + lock (initialRefreshTaskLocker) + { + initialRefreshTask ??= Task.Run(async () => await RefreshInstalledGamesAsync(gameInfo)); + return initialRefreshTask; + } + } + + public async Task> RefreshInstalledGamesAsync(GameInfo gameInfo) + { + GameInstallationCacheData cacheData = await LoadKnownGamesAsync(gameInfo); + await LogInitialCacheSnapshotAsync(gameInfo, cacheData); + ReplaceIgnoredGamePaths(cacheData.IgnoredGamePaths); + + List savedGames = await FilterValidSavedGamesAsync(gameInfo, cacheData.KnownGames); + List discoveredGames = GameInstallationFinder + .FindGamesCached(gameInfo) + .Select(ToKnownGame) + .Where(game => !IsIgnoredGamePath(game.PathToGame)) + .ToList(); + + List mergedGames = savedGames + .Concat(discoveredGames) + .Where(game => !string.IsNullOrWhiteSpace(game.PathToGame)) + .Select(Normalize) + .DistinctBy(game => game.PathToGame, StringComparer.OrdinalIgnoreCase) + .ToList(); + + SelectGameAndUpdateUI(gameInfo, mergedGames); + await SaveKnownGamesAsync(gameInfo); + return mergedGames; + } + + public bool AddGameInstallation(GameInfo gameInfo, string path, out string? errorMessage) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + string normalizedPath = Path.GetFullPath(path); + if (!GameInstallationHelper.HasValidGameFolder(normalizedPath, gameInfo)) + { + errorMessage = $"Invalid {gameInfo.Name} directory"; + return false; + } + + PirateDetection.TriggerOnDirectory(normalizedPath); + if (!FileSystem.Instance.IsWritable(Directory.GetCurrentDirectory()) || !FileSystem.Instance.IsWritable(normalizedPath)) + { + if (!FileSystem.Instance.SetFullAccessToCurrentUser(Directory.GetCurrentDirectory()) || !FileSystem.Instance.SetFullAccessToCurrentUser(normalizedPath)) + { + errorMessage = "Restart Nitrox Launcher as admin to allow Nitrox to change permissions as needed. This is only needed once. Nitrox will close after this message."; + return false; + } + } + + SelectGameInstallation(gameInfo, normalizedPath); + errorMessage = null; + return true; + } + + public void SelectGameInstallation(GameInfo gameInfo, KnownGame game) + { + ArgumentNullException.ThrowIfNull(game); + SelectGameInstallation(gameInfo, game, promoteInstalledGame: false); + } + + public bool RemoveGameInstallation(GameInfo gameInfo, KnownGame game) + { + ArgumentNullException.ThrowIfNull(game); + + KnownGame normalizedGame = Normalize(game); + bool removed = false; + for (int i = InstalledGames.Count - 1; i >= 0; i--) + { + if (string.Equals(InstalledGames[i].PathToGame, normalizedGame.PathToGame, StringComparison.OrdinalIgnoreCase)) + { + InstalledGames.RemoveAt(i); + removed = true; + } + } + + AddIgnoredGamePath(normalizedGame.PathToGame); + + if (IsGamePathSelected(normalizedGame.PathToGame)) + { + KnownGame? fallbackGame = InstalledGames.FirstOrDefault(); + if (fallbackGame is null) + { + SelectedGame = new KnownGame { PathToGame = string.Empty, Platform = Platform.NONE }; + NitroxUser.PreferredGamePath = string.Empty; + NitroxUser.ClearGamePathAndPlatform(); + } + else + { + ApplySelection(fallbackGame); + } + } + + _ = Task.Run(() => SaveKnownGamesAsync(gameInfo)); + return removed; + } + + private void SelectGameInstallation(GameInfo gameInfo, string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + SelectGameInstallation(gameInfo, new KnownGame + { + PathToGame = Path.GetFullPath(path), + Platform = GetPlatformFromPath(path) + }, promoteInstalledGame: true); + } + + private void SelectGameInstallation(GameInfo gameInfo, KnownGame game, bool promoteInstalledGame) + { + KnownGame normalizedGame = Normalize(game); + RemoveIgnoredGamePath(normalizedGame.PathToGame); + if (promoteInstalledGame) + { + AddOrUpdateInstalledGame(normalizedGame); + } + ApplySelection(normalizedGame); + _ = Task.Run(() => SaveKnownGamesAsync(gameInfo)); + } + + private void AddOrUpdateInstalledGame(KnownGame game) + { + for (int i = 0; i < InstalledGames.Count; i++) + { + if (string.Equals(InstalledGames[i].PathToGame, game.PathToGame, StringComparison.OrdinalIgnoreCase)) + { + InstalledGames[i] = game; + return; + } + } + + InstalledGames.Add(game); + } + + private async Task LogInitialCacheSnapshotAsync(GameInfo gameInfo, GameInstallationCacheData cacheData) + { + if (hasLoggedInitialCacheSnapshot) + { + return; + } + + await Task.Run(() => + { + string cachedInstallations = cacheData.KnownGames.Count == 0 + ? " none" + : string.Join(Environment.NewLine, cacheData.KnownGames.Select(game => $" {game.PathToGame}")); + + string ignoredInstallations = cacheData.IgnoredGamePaths.Count == 0 + ? " none" + : string.Join(Environment.NewLine, cacheData.IgnoredGamePaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => $" {path}")); + + Log.Info($"Loaded cached {gameInfo.Name} installations:{Environment.NewLine}{cachedInstallations}"); + Log.Info($"Ignored {gameInfo.Name} installations:{Environment.NewLine}{ignoredInstallations}"); + }); + + hasLoggedInitialCacheSnapshot = true; + } + + private async Task> FilterValidSavedGamesAsync(GameInfo gameInfo, IEnumerable savedGamesFromCache) + { + List validGames = []; + + foreach (KnownGame normalizedGame in savedGamesFromCache + .Select(Normalize) + .Where(game => !string.IsNullOrWhiteSpace(game.PathToGame))) + { + bool exists = await Task.Run(() => Directory.Exists(normalizedGame.PathToGame)); + if (!exists) + { + Log.Info($"Removing missing {gameInfo.Name} installation from cache: {normalizedGame.PathToGame}"); + continue; + } + + if (!IsIgnoredGamePath(normalizedGame.PathToGame)) + { + validGames.Add(normalizedGame); + } + } + + return validGames; + } + + private void SelectGameAndUpdateUI(GameInfo gameInfo, List mergedGames) + { + KnownGame? selectedGame = TryGetSelectedGame(gameInfo, mergedGames); + selectedGame ??= mergedGames.FirstOrDefault(); + + InstalledGames.Clear(); + foreach (KnownGame game in mergedGames) + { + InstalledGames.Add(game); + } + + if (selectedGame is null) + { + SelectedGame = new KnownGame { PathToGame = string.Empty, Platform = Platform.NONE }; + NitroxUser.PreferredGamePath = string.Empty; + NitroxUser.ClearGamePathAndPlatform(); + } + else + { + ApplySelection(selectedGame); + } + } + + private async Task LoadKnownGamesAsync(GameInfo gameInfo) + { + try + { + string cacheFilePath = GetCacheFilePath(gameInfo); + bool fileExists = await Task.Run(() => File.Exists(cacheFilePath)); + if (!fileExists) + { + ignoredGamePaths.Clear(); + return new GameInstallationCacheData(); + } + + string serialized = await File.ReadAllTextAsync(cacheFilePath); + GameInstallationCacheData? cacheData = JsonSerializer.Deserialize(serialized, jsonSerializerOptions); + if (cacheData is not null) + { + return NormalizeCacheData(cacheData); + } + + List? knownGames = JsonSerializer.Deserialize>(serialized, jsonSerializerOptions); + ignoredGamePaths.Clear(); + return new GameInstallationCacheData + { + KnownGames = knownGames?.Select(Normalize).Where(game => !string.IsNullOrWhiteSpace(game.PathToGame)).ToList() ?? [] + }; + } + catch (Exception ex) + { + Log.Warn($"Failed to parse known game installations from cache for '{gameInfo.Name}': {ex.Message}"); + ignoredGamePaths.Clear(); + return new GameInstallationCacheData(); + } + } + + private async Task SaveKnownGamesAsync(GameInfo gameInfo) + { + try + { + await Task.Run(() => Directory.CreateDirectory(NitroxUser.CachePath)); + GameInstallationCacheData cacheData = new() + { + KnownGames = InstalledGames.Select(Normalize).Where(game => !string.IsNullOrWhiteSpace(game.PathToGame)).ToList(), + IgnoredGamePaths = ignoredGamePaths.ToList() + }; + string serialized = JsonSerializer.Serialize(cacheData, jsonSerializerOptions); + await File.WriteAllTextAsync(GetCacheFilePath(gameInfo), serialized); + } + catch (Exception ex) + { + Log.Error(ex, $"Failed to save known game installations for '{gameInfo.Name}'"); + } + } + + private static string GetCacheFilePath(GameInfo gameInfo) + { + string cacheKey = $"game-installations-{gameInfo.Name.ToLowerInvariant()}"; + string hash = Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(cacheKey))); + return Path.Combine(NitroxUser.CachePath, $"nitrox_gi_{hash}.cache"); + } + + private KnownGame? TryGetSelectedGame(GameInfo gameInfo, List discoveredGames) + { + string? selectedPath = GetValidGamePath(NitroxUser.GamePath, gameInfo); + if (selectedPath is not null) + { + return new KnownGame + { + PathToGame = selectedPath, + Platform = NitroxUser.GamePlatform?.Platform ?? GetPlatformFromPath(selectedPath) + }; + } + + string? preferredPath = GetValidGamePath(NitroxUser.PreferredGamePath, gameInfo); + if (preferredPath is not null) + { + return new KnownGame + { + PathToGame = preferredPath, + Platform = GetPlatformFromPath(preferredPath) + }; + } + + return discoveredGames.FirstOrDefault(); + } + + private string? GetValidGamePath(string? path, GameInfo gameInfo) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + if (!GameInstallationHelper.HasValidGameFolder(path, gameInfo) || IsIgnoredGamePath(path)) + { + return null; + } + + return Path.GetFullPath(path); + } + + private static KnownGame Normalize(KnownGame game) + { + string path = NormalizePath(game.PathToGame); + Platform platform = game.Platform == Platform.NONE ? GetPlatformFromPath(path) : game.Platform; + return new KnownGame + { + PathToGame = path, + Platform = platform + }; + } + + + private void ApplySelection(KnownGame game) + { + SelectedGame = game; + NitroxUser.PreferredGamePath = game.PathToGame; + NitroxUser.SetGamePathAndPlatform(game.PathToGame, ResolveGamePlatform(game)); + } + + private bool IsGamePathSelected(string path) + { + return string.Equals(SelectedGame.PathToGame, path, StringComparison.OrdinalIgnoreCase) + || string.Equals(NitroxUser.GamePath, path, StringComparison.OrdinalIgnoreCase) + || string.Equals(NitroxUser.PreferredGamePath, path, StringComparison.OrdinalIgnoreCase); + } + + private void AddIgnoredGamePath(string path) + { + string normalizedPath = NormalizePath(path); + if (!string.IsNullOrWhiteSpace(normalizedPath)) + { + ignoredGamePaths.Add(normalizedPath); + } + } + + private void RemoveIgnoredGamePath(string path) + { + string normalizedPath = NormalizePath(path); + if (!string.IsNullOrWhiteSpace(normalizedPath)) + { + ignoredGamePaths.Remove(normalizedPath); + } + } + + private bool IsIgnoredGamePath(string path) + { + string normalizedPath = NormalizePath(path); + return !string.IsNullOrWhiteSpace(normalizedPath) && ignoredGamePaths.Contains(normalizedPath); + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + try + { + return Path.GetFullPath(path); + } + catch (Exception) + { + return string.Empty; + } + } + + private void ReplaceIgnoredGamePaths(IEnumerable paths) + { + ignoredGamePaths.Clear(); + foreach (string path in paths) + { + AddIgnoredGamePath(path); + } + } + + private GameInstallationCacheData NormalizeCacheData(GameInstallationCacheData cacheData) + { + cacheData.KnownGames = cacheData.KnownGames.Select(Normalize).Where(game => !string.IsNullOrWhiteSpace(game.PathToGame)).ToList(); + cacheData.IgnoredGamePaths = cacheData.IgnoredGamePaths + .Select(NormalizePath) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + ReplaceIgnoredGamePaths(cacheData.IgnoredGamePaths); + return cacheData; + } + + private static IGamePlatform? ResolveGamePlatform(KnownGame game) + { + if (Enum.TryParse(game.Platform.ToString(), out GameLibraries gameLibrary)) + { + return GamePlatforms.GetPlatformByFlag(gameLibrary); + } + + return GamePlatforms.GetPlatformByGameDir(game.PathToGame); + } + + private static Platform GetPlatformFromPath(string path) => GamePlatforms.GetPlatformByGameDir(path)?.Platform ?? Platform.NONE; + + private static KnownGame ToKnownGame(GameFinderResult gameFinderResult) + { + Platform platform = GamePlatforms.GetPlatformByFlag(gameFinderResult.Origin)?.Platform ?? GamePlatforms.GetPlatformByGameDir(gameFinderResult.Path)?.Platform ?? Platform.NONE; + return new KnownGame + { + PathToGame = gameFinderResult.Path, + Platform = platform + }; + } + + private sealed class GameInstallationCacheData + { + public List KnownGames { get; set; } = []; + + public List IgnoredGamePaths { get; set; } = []; + } +} diff --git a/Nitrox.Launcher/Models/Services/RecentServerStatusService.cs b/Nitrox.Launcher/Models/Services/RecentServerStatusService.cs new file mode 100644 index 0000000000..69f55655d5 --- /dev/null +++ b/Nitrox.Launcher/Models/Services/RecentServerStatusService.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Collections; +using LiteNetLib; +using LiteNetLib.Utils; +using Nitrox.Launcher.Models.Design; +using Nitrox.Model.Constants; +using Nitrox.Model.Core; +using Nitrox.Model.Helper; +using Nitrox.Model.Logger; +using Nitrox.Model.Networking; +using Nitrox.Model.Serialization; + +namespace Nitrox.Launcher.Models.Services; + +internal sealed class RecentServerStatusService +{ + private const string RECENT_SERVERS_CACHE_FILE_NAME = "launcher_recent_servers.cache"; + private const int NETWORK_POLL_DELAY_MS = 25; + private const int REMOTE_STATUS_TIMEOUT_SECONDS = 2; + private const int MAX_CONCURRENT_QUERIES = 5; + private static readonly TimeSpan autoRefreshInterval = TimeSpan.FromSeconds(15); + private static readonly JsonSerializerOptions jsonSerializerOptions = new(JsonSerializerDefaults.Web); + + private CancellationTokenSource? autoRefreshCts; + private int autoRefreshCycleCounter; + private volatile bool isRefreshInProgress; + private List cachedOnlineServersEndpoints = []; + private bool hasInitializedOnlineServersState; + + /// + /// Returns a list of RecentServerEntries based on the current entries in ServerList, enriched with cached data from previous status queries (if available). + /// + public AvaloniaList GetRecentServers() + { + RecentServersCacheData cacheData = LoadCache(); + Dictionary cacheByEndpoint = cacheData.Servers + .Where(entry => !string.IsNullOrWhiteSpace(entry.Address)) + .ToDictionary(GetEndpointKey, StringComparer.OrdinalIgnoreCase); + + AvaloniaList list = []; + ServerList.Refresh(); + foreach (ServerList.Entry entry in ServerList.Instance.Entries) + { + string endpointKey = GetEndpointKey(entry.Address, entry.Port); + cacheByEndpoint.TryGetValue(endpointKey, out RecentServerCacheEntry? cached); + + RecentServerEntry recentEntry = new() + { + LocalServerName = entry.Name, + RemoteHostServerName = cached?.RemoteHostServerName, + ServerIP = entry.Address, + ServerPort = entry.Port, + MaxPlayers = cached?.MaxPlayers ?? 100, + ServerIcon = RecentServerEntry.DefaultServerIcon, + IsStatusLoading = false, + PlayerNames = [] + }; + + list.Add(recentEntry); + } + + return list; + } + + public async Task RefreshRecentServersAsync(AvaloniaList recentServers, bool prioritizeOnlineFirst = false, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(recentServers); + + await Task.Run(() => RefreshRecentServersCoreAsync(recentServers, prioritizeOnlineFirst, cancellationToken), cancellationToken).ConfigureAwait(false); + } + + /// + /// Refreshes the status of the provided list of RecentServerEntries by querying each server for its current status, and updates the entries accordingly. + /// + private async Task RefreshRecentServersCoreAsync(AvaloniaList recentServers, bool prioritizeOnlineFirst, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(recentServers); + + isRefreshInProgress = true; + try + { + foreach (RecentServerEntry server in recentServers) + { + server.IsStatusLoading = true; + server.IsVersionCompatible = true; + } + + RecentServersCacheData cacheData = LoadCache(); + ConcurrentDictionary cacheByEndpoint = new(cacheData.Servers + .Where(entry => !string.IsNullOrWhiteSpace(entry.Address)) + .ToDictionary(GetEndpointKey, StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase); + + IEnumerable refreshOrder = prioritizeOnlineFirst + ? recentServers.OrderByDescending(server => server.IsOnline) + : recentServers; + + SemaphoreSlim semaphore = new(MAX_CONCURRENT_QUERIES); + try + { + List refreshTasks = []; + int cacheChanged = 0; + Action markCacheChanged = () => Interlocked.Exchange(ref cacheChanged, 1); + + refreshTasks.AddRange(refreshOrder.ToArray().Select(server => RefreshSingleServerAsync(server, cacheByEndpoint, semaphore, markCacheChanged, cancellationToken))); + + await Task.WhenAll(refreshTasks).ConfigureAwait(false); + + if (cacheChanged != 0) + { + cacheData.Servers = [..cacheByEndpoint.Values.OrderBy(entry => entry.Address).ThenBy(entry => entry.Port)]; + SaveCache(cacheData); + } + } + finally + { + semaphore.Dispose(); + } + } + finally + { + isRefreshInProgress = false; + } + } + + /// + /// Queries a specific RecentServerEntry and updates its values based on the query result. Also updates the cache entry for this server and marks it as changed if needed. + /// + private async Task RefreshSingleServerAsync(RecentServerEntry server, ConcurrentDictionary cacheByEndpoint, SemaphoreSlim semaphore, Action markCacheChanged, CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + + RemoteServerStatusResponse? status = await QueryServerStatusAsync(server.ServerIP, server.ServerPort, cancellationToken).ConfigureAwait(false); + if (status == null) + { + server.IsOnline = false; + server.PlayerCount = 0; + server.PlayerNames = []; + return; + } + + string? remoteHostName = string.IsNullOrWhiteSpace(status.HostServerName) ? status.ServerName : status.HostServerName; + server.IsOnline = status.IsOnline; + server.NitroxVersion = status.NitroxVersion; + server.IsVersionCompatible = string.Equals(status.NitroxVersion, NitroxEnvironment.Version.ToString(), StringComparison.Ordinal); + server.PlayerCount = status.PlayerCount; + server.MaxPlayers = status.MaxPlayerCount > 0 ? status.MaxPlayerCount : server.MaxPlayers; + server.PlayerNames = [..status.PlayerNames]; + if (!string.IsNullOrWhiteSpace(remoteHostName)) + { + server.RemoteHostServerName = remoteHostName; + } + + string endpointKey = GetEndpointKey(server.ServerIP, server.ServerPort); + RecentServerCacheEntry cacheEntry = cacheByEndpoint.GetOrAdd(endpointKey, _ => new RecentServerCacheEntry { Address = server.ServerIP, Port = server.ServerPort }); + + // TODO: Add support for server icon retrieval + server.ServerIcon = RecentServerEntry.DefaultServerIcon; + + string newRemoteName = remoteHostName ?? string.Empty; + if (string.IsNullOrWhiteSpace(newRemoteName) && cacheEntry.RemoteHostServerName != null) + { + newRemoteName = cacheEntry.RemoteHostServerName; + } + + int newMaxPlayers = status.MaxPlayerCount > 0 ? status.MaxPlayerCount : cacheEntry.MaxPlayers; + if (!string.Equals(cacheEntry.RemoteHostServerName, newRemoteName, StringComparison.Ordinal) || cacheEntry.MaxPlayers != newMaxPlayers) + { + cacheEntry.RemoteHostServerName = newRemoteName; + cacheEntry.MaxPlayers = newMaxPlayers; + markCacheChanged(); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Log.Error(ex, $"Failed to refresh recent server status for {server.ServerIP}:{server.ServerPort}"); + } + finally + { + server.IsStatusLoading = false; + semaphore.Release(); + } + } + + /// + /// Starts an auto-refresh loop that periodically refreshes the status of the RecentServerEntries. + /// + /// + /// Refreshes only online RecentServerEntries every , and then refreshes all of them every 4th cycle + /// + public void StartAutoRefresh(AvaloniaList recentServers, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(recentServers); + + try + { + autoRefreshCts?.Cancel(); + autoRefreshCts?.Dispose(); + autoRefreshCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + CancellationToken token = autoRefreshCts.Token; + + autoRefreshCycleCounter = 0; + + _ = Task.Run(async () => + { + try + { + while (!token.IsCancellationRequested) + { + autoRefreshCycleCounter++; + + if (autoRefreshCycleCounter % 4 == 0) + { + if (!isRefreshInProgress) + { + await RefreshRecentServersCoreAsync(recentServers, prioritizeOnlineFirst: false, token).ConfigureAwait(false); + } + } + else + { + if (!isRefreshInProgress) + { + AvaloniaList onlineServers = []; + foreach (RecentServerEntry entry in recentServers) + { + if (entry.IsOnline) + { + onlineServers.Add(entry); + } + } + + if (onlineServers.Count > 0) + { + await RefreshRecentServersCoreAsync(onlineServers, prioritizeOnlineFirst: true, token).ConfigureAwait(false); + } + } + } + + await Task.Delay(autoRefreshInterval, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Log.Error(ex, "Auto-refresh loop failed"); + } + }, token); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to start auto-refresh loop"); + } + } + + public void StopAutoRefresh() + { + try + { + autoRefreshCts?.Cancel(); + autoRefreshCts?.Dispose(); + autoRefreshCts = null; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to stop auto-refresh loop"); + } + } + + public void SaveOnlineServersState(AvaloniaList recentServers) + { + ArgumentNullException.ThrowIfNull(recentServers); + + cachedOnlineServersEndpoints.Clear(); + foreach (RecentServerEntry server in recentServers) + { + if (server.IsOnline) + { + cachedOnlineServersEndpoints.Add(GetEndpointKey(server.ServerIP, server.ServerPort)); + } + } + + hasInitializedOnlineServersState = true; + } + + /// + /// Returns a list of RecentServerEntries that should be refreshed based when LibraryView is loaded. + /// + /// + /// On first load: Return all the RecentServerEntries. + /// On subsequent loads: Return only the RecentServerEntries that were previously online, based on . + /// + public AvaloniaList GetServersToRefreshOnLoad(AvaloniaList recentServers) + { + ArgumentNullException.ThrowIfNull(recentServers); + + // First time load + if (!hasInitializedOnlineServersState) + { + return recentServers; + } + + if (cachedOnlineServersEndpoints.Count == 0) + { + return []; + } + + HashSet previouslyOnlineSet = new(cachedOnlineServersEndpoints, StringComparer.OrdinalIgnoreCase); + + AvaloniaList serversToRefresh = new(); + foreach (RecentServerEntry server in recentServers) + { + string endpointKey = GetEndpointKey(server.ServerIP, server.ServerPort); + if (previouslyOnlineSet.Contains(endpointKey)) + { + serversToRefresh.Add(server); + } + } + + return serversToRefresh; + } + + /// + /// Queries and returns the status of a RecentServerEntry based on and + /// + private static async Task QueryServerStatusAsync(string serverIp, int serverPort, CancellationToken cancellationToken) + { + TimeSpan timeout = TimeSpan.FromSeconds(REMOTE_STATUS_TIMEOUT_SECONDS); + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + + EventBasedNetListener listener = new(); + NetManager client = new(listener) + { + AutoRecycle = true, + UnconnectedMessagesEnabled = true, + IPv6Enabled = true + }; + + try + { + if (!client.Start()) + { + return null; + } + + TaskCompletionSource completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + listener.NetworkReceiveUnconnectedEvent += ReceiveUnconnected; + + NetDataWriter writer = new(); + writer.Put(RemoteServerStatusConstants.REQUEST_STRING); + client.SendUnconnectedMessage(writer, serverIp, serverPort); + + while (!completionSource.Task.IsCompleted && !timeoutCts.IsCancellationRequested) + { + client.PollEvents(); + await Task.Delay(NETWORK_POLL_DELAY_MS, timeoutCts.Token).ConfigureAwait(false); + } + + if (!completionSource.Task.IsCompleted) + { + return null; + } + + return await completionSource.Task; + + void ReceiveUnconnected(IPEndPoint remoteEndPoint, NetPacketReader reader, UnconnectedMessageType messageType) + { + if (messageType != UnconnectedMessageType.BasicMessage || remoteEndPoint.Port != serverPort) + { + return; + } + + try + { + string responseType = reader.GetString(); + if (responseType != RemoteServerStatusConstants.RESPONSE_STRING) + { + return; + } + + string payload = reader.GetString(); + RemoteServerStatusResponse? response = JsonSerializer.Deserialize(payload, jsonSerializerOptions); + completionSource.TrySetResult(response); + } + catch + { + completionSource.TrySetResult(null); + } + } + } + catch (OperationCanceledException) + { + return null; + } + catch + { + return null; + } + finally + { + listener.ClearNetworkReceiveUnconnectedEvent(); + client.Stop(); + } + } + + private static string GetCachePath() => Path.Combine(NitroxUser.CachePath, RECENT_SERVERS_CACHE_FILE_NAME); + + private static string GetEndpointKey(RecentServerCacheEntry entry) => GetEndpointKey(entry.Address, entry.Port); + + private static string GetEndpointKey(string address, int port) => $"{address.Trim()}:{port}"; + + private static RecentServersCacheData LoadCache() + { + try + { + string path = GetCachePath(); + if (!File.Exists(path)) + { + return new RecentServersCacheData(); + } + + string serialized = File.ReadAllText(path); + return JsonSerializer.Deserialize(serialized, jsonSerializerOptions) ?? new RecentServersCacheData(); + } + catch + { + return new RecentServersCacheData(); + } + } + + private static void SaveCache(RecentServersCacheData data) + { + Directory.CreateDirectory(NitroxUser.CachePath); + string serialized = JsonSerializer.Serialize(data, jsonSerializerOptions); + File.WriteAllText(GetCachePath(), serialized); + } + + private sealed class RecentServersCacheData + { + public List Servers { get; set; } = []; + } + + private sealed class RecentServerCacheEntry + { + public string Address { get; set; } = string.Empty; + + public int Port { get; set; } = SubnauticaServerConstants.DEFAULT_PORT; + + public string? RemoteHostServerName { get; set; } + + public int MaxPlayers { get; set; } = SubnauticaServerConstants.DEFAULT_MAX_PLAYERS; + } +} diff --git a/Nitrox.Launcher/Models/Styles/Theme/ButtonStyle.axaml b/Nitrox.Launcher/Models/Styles/Theme/ButtonStyle.axaml index c326e476d6..6e23bc1a37 100644 --- a/Nitrox.Launcher/Models/Styles/Theme/ButtonStyle.axaml +++ b/Nitrox.Launcher/Models/Styles/Theme/ButtonStyle.axaml @@ -155,7 +155,7 @@ - diff --git a/Nitrox.Launcher/Models/Styles/Theme/RadioButtonStyle.axaml b/Nitrox.Launcher/Models/Styles/Theme/RadioButtonStyle.axaml index a389ff9405..5fe2d8ed8a 100644 --- a/Nitrox.Launcher/Models/Styles/Theme/RadioButtonStyle.axaml +++ b/Nitrox.Launcher/Models/Styles/Theme/RadioButtonStyle.axaml @@ -1,4 +1,4 @@ - + @@ -36,7 +36,8 @@ - + + @@ -56,8 +57,8 @@ BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" Name="RootBorder"> - - + + + Width="20" + VerticalAlignment="Center" /> + Width="20" + VerticalAlignment="Center" /> + Width="8" + VerticalAlignment="Center" /> + + - + diff --git a/Nitrox.Launcher/ViewModels/Designer/DesignLaunchGameViewModel.cs b/Nitrox.Launcher/ViewModels/Designer/DesignLaunchGameViewModel.cs index 3782322509..1c28f8f5c7 100644 --- a/Nitrox.Launcher/ViewModels/Designer/DesignLaunchGameViewModel.cs +++ b/Nitrox.Launcher/ViewModels/Designer/DesignLaunchGameViewModel.cs @@ -1,3 +1,3 @@ namespace Nitrox.Launcher.ViewModels.Designer; -internal class DesignLaunchGameViewModel() : LaunchGameViewModel(null!, null!, null!, null!); +internal class DesignLaunchGameViewModel() : LaunchGameViewModel(null!, null!, null!, null!, null!); diff --git a/Nitrox.Launcher/ViewModels/Designer/DesignLibraryViewModel.cs b/Nitrox.Launcher/ViewModels/Designer/DesignLibraryViewModel.cs new file mode 100644 index 0000000000..adcbfee91c --- /dev/null +++ b/Nitrox.Launcher/ViewModels/Designer/DesignLibraryViewModel.cs @@ -0,0 +1,79 @@ +using Nitrox.Launcher.Models.Design; +using Nitrox.Model.Platforms.Discovery.Models; + +namespace Nitrox.Launcher.ViewModels.Designer; + +internal sealed class DesignLibraryViewModel : LibraryViewModel +{ + + public DesignLibraryViewModel() : base(null!, null!, null!, null!) + { + SelectedGame = new KnownGame + { + PathToGame = @"C:\Games\Steam\Subnautica", + Platform = Platform.STEAM + }; + + LibraryEntries = + [ + new KnownGame + { + PathToGame = @"C:\Games\Steam\Subnautica", + Platform = Platform.STEAM + }, + new KnownGame + { + PathToGame = @"C:\Games\Epic\Subnautica", + Platform = Platform.EPIC + }, + new KnownGame + { + PathToGame = @"C:\Games\MicrosoftStore\Subnautica", + Platform = Platform.MICROSOFT + }, + new KnownGame + { + PathToGame = @"C:\Games\HeroicGames\Subnautica", + Platform = Platform.HEROIC + }, + new KnownGame + { + PathToGame = @"C:\Games\Discord\Subnautica", + Platform = Platform.DISCORD + }, + new KnownGame + { + PathToGame = @"C:\Games\UhOh\Subnautica", + Platform = Platform.NONE + } + ]; + + RecentServers = + [ + new RecentServerEntry + { + LocalServerName = "nitrox.subnautica-server.net", + PlayerCount = 67, + IsOnline = true + }, + new RecentServerEntry + { + LocalServerName = "server.nitrox-srv.net" + }, + new RecentServerEntry + { + LocalServerName = "192.168.0.21", + PlayerCount = 5, + MaxPlayers = 6, + IsOnline = true + }, + new RecentServerEntry + { + LocalServerName = "0.0.0.0", + IsOnline = true, + PlayerCount = 1 + } + ]; + } + +} diff --git a/Nitrox.Launcher/ViewModels/Designer/DesignMainWindowViewModel.cs b/Nitrox.Launcher/ViewModels/Designer/DesignMainWindowViewModel.cs index bbe724a25a..9cf7d22e70 100644 --- a/Nitrox.Launcher/ViewModels/Designer/DesignMainWindowViewModel.cs +++ b/Nitrox.Launcher/ViewModels/Designer/DesignMainWindowViewModel.cs @@ -1,3 +1,3 @@ namespace Nitrox.Launcher.ViewModels.Designer; -internal class DesignMainWindowViewModel() : MainWindowViewModel(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); +internal class DesignMainWindowViewModel() : MainWindowViewModel(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); diff --git a/Nitrox.Launcher/ViewModels/Designer/DesignOptionsViewModel.cs b/Nitrox.Launcher/ViewModels/Designer/DesignOptionsViewModel.cs index f317a87d05..54bd7d3bd2 100644 --- a/Nitrox.Launcher/ViewModels/Designer/DesignOptionsViewModel.cs +++ b/Nitrox.Launcher/ViewModels/Designer/DesignOptionsViewModel.cs @@ -5,13 +5,46 @@ namespace Nitrox.Launcher.ViewModels.Designer; internal class DesignOptionsViewModel : OptionsViewModel { - public DesignOptionsViewModel() : base(null!, null!) + public DesignOptionsViewModel() : base(null!, null!, null!, null!) { SelectedGame = new KnownGame { - PathToGame = @"C:\Users\Me\Games\Subnautica", + PathToGame = @"C:\Games\Steam\Subnautica", Platform = Platform.STEAM }; + KnownGames = + [ + new KnownGame + { + PathToGame = @"C:\Games\Steam\Subnautica", + Platform = Platform.STEAM + }, + new KnownGame + { + PathToGame = @"C:\Games\Epic\Subnautica", + Platform = Platform.EPIC + }, + new KnownGame + { + PathToGame = @"C:\Games\MicrosoftStore\Subnautica", + Platform = Platform.MICROSOFT + }, + new KnownGame + { + PathToGame = @"C:\Games\HeroicGames\Subnautica", + Platform = Platform.HEROIC + }, + new KnownGame + { + PathToGame = @"C:\Games\Discord\Subnautica", + Platform = Platform.DISCORD + }, + new KnownGame + { + PathToGame = @"C:\Games\UhOh\Subnautica", + Platform = Platform.NONE + } + ]; LaunchArgs = "-vrmode none"; ProgramDataPath = @"C:\Users\Me\AppData\Roaming\Nitrox"; ScreenshotsPath = @"C:\Users\Me\AppData\Roaming\Nitrox\screenshots"; diff --git a/Nitrox.Launcher/ViewModels/LaunchGameViewModel.cs b/Nitrox.Launcher/ViewModels/LaunchGameViewModel.cs index f7c800eb21..095a8fdde6 100644 --- a/Nitrox.Launcher/ViewModels/LaunchGameViewModel.cs +++ b/Nitrox.Launcher/ViewModels/LaunchGameViewModel.cs @@ -23,15 +23,11 @@ namespace Nitrox.Launcher.ViewModels; -internal partial class LaunchGameViewModel(DialogService dialogService, ServerService serverService, OptionsViewModel optionsViewModel, IKeyValueStore keyValueStore) +internal partial class LaunchGameViewModel(DialogService dialogService, ServerService serverService, OptionsViewModel optionsViewModel, IKeyValueStore keyValueStore, GameInstallationService gameInstallationService) : RoutableViewModelBase { public static Task? LastFindSubnauticaTask; private static bool hasInstantLaunched; - private readonly DialogService dialogService = dialogService; - private readonly IKeyValueStore keyValueStore = keyValueStore; - - private readonly ServerService serverService = serverService; [ObservableProperty] public partial Platform GamePlatform { get; set; } @@ -51,12 +47,11 @@ internal partial class LaunchGameViewModel(DialogService dialogService, ServerSe internal override async Task ViewContentLoadAsync(CancellationToken cancellationToken = default) { - await Task.Run(() => - { - GamePlatform = NitroxUser.GamePlatform?.Platform ?? Platform.NONE; - PlatformToolTip = GamePlatform.GetAttribute()?.Description ?? ""; - HandleInstantLaunchForDevelopment(); - }, cancellationToken); + await gameInstallationService.EnsureInitialRefreshAsync(GameInfo.Subnautica); + + GamePlatform = gameInstallationService.SelectedGame.Platform; + PlatformToolTip = GamePlatform.GetAttribute()?.Description ?? ""; + HandleInstantLaunchForDevelopment(); } internal override Task ViewContentUnloadAsync() diff --git a/Nitrox.Launcher/ViewModels/LibraryViewModel.cs b/Nitrox.Launcher/ViewModels/LibraryViewModel.cs index 84985b1bd7..7aa395bbdb 100644 --- a/Nitrox.Launcher/ViewModels/LibraryViewModel.cs +++ b/Nitrox.Launcher/ViewModels/LibraryViewModel.cs @@ -1,5 +1,159 @@ -using Nitrox.Launcher.ViewModels.Abstract; +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.Models.Services; +using Nitrox.Launcher.Models.Utils; +using Nitrox.Launcher.ViewModels.Abstract; +using Nitrox.Model.Logger; namespace Nitrox.Launcher.ViewModels; -internal class LibraryViewModel : RoutableViewModelBase; +internal partial class LibraryViewModel(GameInstallationService gameInstallationService, StorageService storageService, DialogService dialogService, RecentServerStatusService recentServerStatusService) : RoutableViewModelBase +{ + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RemoveGameInstallationCommand))] + public partial KnownGame SelectedGame { get; set; } + + [ObservableProperty] + public partial AvaloniaList LibraryEntries { get; set; } = []; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RefreshRecentServersCommand))] + public partial AvaloniaList RecentServers { get; set; } = []; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RefreshRecentServersCommand))] + public partial bool IsRefreshingRecentServers { get; set; } + + internal override Task ViewContentLoadAsync(CancellationToken cancellationToken = default) + { + SelectedGame = gameInstallationService.SelectedGame; + LibraryEntries = gameInstallationService.InstalledGames; + + RecentServers = recentServerStatusService.GetRecentServers(); + + AvaloniaList serversToRefresh = recentServerStatusService.GetServersToRefreshOnLoad(RecentServers); + _ = recentServerStatusService.RefreshRecentServersAsync(serversToRefresh, false, cancellationToken).ContinueWithHandleError(); + + recentServerStatusService.StartAutoRefresh(RecentServers, cancellationToken); + return Task.CompletedTask; + } + + internal override Task ViewContentUnloadAsync() + { + recentServerStatusService.SaveOnlineServersState(RecentServers); + recentServerStatusService.StopAutoRefresh(); + + return Task.CompletedTask; + } + + [RelayCommand(CanExecute = nameof(CanRefreshRecentServers), AllowConcurrentExecutions = false)] + private async Task RefreshRecentServers() + { + try + { + IsRefreshingRecentServers = true; + await recentServerStatusService.RefreshRecentServersAsync(RecentServers, true, CancellationToken.None); + LauncherNotifier.Success("Refreshed recent servers"); + } + catch (OperationCanceledException) + { + LauncherNotifier.Warning("Timed out while refreshing recent server status"); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to refresh recent server status"); + LauncherNotifier.Error("Failed to refresh recent server status"); + } + finally + { + IsRefreshingRecentServers = false; + } + } + + private bool CanRefreshRecentServers() => !IsRefreshingRecentServers && RecentServers.Count > 0; + + [RelayCommand] + private void SetSelectedGame(KnownGame game) + { + gameInstallationService.SelectGameInstallation(GameInfo.Subnautica, game); + SelectedGame = gameInstallationService.SelectedGame; + } + + [RelayCommand] + private async Task AddGameInstallation() + { + string selectedDirectory = await storageService.OpenFolderPickerAsync("Select Subnautica installation directory", SelectedGame.PathToGame); + if (string.IsNullOrWhiteSpace(selectedDirectory)) + { + return; + } + + if (selectedDirectory.Equals(SelectedGame.PathToGame, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + string? errorMessage = null; + bool added = await Task.Run(() => gameInstallationService.AddGameInstallation(GameInfo.Subnautica, selectedDirectory, out errorMessage)); + if (!added) + { + if (!string.IsNullOrWhiteSpace(errorMessage)) + { + LauncherNotifier.Error(errorMessage); + } + return; + } + + SelectedGame = gameInstallationService.SelectedGame; + LauncherNotifier.Success("Added game installation"); + } + + [RelayCommand(CanExecute = nameof(CanRemoveGameInstallation))] + private async Task RemoveGameInstallation(KnownGame? game) + { + if (game == null || string.IsNullOrWhiteSpace(game.PathToGame)) + { + return; + } + + DialogBoxViewModel confirmResult = await dialogService.ShowAsync(model => + { + model.Title = $"Are you sure you want to remove the game installation '{game.PathToGame}'?"; + model.Description = "This will remove the installation from the launcher cache and it will no longer appear in the installation list unless it is added again."; + model.ButtonOptions = ButtonOptions.YesNo; + }); + + if (!confirmResult) + { + return; + } + + if (!gameInstallationService.RemoveGameInstallation(GameInfo.Subnautica, game)) + { + LauncherNotifier.Error("Failed to remove game installation"); + return; + } + + SelectedGame = gameInstallationService.SelectedGame; + LauncherNotifier.Success("Game installation removed"); + } + + [RelayCommand] + private async Task RefreshGameInstallations() + { + await gameInstallationService.RefreshInstalledGamesAsync(GameInfo.Subnautica); + SelectedGame = gameInstallationService.SelectedGame; + LauncherNotifier.Success("Refreshed game installations"); + } + + private bool CanRemoveGameInstallation(KnownGame? game) + { + return game != null && !string.IsNullOrWhiteSpace(game.PathToGame); + } + +} diff --git a/Nitrox.Launcher/ViewModels/MainWindowViewModel.cs b/Nitrox.Launcher/ViewModels/MainWindowViewModel.cs index 2b2bc57af1..49aaa3b67e 100644 --- a/Nitrox.Launcher/ViewModels/MainWindowViewModel.cs +++ b/Nitrox.Launcher/ViewModels/MainWindowViewModel.cs @@ -18,7 +18,6 @@ using Nitrox.Model.Core; using Nitrox.Model.Helper; using Nitrox.Model.Logger; -using Nitrox.Model.Platforms.Discovery; namespace Nitrox.Launcher.ViewModels; @@ -28,6 +27,7 @@ internal partial class MainWindowViewModel : ViewModelBase, IRoutingScreen private readonly CommunityViewModel communityViewModel; private readonly DialogService dialogService; private readonly LaunchGameViewModel launchGameViewModel; + private readonly LibraryViewModel libraryViewModel; private readonly Func mainWindowProvider; private readonly NotificationsViewModel notificationsViewModel; private readonly OptionsViewModel optionsViewModel; @@ -48,11 +48,13 @@ public MainWindowViewModel( DialogService dialogService, ServersViewModel serversViewModel, LaunchGameViewModel launchGameViewModel, + LibraryViewModel libraryViewModel, CommunityViewModel communityViewModel, BlogViewModel blogViewModel, NotificationsViewModel notificationsViewModel, UpdatesViewModel updatesViewModel, OptionsViewModel optionsViewModel, + GameInstallationService gameInstallationService, ServerService serverService, IKeyValueStore keyValueStore ) @@ -60,6 +62,7 @@ IKeyValueStore keyValueStore this.mainWindowProvider = mainWindowProvider; this.dialogService = dialogService; this.launchGameViewModel = launchGameViewModel; + this.libraryViewModel = libraryViewModel; this.serversViewModel = serversViewModel; this.communityViewModel = communityViewModel; this.blogViewModel = blogViewModel; @@ -96,7 +99,8 @@ IKeyValueStore keyValueStore { bool lightModeEnabled = keyValueStore.GetIsLightModeEnabled(); Dispatcher.UIThread.Invoke(() => Application.Current!.RequestedThemeVariant = lightModeEnabled ? ThemeVariant.Light : ThemeVariant.Dark); - GameInstallationFinder.FindGameCached(GameInfo.Subnautica); + + _ = gameInstallationService.EnsureInitialRefreshAsync(GameInfo.Subnautica).ContinueWithHandleError(); if (!NitroxEnvironment.IsReleaseMode) { @@ -124,6 +128,9 @@ IKeyValueStore keyValueStore [RelayCommand(AllowConcurrentExecutions = false)] public async Task OpenServersViewAsync() => await this.ShowAsync(serversViewModel); + + [RelayCommand(AllowConcurrentExecutions = false)] + public async Task OpenLibraryViewAsync() => await this.ShowAsync(libraryViewModel); [RelayCommand(AllowConcurrentExecutions = false)] public async Task OpenCommunityViewAsync() => await this.ShowAsync(communityViewModel); diff --git a/Nitrox.Launcher/ViewModels/ManageServerViewModel.cs b/Nitrox.Launcher/ViewModels/ManageServerViewModel.cs index 50eda95cf0..06fa99322b 100644 --- a/Nitrox.Launcher/ViewModels/ManageServerViewModel.cs +++ b/Nitrox.Launcher/ViewModels/ManageServerViewModel.cs @@ -123,7 +123,7 @@ internal partial class ManageServerViewModel : RoutableViewModelBase [NotifyDataErrorInfo] [NitroxWorldSeed] public partial string? ServerSeed { get; set; } - public static Array PlayerPerms => Enum.GetValues(typeof(Perms)); + public static Array PlayerPerms => Enum.GetValues(typeof(Perms)).OfType().Distinct().ToArray(); public string? OriginalServerName => Server?.Name; private string SaveFolderDirectory => Path.Combine(SavesFolderDir, Server?.Name ?? throw new Exception($"{nameof(Server)} is not set")); @@ -136,7 +136,10 @@ public ManageServerViewModel(DialogService dialogService, StorageService storage this.keyValueStore = keyValueStore; this.serverService = serverService; - ServerEmbedded = keyValueStore.GetPreferEmbedded(); + if (!IsDesignMode) + { + ServerEmbedded = keyValueStore.GetPreferEmbedded(); + } this.RegisterMessageListener((status, vm) => { diff --git a/Nitrox.Launcher/ViewModels/OptionsViewModel.cs b/Nitrox.Launcher/ViewModels/OptionsViewModel.cs index 439802eb11..b6e771b419 100644 --- a/Nitrox.Launcher/ViewModels/OptionsViewModel.cs +++ b/Nitrox.Launcher/ViewModels/OptionsViewModel.cs @@ -1,8 +1,8 @@ using System; -using System.IO; using System.Threading; using System.Threading.Tasks; using Avalonia; +using Avalonia.Collections; using Avalonia.Input; using Avalonia.Styling; using Avalonia.Threading; @@ -14,17 +14,12 @@ using Nitrox.Launcher.ViewModels.Abstract; using Nitrox.Model.Core; using Nitrox.Model.Helper; -using Nitrox.Model.Platforms.Discovery; using Nitrox.Model.Platforms.Discovery.Models; -using Nitrox.Model.Platforms.OS.Shared; namespace Nitrox.Launcher.ViewModels; -internal partial class OptionsViewModel(IKeyValueStore keyValueStore, StorageService storageService) : RoutableViewModelBase +internal partial class OptionsViewModel(GameInstallationService gameInstallationService, IKeyValueStore keyValueStore, StorageService storageService, DialogService dialogService) : RoutableViewModelBase { - private readonly IKeyValueStore keyValueStore = keyValueStore; - private readonly StorageService storageService = storageService; - [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SetArgumentsCommand))] public partial string LaunchArgs { get; set; } @@ -44,6 +39,9 @@ internal partial class OptionsViewModel(IKeyValueStore keyValueStore, StorageSer [ObservableProperty] public partial KnownGame SelectedGame { get; set; } + [ObservableProperty] + public partial AvaloniaList KnownGames { get; set; } + [ObservableProperty] public partial bool ShowResetArgsBtn { get; set; } @@ -64,7 +62,8 @@ internal partial class OptionsViewModel(IKeyValueStore keyValueStore, StorageSer internal override async Task ViewContentLoadAsync(CancellationToken cancellationToken = default) { - SelectedGame = new() { PathToGame = NitroxUser.GamePath, Platform = NitroxUser.GamePlatform?.Platform ?? Platform.NONE }; + SelectedGame = gameInstallationService.SelectedGame; + KnownGames = gameInstallationService.InstalledGames; LaunchArgs = keyValueStore.GetLaunchArguments(GameInfo.Subnautica, DefaultLaunchArg); ProgramDataPath = NitroxUser.AppDataPath; ScreenshotsPath = NitroxUser.ScreenshotsPath; @@ -74,53 +73,79 @@ internal override async Task ViewContentLoadAsync(CancellationToken cancellation AllowMultipleGameInstances = keyValueStore.GetIsMultipleGameInstancesAllowed(); UseBigPictureMode = keyValueStore.GetUseBigPictureMode(); IsInReleaseMode = NitroxEnvironment.IsReleaseMode; - await Task.Run(() => SetTargetedSubnauticaPath(SelectedGame.PathToGame), cancellationToken).ContinueWithHandleError(ex => LauncherNotifier.Error(ex.Message)); } - private void SetTargetedSubnauticaPath(string path) + [RelayCommand] + private async Task AddGameInstallation() { - if (!Directory.Exists(path)) + string selectedDirectory = await storageService.OpenFolderPickerAsync("Select Subnautica installation directory", SelectedGame.PathToGame); + if (string.IsNullOrWhiteSpace(selectedDirectory)) + { + return; + } + + if (selectedDirectory.Equals(SelectedGame.PathToGame, StringComparison.OrdinalIgnoreCase)) { return; } - PirateDetection.TriggerOnDirectory(path); - if (!FileSystem.Instance.IsWritable(Directory.GetCurrentDirectory()) || !FileSystem.Instance.IsWritable(path)) + string? errorMessage = null; + bool added = await Task.Run(() => gameInstallationService.AddGameInstallation(GameInfo.Subnautica, selectedDirectory, out errorMessage)); + if (!added) { - // TODO: Move this check to another place where Nitrox installation can be verified. (i.e: another page on the launcher in order to check permissions, network setup, ...) - if (!FileSystem.Instance.SetFullAccessToCurrentUser(Directory.GetCurrentDirectory()) || !FileSystem.Instance.SetFullAccessToCurrentUser(path)) + if (!string.IsNullOrWhiteSpace(errorMessage)) { - LauncherNotifier.Error("Restart Nitrox Launcher as admin to allow Nitrox to change permissions as needed. This is only needed once. Nitrox will close after this message."); - return; + LauncherNotifier.Error(errorMessage); } + return; } - // Save game path as preferred for future sessions. - NitroxUser.PreferredGamePath = path; - NitroxUser.SetGamePathAndPlatform(path, null); + SelectedGame = gameInstallationService.SelectedGame; + LauncherNotifier.Success("Added game installation"); } [RelayCommand] - private async Task SetGamePath() + private void SetSelectedGame(KnownGame game) { - string selectedDirectory = await storageService.OpenFolderPickerAsync("Select Subnautica installation directory", SelectedGame.PathToGame); - if (selectedDirectory == "") + gameInstallationService.SelectGameInstallation(GameInfo.Subnautica, game); + SelectedGame = gameInstallationService.SelectedGame; + } + + [RelayCommand] + private async Task RemoveGameInstallation(KnownGame game) + { + if (game == null || string.IsNullOrWhiteSpace(game.PathToGame)) { return; } - if (!GameInstallationHelper.HasGameExecutable(selectedDirectory, GameInfo.Subnautica)) + DialogBoxViewModel confirmResult = await dialogService.ShowAsync(model => + { + model.Title = $"Are you sure you want to remove the game installation '{game.PathToGame}'?"; + model.Description = "This will remove the installation from the launcher cache and it will no longer appear in the installation list unless it is added again."; + model.ButtonOptions = ButtonOptions.YesNo; + }); + + if (!confirmResult) { - LauncherNotifier.Error("Invalid subnautica directory"); return; } - if (!selectedDirectory.Equals(SelectedGame.PathToGame, StringComparison.OrdinalIgnoreCase)) + if (!gameInstallationService.RemoveGameInstallation(GameInfo.Subnautica, game)) { - await Task.Run(() => SetTargetedSubnauticaPath(selectedDirectory)); - SelectedGame = new() { PathToGame = NitroxUser.GamePath, Platform = NitroxUser.GamePlatform?.Platform ?? Platform.NONE }; - LauncherNotifier.Success("Applied changes"); + LauncherNotifier.Error("Failed to remove game installation"); + return; } + + SelectedGame = gameInstallationService.SelectedGame; + LauncherNotifier.Success("Game installation removed"); + } + + [RelayCommand] + private async Task RefreshGameInstallations() + { + await gameInstallationService.RefreshInstalledGamesAsync(GameInfo.Subnautica); + LauncherNotifier.Success("Refreshed game installations"); } [RelayCommand] diff --git a/Nitrox.Launcher/Views/LibraryView.axaml b/Nitrox.Launcher/Views/LibraryView.axaml index 7a064ed41e..62d9b2df8c 100644 --- a/Nitrox.Launcher/Views/LibraryView.axaml +++ b/Nitrox.Launcher/Views/LibraryView.axaml @@ -1,5 +1,6 @@  + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:designer="clr-namespace:Nitrox.Launcher.ViewModels.Designer" + xmlns:converters="clr-namespace:Nitrox.Launcher.Models.Converters" + xmlns:controls="clr-namespace:Nitrox.Launcher.Models.Controls"> + - + - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Nitrox.Launcher/Views/MainWindow.axaml b/Nitrox.Launcher/Views/MainWindow.axaml index 43d265d8b4..d2e53bd3da 100644 --- a/Nitrox.Launcher/Views/MainWindow.axaml +++ b/Nitrox.Launcher/Views/MainWindow.axaml @@ -239,6 +239,21 @@ + + + + + + + + + + + EXPLORE + + + diff --git a/Nitrox.Launcher/Views/ManageServerView.axaml b/Nitrox.Launcher/Views/ManageServerView.axaml index 3ecfe49d1e..0dfd358471 100644 --- a/Nitrox.Launcher/Views/ManageServerView.axaml +++ b/Nitrox.Launcher/Views/ManageServerView.axaml @@ -428,12 +428,17 @@ ColumnDefinitions="*,*" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> - + + + - + - + + - - - - - - - + + + + + + + + + + + + + + +