first commit
This commit is contained in:
50
Nitrox.Launcher/ViewModels/Abstract/ModalViewModelBase.cs
Normal file
50
Nitrox.Launcher/ViewModels/Abstract/ModalViewModelBase.cs
Normal 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);
|
||||
}
|
||||
}
|
16
Nitrox.Launcher/ViewModels/Abstract/RoutableViewModelBase.cs
Normal file
16
Nitrox.Launcher/ViewModels/Abstract/RoutableViewModelBase.cs
Normal 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;
|
||||
}
|
48
Nitrox.Launcher/ViewModels/Abstract/ViewModelBase.cs
Normal file
48
Nitrox.Launcher/ViewModels/Abstract/ViewModelBase.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
87
Nitrox.Launcher/ViewModels/BackupRestoreViewModel.cs
Normal file
87
Nitrox.Launcher/ViewModels/BackupRestoreViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
55
Nitrox.Launcher/ViewModels/BlogViewModel.cs
Normal file
55
Nitrox.Launcher/ViewModels/BlogViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
38
Nitrox.Launcher/ViewModels/CommunityViewModel.cs
Normal file
38
Nitrox.Launcher/ViewModels/CommunityViewModel.cs
Normal 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");
|
||||
}
|
||||
}
|
92
Nitrox.Launcher/ViewModels/CrashWindowViewModel.cs
Normal file
92
Nitrox.Launcher/ViewModels/CrashWindowViewModel.cs
Normal 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);
|
||||
}
|
55
Nitrox.Launcher/ViewModels/CreateServerViewModel.cs
Normal file
55
Nitrox.Launcher/ViewModels/CreateServerViewModel.cs
Normal 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;
|
||||
}
|
88
Nitrox.Launcher/ViewModels/DialogBoxViewModel.cs
Normal file
88
Nitrox.Launcher/ViewModels/DialogBoxViewModel.cs
Normal 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,
|
||||
}
|
156
Nitrox.Launcher/ViewModels/EmbeddedServerViewModel.cs
Normal file
156
Nitrox.Launcher/ViewModels/EmbeddedServerViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
270
Nitrox.Launcher/ViewModels/LaunchGameViewModel.cs
Normal file
270
Nitrox.Launcher/ViewModels/LaunchGameViewModel.cs
Normal 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";
|
||||
}
|
||||
}
|
7
Nitrox.Launcher/ViewModels/LibraryViewModel.cs
Normal file
7
Nitrox.Launcher/ViewModels/LibraryViewModel.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using Nitrox.Launcher.ViewModels.Abstract;
|
||||
|
||||
namespace Nitrox.Launcher.ViewModels;
|
||||
|
||||
public partial class LibraryViewModel : RoutableViewModelBase
|
||||
{
|
||||
}
|
199
Nitrox.Launcher/ViewModels/MainWindowViewModel.cs
Normal file
199
Nitrox.Launcher/ViewModels/MainWindowViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
427
Nitrox.Launcher/ViewModels/ManageServerViewModel.cs
Normal file
427
Nitrox.Launcher/ViewModels/ManageServerViewModel.cs
Normal 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;
|
||||
}
|
83
Nitrox.Launcher/ViewModels/ObjectPropertyEditorViewModel.cs
Normal file
83
Nitrox.Launcher/ViewModels/ObjectPropertyEditorViewModel.cs
Normal 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;
|
||||
}
|
154
Nitrox.Launcher/ViewModels/OptionsViewModel.cs
Normal file
154
Nitrox.Launcher/ViewModels/OptionsViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
101
Nitrox.Launcher/ViewModels/ServersViewModel.cs
Normal file
101
Nitrox.Launcher/ViewModels/ServersViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
90
Nitrox.Launcher/ViewModels/UpdatesViewModel.cs
Normal file
90
Nitrox.Launcher/ViewModels/UpdatesViewModel.cs
Normal 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");
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user