first commit

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

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
namespace Nitrox.Launcher.Models.Extensions;
/// <summary>
/// Avalonia doesn't provide a public API to close the window non-programmatically so this is a hack to support it.
/// </summary>
public static class CloseByUserExtensions
{
private static readonly Dictionary<Window, bool> isClosingByUser = [];
/// <summary>
/// Closes the window non-programmatically (by user).
/// </summary>
public static void CloseByUser(this Window window)
{
if (window == null)
{
return;
}
window.Closed += WindowOnClosed;
isClosingByUser[window] = true;
window.Close();
static void WindowOnClosed(object sender, EventArgs e)
{
if (sender is not Window window)
{
return;
}
window.Closed -= WindowOnClosed;
isClosingByUser.Remove(window);
}
}
/// <summary>
/// Closes the window programmatically.
/// </summary>
public static void CloseByCode(this Window window)
{
if (window == null)
{
return;
}
isClosingByUser[window] = false;
window.Close();
}
public static bool IsClosingByUser(this Window closingWindow, WindowClosingEventArgs closingArgs = null)
{
if (closingWindow is not null && isClosingByUser.TryGetValue(closingWindow, out bool isByUser))
{
return isByUser;
}
if (closingArgs is { IsProgrammatic: false })
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace Nitrox.Launcher.Models.Extensions;
public static class CollectionExtensions
{
public static void AddRange<T>(this Collection<T> collection, params IEnumerable<T> items)
{
foreach (T item in items)
{
collection.Add(item);
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using Avalonia.Threading;
using HanumanInstitute.MvvmDialogs;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels;
using Nitrox.Launcher.ViewModels.Abstract;
using NitroxModel.Logger;
namespace Nitrox.Launcher.Models.Extensions;
public static class DialogServiceExtensions
{
public static async Task<T> ShowAsync<T, TExtra>(this IDialogService dialogService, Action<T, TExtra> setup = null, TExtra extraParameter = default) where T : ModalViewModelBase
{
try
{
ArgumentNullException.ThrowIfNull(dialogService);
// DataContext must be accessed on the UI thread, or it'll throw error.
INotifyPropertyChanged owner = await Dispatcher.UIThread.InvokeAsync(() => AppViewLocator.MainWindow?.DataContext as INotifyPropertyChanged);
if (owner == null)
{
throw new InvalidOperationException($"Expected {nameof(AppViewLocator.MainWindow)}.{nameof(AppViewLocator.MainWindow.DataContext)} to not be null");
}
T viewModel = dialogService.CreateViewModel<T>();
setup?.Invoke(viewModel, extraParameter);
bool? result = await dialogService.ShowDialogAsync<T>(owner, viewModel);
if (result == true)
{
return viewModel;
}
return default;
}
catch (Exception ex)
{
Log.Error(ex, $"Failed to show dialog for ViewModel {typeof(T).FullName}");
LauncherNotifier.Error(ex.Message);
return default;
}
}
public static Task<T> ShowAsync<T>(this IDialogService dialogService, Action<T> setup = null) where T : ModalViewModelBase => dialogService.ShowAsync<T, Action<T>>((model, act) => act?.Invoke(model), setup);
public static Task ShowErrorAsync(this IDialogService dialogService, Exception exception, string title = null, string description = null) =>
dialogService.ShowAsync<DialogBoxViewModel>(model =>
{
model.Title = title ?? "Error";
model.Description = string.IsNullOrWhiteSpace(description) ? exception.ToString() : $"{description}{Environment.NewLine}{exception}";
model.ButtonOptions = ButtonOptions.OkClipboard;
});
}

View File

@@ -0,0 +1,17 @@
using NitroxModel.Helper;
namespace Nitrox.Launcher.Models.Extensions;
public static class KeyValueStoreExtensions
{
public static string GetSubnauticaLaunchArguments(this IKeyValueStore store, string defaultValue = "-vrmode none") => store == null ? defaultValue : store.GetValue("SubnauticaLaunchArguments", defaultValue);
public static void SetSubnauticaLaunchArguments(this IKeyValueStore store, string value)
{
if (store == null)
{
return;
}
store.SetValue("SubnauticaLaunchArguments", value);
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
namespace Nitrox.Launcher.Models.Extensions;
public static class MessageReceiverExtensions
{
public static void RegisterMessageListener<T, TReceiver>(this TReceiver receiver, Func<T, TReceiver, Task> asyncFunc) where T : class where TReceiver : IMessageReceiver
{
if (WeakReferenceMessenger.Default.IsRegistered<T>(receiver))
{
WeakReferenceMessenger.Default.Unregister<T>(receiver);
}
WeakReferenceMessenger.Default.Register<T>(receiver, (_, message) => asyncFunc(message, receiver));
}
public static void RegisterMessageListener<T, TReceiver>(this TReceiver receiver, Action<T, TReceiver> action) where T : class where TReceiver : IMessageReceiver
{
if (WeakReferenceMessenger.Default.IsRegistered<T>(receiver))
{
WeakReferenceMessenger.Default.Unregister<T>(receiver);
}
WeakReferenceMessenger.Default.Register<T>(receiver, (_, message) => action(message, receiver));
}
}

View File

@@ -0,0 +1,33 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using NitroxModel.Platforms.OS.Shared;
using NitroxModel.Platforms.OS.Windows;
namespace Nitrox.Launcher.Models.Extensions;
public static class ProcessExExtensions
{
public static void SetForegroundWindowAndRestore(this ProcessEx process)
{
if (Avalonia.Controls.Design.IsDesignMode)
{
return;
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
WindowsApi.BringProcessToFront(process.MainWindowHandle);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
// xdotool sends an XEvent to X11 window manager on Linux systems.
string command = $"xdotool windowactivate $(xdotool search --pid {process.Id} --onlyvisible --desktop '$(xdotool get_desktop)' --name 'nitrox launcher')";
using Process proc = Process.Start(new ProcessStartInfo
{
FileName = "sh",
ArgumentList = { "-c", command },
});
// TODO: Support "bring to front" on Wayland window manager.
}
}
}

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels.Abstract;
namespace Nitrox.Launcher.Models.Extensions;
public static class ScreenExtensions
{
private static readonly List<RoutableViewModelBase> navigationStack = [];
/// <summary>
/// Navigates to a view assigned to the given ViewModel.
/// </summary>
/// <param name="screen">The screen used to display the view.</param>
/// <param name="routableViewModel">ViewModel that should be shown.</param>
/// <typeparam name="TViewModel">Type of the ViewModel to show.</typeparam>
public static async Task ShowAsync<TViewModel>(this IRoutingScreen screen, TViewModel routableViewModel) where TViewModel : RoutableViewModelBase
{
if (screen == null)
{
return;
}
// When navigating away from a view in an async button command, busy states on buttons should also reset. Otherwise, when navigating back it would still show buttons being busy.
NitroxAttached.AsyncCommandButtonTagger.Clear();
if (screen.ActiveViewModel is RoutableViewModelBase routableViewModelBase)
{
navigationStack.RemoveAllFast(screen.ActiveViewModel, (item, param) => item.GetType() == param.GetType());
await routableViewModelBase.ViewContentUnloadAsync();
navigationStack.Add(routableViewModelBase);
}
Stopwatch sw = Stopwatch.StartNew();
Task contentLoadTask = routableViewModel.ViewContentLoadAsync();
if (screen.ActiveViewModel != null)
{
// Only show loading screen if page isn't loading super quickly.
await Task.Delay(50);
if (!contentLoadTask.IsCompleted)
{
screen.ActiveViewModel = AssetHelper.GetFullAssetPath("/Assets/Icons/loading.svg");
await Task.Delay((int)Math.Max(0, 500 - sw.Elapsed.TotalMilliseconds));
}
}
await contentLoadTask;
screen.ActiveViewModel = routableViewModel;
}
public static async Task<bool> BackAsync(this IRoutingScreen screen)
{
if (navigationStack.Count < 1)
{
return false;
}
RoutableViewModelBase backViewModel = navigationStack[^1];
navigationStack.Remove(backViewModel);
await ShowAsync(screen, backViewModel);
return true;
}
/// <summary>
/// Tries to go back to the view assigned to the given ViewModel.
/// </summary>
/// <returns>
/// True if ViewModel was found in the routing navigation stack. False when the ViewModel wasn't found and routing
/// failed.
/// </returns>
public static async Task<bool> BackToAsync<T>(this IRoutingScreen screen) where T : RoutableViewModelBase
{
for (int i = navigationStack.Count - 1; i >= 0; i--)
{
if (navigationStack[i] is T target)
{
// Cleanup the stack up and including the back-target.
for (int j = i; j < navigationStack.Count; j++)
{
navigationStack.RemoveAt(j);
}
await screen.ShowAsync(target);
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,45 @@
using HanumanInstitute.MvvmDialogs;
using HanumanInstitute.MvvmDialogs.Avalonia;
using Microsoft.Extensions.DependencyInjection;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Services;
using Nitrox.Launcher.ViewModels.Abstract;
using Nitrox.Launcher.Views.Abstract;
using NitroxModel.Helper;
using ServiceScan.SourceGenerator;
namespace Nitrox.Launcher.Models.Extensions;
public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection collection)
{
// Avalonia and Reactive services
collection.AddSingleton(provider => new AppViewLocator(provider));
collection.AddSingleton<IRoutingScreen, RoutingScreen>();
collection.AddSingleton<IDialogService>(provider => new DialogService(
new DialogManager(
provider.GetRequiredService<AppViewLocator>(),
new DialogFactory()),
provider.GetRequiredService));
// Domain services
collection.AddSingleton(_ => KeyValueStore.Instance);
collection.AddSingleton<ServerService>();
return collection
.AddDialogs()
.AddViews()
.AddViewModels();
}
[GenerateServiceRegistrations(AssignableTo = typeof(ModalViewModelBase), AsSelf = true)]
[GenerateServiceRegistrations(AssignableTo = typeof(ModalBase), AsSelf = true)]
private static partial IServiceCollection AddDialogs(this IServiceCollection services);
[GenerateServiceRegistrations(AssignableTo = typeof(RoutableViewBase<>), AsSelf = true, Lifetime = ServiceLifetime.Singleton)]
private static partial IServiceCollection AddViews(this IServiceCollection services);
[GenerateServiceRegistrations(AssignableTo = typeof(ViewModelBase), AsSelf = true)]
private static partial IServiceCollection AddViewModels(this IServiceCollection services);
}

View File

@@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Platform.Storage;
namespace Nitrox.Launcher.Models.Extensions;
public static class StorageProviderExtensions
{
public static async Task<string> OpenFolderPickerAsync(this IStorageProvider storageProvider, string title, string startingFolder = null)
{
IStorageFolder startingStorageFolder = null;
if (startingFolder != null)
{
startingStorageFolder = await storageProvider.TryGetFolderFromPathAsync(startingFolder);
}
IReadOnlyList<IStorageFolder> dialogResult = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = title,
AllowMultiple = false,
SuggestedStartLocation = startingStorageFolder
});
return dialogResult.FirstOrDefault()?.TryGetLocalPath() ?? "";
}
}

View File

@@ -0,0 +1,15 @@
using System.IO;
namespace Nitrox.Launcher.Models.Extensions;
public static class StringExtensions
{
public static string ReplaceInvalidFileNameCharacters(this string value)
{
foreach (char invalidFileNameChar in Path.GetInvalidFileNameChars())
{
value = value.Replace(invalidFileNameChar, ' ');
}
return value.Trim();
}
}

View File

@@ -0,0 +1,34 @@
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
using NitroxModel.Platforms.OS.Windows;
namespace Nitrox.Launcher.Models.Extensions;
public static class VisualExtensions
{
public static void ApplyOsWindowStyling(this Visual visual)
{
if (Avalonia.Controls.Design.IsDesignMode)
{
return;
}
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return;
}
if (visual.GetWindow() is not { } window)
{
return;
}
nint? windowHandle = window.TryGetPlatformHandle()?.Handle;
if (!windowHandle.HasValue)
{
return;
}
WindowsApi.EnableDefaultWindowAnimations(windowHandle.Value, window.CanResize);
}
public static Window GetWindow(this Visual visual) => TopLevel.GetTopLevel(visual) as Window;
}