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

27
Nitrox.Launcher/App.axaml Normal file
View File

@@ -0,0 +1,27 @@
<Application x:Class="Nitrox.Launcher.App"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Name="Nitrox">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<MergeResourceInclude Source="/Models/Styles/Palette/Nitrox.axaml" />
<MergeResourceInclude Source="/Models/Controls/CustomTitlebar.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="/Models/Styles/Nitrox.axaml" />
</Application.Styles>
<!-- Menu that is used on macOS and some Linux distributions -->
<NativeMenu.Menu>
<NativeMenu>
<NativeMenuItem Header="You are the best captain on this planet" />
</NativeMenu>
</NativeMenu.Menu>
</Application>

View File

@@ -0,0 +1,219 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using Avalonia.Threading;
using ConsoleAppFramework;
using Microsoft.Extensions.DependencyInjection;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.Models.Validators;
using Nitrox.Launcher.ViewModels;
using Nitrox.Launcher.Views;
using NitroxModel.Helper;
using NitroxModel.Logger;
using NitroxModel.Platforms.OS.Shared;
namespace Nitrox.Launcher;
public class App : Application
{
internal const string CRASH_REPORT_FILE_NAME = "Nitrox.Launcher-crash.txt";
internal static Func<Window> StartupWindowFactory;
internal static InstantLaunchData InstantLaunch;
internal static bool IsCrashReport;
/// <summary>
/// If true, allows duplicate instances of the app to be active.
/// </summary>
internal static bool AllowInstances;
internal static X11RenderingMode? PreferredRenderingMode;
public Window AppWindow
{
set
{
switch (ApplicationLifetime)
{
case IClassicDesktopStyleApplicationLifetime desktop:
desktop.MainWindow = value;
break;
case ISingleViewApplicationLifetime singleViewPlatform:
singleViewPlatform.MainView = value;
break;
case null when Design.IsDesignMode:
Log.Info("Running in design previewer!");
break;
default:
throw new NotSupportedException($"Current platform '{ApplicationLifetime?.GetType().Name}' is not supported by {nameof(Nitrox)}.{nameof(Launcher)}");
}
}
}
public static AppBuilder Create()
{
CultureManager.ConfigureCultureInfo();
Log.Setup();
// Handle command line arguments.
ConsoleApp.ConsoleAppBuilder cliParser = ConsoleApp.Create();
cliParser.Add("", (bool crashReport, X11RenderingMode? rendering = null, bool allowInstances = false) =>
{
IsCrashReport = crashReport;
PreferredRenderingMode = rendering;
AllowInstances = allowInstances;
if (IsCrashReport)
{
string executableRootPath = Path.GetDirectoryName(Environment.ProcessPath ?? NitroxUser.ExecutableRootPath);
if (executableRootPath != null)
{
string crashReportContent = File.ReadAllText(Path.Combine(executableRootPath, CRASH_REPORT_FILE_NAME));
StartupWindowFactory = () => new CrashWindow { DataContext = new CrashWindowViewModel { Title = $"Nitrox {NitroxEnvironment.Version} - Crash Report", Message = crashReportContent } };
}
}
});
cliParser.Add("instantlaunch", ([SaveName] string save, [MinLength(1)] params string[] players) =>
{
InstantLaunch = new InstantLaunchData(save, players);
});
cliParser.Run(Environment.GetCommandLineArgs().Skip(1).ToArray());
// Fallback to normal startup.
if (StartupWindowFactory == null)
{
if (!AllowInstances)
{
CheckForRunningInstance();
}
ServiceProvider services = new ServiceCollection().AddAppServices().BuildServiceProvider();
StartupWindowFactory = () => new MainWindow { DataContext = services.GetRequiredService<MainWindowViewModel>() };
}
AppBuilder builder = AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace()
.With(new SkiaOptions { UseOpacitySaveLayer = true });
builder = WithRenderingMode(builder, PreferredRenderingMode);
return builder;
static AppBuilder WithRenderingMode(AppBuilder builder, X11RenderingMode? rendering)
{
if (rendering.HasValue)
{
return builder.With(new X11PlatformOptions { RenderingMode = [rendering.Value] });
}
// The Wayland+GPU is not supported by Avalonia, but Xwayland should work.
if (Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is not null)
{
if (!ProcessEx.ProcessExists("Xwayland"))
{
return builder.With(new X11PlatformOptions { RenderingMode = [X11RenderingMode.Software] });
}
}
return builder;
}
}
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
FixAvaloniaPlugins();
ApplyAppDefaults();
Dispatcher.UIThread.UnhandledException += (_, eventArgs) => HandleUnhandledException(eventArgs.Exception);
AppWindow = StartupWindowFactory();
base.OnFrameworkInitializationCompleted();
}
internal static void HandleUnhandledException(Exception ex)
{
if (IsCrashReport)
{
Log.Error(ex, "Error while trying to show crash report");
return;
}
Log.Error(ex, "!!!Nitrox Launcher Crash!!!");
// Write crash report if we're not reporting one right now.
try
{
string executableFilePath = NitroxUser.ExecutableFilePath ?? Environment.ProcessPath;
string executableRoot = Path.GetDirectoryName(executableFilePath);
if (executableFilePath != null && executableRoot != null)
{
string crashReportFile = Path.Combine(executableRoot, CRASH_REPORT_FILE_NAME);
File.WriteAllText(crashReportFile, ex.ToString());
ProcessUtils.StartSelf("--crash-report");
}
else
{
Log.Error(ex, "Unable to get executable file path for writing crash report.");
}
}
catch (Exception exception)
{
Console.WriteLine(exception);
}
Environment.Exit(1);
}
private static void CheckForRunningInstance()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return;
}
try
{
using ProcessEx process = ProcessEx.GetFirstProcess("Nitrox.Launcher", process => process.Id != Environment.ProcessId && process.IsRunning);
if (process is not null)
{
process.SetForegroundWindowAndRestore();
Environment.Exit(0);
}
}
catch (Exception)
{
// Ignore
}
}
/// <summary>
/// Disables Avalonia plugins which are replaced by MVVM Toolkit.
/// </summary>
private void FixAvaloniaPlugins()
{
for (int i = BindingPlugins.DataValidators.Count - 1; i >= 0; i--)
{
if (BindingPlugins.DataValidators[i] is DataAnnotationsValidationPlugin)
{
BindingPlugins.DataValidators.RemoveAt(i);
}
}
}
private void ApplyAppDefaults() => RequestedThemeVariant = ThemeVariant.Dark;
internal class InstantLaunchData
{
public string SaveName { get; private set; }
public string[] PlayerNames { get; private set; }
public InstantLaunchData(string saveName, string[] playerNames)
{
SaveName = saveName;
PlayerNames = playerNames;
}
}
}

View File

