first commit

This commit is contained in:
2025-07-06 00:23:46 +02:00
commit 38f50c8819
1788 changed files with 112878 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
using System.Linq;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using HanumanInstitute.MvvmDialogs;
using Nitrox.Launcher.Models;
namespace Nitrox.Launcher.ViewModels.Abstract;
/// <summary>
/// Base class for (popup) dialog ViewModels.
/// </summary>
public abstract partial class ModalViewModelBase : ObservableValidator, IModalDialogViewModel, IMessageReceiver
{
[ObservableProperty] private ButtonOptions? selectedOption;
bool? IModalDialogViewModel.DialogResult => (bool)this;
protected ModalViewModelBase()
{
// Always run validation first so HasErrors is set (i.e. trigger CanExecute logic).
ValidateAllProperties();
}
public static implicit operator bool(ModalViewModelBase self)
{
return self is { HasErrors: false } and not { SelectedOption: null or ButtonOptions.No };
}
/// <summary>
/// Closes the dialog window. By default, sets the dialog result as cancelled.
/// </summary>
/// <param name="buttonOptions">The dialog result to set before closing.</param>
[RelayCommand]
public void Close(ButtonOptions? buttonOptions = null)
{
if (buttonOptions != null)
{
SelectedOption = buttonOptions;
}
((IClassicDesktopStyleApplicationLifetime)Application.Current?.ApplicationLifetime)?.Windows.FirstOrDefault(w => w.DataContext == this)?.CloseByUser();
}
public virtual void Dispose()
{
WeakReferenceMessenger.Default.UnregisterAll(this);
}
}

View File

@@ -0,0 +1,16 @@
using System.Threading.Tasks;
using Nitrox.Launcher.Models.Design;
namespace Nitrox.Launcher.ViewModels.Abstract;
public abstract class RoutableViewModelBase : ViewModelBase
{
public IRoutingScreen HostScreen { get; } = AppViewLocator.HostScreen;
/// <summary>
/// Loads content that the view should show. While the returned task is running a loading indicator will be visible.
/// </summary>
internal virtual Task ViewContentLoadAsync() => Task.CompletedTask;
internal virtual Task ViewContentUnloadAsync() => Task.CompletedTask;
}

View File

@@ -0,0 +1,48 @@
using System;
using System.Diagnostics;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Nitrox.Launcher.Models;
namespace Nitrox.Launcher.ViewModels.Abstract;
public abstract class ViewModelBase : ObservableValidator, IMessageReceiver
{
protected Window MainWindow => AppViewLocator.MainWindow;
protected ViewModelBase()
{
ThrowIfViewModelCtorWasEmptyWhileNonEmptyExists();
}
public virtual void Dispose() => WeakReferenceMessenger.Default.UnregisterAll(this);
/// <summary>
/// This will check that DI did not call the empty ViewModel constructor if dependencies for another constructor aren't met.
/// </summary>
[Conditional("DEBUG")]
private static void ThrowIfViewModelCtorWasEmptyWhileNonEmptyExists()
{
if (Design.IsDesignMode)
{
return;
}
foreach (StackFrame stackFrame in new StackTrace(2, false).GetFrames())
{
if (stackFrame.GetMethod() is not { IsConstructor: true } method)
{
continue;
}
if (method.DeclaringType is not { IsAbstract: false } declaringType)
{
continue;
}
if (method.GetParameters().Length > 0 || declaringType.GetConstructors().Length == 1)
{
break;
}
throw new Exception($"Empty ViewModel constructor of '{declaringType.Name}' should only be used in design mode! Check that the DI-container has all the dependencies to call a different constructor.");
}
}
}

View File

