first commit
This commit is contained in:
87
Nitrox.Launcher/Models/Design/AsyncCommandButtonTagger.cs
Normal file
87
Nitrox.Launcher/Models/Design/AsyncCommandButtonTagger.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Reactive;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Listens for async command changes on buttons to add the chosen classname to, for use with styling.
|
||||
/// </summary>
|
||||
public class AsyncCommandButtonTagger : IDisposable
|
||||
{
|
||||
public string ClassName { get; init; }
|
||||
private readonly ConcurrentDictionary<ICommand, BusyState> states = [];
|
||||
private readonly IDisposable commandChangeSubscription;
|
||||
|
||||
public AsyncCommandButtonTagger(string className)
|
||||
{
|
||||
ClassName = className;
|
||||
commandChangeSubscription = Button.CommandProperty.Changed.Subscribe(new AnonymousObserver<AvaloniaPropertyChangedEventArgs<ICommand>>(ButtonCommandChangedOnNext));
|
||||
|
||||
void ButtonCommandChangedOnNext(AvaloniaPropertyChangedEventArgs<ICommand> args)
|
||||
{
|
||||
if (args.Sender is not Button button)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (args.OldValue.Value is { } oldCommand && states.TryRemove(oldCommand, out BusyState oldState))
|
||||
{
|
||||
oldState.Dispose();
|
||||
}
|
||||
if (args.NewValue.Value is { } newCommand)
|
||||
{
|
||||
states.TryAdd(newCommand, new BusyState(ClassName, newCommand, button));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BusyState : IDisposable
|
||||
{
|
||||
public string ClassName { get; }
|
||||
private ICommand Command { get; }
|
||||
private Button Button { get; }
|
||||
|
||||
public BusyState(string className, ICommand command, Button button)
|
||||
{
|
||||
ClassName = className;
|
||||
Command = command;
|
||||
Button = button;
|
||||
Command.CanExecuteChanged += CommandOnCanExecuteChanged;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Command.CanExecuteChanged -= CommandOnCanExecuteChanged;
|
||||
Button.Classes.Set(ClassName, false);
|
||||
}
|
||||
|
||||
private void CommandOnCanExecuteChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (sender is IAsyncRelayCommand asyncCommand)
|
||||
{
|
||||
Button.Classes.Set(ClassName, asyncCommand.IsRunning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
commandChangeSubscription.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the busy states of buttons.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach ((ICommand _, BusyState value) in states)
|
||||
{
|
||||
value.Dispose();
|
||||
}
|
||||
states.Clear();
|
||||
}
|
||||
}
|
5
Nitrox.Launcher/Models/Design/BackupItem.cs
Normal file
5
Nitrox.Launcher/Models/Design/BackupItem.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public record BackupItem(DateTime BackupDate, string BackupFileName);
|
34
Nitrox.Launcher/Models/Design/EditorField.cs
Normal file
34
Nitrox.Launcher/Models/Design/EditorField.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Reflection;
|
||||
using Avalonia.Collections;
|
||||
using NitroxModel.Serialization;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public record EditorField
|
||||
{
|
||||
public object Value { get; set; }
|
||||
|
||||
public PropertyInfo PropertyInfo { get; init; }
|
||||
|
||||
public AvaloniaList<object> PossibleValues { get; set; }
|
||||
|
||||
public string Description
|
||||
{
|
||||
get
|
||||
{
|
||||
string description = PropertyInfo.GetCustomAttribute<PropertyDescriptionAttribute>()?.Description;
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
description = null;
|
||||
}
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
public EditorField(PropertyInfo propertyInfo, object value, AvaloniaList<object> possibleValues)
|
||||
{
|
||||
PropertyInfo = propertyInfo;
|
||||
Value = value;
|
||||
PossibleValues = possibleValues;
|
||||
}
|
||||
}
|
6
Nitrox.Launcher/Models/Design/IRoutingScreen.cs
Normal file
6
Nitrox.Launcher/Models/Design/IRoutingScreen.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public interface IRoutingScreen
|
||||
{
|
||||
object ActiveViewModel { get; set; }
|
||||
}
|
9
Nitrox.Launcher/Models/Design/KnownGame.cs
Normal file
9
Nitrox.Launcher/Models/Design/KnownGame.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using NitroxModel.Discovery.Models;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public class KnownGame
|
||||
{
|
||||
public string PathToGame { get; init; }
|
||||
public Platform Platform { get; init; }
|
||||
}
|
59
Nitrox.Launcher/Models/Design/MultiDataTemplate.cs
Normal file
59
Nitrox.Launcher/Models/Design/MultiDataTemplate.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
extern alias JB;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Markup.Xaml.Templates;
|
||||
using Avalonia.Metadata;
|
||||
using JB::JetBrains.Annotations;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Selects a <see cref="DataTemplate" /> based on its <see cref="DataTemplate.DataType" />.
|
||||
/// </summary>
|
||||
public class MultiDataTemplate : AvaloniaList<DataTemplate>, IRecyclingDataTemplate
|
||||
{
|
||||
[Content]
|
||||
[UsedImplicitly]
|
||||
public List<DataTemplate> Content { get; set; } = new();
|
||||
|
||||
private readonly Dictionary<Type, Control> typeToControlCache = [];
|
||||
|
||||
public bool Match(object data) => GetTemplateForType(data?.GetType()) != null;
|
||||
|
||||
public Control Build(object data, Control existing)
|
||||
{
|
||||
Type type = data?.GetType();
|
||||
if (type != null && typeToControlCache.TryGetValue(type, out Control control))
|
||||
{
|
||||
return control;
|
||||
}
|
||||
Control build = GetTemplateForType(type)?.Build(data);
|
||||
if (type != null && build != null)
|
||||
{
|
||||
typeToControlCache[type] = build;
|
||||
}
|
||||
|
||||
return build ?? existing;
|
||||
}
|
||||
|
||||
public Control Build(object data) => GetTemplateForType(data.GetType())?.Build(data) ?? new TextBlock { Text = "" };
|
||||
|
||||
private IDataTemplate GetTemplateForType(Type type)
|
||||
{
|
||||
if (type == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
foreach (DataTemplate template in Content)
|
||||
{
|
||||
if (template.DataType?.IsAssignableTo(type) ?? false)
|
||||
{
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
167
Nitrox.Launcher/Models/Design/NitroxAttached.cs
Normal file
167
Nitrox.Launcher/Models/Design/NitroxAttached.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Reactive;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Container class for our attached properties.
|
||||
/// </summary>
|
||||
public class NitroxAttached : AvaloniaObject
|
||||
{
|
||||
public static readonly AttachedProperty<bool> SelectedProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, Interactive, bool>("Selected");
|
||||
public static readonly AttachedProperty<bool> AutoScrollToHomeProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, ScrollViewer, bool>("AutoScrollToHome");
|
||||
public static readonly AttachedProperty<Orientation> PrimaryScrollWheelDirectionProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, ScrollViewer, Orientation>("PrimaryScrollWheelDirection", Orientation.Vertical);
|
||||
public static readonly AttachedProperty<bool> IsNumericInputProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, InputElement, bool>("IsNumericInput");
|
||||
public static readonly AttachedProperty<bool> HasUserInteractedProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, InputElement, bool>("HasUserInteracted");
|
||||
public static readonly AttachedProperty<bool> UseCustomTitleBarProperty = AvaloniaProperty.RegisterAttached<NitroxAttached, Window, bool>("UseCustomTitleBar", true);
|
||||
internal static readonly AsyncCommandButtonTagger AsyncCommandButtonTagger;
|
||||
|
||||
static NitroxAttached()
|
||||
{
|
||||
InputElement.LostFocusEvent.Raised.Subscribe(new AnonymousObserver<(object, RoutedEventArgs)>(HasUserInteractedOnNext));
|
||||
InputElement.TextInputEvent.Raised.Subscribe(new AnonymousObserver<(object, RoutedEventArgs)>(HasUserInteractedOnNext));
|
||||
AsyncCommandButtonTagger = new AsyncCommandButtonTagger("busy");
|
||||
|
||||
void HasUserInteractedOnNext((object Sender, RoutedEventArgs EventArgs) args)
|
||||
{
|
||||
if (args.Sender is InputElement element)
|
||||
{
|
||||
SetHasUserInteracted(element, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetSelected(AvaloniaObject element) => element.GetValue(SelectedProperty);
|
||||
|
||||
public static void SetSelected(AvaloniaObject obj, bool value) => obj.SetValue(SelectedProperty, value);
|
||||
|
||||
public static void SetAutoScrollToHome(AvaloniaObject obj, bool value)
|
||||
{
|
||||
static void VisualAttached(object sender, VisualTreeAttachmentEventArgs e) => (sender as ScrollViewer)?.ScrollToHome();
|
||||
|
||||
obj.SetValue(AutoScrollToHomeProperty, value);
|
||||
if (obj is not Visual visual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (value)
|
||||
{
|
||||
visual.AttachedToVisualTree += VisualAttached;
|
||||
}
|
||||
else
|
||||
{
|
||||
visual.AttachedToVisualTree -= VisualAttached;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetAutoScrollToHome(AvaloniaObject element) => element.GetValue(AutoScrollToHomeProperty);
|
||||
|
||||
public static Orientation GetPrimaryScrollWheelDirection(AvaloniaObject obj) => obj.GetValue(PrimaryScrollWheelDirectionProperty);
|
||||
|
||||
/// <summary>
|
||||
/// Changes scroll wheel input to move scroll viewer left and right if set to <see cref="Orientation.Horizontal"/>.
|
||||
/// </summary>
|
||||
public static void SetPrimaryScrollWheelDirection(AvaloniaObject obj, Orientation orientation)
|
||||
{
|
||||
static void RotatedOrientationWheelHandler(object sender, PointerWheelEventArgs e)
|
||||
{
|
||||
ScrollViewer scrollViewer = sender as ScrollViewer;
|
||||
if (scrollViewer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (GetPrimaryScrollWheelDirection(scrollViewer) == Orientation.Vertical)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Delta.Y < 0)
|
||||
{
|
||||
for (int i = 0; i <= -e.Delta.Y; i++)
|
||||
{
|
||||
scrollViewer.LineRight();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i <= e.Delta.Y; i++)
|
||||
{
|
||||
scrollViewer.LineLeft();
|
||||
}
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
obj.SetValue(PrimaryScrollWheelDirectionProperty, orientation);
|
||||
if (obj is not ScrollViewer scrollViewer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (orientation)
|
||||
{
|
||||
case Orientation.Horizontal:
|
||||
scrollViewer.PointerWheelChanged += RotatedOrientationWheelHandler;
|
||||
break;
|
||||
case Orientation.Vertical:
|
||||
scrollViewer.PointerWheelChanged -= RotatedOrientationWheelHandler;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetIsNumericInput(AvaloniaObject obj, bool value)
|
||||
{
|
||||
static void OnKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Up:
|
||||
case Key.Down:
|
||||
if (sender is not TextBox textBox)
|
||||
{
|
||||
throw new NotSupportedException($"{sender.GetType()} is not supported by property {nameof(IsNumericInputProperty)}");
|
||||
}
|
||||
|
||||
string previousText = textBox.Text;
|
||||
if (int.TryParse(textBox.Text, out int val))
|
||||
{
|
||||
val += e.Key == Key.Up ? 1 : -1;
|
||||
}
|
||||
textBox.Text = Math.Clamp(val, 0, int.MaxValue).ToString();
|
||||
if (textBox.Text.Length > textBox.MaxLength)
|
||||
{
|
||||
textBox.Text = previousText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (obj is not InputElement inputElement)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (value)
|
||||
{
|
||||
inputElement.KeyDown += OnKeyDown;
|
||||
}
|
||||
else
|
||||
{
|
||||
inputElement.KeyDown -= OnKeyDown;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool GetHasUserInteracted(InputElement input) => input.GetValue(HasUserInteractedProperty);
|
||||
|
||||
public static void SetHasUserInteracted(InputElement input, bool value) => input.SetValue(HasUserInteractedProperty, value);
|
||||
|
||||
public static bool GetUseCustomTitleBar(Window window) => window.GetValue(UseCustomTitleBarProperty);
|
||||
|
||||
public static void SetUseCustomTitleBar(Window window, bool value) => window.SetValue(UseCustomTitleBarProperty, value);
|
||||
}
|
12
Nitrox.Launcher/Models/Design/NitroxBlog.cs
Normal file
12
Nitrox.Launcher/Models/Design/NitroxBlog.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public sealed record NitroxBlog(string Title, DateOnly Date, string Url, Bitmap Image)
|
||||
{
|
||||
public NitroxBlog() : this("", default, "", null)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
6
Nitrox.Launcher/Models/Design/NitroxChangelog.cs
Normal file
6
Nitrox.Launcher/Models/Design/NitroxChangelog.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
[Serializable]
|
||||
public record NitroxChangelog(string Version, DateTime Released, string PatchNotes);
|
28
Nitrox.Launcher/Models/Design/NotificationItem.cs
Normal file
28
Nitrox.Launcher/Models/Design/NotificationItem.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Controls.Notifications;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public partial class NotificationItem : ObservableObject
|
||||
{
|
||||
public string Message { get; }
|
||||
public NotificationType Type { get; }
|
||||
public ICommand CloseCommand { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool dismissed;
|
||||
|
||||
public NotificationItem()
|
||||
{
|
||||
}
|
||||
|
||||
public NotificationItem(string message, NotificationType type = NotificationType.Information, ICommand closeCommand = null)
|
||||
{
|
||||
Message = message;
|
||||
Type = type;
|
||||
CloseCommand = closeCommand ?? new RelayCommand(() => WeakReferenceMessenger.Default.Send(new NotificationCloseMessage(this)));
|
||||
}
|
||||
}
|
8
Nitrox.Launcher/Models/Design/OutputLine.cs
Normal file
8
Nitrox.Launcher/Models/Design/OutputLine.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public record OutputLine
|
||||
{
|
||||
public string Timestamp { get; init; }
|
||||
public string LogText { get; init; }
|
||||
public OutputLineType Type { get; init; }
|
||||
}
|
10
Nitrox.Launcher/Models/Design/OutputLineType.cs
Normal file
10
Nitrox.Launcher/Models/Design/OutputLineType.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public enum OutputLineType
|
||||
{
|
||||
INFO_LOG,
|
||||
DEBUG_LOG,
|
||||
WARNING_LOG,
|
||||
ERROR_LOG,
|
||||
COMMAND
|
||||
}
|
20
Nitrox.Launcher/Models/Design/RoutingScreen.cs
Normal file
20
Nitrox.Launcher/Models/Design/RoutingScreen.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public partial class RoutingScreen : ObservableObject, IRoutingScreen
|
||||
{
|
||||
[ObservableProperty]
|
||||
private object activeViewModel;
|
||||
|
||||
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(ActiveViewModel))
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new ViewShownMessage(ActiveViewModel));
|
||||
}
|
||||
base.OnPropertyChanged(e);
|
||||
}
|
||||
}
|
413
Nitrox.Launcher/Models/Design/ServerEntry.cs
Normal file
413
Nitrox.Launcher/Models/Design/ServerEntry.cs
Normal file
@@ -0,0 +1,413 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Nitrox.Launcher.Models.Exceptions;
|
||||
using NitroxModel.DataStructures.GameLogic;
|
||||
using NitroxModel.Helper;
|
||||
using NitroxModel.Logger;
|
||||
using NitroxModel.Serialization;
|
||||
using NitroxModel.Server;
|
||||
using NitroxServer.Serialization;
|
||||
using NitroxServer.Serialization.World;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Manager object for a server. Used to start/stop a server and change its settings.
|
||||
/// </summary>
|
||||
public partial class ServerEntry : ObservableObject
|
||||
{
|
||||
public const string DEFAULT_SERVER_ICON_NAME = "servericon.png";
|
||||
public const string DEFAULT_SERVER_CONFIG_NAME = "server.cfg";
|
||||
|
||||
private static readonly SubnauticaServerConfig serverDefaults = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private bool allowCommands = !serverDefaults.DisableConsole;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool allowLanDiscovery = serverDefaults.LANDiscoveryEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool autoPortForward = serverDefaults.AutoPortForward;
|
||||
|
||||
[ObservableProperty]
|
||||
private int autoSaveInterval = serverDefaults.SaveInterval / 1000;
|
||||
|
||||
[ObservableProperty]
|
||||
private NitroxGameMode gameMode = serverDefaults.GameMode;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isEmbedded;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isNewServer = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isOnline;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime lastAccessedTime = DateTime.Now;
|
||||
|
||||
[ObservableProperty]
|
||||
private int maxPlayers = serverDefaults.MaxConnections;
|
||||
|
||||
[ObservableProperty]
|
||||
private string name;
|
||||
|
||||
[ObservableProperty]
|
||||
private string password;
|
||||
|
||||
[ObservableProperty]
|
||||
private Perms playerPermissions = serverDefaults.DefaultPlayerPerm;
|
||||
|
||||
[ObservableProperty]
|
||||
private int players;
|
||||
|
||||
[ObservableProperty]
|
||||
private int port = serverDefaults.ServerPort;
|
||||
|
||||
[ObservableProperty]
|
||||
private string seed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Bitmap serverIcon;
|
||||
|
||||
[ObservableProperty]
|
||||
private Version version = NitroxEnvironment.Version;
|
||||
|
||||
internal ServerProcess Process { get; private set; }
|
||||
|
||||
public static ServerEntry FromDirectory(string saveDir)
|
||||
{
|
||||
ServerEntry result = new();
|
||||
return result.RefreshFromDirectory(saveDir) ? result : null;
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
case nameof(IsOnline):
|
||||
WeakReferenceMessenger.Default.Send(new ServerStatusMessage(this, IsOnline));
|
||||
break;
|
||||
}
|
||||
base.OnPropertyChanged(e);
|
||||
}
|
||||
|
||||
public static ServerEntry CreateNew(string saveDir, NitroxGameMode saveGameMode)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(saveDir, nameof(saveDir));
|
||||
|
||||
Directory.CreateDirectory(saveDir);
|
||||
|
||||
SubnauticaServerConfig config = SubnauticaServerConfig.Load(saveDir);
|
||||
string fileEnding = config.SerializerMode switch
|
||||
{
|
||||
ServerSerializerMode.JSON => ServerJsonSerializer.FILE_ENDING,
|
||||
ServerSerializerMode.PROTOBUF => ServerProtoBufSerializer.FILE_ENDING,
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
|
||||
File.WriteAllText(Path.Combine(saveDir, $"Version{fileEnding}"), null);
|
||||
using (config.Update(saveDir))
|
||||
{
|
||||
config.GameMode = saveGameMode;
|
||||
}
|
||||
|
||||
return FromDirectory(saveDir);
|
||||
}
|
||||
|
||||
public bool RefreshFromDirectory(string saveDir)
|
||||
{
|
||||
if (!File.Exists(Path.Combine(saveDir, DEFAULT_SERVER_CONFIG_NAME)))
|
||||
{
|
||||
Log.Warn($"Tried loading invalid save directory at '{saveDir}'");
|
||||
return false;
|
||||
}
|
||||
|
||||
Bitmap serverIcon = null;
|
||||
string serverIconPath = Path.Combine(saveDir, DEFAULT_SERVER_ICON_NAME);
|
||||
if (File.Exists(serverIconPath))
|
||||
{
|
||||
serverIcon = new Bitmap(Path.Combine(saveDir, DEFAULT_SERVER_ICON_NAME));
|
||||
}
|
||||
|
||||
SubnauticaServerConfig config = SubnauticaServerConfig.Load(saveDir);
|
||||
string fileEnding = config.SerializerMode switch
|
||||
{
|
||||
ServerSerializerMode.JSON => ServerJsonSerializer.FILE_ENDING,
|
||||
ServerSerializerMode.PROTOBUF => ServerProtoBufSerializer.FILE_ENDING,
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
|
||||
string saveFileVersion = Path.Combine(saveDir, $"Version{fileEnding}");
|
||||
if (!File.Exists(saveFileVersion))
|
||||
{
|
||||
Log.Warn($"Tried loading invalid save directory at '{saveDir}', Version file is missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
Version version;
|
||||
using (FileStream stream = new(saveFileVersion, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
version = config.SerializerMode switch
|
||||
{
|
||||
ServerSerializerMode.JSON => new ServerJsonSerializer().Deserialize<SaveFileVersion>(stream)?.Version ?? NitroxEnvironment.Version,
|
||||
ServerSerializerMode.PROTOBUF => new ServerProtoBufSerializer().Deserialize<SaveFileVersion>(stream)?.Version ?? NitroxEnvironment.Version,
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
}
|
||||
|
||||
Name = Path.GetFileName(saveDir);
|
||||
ServerIcon = serverIcon;
|
||||
Password = config.ServerPassword;
|
||||
Seed = config.Seed;
|
||||
GameMode = config.GameMode;
|
||||
PlayerPermissions = config.DefaultPlayerPerm;
|
||||
AutoSaveInterval = config.SaveInterval / 1000;
|
||||
MaxPlayers = config.MaxConnections;
|
||||
Port = config.ServerPort;
|
||||
AutoPortForward = config.AutoPortForward;
|
||||
AllowLanDiscovery = config.LANDiscoveryEnabled;
|
||||
AllowCommands = !config.DisableConsole;
|
||||
IsNewServer = !File.Exists(Path.Combine(saveDir, $"PlayerData{fileEnding}"));
|
||||
Version = version;
|
||||
IsEmbedded = config.IsEmbedded || RuntimeInformation.IsOSPlatform(OSPlatform.OSX); // Force embedded on MacOS
|
||||
LastAccessedTime = File.GetLastWriteTime(File.Exists(Path.Combine(saveDir, $"PlayerData{fileEnding}"))
|
||||
?
|
||||
// This file is affected by server saving
|
||||
Path.Combine(saveDir, $"PlayerData{fileEnding}")
|
||||
:
|
||||
// If the above file doesn't exist (server was never ran), use the Version file instead
|
||||
Path.Combine(saveDir, $"Version{fileEnding}"));
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Start(string savesDir)
|
||||
{
|
||||
if (!Directory.Exists(savesDir))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Directory '{savesDir}' not found");
|
||||
}
|
||||
|
||||
if (Process?.IsRunning ?? false)
|
||||
{
|
||||
throw new DuplicateSingularApplicationException("Nitrox Server");
|
||||
}
|
||||
|
||||
// Start server and add notify when server closed.
|
||||
Process = ServerProcess.Start(Path.Combine(savesDir, Name), () => Dispatcher.UIThread.InvokeAsync(StopAsync), IsEmbedded);
|
||||
|
||||
IsNewServer = false;
|
||||
IsOnline = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task<bool> StopAsync()
|
||||
{
|
||||
if (Process is not { IsRunning: true })
|
||||
{
|
||||
IsOnline = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await Process.CloseAsync())
|
||||
{
|
||||
IsOnline = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void OpenSaveFolder()
|
||||
{
|
||||
System.Diagnostics.Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), Name),
|
||||
Verb = "open",
|
||||
UseShellExecute = true
|
||||
})?.Dispose();
|
||||
}
|
||||
|
||||
internal partial class ServerProcess : IDisposable
|
||||
{
|
||||
private NamedPipeClientStream commandStream;
|
||||
private OutputLineType lastOutputType;
|
||||
private Process serverProcess;
|
||||
|
||||
[GeneratedRegex(@"^\[(?<timestamp>\d{2}:\d{2}:\d{2}\.\d{3})\]\s\[(?<level>\w+)\](?<logText>.*)?$")]
|
||||
private static partial Regex OutputLineRegex { get; }
|
||||
|
||||
public bool IsRunning => !serverProcess?.HasExited ?? false;
|
||||
public AvaloniaList<OutputLine> Output { get; } = [];
|
||||
|
||||
private ServerProcess(string saveDir, Action onExited, bool isEmbeddedMode = false)
|
||||
{
|
||||
string serverExeName = "NitroxServer-Subnautica.exe";
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
serverExeName = "NitroxServer-Subnautica";
|
||||
}
|
||||
string serverFile = Path.Combine(NitroxUser.ExecutableRootPath, serverExeName);
|
||||
ProcessStartInfo startInfo = new(serverFile)
|
||||
{
|
||||
WorkingDirectory = NitroxUser.ExecutableRootPath,
|
||||
ArgumentList =
|
||||
{
|
||||
"--save",
|
||||
Path.GetFileName(saveDir)
|
||||
},
|
||||
RedirectStandardOutput = isEmbeddedMode,
|
||||
RedirectStandardError = isEmbeddedMode,
|
||||
RedirectStandardInput = isEmbeddedMode,
|
||||
WindowStyle = isEmbeddedMode ? ProcessWindowStyle.Hidden : ProcessWindowStyle.Normal,
|
||||
CreateNoWindow = isEmbeddedMode
|
||||
};
|
||||
if (isEmbeddedMode)
|
||||
{
|
||||
startInfo.ArgumentList.Add("--embedded");
|
||||
}
|
||||
Log.Info($"Starting server:{Environment.NewLine}File: {startInfo.FileName}{Environment.NewLine}Working directory: {startInfo.WorkingDirectory}{Environment.NewLine}Arguments: {string.Join(", ", startInfo.ArgumentList)}");
|
||||
|
||||
serverProcess = System.Diagnostics.Process.Start(startInfo);
|
||||
if (serverProcess != null)
|
||||
{
|
||||
serverProcess.EnableRaisingEvents = true; // Required for 'Exited' event from process.
|
||||
if (isEmbeddedMode)
|
||||
{
|
||||
serverProcess.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Match match = OutputLineRegex.Match(args.Data);
|
||||
if (match.Success)
|
||||
{
|
||||
OutputLine outputLine = new()
|
||||
{
|
||||
Timestamp = $"[{match.Groups["timestamp"].ValueSpan}]",
|
||||
LogText = match.Groups["logText"].ValueSpan.Trim().ToString(),
|
||||
Type = match.Groups["level"].ValueSpan switch
|
||||
{
|
||||
"DBG" => OutputLineType.DEBUG_LOG,
|
||||
"WRN" => OutputLineType.WARNING_LOG,
|
||||
"ERR" => OutputLineType.ERROR_LOG,
|
||||
_ => OutputLineType.INFO_LOG
|
||||
}
|
||||
};
|
||||
lastOutputType = outputLine.Type;
|
||||
Output.Add(outputLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
Output.Add(new OutputLine
|
||||
{
|
||||
Timestamp = "",
|
||||
LogText = args.Data,
|
||||
Type = lastOutputType
|
||||
});
|
||||
}
|
||||
};
|
||||
serverProcess.BeginOutputReadLine();
|
||||
}
|
||||
serverProcess.Exited += (_, _) =>
|
||||
{
|
||||
onExited?.Invoke();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static ServerProcess Start(string saveDir, Action onExited, bool isEmbedded) => new(saveDir, onExited, isEmbedded);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to close the server gracefully with a timeout of 30 seconds. If it fails, returns false.
|
||||
/// </summary>
|
||||
public async Task<bool> CloseAsync()
|
||||
{
|
||||
using CancellationTokenSource ctsCloseTimeout = new(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
do
|
||||
{
|
||||
if (!await SendCommandAsync("stop"))
|
||||
{
|
||||
await Task.Delay(100, ctsCloseTimeout.Token);
|
||||
}
|
||||
} while (IsRunning && !ctsCloseTimeout.IsCancellationRequested);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
if (IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> SendCommandAsync(string command)
|
||||
{
|
||||
if (!IsRunning || string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
commandStream ??= new NamedPipeClientStream(".", $"Nitrox Server {serverProcess.Id}", PipeDirection.Out, PipeOptions.Asynchronous);
|
||||
if (!commandStream.IsConnected)
|
||||
{
|
||||
await commandStream.ConnectAsync(5000);
|
||||
}
|
||||
byte[] commandBytes = Encoding.UTF8.GetBytes(command);
|
||||
await commandStream.WriteAsync(BitConverter.GetBytes((uint)commandBytes.Length));
|
||||
await commandStream.WriteAsync(commandBytes);
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// ignored - "broken pipe" or "socket shutdown"
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
commandStream?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
serverProcess?.Dispose();
|
||||
serverProcess = null;
|
||||
}
|
||||
}
|
||||
}
|
18
Nitrox.Launcher/Models/Design/ServerStartEventArgs.cs
Normal file
18
Nitrox.Launcher/Models/Design/ServerStartEventArgs.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace Nitrox.Launcher.Models.Design;
|
||||
|
||||
public sealed class ServerStartEventArgs : EventArgs
|
||||
{
|
||||
public bool IsEmbedded { get; }
|
||||
|
||||
public ServerStartEventArgs(bool embedded)
|
||||
{
|
||||
IsEmbedded = embedded;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[ServerStartEventArgs - IsEmbedded: {IsEmbedded}]";
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user