@@ -0,0 +1,57 @@
using System;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using HanumanInstitute.MvvmDialogs;
using HanumanInstitute.MvvmDialogs.Avalonia;
using Microsoft.Extensions.DependencyInjection;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.ViewModels;
using Nitrox.Launcher.Views;
namespace Nitrox.Launcher;
public sealed class AppViewLocator : ViewLocatorBase
{
private static IServiceProvider serviceProvider;
public AppViewLocator(IServiceProvider serviceProvider)
{
AppViewLocator.serviceProvider = serviceProvider;
}
private static MainWindow mainWindow;
public static MainWindow MainWindow
{
get
{
if (mainWindow != null)
{
return mainWindow;
}
if (Application.Current?.ApplicationLifetime is ClassicDesktopStyleApplicationLifetime desktop)
{
return mainWindow = (MainWindow)desktop.MainWindow;
}
throw new NotSupportedException("This Avalonia application is only supported on desktop environments.");
}
}
public static IRoutingScreen HostScreen => serviceProvider.GetRequiredService<IRoutingScreen>();
public override ViewDefinition Locate(object viewModel)
{
// Only dialogs need to be mapped here. Other views are handled in MainWindow.axaml.
static Type GetViewType(object viewModel) => viewModel switch
{
CreateServerViewModel => typeof(CreateServerModal),
DialogBoxViewModel => typeof(DialogBoxModal),
ObjectPropertyEditorViewModel => typeof(ObjectPropertyEditorModal),
BackupRestoreViewModel => typeof(BackupRestoreModal),
_ => throw new ArgumentOutOfRangeException(nameof(viewModel), viewModel, null)
};
Type newView = GetViewType(viewModel);
return new ViewDefinition(newView, () => serviceProvider.GetRequiredService(newView));
}
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 568 500">
<path class="st0" fill="#fff" d="M123 30c65 49 135 148 161 201 26-53 96-152 161-201 47-36 123-63 123 24 0 17-10 146-16 167-20 72-94 90-160 79 115 20 144 85 81 149-120 123-172-31-185-70-3-7-4-11-4-8 0-3-1 1-4 8-13 39-65 193-185 70-63-64-34-129 81-149-66 11-140-7-160-79C10 200 0 71 0 54 0-33 76-6 123 30z"/>
</svg>

After

Width:  |  Height:  |  Size: 381 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15.97 12.2">
<path class="st0" d="M13.57 1.1a13.2 13.2 0 0 0-3.2-1.1l-.5.9a12.2 12.2 0 0 0-3.6 0 8 8 0 0 0-.4-.9c-1.2.2-2.3.5-3.3 1a12.9 12.9 0 0 0-2.5 9.1 13.3 13.3 0 0 0 4 2l.9-1.3a9 9 0 0 1-1.3-.6v-.1l.2-.2a9.5 9.5 0 0 0 8.1 0l.3.2a8 8 0 0 1-1.2.7l.8 1.4a13.2 13.2 0 0 0 4-2c.4-3.5-.5-6.5-2.3-9.2m-8.3 7.3c-.7 0-1.4-.7-1.4-1.6 0-.9.7-1.6 1.5-1.6.9 0 1.5.7 1.5 1.6 0 .9-.7 1.6-1.5 1.6m5.4 0c-.8 0-1.5-.7-1.5-1.6 0-.9.8-1.6 1.5-1.6s1.4.7 1.4 1.6c0 .9-.6 1.6-1.4 1.6" style="fill:#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 551 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22.94 22.43">
<path class="st0" fill="#fff" d="M11.5 0a11.5 11.5 0 0 0-3.64 22.41c.58.1.8-.24.8-.55l-.02-2.14c-2.89.53-3.64-.7-3.87-1.35-.13-.33-.69-1.35-1.18-1.62-.4-.22-.97-.75 0-.77.9 0 1.54.84 1.76 1.18 1.03 1.74 2.7 1.25 3.35.95.1-.75.4-1.25.73-1.54-2.56-.28-5.23-1.28-5.23-5.67 0-1.25.44-2.29 1.18-3.1a4.14 4.14 0 0 1 .11-3.04s.96-.3 3.16 1.18a10.66 10.66 0 0 1 5.75 0c2.2-1.5 3.17-1.18 3.17-1.18.63 1.58.23 2.76.11 3.05.73.8 1.18 1.82 1.18 3.09 0 4.4-2.69 5.39-5.25 5.67.42.36.78 1.05.78 2.13l-.02 3.16c0 .3.22.67.8.55A11.52 11.52 0 0 0 11.5 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 618 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60.3 53.2">
<path class="st0" d="M60.2 27a6.6 6.6 0 0 0-11-4.5A32 32 0 0 0 31.8 17l2.9-14 9.6 2a4.5 4.5 0 1 0 .6-2.8L33.9 0c-.8-.2-1.5.3-1.7 1l-3.3 15.7c-6.3 0-12.4 2-17.6 5.5A6.6 6.6 0 1 0 4.2 33v2c0 10 11.8 18.2 26.2 18.2 14.5 0 26.3-8 26.3-18.2v-2a6.6 6.6 0 0 0 3.6-6zm-45 4.5a4.5 4.5 0 0 1 9 0 4.5 4.5 0 0 1-9 0zm26.2 12.6V44a17.3 17.3 0 0 1-11.2 3.4c-4 .2-8-1-11-3.4-.5-.5-.4-1.3 0-1.7.5-.4 1.2-.4 1.6 0 2.7 2 6 3 9.4 2.8a15 15 0 0 0 9.4-2.7c.5-.5 1.3-.5 1.8 0s.4 1.3 0 1.8zm-.7-7.8h-.2a4.5 4.5 0 0 1 0-9c2.5 0 4.6 2 4.6 4.4a4.5 4.5 0 0 1-4.4 4.6z" style="fill:#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 637 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 48 48">
<path class="st0" fill="#fff" d="M48 4.74a19.2 19.2 0 0 1-5.65 1.58A10.08 10.08 0 0 0 46.68.74c-1.9 1.16-4.01 2-6.26 2.45A9.71 9.71 0 0 0 33.23 0a9.97 9.97 0 0 0-9.84 10.1c0 .79.08 1.56.25 2.3A27.73 27.73 0 0 1 3.34 1.84 10.28 10.28 0 0 0 6.4 15.33a9.67 9.67 0 0 1-4.46-1.26v.12c0 4.9 3.4 8.98 7.9 9.9a9.39 9.39 0 0 1-4.45.18 9.89 9.89 0 0 0 9.2 7.01A19.44 19.44 0 0 1 0 35.47 27.42 27.42 0 0 0 15.1 40c18.1 0 28-15.38 28-28.73 0-.44 0-.88-.02-1.3A20.45 20.45 0 0 0 48 4.73"/>
</svg>

After

Width:  |  Height:  |  Size: 549 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 199 198" xmlns="http://www.w3.org/2000/svg">
<path stroke="black" style="stroke-width:20;stroke-linejoin:round" d="M11 187 188 11Zm177 0-88-88-89-88 89 88Z"/>
</svg>

After

Width:  |  Height:  |  Size: 187 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88">
<path stroke="black" d="m28 61 32-33Zm32 0L44 45 28 28l16 17Z" style="stroke-width:7;stroke-linejoin:round"/>
<circle stroke="black" cx="44.1" cy="44.1" r="40.6" style="fill:none;stroke-width:7"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88">
<circle stroke="black" cx="44.1" cy="44.1" r="40.6" style="fill:none;stroke-width:7"/>
<path stroke="black" d="M38 35h6zm6 0c0 66 0 0 0 0z" style="fill:none;stroke-width:7;stroke-linejoin:round;stroke-dasharray:none"/>
<circle fill="black" cx="43" cy="20.3" r="5.1" style="fill-opacity:1;stroke:none;stroke-width:34.4577;stroke-linecap:butt;stroke-linejoin:bevel;stroke-dasharray:none;stroke-opacity:1"/>
</svg>

After

Width:  |  Height:  |  Size: 484 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M12 1a11 11 0 1 0 11 11A11 11 0 0 0 12 1zm0 19a8 8 0 1 1 8-8 8 8 0 0 1-8 8z" opacity=".25" />
<path d="M10.14 1.16a11 11 0 0 0-9 8.92A1.59 1.59 0 0 0 2.46 12a1.52 1.52 0 0 0 1.65-1.3 8 8 0 0 1 6.66-6.61A1.42 1.42 0 0 0 12 2.69h0a1.57 1.57 0 0 0-1.86-1.53z" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 132">
<path class="st0" d="M0 1h130v130H0z" stroke="black" style="fill:none;stroke-width:15"/>
</svg>

After

Width:  |  Height:  |  Size: 162 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 132">
<path fill="black" d="M0 61h130v10H0Z" />
</svg>

After

Width:  |  Height:  |  Size: 115 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 132">
<path stroke="black" d="M30 7v29h62v64h31V7Zm62 93V36H7v89h85Z" style="fill:none;stroke-width:8;"/>
</svg>

After

Width:  |  Height:  |  Size: 173 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 132">
<path fill="black" d="M0 0v51l107 16L0 81v51l130-65Z"/>
</svg>

After

Width:  |  Height:  |  Size: 129 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88">
<circle stroke="black" cx="44.1" cy="44.1" r="40.6" style="fill:none;stroke-width:7"/>
<path stroke="black" d="m23 47 15 13 26-27-26 27Z" style="stroke-width:7;stroke-linejoin:round"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 63.3 63.5">
<circle stroke="black" cx="31.6" cy="48" r="4.3" />
<path stroke="black" d="M31.6 25.8v14.4z" style="stroke-width:7"/>
<path stroke="black" d="M85.3 66H18.5l16.7-29L52 8.1l16.7 29Z" style="fill:none;stroke-width:5;stroke-linejoin:round" transform="matrix(.88 0 0 1 -14 -5.5)"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

View File

@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024 Nitrox
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>SecTaskAccess</key>
<array>
<string>allowed</string>
</array>
</dict>
</plist>

Binary file not shown.

View File

@@ -0,0 +1,2 @@
global using NitroxModel;
global using Nitrox.Launcher.Models.Extensions;

View File

@@ -0,0 +1,43 @@
using Avalonia.Controls;
using Avalonia.Xaml.Interactivity;
using CommunityToolkit.Mvvm.Messaging;
namespace Nitrox.Launcher.Models.Behaviors;
/// <summary>
/// Focuses the <see cref="Behavior.AssociatedObject" /> when its parent view is shown.
/// </summary>
public class FocusOnViewShowBehavior : Behavior<Control>
{
protected override void OnAttached()
{
WeakReferenceMessenger.Default.Register<ViewShownMessage>(this, static (obj, _) => (obj as FocusOnViewShowBehavior)?.Focus());
base.OnAttached();
}
protected override void OnDetaching()
{
WeakReferenceMessenger.Default.UnregisterAll(this);
base.OnDetaching();
}
protected override void OnAttachedToVisualTree() => Focus();
private void Focus()
{
if (AssociatedObject == null)
{
return;
}
if (!AssociatedObject.IsEffectivelyVisible)
{
return;
}
AssociatedObject.Focus();
if (AssociatedObject is TextBox textBox)
{
textBox.SelectAll();
}
}
}

View File

@@ -0,0 +1,133 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Reactive;
using Avalonia.Styling;
namespace Nitrox.Launcher.Models.Behaviors;
public abstract class SmoothScrollBehavior
{
private static CancellationTokenSource animationTokenSource;
private static readonly Easing smoothScrollEasing = new ExponentialEaseOut();
private static readonly Animation animation = new()
{
Duration = TimeSpan.FromMilliseconds(250),
Easing = smoothScrollEasing,
Children =
{
new KeyFrame
{
Cue = new Cue(0),
Setters = { new Setter(ScrollViewer.OffsetProperty, 0) }
},
new KeyFrame
{
Cue = new Cue(1),
Setters = { new Setter(ScrollViewer.OffsetProperty, 0) }
}
}
};
public static readonly AttachedProperty<bool> SmoothScrollProperty =
AvaloniaProperty.RegisterAttached<SmoothScrollBehavior, ScrollViewer, bool>("SmoothScroll");
/// <summary>
/// Gets or sets the target offset which was last used as smooth scrolling target.
/// </summary>
/// <remarks>
/// lastOffsetProperty is needed here since the ScrollViewer.Offset property is already set to the target offset when
/// the PointerWheelChanged event is raised
/// </remarks>
private static readonly AttachedProperty<Vector> lastOffsetProperty =
AvaloniaProperty.RegisterAttached<SmoothScrollBehavior, ScrollViewer, Vector>("LastOffset", new Vector(0, 0));
static SmoothScrollBehavior()
{
SmoothScrollProperty.Changed.Subscribe(new AnonymousObserver<AvaloniaPropertyChangedEventArgs<bool>>(OnEnableSmoothScrollingChanged));
ScrollViewer.OffsetProperty.Changed.Subscribe(new AnonymousObserver<AvaloniaPropertyChangedEventArgs<Vector>>(OnScrollOffsetChanged));
}
private static void OnScrollOffsetChanged(AvaloniaPropertyChangedEventArgs<Vector> args)
{
if (args.Sender is not ScrollViewer scrollViewer)
{
return;
}
// This keeps LastOffset in sync with programmatic changes to offset so there won't be any huge and ugly scroll jumps.
if (animationTokenSource is null or { IsCancellationRequested: true })
{
SetLastOffset(scrollViewer, args.OldValue.Value);
}
}
public static bool GetSmoothScroll(ScrollViewer element) => element.GetValue(SmoothScrollProperty);
public static void SetSmoothScroll(ScrollViewer element, bool value) => element.SetValue(SmoothScrollProperty, value);
private static Vector GetLastOffset(ScrollViewer element) => element.GetValue(lastOffsetProperty);
private static void SetLastOffset(ScrollViewer element, Vector value) => element.SetValue(lastOffsetProperty, value);
private static void OnEnableSmoothScrollingChanged(AvaloniaPropertyChangedEventArgs<bool> args)
{
if (args.Sender is not ScrollViewer scrollViewer)
{
return;
}
if (args.NewValue.GetValueOrDefault())
{
scrollViewer.AddHandler(InputElement.PointerWheelChangedEvent, OnPointerWheelChanged, handledEventsToo: true);
}
else
{
scrollViewer.RemoveHandler(InputElement.PointerWheelChangedEvent, OnPointerWheelChanged);
}
}
private static void OnPointerWheelChanged(object sender, PointerWheelEventArgs e)
{
if (sender is not ScrollViewer scrollViewer)
{
return;
}
// Cancel ongoing animations
if (animationTokenSource is { IsCancellationRequested: false })
{
animationTokenSource.Cancel();
animationTokenSource.Dispose();
}
// Get new offset (already set on each ScrollViewer as attached property)
Vector lastOffset = GetLastOffset(scrollViewer);
Vector newOffset = scrollViewer.Offset;
if (lastOffset != newOffset)
{
animationTokenSource = new CancellationTokenSource();
SetLastOffset(scrollViewer, newOffset);
AnimateScrollToTargetAsync(scrollViewer, lastOffset, newOffset, animationTokenSource.Token).ContinueWithHandleError();
}
}
private static async Task AnimateScrollToTargetAsync(ScrollViewer scrollViewer, Vector previousOffset, Vector targetOffset, CancellationToken cancellationToken = default)
{
try
{
((Setter)animation.Children[0].Setters[0]).Value = previousOffset;
((Setter)animation.Children[1].Setters[0]).Value = targetOffset;
await animation.RunAsync(scrollViewer, cancellationToken);
}
catch (OperationCanceledException)
{
// ignored
}
}
}

View File

@@ -0,0 +1,94 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
using Avalonia.Skia;
using SkiaSharp;
namespace Nitrox.Launcher.Models.Controls;
/// <summary>
/// Draws a blur filter over the already rendered content.
/// </summary>
/// <remarks>
/// Based off of GrayscaleControl
/// </remarks>
public sealed class BlurControl : Decorator
{
public static readonly StyledProperty<float> BlurStrengthProperty =
AvaloniaProperty.Register<BlurControl, float>(nameof(BlurStrength), 5);
/// <summary>
/// Sets or gets how strong the blur should be. Defaults to 5.
/// </summary>
public float BlurStrength
{
get => GetValue(BlurStrengthProperty);
set => SetValue(BlurStrengthProperty, value);
}
static BlurControl()
{
ClipToBoundsProperty.OverrideDefaultValue<BlurControl>(true);
AffectsRender<BlurControl>(OpacityProperty);
AffectsRender<BlurControl>(BlurStrengthProperty);
}
public override void Render(DrawingContext context)
{
context.Custom(new BlurBehindRenderOperation((byte)Math.Round(byte.MaxValue * Opacity), BlurStrength, new Rect(default, Bounds.Size)));
}
private sealed record BlurBehindRenderOperation : ICustomDrawOperation
{
private readonly Rect bounds;
private readonly byte opacity;
private readonly float strength;
public Rect Bounds => bounds;
public BlurBehindRenderOperation(byte opacity, float strength, Rect bounds)
{
this.opacity = opacity;
this.strength = strength;
this.bounds = bounds;
}
public void Dispose()
{
}
public bool HitTest(Point p) => bounds.Contains(p);
public void Render(ImmediateDrawingContext context)
{
ISkiaSharpApiLeaseFeature leaseFeature = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
if (leaseFeature == null)
{
return;
}
using ISkiaSharpApiLease skia = leaseFeature.Lease();
if (!skia.SkCanvas.TotalMatrix.TryInvert(out SKMatrix currentInvertedTransform))
{
return;
}
if (skia.SkSurface == null)
{
return;
}
using SKImage backgroundSnapshot = skia.SkSurface.Snapshot();
using SKShader backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, currentInvertedTransform);
using SKImageFilter blurFilter = SKImageFilter.CreateBlur(strength, strength);
using SKPaint paint = new();
paint.Shader = backdropShader;
paint.ImageFilter = blurFilter;
paint.Color = new SKColor(0, 0, 0, opacity);
skia.SkCanvas.DrawRect(0, 0, (float)bounds.Width, (float)bounds.Height, paint);
}
public bool Equals(ICustomDrawOperation other) => Equals(other as BlurBehindRenderOperation);
}
}