@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
using Avalonia.Collections;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Validators;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxServer.Serialization.World;
namespace Nitrox.Launcher.ViewModels;
public partial class BackupRestoreViewModel : ModalViewModelBase
{
[ObservableProperty]
private AvaloniaList<BackupItem> backups = [];
[ObservableProperty]
private string saveFolderDirectory;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RestoreBackupCommand))]
[NotifyDataErrorInfo]
[Backup]
private BackupItem selectedBackup;
[ObservableProperty]
private string title;
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
{
base.OnPropertyChanged(e);
switch (e.PropertyName)
{
case nameof(SaveFolderDirectory) when !string.IsNullOrWhiteSpace(SaveFolderDirectory) && Directory.Exists(SaveFolderDirectory):
Backups.Clear();
Backups.AddRange(GetBackups(SaveFolderDirectory));
break;
}
}
[RelayCommand(CanExecute = nameof(CanRestoreBackup))]
public void RestoreBackup() => Close(ButtonOptions.Ok);
public bool CanRestoreBackup() => !HasErrors;
private static IEnumerable<BackupItem> GetBackups(string saveDirectory)
{
IEnumerable<string> GetBackupFilePaths(string backupRootDir) =>
Directory.EnumerateFiles(backupRootDir, "*.zip")
.Where(file =>
{
// Verify file name format of "Backup - {DateTime:BACKUP_DATE_TIME_FORMAT}.zip"
string fileName = Path.GetFileNameWithoutExtension(file);
if (!fileName.StartsWith("Backup - "))
{
return false;
}
string dateTimePart = fileName["Backup - ".Length..];
return DateTime.TryParseExact(dateTimePart, WorldPersistence.BACKUP_DATE_TIME_FORMAT, CultureInfo.InvariantCulture, DateTimeStyles.None, out _);
});
if (saveDirectory == null)
{
yield break;
}
string backupDir = Path.Combine(saveDirectory, "Backups");
if (!Directory.Exists(backupDir))
{
yield break;
}
foreach (string backupPath in GetBackupFilePaths(backupDir))
{
if (!DateTime.TryParseExact(Path.GetFileNameWithoutExtension(backupPath)["Backup - ".Length..], WorldPersistence.BACKUP_DATE_TIME_FORMAT, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime backupDate))
{
backupDate = File.GetCreationTime(backupPath);
}
yield return new BackupItem(backupDate, backupPath);
}
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Threading.Tasks;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.Logger;
namespace Nitrox.Launcher.ViewModels;
public partial class BlogViewModel : RoutableViewModelBase
{
public static Bitmap FallbackImage { get; } = AssetHelper.GetAssetFromStream("/Assets/Images/blog/vines.png", static stream => new Bitmap(stream));
[ObservableProperty]
private AvaloniaList<NitroxBlog> nitroxBlogs = [];
public BlogViewModel()
{
}
internal override async Task ViewContentLoadAsync()
{
if (Design.IsDesignMode)
{
return;
}
if (NitroxBlogs.Count <= 0)
{
await Dispatcher.UIThread.InvokeAsync(async () =>
{
try
{
NitroxBlogs.Clear();
NitroxBlogs.AddRange(await Downloader.GetBlogsAsync());
}
catch (Exception ex)
{
Log.Error(ex, "Error while trying to display nitrox blogs");
}
});
}
}
[RelayCommand]
private void BlogEntryClick(string blogUrl)
{
ProcessUtils.OpenUrl(blogUrl);
}
}

View File

@@ -0,0 +1,38 @@
using CommunityToolkit.Mvvm.Input;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels.Abstract;
namespace Nitrox.Launcher.ViewModels;
public partial class CommunityViewModel : RoutableViewModelBase
{
[RelayCommand]
private void DiscordLink()
{
ProcessUtils.OpenUrl("discord.gg/E8B4X9s");
}
[RelayCommand]
private void TwitterLink()
{
ProcessUtils.OpenUrl("twitter.com/modnitrox");
}
[RelayCommand]
private void RedditLink()
{
ProcessUtils.OpenUrl("reddit.com/r/SubnauticaNitrox");
}
[RelayCommand]
private void BlueskyLink()
{
ProcessUtils.OpenUrl("bsky.app/profile/nitroxmod.bsky.social");
}
[RelayCommand]
private void GithubLink()
{
ProcessUtils.OpenUrl("github.com/SubnauticaNitrox/Nitrox");
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Threading.Tasks;
using System.Web;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.Discovery.Models;
using NitroxModel.Helper;
namespace Nitrox.Launcher.ViewModels;
public partial class CrashWindowViewModel : ViewModelBase
{
[ObservableProperty]
private string title;
[ObservableProperty]
private string message;
[RelayCommand(CanExecute = nameof(CanRestart))]
private void Restart()
{
ProcessUtils.StartSelf();
Environment.Exit(0);
}
[RelayCommand]
private void Report()
{
string errorTitle = Message[..Math.Min(Message.Length, 100)];
try
{
errorTitle = Message.Substring(0, Math.Max(0, Math.Min(Message.IndexOf("at ", StringComparison.OrdinalIgnoreCase), Message.IndexOf('\n'))));
}
catch
{
// ignored
}
// TODO: Fill in more issue details (is latest release or commit, last view, last clicked button, etc).
string issueTitle = $"Launcher v{NitroxEnvironment.Version} crashed with {errorTitle}";
string whatHappened = $"```\n{Message}\n```";
string storeType = NitroxUser.GamePlatform.Platform switch
{
Platform.STEAM => "Steam",
Platform.EPIC => "Epic",
Platform.MICROSOFT => "MS-Store",
_ => "Other"
};
string createGithubIssueUrl = $"https://github.com/SubnauticaNitrox/Nitrox/issues/new?assignees=&labels=Type%3A+bug%2CStatus%3A+to+verify&projects=&template=bug_report.yaml&title={HttpUtility.UrlEncode(issueTitle)}&what_happened={HttpUtility.UrlEncode(whatHappened)}&os_type={HttpUtility.UrlEncode(GetOsType())}&store_type={HttpUtility.UrlEncode(storeType)}";
ProcessUtils.OpenUrl(createGithubIssueUrl);
static string GetOsType()
{
if (OperatingSystem.IsWindows())
{
return "Windows";
}
if (OperatingSystem.IsMacOS())
{
return "MacOS";
}
if (OperatingSystem.IsLinux())
{
return "Linux";
}
return "Windows"; // No "Other" option in issue template so "Windows" is default.
}
}
[RelayCommand(AllowConcurrentExecutions = false)]
private async Task CopyToClipboard(ContentControl commandControl)
{
IClipboard clipboard = commandControl?.GetWindow().Clipboard;
if (clipboard != null)
{
await clipboard.SetTextAsync(Message);
object previousContent = commandControl.Content;
commandControl.Content = "Copied!";
await Dispatcher.UIThread.InvokeAsync(async () =>
{
await Task.Delay(3000);
commandControl.Content = previousContent;
});
}
}
private bool CanRestart() => !string.IsNullOrWhiteSpace(NitroxUser.ExecutableFilePath ?? Environment.ProcessPath);
}

View File

@@ -0,0 +1,55 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Validators;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.Helper;
using NitroxModel.Server;
namespace Nitrox.Launcher.ViewModels;
public partial class CreateServerViewModel : ModalViewModelBase
{
private readonly IKeyValueStore keyValueStore;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(CreateCommand))]
[NotifyDataErrorInfo]
[Required]
[FileName]
[NotEndsWith(".")]
[NitroxUniqueSaveName(nameof(SavesFolderDir))]
private string name;
[ObservableProperty]
private NitroxGameMode selectedGameMode = NitroxGameMode.SURVIVAL;
private string SavesFolderDir => keyValueStore.GetSavesFolderDir();
public CreateServerViewModel()
{
}
public CreateServerViewModel(IKeyValueStore keyValueStore)
{
this.keyValueStore = keyValueStore;
}
public void CreateEmptySave(string saveName, NitroxGameMode saveGameMode)
{
string saveDir = Path.Combine(SavesFolderDir, saveName);
ServerEntry.CreateNew(saveDir, saveGameMode);
}
[RelayCommand(CanExecute = nameof(CanCreate))]
private async Task CreateAsync()
{
await Task.Run(() => CreateEmptySave(Name, SelectedGameMode));
Close(ButtonOptions.Ok);
}
private bool CanCreate() => !HasErrors;
}

View File

@@ -0,0 +1,88 @@
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Media;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Nitrox.Launcher.ViewModels.Abstract;
namespace Nitrox.Launcher.ViewModels;
/// <summary>
/// Simple Yes/No or OK confirmation box.
/// </summary>
public partial class DialogBoxViewModel : ModalViewModelBase
{
[ObservableProperty] private string windowTitle;
[ObservableProperty] private string title;
[ObservableProperty] private double titleFontSize = 24;
[ObservableProperty] private FontWeight titleFontWeight = FontWeight.Bold;
[ObservableProperty] private string description;
[ObservableProperty] private double descriptionFontSize = 14;
[ObservableProperty] private FontWeight descriptionFontWeight = FontWeight.Normal;
[ObservableProperty] private ButtonOptions buttonOptions = ButtonOptions.Ok;
public KeyGesture OkHotkey { get; } = new(Key.Return);
public KeyGesture NoHotkey { get; } = new(Key.Escape);
public KeyGesture CopyToClipboardHotkey { get; } = new(Key.C, KeyModifiers.Control);
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(Title):
case nameof(Description):
if (WindowTitle is null or "")
{
WindowTitle = string.IsNullOrEmpty(Title) ? WindowTitle : Title;
}
if (WindowTitle is null or "" && Description is not (null or ""))
{
WindowTitle = $"{Description[..Math.Min(30, Description.Length)]}...";
}
break;
}
base.OnPropertyChanged(e);
}
[RelayCommand(AllowConcurrentExecutions = false)]
private async Task CopyToClipboard(ContentControl commandControl)
{
string text = $"{Title}{Environment.NewLine}{(Description.StartsWith(Title) ? Description[Title.Length..].TrimStart() : Description)}";
IClipboard clipboard = commandControl.GetWindow().Clipboard;
if (clipboard != null)
{
await clipboard.SetTextAsync(text);
if (commandControl != null)
{
object previousContent = commandControl.Content;
commandControl.Content = "Copied!";
await Dispatcher.UIThread.InvokeAsync(async () =>
{
await Task.Delay(3000);
commandControl.Content = previousContent;
});
}
}
}
}
[Flags]
public enum ButtonOptions
{
Ok = 1 << 0,
Yes = 1 << 1,
No = 1 << 2,
Clipboard = 1 << 3,
OkClipboard = Ok | Clipboard,
YesNo = Yes | No,
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading;
using Avalonia.VisualTree;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Nitrox.Launcher.Models;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.DataStructures;
namespace Nitrox.Launcher.ViewModels;
/// <summary>
/// Each (embedded) running server should have its own ViewModel.
/// </summary>
public partial class EmbeddedServerViewModel : RoutableViewModelBase
{
private readonly CircularBuffer<string> commandHistory = new(1000);
private int? selectedHistoryIndex;
[ObservableProperty]
private string serverCommand;
[ObservableProperty]
private ServerEntry serverEntry;
[ObservableProperty]
private bool shouldAutoScroll = true;
public AvaloniaList<OutputLine> ServerOutput => ServerEntry.Process.Output;
public EmbeddedServerViewModel()
{
}
public EmbeddedServerViewModel(ServerEntry serverEntry)
{
this.serverEntry = serverEntry;
this.RegisterMessageListener<ServerStatusMessage, EmbeddedServerViewModel>(static (status, model) =>
{
if (status.Server != model.ServerEntry)
{
return;
}
if (!status.IsOnline && model.HostScreen.ActiveViewModel is EmbeddedServerViewModel)
{
model.HostScreen.BackAsync().ConfigureAwait(false);
}
});
}
[RelayCommand]
private async Task BackAsync() => await HostScreen.BackToAsync<ServersViewModel>();
[RelayCommand]
private async Task SendServerAsync(TextBox textBox)
{
if (ServerEntry.Process == null)
{
return;
}
if (string.IsNullOrWhiteSpace(ServerCommand))
{
return;
}
if (commandHistory.Count < 1 || commandHistory[commandHistory.LastChangedIndex] != ServerCommand)
{
commandHistory.Add(ServerCommand);
ServerOutput.Add(new OutputLine
{
Type = OutputLineType.COMMAND,
Timestamp = $@"[{TimeSpan.FromTicks(DateTime.Now.Ticks):hh\:mm\:ss\.fff}]",
LogText = $"> {ServerCommand}"
});
}
await ServerEntry.Process.SendCommandAsync(ServerCommand);
ClearInput(textBox);
}
[RelayCommand]
private async Task StopServerAsync()
{
if (ServerEntry.Process == null)
{
return;
}
await ServerEntry.StopAsync();
}
[RelayCommand]
private void ClearInput(TextBox textBox)
{
ServerCommand = "";
selectedHistoryIndex = null;
SetCaretToEnd(textBox);
}
[RelayCommand]
private void CommandHistoryGoBack(TextBox textBox)
{
if (commandHistory.Count < 1)
{
return;
}
selectedHistoryIndex ??= 0;
selectedHistoryIndex--;
ServerCommand = commandHistory[selectedHistoryIndex.Value];
SetCaretToEnd(textBox);
}
[RelayCommand]
private void CommandHistoryGoForward(TextBox textBox)
{
if (commandHistory.Count < 1)
{
return;
}
selectedHistoryIndex ??= -1;
selectedHistoryIndex++;
ServerCommand = commandHistory[selectedHistoryIndex.Value];
SetCaretToEnd(textBox);
}
[RelayCommand]
private void OutputSizeChanged(SizeChangedEventArgs args)
{
if (ShouldAutoScroll && args.HeightChanged && args.Source is Visual visual)
{
ScrollViewer scrollViewer = visual.FindAncestorOfType<ScrollViewer>();
if (scrollViewer is not null)
{
// TODO: ScrollToEnd for virtualized lists is not working well, see: https://github.com/AvaloniaUI/Avalonia/issues/14365 - wait for fix to clean up this code.
// Workaround: Run ScrollToEnd twice on the next two frames.
Dispatcher.UIThread.InvokeAsync(() =>
{
scrollViewer.ScrollToEnd();
// Run it again next frame
Dispatcher.UIThread.InvokeAsync(() => scrollViewer.ScrollToEnd());
});
}
}
}
private void SetCaretToEnd(TextBox textBox)
{
if (textBox is not { Text: { } text })
{
return;
}
textBox.CaretIndex = text.Length;
}
}

View File

@@ -0,0 +1,270 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Web;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HanumanInstitute.MvvmDialogs;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Services;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.Discovery.Models;
using NitroxModel.Helper;
using NitroxModel.Logger;
using NitroxModel.Platforms.OS.Shared;
using NitroxModel.Platforms.Store;
using NitroxModel.Platforms.Store.Interfaces;
namespace Nitrox.Launcher.ViewModels;
public partial class LaunchGameViewModel : RoutableViewModelBase
{
public static Task<string> LastFindSubnauticaTask;
private static bool hasInstantLaunched;
private readonly OptionsViewModel optionsViewModel;
private readonly ServerService serverService;
private readonly IKeyValueStore keyValueStore;
private readonly IDialogService dialogService;
[ObservableProperty]
private Platform gamePlatform;
[ObservableProperty]
private string platformToolTip;
public Bitmap[] GalleryImageSources { get; } = [
AssetHelper.GetAssetFromStream("/Assets/Images/gallery/image-1.png", static stream => new Bitmap(stream)),
AssetHelper.GetAssetFromStream("/Assets/Images/gallery/image-2.png", static stream => new Bitmap(stream)),
AssetHelper.GetAssetFromStream("/Assets/Images/gallery/image-3.png", static stream => new Bitmap(stream)),
AssetHelper.GetAssetFromStream("/Assets/Images/gallery/image-4.png", static stream => new Bitmap(stream))
];
public string Version => $"{NitroxEnvironment.ReleasePhase} {NitroxEnvironment.Version}";
public string SubnauticaLaunchArguments => keyValueStore.GetSubnauticaLaunchArguments();
public LaunchGameViewModel()
{
}
public LaunchGameViewModel(IDialogService dialogService, ServerService serverService, OptionsViewModel optionsViewModel, IKeyValueStore keyValueStore)
{
this.dialogService = dialogService;
this.serverService = serverService;
this.optionsViewModel = optionsViewModel;
this.keyValueStore = keyValueStore;
}
internal override async Task ViewContentLoadAsync()
{
await Task.Run(() =>
{
NitroxUser.GamePlatformChanged += UpdateGamePlatform;
UpdateGamePlatform();
HandleInstantLaunchForDevelopment();
});
}
internal override Task ViewContentUnloadAsync()
{
NitroxUser.GamePlatformChanged -= UpdateGamePlatform;
return Task.CompletedTask;
}
[RelayCommand]
private async Task StartSingleplayerAsync()
{
if (GameInspect.WarnIfGameProcessExists(GameInfo.Subnautica))
{
return;
}
Log.Info("Launching Subnautica in singleplayer mode");
try
{
if (string.IsNullOrWhiteSpace(NitroxUser.GamePath) || !Directory.Exists(NitroxUser.GamePath))
{
await HostScreen.ShowAsync(optionsViewModel);
LauncherNotifier.Warning("Location of Subnautica is unknown. Set the path to it in settings");
return;
}
NitroxEntryPatch.Remove(NitroxUser.GamePath);
await StartSubnauticaAsync();
}
catch (Exception ex)
{
Log.Error(ex, "Error while starting game in singleplayer mode:");
await dialogService.ShowErrorAsync(ex, "Error while starting game in singleplayer mode");
}
}
[RelayCommand]
private async Task StartMultiplayerAsync(string[] args = null)
{
Log.Info("Launching Subnautica in multiplayer mode");
try
{
bool setupResult = await Task.Run(async () =>
{
if (string.IsNullOrWhiteSpace(NitroxUser.GamePath) || !Directory.Exists(NitroxUser.GamePath))
{
await Dispatcher.UIThread.InvokeAsync(async () => await HostScreen.ShowAsync(optionsViewModel));
LauncherNotifier.Warning("Location of Subnautica is unknown. Set the path to it in settings");
return false;
}
if (PirateDetection.HasTriggered)
{
LauncherNotifier.Error("Aarrr! Nitrox has walked the plank :(");
return false;
}
if (GameInspect.WarnIfGameProcessExists(GameInfo.Subnautica))
{
return false;
}
if (await GameInspect.IsOutdatedGameAndNotify(NitroxUser.GamePath, dialogService))
{
return false;
}
// TODO: The launcher should override FileRead win32 API for the Subnautica process to give it the modified Assembly-CSharp from memory
try
{
const string PATCHER_DLL_NAME = "NitroxPatcher.dll";
string patcherDllPath = Path.Combine(NitroxUser.ExecutableRootPath ?? "", "lib", "net472", PATCHER_DLL_NAME);
if (!File.Exists(patcherDllPath))
{
LauncherNotifier.Error("Launcher files seems corrupted, please contact us");
return false;
}
File.Copy(
patcherDllPath,
Path.Combine(NitroxUser.GamePath, GameInfo.Subnautica.DataFolder, "Managed", PATCHER_DLL_NAME),
true
);
}
catch (IOException ex)
{
Log.Error(ex, "Unable to move initialization dll to Managed folder. Still attempting to launch because it might exist from previous runs");
}
// Try inject Nitrox into Subnautica code.
if (LastFindSubnauticaTask != null)
{
await LastFindSubnauticaTask;
}
NitroxEntryPatch.Remove(NitroxUser.GamePath);
NitroxEntryPatch.Apply(NitroxUser.GamePath);
if (QModHelper.IsQModInstalled(NitroxUser.GamePath))
{
Log.Warn("Seems like QModManager is installed");
LauncherNotifier.Warning("QModManager Detected in the game folder");
}
return true;
});
if (!setupResult)
{
return;
}
await StartSubnauticaAsync(args);
}
catch (Exception ex)
{
Log.Error(ex, "Error while starting game in multiplayer mode:");
await Dispatcher.UIThread.InvokeAsync(async () => await dialogService.ShowErrorAsync(ex, "Error while starting game in multiplayer mode"));
}
}
[RelayCommand]
private void OpenContributionsOfYear()
{
Process.Start(new ProcessStartInfo($"https://github.com/SubnauticaNitrox/Nitrox/graphs/contributors?from={HttpUtility.UrlEncode($"{DateTime.UtcNow.AddYears(-1):yyyy/M/d}")}") { UseShellExecute = true, Verb = "open" })?.Dispose();
}
/// <summary>
/// Launches the server and Subnautica immediately if instant launch is active.
/// </summary>
[Conditional("DEBUG")]
private void HandleInstantLaunchForDevelopment()
{
if (hasInstantLaunched)
{
return;
}
hasInstantLaunched = true;
if (App.InstantLaunch == null)
{
return;
}
Task.Run(async () =>
{
// Start the server
ServerEntry server = await serverService.GetOrCreateServerAsync(App.InstantLaunch.SaveName);
server.Name = App.InstantLaunch.SaveName;
Task serverStartTask = Dispatcher.UIThread.InvokeAsync(async () => await serverService.StartServerAsync(server)).ContinueWithHandleError();
// Start a game in multiplayer for each player
foreach (string playerName in App.InstantLaunch.PlayerNames)
{
await StartMultiplayerAsync(["--instantlaunch", playerName]).ContinueWithHandleError();
}
await serverStartTask;
}).ContinueWithHandleError();
}
private async Task StartSubnauticaAsync(string[] args = null)
{
LauncherNotifier.Info("Starting game");
string subnauticaPath = NitroxUser.GamePath;
string subnauticaLaunchArguments = $"{SubnauticaLaunchArguments} {string.Join(" ", args ?? Environment.GetCommandLineArgs())}";
string subnauticaExe;
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
subnauticaExe = Path.Combine(subnauticaPath, "MacOS", GameInfo.Subnautica.ExeName);
}
else
{
subnauticaExe = Path.Combine(subnauticaPath, GameInfo.Subnautica.ExeName);
}
if (!File.Exists(subnauticaExe))
{
throw new FileNotFoundException("Unable to find Subnautica executable");
}
IGamePlatform platform = GamePlatforms.GetPlatformByGameDir(subnauticaPath);
// Start game & gaming platform if needed.
using ProcessEx game = platform switch
{
Steam s => await s.StartGameAsync(subnauticaExe, subnauticaLaunchArguments, GameInfo.Subnautica.SteamAppId),
EpicGames e => await e.StartGameAsync(subnauticaExe, subnauticaLaunchArguments),
MSStore m => await m.StartGameAsync(subnauticaExe, subnauticaLaunchArguments),
Discord d => await d.StartGameAsync(subnauticaExe, subnauticaLaunchArguments),
_ => throw new Exception($"Directory '{subnauticaPath}' is not a valid {GameInfo.Subnautica.Name} game installation or the game platform is unsupported by Nitrox.")
};
if (game is null)
{
throw new Exception($"Game failed to start through {platform.Name}");
}
}
private void UpdateGamePlatform()
{
GamePlatform = NitroxUser.GamePlatform?.Platform ?? Platform.NONE;
PlatformToolTip = GamePlatform.GetAttribute<DescriptionAttribute>()?.Description ?? "Unknown";
}
}

