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((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 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(); 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 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(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(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(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; }