View File

@@ -0,0 +1,121 @@
<ResourceDictionary
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:Nitrox.Launcher.Models.Controls"
xmlns:converters="clr-namespace:Nitrox.Launcher.Models.Converters">
<Design.PreviewWith>
<StackPanel Width="200">
<controls:CustomTitlebar
Background="IndianRed"
CanMaximize="False"
CanMinimize="False" />
<controls:CustomTitlebar
Background="ForestGreen"
CanMaximize="True"
CanMinimize="False" />
<controls:CustomTitlebar Background="CornflowerBlue" CanMaximize="False" />
<controls:CustomTitlebar Background="Violet" />
</StackPanel>
</Design.PreviewWith>
<ControlTheme x:Key="{x:Type controls:CustomTitlebar}" TargetType="controls:CustomTitlebar">
<Setter Property="Template">
<ControlTemplate>
<Panel Background="Transparent">
<Border
Padding="5,2"
HorizontalAlignment="Left"
IsVisible="{TemplateBinding ShowTitle}">
<TextBlock
VerticalAlignment="Stretch"
Text="{Binding $parent[Window].Title}"
TextAlignment="Center" />
</Border>
<StackPanel>
<Button
x:Name="PART_MinimizeButton"
Classes.leftOff1="{x:True}"
Classes.leftOff2="{x:True}"
Command="{Binding MinimizeCommand, RelativeSource={RelativeSource TemplatedParent}}"
IsVisible="{TemplateBinding CanMinimize}">
<Svg Classes="theme" Path="/Assets/Icons/minimize.svg" />
</Button>
<Button
x:Name="PART_MaximizeButton"
Classes.leftOff1="{Binding !#PART_MinimizeButton.IsVisible}"
Classes.leftOff2="{x:True}"
Command="{Binding ToggleMaximizeCommand, RelativeSource={RelativeSource TemplatedParent}}">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<TemplateBinding Property="CanMaximize" />
<Binding Path="$parent[Window].CanResize" />
</MultiBinding>
</Button.IsVisible>
<Svg
Classes="theme"
Classes.maximize="{Binding $parent[Window].WindowState, Converter={converters:EqualityConverter}, ConverterParameter={x:Static WindowState.Normal}}"
Classes.restore="{Binding $parent[Window].WindowState, Converter={converters:EqualityConverter}, ConverterParameter={x:Static WindowState.Maximized}}" />
</Button>
<Button
x:Name="PART_CloseButton"
Classes="close"
Classes.leftOff1="{Binding !#PART_MinimizeButton.IsVisible}"
Classes.leftOff2="{Binding !#PART_MaximizeButton.IsVisible}"
Command="{Binding CloseCommand, RelativeSource={RelativeSource TemplatedParent}}">
<Svg Classes="theme" Path="/Assets/Icons/close.svg" />
</Button>
</StackPanel>
</Panel>
</ControlTemplate>
</Setter>
<!-- Default template values -->
<Setter Property="Background" Value="Black" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="ZIndex" Value="100" />
<Setter Property="Height" Value="28" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="ShowTitle" Value="False" />
<Style Selector="^ /template/ StackPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="HorizontalAlignment" Value="Right" />
<Style Selector="^ > Button">
<Setter Property="Width" Value="46" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Opacity" Value="1" />
<!-- This selector force overrides button style. TODO: fix ButtonStyle.axaml so this isn't necessary and move setters out of this selector. -->
<Style Selector="^:nth-child(1n)">
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Background" Value="{TemplateBinding Background}" />
</Style>
<!-- Button[IsVisible=True]:nth-child(1) doesn't work to filter only visible buttons. Here, leftOff1 is used to check if previous button is on and leftOff2 is the next one over that. -->
<Style Selector="^.leftOff1.leftOff2">
<Setter Property="CornerRadius" Value="0 0 0 5" />
</Style>
<Style Selector="^ > :is(Control)">
<Setter Property="Height" Value="11" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="^.close:pointerover">
<Setter Property="Background" Value="Red" />
</Style>
</Style>
<Style Selector="^ Svg.maximize">
<Setter Property="Path" Value="/Assets/Icons/maximize.svg" />
</Style>
<Style Selector="^ Svg.restore">
<Setter Property="Path" Value="/Assets/Icons/restore.svg" />
</Style>
</Style>
</ControlTheme>
</ResourceDictionary>