View File

@@ -0,0 +1,7 @@
using Nitrox.Launcher.ViewModels.Abstract;
namespace Nitrox.Launcher.ViewModels;
public partial class LibraryViewModel : RoutableViewModelBase
{
}

View File

@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Collections;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using HanumanInstitute.MvvmDialogs;
using Nitrox.Launcher.Models;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Services;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.Helper;
using NitroxModel.Logger;
namespace Nitrox.Launcher.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private readonly BlogViewModel blogViewModel;
private readonly CommunityViewModel communityViewModel;
private readonly LaunchGameViewModel launchGameViewModel;
private readonly OptionsViewModel optionsViewModel;
private readonly ServersViewModel serversViewModel;
private readonly UpdatesViewModel updatesViewModel;
private readonly IDialogService dialogService;
private readonly ServerService serverService;
[ObservableProperty]
private bool updateAvailableOrUnofficial;
public AvaloniaList<NotificationItem> Notifications { get; init; } = [];
[ObservableProperty]
private IRoutingScreen routingScreen;
[ObservableProperty]
private object activeViewModel;
public MainWindowViewModel()
{
}
public MainWindowViewModel(
IRoutingScreen routingScreen,
ServersViewModel serversViewModel,
LaunchGameViewModel launchGameViewModel,
CommunityViewModel communityViewModel,
BlogViewModel blogViewModel,
UpdatesViewModel updatesViewModel,
OptionsViewModel optionsViewModel,
IDialogService dialogService,
ServerService serverService
)
{
this.launchGameViewModel = launchGameViewModel;
this.serversViewModel = serversViewModel;
this.communityViewModel = communityViewModel;
this.blogViewModel = blogViewModel;
this.updatesViewModel = updatesViewModel;
this.optionsViewModel = optionsViewModel;
this.routingScreen = routingScreen;
this.dialogService = dialogService;
this.serverService = serverService;
this.RegisterMessageListener<ViewShownMessage, MainWindowViewModel>(static (message, vm) => vm.ActiveViewModel = message.ViewModel);
this.RegisterMessageListener<NotificationAddMessage, MainWindowViewModel>(static async (message, vm) =>
{
vm.Notifications.Add(message.Item);
await Task.Delay(7000);
WeakReferenceMessenger.Default.Send(new NotificationCloseMessage(message.Item));
});
this.RegisterMessageListener<NotificationCloseMessage, MainWindowViewModel>(static async (message, vm) =>
{
message.Item.Dismissed = true;
await Task.Delay(1000); // Wait for animations
if (!Design.IsDesignMode) // Prevent design preview crashes
{
vm.Notifications.Remove(message.Item);
}
});
if (!Design.IsDesignMode)
{
if (!NitroxEnvironment.IsReleaseMode)
{
LauncherNotifier.Info("You're now using Nitrox DEV build");
}
Task.Run(async () =>
{
if (!await NetHelper.HasInternetConnectivityAsync())
{
Log.Warn("Launcher may not be connected to internet");
LauncherNotifier.Warning("Launcher may not be connected to internet");
}
UpdateAvailableOrUnofficial = await updatesViewModel.IsNitroxUpdateAvailableAsync();
});
}
ActiveViewModel = this.launchGameViewModel;
_ = RoutingScreen.ShowAsync(launchGameViewModel).ContinueWithHandleError(ex => LauncherNotifier.Error(ex.Message));
}
[RelayCommand(AllowConcurrentExecutions = false)]
public async Task OpenLaunchGameViewAsync()
{
await RoutingScreen.ShowAsync(launchGameViewModel);
}
[RelayCommand(AllowConcurrentExecutions = false)]
public async Task OpenServersViewAsync()
{
await RoutingScreen.ShowAsync(serversViewModel);
}
[RelayCommand(AllowConcurrentExecutions = false)]
public async Task OpenCommunityViewAsync()
{
await RoutingScreen.ShowAsync(communityViewModel);
}
[RelayCommand(AllowConcurrentExecutions = false)]
public async Task OpenBlogViewAsync()
{
await RoutingScreen.ShowAsync(blogViewModel);
}
[RelayCommand(AllowConcurrentExecutions = false)]
public async Task OpenUpdatesViewAsync()
{
await RoutingScreen.ShowAsync(updatesViewModel);
}
[RelayCommand(AllowConcurrentExecutions = false)]
public async Task OpenOptionsViewAsync()
{
await RoutingScreen.ShowAsync(optionsViewModel);
}
[RelayCommand]
public async Task ClosingAsync(WindowClosingEventArgs args)
{
ServerEntry[] embeddedServers = serverService.Servers.Where(s => s.IsOnline && s.IsEmbedded).ToArray();
if (embeddedServers.Length > 0)
{
DialogBoxViewModel result = await ShowDialogAsync(dialogService, args, $"{embeddedServers.Length} embedded server(s) will stop, continue?");
if (!result)
{
args.Cancel = true;
return;
}
await HideWindowAndStopServersAsync(MainWindow, embeddedServers);
}
// As closing handler isn't async, cancellation might have happened anyway. So check manually if we should close the window after all the tasks are done.
if (args.Cancel == false && MainWindow.IsClosingByUser(args))
{
MainWindow.CloseByCode();
}
static async Task<DialogBoxViewModel> ShowDialogAsync(IDialogService dialogService, WindowClosingEventArgs args, string title)
{
// Showing dialogs doesn't work if closing isn't set as 'cancelled'.
bool prevCancelFlag = args.Cancel;
args.Cancel = true;
try
{
return await dialogService.ShowAsync<DialogBoxViewModel>(model =>
{
model.Title = title;
model.ButtonOptions = ButtonOptions.YesNo;
});
}
finally
{
args.Cancel = prevCancelFlag;
}
}
static async Task HideWindowAndStopServersAsync(Window mainWindow, IEnumerable<ServerEntry> servers)
{
// Closing servers can take a while: hide the main window.
mainWindow.Hide();
try
{
await Task.WhenAll(servers.Select(s => s.StopAsync()));
}
catch (Exception ex)
{
Log.Error(ex);
}
}
}
}

