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">
-
+
+
+
-
+
-
+
+ xmlns:models="clr-namespace:Nitrox.Model.Platforms.Discovery.Models;assembly=Nitrox.Model"
+ xmlns:design="clr-namespace:Nitrox.Launcher.Models.Design">
@@ -40,51 +41,130 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+ ClipToBounds="True">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -127,9 +207,10 @@
-
+