View File

@@ -0,0 +1,113 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using CommunityToolkit.Mvvm.Input;
namespace Nitrox.Launcher.Models.Controls;
public partial class CustomTitlebar : TemplatedControl
{
public static readonly DirectProperty<CustomTitlebar, bool> ShowTitleProperty =
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
nameof(showTitle),
o => o.showTitle,
(o, v) => o.showTitle = v, true);
public static readonly DirectProperty<CustomTitlebar, bool> CanMaximizeProperty =
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
nameof(CanMaximize),
o => o.CanMaximize,
(o, v) => o.CanMaximize = v, true);
public static readonly DirectProperty<CustomTitlebar, bool> CanMinimizeProperty =
AvaloniaProperty.RegisterDirect<CustomTitlebar, bool>(
nameof(CanMinimize),
o => o.CanMinimize,
(o, v) => o.CanMinimize = v, true);
private bool showTitle = true;
private bool canMaximize = true;
private bool canMinimize = true;
public bool ShowTitle
{
get => showTitle;
set => SetAndRaise(ShowTitleProperty, ref showTitle, value);
}
public bool CanMaximize
{
get => canMaximize;
set => SetAndRaise(CanMaximizeProperty, ref canMaximize, value);
}
public bool CanMinimize
{
get => canMinimize;
set => SetAndRaise(CanMinimizeProperty, ref canMinimize, value);
}
[RelayCommand]
public void Minimize()
{
if (!CanMinimize)
{
return;
}
if (this.GetWindow() is not { } window)
{
return;
}
window.WindowState = WindowState.Minimized;
}
[RelayCommand]
public void ToggleMaximize()
{
if (!CanMaximize)
{
return;
}
if (this.GetWindow() is not { } window)
{
return;
}
window.WindowState = window.WindowState == WindowState.Normal ? WindowState.Maximized : WindowState.Normal;
}
protected override void OnLoaded(RoutedEventArgs e)
{
PointerPressed += OnPointerPressed;
DoubleTapped += OnDoubleTapped;
base.OnLoaded(e);
}
protected override void OnUnloaded(RoutedEventArgs e)
{
PointerPressed -= OnPointerPressed;
DoubleTapped -= OnDoubleTapped;
base.OnUnloaded(e);
}
private void OnPointerPressed(object sender, PointerPressedEventArgs e)
{
if (e.Source is Visual element && element.GetWindow() is { } window)
{
window.BeginMoveDrag(e);
}
}
private void OnDoubleTapped(object sender, TappedEventArgs e) => ToggleMaximize();
[RelayCommand]
private void Close()
{
if (this.GetWindow() is not { } window)
{
return;
}
window.CloseByUser();
}
}