View File

@@ -0,0 +1,427 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using HanumanInstitute.MvvmDialogs;
using Nitrox.Launcher.Models;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Services;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.Models.Validators;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.DataStructures.GameLogic;
using NitroxModel.Helper;
using NitroxModel.Logger;
using NitroxModel.Server;
using Config = NitroxModel.Serialization.SubnauticaServerConfig;
namespace Nitrox.Launcher.ViewModels;
public partial class ManageServerViewModel : RoutableViewModelBase
{
private readonly string[] advancedSettingsDeniedFields =
[
"password", "filename", nameof(Config.ServerPort), nameof(Config.MaxConnections), nameof(Config.AutoPortForward), nameof(Config.SaveInterval), nameof(Config.Seed), nameof(Config.GameMode), nameof(Config.DisableConsole),
nameof(Config.LANDiscoveryEnabled), nameof(Config.DefaultPlayerPerm), nameof(Config.IsEmbedded)
];
private readonly IDialogService dialogService;
private readonly IKeyValueStore keyValueStore;
private readonly ServerService serverService;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
private bool serverAllowCommands;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
private bool serverAllowLanDiscovery;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
private bool serverAutoPortForward;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
[NotifyDataErrorInfo]
[Range(10, 86400, ErrorMessage = "Value must be between 10s and 24 hours (86400s).")]
private int serverAutoSaveInterval;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
private Perms serverDefaultPlayerPerm;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
private NitroxGameMode serverGameMode;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
private Bitmap serverIcon;
private string serverIconDir;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
[Range(1, 1000)]
[NotifyDataErrorInfo]
private int serverMaxPlayers;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
[NotifyDataErrorInfo]
[Required]
[FileName]
[NotEndsWith(".")]
[NitroxUniqueSaveName(nameof(SavesFolderDir), true, nameof(OriginalServerName))]
private string serverName;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
private string serverPassword;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
private int serverPlayers;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
[NotifyDataErrorInfo]
[Range(ushort.MinValue, ushort.MaxValue)]
private int serverPort;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
[NotifyDataErrorInfo]
[NitroxWorldSeed]
private string serverSeed;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))]
private bool serverEmbedded = true;
public static Array PlayerPerms => Enum.GetValues(typeof(Perms));
public string OriginalServerName => Server?.Name;
[ObservableProperty]
private ServerEntry server;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RestoreBackupCommand), nameof(DeleteServerCommand))]
private bool serverIsOnline;
private string SaveFolderDirectory => Path.Combine(SavesFolderDir, Server.Name);
private string SavesFolderDir => keyValueStore.GetSavesFolderDir();
public ManageServerViewModel()
{
}
public ManageServerViewModel(IDialogService dialogService, IKeyValueStore keyValueStore, ServerService serverService)
{
this.dialogService = dialogService;
this.keyValueStore = keyValueStore;
this.serverService = serverService;
this.RegisterMessageListener<ServerStatusMessage, ManageServerViewModel>((status, vm) =>
{
if (vm.server != status.Server)
{
return;
}
vm.ServerIsOnline = status.IsOnline;
});
}
[RelayCommand(CanExecute = nameof(CanGoBackAndStartServer))]
public async Task StartServerAsync()
{
await serverService.StartServerAsync(Server);
}
[RelayCommand]
public async Task<bool> StopServerAsync()
{
if (!await Server.StopAsync())
{
return false;
}
return true;
}
public void LoadFrom(ServerEntry serverEntry)
{
Server = serverEntry;
ServerName = Server.Name;
ServerIcon = Server.ServerIcon;
ServerPassword = Server.Password;
ServerGameMode = Server.GameMode;
ServerSeed = Server.Seed;
ServerDefaultPlayerPerm = Server.PlayerPermissions;
ServerAutoSaveInterval = Server.AutoSaveInterval;
ServerMaxPlayers = Server.MaxPlayers;
ServerPlayers = Server.Players;
ServerPort = Server.Port;
ServerAutoPortForward = Server.AutoPortForward;
ServerAllowLanDiscovery = Server.AllowLanDiscovery;
ServerAllowCommands = Server.AllowCommands;
ServerEmbedded = Server.IsEmbedded;
// Force embedded on MacOS
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Server.IsEmbedded = ServerEmbedded = true;
Config config = Config.Load(SaveFolderDirectory);
using (config.Update(SaveFolderDirectory))
{
config.IsEmbedded = Server.IsEmbedded;
}
}
}
private bool HasChanges() => ServerName != Server.Name ||
ServerIcon != Server.ServerIcon ||
ServerPassword != Server.Password ||
ServerGameMode != Server.GameMode ||
ServerSeed != Server.Seed ||
ServerDefaultPlayerPerm != Server.PlayerPermissions ||
ServerAutoSaveInterval != Server.AutoSaveInterval ||
ServerMaxPlayers != Server.MaxPlayers ||
ServerPlayers != Server.Players ||
ServerPort != Server.Port ||
ServerAutoPortForward != Server.AutoPortForward ||
ServerAllowLanDiscovery != Server.AllowLanDiscovery ||
ServerAllowCommands != Server.AllowCommands ||
ServerEmbedded != Server.IsEmbedded;
[RelayCommand(CanExecute = nameof(CanGoBackAndStartServer))]
private async Task BackAsync() => await HostScreen.BackToAsync<ServersViewModel>();
private bool CanGoBackAndStartServer() => !HasChanges();
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save()
{
// If world name was changed, rename save folder to match it
string newPath = Path.Combine(SavesFolderDir, ServerName);
if (SaveFolderDirectory != newPath)
{
// Windows, by default, ignores case when renaming folders. We circumvent this by changing the name to a random one, and then to the desired name.
// OS tmp directory is not used because on Linux this causes cross-link error, see https://github.com/dotnet/runtime/issues/31149
string tempSavePath = Path.Combine(SavesFolderDir, $"{Guid.NewGuid():N}_{ServerName[..Math.Min(ServerName.Length, 10)]}");
Directory.Move(SaveFolderDirectory, tempSavePath);
Directory.Move(tempSavePath, newPath);
}
// Update the servericon.png file if needed
if (Server.ServerIcon != ServerIcon && serverIconDir != null)
{
File.Copy(serverIconDir, Path.Combine(newPath, "servericon.png"), true);
}
Server.Name = ServerName;
Server.ServerIcon = ServerIcon;
Server.Password = ServerPassword;
Server.GameMode = ServerGameMode;
Server.Seed = ServerSeed;
Server.PlayerPermissions = ServerDefaultPlayerPerm;
Server.AutoSaveInterval = ServerAutoSaveInterval;
Server.MaxPlayers = ServerMaxPlayers;
Server.Players = ServerPlayers;
Server.Port = ServerPort;
Server.AutoPortForward = ServerAutoPortForward;
Server.AllowLanDiscovery = ServerAllowLanDiscovery;
Server.AllowCommands = ServerAllowCommands;
Server.IsEmbedded = ServerEmbedded || RuntimeInformation.IsOSPlatform(OSPlatform.OSX); // Force embedded on MacOS;
Config config = Config.Load(SaveFolderDirectory);
using (config.Update(SaveFolderDirectory))
{
config.ServerPassword = Server.Password;
if (Server.IsNewServer) { config.Seed = Server.Seed; }
config.GameMode = Server.GameMode;
config.DefaultPlayerPerm = Server.PlayerPermissions;
config.SaveInterval = (int)TimeSpan.FromSeconds(Server.AutoSaveInterval).TotalMilliseconds;
config.MaxConnections = Server.MaxPlayers;
config.ServerPort = Server.Port;
config.AutoPortForward = Server.AutoPortForward;
config.LANDiscoveryEnabled = Server.AllowLanDiscovery;
config.DisableConsole = !Server.AllowCommands;
config.IsEmbedded = Server.IsEmbedded;
}
Undo(); // Used to update the UI with corrected values (Trims and ToUppers)
BackCommand.NotifyCanExecuteChanged();
StartServerCommand.NotifyCanExecuteChanged();
UndoCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
}
private bool CanSave() => !HasErrors && !ServerIsOnline && HasChanges();
[RelayCommand(CanExecute = nameof(CanUndo))]
private void Undo()
{
ServerName = Server.Name;
ServerIcon = Server.ServerIcon;
ServerPassword = Server.Password;
ServerGameMode = Server.GameMode;
ServerSeed = Server.Seed;
ServerDefaultPlayerPerm = Server.PlayerPermissions;
ServerAutoSaveInterval = Server.AutoSaveInterval;
ServerMaxPlayers = Server.MaxPlayers;
ServerPlayers = Server.Players;
ServerPort = Server.Port;
ServerAutoPortForward = Server.AutoPortForward;
ServerAllowLanDiscovery = Server.AllowLanDiscovery;
ServerAllowCommands = Server.AllowCommands;
ServerEmbedded = Server.IsEmbedded;
}
private bool CanUndo() => !ServerIsOnline && HasChanges();
[RelayCommand]
private async Task ChangeServerIconAsync()
{
try
{
IReadOnlyList<IStorageFile> files = await MainWindow.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Select an image",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("All Images + Icons")
{
Patterns = ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp", "*.ico"],
AppleUniformTypeIdentifiers = ["public.image"],
MimeTypes = ["image/*"]
}
]
});
string newIconFile = files.FirstOrDefault()?.TryGetLocalPath();
if (newIconFile == null || !File.Exists(newIconFile))
{
return;
}
serverIconDir = newIconFile;
ServerIcon = new Bitmap(serverIconDir);
}
catch (Exception ex)
{
Log.Error(ex);
}
}
[RelayCommand]
private async Task ShowAdvancedSettings()
{
ObjectPropertyEditorViewModel result = await dialogService.ShowAsync<ObjectPropertyEditorViewModel>(model =>
{
model.Title = $"Server '{ServerName}' config editor";
model.FieldAcceptFilter = p => !advancedSettingsDeniedFields.Any(v => p.Name.Contains(v, StringComparison.OrdinalIgnoreCase));
model.OwnerObject = Config.Load(SaveFolderDirectory);
});
if (result && result.OwnerObject is Config config)
{
config.Serialize(SaveFolderDirectory);
}
LoadFrom(Server);
}
[RelayCommand]
private void OpenWorldFolder() =>
Process.Start(new ProcessStartInfo
{
FileName = SaveFolderDirectory,
Verb = "open",
UseShellExecute = true
})?.Dispose();
[RelayCommand(CanExecute = nameof(CanRestoreBackupAndDeleteServer))]
private async Task RestoreBackup()
{
BackupRestoreViewModel result = await dialogService.ShowAsync<BackupRestoreViewModel>(model =>
{
model.Title = $"Restore a Backup for '{ServerName}'";
model.SaveFolderDirectory = SaveFolderDirectory;
});
if (result)
{
string backupFile = result.SelectedBackup.BackupFileName;
try
{
if (!File.Exists(backupFile))
{
throw new FileNotFoundException("Selected backup file not found.", backupFile);
}
ZipFile.ExtractToDirectory(backupFile, SaveFolderDirectory, true);
Server.RefreshFromDirectory(SaveFolderDirectory);
LoadFrom(Server);
LauncherNotifier.Success("Backup restored successfully.");
}
catch (Exception ex)
{
await dialogService.ShowErrorAsync(ex, "Error while restoring backup");
}
}
}
[RelayCommand(CanExecute = nameof(CanRestoreBackupAndDeleteServer))]
private async Task DeleteServerAsync()
{
await CoreDeleteServerAsync();
}
[RelayCommand(CanExecute = nameof(CanRestoreBackupAndDeleteServer))]
private async Task ForceDeleteServerAsync()
{
await CoreDeleteServerAsync(true);
}
private async Task CoreDeleteServerAsync(bool force = false)
{
if (!force)
{
DialogBoxViewModel modal = await dialogService.ShowAsync<DialogBoxViewModel>(model =>
{
model.Title = $"Are you sure you want to delete the server '{ServerName}'?";
model.ButtonOptions = ButtonOptions.YesNo;
});
if (!modal)
{
return;
}
}
try
{
Directory.Delete(SaveFolderDirectory, true);
WeakReferenceMessenger.Default.Send(new SaveDeletedMessage(ServerName));
await HostScreen.BackAsync();
}
catch (Exception ex)
{
await dialogService.ShowErrorAsync(ex, $"Error while deleting world \"{ServerName}\"");
}
}
private bool CanRestoreBackupAndDeleteServer() => !ServerIsOnline;
}