View File

@@ -0,0 +1,189 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Utilities;
using static System.Math;
namespace Nitrox.Launcher.Models.Controls;
/// <summary>
/// Panel that arranges stretchable child controls to fit min width, up to the limit of <see cref="MinItemWidth" />.
/// Code inspired by Avalonia's WrapPanel
/// (https://github.com/AvaloniaUI/Avalonia/blob/master/src/Avalonia.Controls/WrapPanel.cs).
/// </summary>
/// <remarks>
/// Looks similar to YouTube video layout.
/// </remarks>
public class FittingWrapPanel : Panel, INavigableContainer
{
public static readonly StyledProperty<double> MinItemWidthProperty =
AvaloniaProperty.Register<WrapPanel, double>(nameof(MinItemWidth), 100);
public double MinItemWidth
{
get => GetValue(MinItemWidthProperty);
set => SetValue(MinItemWidthProperty, value);
}
static FittingWrapPanel()
{
AffectsMeasure<WrapPanel>(MinItemWidthProperty);
}
/// <inheritdoc />
protected override Size MeasureOverride(Size constraint)
{
UVSize curLineSize = new();
UVSize panelSize = new();
UVSize uvConstraint = new(constraint.Width, constraint.Height);
int itemsPerRow = (int)Min(constraint.Width / MinItemWidth, Max(Children.Count, 1));
double adjustedWidth = constraint.Width / itemsPerRow;
for (int i = 0, count = Children.Count; i < count; i++)
{
Control child = Children[i];
child.Measure(new Size(adjustedWidth, constraint.Height));
UVSize sz = new(adjustedWidth, child.DesiredSize.Height);
if (MathUtilities.GreaterThan(curLineSize.Width + sz.Width, uvConstraint.Width)) // Need to switch to another line
{
panelSize = new UVSize { Width = Max(curLineSize.Width, panelSize.Width), Height = panelSize.Height + curLineSize.Height };
curLineSize = sz;
if (MathUtilities.GreaterThan(sz.Width, uvConstraint.Width)) // The element is wider then the constraint - give it a separate line
{
panelSize = new UVSize { Width = Max(sz.Width, panelSize.Width), Height = panelSize.Height + sz.Height };
curLineSize = new UVSize();
}
}
else // Continue to accumulate a line
{
curLineSize = new UVSize { Width = curLineSize.Width + sz.Width, Height = Max(sz.Height, curLineSize.Height) };
}
}
panelSize = new UVSize { Width = Max(curLineSize.Width, panelSize.Width), Height = panelSize.Height + curLineSize.Height };
return new Size(panelSize.Width, panelSize.Height);
}
/// <inheritdoc />
protected override Size ArrangeOverride(Size finalSize)
{
int firstInLine = 0;
double accumulatedV = 0;
UVSize curLineSize = new();
UVSize uvFinalSize = new(finalSize.Width, finalSize.Height);
int itemsPerRow = (int)Min(finalSize.Width / MinItemWidth, Max(Children.Count, 1));
double adjustedWidth = finalSize.Width / itemsPerRow;
for (int i = 0; i < Children.Count; i++)
{
Control child = Children[i];
UVSize sz = new(adjustedWidth, child.DesiredSize.Height);
if (MathUtilities.GreaterThan(curLineSize.Width + sz.Width, uvFinalSize.Width)) // Need to switch to another line
{
ArrangeLine(accumulatedV, curLineSize.Height, firstInLine, i, adjustedWidth);
accumulatedV += curLineSize.Height;
curLineSize = sz;
if (MathUtilities.GreaterThan(sz.Width, uvFinalSize.Width)) // The element is wider then the constraint - give it a separate line
{
ArrangeLine(accumulatedV, sz.Height, i, ++i, adjustedWidth);
accumulatedV += sz.Height;
curLineSize = new UVSize();
}
firstInLine = i;
}
else // Continue to accumulate a line
{
curLineSize = new UVSize { Width = curLineSize.Width + sz.Width, Height = Max(sz.Height, curLineSize.Height) };
}
}
if (firstInLine < Children.Count)
{
ArrangeLine(accumulatedV, curLineSize.Height, firstInLine, Children.Count, adjustedWidth);
}
return finalSize;
}
/// <summary>
/// Gets the next control in the specified direction.
/// </summary>
/// <param name="direction">The movement direction.</param>
/// <param name="from">The control from which movement begins.</param>
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
/// <returns>The control.</returns>
IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
{
Avalonia.Controls.Controls children = Children;
int index = from is not null ? Children.IndexOf((Control)from) : -1;
switch (direction)
{
case NavigationDirection.First:
index = 0;
break;
case NavigationDirection.Last:
index = children.Count - 1;
break;
case NavigationDirection.Next:
++index;
break;
case NavigationDirection.Previous:
--index;
break;
case NavigationDirection.Left:
index -= 1;
break;
case NavigationDirection.Right:
index += 1;
break;
case NavigationDirection.Up:
case NavigationDirection.Down:
index = -1;
break;
}
if (index >= 0 && index < children.Count)
{
return children[index];
}
return null;
}
private void ArrangeLine(double v, double lineV, int start, int end, double itemU)
{
Avalonia.Controls.Controls children = Children;
double u = 0;
for (int i = start; i < end; i++)
{
Control child = children[i];
child.Arrange(new Rect(u, v, itemU, lineV));
u += itemU;
}
}
private readonly struct UVSize
{
internal UVSize(double width, double height)
{
Width = width;
Height = height;
}
public double Width { get; init; }
internal double Height { get; init; }
}
}

View File

@@ -0,0 +1,93 @@
extern alias JB;
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
using Avalonia.Skia;
using SkiaSharp;
namespace Nitrox.Launcher.Models.Controls;
/// <summary>
/// Draws a grayscale filter over the already rendered content.
/// </summary>
/// <remarks>
/// Code from:<br/>
/// - Draw-on-top logic: https://gist.github.com/kekekeks/ac06098a74fe87d49a9ff9ea37fa67bc <br/>
/// - Grayscale logic: https://learn.microsoft.com/en-us/xamarin/xamarin-forms/user-interface/graphics/skiasharp/effects/color-filters <br/>
/// </remarks>
public class GrayscaleControl : Decorator
{
static GrayscaleControl()
{
AffectsRender<GrayscaleControl>(OpacityProperty);
}
public override void Render(DrawingContext context)
{
context.Custom(new GrayscaleBehindRenderOperation((byte)Math.Round(byte.MaxValue * Opacity), new Rect(default, Bounds.Size)));
}
private class GrayscaleBehindRenderOperation : ICustomDrawOperation
{
private static readonly float[] grayscaleColorFilterMatrix =
{
0.21f, 0.72f, 0.07f, 0, 0,
0.21f, 0.72f, 0.07f, 0, 0,
0.21f, 0.72f, 0.07f, 0, 0,
0, 0, 0, 1, 0
};
private readonly byte opacity;
private readonly Rect bounds;
public Rect Bounds => bounds;
public GrayscaleBehindRenderOperation(byte opacity, Rect bounds)
{
this.opacity = opacity;
this.bounds = bounds;
}
public void Dispose()
{
}
public bool HitTest(Point p) => bounds.Contains(p);
public void Render(ImmediateDrawingContext context)
{
ISkiaSharpApiLeaseFeature leaseFeature = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
if (leaseFeature == null)
{
return;
}
using ISkiaSharpApiLease skia = leaseFeature.Lease();
if (!skia.SkCanvas.TotalMatrix.TryInvert(out SKMatrix currentInvertedTransform))
{
return;
}
if (skia.SkSurface == null)
{
return;
}
using SKImage backgroundSnapshot = skia.SkSurface.Snapshot();
using SKShader backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, currentInvertedTransform);
using SKImageFilter grayscaleFilter = SKImageFilter.CreateColorFilter(CreateGrayscaleColorFilter());
using SKPaint paint = new()
{
Shader = backdropShader,
ImageFilter = grayscaleFilter,
Color = new SKColor(0, 0, 0, opacity)
};
skia.SkCanvas.DrawRect(0, 0, (float)bounds.Width, (float)bounds.Height, paint);
}
public bool Equals(ICustomDrawOperation other) => other is GrayscaleBehindRenderOperation op && op.bounds == bounds;
private static SKColorFilter CreateGrayscaleColorFilter() => SKColorFilter.CreateColorMatrix(grayscaleColorFilterMatrix);
}
}

View File

@@ -0,0 +1,51 @@
using System;
using Avalonia;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.Input;
namespace Nitrox.Launcher.Models.Controls;
public class RadioButtonGroup : ItemsControl
{
public static readonly DirectProperty<RadioButtonGroup, Type> EnumProperty = AvaloniaProperty.RegisterDirect<RadioButtonGroup, Type>(nameof(Enum), o => o.Enum, (o, v) => o.Enum = v);
public static readonly StyledProperty<object> SelectedItemProperty = AvaloniaProperty.Register<RadioButtonGroup, object>(nameof(SelectedItem));
public static readonly DirectProperty<RadioButtonGroup, RelayCommand<Button>> ItemClickCommandProperty = AvaloniaProperty.RegisterDirect<RadioButtonGroup, RelayCommand<Button>>(nameof(ItemClickCommand), o => o.ItemClickCommand, (o, v) => o.ItemClickCommand = v);
private Type @enum;
private RelayCommand<Button> itemClickCommand;
public Type Enum
{
get => @enum;
set
{
if (value is not { IsEnum: true })
{
return;
}
ItemsSource = System.Enum.GetValues(value);
SetAndRaise(EnumProperty, ref @enum, value);
}
}
public RelayCommand<Button> ItemClickCommand
{
get => itemClickCommand;
private set => SetAndRaise(ItemClickCommandProperty, ref itemClickCommand, value);
}
public object SelectedItem
{
get => GetValue(SelectedItemProperty);
set => SetValue(SelectedItemProperty, value);
}
public RadioButtonGroup()
{
itemClickCommand = new RelayCommand<Button>(param => SelectedItem = param.Tag);
}
protected override Type StyleKeyOverride { get; } = typeof(ItemsControl);
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Media;
namespace Nitrox.Launcher.Models.Controls;
/// <summary>
/// A basic Rich Textbox. Supports bold, italic, underline, colors and hyperlinks.
/// </summary>
/// <remarks>
/// Tag legend:<br />
/// [b][/b] - Bold <br />
/// [i][/i] - Italicize <br />
/// [u][/u] - Underline <br />
/// [#colorHex][/#colorHex] - Change text color <br />
/// [Flavor text](example.com) <br />
/// </remarks>
/// <example>
/// [b]Text[/b] => <b>Text</b> <br />
/// [i]Text[/i] => <i>Text</i> <br />
/// [u]Text[/u] => <u>Text</u> <br />
/// [#0000FF]Text[/#0000FF] => Text (with blue foreground) <br />
/// <a href="https://example.com">Flavor text</a> <br />
/// </example>
public partial class RichTextBlock : TextBlock
{
private static readonly TextDecorationCollection underlineTextDecoration = [new() { Location = TextDecorationLocation.Underline }];
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == TextProperty)
{
Inlines?.Clear();
ParseTextAndAddInlines(Text ?? "", Inlines);
// If all text was just tags, set Text to empty. Otherwise, it will be displayed as fallback by Avalonia.
if (Inlines?.Count < 1)
{
Text = "";
}
}
}
[GeneratedRegex(@"\[\/?([^]]+)\](?:\(([^\)]*)\))?")]
private static partial Regex TagParserRegex { get; }
public static void ParseTextAndAddInlines(ReadOnlySpan<char> text, InlineCollection inlines)
{
if (inlines == null)
{
return;
}
Regex.ValueMatchEnumerator matchEnumerator = TagParserRegex.EnumerateMatches(text);
if (!matchEnumerator.MoveNext())
{
inlines.Add(new Run(text.ToString()));
return;
}
ValueMatch lastRange = default;
Dictionary<string, Action<Run, string>> activeTags = new(4);
do
{
ValueMatch range = matchEnumerator.Current;
// Handle text in-between previous and current tag.
ReadOnlySpan<char> textPart = text[(lastRange.Index + lastRange.Length)..range.Index];
if (!textPart.IsEmpty)
{
inlines.Add(CreateRunWithTags(textPart.ToString(), activeTags));
}
// Handle current tag (this tracks state of active tags at current text position)
ReadOnlySpan<char> match = text.Slice(range.Index, range.Length);
switch (match)
{
case ['[', '/', ..]:
activeTags.Remove(match[2..^1].ToString());
break;
case "[b]":
activeTags["b"] = static (run, _) => run.FontWeight = FontWeight.Bold;
break;
case "[u]":
activeTags["u"] = static (run, _) => run.TextDecorations = underlineTextDecoration;
break;
case "[i]":
activeTags["i"] = static (run, _) => run.FontStyle = FontStyle.Italic;
break;
case ['[', ..] when match.IndexOf("](", StringComparison.OrdinalIgnoreCase) > -1:
TextBlock textBlock = new();
textBlock.Classes.Add("link");
textBlock.Text = match[1..match.IndexOfAny("]")].ToString();
textBlock.Tag = match[(match.IndexOfAny("(")+1)..match.IndexOfAny(")")].ToString();
inlines.Add(textBlock);
break;
case ['[', '#', ..]:
ReadOnlySpan<char> colorCode = match[1..match.IndexOfAny("]")];
if (!Color.TryParse(colorCode, out Color _))
{
goto default;
}
activeTags[colorCode.ToString()] = static (run, tag) => run.Foreground = new SolidColorBrush(Color.Parse(tag));
break;
default:
// Unknown tag, let's handle as normal text (issue is likely due to input text not knowing about this RichTextBox format)
inlines.Add(CreateRunWithTags(match.ToString(), activeTags));
break;
}
lastRange = range;
} while (matchEnumerator.MoveNext());
// Handle any final text (after the last tag).
ReadOnlySpan<char> lastPart = text[(lastRange.Index + lastRange.Length)..];
if (!lastPart.IsEmpty)
{
inlines.Add(CreateRunWithTags(lastPart.ToString(), activeTags));
}
}
private static Run CreateRunWithTags(string text, Dictionary<string, Action<Run, string>> tags)
{
Run run = new(text);
KeyValuePair<string, Action<Run, string>>? lastColorTag = null;
foreach (KeyValuePair<string, Action<Run, string>> pair in tags)
{
switch (pair.Key)
{
case ['#', ..]:
// Optimization: only the last color needs to be applied for the current run, ignore all others.
lastColorTag = pair;
break;
default:
pair.Value(run, pair.Key);
break;
}
}
lastColorTag?.Value(run, lastColorTag.Value.Key);
return run;
}
protected override Type StyleKeyOverride { get; } = typeof(TextBlock);
}