View File

@@ -0,0 +1,83 @@
using System;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Collections;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HanumanInstitute.MvvmDialogs;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.ViewModels.Abstract;
namespace Nitrox.Launcher.ViewModels;
public partial class ObjectPropertyEditorViewModel : ModalViewModelBase
{
private readonly IDialogService dialogService;
[ObservableProperty]
private AvaloniaList<EditorField> editorFields = [];
[ObservableProperty]
private object ownerObject;
private string title;
public string Title
{
get => title ?? $"{OwnerObject.GetType().Name} editor";
set => title = value;
}
/// <summary>
/// Gets or sets the field filter to use. If filter returns false, it will omit the field.
/// </summary>
public Func<PropertyInfo, bool> FieldAcceptFilter { get; set; } = _ => true;
public ObjectPropertyEditorViewModel() : this(null)
{
}
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(OwnerObject))
{
EditorFields.Clear();
EditorFields.AddRange(OwnerObject
.GetType()
.GetProperties()
.Where(FieldAcceptFilter)
.Select(p => new EditorField(p, p.GetValue(OwnerObject), GetPossibleValues(p)))
.Where(editorField => editorField.Value is string or bool or int or float || editorField.PossibleValues != null));
}
base.OnPropertyChanged(e);
}
public ObjectPropertyEditorViewModel(IDialogService dialogService)
{
this.dialogService = dialogService;
}
[RelayCommand(CanExecute = nameof(CanSave))]
public async Task Save()
{
foreach (EditorField field in EditorFields)
{
try
{
field.PropertyInfo.SetValue(OwnerObject, Convert.ChangeType(field.Value, field.PropertyInfo.PropertyType));
}
catch (Exception ex)
{
await dialogService.ShowErrorAsync(ex, description: field.ToString());
}
}
Close(ButtonOptions.Ok);
}
public bool CanSave() => !HasErrors;
private static AvaloniaList<object> GetPossibleValues(PropertyInfo propertyInfo) =>
propertyInfo.PropertyType.IsEnum ? new AvaloniaList<object>(propertyInfo.PropertyType.GetFields(BindingFlags.Static | BindingFlags.Public).Select(f => f.GetValue(propertyInfo.PropertyType))) : null;
}