View File

@@ -0,0 +1,27 @@
using System;
using Avalonia;
using Avalonia.Controls;
namespace Nitrox.Launcher.Models.Controls;
/// <inheritdoc cref="RichTextBlock"/>
public class SelectableRichTextBlock : SelectableTextBlock
{
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == TextProperty)
{
Inlines?.Clear();
RichTextBlock.ParseTextAndAddInlines(Text ?? "", Inlines);
// If all text was just tags, set Text to empty. Otherwise, it will be displayed as fallback by Avalonia.
if (Inlines?.Count < 1)
{
Text = "";
}
}
}
protected override Type StyleKeyOverride { get; } = typeof(SelectableTextBlock);
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Globalization;
using Avalonia.Media.Imaging;
using Nitrox.Launcher.Models.Utils;
namespace Nitrox.Launcher.Models.Converters;
public class BitmapAssetValueConverter : Converter<BitmapAssetValueConverter>
{
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value switch
{
null => null,
Bitmap when targetType.IsAssignableFrom(typeof(Bitmap)) => value,
string s when targetType.IsAssignableFrom(typeof(Bitmap)) => AssetHelper.GetAssetFromStream(s, static stream => new Bitmap(stream)),
_ => throw new NotSupportedException()
};
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Nitrox.Launcher.Models.Utils;
namespace Nitrox.Launcher.Models.Converters;
public sealed class BoolToIconConverter : MarkupExtension, IValueConverter
{
/// <summary>
/// String that will be outputted if the input boolean value is <c>true</c>
/// </summary>
public string True { get; set; }
/// <summary>
/// String that will be outputted if the input boolean value is <c>false</c>
/// </summary>
public string False { get; set; }
/// <summary>
/// Decides if the converter will inverse the input boolean value before computing the output
/// </summary>
public bool Invert { get; set; }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not bool @bool)
{
return null;
}
if (Invert)
{
@bool = !@bool;
}
return AssetHelper.GetAssetFromStream(@bool ? True : False, static stream => new Bitmap(stream));
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException();
public override object ProvideValue(IServiceProvider serviceProvider) => this;
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Markup.Xaml;
namespace Nitrox.Launcher.Models.Converters;
/// <summary>
/// A converter base class that provides itself as value to the XAML compiler.
/// </summary>
public abstract class Converter<TSelf> : MarkupExtension, IValueConverter
where TSelf : Converter<TSelf>, new()
{
private static TSelf Instance { get; } = new();
public sealed override object ProvideValue(IServiceProvider serviceProvider) => Instance;
public abstract object Convert(object value, Type targetType, object parameter, CultureInfo culture);
public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException();
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Globalization;
namespace Nitrox.Launcher.Models.Converters;
/// <summary>
/// Formats the bound value as a relative date string from a DateTime value.
/// </summary>
public class DateToRelativeDateConverter : Converter<DateToRelativeDateConverter>
{
private const float DAYS_IN_YEAR = 365.2425f;
private const float MEAN_DAYS_IN_MONTH = DAYS_IN_YEAR / 12f;
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
DateTimeOffset date = value switch
{
DateTime dateTime => dateTime,
DateTimeOffset dateTimeOffset => dateTimeOffset,
DateOnly dateOnly => dateOnly.ToDateTime(TimeOnly.MinValue),
string text when DateTimeOffset.TryParse(text, out DateTimeOffset offset) => offset,
_ => throw new ArgumentException($"Value must be a {nameof(DateTime)} or {nameof(DateTimeOffset)}", nameof(value))
};
TimeSpan delta = DateTimeOffset.UtcNow - date.UtcDateTime;
return delta switch
{
{ TotalSeconds: < 1 } => "just now",
{ TotalSeconds: < 2 } => "a second ago",
{ TotalMinutes: < 1 } => $"{(int)delta.TotalSeconds} seconds ago",
{ TotalMinutes: < 2 } => "a minute ago",
{ TotalMinutes: < 45 } => $"{(int)delta.TotalMinutes} minutes ago",
{ TotalHours: < 1.5 } => "an hour ago",
{ TotalDays: < 1 } => $"{(int)delta.TotalHours} hours ago",
{ TotalDays: < 2 } => "yesterday",
{ TotalDays: < MEAN_DAYS_IN_MONTH } => $"{(int)delta.TotalDays} days ago",
{ TotalDays: < MEAN_DAYS_IN_MONTH * 2 } => "a month ago",
{ TotalDays: < DAYS_IN_YEAR } => $"{(int)(delta.TotalDays / MEAN_DAYS_IN_MONTH)} months ago",
{ TotalDays: < DAYS_IN_YEAR * 2 } => "a year ago",
_ => $"{(int)(delta.TotalDays / DAYS_IN_YEAR)} years ago"
};
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace Nitrox.Launcher.Models.Converters;
/// <summary>
/// Removes duplicates by non-unique ToString values of the given list.
/// </summary>
public class DeduplicateConverter : Converter<DeduplicateConverter>
{
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not IEnumerable<object> list)
{
return value;
}
return list.DistinctBy(i => i.ToString());
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Data.Converters;
namespace Nitrox.Launcher.Models.Converters;
/// <summary>
/// Returns true if values are equal to each other.
/// Or if value is singular, if parameter is equal to the value.
/// </summary>
public class EqualityConverter : Converter<EqualityConverter>, IMultiValueConverter
{
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) => Equals(value, parameter);
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException();
public object Convert(IList<object> values, Type targetType, object parameter, CultureInfo culture)
{
foreach (object val1 in values)
{
foreach (object val2 in values)
{
if (ReferenceEquals(val1, val2))
{
continue;
}
if (!Equals(val1, val2))
{
return false;
}
}
}
return true;
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Globalization;
using System.Text.RegularExpressions;
namespace Nitrox.Launcher.Models.Converters;
/// <summary>
/// Formats the bound value as a string from an integer.
/// </summary>
public partial class IntToStringConverter : Converter<IntToStringConverter>
{
[GeneratedRegex("[^0-9]")]
private static partial Regex DigitReplaceRegex { get; }
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value?.ToString() ?? "";
}
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is null)
{
return 0;
}
if (value is not string str)
{
str = value.ToString();
if (str is null)
{
return 0;
}
}
str = DigitReplaceRegex.Replace(str, "");
if (int.TryParse(str, out int result))
{
return result;
}
return 0;
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Data;
namespace Nitrox.Launcher.Models.Converters;
/// <summary>
/// Returns true if value is of the type as given by parameter (or any if parameter is a collection of types).
/// </summary>
public class IsTypeConverter : Converter<IsTypeConverter>
{
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
switch (parameter)
{
case Type typeParameter:
return typeParameter.IsInstanceOfType(value);
case IEnumerable<Type> typeParameters:
{
foreach (Type type in typeParameters)
{
if (type.IsInstanceOfType(value))
{
return true;
}
}
return false;
}
default:
return new BindingNotification(new ArgumentException($"Expected {nameof(parameter)} to be a {typeof(Type).FullName}"), BindingErrorType.Error);
}
}
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Globalization;
using Avalonia.Media.Imaging;
using Nitrox.Launcher.Models.Utils;
using NitroxModel.Discovery.Models;
namespace Nitrox.Launcher.Models.Converters;
public class PlatformToIconConverter : Converter<PlatformToIconConverter>
{
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return AssetHelper.GetAssetFromStream(GetIconPathForPlatform(value as Platform?), static stream => new Bitmap(stream));
}
private static string GetIconPathForPlatform(Platform? platform) => platform switch
{
Platform.EPIC => "/Assets/Images/store-icons/epic.png",
Platform.STEAM => "/Assets/Images/store-icons/steam.png",
Platform.MICROSOFT => "/Assets/Images/store-icons/xbox.png",
Platform.DISCORD => "/Assets/Images/store-icons/discord.png",
_ => "/Assets/Images/store-icons/missing.png",
};
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Globalization;
using Avalonia;
namespace Nitrox.Launcher.Models.Converters;
/// <summary>
/// Formats the bound value as "0 BoundValue 0 BoundValue" Margin from a Padding, used for TextBox styling.
/// </summary>
/// <remarks>
/// This converter is used to solve a niche issue with the styling of TextBoxes.
/// </remarks>
public class TextBoxPaddingToMarginConverter : Converter<TextBoxPaddingToMarginConverter>
{
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not Thickness padding)
{
return value;
}
bool isNegative = parameter != null && bool.TryParse(parameter.ToString(), out bool result) && result;
double top = isNegative ? -padding.Top : padding.Top;
double bottom = isNegative ? -padding.Bottom : padding.Bottom;
return new Thickness(0, top, 0, bottom);
}
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections;
using System.Globalization;
using System.Linq;
namespace Nitrox.Launcher.Models.Converters;
public class ToIntConverter : Converter<ToIntConverter>
{
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
try
{
return value switch
{
int i => i,
string valueStr when int.TryParse(valueStr, out int result) => result,
ICollection list => list.Count,
IEnumerable enumerable => enumerable.Cast<object>().Count(),
_ => System.Convert.ToInt32(value)
};
}
catch
{
return 0;
}
}
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => value;
}

View File

@@ -0,0 +1,45 @@
using System;
using System.ComponentModel;
using System.Globalization;
using Avalonia.Data;
using NitroxModel.Helper;
namespace Nitrox.Launcher.Models.Converters;
/// <summary>
/// Formats the bound value as a string using a specific formatting style.
/// </summary>
public class ToStringConverter : Converter<ToStringConverter>
{
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is null)
{
return null;
}
if (value.GetType().IsEnum)
{
value = (value as Enum)?.GetAttribute<DescriptionAttribute>()?.Description ?? value.ToString();
}
if (value is not string sourceText)
{
sourceText = value?.ToString();
}
if (!targetType.IsAssignableTo(typeof(string)) || sourceText == null)
{
return new BindingNotification(new InvalidCastException(), BindingErrorType.Error);
}
return parameter switch
{
"upper" => sourceText.ToUpperInvariant(),
"lower" => sourceText.ToLowerInvariant(),
_ => CultureManager.CultureInfo.TextInfo.ToTitleCase(sourceText.ToLower().Replace("_", " ")),
};
}
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => value;
}