View File

@@ -0,0 +1,154 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Input;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.Discovery;
using NitroxModel.Discovery.Models;
using NitroxModel.Helper;
using NitroxModel.Platforms.OS.Shared;
namespace Nitrox.Launcher.ViewModels;
public partial class OptionsViewModel : RoutableViewModelBase
{
private readonly IKeyValueStore keyValueStore;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SetArgumentsCommand))]
private string launchArgs;
[ObservableProperty]
private string savesFolderDir;
[ObservableProperty]
private KnownGame selectedGame;
[ObservableProperty]
private bool showResetArgsBtn;
private static string DefaultLaunchArg => "-vrmode none";
public OptionsViewModel()
{
}
public OptionsViewModel(IKeyValueStore keyValueStore)
{
this.keyValueStore = keyValueStore;
}
internal override async Task ViewContentLoadAsync()
{
await Task.Run(() =>
{
SelectedGame = new() { PathToGame = NitroxUser.GamePath, Platform = NitroxUser.GamePlatform?.Platform ?? Platform.NONE };
LaunchArgs = keyValueStore.GetSubnauticaLaunchArguments(DefaultLaunchArg);
SavesFolderDir = keyValueStore.GetSavesFolderDir();
});
await SetTargetedSubnauticaPathAsync(SelectedGame.PathToGame).ContinueWithHandleError(ex => LauncherNotifier.Error(ex.Message));
}
public async Task SetTargetedSubnauticaPathAsync(string path)
{
if (!Directory.Exists(path))
{
return;
}
NitroxUser.GamePath = path;
if (LaunchGameViewModel.LastFindSubnauticaTask != null)
{
await LaunchGameViewModel.LastFindSubnauticaTask;
}
LaunchGameViewModel.LastFindSubnauticaTask = Task.Run(() =>
{
PirateDetection.TriggerOnDirectory(path);
if (!FileSystem.Instance.IsWritable(Directory.GetCurrentDirectory()) || !FileSystem.Instance.IsWritable(path))
{
// 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))
{
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 null;
}
}
// Save game path as preferred for future sessions.
NitroxUser.PreferredGamePath = path;
if (NitroxEntryPatch.IsPatchApplied(NitroxUser.GamePath))
{
NitroxEntryPatch.Remove(NitroxUser.GamePath);
}
return path;
});
await LaunchGameViewModel.LastFindSubnauticaTask;
}
[RelayCommand]
private async Task SetGamePath()
{
string selectedDirectory = await MainWindow.StorageProvider.OpenFolderPickerAsync("Select Subnautica installation directory", SelectedGame.PathToGame);
if (selectedDirectory == "")
{
return;
}
if (!GameInstallationHelper.HasGameExecutable(selectedDirectory, GameInfo.Subnautica))
{
LauncherNotifier.Error("Invalid subnautica directory");
return;
}
if (!selectedDirectory.Equals(SelectedGame.PathToGame, StringComparison.OrdinalIgnoreCase))
{
await SetTargetedSubnauticaPathAsync(selectedDirectory);
SelectedGame = new() { PathToGame = NitroxUser.GamePath, Platform = NitroxUser.GamePlatform?.Platform ?? Platform.NONE };
LauncherNotifier.Success("Applied changes");
}
}
[RelayCommand]
private void ResetArguments(IInputElement focusTargetAfterReset = null)
{
LaunchArgs = DefaultLaunchArg;
ShowResetArgsBtn = false;
SetArgumentsCommand.NotifyCanExecuteChanged();
focusTargetAfterReset?.Focus();
}
[RelayCommand(CanExecute = nameof(CanSetArguments))]
private void SetArguments()
{
keyValueStore.SetSubnauticaLaunchArguments(LaunchArgs);
SetArgumentsCommand.NotifyCanExecuteChanged();
}
private bool CanSetArguments()
{
ShowResetArgsBtn = LaunchArgs != DefaultLaunchArg;
return LaunchArgs != keyValueStore.GetSubnauticaLaunchArguments(DefaultLaunchArg);
}
[RelayCommand]
private void OpenSavesFolder()
{
Process.Start(new ProcessStartInfo
{
FileName = SavesFolderDir,
Verb = "open",
UseShellExecute = true
})?.Dispose();
}
}

View File

@@ -0,0 +1,101 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Collections;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HanumanInstitute.MvvmDialogs;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Services;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.Helper;
using NitroxModel.Logger;
namespace Nitrox.Launcher.ViewModels;
public partial class ServersViewModel : RoutableViewModelBase
{
private readonly IKeyValueStore keyValueStore;
private readonly IDialogService dialogService;
private readonly ServerService serverService;
private readonly ManageServerViewModel manageServerViewModel;
[ObservableProperty]
private AvaloniaList<ServerEntry> servers;
public ServersViewModel()
{
}
public ServersViewModel(IKeyValueStore keyValueStore, IDialogService dialogService, ServerService serverService, ManageServerViewModel manageServerViewModel)
{
this.keyValueStore = keyValueStore;
this.dialogService = dialogService;
this.serverService = serverService;
this.manageServerViewModel = manageServerViewModel;
serverService.PropertyChanged += ServerServiceOnPropertyChanged;
}
private void ServerServiceOnPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(serverService.Servers))
{
Servers = [..serverService.Servers];
}
}
internal override async Task ViewContentLoadAsync()
{
Servers = [..await serverService.GetServersAsync()];
}
[RelayCommand(AllowConcurrentExecutions = false)]
public async Task CreateServerAsync()
{
CreateServerViewModel result = await dialogService.ShowAsync<CreateServerViewModel>();
if (!result)
{
return;
}
try
{
ServerEntry serverEntry = await Task.Run(() => ServerEntry.FromDirectory(Path.Join(keyValueStore.GetSavesFolderDir(), result.Name)));
if (serverEntry == null)
{
throw new Exception("Failed to create save file");
}
// Don't add to servers list manually here, it will be added by file system watcher. Otherwise: possible duplicate entries by race-condition.
}
catch (Exception ex)
{
LauncherNotifier.Error($"Server create failed: {ex.Message}");
Log.Error(ex);
}
}
[RelayCommand]
public async Task<bool> StartServerAsync(ServerEntry server)
{
return await serverService.StartServerAsync(server);
}
[RelayCommand]
public async Task ManageServer(ServerEntry server)
{
if (server.IsOnline && server.IsEmbedded)
{
await HostScreen.ShowAsync(new EmbeddedServerViewModel(server));
return;
}
if (server.Version != NitroxEnvironment.Version && !await serverService.ConfirmServerVersionAsync(server))
{
return;
}
manageServerViewModel.LoadFrom(server);
await HostScreen.ShowAsync(manageServerViewModel);
}
}