View File

@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
namespace Nitrox.Launcher.Models.Converters;
/// <summary>
/// Trims the value when retrieved by code but keeps the spaces in the input field intact for improved UX.
/// </summary>
/// <remarks>
/// This converter is unconventional (inverted converter) in that the value is converted for the backend.
/// The user wants to be able to input spaces while they're typing, but we don't want to save those spaces.
/// </remarks>
public class TrimConverter : Converter<TrimConverter>
{
private readonly Lock inOutCacheLock = new();
/// <summary>
/// Cache to remember the last known untrimmed value (here, the value) for trimmed values (here, the key).
/// </summary>
private readonly Dictionary<string, string> inOutCache = new();
/// <summary>
/// Converts trimmed value back to last known untrimmed value.
/// </summary>
public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not string strValue)
{
return value;
}
lock (inOutCacheLock)
{
if (inOutCache.TryGetValue(strValue.Trim(), out string untrimmedValue))
{
strValue = untrimmedValue;
}
}
return strValue;
}
/// <summary>
/// Converts untrimmed value back to trimmed value.
/// </summary>
public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not string strValue)
{
return value;
}
if (!strValue.StartsWith(' ') && !strValue.EndsWith(' '))
{
// It's safe to reset cache now.
lock (inOutCacheLock)
{
inOutCache.Clear();
}
return strValue;
}
string trim = strValue.Trim();
lock (inOutCacheLock)
{
inOutCache[trim] = strValue;
}
return trim;
}
}

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

View File

@@ -0,0 +1,5 @@
using System;
namespace Nitrox.Launcher.Models.Design;
public record BackupItem(DateTime BackupDate, string BackupFileName);

View 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;
}
}

View File

@@ -0,0 +1,6 @@
namespace Nitrox.Launcher.Models.Design;
public interface IRoutingScreen
{
object ActiveViewModel { get; set; }
}

View 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; }
}

View 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;
}
}

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

View 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)
{
}
}

View File

@@ -0,0 +1,6 @@
using System;
namespace Nitrox.Launcher.Models.Design;
[Serializable]
public record NitroxChangelog(string Version, DateTime Released, string PatchNotes);

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

View 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; }
}

View File

@@ -0,0 +1,10 @@
namespace Nitrox.Launcher.Models.Design;
public enum OutputLineType
{
INFO_LOG,
DEBUG_LOG,
WARNING_LOG,
ERROR_LOG,
COMMAND
}

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

View 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;
}
}
}

View 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}]";
}
}

View File

@@ -0,0 +1,10 @@
using System;
namespace Nitrox.Launcher.Models.Exceptions;
public class DuplicateSingularApplicationException : Exception
{
public DuplicateSingularApplicationException(string applicationName) : base($"An instance of {applicationName} is already running")
{
}
}

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.
}
}
}

Some files were not shown because too many files have changed in this diff Show More