View File

@@ -0,0 +1,90 @@
using System;
using System.Threading.Tasks;
using Avalonia.Collections;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.Helper;
using NitroxModel.Logger;
namespace Nitrox.Launcher.ViewModels;
public partial class UpdatesViewModel : RoutableViewModelBase
{
[ObservableProperty]
private bool newUpdateAvailable;
[ObservableProperty]
private bool usingOfficialVersion;
[ObservableProperty]
private string version;
[ObservableProperty]
private string officialVersion;
[ObservableProperty]
private AvaloniaList<NitroxChangelog> nitroxChangelogs = [];
internal override async Task ViewContentLoadAsync()
{
await Dispatcher.UIThread.InvokeAsync(async () =>
{
try
{
NitroxChangelogs.Clear();
NitroxChangelogs.AddRange(await Downloader.GetChangeLogsAsync());
}
catch (Exception ex)
{
Log.Error(ex, "Error while trying to display Nitrox changelogs");
}
});
}
public async Task<bool> IsNitroxUpdateAvailableAsync()
{
try
{
Version currentVersion = NitroxEnvironment.Version;
Version latestVersion = await Downloader.GetNitroxLatestVersionAsync();
NewUpdateAvailable = latestVersion > currentVersion;
#if DEBUG
UsingOfficialVersion = false;
#else
UsingOfficialVersion = latestVersion >= currentVersion;
#endif
if (NewUpdateAvailable)
{
string versionMessage = $"A new version of the mod ({latestVersion}) is available.";
Log.Info(versionMessage);
LauncherNotifier.Warning(versionMessage);
}
Version = currentVersion.ToString();
OfficialVersion = latestVersion.ToString();
}
catch // If update check fails, just show "No Update Available" text unless on debug mode
{
NewUpdateAvailable = false;
#if DEBUG
UsingOfficialVersion = false;
#else
UsingOfficialVersion = true;
#endif
}
return NewUpdateAvailable || !UsingOfficialVersion;
}
[RelayCommand]
private void DownloadUpdate()
{
ProcessUtils.OpenUrl("nitrox.rux.gg/download");
}
}