diff --git a/.editorconfig b/.editorconfig index 1adb987c63..1a22834763 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,6 +12,8 @@ resharper_braces_for_ifelse = required_for_multiline resharper_braces_redundant = false resharper_keep_existing_attribute_arrangement = true resharper_wrap_object_and_collection_initializer_style = chop_if_long +resharper_space_before_self_closing = true +resharper_xmldoc_space_before_self_closing = true [*.{proj,csproj,props,targets}] indent_size = 4 @@ -107,9 +109,6 @@ csharp_style_var_elsewhere = false:silent csharp_style_conditional_delegate_call = true:suggestion csharp_using_directive_placement = outside_namespace:silent -[*.g.cs] -generated_code = true - # Newlines csharp_new_line_before_open_brace = all csharp_new_line_before_else = true @@ -217,3 +216,6 @@ dotnet_naming_rule.public_fields.severity = warning dotnet_naming_rule.general_types.symbols = types dotnet_naming_rule.general_types.style = pascal_case_style dotnet_naming_rule.general_types.severity = warning + +[*.g.cs] +generated_code = true diff --git a/.gitignore b/.gitignore index 72de34f1ea..ed4018599c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# MacOS specific +.DS_Store + # User-specific files *.rsuser *.suo @@ -385,4 +388,4 @@ FodyWeavers.xsd # JetBrains Rider .idea/ -*.sln.iml \ No newline at end of file +*.sln.iml diff --git a/Directory.Build.props b/Directory.Build.props index 7b7a1d34b1..c69a8dd9ce 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,29 +1,41 @@ - - + + + + + + - 12.0 + 13 1.8.0.0 - false - false - false - false true false embedded - false + disable disable $(MSBuildProjectDirectory)=$(MSBuildProjectName) true - true + false + + + + + + $(MSBuildThisFileDirectory) + Nitrox + Nitrox + false + false + false + false true - + true - + true @@ -35,42 +47,34 @@ - - - - - JB - - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - + + + JB + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + - + all build; native; contentfiles; analyzers - + all build; native; contentfiles; analyzers - - NitroxModel - all - runtime; build; native; contentfiles; analyzers; buildtransitive + build; native; contentfiles; analyzers; buildtransitive @@ -78,7 +82,7 @@ - + <_Parameter1>Nitrox.Test diff --git a/Directory.Build.targets b/Directory.Build.targets index b642a6a718..5479c2cd88 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,5 +1,6 @@ + @@ -7,8 +8,14 @@ $(GameDir)\ - $(GameDir)Subnautica_Data\Managed\ - $(GameDir)Subnautica_Data\StreamingAssets\SNUnmanagedData\plastic_status.ignore + Subnautica_Data + + + Resources\Data + + + $(GameDir)$(GameDataFolder)\Managed\ + $(GameDir)$(GameDataFolder)\StreamingAssets\SNUnmanagedData\plastic_status.ignore 68598 @@ -17,278 +24,280 @@ - + + + $(GameManagedDir)Assembly-CSharp.dll + $(TestLibrary) + + + $(GameManagedDir)Assembly-CSharp-firstpass.dll + $(TestLibrary) + + + - $(GameManagedDir)\FMODUnity.dll + $(GameManagedDir)FMODUnity.dll $(TestLibrary) - $(GameManagedDir)\Newtonsoft.Json.dll + $(GameManagedDir)Newtonsoft.Json.dll $(TestLibrary) - $(GameManagedDir)\Unity.Addressables.dll + $(GameManagedDir)Unity.Addressables.dll $(TestLibrary) - $(GameManagedDir)\Unity.ResourceManager.dll + $(GameManagedDir)Unity.ResourceManager.dll $(TestLibrary) - $(GameManagedDir)\Unity.TextMeshPro.dll + $(GameManagedDir)Unity.TextMeshPro.dll $(TestLibrary) - $(GameManagedDir)\Unity.Timeline.dll + $(GameManagedDir)Unity.Timeline.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.dll + $(GameManagedDir)UnityEngine.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.AccessibilityModule.dll + $(GameManagedDir)UnityEngine.AccessibilityModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.AIModule.dll + $(GameManagedDir)UnityEngine.AIModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.AndroidJNIModule.dll + $(GameManagedDir)UnityEngine.AndroidJNIModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.AnimationModule.dll + $(GameManagedDir)UnityEngine.AnimationModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.ARModule.dll + $(GameManagedDir)UnityEngine.ARModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.AssetBundleModule.dll + $(GameManagedDir)UnityEngine.AssetBundleModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.AudioModule.dll + $(GameManagedDir)UnityEngine.AudioModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.ClothModule.dll + $(GameManagedDir)UnityEngine.ClothModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.ClusterInputModule.dll + $(GameManagedDir)UnityEngine.ClusterInputModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.ClusterRendererModule.dll + $(GameManagedDir)UnityEngine.ClusterRendererModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.CoreModule.dll + $(GameManagedDir)UnityEngine.CoreModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.CrashReportingModule.dll + $(GameManagedDir)UnityEngine.CrashReportingModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.DirectorModule.dll + $(GameManagedDir)UnityEngine.DirectorModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.DSPGraphModule.dll + $(GameManagedDir)UnityEngine.DSPGraphModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.GameCenterModule.dll + $(GameManagedDir)UnityEngine.GameCenterModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.GridModule.dll + $(GameManagedDir)UnityEngine.GridModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.HotReloadModule.dll + $(GameManagedDir)UnityEngine.HotReloadModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.ImageConversionModule.dll + $(GameManagedDir)UnityEngine.ImageConversionModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.IMGUIModule.dll + $(GameManagedDir)UnityEngine.IMGUIModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.InputLegacyModule.dll + $(GameManagedDir)UnityEngine.InputLegacyModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.InputModule.dll + $(GameManagedDir)UnityEngine.InputModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.JSONSerializeModule.dll + $(GameManagedDir)UnityEngine.JSONSerializeModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.LocalizationModule.dll + $(GameManagedDir)UnityEngine.LocalizationModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.ParticleSystemModule.dll + $(GameManagedDir)UnityEngine.ParticleSystemModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.PerformanceReportingModule.dll + $(GameManagedDir)UnityEngine.PerformanceReportingModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.Physics2DModule.dll + $(GameManagedDir)UnityEngine.Physics2DModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.PhysicsModule.dll + $(GameManagedDir)UnityEngine.PhysicsModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.ProfilerModule.dll + $(GameManagedDir)UnityEngine.ProfilerModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.ScreenCaptureModule.dll + $(GameManagedDir)UnityEngine.ScreenCaptureModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.SharedInternalsModule.dll + $(GameManagedDir)UnityEngine.SharedInternalsModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.SpriteMaskModule.dll + $(GameManagedDir)UnityEngine.SpriteMaskModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.SpriteShapeModule.dll + $(GameManagedDir)UnityEngine.SpriteShapeModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.StreamingModule.dll + $(GameManagedDir)UnityEngine.StreamingModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.SubstanceModule.dll + $(GameManagedDir)UnityEngine.SubstanceModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.TerrainModule.dll + $(GameManagedDir)UnityEngine.TerrainModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.TerrainPhysicsModule.dll + $(GameManagedDir)UnityEngine.TerrainPhysicsModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.TextCoreModule.dll + $(GameManagedDir)UnityEngine.TextCoreModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.TextRenderingModule.dll + $(GameManagedDir)UnityEngine.TextRenderingModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.TilemapModule.dll + $(GameManagedDir)UnityEngine.TilemapModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.TLSModule.dll + $(GameManagedDir)UnityEngine.TLSModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UI.dll + $(GameManagedDir)UnityEngine.UI.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UIElementsModule.dll + $(GameManagedDir)UnityEngine.UIElementsModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UIModule.dll + $(GameManagedDir)UnityEngine.UIModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UmbraModule.dll + $(GameManagedDir)UnityEngine.UmbraModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UNETModule.dll + $(GameManagedDir)UnityEngine.UNETModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UnityAnalyticsModule.dll + $(GameManagedDir)UnityEngine.UnityAnalyticsModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UnityConnectModule.dll + $(GameManagedDir)UnityEngine.UnityConnectModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UnityTestProtocolModule.dll + $(GameManagedDir)UnityEngine.UnityTestProtocolModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UnityWebRequestAssetBundleModule.dll + $(GameManagedDir)UnityEngine.UnityWebRequestAssetBundleModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UnityWebRequestAudioModule.dll + $(GameManagedDir)UnityEngine.UnityWebRequestAudioModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UnityWebRequestModule.dll + $(GameManagedDir)UnityEngine.UnityWebRequestModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UnityWebRequestTextureModule.dll + $(GameManagedDir)UnityEngine.UnityWebRequestTextureModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.UnityWebRequestWWWModule.dll + $(GameManagedDir)UnityEngine.UnityWebRequestWWWModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.VehiclesModule.dll + $(GameManagedDir)UnityEngine.VehiclesModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.VFXModule.dll + $(GameManagedDir)UnityEngine.VFXModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.VideoModule.dll + $(GameManagedDir)UnityEngine.VideoModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.VRModule.dll + $(GameManagedDir)UnityEngine.VRModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.WindModule.dll + $(GameManagedDir)UnityEngine.WindModule.dll $(TestLibrary) - $(GameManagedDir)\UnityEngine.XRModule.dll - $(TestLibrary) - - - $(GameManagedDir)\Assembly-CSharp.dll - $(TestLibrary) - - - $(GameManagedDir)\Assembly-CSharp-firstpass.dll + $(GameManagedDir)UnityEngine.XRModule.dll $(TestLibrary) diff --git a/Nitrox.Launcher/App.axaml b/Nitrox.Launcher/App.axaml new file mode 100644 index 0000000000..9764d4d11d --- /dev/null +++ b/Nitrox.Launcher/App.axaml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/App.axaml.cs b/Nitrox.Launcher/App.axaml.cs new file mode 100644 index 0000000000..663f78deb0 --- /dev/null +++ b/Nitrox.Launcher/App.axaml.cs @@ -0,0 +1,52 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; +using Avalonia.Markup.Xaml; +using Microsoft.Extensions.DependencyInjection; +using NitroxModel.Logger; + +namespace Nitrox.Launcher; + +public class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + // Disable Avalonia plugins replaced by MVVM Toolkit. + for (int i = BindingPlugins.DataValidators.Count - 1; i >= 0; i--) + { + if (BindingPlugins.DataValidators[i] is DataAnnotationsValidationPlugin) + { + BindingPlugins.DataValidators.RemoveAt(i); + } + } + + ServiceProvider services = new ServiceCollection().AddAppServices() + .BuildServiceProvider(); + + MainWindow mainWindow = services.GetRequiredService(); + + switch (ApplicationLifetime) + { + case IClassicDesktopStyleApplicationLifetime desktop: + desktop.MainWindow = mainWindow; + break; + case ISingleViewApplicationLifetime singleViewPlatform: + singleViewPlatform.MainView = mainWindow; + 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)}"); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/Nitrox.Launcher/AppViewLocator.cs b/Nitrox.Launcher/AppViewLocator.cs new file mode 100644 index 0000000000..3cd3031d95 --- /dev/null +++ b/Nitrox.Launcher/AppViewLocator.cs @@ -0,0 +1,69 @@ +using System; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using HanumanInstitute.MvvmDialogs; +using HanumanInstitute.MvvmDialogs.Avalonia; +using Microsoft.Extensions.DependencyInjection; +using Nitrox.Launcher.ViewModels; +using Nitrox.Launcher.Views; +using ReactiveUI; + +namespace Nitrox.Launcher; + +public sealed class AppViewLocator : ViewLocatorBase, ReactiveUI.IViewLocator +{ + private static IServiceProvider serviceProvider; + + public AppViewLocator(IServiceProvider serviceProvider) + { + if (serviceProvider is IServiceScope) + { + 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 override ViewDefinition Locate(object viewModel) + { + static Type GetViewType(object viewModel) => viewModel switch + { + MainWindowViewModel => typeof(MainWindow), + LaunchGameViewModel => typeof(LaunchGameView), + ServersViewModel => typeof(ServersView), + ManageServerViewModel => typeof(ManageServerView), + CreateServerViewModel => typeof(CreateServerModal), + LibraryViewModel => typeof(LibraryView), + CommunityViewModel => typeof(CommunityView), + BlogViewModel => typeof(BlogView), + UpdatesViewModel => typeof(UpdatesView), + OptionsViewModel => typeof(OptionsView), + DialogBoxViewModel => typeof(DialogBoxModal), + ObjectPropertyEditorViewModel => typeof(ObjectPropertyEditorModal), + BackupRestoreViewModel => typeof(BackupRestoreModal), + _ => throw new ArgumentOutOfRangeException(nameof(viewModel), viewModel, null) + }; + + // If the view type is the same as last time, return the same instance. + Type newView = GetViewType(viewModel); + return new ViewDefinition(newView, () => serviceProvider.GetRequiredService(newView)); + } + + public IViewFor ResolveView(T viewModel, string contract = null) => (IViewFor)Locate(viewModel).Create(); +} diff --git a/NitroxLauncher/Assets/Images/material-design-icons/LICENSE.txt b/Nitrox.Launcher/Assets/Images/LICENSE.txt similarity index 94% rename from NitroxLauncher/Assets/Images/material-design-icons/LICENSE.txt rename to Nitrox.Launcher/Assets/Images/LICENSE.txt index 7a4a3ea242..0c902e5e48 100644 --- a/NitroxLauncher/Assets/Images/material-design-icons/LICENSE.txt +++ b/Nitrox.Launcher/Assets/Images/LICENSE.txt @@ -176,18 +176,7 @@ END OF TERMS AND CONDITIONS - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] + 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. diff --git a/NitroxLauncher/Assets/Images/communityHeader.png b/Nitrox.Launcher/Assets/Images/banners/community.png similarity index 100% rename from NitroxLauncher/Assets/Images/communityHeader.png rename to Nitrox.Launcher/Assets/Images/banners/community.png diff --git a/NitroxLauncher/Assets/Images/PlayGameImage.png b/Nitrox.Launcher/Assets/Images/banners/home.png similarity index 100% rename from NitroxLauncher/Assets/Images/PlayGameImage.png rename to Nitrox.Launcher/Assets/Images/banners/home.png diff --git a/Nitrox.Launcher/Assets/Images/blog/vines.png b/Nitrox.Launcher/Assets/Images/blog/vines.png new file mode 100644 index 0000000000..fe30075d0e Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/blog/vines.png differ diff --git a/Nitrox.Launcher/Assets/Images/gallery/image-1.png b/Nitrox.Launcher/Assets/Images/gallery/image-1.png new file mode 100644 index 0000000000..197211c354 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/gallery/image-1.png differ diff --git a/Nitrox.Launcher/Assets/Images/gallery/image-2.png b/Nitrox.Launcher/Assets/Images/gallery/image-2.png new file mode 100644 index 0000000000..64cf163749 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/gallery/image-2.png differ diff --git a/Nitrox.Launcher/Assets/Images/gallery/image-3.png b/Nitrox.Launcher/Assets/Images/gallery/image-3.png new file mode 100644 index 0000000000..3a208360b8 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/gallery/image-3.png differ diff --git a/Nitrox.Launcher/Assets/Images/gallery/image-4.png b/Nitrox.Launcher/Assets/Images/gallery/image-4.png new file mode 100644 index 0000000000..c844a33180 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/gallery/image-4.png differ diff --git a/NitroxLauncher/Assets/Images/material-design-icons/close-w-10.png b/Nitrox.Launcher/Assets/Images/material-design-icons/close.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/close-w-10.png rename to Nitrox.Launcher/Assets/Images/material-design-icons/close.png diff --git a/NitroxLauncher/Assets/Images/material-design-icons/max-w-10.png b/Nitrox.Launcher/Assets/Images/material-design-icons/max.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/max-w-10.png rename to Nitrox.Launcher/Assets/Images/material-design-icons/max.png diff --git a/NitroxLauncher/Assets/Images/material-design-icons/min-w-10.png b/Nitrox.Launcher/Assets/Images/material-design-icons/min.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/min-w-10.png rename to Nitrox.Launcher/Assets/Images/material-design-icons/min.png diff --git a/NitroxLauncher/Assets/Images/material-design-icons/restore-w-10.png b/Nitrox.Launcher/Assets/Images/material-design-icons/restore.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/restore-w-10.png rename to Nitrox.Launcher/Assets/Images/material-design-icons/restore.png diff --git a/Nitrox.Launcher/Assets/Images/nitrox-icon.ico b/Nitrox.Launcher/Assets/Images/nitrox-icon.ico new file mode 100644 index 0000000000..0d90350263 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/nitrox-icon.ico differ diff --git a/Nitrox.Launcher/Assets/Images/notification-icons/error.png b/Nitrox.Launcher/Assets/Images/notification-icons/error.png new file mode 100644 index 0000000000..1df3f2326e Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/notification-icons/error.png differ diff --git a/Nitrox.Launcher/Assets/Images/notification-icons/information.png b/Nitrox.Launcher/Assets/Images/notification-icons/information.png new file mode 100644 index 0000000000..d8e25e3c95 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/notification-icons/information.png differ diff --git a/Nitrox.Launcher/Assets/Images/notification-icons/success.png b/Nitrox.Launcher/Assets/Images/notification-icons/success.png new file mode 100644 index 0000000000..27af6990b9 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/notification-icons/success.png differ diff --git a/Nitrox.Launcher/Assets/Images/notification-icons/warning.png b/Nitrox.Launcher/Assets/Images/notification-icons/warning.png new file mode 100644 index 0000000000..df5bf209e5 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/notification-icons/warning.png differ diff --git a/NitroxLauncher/Assets/Images/store-icons/discord-2x.png b/Nitrox.Launcher/Assets/Images/store-icons/discord.png similarity index 100% rename from NitroxLauncher/Assets/Images/store-icons/discord-2x.png rename to Nitrox.Launcher/Assets/Images/store-icons/discord.png diff --git a/NitroxLauncher/Assets/Images/store-icons/epic-2x.png b/Nitrox.Launcher/Assets/Images/store-icons/epic.png similarity index 100% rename from NitroxLauncher/Assets/Images/store-icons/epic-2x.png rename to Nitrox.Launcher/Assets/Images/store-icons/epic.png diff --git a/NitroxLauncher/Assets/Images/store-icons/missing-2x.png b/Nitrox.Launcher/Assets/Images/store-icons/missing.png similarity index 100% rename from NitroxLauncher/Assets/Images/store-icons/missing-2x.png rename to Nitrox.Launcher/Assets/Images/store-icons/missing.png diff --git a/Nitrox.Launcher/Assets/Images/store-icons/pirated.png b/Nitrox.Launcher/Assets/Images/store-icons/pirated.png new file mode 100644 index 0000000000..17fa6edbca Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/store-icons/pirated.png differ diff --git a/NitroxLauncher/Assets/Images/store-icons/steam-2x.png b/Nitrox.Launcher/Assets/Images/store-icons/steam.png similarity index 100% rename from NitroxLauncher/Assets/Images/store-icons/steam-2x.png rename to Nitrox.Launcher/Assets/Images/store-icons/steam.png diff --git a/NitroxLauncher/Assets/Images/store-icons/xbox-2x.png b/Nitrox.Launcher/Assets/Images/store-icons/xbox.png similarity index 100% rename from NitroxLauncher/Assets/Images/store-icons/xbox-2x.png rename to Nitrox.Launcher/Assets/Images/store-icons/xbox.png diff --git a/NitroxLauncher/Assets/Images/subnauticaIcon.png b/Nitrox.Launcher/Assets/Images/subnautica-bz-icon.png similarity index 100% rename from NitroxLauncher/Assets/Images/subnauticaIcon.png rename to Nitrox.Launcher/Assets/Images/subnautica-bz-icon.png diff --git a/Nitrox.Launcher/Assets/Images/subnautica-icon.png b/Nitrox.Launcher/Assets/Images/subnautica-icon.png new file mode 100644 index 0000000000..0a429e19d9 Binary files /dev/null and b/Nitrox.Launcher/Assets/Images/subnautica-icon.png differ diff --git a/NitroxLauncher/Assets/Images/SubnauticaMain.png b/Nitrox.Launcher/Assets/Images/subnautica-name.png similarity index 100% rename from NitroxLauncher/Assets/Images/SubnauticaMain.png rename to Nitrox.Launcher/Assets/Images/subnautica-name.png diff --git a/NitroxLauncher/Assets/Images/material-design-icons/blog.png b/Nitrox.Launcher/Assets/Images/tabs-icons/blog.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/blog.png rename to Nitrox.Launcher/Assets/Images/tabs-icons/blog.png diff --git a/NitroxLauncher/Assets/Images/material-design-icons/community.png b/Nitrox.Launcher/Assets/Images/tabs-icons/community.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/community.png rename to Nitrox.Launcher/Assets/Images/tabs-icons/community.png diff --git a/NitroxLauncher/Assets/Images/material-design-icons/options.png b/Nitrox.Launcher/Assets/Images/tabs-icons/options.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/options.png rename to Nitrox.Launcher/Assets/Images/tabs-icons/options.png diff --git a/NitroxLauncher/Assets/Images/material-design-icons/play.png b/Nitrox.Launcher/Assets/Images/tabs-icons/play.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/play.png rename to Nitrox.Launcher/Assets/Images/tabs-icons/play.png diff --git a/NitroxLauncher/Assets/Images/material-design-icons/server.png b/Nitrox.Launcher/Assets/Images/tabs-icons/server.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/server.png rename to Nitrox.Launcher/Assets/Images/tabs-icons/server.png diff --git a/NitroxLauncher/Assets/Images/material-design-icons/downloadDot.png b/Nitrox.Launcher/Assets/Images/tabs-icons/update-available.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/downloadDot.png rename to Nitrox.Launcher/Assets/Images/tabs-icons/update-available.png diff --git a/NitroxLauncher/Assets/Images/material-design-icons/download.png b/Nitrox.Launcher/Assets/Images/tabs-icons/update.png similarity index 100% rename from NitroxLauncher/Assets/Images/material-design-icons/download.png rename to Nitrox.Launcher/Assets/Images/tabs-icons/update.png diff --git a/NitroxLauncher/Assets/Images/world-manager/back-2x.png b/Nitrox.Launcher/Assets/Images/world-manager/back.png similarity index 100% rename from NitroxLauncher/Assets/Images/world-manager/back-2x.png rename to Nitrox.Launcher/Assets/Images/world-manager/back.png diff --git a/NitroxLauncher/Assets/Images/world-manager/cog-2x.png b/Nitrox.Launcher/Assets/Images/world-manager/cog.png similarity index 100% rename from NitroxLauncher/Assets/Images/world-manager/cog-2x.png rename to Nitrox.Launcher/Assets/Images/world-manager/cog.png diff --git a/NitroxLauncher/Assets/Images/world-manager/delete-2x.png b/Nitrox.Launcher/Assets/Images/world-manager/delete.png similarity index 100% rename from NitroxLauncher/Assets/Images/world-manager/delete-2x.png rename to Nitrox.Launcher/Assets/Images/world-manager/delete.png diff --git a/NitroxLauncher/Assets/Images/world-manager/export-2x.png b/Nitrox.Launcher/Assets/Images/world-manager/export.png similarity index 100% rename from NitroxLauncher/Assets/Images/world-manager/export-2x.png rename to Nitrox.Launcher/Assets/Images/world-manager/export.png diff --git a/NitroxLauncher/Assets/Images/world-manager/import-2x.png b/Nitrox.Launcher/Assets/Images/world-manager/import.png similarity index 100% rename from NitroxLauncher/Assets/Images/world-manager/import-2x.png rename to Nitrox.Launcher/Assets/Images/world-manager/import.png diff --git a/NitroxLauncher/Assets/Images/world-manager/plus-2x.png b/Nitrox.Launcher/Assets/Images/world-manager/plus.png similarity index 100% rename from NitroxLauncher/Assets/Images/world-manager/plus-2x.png rename to Nitrox.Launcher/Assets/Images/world-manager/plus.png diff --git a/NitroxLauncher/Assets/Images/world-manager/reload-2x.png b/Nitrox.Launcher/Assets/Images/world-manager/reload.png similarity index 100% rename from NitroxLauncher/Assets/Images/world-manager/reload-2x.png rename to Nitrox.Launcher/Assets/Images/world-manager/reload.png diff --git a/NitroxLauncher/Assets/Images/serverIllustration.png b/Nitrox.Launcher/Assets/Images/world-manager/server.png similarity index 100% rename from NitroxLauncher/Assets/Images/serverIllustration.png rename to Nitrox.Launcher/Assets/Images/world-manager/server.png diff --git a/NitroxLauncher/Assets/Images/world-manager/window-external-2x.png b/Nitrox.Launcher/Assets/Images/world-manager/window.png similarity index 100% rename from NitroxLauncher/Assets/Images/world-manager/window-external-2x.png rename to Nitrox.Launcher/Assets/Images/world-manager/window.png diff --git a/Nitrox.Launcher/GlobalUsings.cs b/Nitrox.Launcher/GlobalUsings.cs new file mode 100644 index 0000000000..78b90151b6 --- /dev/null +++ b/Nitrox.Launcher/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using NitroxModel; +global using Nitrox.Launcher.Models.Extensions; diff --git a/Nitrox.Launcher/MainWindow.axaml b/Nitrox.Launcher/MainWindow.axaml new file mode 100644 index 0000000000..8421f9f5c5 --- /dev/null +++ b/Nitrox.Launcher/MainWindow.axaml @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NITROX + + + + PLAY + + + EXPLORE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/MainWindow.axaml.cs b/Nitrox.Launcher/MainWindow.axaml.cs new file mode 100644 index 0000000000..0444ed120b --- /dev/null +++ b/Nitrox.Launcher/MainWindow.axaml.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reactive; +using System.Threading.Tasks; +using Avalonia.Controls; +using HanumanInstitute.MvvmDialogs; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.ViewModels; +using Nitrox.Launcher.Views.Abstract; +using ReactiveUI; +using Serilog; + +namespace Nitrox.Launcher; + +public partial class MainWindow : WindowBase +{ + private readonly IDialogService dialogService; + private readonly HashSet handledExceptions = []; + + // For designer + public MainWindow() + { + InitializeComponent(); + } + + public MainWindow(IDialogService dialogService) + { + this.dialogService = dialogService; + + // Handle thrown exceptions so they aren't hidden. + AppDomain.CurrentDomain.UnhandledException += (_, args) => + { + if (args.ExceptionObject is Exception ex) + { + UnhandledExceptionHandler(ex); + } + }; + TaskScheduler.UnobservedTaskException += (_, args) => + { + if (!args.Observed) + { + UnhandledExceptionHandler(args.Exception); + } + }; + RxApp.DefaultExceptionHandler = Observer.Create(UnhandledExceptionHandler); + + this.WhenActivated(d => + { + // On Linux systems, Avalonia has trouble allowing windows to resize without "decorations". So we enable it in full, but hide the custom titlebar as it'll look bad. + if (OperatingSystem.IsLinux()) + { + // TODO: Fix scrollbar not going all the way to the top when titlebar is hidden. + SystemDecorations = SystemDecorations.Full; + if (CustomTitleBar != null) + { + CustomTitleBar.IsVisible = false; + } + } + + // Set clicked nav item as selected (and deselect the others). + Button lastClickedNav = OpenLaunchGameViewButton; + d(Button.ClickEvent.Raised.Subscribe(args => + { + if (args.Item2 is { Source: Button btn } && btn.Parent?.Classes.Contains("nav") == true && btn.GetValue(NitroxAttached.SelectedProperty) == false) + { + lastClickedNav?.SetValue(NitroxAttached.SelectedProperty, false); + lastClickedNav = btn; + btn.SetValue(NitroxAttached.SelectedProperty, true); + } + })); + d(PointerPressedEvent.Raised.Subscribe(args => + { + if (args.Item2 is { Handled: false, Source: Control { Tag: string url } control } && control.Classes.Contains("link")) + { + Task.Run(() => + { + UriBuilder urlBuilder = new(url) + { + Scheme = Uri.UriSchemeHttps, + Port = -1 + }; + Process.Start(new ProcessStartInfo(urlBuilder.Uri.ToString()) { UseShellExecute = true, Verb = "open" })?.Dispose(); + }); + args.Item2.Handled = true; + } + })); + + try + { + ViewModel?.DefaultViewCommand.Execute(null); + } + catch (Exception ex) + { + Log.Error(ex, $"Failed to execute {nameof(ViewModel.DefaultViewCommand)} command"); + } + }); + + InitializeComponent(); + } + + private async void UnhandledExceptionHandler(Exception ex) + { + if (!handledExceptions.Add(ex)) + { + return; + } + + if (Design.IsDesignMode) + { + Debug.WriteLine(ex); + return; + } + + string title = ex switch + { + { InnerException: { } inner } => inner.Message, + _ => ex.Message + }; + await dialogService.ShowErrorAsync(ex, $"Error: {title}"); + + Environment.Exit(1); + } +} diff --git a/Nitrox.Launcher/Models/Converters/BitmapAssetValueConverter.cs b/Nitrox.Launcher/Models/Converters/BitmapAssetValueConverter.cs new file mode 100644 index 0000000000..f8bf92d918 --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/BitmapAssetValueConverter.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Path = System.IO.Path; + +namespace Nitrox.Launcher.Models.Converters; + +public class BitmapAssetValueConverter : Converter +{ + private static readonly string assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? throw new Exception("Unable to get Assembly name"); + private static readonly Dictionary assetCache = []; + private static readonly Lock assetCacheLock = new(); + + public static Bitmap GetBitmapFromPath(string rawUri) + { + Bitmap bitmap; + lock (assetCacheLock) + { + if (assetCache.TryGetValue(rawUri, out bitmap)) + { + return bitmap; + } + } + Uri uri = rawUri.StartsWith("avares://") ? new Uri(rawUri) : new Uri($"avares://{assemblyName}{rawUri}"); + if (!AssetLoader.Exists(uri) && !Avalonia.Controls.Design.IsDesignMode) + { + return null; + } + // In design mode, resource aren't yet embedded. + if (Avalonia.Controls.Design.IsDesignMode) + { + bitmap = TryLoadFromLocalFileSystem(rawUri); + } + + bitmap ??= new Bitmap(AssetLoader.Open(uri)); + lock (assetCacheLock) + { + assetCache.Add(rawUri, bitmap); + } + return bitmap; + } + + public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value == null) + { + return null; + } + if (value is not string rawUri || !targetType.IsAssignableFrom(typeof(Bitmap))) + { + throw new NotSupportedException(); + } + return GetBitmapFromPath(rawUri); + } + + private static Bitmap TryLoadFromLocalFileSystem(string fileUri) + { + string targetedProject = Path.GetDirectoryName(Environment.GetCommandLineArgs().FirstOrDefault(part => !part.Contains("Designer", StringComparison.Ordinal) && part.EndsWith("dll", StringComparison.OrdinalIgnoreCase) && File.Exists(part))); + while (targetedProject != null && !Directory.EnumerateFileSystemEntries(targetedProject, "*.csproj", SearchOption.TopDirectoryOnly).Any()) + { + targetedProject = Path.GetDirectoryName(targetedProject); + } + if (targetedProject == null) + { + return null; + } + ReadOnlySpan fileUriSpan = fileUri.AsSpan(); + while (fileUriSpan.StartsWith("/") || fileUriSpan.StartsWith("\\")) + { + fileUriSpan = fileUriSpan.Slice(1); + } + return new Bitmap(Path.Combine(targetedProject, fileUriSpan.ToString())); + } +} diff --git a/Nitrox.Launcher/Models/Converters/BoolToIconConverter.cs b/Nitrox.Launcher/Models/Converters/BoolToIconConverter.cs new file mode 100644 index 0000000000..ea0333c2fb --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/BoolToIconConverter.cs @@ -0,0 +1,43 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Markup.Xaml; + +namespace Nitrox.Launcher.Models.Converters; + +public sealed class BoolToIconConverter : MarkupExtension, IValueConverter +{ + /// + /// String that will be outputted if the input boolean value is true + /// + public string True { get; set; } + + /// + /// String that will be outputted if the input boolean value is false + /// + public string False { get; set; } + + /// + /// Decides if the converter will inverse the input boolean value before computing the output + /// + 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 BitmapAssetValueConverter.GetBitmapFromPath(@bool ? True : False); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException(); + + public override object ProvideValue(IServiceProvider serviceProvider) => this; +} diff --git a/Nitrox.Launcher/Models/Converters/Converter.cs b/Nitrox.Launcher/Models/Converters/Converter.cs new file mode 100644 index 0000000000..6a7d62bcca --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/Converter.cs @@ -0,0 +1,21 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Markup.Xaml; + +namespace Nitrox.Launcher.Models.Converters; + +/// +/// A converter base class that provides itself as value to the XAML compiler. +/// +public abstract class Converter : MarkupExtension, IValueConverter + where TSelf : Converter, 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(); +} diff --git a/Nitrox.Launcher/Models/Converters/DateToRelativeDateConverter.cs b/Nitrox.Launcher/Models/Converters/DateToRelativeDateConverter.cs new file mode 100644 index 0000000000..9af87e354c --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/DateToRelativeDateConverter.cs @@ -0,0 +1,44 @@ +using System; +using System.Globalization; + +namespace Nitrox.Launcher.Models.Converters; + +/// +/// Formats the bound value as a relative date string from a DateTime value. +/// +public class DateToRelativeDateConverter : Converter +{ + 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; + + 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" + }; + } +} diff --git a/Nitrox.Launcher/Models/Converters/DeduplicateConverter.cs b/Nitrox.Launcher/Models/Converters/DeduplicateConverter.cs new file mode 100644 index 0000000000..73984a188e --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/DeduplicateConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Nitrox.Launcher.Models.Converters; + +/// +/// Removes duplicates by non-unique ToString values of the given list. +/// +public class DeduplicateConverter : Converter +{ + public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not IEnumerable list) + { + return value; + } + + return list.DistinctBy(i => i.ToString()); + } +} diff --git a/Nitrox.Launcher/Models/Converters/EqualityConverter.cs b/Nitrox.Launcher/Models/Converters/EqualityConverter.cs new file mode 100644 index 0000000000..d11f6a53a4 --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/EqualityConverter.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Nitrox.Launcher.Models.Converters; + +/// +/// Returns true if values are equal to each other. +/// Or if value is singular, if parameter is equal to the value. +/// +public class EqualityConverter : Converter, 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 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; + } +} diff --git a/Nitrox.Launcher/Models/Converters/IntToStringConverter.cs b/Nitrox.Launcher/Models/Converters/IntToStringConverter.cs new file mode 100644 index 0000000000..bb42308fc5 --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/IntToStringConverter.cs @@ -0,0 +1,43 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Nitrox.Launcher.Models.Converters; + +/// +/// Formats the bound value as a string from an integer. +/// +public partial class IntToStringConverter : Converter +{ + [GeneratedRegex("[^0-9]")] + private static partial Regex DigitReplaceRegex(); + + 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; + } +} diff --git a/Nitrox.Launcher/Models/Converters/IsTypeConverter.cs b/Nitrox.Launcher/Models/Converters/IsTypeConverter.cs new file mode 100644 index 0000000000..2ca7128b4e --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/IsTypeConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Globalization; +using Avalonia.Data; + +namespace Nitrox.Launcher.Models.Converters; + +/// +/// Returns true if values are equal to each other. +/// Or if value is singular, if parameter is equal to the value. +/// +public class IsTypeConverter : Converter +{ + public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (parameter is not Type typeParameter) + { + return new BindingNotification(new ArgumentException($"Expected {nameof(parameter)} to be a {typeof(Type).FullName}"), BindingErrorType.Error); + } + + return typeParameter.IsInstanceOfType(value); + } +} diff --git a/Nitrox.Launcher/Models/Converters/NotificationTypeToColorConverter.cs b/Nitrox.Launcher/Models/Converters/NotificationTypeToColorConverter.cs new file mode 100644 index 0000000000..0047254cfd --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/NotificationTypeToColorConverter.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls.Notifications; + +namespace Nitrox.Launcher.Models.Converters; + +public class NotificationTypeToColorConverter : Converter +{ + private static readonly Dictionary typeToResourceCache = new() + { + [NotificationType.Success] = Application.Current?.Resources.GetResource("BrandSuccessBrush"), + [NotificationType.Information] = Application.Current?.Resources.GetResource("BrandInformationBrush"), + [NotificationType.Warning] = Application.Current?.Resources.GetResource("BrandWarningBrush"), + [NotificationType.Error] = Application.Current?.Resources.GetResource("BrandErrorBrush") + }; + + static NotificationTypeToColorConverter() + { + if (typeToResourceCache.Values.Any(t => t == null)) + { + throw new Exception($"One or more notification types do not have an assigned color resource:{Environment.NewLine}{string.Join(", ", typeToResourceCache.Where(p => p.Value == null).Select(p => p.Key))}"); + } + } + + public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (Application.Current is null) + { + return null; + } + + if (value is not NotificationType type) + { + return typeToResourceCache[NotificationType.Error]; + } + + return typeToResourceCache[type]; + } +} diff --git a/Nitrox.Launcher/Models/Converters/NotificationTypeToIconConverter.cs b/Nitrox.Launcher/Models/Converters/NotificationTypeToIconConverter.cs new file mode 100644 index 0000000000..463b7e26b9 --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/NotificationTypeToIconConverter.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls.Notifications; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace Nitrox.Launcher.Models.Converters; + +public class NotificationTypeToIconConverter : Converter +{ + private static readonly Dictionary typeToResourceCache = new() + { + [NotificationType.Success] = new Bitmap(AssetLoader.Open(new Uri("avares://Nitrox.Launcher/Assets/Images/notification-icons/success.png"))), + [NotificationType.Information] = new Bitmap(AssetLoader.Open(new Uri("avares://Nitrox.Launcher/Assets/Images/notification-icons/information.png"))), + [NotificationType.Warning] = new Bitmap(AssetLoader.Open(new Uri("avares://Nitrox.Launcher/Assets/Images/notification-icons/warning.png"))), + [NotificationType.Error] = new Bitmap(AssetLoader.Open(new Uri("avares://Nitrox.Launcher/Assets/Images/notification-icons/error.png"))) + }; + + static NotificationTypeToIconConverter() + { + if (typeToResourceCache.Values.Any(t => t == null)) + { + throw new Exception($"One or more notification types do not have an assigned icon resource:{Environment.NewLine}{string.Join(", ", typeToResourceCache.Where(p => p.Value == null).Select(p => p.Key))}"); + } + } + + public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (Application.Current is null) + { + return null; + } + + if (value is not NotificationType type) + { + return typeToResourceCache[NotificationType.Error]; + } + + return typeToResourceCache[type]; + } +} diff --git a/Nitrox.Launcher/Models/Converters/PlatformToIconConverter.cs b/Nitrox.Launcher/Models/Converters/PlatformToIconConverter.cs new file mode 100644 index 0000000000..464beae5a3 --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/PlatformToIconConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Globalization; +using NitroxModel.Discovery.Models; + +namespace Nitrox.Launcher.Models.Converters; + +public class PlatformToIconConverter : Converter +{ + public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return BitmapAssetValueConverter.GetBitmapFromPath(GetIconPathForPlatform(value as Platform?)); + } + + 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", + }; +} diff --git a/Nitrox.Launcher/Models/Converters/ToStringConverter.cs b/Nitrox.Launcher/Models/Converters/ToStringConverter.cs new file mode 100644 index 0000000000..c26ca2b187 --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/ToStringConverter.cs @@ -0,0 +1,46 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using Avalonia.Data; + +namespace Nitrox.Launcher.Models.Converters; + +/// +/// Formats the bound value as a string using a specific formatting style. +/// +public class ToStringConverter : Converter +{ + private static readonly CultureInfo enUsCulture = new("en-US", false); + + 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()?.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(), + _ => enUsCulture.TextInfo.ToTitleCase(sourceText.ToLower().Replace("_", " ")), + }; + } + + public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => value; +} diff --git a/Nitrox.Launcher/Models/Converters/TrimConverter.cs b/Nitrox.Launcher/Models/Converters/TrimConverter.cs new file mode 100644 index 0000000000..1f12a8fe81 --- /dev/null +++ b/Nitrox.Launcher/Models/Converters/TrimConverter.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; + +namespace Nitrox.Launcher.Models.Converters; + +/// +/// Trims the value when retrieved by code but keeps the spaces in the input field intact for improved UX. +/// +/// +/// 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. +/// +public class TrimConverter : Converter +{ + private readonly Lock inOutCacheLock = new(); + /// + /// Cache to remember the last known untrimmed value (here, the value) for trimmed values (here, the key). + /// + private readonly Dictionary inOutCache = new(); + + /// + /// Converts trimmed value back to last known untrimmed value. + /// + 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; + } + + /// + /// Converts untrimmed value back to trimmed value. + /// + 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; + } +} diff --git a/Nitrox.Launcher/Models/Design/AsyncCommandButtonTagger.cs b/Nitrox.Launcher/Models/Design/AsyncCommandButtonTagger.cs new file mode 100644 index 0000000000..4d61594155 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/AsyncCommandButtonTagger.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Concurrent; +using System.Reactive.Disposables; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using CommunityToolkit.Mvvm.Input; + +namespace Nitrox.Launcher.Models.Design; + +/// +/// Listens for async command changes on buttons to add the chosen classname to, for use with styling. +/// +public class AsyncCommandButtonTagger : IDisposable +{ + public string ClassName { get; init; } + private readonly ConcurrentDictionary states = []; + private readonly IDisposable commandChangeSubscription; + + public AsyncCommandButtonTagger(string className) + { + ClassName = className; + commandChangeSubscription = Button.CommandProperty.Changed.Subscribe(ButtonCommandChangedOnNext); + + void ButtonCommandChangedOnNext(AvaloniaPropertyChangedEventArgs 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(); + } +} diff --git a/Nitrox.Launcher/Models/Design/BackupItem.cs b/Nitrox.Launcher/Models/Design/BackupItem.cs new file mode 100644 index 0000000000..cb352ed951 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/BackupItem.cs @@ -0,0 +1,5 @@ +using System; + +namespace Nitrox.Launcher.Models.Design; + +public record BackupItem(DateTime BackupDate, string BackupFileName); diff --git a/Nitrox.Launcher/Models/Design/DesignData.cs b/Nitrox.Launcher/Models/Design/DesignData.cs new file mode 100644 index 0000000000..18521198f2 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/DesignData.cs @@ -0,0 +1,59 @@ +using System; +using Avalonia.Controls.Notifications; +using Nitrox.Launcher.ViewModels; +using NitroxModel.Discovery.Models; +using NitroxModel.Logger; +using NitroxModel.Serialization; +using NitroxModel.Server; + +namespace Nitrox.Launcher.Models.Design; + +/// +/// Design-time data for use with the XAML previewer plugin. +/// +public static class DesignData +{ + static DesignData() + { + // Skip initialization if not in design mode. + if (!Avalonia.Controls.Design.IsDesignMode) + { + return; + } + + try + { + MainWindowViewModel = new(null, null, null, null, null, null, null, notifications: [new NotificationItem("Something bad happened :(", NotificationType.Error), new NotificationItem("You're in design mode :)")]); + LaunchGameViewModel = new(null, null, null, null, null); + ManageServerViewModel = new(null, null, null) { ServerName = "My fun server" }; + CreateServerViewModel = new(null) { Name = "My Server Name", SelectedGameMode = NitroxGameMode.CREATIVE }; + LibraryViewModel = new(null); + CommunityViewModel = new(null); + BlogViewModel = new(null, [new NitroxBlog("Design blog", DateOnly.FromDateTime(DateTime.UtcNow - TimeSpan.FromDays(5)), "google.com", null)]); + UpdatesViewModel = new(null); + OptionsViewModel = new(null, null) { SelectedGame = new() { PathToGame = @"C:\Games\Steam\steamapps\common\Subnautica", Platform = Platform.STEAM } }; + DialogBoxViewModel = new() { Title = "Title Text", Description = "Description Text" }; + ObjectPropertyEditorViewModel = new(null) { OwnerObject = new SubnauticaServerConfig() }; + BackupRestoreViewModel = new(); + ServersViewModel = new(null, null, null, null); + } + catch (Exception ex) + { + Log.Error(ex); + } + } + + public static MainWindowViewModel MainWindowViewModel { get; } + public static LaunchGameViewModel LaunchGameViewModel { get; } + public static ManageServerViewModel ManageServerViewModel { get; } + public static CreateServerViewModel CreateServerViewModel { get; } + public static LibraryViewModel LibraryViewModel { get; } + public static CommunityViewModel CommunityViewModel { get; } + public static BlogViewModel BlogViewModel { get; } + public static UpdatesViewModel UpdatesViewModel { get; } + public static OptionsViewModel OptionsViewModel { get; } + public static DialogBoxViewModel DialogBoxViewModel { get; } + public static ObjectPropertyEditorViewModel ObjectPropertyEditorViewModel { get; } + public static BackupRestoreViewModel BackupRestoreViewModel { get; } + public static ServersViewModel ServersViewModel { get; } +} diff --git a/Nitrox.Launcher/Models/Design/EditorField.cs b/Nitrox.Launcher/Models/Design/EditorField.cs new file mode 100644 index 0000000000..7846d32a23 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/EditorField.cs @@ -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 PossibleValues { get; set; } + + public string Description + { + get + { + string description = PropertyInfo.GetCustomAttribute()?.Description; + if (string.IsNullOrWhiteSpace(description)) + { + description = null; + } + return description; + } + } + + public EditorField(PropertyInfo propertyInfo, object value, AvaloniaList possibleValues) + { + PropertyInfo = propertyInfo; + Value = value; + PossibleValues = possibleValues; + } +} diff --git a/Nitrox.Launcher/Models/Design/MultiDataTemplate.cs b/Nitrox.Launcher/Models/Design/MultiDataTemplate.cs new file mode 100644 index 0000000000..a973ff3790 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/MultiDataTemplate.cs @@ -0,0 +1,47 @@ +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; + +/// +/// Selects a based on its . +/// +public class MultiDataTemplate : AvaloniaList, IDataTemplate +{ + [Content] + [UsedImplicitly] + public List Content { get; set; } = new(); + + public bool Match(object data) + { + foreach (DataTemplate template in Content) + { + if (template.DataType?.IsInstanceOfType(data) ?? false) + { + return true; + } + } + + return false; + } + + public Control Build(object param) + { + foreach (DataTemplate template in Content) + { + if (template.DataType?.IsInstanceOfType(param) ?? false) + { + return template.Build(param); + } + } + + return new TextBlock() { Text = "" }; + } +} diff --git a/Nitrox.Launcher/Models/Design/NitroxAttached.cs b/Nitrox.Launcher/Models/Design/NitroxAttached.cs new file mode 100644 index 0000000000..897fbefc34 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/NitroxAttached.cs @@ -0,0 +1,180 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Threading; + +namespace Nitrox.Launcher.Models.Design; + +/// +/// Container class for our attached properties. +/// +public class NitroxAttached : AvaloniaObject +{ + public static readonly AttachedProperty SelectedProperty = AvaloniaProperty.RegisterAttached("Selected"); + public static readonly AttachedProperty AutoScrollToHomeProperty = AvaloniaProperty.RegisterAttached("AutoScrollToHome"); + public static readonly AttachedProperty PrimaryScrollWheelDirectionProperty = AvaloniaProperty.RegisterAttached("PrimaryScrollWheelDirection", Orientation.Vertical); + public static readonly AttachedProperty IsNumericInputProperty = AvaloniaProperty.RegisterAttached("IsNumericInput"); + public static readonly AttachedProperty HasUserInteractedProperty = AvaloniaProperty.RegisterAttached("HasUserInteracted"); + private static AsyncCommandButtonTagger asyncCommandButtonTagger; + + static NitroxAttached() + { + InputElement.LostFocusEvent.Raised.Subscribe(HasUserInteractedOnNext); + InputElement.TextInputEvent.Raised.Subscribe(HasUserInteractedOnNext); + asyncCommandButtonTagger = new AsyncCommandButtonTagger("busy"); + + void HasUserInteractedOnNext((object Sender, RoutedEventArgs EventArgs) args) + { + if (args.Sender is InputElement element) + { + SetHasUserInteracted(element, true); + } + } + } + + /// + /// Sets the focus to this control when view is loaded. + /// + public static void SetFocus(AvaloniaObject obj, object value) + { + static void VisualOnAttachedToVisualTree(object sender, VisualTreeAttachmentEventArgs e) => (sender as IInputElement)?.Focus(); + + switch (obj) + { + case Visual visual when visual is IInputElement: + Dispatcher.UIThread.Post(() => (visual as IInputElement)?.Focus()); + visual.AttachedToVisualTree += VisualOnAttachedToVisualTree; + break; + default: + throw new NotSupportedException($@"Element {obj} must be an {nameof(IInputElement)} to support ""{nameof(SetFocus)}"""); + } + } + + 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); + + /// + /// Changes scroll wheel input to move scroll viewer left and right if set to . + /// + 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); +} diff --git a/Nitrox.Launcher/Models/Design/NitroxBlog.cs b/Nitrox.Launcher/Models/Design/NitroxBlog.cs new file mode 100644 index 0000000000..1be8f79820 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/NitroxBlog.cs @@ -0,0 +1,6 @@ +using System; +using Avalonia.Media.Imaging; + +namespace Nitrox.Launcher.Models.Design; + +public sealed record NitroxBlog(string Title, DateOnly Date, string Url, Bitmap Image); diff --git a/Nitrox.Launcher/Models/Design/NitroxChangelog.cs b/Nitrox.Launcher/Models/Design/NitroxChangelog.cs new file mode 100644 index 0000000000..29d45331de --- /dev/null +++ b/Nitrox.Launcher/Models/Design/NitroxChangelog.cs @@ -0,0 +1,30 @@ +using System; + +namespace Nitrox.Launcher.Models.Design; + +[Serializable] +public class NitroxChangelog +{ + public string Version { get; } + + public DateTime Released { get; } + + public string PatchNotes { get; } + + protected NitroxChangelog() + { + // Constructor for serialization. Has to be "protected" for json serialization. + } + + public NitroxChangelog(string version, DateTime released, string patchnotes) + { + Version = version; + Released = released; + PatchNotes = patchnotes; + } + + public override string ToString() + { + return $"[{nameof(NitroxChangelog)} - Version: {Version}, Released: {Released}, PatchNotes: {PatchNotes}]"; + } +} diff --git a/Nitrox.Launcher/Models/Design/NotificationItem.cs b/Nitrox.Launcher/Models/Design/NotificationItem.cs new file mode 100644 index 0000000000..0523e68ab6 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/NotificationItem.cs @@ -0,0 +1,24 @@ +using System.Windows.Input; +using Avalonia.Controls.Notifications; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using ReactiveUI; + +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(string message, NotificationType type = NotificationType.Information, ICommand closeCommand = null) + { + Message = message; + Type = type; + CloseCommand = closeCommand ?? ReactiveCommand.Create(() => WeakReferenceMessenger.Default.Send(new NotificationCloseMessage(this))); + } +} diff --git a/Nitrox.Launcher/Models/Design/RoutingScreen.cs b/Nitrox.Launcher/Models/Design/RoutingScreen.cs new file mode 100644 index 0000000000..a2b6af1e63 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/RoutingScreen.cs @@ -0,0 +1,8 @@ +using ReactiveUI; + +namespace Nitrox.Launcher.Models.Design; + +public class RoutingScreen : IScreen +{ + public RoutingState Router { get; } = new(); +} diff --git a/Nitrox.Launcher/Models/Design/ServerEntry.cs b/Nitrox.Launcher/Models/Design/ServerEntry.cs new file mode 100644 index 0000000000..0ee9657c3c --- /dev/null +++ b/Nitrox.Launcher/Models/Design/ServerEntry.cs @@ -0,0 +1,269 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +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 Nitrox.Launcher.Models.Utils; +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; + +/// +/// Manager object for a server. Used to start/stop a server and change its settings. +/// +public partial class ServerEntry : ObservableObject +{ + 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 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; + + private ServerProcess serverProcess; + + [ObservableProperty] + private Version version = NitroxEnvironment.Version; + + public ServerEntry() + { + PropertyChanged += OnPropertyChanged; + } + + public static ServerEntry FromDirectory(string saveDir) + { + ServerEntry result = new(); + result.RefreshFromDirectory(saveDir); + return result; + } + + public void RefreshFromDirectory(string saveDir) + { + if (!File.Exists(Path.Combine(saveDir, "server.cfg")) || !File.Exists(Path.Combine(saveDir, "Version.json"))) + { + return; + } + + Bitmap serverIcon = null; + string serverIconPath = Path.Combine(saveDir, "servericon.png"); + if (File.Exists(serverIconPath)) + { + serverIcon = new Bitmap(Path.Combine(saveDir, "servericon.png")); + } + + SubnauticaServerConfig config = SubnauticaServerConfig.Load(saveDir); + string fileEnding = config.SerializerMode switch + { + ServerSerializerMode.JSON => "json", + ServerSerializerMode.PROTOBUF => "nitrox", + _ => throw new NotImplementedException() + }; + + Version version; + using (FileStream stream = new(Path.Combine(saveDir, $"Version.{fileEnding}"), FileMode.Open, FileAccess.Read, FileShare.Read)) + { + version = new ServerJsonSerializer().Deserialize(stream)?.Version ?? NitroxEnvironment.Version; + } + + 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, "WorldData.json")); + Version = version; + LastAccessedTime = File.GetLastWriteTime(File.Exists(Path.Combine(saveDir, $"WorldData.{fileEnding}")) + ? + // This file is affected by server saving + Path.Combine(saveDir, $"WorldData.{fileEnding}") + : + // If the above file doesn't exist (server was never ran), use the Version file instead + Path.Combine(saveDir, $"Version.{fileEnding}")); + } + + public void Start(string savesDir) + { + if (!Directory.Exists(savesDir)) + { + throw new DirectoryNotFoundException($"Directory '{savesDir}' not found"); + } + if (serverProcess?.IsRunning ?? false) + { + throw new DuplicateSingularApplicationException("Nitrox Server"); + } + // Start server and add notify when server closed. + serverProcess = ServerProcess.Start(Path.Combine(savesDir, Name), () => Dispatcher.UIThread.InvokeAsync(StopAsync)); + + IsNewServer = false; + IsOnline = true; + } + + [RelayCommand] + public async Task StopAsync() + { + if (serverProcess == null || await serverProcess.CloseAsync()) + { + IsOnline = false; + return true; + } + + return false; + } + + [RelayCommand] + public void OpenSaveFolder() + { + Process.Start(new ProcessStartInfo + { + FileName = Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), Name), + Verb = "open", + UseShellExecute = true + })?.Dispose(); + } + + private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) => WeakReferenceMessenger.Default.Send(new ServerEntryPropertyChangedMessage(e.PropertyName)); + + private class ServerProcess : IDisposable + { + private NamedPipeClientStream commandStream; + private Process serverProcess; + public bool IsRunning => !serverProcess?.HasExited ?? false; + + private ServerProcess(string saveDir, Action onExited) + { + string serverExeName = "NitroxServer-Subnautica.exe"; + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + serverExeName = "NitroxServer-Subnautica"; + } + string serverPath = Path.Combine(NitroxUser.CurrentExecutablePath, serverExeName); + ProcessStartInfo startInfo = new(serverPath) + { + WorkingDirectory = NitroxUser.CurrentExecutablePath, + Verb = "open", + Arguments = $@"""{Path.GetFileName(saveDir)}""" + }; + + serverProcess = Process.Start(startInfo); + if (serverProcess != null) + { + serverProcess.EnableRaisingEvents = true; // Required for 'Exited' event from process. + serverProcess.Exited += (_, _) => + { + onExited?.Invoke(); + }; + } + + if (File.Exists(Path.Combine(saveDir, "WorldData.json"))) + { + File.SetLastWriteTime(Path.Combine(saveDir, "WorldData.json"), DateTime.Now); + } + } + + public static ServerProcess Start(string saveDir, Action onExited) => new(saveDir, onExited); + + public async Task CloseAsync() + { + try + { + // TODO: Fix the server to always handle stop command, even when it's still starting up. + await SendCommandAsync("stop"); + } + catch (TimeoutException) + { + // server could be dead, ignore + } + + Dispose(); + return true; + } + + public async Task SendCommandAsync(string command) + { + if (!IsRunning) + { + return; + } + + 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); + } + + public void Dispose() + { + serverProcess?.Dispose(); + serverProcess = null; + } + } +} diff --git a/Nitrox.Launcher/Models/Design/ServerStartEventArgs.cs b/Nitrox.Launcher/Models/Design/ServerStartEventArgs.cs new file mode 100644 index 0000000000..7dd1053e70 --- /dev/null +++ b/Nitrox.Launcher/Models/Design/ServerStartEventArgs.cs @@ -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}]"; + } +} diff --git a/Nitrox.Launcher/Models/Exceptions/DuplicateSingularApplicationException.cs b/Nitrox.Launcher/Models/Exceptions/DuplicateSingularApplicationException.cs new file mode 100644 index 0000000000..78002ba57a --- /dev/null +++ b/Nitrox.Launcher/Models/Exceptions/DuplicateSingularApplicationException.cs @@ -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") + { + } +} diff --git a/Nitrox.Launcher/Models/Extensions/DialogServiceExtensions.cs b/Nitrox.Launcher/Models/Extensions/DialogServiceExtensions.cs new file mode 100644 index 0000000000..b9fd694903 --- /dev/null +++ b/Nitrox.Launcher/Models/Extensions/DialogServiceExtensions.cs @@ -0,0 +1,54 @@ +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using Avalonia.Media; +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 ShowAsync(this IDialogService dialogService, Action 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(); + setup?.Invoke(viewModel, extraParameter); + bool? result = await dialogService.ShowDialogAsync(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 ShowAsync(this IDialogService dialogService, Action setup = null) where T : ModalViewModelBase => dialogService.ShowAsync>((model, act) => act?.Invoke(model), setup); + + public static Task ShowErrorAsync(this IDialogService dialogService, Exception exception, string title = null, string description = null) => + dialogService.ShowAsync(model => + { + model.Title = title ?? "Error"; + model.Description = string.IsNullOrWhiteSpace(description) ? exception.ToString() : $"{description}{Environment.NewLine}{exception}"; + model.ButtonOptions = ButtonOptions.OkClipboard; + }); +} diff --git a/Nitrox.Launcher/Models/Extensions/KeyValueStoreExtensions.cs b/Nitrox.Launcher/Models/Extensions/KeyValueStoreExtensions.cs new file mode 100644 index 0000000000..59f8914de9 --- /dev/null +++ b/Nitrox.Launcher/Models/Extensions/KeyValueStoreExtensions.cs @@ -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); + } +} diff --git a/Nitrox.Launcher/Models/Extensions/ProcessExExtensions.cs b/Nitrox.Launcher/Models/Extensions/ProcessExExtensions.cs new file mode 100644 index 0000000000..28fd7d70a3 --- /dev/null +++ b/Nitrox.Launcher/Models/Extensions/ProcessExExtensions.cs @@ -0,0 +1,27 @@ +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 (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')"; + using Process proc = Process.Start(new ProcessStartInfo + { + FileName = "sh", + Arguments = $"-c \"{command}\"", + }); + } + } +} diff --git a/Nitrox.Launcher/Models/Extensions/ResourceDictionaryExtensions.cs b/Nitrox.Launcher/Models/Extensions/ResourceDictionaryExtensions.cs new file mode 100644 index 0000000000..f381c2df9b --- /dev/null +++ b/Nitrox.Launcher/Models/Extensions/ResourceDictionaryExtensions.cs @@ -0,0 +1,15 @@ +using Avalonia.Controls; + +namespace Nitrox.Launcher.Models.Extensions; + +public static class ResourceDictionaryExtensions +{ + public static object GetResource(this IResourceDictionary resourceDictionary, string key) + { + if (!resourceDictionary.TryGetResource(key, null, out object value)) + { + return null; + } + return value; + } +} diff --git a/Nitrox.Launcher/Models/Extensions/ScreenExtensions.cs b/Nitrox.Launcher/Models/Extensions/ScreenExtensions.cs new file mode 100644 index 0000000000..c8bdff1365 --- /dev/null +++ b/Nitrox.Launcher/Models/Extensions/ScreenExtensions.cs @@ -0,0 +1,17 @@ +using Nitrox.Launcher.ViewModels.Abstract; +using ReactiveUI; + +namespace Nitrox.Launcher.Models.Extensions; + +public static class ScreenExtensions +{ + public static void Show(this IScreen screen, TViewModel routableViewModel) where TViewModel : RoutableViewModelBase + { + screen.Router.Navigate.Execute(routableViewModel); + } + + public static void Back(this IScreen screen) + { + screen.Router.NavigateBack.Execute(); + } +} diff --git a/Nitrox.Launcher/Models/Extensions/ServiceCollectionExtensions.cs b/Nitrox.Launcher/Models/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..3040b5fa99 --- /dev/null +++ b/Nitrox.Launcher/Models/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,60 @@ +using HanumanInstitute.MvvmDialogs; +using HanumanInstitute.MvvmDialogs.Avalonia; +using Microsoft.Extensions.DependencyInjection; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.ViewModels; +using Nitrox.Launcher.Views; +using NitroxModel.Helper; +using ReactiveUI; + +namespace Nitrox.Launcher.Models.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddAppServices(this IServiceCollection collection) + { + // Domain services + collection.AddSingleton(provider => new AppViewLocator(provider)); + collection.AddSingleton(_ => KeyValueStore.Instance); + + // Avalonia and Reactive services + collection.AddSingleton(); + collection.AddSingleton(provider => new DialogService( + new DialogManager( + provider.GetRequiredService(), + new DialogFactory()), + provider.GetRequiredService)); + + // Dialog ViewModels and Dialog Views + collection.AddTransient(); + collection.AddTransient(); + collection.AddTransient(); + collection.AddTransient(); + collection.AddTransient(); + collection.AddTransient(); + collection.AddTransient(); + collection.AddTransient(); + + // Views + collection.AddSingleton(provider => new MainWindow(provider.GetRequiredService()) { DataContext = provider.GetRequiredService() }); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + + // ViewModels + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + + return collection; + } +} diff --git a/Nitrox.Launcher/Models/Extensions/StorageProviderExtensions.cs b/Nitrox.Launcher/Models/Extensions/StorageProviderExtensions.cs new file mode 100644 index 0000000000..c097a3d06d --- /dev/null +++ b/Nitrox.Launcher/Models/Extensions/StorageProviderExtensions.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Platform.Storage; + +namespace Nitrox.Launcher.Models.Extensions; + +public static class StorageProviderExtensions +{ + public static async Task OpenFolderPickerAsync(this IStorageProvider storageProvider, string title, string startingFolder = null) + { + IStorageFolder startingStorageFolder = null; + if (startingFolder != null) + { + startingStorageFolder = await storageProvider.TryGetFolderFromPathAsync(startingFolder); + } + IReadOnlyList dialogResult = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = title, + AllowMultiple = false, + SuggestedStartLocation = startingStorageFolder + }); + return dialogResult.FirstOrDefault()?.TryGetLocalPath() ?? ""; + } +} diff --git a/Nitrox.Launcher/Models/Extensions/StringExtensions.cs b/Nitrox.Launcher/Models/Extensions/StringExtensions.cs new file mode 100644 index 0000000000..302bf96d1e --- /dev/null +++ b/Nitrox.Launcher/Models/Extensions/StringExtensions.cs @@ -0,0 +1,15 @@ +using System.IO; + +namespace Nitrox.Launcher.Models.Extensions; + +public static class StringExtensions +{ + public static string ReplaceInvalidFileNameCharacters(this string value) + { + foreach (char invalidFileNameChar in Path.GetInvalidFileNameChars()) + { + value = value.Replace(invalidFileNameChar, ' '); + } + return value.Trim(); + } +} diff --git a/Nitrox.Launcher/Models/Extensions/VisualExtensions.cs b/Nitrox.Launcher/Models/Extensions/VisualExtensions.cs new file mode 100644 index 0000000000..ef959d4cfa --- /dev/null +++ b/Nitrox.Launcher/Models/Extensions/VisualExtensions.cs @@ -0,0 +1,34 @@ +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Controls; +using NitroxModel.Platforms.OS.Windows; + +namespace Nitrox.Launcher.Models.Extensions; + +public static class VisualExtensions +{ + public static void ApplyOsWindowStyling(this Visual visual) + { + if (Avalonia.Controls.Design.IsDesignMode) + { + return; + } + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + if (visual.GetWindow() is not { } window) + { + return; + } + nint? windowHandle = window.TryGetPlatformHandle()?.Handle; + if (!windowHandle.HasValue) + { + return; + } + + WindowsApi.EnableDefaultWindowAnimations(windowHandle.Value, window.CanResize); + } + + public static Window GetWindow(this Visual visual) => TopLevel.GetTopLevel(visual) as Window; +} diff --git a/Nitrox.Launcher/Models/Messages.cs b/Nitrox.Launcher/Models/Messages.cs new file mode 100644 index 0000000000..6bf85fb8de --- /dev/null +++ b/Nitrox.Launcher/Models/Messages.cs @@ -0,0 +1,14 @@ +using Nitrox.Launcher.Models.Design; + +namespace Nitrox.Launcher.Models; + +/// +/// Sent when a save is deleted outside the Servers view (i.e. server manage view or via file explorer). +/// +public record SaveDeletedMessage(string SaveName); + +public record ServerEntryPropertyChangedMessage(string PropertyName); + +public record NotificationAddMessage(NotificationItem Item); + +public record NotificationCloseMessage(NotificationItem Item); diff --git a/Nitrox.Launcher/Models/Utils/CacheFile.cs b/Nitrox.Launcher/Models/Utils/CacheFile.cs new file mode 100644 index 0000000000..71b29488db --- /dev/null +++ b/Nitrox.Launcher/Models/Utils/CacheFile.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Nitrox.Launcher.Models.Utils; + +public class CacheFile +{ + private DateTimeOffset? creationTime; + public string FileName { get; init; } + public string TempFilePath => Path.Combine(Path.GetTempPath(), FileName); + + public DateTimeOffset? CreationTime + { + get + { + if (creationTime == null && File.Exists(TempFilePath)) + { + using FileStream stream = File.OpenRead(TempFilePath); + if (stream.Length < 8) + { + return null; + } + Span buffer = stackalloc byte[8]; + stream.ReadExactly(buffer); + creationTime = DateTimeOffset.FromUnixTimeSeconds(BitConverter.ToInt64(buffer)); + } + return creationTime; + } + } + + public CacheFile(string fileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + FileName = $"nitrox_{fileName.Trim()}.cache"; + } + + /// + /// Gets the cached data if not old or refreshes the cache using the . + /// + public static async Task GetOrRefreshAsync(string name, Func reader, Action writer, Func> refreshedValueFactory = null, TimeSpan age = default) + { + if (age == default) + { + age = TimeSpan.FromDays(1); + } + + CacheFile file = new(name); + if (writer != null && (file.CreationTime == null || DateTimeOffset.UtcNow - file.CreationTime >= age)) + { + await using BinaryWriter binaryWriter = new(File.Create(file.TempFilePath)); + binaryWriter.Write(BitConverter.GetBytes(DateTimeOffset.UtcNow.ToUnixTimeSeconds())); + T newValue = refreshedValueFactory == null ? default : await refreshedValueFactory(); + writer(binaryWriter, newValue); + return newValue; + } + + using ValueReader valueReader = new(await file.GetStream()); + T readerResult = reader(valueReader); + if (valueReader.ReachedEarlyEnd) + { + return refreshedValueFactory == null ? default : await refreshedValueFactory(); + } + return readerResult; + } + + private Task GetStream() + { + BinaryReader reader = new(File.OpenRead(TempFilePath)); + reader.ReadInt64(); + return Task.FromResult(reader); + } + + public class ValueReader : IDisposable + { + private readonly BinaryReader binaryReader; + public bool ReachedEarlyEnd { get; private set; } + + public ValueReader(BinaryReader binaryReader) + { + this.binaryReader = binaryReader; + } + + public T Read(T defaultValue = default) + { + static T InnerRead(ValueReader reader, Func read, T defaultValue = default) + { + try + { + return (T)(object)read(reader.binaryReader); + } + catch (EndOfStreamException) + { + reader.ReachedEarlyEnd = true; + return defaultValue; + } + catch + { + return defaultValue; + } + } + + // Return default values for future reads when end of file (EOF). + if (ReachedEarlyEnd) + { + return defaultValue; + } + + Type requestedType = typeof(T); + if (requestedType == typeof(int)) + { + return InnerRead(this, reader => reader.ReadInt32(), defaultValue); + } + if (requestedType == typeof(string)) + { + return InnerRead(this, reader => reader.ReadString(), defaultValue); + } + if (requestedType == typeof(byte[])) + { + return InnerRead(this, reader => + { + int dataSize = reader.ReadInt32(); + return reader.ReadBytes(dataSize); + }, defaultValue); + } + throw new NotSupportedException($"Type: '{requestedType}' is not yet supported to be read from cache files"); + } + + public void Dispose() => binaryReader?.Dispose(); + } +} diff --git a/Nitrox.Launcher/Models/Utils/Downloader.cs b/Nitrox.Launcher/Models/Utils/Downloader.cs new file mode 100644 index 0000000000..66ecd71f34 --- /dev/null +++ b/Nitrox.Launcher/Models/Utils/Downloader.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using LitJson; +using Nitrox.Launcher.Models.Design; +using NitroxModel.Logger; + +namespace Nitrox.Launcher.Models.Utils; + +public partial class Downloader +{ + public const string BLOGS_URL = "https://nitroxblog.rux.gg/wp-json/wp/v2/posts?per_page=8&page=1"; + public const string LATEST_VERSION_URL = "https://nitrox.rux.gg/api/version/latest"; + public const string CHANGELOGS_URL = "https://nitrox.rux.gg/api/changelog/releases"; + public const string RELEASES_URL = "https://nitrox.rux.gg/api/version/releases"; + + [GeneratedRegex(@"""version"":""([^""]*)""")] + private static partial Regex JsonVersionFieldRegex(); + + public static async Task> GetBlogs() + { + IList blogs = new List(); + + try + { + string jsonString = await CacheFile.GetOrRefreshAsync("blogs", + r => r.Read(""), + (w, v) => w.Write(v), + async () => + { + using HttpResponseMessage response = await GetResponseFromCache(BLOGS_URL); + return await response.Content.ReadAsStringAsync(); + }); + + JsonData data = JsonMapper.ToObject(jsonString); + + // TODO : Add a json schema validator + for (int i = 0; i < data.Count; i++) + { + string released = (string)data[i]["date"]; + string url = (string)data[i]["link"]; + string title = WebUtility.HtmlDecode((string)data[i]["title"]["rendered"]); + string imageUrl = (string)data[i]["jetpack_featured_media_url"]; + string imageCacheName = $"blogimage_{title.ReplaceInvalidFileNameCharacters().ToLowerInvariant()}"; + if (!DateTimeOffset.TryParse(released, out DateTimeOffset dateTime)) + { + dateTime = DateTimeOffset.UtcNow; + Log.Error($"Error while trying to parse release time ({released}) of blog {url}"); + } + else + { + imageCacheName = $"blogimage_{dateTime.ToUnixTimeSeconds()}"; + } + // Get image bitmap from image URL + byte[] imageData = await CacheFile.GetOrRefreshAsync(imageCacheName, + r => r.Read(), + (w, v) => + { + w.Write(v.Length); + w.Write(v); + }, + async () => + { + HttpResponseMessage imageResponse = await GetResponseFromCache(imageUrl); + return await imageResponse.Content.ReadAsByteArrayAsync(); + }); + using MemoryStream imageMemoryStream = new(imageData); + Bitmap image = new(imageMemoryStream); + + blogs.Add(new NitroxBlog(title, DateOnly.FromDateTime(dateTime.DateTime), url, image)); + } + } + catch (Exception ex) + { + Log.Error(ex, $"{nameof(Downloader)} : Error while fetching Nitrox blogs from {BLOGS_URL}"); + LauncherNotifier.Error("Unable to fetch Nitrox blogs"); + } + + return blogs; + } + + public static async Task> GetChangeLogs() + { + IList changelogs = new List(); + + try + { + //https://developer.wordpress.org/rest-api/reference/posts/#arguments + string jsonString = await CacheFile.GetOrRefreshAsync("changelogs", + r => r.Read(""), + (w, v) => w.Write(v), + async () => + { + using HttpResponseMessage response = await GetResponseFromCache(CHANGELOGS_URL); + return await response.Content.ReadAsStringAsync(); + }); + StringBuilder builder = new(); + JsonData data = JsonMapper.ToObject(jsonString); + + // TODO : Add a json schema validator + for (int i = 0; i < data.Count; i++) + { + string version = (string)data[i]["version"]; + string released = (string)data[i]["released"]; + JsonData patchnotes = data[i]["patchnotes"]; + + if (!DateTime.TryParse(released, out DateTime dateTime)) + { + dateTime = DateTime.UtcNow; + Log.Error($"Error while trying to parse release time ({released}) of Nitrox v{version}"); + } + + builder.Clear(); + for (int j = 0; j < patchnotes.Count; j++) + { + if (patchnotes[j].ToString().StartsWith('-')) + { + builder.AppendLine($"\n[b][u]{patchnotes[j].ToString().TrimStart('-', ' ')}[/u][/b]"); + } + else + { + builder.AppendLine($"• {(string)patchnotes[j]}"); + } + } + + changelogs.Add(new NitroxChangelog(version, dateTime, builder.ToString())); + } + } + catch (Exception ex) + { + Log.Error(ex, $"{nameof(Downloader)} : Error while fetching Nitrox changelogs from {CHANGELOGS_URL}"); + LauncherNotifier.Error("Unable to fetch Nitrox changelogs"); + } + + return changelogs; + } + + public static async Task GetNitroxLatestVersion() + { + try + { + string jsonString = await CacheFile.GetOrRefreshAsync("update", + r => r.Read(""), + (w, v) => w.Write(v), + async () => + { + using HttpResponseMessage response = await GetResponseFromCache(LATEST_VERSION_URL); + return await response.Content.ReadAsStringAsync(); + }); + + Match match = JsonVersionFieldRegex().Match(jsonString); + if (match.Success && match.Groups.Count > 1) + { + return new Version(match.Groups[1].Value); + } + } + catch (Exception ex) + { + Log.Error(ex, $"{nameof(Downloader)} : Error while fetching Nitrox version from {LATEST_VERSION_URL}"); + LauncherNotifier.Error("Unable to check for Nitrox updates"); + throw; + } + + return new Version(); + } + + private static async Task GetResponseFromCache(string url) + { + Log.Info($"Trying to request data from {url}"); + + using HttpClient client = new(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Nitrox.Launcher"); + client.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromDays(1) }; + client.Timeout = TimeSpan.FromSeconds(5); + + try + { + return await client.GetAsync(url); + } + catch (Exception ex) + { + Log.Error(ex, $"Error while requesting data from {url}"); + } + + return null; + } +} diff --git a/Nitrox.Launcher/Models/Utils/GameInspect.cs b/Nitrox.Launcher/Models/Utils/GameInspect.cs new file mode 100644 index 0000000000..f22c89a870 --- /dev/null +++ b/Nitrox.Launcher/Models/Utils/GameInspect.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using HanumanInstitute.MvvmDialogs; +using Nitrox.Launcher.ViewModels; +using NitroxModel.Discovery.Models; +using NitroxModel.Helper; +using NitroxModel.Logger; +using NitroxModel.Platforms.OS.Shared; +using NitroxModel.Platforms.Store; +using NitroxModel.Platforms.Store.Interfaces; + +namespace Nitrox.Launcher.Models.Utils; + +internal static class GameInspect +{ + /// + /// Check to ensure the Subnautica is not in legacy. + /// + public static async Task IsOutdatedGameAndNotify(string gameInstallDir, IDialogService dialogService = null) + { + try + { + ArgumentException.ThrowIfNullOrWhiteSpace(gameInstallDir); + + string gameVersionFile = Path.Combine(gameInstallDir, GameInfo.Subnautica.DataFolder, "StreamingAssets", "SNUnmanagedData", "plastic_status.ignore"); + if (int.TryParse(await File.ReadAllTextAsync(gameVersionFile), out int gameVersion) && gameVersion <= 68598) + { + if (dialogService != null) + { + await dialogService.ShowAsync(model => + { + model.Title = "Legacy Game Detected"; + model.Description = $"Nitrox does not support the legacy version of Subnautica. Please update your game to the latest version to run the Subnautica with Nitrox.{Environment.NewLine}{Environment.NewLine}Version file location:{Environment.NewLine}{gameVersionFile}"; + model.ButtonOptions = ButtonOptions.Ok; + }); + } + return true; + } + } + catch (Exception ex) + { + Log.Error(ex, "Error while checking game version:"); + LauncherNotifier.Debug(ex.Message); + // On error: ignore and assume it's not outdated in case of unforeseen changes. We don't want to block users. + return false; + } + + return false; + } + + /// + /// Checks game is running and if it is, warns. Does nothing in development mode for debugging purposes. + /// + public static bool WarnIfGameProcessExists(GameInfo game) + { + if (!NitroxEnvironment.IsReleaseMode) + { + return false; + } + + if (!ProcessEx.ProcessExists(game.Name)) + { + return false; + } + + LauncherNotifier.Warning("An instance of Subnautica is already running"); + return true; + } +} diff --git a/Nitrox.Launcher/Models/Utils/LauncherNotifier.cs b/Nitrox.Launcher/Models/Utils/LauncherNotifier.cs new file mode 100644 index 0000000000..7c431abadf --- /dev/null +++ b/Nitrox.Launcher/Models/Utils/LauncherNotifier.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Avalonia.Controls.Notifications; +using CommunityToolkit.Mvvm.Messaging; +using Nitrox.Launcher.Models.Design; + +namespace Nitrox.Launcher.Models.Utils; + +public static class LauncherNotifier +{ + public static void Error(string message) + { + WeakReferenceMessenger.Default.Send(new NotificationAddMessage(new NotificationItem(message, NotificationType.Error))); + } + + public static void Info(string message) + { + WeakReferenceMessenger.Default.Send(new NotificationAddMessage(new NotificationItem(message))); + } + + public static void Warning(string message) + { + WeakReferenceMessenger.Default.Send(new NotificationAddMessage(new NotificationItem(message, NotificationType.Warning))); + } + + public static void Success(string message) + { + WeakReferenceMessenger.Default.Send(new NotificationAddMessage(new NotificationItem(message, NotificationType.Success))); + } + + [Conditional("DEBUG")] + public static void Debug(string message, [CallerMemberName] string memberName = "") + { + WeakReferenceMessenger.Default.Send(new NotificationAddMessage(new NotificationItem($"Error in '{memberName}':{Environment.NewLine}{message}", NotificationType.Success))); + } +} diff --git a/Nitrox.Launcher/Models/Utils/NitroxEntryPatch.cs b/Nitrox.Launcher/Models/Utils/NitroxEntryPatch.cs new file mode 100644 index 0000000000..302847df54 --- /dev/null +++ b/Nitrox.Launcher/Models/Utils/NitroxEntryPatch.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using dnlib.DotNet; +using dnlib.DotNet.Emit; +using NitroxModel; +using NitroxModel.Platforms.OS.Shared; + +namespace Nitrox.Launcher.Models.Patching; + +public static class NitroxEntryPatch +{ + public const string GAME_ASSEMBLY_NAME = "Assembly-CSharp.dll"; + public const string NITROX_ASSEMBLY_NAME = "NitroxPatcher.dll"; + public const string GAME_ASSEMBLY_MODIFIED_NAME = "Assembly-CSharp-Nitrox.dll"; + + private const string NITROX_ENTRY_TYPE_NAME = "Main"; + private const string NITROX_ENTRY_METHOD_NAME = "Execute"; + + private const string GAME_INPUT_TYPE_NAME = "GameInput"; + private const string GAME_INPUT_METHOD_NAME = "Awake"; + + private const string NITROX_EXECUTE_INSTRUCTION = "System.Void NitroxPatcher.Main::Execute()"; + + public static void Apply(string subnauticaBasePath) + { + string subnauticaManagedPath = Path.Combine(subnauticaBasePath, GameInfo.Subnautica.DataFolder, "Managed"); + string assemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_NAME); + string nitroxPatcherPath = Path.Combine(subnauticaManagedPath, NITROX_ASSEMBLY_NAME); + string modifiedAssemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_MODIFIED_NAME); + + if (File.Exists(modifiedAssemblyCSharp)) + { + File.Delete(modifiedAssemblyCSharp); + } + + using (ModuleDefMD module = ModuleDefMD.Load(assemblyCSharp)) + using (ModuleDefMD nitroxPatcherAssembly = ModuleDefMD.Load(nitroxPatcherPath)) + { + TypeDef nitroxMainDefinition = nitroxPatcherAssembly.GetTypes().FirstOrDefault(x => x.Name == NITROX_ENTRY_TYPE_NAME); + MethodDef executeMethodDefinition = nitroxMainDefinition.Methods.FirstOrDefault(x => x.Name == NITROX_ENTRY_METHOD_NAME); + + MemberRef executeMethodReference = module.Import(executeMethodDefinition); + + TypeDef gameInputType = module.GetTypes().First(x => x.FullName == GAME_INPUT_TYPE_NAME); + MethodDef awakeMethod = gameInputType.Methods.First(x => x.Name == GAME_INPUT_METHOD_NAME); + + Instruction callNitroxExecuteInstruction = OpCodes.Call.ToInstruction(executeMethodReference); + + awakeMethod.Body.Instructions.Insert(0, callNitroxExecuteInstruction); + module.Write(modifiedAssemblyCSharp); + } + + // The assembly might be used by other code or some other program might work in it. Retry to be on the safe side. + Exception error = RetryWait(() => File.Delete(assemblyCSharp), 100, 5); + if (error != null) + { + throw error; + } + FileSystem.Instance.ReplaceFile(modifiedAssemblyCSharp, assemblyCSharp); + } + + public static void Remove(string subnauticaBasePath) + { + string subnauticaManagedPath = Path.Combine(subnauticaBasePath, GameInfo.Subnautica.DataFolder, "Managed"); + string assemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_NAME); + string modifiedAssemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_MODIFIED_NAME); + + using (ModuleDefMD module = ModuleDefMD.Load(assemblyCSharp)) + { + TypeDef gameInputType = module.GetTypes().First(x => x.FullName == GAME_INPUT_TYPE_NAME); + MethodDef awakeMethod = gameInputType.Methods.First(x => x.Name == GAME_INPUT_METHOD_NAME); + + IList methodInstructions = awakeMethod.Body.Instructions; + int nitroxExecuteInstructionIndex = FindNitroxExecuteInstructionIndex(methodInstructions); + + if (nitroxExecuteInstructionIndex == -1) + { + return; + } + + methodInstructions.RemoveAt(nitroxExecuteInstructionIndex); + module.Write(modifiedAssemblyCSharp); + + File.SetAttributes(assemblyCSharp, System.IO.FileAttributes.Normal); + } + + FileSystem.Instance.ReplaceFile(modifiedAssemblyCSharp, assemblyCSharp); + } + + private static int FindNitroxExecuteInstructionIndex(IList methodInstructions) + { + for (int instructionIndex = 0; instructionIndex < methodInstructions.Count; instructionIndex++) + { + string instruction = methodInstructions[instructionIndex].Operand?.ToString(); + + if (instruction == NITROX_EXECUTE_INSTRUCTION) + { + return instructionIndex; + } + } + + return -1; + } + + private static Exception RetryWait(Action action, int interval, int retries = 0) + { + Exception lastException = null; + while (retries >= 0) + { + try + { + retries--; + action(); + return null; + } + catch (Exception ex) + { + lastException = ex; + Task.Delay(interval).Wait(); + } + } + return lastException; + } + + public static bool IsPatchApplied(string subnauticaBasePath) + { + string subnauticaManagedPath = Path.Combine(subnauticaBasePath, GameInfo.Subnautica.DataFolder, "Managed"); + string gameInputPath = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_NAME); + + using (ModuleDefMD module = ModuleDefMD.Load(gameInputPath)) + { + TypeDef gameInputType = module.GetTypes().First(x => x.FullName == GAME_INPUT_TYPE_NAME); + MethodDef awakeMethod = gameInputType.Methods.First(x => x.Name == GAME_INPUT_METHOD_NAME); + + return awakeMethod.Body.Instructions.Any(instruction => instruction.Operand?.ToString() == NITROX_EXECUTE_INSTRUCTION); + } + } +} diff --git a/Nitrox.Launcher/Models/Utils/QModHelper.cs b/Nitrox.Launcher/Models/Utils/QModHelper.cs new file mode 100644 index 0000000000..39633c6df2 --- /dev/null +++ b/Nitrox.Launcher/Models/Utils/QModHelper.cs @@ -0,0 +1,12 @@ +using System.IO; + +namespace Nitrox.Launcher.Models.Utils; + +internal static class QModHelper +{ + internal static bool IsQModInstalled(string subnauticaBasePath) + { + string subnauticaQModManagerPath = Path.Combine(subnauticaBasePath, "Bepinex", "plugins", "QModManager"); + return Directory.Exists(subnauticaQModManagerPath); + } +} diff --git a/Nitrox.Launcher/Models/Validators/BackupAttribute.cs b/Nitrox.Launcher/Models/Validators/BackupAttribute.cs new file mode 100644 index 0000000000..7b6a1ebc32 --- /dev/null +++ b/Nitrox.Launcher/Models/Validators/BackupAttribute.cs @@ -0,0 +1,29 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.IO; +using Nitrox.Launcher.Models.Design; + +namespace Nitrox.Launcher.Models.Validators; + +/// +/// Checks that value is a usable . +/// +public sealed class BackupAttribute : TypedValidationAttribute +{ + protected override ValidationResult IsValid(BackupItem value, ValidationContext context) + { + if (value == null) + { + return new ValidationResult($"{context.DisplayName} must not be null."); + } + if (value.BackupFileName == null || value.BackupFileName.AsSpan().Trim().IsEmpty) + { + return new ValidationResult($"{context.DisplayName} must have a backup path assigned"); + } + if (!File.Exists(value.BackupFileName)) + { + return new ValidationResult($"{context.DisplayName} must point to a valid file."); + } + return ValidationResult.Success; + } +} diff --git a/Nitrox.Launcher/Models/Validators/FileNameAttribute.cs b/Nitrox.Launcher/Models/Validators/FileNameAttribute.cs new file mode 100644 index 0000000000..8546a55ff9 --- /dev/null +++ b/Nitrox.Launcher/Models/Validators/FileNameAttribute.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; + +namespace Nitrox.Launcher.Models.Validators; + +/// +/// Tests that the value is usable as file name (excluding validity as file path or file extension). +/// +public sealed class FileNameAttribute : TypedValidationAttribute +{ + private static readonly char[] invalidPathCharacters = Path.GetInvalidFileNameChars(); + + protected override ValidationResult IsValid(string value, ValidationContext context) + { + if (value == null) + { + return ValidationResult.Success; + } + int indexOfAny = value.IndexOfAny(invalidPathCharacters); + if (indexOfAny > -1) + { + return new ValidationResult($"{context.DisplayName} must not contain '{value[indexOfAny]}'. All invalid characters: {string.Join(' ', invalidPathCharacters.Where(c => c > 31))}"); + } + + return ValidationResult.Success; + } +} diff --git a/Nitrox.Launcher/Models/Validators/NitroxUniqueSaveName.cs b/Nitrox.Launcher/Models/Validators/NitroxUniqueSaveName.cs new file mode 100644 index 0000000000..2e3600f5e0 --- /dev/null +++ b/Nitrox.Launcher/Models/Validators/NitroxUniqueSaveName.cs @@ -0,0 +1,58 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.IO; + +namespace Nitrox.Launcher.Models.Validators; + +/// +/// Tests that the save name doesn't conflict with other Nitrox saves. +/// +public sealed class NitroxUniqueSaveName : TypedValidationAttribute +{ + public string SavesFolderDirPropertyName { get; } + public bool AllowCaseInsensitiveName { get; } + public string OriginalValuePropertyName { get; } + + public NitroxUniqueSaveName(string savesFolderDirPropertyName, bool allowCaseInsensitiveName = false, string originalValuePropertyName = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(savesFolderDirPropertyName); + SavesFolderDirPropertyName = savesFolderDirPropertyName; + AllowCaseInsensitiveName = allowCaseInsensitiveName; + OriginalValuePropertyName = originalValuePropertyName; + } + + protected override ValidationResult IsValid(string value, ValidationContext context) + { + static bool SaveFolderExists(string folderName, bool matchExact, string savesFolderDir) + { + if (!matchExact) + { + foreach (string dir in Directory.EnumerateDirectories(savesFolderDir)) + { + if (Path.GetFileName(dir).Equals(folderName, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + return Path.Exists(Path.Combine(savesFolderDir, folderName)); + } + + if (!Directory.Exists(ReadProperty(context, SavesFolderDirPropertyName))) + { + return ValidationResult.Success; + } + if (!string.IsNullOrEmpty(OriginalValuePropertyName) && value == ReadProperty(context, OriginalValuePropertyName)) + { + return ValidationResult.Success; + } + if (SaveFolderExists(value, !AllowCaseInsensitiveName, ReadProperty(context, SavesFolderDirPropertyName))) + { + return new ValidationResult($@"Save ""{value}"" already exists."); + } + + return ValidationResult.Success; + } +} diff --git a/Nitrox.Launcher/Models/Validators/NitroxWorldSeedAttribute.cs b/Nitrox.Launcher/Models/Validators/NitroxWorldSeedAttribute.cs new file mode 100644 index 0000000000..d29aba35bc --- /dev/null +++ b/Nitrox.Launcher/Models/Validators/NitroxWorldSeedAttribute.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +namespace Nitrox.Launcher.Models.Validators; + +public sealed partial class NitroxWorldSeedAttribute : TypedValidationAttribute +{ + [GeneratedRegex(@"^[a-zA-Z]{10}$")] + private static partial Regex NitroxWorldSeedRegex(); + + protected override ValidationResult IsValid(string value, ValidationContext context) + { + if (string.IsNullOrEmpty(value) || NitroxWorldSeedRegex().IsMatch(value)) + { + return ValidationResult.Success; + } + + return new ValidationResult($"The field {context.DisplayName} must contain 10 alphabetical characters."); + } +} diff --git a/Nitrox.Launcher/Models/Validators/NotEndsWithAttribute.cs b/Nitrox.Launcher/Models/Validators/NotEndsWithAttribute.cs new file mode 100644 index 0000000000..05b2efabe8 --- /dev/null +++ b/Nitrox.Launcher/Models/Validators/NotEndsWithAttribute.cs @@ -0,0 +1,19 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Nitrox.Launcher.Models.Validators; + +/// +/// Tests that the value is doesn't end with the specified text. +/// +public sealed class NotEndsWithAttribute(string text, StringComparison comparison = StringComparison.OrdinalIgnoreCase) : TypedValidationAttribute +{ + protected override ValidationResult IsValid(string value, ValidationContext context) + { + if (value == null) + { + return ValidationResult.Success; + } + return value.EndsWith(text, comparison) ? new ValidationResult($"{context.DisplayName} must not contain the text '{text}' at the end.") : ValidationResult.Success; + } +} diff --git a/Nitrox.Launcher/Models/Validators/TypedValidationAttribute.cs b/Nitrox.Launcher/Models/Validators/TypedValidationAttribute.cs new file mode 100644 index 0000000000..2c4014e394 --- /dev/null +++ b/Nitrox.Launcher/Models/Validators/TypedValidationAttribute.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace Nitrox.Launcher.Models.Validators; + +public abstract class TypedValidationAttribute : ValidationAttribute +{ + protected abstract ValidationResult IsValid(T value, ValidationContext context); + + protected override ValidationResult IsValid(object value, ValidationContext context) + { + if (value == default) + { + return IsValid(default, context); + } + if (value is not T typedValue) + { + return new ValidationResult($"The field {context.DisplayName} must be of type {typeof(T).Name}."); + } + return IsValid(typedValue, context); + } + + protected static TResult ReadProperty(ValidationContext context, string propertyName) + { + if (string.IsNullOrWhiteSpace(propertyName)) + { + return default; + } + object value = context.ObjectType.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(context.ObjectInstance); + return value is TResult tValue ? tValue : default; + } +} diff --git a/Nitrox.Launcher/Nitrox.Launcher.csproj b/Nitrox.Launcher/Nitrox.Launcher.csproj new file mode 100644 index 0000000000..b9bb4be44f --- /dev/null +++ b/Nitrox.Launcher/Nitrox.Launcher.csproj @@ -0,0 +1,100 @@ + + + + + WinExe + net9.0 + false + true + app.manifest + en-US + Assets\Images\nitrox-icon.ico + false + + + + Nitrox.app + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $(TargetDir) + + -r $(RuntimeIdentifier) + + + + + + + + + + + + + + + $(TargetDir)lib\net472 + + + + + + + + + diff --git a/Nitrox.Launcher/Platforms/MacOS/Entitlements.plist b/Nitrox.Launcher/Platforms/MacOS/Entitlements.plist new file mode 100644 index 0000000000..0766871707 --- /dev/null +++ b/Nitrox.Launcher/Platforms/MacOS/Entitlements.plist @@ -0,0 +1,18 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + SecTaskAccess + + allowed + + + \ No newline at end of file diff --git a/Nitrox.Launcher/Platforms/MacOS/Info.plist b/Nitrox.Launcher/Platforms/MacOS/Info.plist new file mode 100644 index 0000000000..02a5aaad40 --- /dev/null +++ b/Nitrox.Launcher/Platforms/MacOS/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleIconFile + Nitrox.icns + CFBundleIdentifier + com.nitrox.launcher + CFBundleName + Nitrox + CFBundleDisplayName + Nitrox + CFBundleVersion + 1.8.0 + LSMinimumSystemVersion + 12.0 + CFBundleExecutable + Nitrox.Launcher + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.8 + NSPrincipalClass + NSApplication + NSHighResolutionCapable + + + \ No newline at end of file diff --git a/Nitrox.Launcher/Platforms/MacOS/Resources/Nitrox.icns b/Nitrox.Launcher/Platforms/MacOS/Resources/Nitrox.icns new file mode 100644 index 0000000000..2622f5bf18 Binary files /dev/null and b/Nitrox.Launcher/Platforms/MacOS/Resources/Nitrox.icns differ diff --git a/Nitrox.Launcher/Program.cs b/Nitrox.Launcher/Program.cs new file mode 100644 index 0000000000..b9628c4e0a --- /dev/null +++ b/Nitrox.Launcher/Program.cs @@ -0,0 +1,136 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.ReactiveUI; +using NitroxModel.Helper; +using NitroxModel.Logger; +using NitroxModel.Platforms.OS.Shared; + +namespace Nitrox.Launcher; + +internal static class Program +{ + // Don't use any Avalonia, third-party APIs or any SynchronizationContext-reliant code before AppMain is called + // Things aren't initialized yet and stuff might break + [STAThread] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void Main(string[] args) + { + AppDomain.CurrentDomain.AssemblyResolve += AssemblyResolver.Handler; + AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += AssemblyResolver.Handler; + + LoadAvalonia(args); + } + + private static AppBuilder BuildAvaloniaApp() + { + CultureManager.ConfigureCultureInfo(); + CheckForRunningInstance(); + Log.Setup(); + + AppBuilder builder = AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace() + .UseReactiveUI(); + + // The Wayland renderer on Linux using GPU rendering is not (yet) supported by Avalonia + // Waiting on issue: https://github.com/AvaloniaUI/Avalonia/issues/1243 to enable rendering on GPU + if (Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is not null) + { + builder = builder.With(new X11PlatformOptions { RenderingMode = [X11RenderingMode.Software] }); + } + + return builder; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void LoadAvalonia(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + private static void CheckForRunningInstance() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return; + } + + try + { + using ProcessEx process = ProcessEx.GetFirstProcess("Nitrox.Launcher", process => process.Id != Environment.ProcessId); + if (process is not null) + { + process.SetForegroundWindowAndRestore(); + Environment.Exit(0); + } + } + catch (Exception) + { + // Ignore + } + } + + private static class AssemblyResolver + { + private static string currentExecutableDirectory; + + public static Assembly Handler(object sender, ResolveEventArgs args) + { + static Assembly ResolveFromLib(ReadOnlySpan dllName) + { + dllName = dllName.Slice(0, dllName.IndexOf(',')); + if (!dllName.EndsWith(".dll")) + { + dllName = string.Concat(dllName, ".dll"); + } + + if (dllName.EndsWith(".resources.dll")) + { + return null; + } + + string dllNameStr = dllName.ToString(); + + string dllPath = Path.Combine(GetExecutableDirectory(), "lib", dllNameStr); + if (!File.Exists(dllPath)) + { + dllPath = Path.Combine(GetExecutableDirectory(), dllNameStr); + } + + try + { + return Assembly.LoadFile(dllPath); + } + catch + { + return null; + } + } + + Assembly assembly = ResolveFromLib(args.Name); + if (assembly == null && !args.Name.Contains(".resources")) + { + assembly = Assembly.Load(args.Name); + } + + return assembly; + } + + private static string GetExecutableDirectory() + { + if (currentExecutableDirectory != null) + { + return currentExecutableDirectory; + } + string pathAttempt = Assembly.GetEntryAssembly()?.Location; + if (string.IsNullOrWhiteSpace(pathAttempt)) + { + using Process proc = Process.GetCurrentProcess(); + pathAttempt = proc.MainModule?.FileName; + } + return currentExecutableDirectory = new Uri(Path.GetDirectoryName(pathAttempt ?? ".") ?? Directory.GetCurrentDirectory()).LocalPath; + } + } +} diff --git a/Nitrox.Launcher/Properties/launchSettings.json b/Nitrox.Launcher/Properties/launchSettings.json new file mode 100644 index 0000000000..2ea589c8d6 --- /dev/null +++ b/Nitrox.Launcher/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Nitrox.Launcher": { + "commandName": "Project" + }, + "Instant launch \"world\"": { + "commandName": "Project", + "commandLineArgs": "--instantlaunch \"world\" \"Player1\"" + } + } +} diff --git a/Nitrox.Launcher/Roots.xml b/Nitrox.Launcher/Roots.xml new file mode 100644 index 0000000000..e1ca6b2f67 --- /dev/null +++ b/Nitrox.Launcher/Roots.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Nitrox.Launcher/UI/Controls/BlurControl.cs b/Nitrox.Launcher/UI/Controls/BlurControl.cs new file mode 100644 index 0000000000..16d322ec37 --- /dev/null +++ b/Nitrox.Launcher/UI/Controls/BlurControl.cs @@ -0,0 +1,96 @@ +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.UI.Controls; + +/// +/// Draws a blur filter over the already rendered content. +/// +/// +/// Based off of GrayscaleControl +/// +public sealed class BlurControl : Decorator +{ + public static readonly StyledProperty BlurStrengthProperty = + AvaloniaProperty.Register(nameof(BlurStrength), 5); + + /// + /// Sets or gets how strong the blur should be. Defaults to 5. + /// + public float BlurStrength + { + get => GetValue(BlurStrengthProperty); + set => SetValue(BlurStrengthProperty, value); + } + + static BlurControl() + { + ClipToBoundsProperty.OverrideDefaultValue(true); + AffectsRender(OpacityProperty); + AffectsRender(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(); + 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() + { + Shader = backdropShader, + ImageFilter = blurFilter, + 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); + } +} diff --git a/Nitrox.Launcher/UI/Controls/FittingWrapPanel.cs b/Nitrox.Launcher/UI/Controls/FittingWrapPanel.cs new file mode 100644 index 0000000000..e3e500dbc6 --- /dev/null +++ b/Nitrox.Launcher/UI/Controls/FittingWrapPanel.cs @@ -0,0 +1,189 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Utilities; +using static System.Math; + +namespace Nitrox.Launcher.UI.Controls; + +/// +/// Panel that arranges stretchable child controls to fit min width, up to the limit of . +/// Code inspired by Avalonia's WrapPanel +/// (https://github.com/AvaloniaUI/Avalonia/blob/master/src/Avalonia.Controls/WrapPanel.cs). +/// +/// +/// Looks similar to YouTube video layout. +/// +public class FittingWrapPanel : Panel, INavigableContainer +{ + public static readonly StyledProperty MinItemWidthProperty = + AvaloniaProperty.Register(nameof(MinItemWidth), 100); + + public double MinItemWidth + { + get => GetValue(MinItemWidthProperty); + set => SetValue(MinItemWidthProperty, value); + } + + static FittingWrapPanel() + { + AffectsMeasure(MinItemWidthProperty); + } + + /// + 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); + } + + /// + 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; + } + + /// + /// Gets the next control in the specified direction. + /// + /// The movement direction. + /// The control from which movement begins. + /// Whether to wrap around when the first or last item is reached. + /// The control. + 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; } + } +} diff --git a/Nitrox.Launcher/UI/Controls/GrayscaleControl.cs b/Nitrox.Launcher/UI/Controls/GrayscaleControl.cs new file mode 100644 index 0000000000..513d118524 --- /dev/null +++ b/Nitrox.Launcher/UI/Controls/GrayscaleControl.cs @@ -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.UI.Controls; + +/// +/// Draws a grayscale filter over the already rendered content. +/// +/// +/// Code from:
+/// - Draw-on-top logic: https://gist.github.com/kekekeks/ac06098a74fe87d49a9ff9ea37fa67bc
+/// - Grayscale logic: https://learn.microsoft.com/en-us/xamarin/xamarin-forms/user-interface/graphics/skiasharp/effects/color-filters
+///
+public class GrayscaleControl : Decorator +{ + static GrayscaleControl() + { + AffectsRender(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(); + 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); + } +} diff --git a/Nitrox.Launcher/UI/Controls/RadioButtonGroup.cs b/Nitrox.Launcher/UI/Controls/RadioButtonGroup.cs new file mode 100644 index 0000000000..fe97b495f5 --- /dev/null +++ b/Nitrox.Launcher/UI/Controls/RadioButtonGroup.cs @@ -0,0 +1,52 @@ +using System; +using System.Reactive; +using Avalonia; +using Avalonia.Controls; +using ReactiveUI; + +namespace Nitrox.Launcher.UI.Controls; + +public class RadioButtonGroup : ItemsControl +{ + public static readonly DirectProperty EnumProperty = AvaloniaProperty.RegisterDirect(nameof(Enum), o => o.Enum, (o, v) => o.Enum = v); + public static readonly StyledProperty SelectedItemProperty = AvaloniaProperty.Register(nameof(SelectedItem)); + + public static readonly DirectProperty> ItemClickCommandProperty = AvaloniaProperty.RegisterDirect>(nameof(ItemClickCommand), o => o.ItemClickCommand, (o, v) => o.ItemClickCommand = v); + + private Type @enum; + private ReactiveCommand 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 ReactiveCommand ItemClickCommand + { + get => itemClickCommand; + private set => SetAndRaise(ItemClickCommandProperty, ref itemClickCommand, value); + } + + public object SelectedItem + { + get => GetValue(SelectedItemProperty); + set => SetValue(SelectedItemProperty, value); + } + + public RadioButtonGroup() + { + itemClickCommand = ReactiveCommand.Create + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/UI/Styles/Theme/CheckBoxStyle.axaml b/Nitrox.Launcher/UI/Styles/Theme/CheckBoxStyle.axaml new file mode 100644 index 0000000000..414fa105ff --- /dev/null +++ b/Nitrox.Launcher/UI/Styles/Theme/CheckBoxStyle.axaml @@ -0,0 +1,136 @@ + + + + + + + + Light + + + + + Dark + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/UI/Styles/Theme/ComboBoxStyle.axaml b/Nitrox.Launcher/UI/Styles/Theme/ComboBoxStyle.axaml new file mode 100644 index 0000000000..6b426b1c44 --- /dev/null +++ b/Nitrox.Launcher/UI/Styles/Theme/ComboBoxStyle.axaml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/UI/Styles/Theme/ExpanderStyle.axaml b/Nitrox.Launcher/UI/Styles/Theme/ExpanderStyle.axaml new file mode 100644 index 0000000000..fa72d7ec4a --- /dev/null +++ b/Nitrox.Launcher/UI/Styles/Theme/ExpanderStyle.axaml @@ -0,0 +1,135 @@ + + + + + + + + + + Expanded content + + + + Expanded content + + + + + + + + + + Expanded content + + + + Expanded content + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/UI/Styles/Theme/RadioButtonGroupStyle.axaml b/Nitrox.Launcher/UI/Styles/Theme/RadioButtonGroupStyle.axaml new file mode 100644 index 0000000000..6dec2f7fb2 --- /dev/null +++ b/Nitrox.Launcher/UI/Styles/Theme/RadioButtonGroupStyle.axaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/UI/Styles/Theme/RadioButtonStyle.axaml b/Nitrox.Launcher/UI/Styles/Theme/RadioButtonStyle.axaml new file mode 100644 index 0000000000..ad0df74e19 --- /dev/null +++ b/Nitrox.Launcher/UI/Styles/Theme/RadioButtonStyle.axaml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/UI/Styles/Theme/ScrollViewerStyle.axaml b/Nitrox.Launcher/UI/Styles/Theme/ScrollViewerStyle.axaml new file mode 100644 index 0000000000..9c2eb11a0f --- /dev/null +++ b/Nitrox.Launcher/UI/Styles/Theme/ScrollViewerStyle.axaml @@ -0,0 +1,276 @@ + + + + + + + + + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + Item 6 + Item 7 + Item 8 + Item 9 + + + + + + + + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + Item 6 + Item 7 + Item 8 + Item 9 + + + + + + + + + + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + Item 6 + Item 7 + Item 8 + Item 9 + + + + + + + + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + Item 6 + Item 7 + Item 8 + Item 9 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/UI/Styles/Theme/TextBlockStyle.axaml b/Nitrox.Launcher/UI/Styles/Theme/TextBlockStyle.axaml new file mode 100644 index 0000000000..42b998560d --- /dev/null +++ b/Nitrox.Launcher/UI/Styles/Theme/TextBlockStyle.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/UI/Styles/Theme/TextBoxStyle.axaml b/Nitrox.Launcher/UI/Styles/Theme/TextBoxStyle.axaml new file mode 100644 index 0000000000..200ea2d22d --- /dev/null +++ b/Nitrox.Launcher/UI/Styles/Theme/TextBoxStyle.axaml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/UI/Styles/Theme/ToolTipStyle.axaml b/Nitrox.Launcher/UI/Styles/Theme/ToolTipStyle.axaml new file mode 100644 index 0000000000..90359f9de0 --- /dev/null +++ b/Nitrox.Launcher/UI/Styles/Theme/ToolTipStyle.axaml @@ -0,0 +1,30 @@ + + + + + Text Content + Very long text content which should exceed the maximum with of the tooltip and wrap. + + + Multi-line + Control Content + + + + + + + + + + + diff --git a/Nitrox.Launcher/UI/Styles/Theme/ValidationErrorsStyle.axaml b/Nitrox.Launcher/UI/Styles/Theme/ValidationErrorsStyle.axaml new file mode 100644 index 0000000000..7b415f6003 --- /dev/null +++ b/Nitrox.Launcher/UI/Styles/Theme/ValidationErrorsStyle.axaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/ViewModels/Abstract/ModalViewModelBase.cs b/Nitrox.Launcher/ViewModels/Abstract/ModalViewModelBase.cs new file mode 100644 index 0000000000..ca141f3029 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/Abstract/ModalViewModelBase.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using System.Reactive.Disposables; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using HanumanInstitute.MvvmDialogs; + +namespace Nitrox.Launcher.ViewModels.Abstract; + +/// +/// Base class for (popup) dialog ViewModels. +/// +public abstract partial class ModalViewModelBase : ObservableValidator, IModalDialogViewModel, IDisposable +{ + protected readonly CompositeDisposable Disposables = new(); + [ObservableProperty] private ButtonOptions? selectedOption; + + bool? IModalDialogViewModel.DialogResult => (bool)this; + + protected ModalViewModelBase() + { + // Always run validation first so HasErrors is set (i.e. trigger CanExecute logic). + ValidateAllProperties(); + } + + public static implicit operator bool(ModalViewModelBase self) => self is { HasErrors: false } and not { SelectedOption: null or ButtonOptions.No }; + + [RelayCommand] + public void Close(ButtonOptions? buttonOptions = null) + { + if (buttonOptions != null) + { + SelectedOption = buttonOptions; + } + ((IClassicDesktopStyleApplicationLifetime)Application.Current?.ApplicationLifetime)?.Windows.FirstOrDefault(w => w.DataContext == this)?.Close(); + } + + public void Dispose() => Disposables.Dispose(); + + [RelayCommand] + public void Drag(PointerPressedEventArgs args) + { + ArgumentNullException.ThrowIfNull(args); + + if (args.Source is Visual element && element.GetWindow() is {} window) + { + window.BeginMoveDrag(args); + } + } +} diff --git a/Nitrox.Launcher/ViewModels/Abstract/RoutableViewModelBase.cs b/Nitrox.Launcher/ViewModels/Abstract/RoutableViewModelBase.cs new file mode 100644 index 0000000000..ab57486fec --- /dev/null +++ b/Nitrox.Launcher/ViewModels/Abstract/RoutableViewModelBase.cs @@ -0,0 +1,32 @@ +using System; +using System.ComponentModel; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels.Abstract; + +public abstract class RoutableViewModelBase : ViewModelBase, IRoutableViewModel, IActivatableViewModel +{ + /// + /// Gets the unique URL for the view. + /// + public string UrlPathSegment => Convert.ToHexString(GetType().Name.AsMd5Hash()); + + public IScreen HostScreen { get; } + + protected RoutableViewModelBase(IScreen screen) + { + HostScreen = screen; + } + + /// + /// Pass-through event from MVVM toolkit to ReactiveUI. + /// + public void RaisePropertyChanging(PropertyChangingEventArgs args) => OnPropertyChanging(args); + + /// + /// Pass-through event from MVVM toolkit to ReactiveUI. + /// + public void RaisePropertyChanged(PropertyChangedEventArgs args) => OnPropertyChanged(args); + + public ViewModelActivator Activator { get; } = new(); +} diff --git a/Nitrox.Launcher/ViewModels/Abstract/ViewModelBase.cs b/Nitrox.Launcher/ViewModels/Abstract/ViewModelBase.cs new file mode 100644 index 0000000000..347c309416 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/Abstract/ViewModelBase.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Nitrox.Launcher.ViewModels.Abstract; + +public abstract class ViewModelBase : ObservableValidator +{ + protected Window MainWindow => AppViewLocator.MainWindow; +} diff --git a/Nitrox.Launcher/ViewModels/BackupRestoreViewModel.cs b/Nitrox.Launcher/ViewModels/BackupRestoreViewModel.cs new file mode 100644 index 0000000000..c05ddbb979 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/BackupRestoreViewModel.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reactive.Disposables; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.Models.Validators; +using Nitrox.Launcher.ViewModels.Abstract; +using NitroxServer.Serialization.World; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +public partial class BackupRestoreViewModel : ModalViewModelBase +{ + [ObservableProperty] + private AvaloniaList backups = []; + + [ObservableProperty] + private string saveFolderDirectory; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RestoreBackupCommand))] + [NotifyDataErrorInfo] + [Backup] + private BackupItem selectedBackup; + + [ObservableProperty] + private string title; + + public BackupRestoreViewModel() + { + this.WhenAnyValue(model => model.SaveFolderDirectory) + .Subscribe(owner => + { + Backups.Clear(); + Backups.AddRange(GetBackups(SaveFolderDirectory)); + }) + .DisposeWith(Disposables); + } + + [RelayCommand(CanExecute = nameof(CanRestoreBackup))] + public void RestoreBackup() + { + Close(); + } + + public bool CanRestoreBackup() => !HasErrors; + + private static IEnumerable GetBackups(string saveDirectory) + { + IEnumerable GetBackupFilePaths(string backupRootDir) => + Directory.GetFiles(backupRootDir, "*.zip") + .Where(file => + { + // Verify file name format of "Backup - {DateTime:BACKUP_DATE_TIME_FORMAT}.zip" + string fileName = Path.GetFileNameWithoutExtension(file); + if (!fileName.StartsWith("Backup - ")) + { + return false; + } + + string dateTimePart = fileName["Backup - ".Length..]; + return DateTime.TryParseExact(dateTimePart, WorldPersistence.BACKUP_DATE_TIME_FORMAT, CultureInfo.InvariantCulture, DateTimeStyles.None, out _); + }); + + if (saveDirectory == null) + { + yield break; + } + string backupDir = Path.Combine(saveDirectory, "Backups"); + if (!Directory.Exists(backupDir)) + { + yield break; + } + + foreach (string backupPath in GetBackupFilePaths(backupDir)) + { + if (!DateTime.TryParseExact(Path.GetFileNameWithoutExtension(backupPath)["Backup - ".Length..], WorldPersistence.BACKUP_DATE_TIME_FORMAT, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime backupDate)) + { + backupDate = File.GetCreationTime(backupPath); + } + yield return new BackupItem(backupDate, backupPath); + } + } +} diff --git a/Nitrox.Launcher/ViewModels/BlogViewModel.cs b/Nitrox.Launcher/ViewModels/BlogViewModel.cs new file mode 100644 index 0000000000..6482544c68 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/BlogViewModel.cs @@ -0,0 +1,58 @@ +using System; +using System.Diagnostics; +using Avalonia.Collections; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nitrox.Launcher.Models.Converters; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.Models.Utils; +using Nitrox.Launcher.ViewModels.Abstract; +using NitroxModel.Logger; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +public partial class BlogViewModel : RoutableViewModelBase +{ + public Bitmap FallbackImage { get; set; } = BitmapAssetValueConverter.GetBitmapFromPath("/Assets/Images/blog/vines.png"); + + [ObservableProperty] + private AvaloniaList nitroxBlogs = []; + + public BlogViewModel(IScreen screen, NitroxBlog[] blogs = null) : base(screen) + { + nitroxBlogs.AddRange(blogs ?? []); + Dispatcher.UIThread.Invoke(async () => + { + try + { + nitroxBlogs.Clear(); + nitroxBlogs.AddRange(await Downloader.GetBlogs()); + } + catch (Exception ex) + { + Log.Error(ex, "Error while trying to display nitrox blogs"); + } + }); + } + + [RelayCommand] + private void BlogEntryClick(string blogUrl) + { + UriBuilder blogUriBuilder = new(blogUrl) + { + Scheme = Uri.UriSchemeHttps, + Port = -1 + }; + + Process.Start( + new ProcessStartInfo(blogUriBuilder.Uri.ToString()) + { + UseShellExecute = true, + Verb = "open" + } + )?.Dispose(); + } +} diff --git a/Nitrox.Launcher/ViewModels/CommunityViewModel.cs b/Nitrox.Launcher/ViewModels/CommunityViewModel.cs new file mode 100644 index 0000000000..4d3996423d --- /dev/null +++ b/Nitrox.Launcher/ViewModels/CommunityViewModel.cs @@ -0,0 +1,43 @@ +using System.Diagnostics; +using CommunityToolkit.Mvvm.Input; +using Nitrox.Launcher.ViewModels.Abstract; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +public partial class CommunityViewModel : RoutableViewModelBase +{ + public CommunityViewModel(IScreen screen) : base(screen) + { + } + + [RelayCommand] + private void DiscordLink() + { + Process.Start(new ProcessStartInfo("https://discord.gg/E8B4X9s") { UseShellExecute = true, Verb = "open" })?.Dispose(); + } + + [RelayCommand] + private void TwitterLink() + { + Process.Start(new ProcessStartInfo("https://twitter.com/modnitrox") { UseShellExecute = true, Verb = "open" })?.Dispose(); + } + + [RelayCommand] + private void RedditLink() + { + Process.Start(new ProcessStartInfo("https://reddit.com/r/SubnauticaNitrox") { UseShellExecute = true, Verb = "open" })?.Dispose(); + } + + [RelayCommand] + private void BlueskyLink() + { + Process.Start(new ProcessStartInfo("https://bsky.app/profile/nitroxmod.bsky.social") { UseShellExecute = true, Verb = "open" })?.Dispose(); + } + + [RelayCommand] + private void GithubLink() + { + Process.Start(new ProcessStartInfo("https://github.com/SubnauticaNitrox/Nitrox") { UseShellExecute = true, Verb = "open" })?.Dispose(); + } +} diff --git a/Nitrox.Launcher/ViewModels/CreateServerViewModel.cs b/Nitrox.Launcher/ViewModels/CreateServerViewModel.cs new file mode 100644 index 0000000000..b58fbb3aba --- /dev/null +++ b/Nitrox.Launcher/ViewModels/CreateServerViewModel.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nitrox.Launcher.Models.Validators; +using Nitrox.Launcher.ViewModels.Abstract; +using NitroxModel.Helper; +using NitroxModel.Serialization; +using NitroxModel.Server; + +namespace Nitrox.Launcher.ViewModels; + +public partial class CreateServerViewModel : ModalViewModelBase +{ + private readonly IKeyValueStore keyValueStore; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(CreateCommand))] + [NotifyDataErrorInfo] + [Required] + [FileName] + [NotEndsWith(".")] + [NitroxUniqueSaveName(nameof(SavesFolderDir))] + private string name; + + [ObservableProperty] + private NitroxGameMode selectedGameMode = NitroxGameMode.SURVIVAL; + + public KeyGesture BackHotkey { get; } = new(Key.Escape); + public KeyGesture CreateHotkey { get; } = new(Key.Return); + + private string SavesFolderDir => keyValueStore.GetSavesFolderDir(); + + public CreateServerViewModel(IKeyValueStore keyValueStore) + { + this.keyValueStore = keyValueStore; + } + + public void CreateEmptySave(string saveName, NitroxGameMode saveGameMode) + { + string saveDir = Path.Combine(SavesFolderDir, saveName); + Directory.CreateDirectory(saveDir); + SubnauticaServerConfig config = SubnauticaServerConfig.Load(saveDir); + string fileEnding = "json"; + if (config.SerializerMode == ServerSerializerMode.PROTOBUF) + { + fileEnding = "nitrox"; + } + + File.WriteAllText(Path.Combine(saveDir, $"Version.{fileEnding}"), null); + + using (config.Update(saveDir)) + { + config.GameMode = saveGameMode; + } + } + + [RelayCommand(CanExecute = nameof(CanCreate))] + private async Task CreateAsync() + { + await Task.Run(() => CreateEmptySave(Name, SelectedGameMode)); + Close(); + } + + private bool CanCreate() => !HasErrors; +} diff --git a/Nitrox.Launcher/ViewModels/DialogBoxViewModel.cs b/Nitrox.Launcher/ViewModels/DialogBoxViewModel.cs new file mode 100644 index 0000000000..c6bc2565cd --- /dev/null +++ b/Nitrox.Launcher/ViewModels/DialogBoxViewModel.cs @@ -0,0 +1,88 @@ +using System; +using System.Reactive.Disposables; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Media; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nitrox.Launcher.ViewModels.Abstract; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +/// +/// Simple Yes/No or OK confirmation box. +/// +public partial class DialogBoxViewModel : ModalViewModelBase +{ + [ObservableProperty] private string windowTitle; + + [ObservableProperty] private string title; + [ObservableProperty] private double titleFontSize = 24; + [ObservableProperty] private FontWeight titleFontWeight = FontWeight.Bold; + + [ObservableProperty] private string description; + [ObservableProperty] private double descriptionFontSize = 14; + [ObservableProperty] private FontWeight descriptionFontWeight = FontWeight.Normal; + + [ObservableProperty] private ButtonOptions buttonOptions = ButtonOptions.Ok; + private Task copyToClipboardTask; + + public KeyGesture OkHotkey { get; } = new(Key.Return); + public KeyGesture NoHotkey { get; } = new(Key.Escape); + public KeyGesture CopyToClipboardHotkey { get; } = new(Key.C, KeyModifiers.Control); + + public DialogBoxViewModel() + { + this.WhenAnyValue(model => model.Title, model => model.Description) + .Subscribe(tuple => + { + (string titleText, string descriptionText) = tuple; + WindowTitle ??= string.IsNullOrEmpty(titleText) ? WindowTitle : titleText; + WindowTitle ??= string.IsNullOrEmpty(descriptionText) ? WindowTitle : $"{descriptionText[..Math.Min(30, descriptionText.Length)]}..."; + }) + .DisposeWith(Disposables); + } + + [RelayCommand] + private async Task CopyToClipboard(ContentControl commandControl) + { + if (!copyToClipboardTask?.IsCompleted ?? false) + { + return; + } + + + string text = $"{Title}{Environment.NewLine}{(Description.StartsWith(Title) ? Description[Title.Length..].TrimStart() : Description)}"; + IClipboard clipboard = AppViewLocator.MainWindow.Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(text); + + if (commandControl != null) + { + object previousContent = commandControl.Content; + commandControl.Content = "Copied!"; + copyToClipboardTask = Dispatcher.UIThread.InvokeAsync(async () => + { + await Task.Delay(3000); + commandControl.Content = previousContent; + }); + } + } + } +} + +[Flags] +public enum ButtonOptions +{ + Ok = 1 << 0, + Yes = 1 << 1, + No = 1 << 2, + Clipboard = 1 << 3, + OkClipboard = Ok | Clipboard, + YesNo = Yes | No, +} diff --git a/Nitrox.Launcher/ViewModels/LaunchGameViewModel.cs b/Nitrox.Launcher/ViewModels/LaunchGameViewModel.cs new file mode 100644 index 0000000000..9d2998bf39 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/LaunchGameViewModel.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using HanumanInstitute.MvvmDialogs; +using Nitrox.Launcher.Models.Converters; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.Models.Patching; +using Nitrox.Launcher.Models.Utils; +using Nitrox.Launcher.ViewModels.Abstract; +using NitroxModel.Discovery.Models; +using NitroxModel.Helper; +using NitroxModel.Logger; +using NitroxModel.Platforms.OS.Shared; +using NitroxModel.Platforms.Store; +using NitroxModel.Platforms.Store.Interfaces; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +public partial class LaunchGameViewModel : RoutableViewModelBase +{ + public static Task LastFindSubnauticaTask; + + private readonly OptionsViewModel optionsViewModel; + private readonly ServersViewModel serversViewModel; + private readonly IKeyValueStore keyValueStore; + private readonly IDialogService dialogService; + + [ObservableProperty] + private Platform gamePlatform; + + [ObservableProperty] + private string platformToolTip; + + public Bitmap[] GalleryImageSources { get; } = [ + BitmapAssetValueConverter.GetBitmapFromPath("/Assets/Images/gallery/image-1.png"), + BitmapAssetValueConverter.GetBitmapFromPath("/Assets/Images/gallery/image-2.png"), + BitmapAssetValueConverter.GetBitmapFromPath("/Assets/Images/gallery/image-3.png"), + BitmapAssetValueConverter.GetBitmapFromPath("/Assets/Images/gallery/image-4.png") + ]; + + public string Version => $"{NitroxEnvironment.ReleasePhase} {NitroxEnvironment.Version}"; + public string SubnauticaLaunchArguments => keyValueStore.GetSubnauticaLaunchArguments(); + + public LaunchGameViewModel(IScreen screen, IDialogService dialogService, ServersViewModel serversViewModel, OptionsViewModel optionsViewModel, IKeyValueStore keyValueStore) : base(screen) + { + this.dialogService = dialogService; + this.serversViewModel = serversViewModel; + this.optionsViewModel = optionsViewModel; + this.keyValueStore = keyValueStore; + + NitroxUser.GamePlatformChanged += UpdateGamePlatform; + + UpdateGamePlatform(); + HandleInstantLaunchForDevelopment(); + } + + [RelayCommand] + private async Task StartSingleplayerAsync() + { + if (GameInspect.WarnIfGameProcessExists(GameInfo.Subnautica)) + { + return; + } + + LauncherNotifier.Info("Starting game"); + Log.Info("Launching Subnautica in singleplayer mode"); + + try + { + if (string.IsNullOrWhiteSpace(NitroxUser.GamePath) || !Directory.Exists(NitroxUser.GamePath)) + { + HostScreen.Show(optionsViewModel); + LauncherNotifier.Warning("Location of Subnautica is unknown. Set the path to it in settings"); + return; + } + NitroxEntryPatch.Remove(NitroxUser.GamePath); + await StartSubnauticaAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "Error while starting game in singleplayer mode:"); + await dialogService.ShowErrorAsync(ex, "Error while starting game in singleplayer mode"); + } + } + + [RelayCommand] + private async Task StartMultiplayerAsync(string[] args = null) + { + LauncherNotifier.Info("Starting game"); + Log.Info("Launching Subnautica in multiplayer mode"); + try + { + bool setupResult = await Task.Run(async () => + { + if (string.IsNullOrWhiteSpace(NitroxUser.GamePath) || !Directory.Exists(NitroxUser.GamePath)) + { + await Dispatcher.UIThread.InvokeAsync(() => HostScreen.Show(optionsViewModel)); + LauncherNotifier.Warning("Location of Subnautica is unknown. Set the path to it in settings"); + return false; + } + if (PirateDetection.HasTriggered) + { + LauncherNotifier.Error("Aarrr! Nitrox has walked the plank :("); + return false; + } + if (GameInspect.WarnIfGameProcessExists(GameInfo.Subnautica)) + { + return false; + } + if (await GameInspect.IsOutdatedGameAndNotify(NitroxUser.GamePath, dialogService)) + { + return false; + } + + // TODO: The launcher should override FileRead win32 API for the Subnautica process to give it the modified Assembly-CSharp from memory + try + { + const string PATCHER_DLL_NAME = "NitroxPatcher.dll"; + + File.Copy( + Path.Combine(NitroxUser.CurrentExecutablePath ?? "", "lib", "net472", PATCHER_DLL_NAME), + Path.Combine(NitroxUser.GamePath, GameInfo.Subnautica.DataFolder, "Managed", PATCHER_DLL_NAME), + true + ); + } + catch (IOException ex) + { + Log.Error(ex, "Unable to move initialization dll to Managed folder. Still attempting to launch because it might exist from previous runs"); + } + + // Try inject Nitrox into Subnautica code. + if (LastFindSubnauticaTask != null) + { + await LastFindSubnauticaTask; + } + NitroxEntryPatch.Remove(NitroxUser.GamePath); + NitroxEntryPatch.Apply(NitroxUser.GamePath); + + if (QModHelper.IsQModInstalled(NitroxUser.GamePath)) + { + Log.Warn("Seems like QModManager is installed"); + LauncherNotifier.Warning("QModManager Detected in the game folder"); + } + + return true; + }); + + if (!setupResult) + { + return; + } + + await StartSubnauticaAsync(args); + } + catch (Exception ex) + { + Log.Error(ex, "Error while starting game in multiplayer mode:"); + await Dispatcher.UIThread.InvokeAsync(async () => await dialogService.ShowErrorAsync(ex, "Error while starting game in multiplayer mode")); + } + } + + /// + /// Launch the server and Subnautica (for each given player name) if the --instantlaunch argument is present. + /// + [Conditional("DEBUG")] + private void HandleInstantLaunchForDevelopment() + { + Task.Run(async () => + { + string[] launchArgs = Environment.GetCommandLineArgs(); + for (int i = 0; i < launchArgs.Length; i++) + { + if (!launchArgs[i].Equals("--instantlaunch", StringComparison.OrdinalIgnoreCase) || launchArgs.Length <= i + 1) + { + continue; + } + List playerNames = []; + for (int j = i + 2; j < launchArgs.Length; j++) + { + if (launchArgs[j].StartsWith("--", StringComparison.OrdinalIgnoreCase)) + { + break; + } + playerNames.Add(launchArgs[j]); + } + if (playerNames is []) + { + string error = "--instantlaunch requires at least one player name"; + Log.Error(error); + LauncherNotifier.Error(error); + return; + } + + // Start the server + string serverName = launchArgs[i + 1]; + string serverPath = Path.Combine(keyValueStore.GetSavesFolderDir(), serverName); + ServerEntry server = ServerEntry.FromDirectory(serverPath); + server.Name = serverName; + Task serverStartTask = serversViewModel.StartServerAsync(server).ContinueWithHandleError(); + // Start a game in multiplayer for each player + foreach (string playerName in playerNames) + { + await StartMultiplayerAsync(["--instantlaunch", playerName]).ContinueWithHandleError(); + } + + await serverStartTask; + } + }); + } + + private async Task StartSubnauticaAsync(string[] args = null) + { + string subnauticaPath = NitroxUser.GamePath; + string subnauticaLaunchArguments = $"{SubnauticaLaunchArguments} {string.Join(" ", args ?? Environment.GetCommandLineArgs())}"; + string subnauticaExe; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + subnauticaExe = Path.Combine(subnauticaPath, "MacOS", GameInfo.Subnautica.ExeName); + } + else + { + subnauticaExe = Path.Combine(subnauticaPath, GameInfo.Subnautica.ExeName); + } + + if (!File.Exists(subnauticaExe)) + { + throw new FileNotFoundException("Unable to find Subnautica executable"); + } + + IGamePlatform platform = GamePlatforms.GetPlatformByGameDir(subnauticaPath); + + // Start game & gaming platform if needed. + using ProcessEx game = platform switch + { + Steam s => await s.StartGameAsync(subnauticaExe, GameInfo.Subnautica.SteamAppId, subnauticaLaunchArguments), + EpicGames e => await e.StartGameAsync(subnauticaExe, subnauticaLaunchArguments), + MSStore m => await m.StartGameAsync(subnauticaExe), + _ => throw new Exception($"Directory '{subnauticaPath}' is not a valid {GameInfo.Subnautica.Name} game installation or the game's platform is unsupported by Nitrox.") + }; + + if (game is null) + { + throw new Exception($"Game failed to start through {platform.Name}"); + } + } + + private void UpdateGamePlatform() + { + GamePlatform = NitroxUser.GamePlatform?.Platform ?? Platform.NONE; + PlatformToolTip = GamePlatform.GetAttribute()?.Description ?? "Unknown"; + } +} diff --git a/Nitrox.Launcher/ViewModels/LibraryViewModel.cs b/Nitrox.Launcher/ViewModels/LibraryViewModel.cs new file mode 100644 index 0000000000..b43bd68071 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/LibraryViewModel.cs @@ -0,0 +1,11 @@ +using Nitrox.Launcher.ViewModels.Abstract; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +public partial class LibraryViewModel : RoutableViewModelBase +{ + public LibraryViewModel(IScreen screen) : base(screen) + { + } +} \ No newline at end of file diff --git a/Nitrox.Launcher/ViewModels/MainWindowViewModel.cs b/Nitrox.Launcher/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000000..d7b43dd281 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows.Input; +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Nitrox.Launcher.Models; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.Models.Utils; +using Nitrox.Launcher.ViewModels.Abstract; +using NitroxModel.Helper; +using NitroxModel.Logger; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + private readonly BlogViewModel blogViewModel; + private readonly CommunityViewModel communityViewModel; + private readonly LaunchGameViewModel launchGameViewModel; + private readonly OptionsViewModel optionsViewModel; + private readonly IScreen screen; + private readonly ServersViewModel serversViewModel; + private readonly UpdatesViewModel updatesViewModel; + + [ObservableProperty] + private string maximizeButtonIcon = "/Assets/Images/material-design-icons/max.png"; + + [ObservableProperty] + private bool updateAvailableOrUnofficial; + + public ICommand DefaultViewCommand { get; } + + public AvaloniaList Notifications { get; init; } + + public RoutingState Router => screen.Router; + + public MainWindowViewModel( + IScreen screen, + ServersViewModel serversViewModel, + LaunchGameViewModel launchGameViewModel, + CommunityViewModel communityViewModel, + BlogViewModel blogViewModel, + UpdatesViewModel updatesViewModel, + OptionsViewModel optionsViewModel, + IList notifications = null + ) + { + this.screen = screen; + this.launchGameViewModel = launchGameViewModel; + this.serversViewModel = serversViewModel; + this.communityViewModel = communityViewModel; + this.blogViewModel = blogViewModel; + this.updatesViewModel = updatesViewModel; + this.optionsViewModel = optionsViewModel; + + DefaultViewCommand = OpenLaunchGameViewCommand; + Notifications = notifications == null ? [] : [.. notifications]; + + + WeakReferenceMessenger.Default.Register(this, (_, message) => + { + Notifications.Add(message.Item); + Task.Run(async () => + { + await Task.Delay(7000); + WeakReferenceMessenger.Default.Send(new NotificationCloseMessage(message.Item)); + }); + }); + WeakReferenceMessenger.Default.Register(this, async (_, message) => + { + message.Item.Dismissed = true; + await Task.Delay(1000); // Wait for animations + if (!Design.IsDesignMode) // Prevent design preview crashes + { + Notifications.Remove(message.Item); + } + }); + + if (!NitroxEnvironment.IsReleaseMode) + { + LauncherNotifier.Info("You're now using Nitrox DEV build"); + } + + Task.Run(async () => + { + if (!await NetHelper.HasInternetConnectivityAsync()) + { + Log.Warn("Launcher may not be connected to internet"); + LauncherNotifier.Warning("Launcher may not be connected to internet"); + } + UpdateAvailableOrUnofficial = await UpdatesViewModel.IsNitroxUpdateAvailableAsync(); + }); + } + + [RelayCommand] + public void OpenLaunchGameView() + { + screen.Show(launchGameViewModel); + } + + [RelayCommand] + public void OpenServersView() + { + screen.Show(serversViewModel); + } + + [RelayCommand] + public void OpenCommunityView() + { + screen.Show(communityViewModel); + } + + [RelayCommand] + public void OpenBlogView() + { + screen.Show(blogViewModel); + } + + [RelayCommand] + public void OpenUpdatesView() + { + screen.Show(updatesViewModel); + } + + [RelayCommand] + public void OpenOptionsView() + { + screen.Show(optionsViewModel); + } + + [RelayCommand] + public void Minimize() + { + MainWindow.WindowState = WindowState.Minimized; + } + + [RelayCommand] + public void Close() + { + MainWindow.Close(); + } + + [RelayCommand] + public void Maximize() + { + if (MainWindow.WindowState == WindowState.Normal) + { + MainWindow.WindowState = WindowState.Maximized; + MaximizeButtonIcon = "/Assets/Images/material-design-icons/restore.png"; + } + else + { + MainWindow.WindowState = WindowState.Normal; + MaximizeButtonIcon = "/Assets/Images/material-design-icons/max.png"; + } + } + + [RelayCommand] + public void Drag(PointerPressedEventArgs args) + { + ArgumentNullException.ThrowIfNull(args); + + if (args.Source is Visual element && element.GetWindow() is { } window) + { + window.BeginMoveDrag(args); + } + } +} diff --git a/Nitrox.Launcher/ViewModels/ManageServerViewModel.cs b/Nitrox.Launcher/ViewModels/ManageServerViewModel.cs new file mode 100644 index 0000000000..3dcd9b4fa4 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/ManageServerViewModel.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using HanumanInstitute.MvvmDialogs; +using Nitrox.Launcher.Models; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.Models.Utils; +using Nitrox.Launcher.Models.Validators; +using Nitrox.Launcher.ViewModels.Abstract; +using Nitrox.Launcher.Views; +using NitroxModel.DataStructures.GameLogic; +using NitroxModel.Helper; +using NitroxModel.Logger; +using NitroxModel.Server; +using ReactiveUI; +using Config = NitroxModel.Serialization.SubnauticaServerConfig; + +namespace Nitrox.Launcher.ViewModels; + +public partial class ManageServerViewModel : RoutableViewModelBase +{ + private readonly string[] advancedSettingsDeniedFields = + [ + "password", "filename", nameof(Config.ServerPort), nameof(Config.MaxConnections), nameof(Config.AutoPortForward), nameof(Config.SaveInterval), nameof(Config.Seed), nameof(Config.GameMode), nameof(Config.DisableConsole), + nameof(Config.LANDiscoveryEnabled), nameof(Config.DefaultPlayerPerm) + ]; + + private readonly IDialogService dialogService; + private readonly IKeyValueStore keyValueStore; + private ServerEntry server; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + private bool serverAllowCommands; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + private bool serverAllowLanDiscovery; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + private bool serverAutoPortForward; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + [NotifyDataErrorInfo] + [Range(10, 86400, ErrorMessage = "Value must be between 10s and 24 hours (86400s).")] + private int serverAutoSaveInterval; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + private Perms serverDefaultPlayerPerm; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + private NitroxGameMode serverGameMode; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + private Bitmap serverIcon; + + private string serverIconDir; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + [Range(1, 1000)] + [NotifyDataErrorInfo] + private int serverMaxPlayers; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + [NotifyDataErrorInfo] + [Required] + [FileName] + [NotEndsWith(".")] + [NitroxUniqueSaveName(nameof(SavesFolderDir), true, nameof(OriginalServerName))] + private string serverName; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + private string serverPassword; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + private int serverPlayers; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + [NotifyDataErrorInfo] + [Range(ushort.MinValue, ushort.MaxValue)] + private int serverPort; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(UndoCommand), nameof(BackCommand), nameof(StartServerCommand))] + [NotifyDataErrorInfo] + [NitroxWorldSeed] + private string serverSeed; + + public static Array PlayerPerms => Enum.GetValues(typeof(Perms)); + public string OriginalServerName => Server?.Name; + + /// + /// When set, navigates to the . + /// + public ServerEntry Server + { + get => server; + private set + { + if (server != null) + { + server.PropertyChanged -= Server_PropertyChanged; + } + SetProperty(ref server, value); + if (server != null) + { + server.PropertyChanged += Server_PropertyChanged; + } + } + } + + private bool ServerIsOnline => Server.IsOnline; + + private string SaveFolderDirectory => Path.Combine(SavesFolderDir, Server.Name); + private string SavesFolderDir => keyValueStore.GetSavesFolderDir(); + + public ManageServerViewModel(IScreen screen, IDialogService dialogService, IKeyValueStore keyValueStore) : base(screen) + { + this.dialogService = dialogService; + this.keyValueStore = keyValueStore; + } + + [RelayCommand(CanExecute = nameof(CanGoBackAndStartServer))] + public async Task StartServer() + { + if (await GameInspect.IsOutdatedGameAndNotify(NitroxUser.GamePath, dialogService)) + { + return; + } + + try + { + server.Start(keyValueStore.GetSavesFolderDir()); + server.Version = NitroxEnvironment.Version; + } + catch (Exception ex) + { + Log.Error(ex, $"Error while starting server \"{server.Name}\""); + await Dispatcher.UIThread.InvokeAsync(async () => await dialogService.ShowErrorAsync(ex, $"Error while starting server \"{server.Name}\"")); + } + } + + [RelayCommand] + public async Task StopServerAsync() + { + if (!await Server.StopAsync()) + { + return false; + } + + return true; + } + + public void LoadFrom(ServerEntry serverEntry) + { + Server = serverEntry; + + ServerName = Server.Name; + ServerIcon = Server.ServerIcon; + ServerPassword = Server.Password; + ServerGameMode = Server.GameMode; + ServerSeed = Server.Seed; + ServerDefaultPlayerPerm = Server.PlayerPermissions; + ServerAutoSaveInterval = Server.AutoSaveInterval; + ServerMaxPlayers = Server.MaxPlayers; + ServerPlayers = Server.Players; + ServerPort = Server.Port; + ServerAutoPortForward = Server.AutoPortForward; + ServerAllowLanDiscovery = Server.AllowLanDiscovery; + ServerAllowCommands = Server.AllowCommands; + } + + private bool HasChanges() => ServerName != Server.Name || + ServerIcon != Server.ServerIcon || + ServerPassword != Server.Password || + ServerGameMode != Server.GameMode || + ServerSeed != Server.Seed || + ServerDefaultPlayerPerm != Server.PlayerPermissions || + ServerAutoSaveInterval != Server.AutoSaveInterval || + ServerMaxPlayers != Server.MaxPlayers || + ServerPlayers != Server.Players || + ServerPort != Server.Port || + ServerAutoPortForward != Server.AutoPortForward || + ServerAllowLanDiscovery != Server.AllowLanDiscovery || + ServerAllowCommands != Server.AllowCommands; + + [RelayCommand(CanExecute = nameof(CanGoBackAndStartServer))] + private void Back() => HostScreen.Back(); + + private bool CanGoBackAndStartServer() => !HasChanges(); + + [RelayCommand(CanExecute = nameof(CanSave))] + private void Save() + { + // If world name was changed, rename save folder to match it + string newDir = Path.Combine(SavesFolderDir, ServerName); + if (SaveFolderDirectory != newDir) + { + // Windows, by default, ignores case when renaming folders. We circumvent this by changing the name to a random one, and then to the desired name. + DirectoryInfo temp = Directory.CreateTempSubdirectory(); + temp.Delete(); + Directory.Move(SaveFolderDirectory, temp.FullName); + Directory.Move(temp.FullName, newDir); + } + + // Update the servericon.png file if needed + if (Server.ServerIcon != ServerIcon && serverIconDir != null) + { + File.Copy(serverIconDir, Path.Combine(newDir, "servericon.png"), true); + } + + Server.Name = ServerName; + Server.ServerIcon = ServerIcon; + Server.Password = ServerPassword; + Server.GameMode = ServerGameMode; + Server.Seed = ServerSeed; + Server.PlayerPermissions = ServerDefaultPlayerPerm; + Server.AutoSaveInterval = ServerAutoSaveInterval; + Server.MaxPlayers = ServerMaxPlayers; + Server.Players = ServerPlayers; + Server.Port = ServerPort; + Server.AutoPortForward = ServerAutoPortForward; + Server.AllowLanDiscovery = ServerAllowLanDiscovery; + Server.AllowCommands = ServerAllowCommands; + + Config config = Config.Load(SaveFolderDirectory); + using (config.Update(SaveFolderDirectory)) + { + config.ServerPassword = Server.Password; + if (Server.IsNewServer) { config.Seed = Server.Seed; } + config.GameMode = Server.GameMode; + config.DefaultPlayerPerm = Server.PlayerPermissions; + config.SaveInterval = Server.AutoSaveInterval * 1000; // Convert seconds to milliseconds + config.MaxConnections = Server.MaxPlayers; + config.ServerPort = Server.Port; + config.AutoPortForward = Server.AutoPortForward; + config.LANDiscoveryEnabled = Server.AllowLanDiscovery; + config.DisableConsole = !Server.AllowCommands; + } + + Undo(); // Used to update the UI with corrected values (Trims and ToUppers) + + BackCommand.NotifyCanExecuteChanged(); + StartServerCommand.NotifyCanExecuteChanged(); + UndoCommand.NotifyCanExecuteChanged(); + SaveCommand.NotifyCanExecuteChanged(); + } + + private bool CanSave() => !HasErrors && !ServerIsOnline && HasChanges(); + + [RelayCommand(CanExecute = nameof(CanUndo))] + private void Undo() + { + ServerName = Server.Name; + ServerIcon = Server.ServerIcon; + ServerPassword = Server.Password; + ServerGameMode = Server.GameMode; + ServerSeed = Server.Seed; + ServerDefaultPlayerPerm = Server.PlayerPermissions; + ServerAutoSaveInterval = Server.AutoSaveInterval; + ServerMaxPlayers = Server.MaxPlayers; + ServerPlayers = Server.Players; + ServerPort = Server.Port; + ServerAutoPortForward = Server.AutoPortForward; + ServerAllowLanDiscovery = Server.AllowLanDiscovery; + ServerAllowCommands = Server.AllowCommands; + } + + private bool CanUndo() => !ServerIsOnline && HasChanges(); + + [RelayCommand] + private async Task ChangeServerIconAsync() + { + try + { + IReadOnlyList files = await MainWindow.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Select an image", + AllowMultiple = false, + FileTypeFilter = new[] + { + new FilePickerFileType("All Images + Icons") + { + Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp", "*.ico" }, + AppleUniformTypeIdentifiers = new[] { "public.image" }, + MimeTypes = new[] { "image/*" } + } + } + }); + string newIconFile = files.FirstOrDefault()?.TryGetLocalPath(); + if (newIconFile == null || !File.Exists(newIconFile)) + { + return; + } + + serverIconDir = newIconFile; + ServerIcon = new Bitmap(serverIconDir); + } + catch (Exception ex) + { + Log.Error(ex); + } + } + + [RelayCommand] + private async Task ShowAdvancedSettings() + { + ObjectPropertyEditorViewModel result = await dialogService.ShowAsync(model => + { + model.Title = $"Server '{ServerName}' config editor"; + model.FieldAcceptFilter = p => !advancedSettingsDeniedFields.Any(v => p.Name.Contains(v, StringComparison.OrdinalIgnoreCase)); + model.OwnerObject = Config.Load(SaveFolderDirectory); + }); + if (result && result.OwnerObject is Config config) + { + config.Serialize(SaveFolderDirectory); + } + LoadFrom(server); + } + + [RelayCommand] + private void OpenWorldFolder() => + Process.Start(new ProcessStartInfo + { + FileName = SaveFolderDirectory, + Verb = "open", + UseShellExecute = true + })?.Dispose(); + + [RelayCommand(CanExecute = nameof(CanRestoreBackupAndDeleteServer))] + private async Task RestoreBackup() + { + BackupRestoreViewModel result = await dialogService.ShowAsync(model => + { + model.Title = $"Restore a Backup for '{ServerName}'"; + model.SaveFolderDirectory = SaveFolderDirectory; + }); + + if (result) + { + string backupFile = result.SelectedBackup.BackupFileName; + try + { + if (!File.Exists(backupFile)) + { + throw new FileNotFoundException("Selected backup file not found.", backupFile); + } + + ZipFile.ExtractToDirectory(backupFile, SaveFolderDirectory, true); + server.RefreshFromDirectory(SaveFolderDirectory); + LoadFrom(server); + LauncherNotifier.Success("Backup restored successfully."); + } + catch (Exception ex) + { + await dialogService.ShowErrorAsync(ex, "Error while restoring backup"); + } + } + } + + [RelayCommand(CanExecute = nameof(CanRestoreBackupAndDeleteServer))] + private async Task DeleteServer() + { + DialogBoxViewModel modalViewModel = await dialogService.ShowAsync(model => + { + model.Description = $"Are you sure you want to delete the server '{ServerName}'?"; + model.DescriptionFontSize = 24; + model.DescriptionFontWeight = FontWeight.Bold; + model.ButtonOptions = ButtonOptions.YesNo; + }); + if (!modalViewModel) + { + return; + } + + try + { + Directory.Delete(SaveFolderDirectory, true); + WeakReferenceMessenger.Default.Send(new SaveDeletedMessage(ServerName)); + HostScreen.Back(); + } + catch (Exception ex) + { + await dialogService.ShowErrorAsync(ex, $"Error while deleting world \"{ServerName}\""); + } + } + + private bool CanRestoreBackupAndDeleteServer() => !ServerIsOnline; + + private void Server_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ServerEntry.IsOnline)) + { + OnPropertyChanged(nameof(ServerIsOnline)); + RestoreBackupCommand.NotifyCanExecuteChanged(); + DeleteServerCommand.NotifyCanExecuteChanged(); + } + } +} diff --git a/Nitrox.Launcher/ViewModels/ObjectPropertyEditorViewModel.cs b/Nitrox.Launcher/ViewModels/ObjectPropertyEditorViewModel.cs new file mode 100644 index 0000000000..08794d76ef --- /dev/null +++ b/Nitrox.Launcher/ViewModels/ObjectPropertyEditorViewModel.cs @@ -0,0 +1,83 @@ +using System; +using System.Linq; +using System.Reactive.Disposables; +using System.Reflection; +using System.Threading.Tasks; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using HanumanInstitute.MvvmDialogs; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.ViewModels.Abstract; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +public partial class ObjectPropertyEditorViewModel : ModalViewModelBase +{ + private readonly IDialogService dialogService; + + [ObservableProperty] + private AvaloniaList editorFields = []; + + [ObservableProperty] + private object ownerObject; + + private string title; + + public string Title + { + get => title ?? $"{OwnerObject.GetType().Name} editor"; + set => title = value; + } + + /// + /// Gets or sets the field filter to use. If filter returns false, it will omit the field. + /// + public Func FieldAcceptFilter { get; set; } = _ => true; + + + public ObjectPropertyEditorViewModel(IDialogService dialogService) + { + this.dialogService = dialogService; + this.WhenAnyValue(model => model.OwnerObject) + .Subscribe(owner => + { + EditorFields.Clear(); + if (owner != null) + { + EditorFields.AddRange(owner + .GetType() + .GetProperties() + .Where(FieldAcceptFilter) + .Select(p => new EditorField(p, p.GetValue(owner), GetPossibleValues(p))) + .Where(editorField => editorField.Value is string or bool or int or float || editorField.PossibleValues != null)); + } + }) + .DisposeWith(Disposables); + } + + [RelayCommand(CanExecute = nameof(CanSave))] + public async Task Save() + { + foreach (EditorField field in EditorFields) + { + try + { + field.PropertyInfo.SetValue(OwnerObject, Convert.ChangeType(field.Value, field.PropertyInfo.PropertyType)); + } + catch (Exception ex) + { + await dialogService.ShowErrorAsync(ex, description: field.ToString()); + } + } + Close(ButtonOptions.Ok); + } + + public bool CanSave() => !HasErrors; + + private static AvaloniaList GetPossibleValues(PropertyInfo propertyInfo) + { + return propertyInfo.PropertyType.IsEnum ? new AvaloniaList(propertyInfo.PropertyType.GetFields(BindingFlags.Static | BindingFlags.Public).Select(f => f.GetValue(propertyInfo.PropertyType))) : null; + } +} diff --git a/Nitrox.Launcher/ViewModels/OptionsViewModel.cs b/Nitrox.Launcher/ViewModels/OptionsViewModel.cs new file mode 100644 index 0000000000..a02f26f054 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/OptionsViewModel.cs @@ -0,0 +1,153 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Input; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nitrox.Launcher.Models.Patching; +using Nitrox.Launcher.Models.Utils; +using Nitrox.Launcher.ViewModels.Abstract; +using NitroxModel.Discovery; +using NitroxModel.Discovery.Models; +using NitroxModel.Helper; +using NitroxModel.Platforms.OS.Shared; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +public partial class OptionsViewModel : RoutableViewModelBase +{ + private readonly IKeyValueStore keyValueStore; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SetArgumentsCommand))] + private string launchArgs; + + [ObservableProperty] + private string savesFolderDir; + + [ObservableProperty] + private KnownGame selectedGame; + + [ObservableProperty] + private bool showResetArgsBtn; + + private static string DefaultLaunchArg => "-vrmode none"; + + public OptionsViewModel(IScreen screen, IKeyValueStore keyValueStore) : base(screen) + { + this.keyValueStore = keyValueStore; + + SelectedGame = new() { PathToGame = NitroxUser.GamePath, Platform = NitroxUser.GamePlatform?.Platform ?? Platform.NONE }; + LaunchArgs = keyValueStore.GetSubnauticaLaunchArguments(DefaultLaunchArg); + SavesFolderDir = keyValueStore.GetSavesFolderDir(); + + _ = SetTargetedSubnauticaPath(SelectedGame.PathToGame).ContinueWithHandleError(ex => LauncherNotifier.Error(ex.Message)); + } + + public async Task SetTargetedSubnauticaPath(string path) + { + if (!Directory.Exists(path)) + { + return; + } + + NitroxUser.GamePath = path; + if (LaunchGameViewModel.LastFindSubnauticaTask != null) + { + await LaunchGameViewModel.LastFindSubnauticaTask; + } + + LaunchGameViewModel.LastFindSubnauticaTask = Task.Factory.StartNew(() => + { + PirateDetection.TriggerOnDirectory(path); + + if (!FileSystem.Instance.IsWritable(Directory.GetCurrentDirectory()) || !FileSystem.Instance.IsWritable(path)) + { + // TODO: Move this check to another place where Nitrox installation can be verified. (i.e: another page on the launcher in order to check permissions, network setup, ...) + if (!FileSystem.Instance.SetFullAccessToCurrentUser(Directory.GetCurrentDirectory()) || !FileSystem.Instance.SetFullAccessToCurrentUser(path)) + { + LauncherNotifier.Error("Restart Nitrox Launcher as admin to allow Nitrox to change permissions as needed. This is only needed once. Nitrox will close after this message."); + return null; + } + } + + // Save game path as preferred for future sessions. + NitroxUser.PreferredGamePath = path; + if (NitroxEntryPatch.IsPatchApplied(NitroxUser.GamePath)) + { + NitroxEntryPatch.Remove(NitroxUser.GamePath); + } + + return path; + }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()); + + await LaunchGameViewModel.LastFindSubnauticaTask; + } + + [RelayCommand] + private async Task SetGamePath() + { + string selectedDirectory = await MainWindow.StorageProvider.OpenFolderPickerAsync("Select Subnautica installation directory", SelectedGame.PathToGame); + if (selectedDirectory == "") + { + return; + } + + if (!GameInstallationHelper.HasGameExecutable(selectedDirectory, GameInfo.Subnautica)) + { + LauncherNotifier.Error("Invalid subnautica directory"); + return; + } + + if (!selectedDirectory.Equals(SelectedGame.PathToGame, StringComparison.OrdinalIgnoreCase)) + { + await SetTargetedSubnauticaPath(selectedDirectory); + SelectedGame = new() { PathToGame = NitroxUser.GamePath, Platform = NitroxUser.GamePlatform?.Platform ?? Platform.NONE }; + LauncherNotifier.Success("Applied changes"); + } + } + + [RelayCommand] + private void ResetArguments(IInputElement focusTargetAfterReset = null) + { + LaunchArgs = DefaultLaunchArg; + ShowResetArgsBtn = false; + SetArgumentsCommand.NotifyCanExecuteChanged(); + focusTargetAfterReset?.Focus(); + } + + [RelayCommand(CanExecute = nameof(CanSetArguments))] + private void SetArguments() + { + keyValueStore.SetSubnauticaLaunchArguments(LaunchArgs); + SetArgumentsCommand.NotifyCanExecuteChanged(); + } + + private bool CanSetArguments() + { + ShowResetArgsBtn = LaunchArgs != DefaultLaunchArg; + + return LaunchArgs != keyValueStore.GetSubnauticaLaunchArguments(DefaultLaunchArg); + } + + [RelayCommand] + private void OpenSavesFolder() + { + Process.Start(new ProcessStartInfo + { + FileName = SavesFolderDir, + Verb = "open", + UseShellExecute = true + })?.Dispose(); + } + + public class KnownGame + { + public string PathToGame { get; init; } + public Platform Platform { get; init; } + } +} diff --git a/Nitrox.Launcher/ViewModels/ServersViewModel.cs b/Nitrox.Launcher/ViewModels/ServersViewModel.cs new file mode 100644 index 0000000000..32fc3b36f1 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/ServersViewModel.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reactive.Disposables; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using HanumanInstitute.MvvmDialogs; +using Nitrox.Launcher.Models; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.Models.Utils; +using Nitrox.Launcher.ViewModels.Abstract; +using NitroxModel.Helper; +using NitroxModel.Logger; +using NitroxModel.Server; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +public partial class ServersViewModel : RoutableViewModelBase +{ + private readonly IKeyValueStore keyValueStore; + private readonly IDialogService dialogService; + private readonly ManageServerViewModel manageServerViewModel; + private CancellationTokenSource serverRefreshCts; + + [ObservableProperty] + private AvaloniaList servers = []; + + private bool shouldRefreshServersList; + + private FileSystemWatcher watcher; + + private readonly HashSet loggedErrorDirectories = []; + + public ServersViewModel(IScreen screen, IKeyValueStore keyValueStore, IDialogService dialogService, ManageServerViewModel manageServerViewModel) : base(screen) + { + this.keyValueStore = keyValueStore; + this.dialogService = dialogService; + this.manageServerViewModel = manageServerViewModel; + + if (!Design.IsDesignMode) + { + Task.Run(GetSavesOnDisk); + } + + WeakReferenceMessenger.Default.Register(this, (sender, message) => + { + if (message.PropertyName == nameof(ServerEntry.IsOnline)) + { + ManageServerCommand.NotifyCanExecuteChanged(); + } + }); + WeakReferenceMessenger.Default.Register(this, (sender, message) => + { + for (int i = Servers.Count - 1; i >= 0; i--) + { + if (Servers[i].Name == message.SaveName) + { + Servers.RemoveAt(i); + } + } + }); + + this.WhenActivated(disposables => + { + // Activation + serverRefreshCts = new(); + GetSavesOnDisk(); + InitializeWatcher(); + + // Deactivation + Disposable + .Create(this, vm => + { + vm.watcher?.Dispose(); + vm.serverRefreshCts.Cancel(); + }) + .DisposeWith(disposables); + }); + } + + [RelayCommand] + public async Task CreateServer(IInputElement focusTargetOnClose = null) + { + CreateServerViewModel result = await dialogService.ShowAsync(); + if (result == null) + { + Dispatcher.UIThread.Post(() => focusTargetOnClose?.Focus()); + return; + } + + Dispatcher.UIThread.Post(() => focusTargetOnClose?.Focus()); + AddServer(result.Name, result.SelectedGameMode); + } + + [RelayCommand] + public async Task StartServerAsync(ServerEntry server) + { + if (server.Version != NitroxEnvironment.Version && !await ConfirmServerVersionAsync(server)) // TODO: Exclude upgradeable versions + add separate prompt to upgrade first? + { + return false; + } + if (await GameInspect.IsOutdatedGameAndNotify(NitroxUser.GamePath, dialogService)) + { + return false; + } + + try + { + server.Start(keyValueStore.GetSavesFolderDir()); + server.Version = NitroxEnvironment.Version; + return true; + } + catch (Exception ex) + { + Log.Error(ex, $"Error while starting server \"{server.Name}\""); + await Dispatcher.UIThread.InvokeAsync(async () => await dialogService.ShowErrorAsync(ex, $"Error while starting server \"{server.Name}\"")); + return false; + } + } + + [RelayCommand] + public async Task ManageServer(ServerEntry server) + { + if (server.Version != NitroxEnvironment.Version && !await ConfirmServerVersionAsync(server)) // TODO: Exclude upgradeable versions + add separate prompt to upgrade first? + { + return; + } + + manageServerViewModel.LoadFrom(server); + HostScreen.Show(manageServerViewModel); + } + + public void GetSavesOnDisk() + { + try + { + Directory.CreateDirectory(keyValueStore.GetSavesFolderDir()); + + List serversOnDisk = []; + foreach (string saveDir in Directory.EnumerateDirectories(keyValueStore.GetSavesFolderDir())) + { + try + { + ServerEntry entryFromDir = ServerEntry.FromDirectory(saveDir); + if (entryFromDir != null) + { + serversOnDisk.Add(entryFromDir); + } + loggedErrorDirectories.Remove(saveDir); + } + catch (Exception ex) + { + if (loggedErrorDirectories.Add(saveDir)) // Only log once per directory to prevent log spam + { + Log.Error(ex, $"Error while initializing save from directory \"{saveDir}\". Skipping..."); + } + } + } + + // Remove any servers from the Servers list that are not found in the saves folder + for (int i = Servers.Count - 1; i >= 0; i--) + { + if (serversOnDisk.All(s => s.Name != Servers[i].Name)) + { + Servers.RemoveAt(i); + } + } + + // Add any new servers found on the disk to the Servers list + foreach (ServerEntry server in serversOnDisk) + { + if (Servers.All(s => s.Name != server.Name) && !string.IsNullOrWhiteSpace(server.Name)) + { + Servers.Add(server); + } + } + + Servers = [..Servers.OrderByDescending(entry => entry.LastAccessedTime)]; + } + catch (Exception ex) + { + Log.Error(ex, "Error while getting saves"); + dialogService.ShowErrorAsync(ex, "Error while getting saves"); + } + } + + private async Task ConfirmServerVersionAsync(ServerEntry server) + { + return await dialogService.ShowAsync(model => + { + model.Description = $"The version of '{server.Name}' is v{(server.Version != null ? server.Version.ToString() : "X.X.X.X")}. It is highly recommended to NOT use this save file with Nitrox v{NitroxEnvironment.Version}. Would you still like to continue?"; + model.DescriptionFontSize = 24; + model.DescriptionFontWeight = FontWeight.Bold; + model.ButtonOptions = ButtonOptions.YesNo; + }); + } + + public void AddServer(string name, NitroxGameMode gameMode) + { + Servers.Insert(0, new ServerEntry + { + Name = name, + GameMode = gameMode, + Seed = "", + Version = NitroxEnvironment.Version + }); + } + + private void InitializeWatcher() + { + watcher = new FileSystemWatcher + { + Path = keyValueStore.GetSavesFolderDir(), + NotifyFilter = NotifyFilters.DirectoryName | NotifyFilters.LastWrite | NotifyFilters.Size, + Filter = "*.*", + IncludeSubdirectories = true + }; + watcher.Changed += OnDirectoryChanged; + watcher.Created += OnDirectoryChanged; + watcher.Deleted += OnDirectoryChanged; + watcher.Renamed += OnDirectoryChanged; + + Task.Run(async () => + { + watcher.EnableRaisingEvents = true; // Slowish (~2ms) - Moved into Task.Run. + + while (!serverRefreshCts.IsCancellationRequested) + { + while (shouldRefreshServersList) + { + try + { + GetSavesOnDisk(); + shouldRefreshServersList = false; + } + catch (IOException) + { + await Task.Delay(500); + } + } + await Task.Delay(1000); + } + }).ContinueWith(t => + { + if (t.IsFaulted) + { + LauncherNotifier.Error(t.Exception.Message); + } + }); + } + + private void OnDirectoryChanged(object sender, FileSystemEventArgs e) + { + shouldRefreshServersList = true; + } +} diff --git a/Nitrox.Launcher/ViewModels/UpdatesViewModel.cs b/Nitrox.Launcher/ViewModels/UpdatesViewModel.cs new file mode 100644 index 0000000000..9ca8d5d541 --- /dev/null +++ b/Nitrox.Launcher/ViewModels/UpdatesViewModel.cs @@ -0,0 +1,96 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nitrox.Launcher.Models.Design; +using Nitrox.Launcher.Models.Utils; +using Nitrox.Launcher.ViewModels.Abstract; +using NitroxModel.Helper; +using NitroxModel.Logger; +using ReactiveUI; + +namespace Nitrox.Launcher.ViewModels; + +public partial class UpdatesViewModel : RoutableViewModelBase +{ + [ObservableProperty] + private static bool newUpdateAvailable; + + [ObservableProperty] + private static bool usingOfficialVersion; + + [ObservableProperty] + private static string version; + + [ObservableProperty] + private static string officialVersion; + + [ObservableProperty] + private AvaloniaList nitroxChangelogs = []; + + public UpdatesViewModel(IScreen screen) : base(screen) + { + Task.Run(async () => + { + try + { + nitroxChangelogs.AddRange(await Downloader.GetChangeLogs()); + } + catch (Exception ex) + { + Log.Error(ex, "Error while trying to display Nitrox changelogs"); + } + }); + } + + public static async Task IsNitroxUpdateAvailableAsync() + { + try + { + Version currentVersion = NitroxEnvironment.Version; + Version latestVersion = await Downloader.GetNitroxLatestVersion(); + + newUpdateAvailable = latestVersion > currentVersion; +#if DEBUG + usingOfficialVersion = false; +#else + usingOfficialVersion = latestVersion >= currentVersion; +#endif + + if (newUpdateAvailable) + { + string versionMessage = $"A new version of the mod ({latestVersion}) is available."; + Log.Info(versionMessage); + LauncherNotifier.Warning(versionMessage); //, new ToastNotifications.Core.MessageOptions() // TODO: Implement this? + //{ + // NotificationClickAction = (n) => + // { + // MainViewModel.Router.Navigate.Execute(AppViewLocator.GetSharedViewModel< UpdatesViewModel>();); + // }, + //}); + } + + version = currentVersion.ToString(); + officialVersion = latestVersion.ToString(); + } + catch // If update check fails, just show "No Update Available" text unless on debug mode + { + newUpdateAvailable = false; +#if DEBUG + usingOfficialVersion = false; +#else + usingOfficialVersion = true; +#endif + } + + return newUpdateAvailable || !usingOfficialVersion; + } + + [RelayCommand] + private void DownloadUpdate() + { + Process.Start(new ProcessStartInfo("https://nitrox.rux.gg/download") { UseShellExecute = true, Verb = "open" })?.Dispose(); + } +} diff --git a/Nitrox.Launcher/Views/Abstract/ModalBase.cs b/Nitrox.Launcher/Views/Abstract/ModalBase.cs new file mode 100644 index 0000000000..04be535127 --- /dev/null +++ b/Nitrox.Launcher/Views/Abstract/ModalBase.cs @@ -0,0 +1,14 @@ +using System.Runtime.InteropServices; +using Avalonia.Controls; + +namespace Nitrox.Launcher.Views.Abstract; + +public abstract class ModalBase : Window +{ + protected ModalBase() + { + SystemDecorations = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? SystemDecorations.Full : SystemDecorations.None; + } + + protected override void OnInitialized() => this.ApplyOsWindowStyling(); +} diff --git a/Nitrox.Launcher/Views/Abstract/RoutableViewBase.cs b/Nitrox.Launcher/Views/Abstract/RoutableViewBase.cs new file mode 100644 index 0000000000..48541e25c4 --- /dev/null +++ b/Nitrox.Launcher/Views/Abstract/RoutableViewBase.cs @@ -0,0 +1,9 @@ +using Avalonia.ReactiveUI; +using Nitrox.Launcher.ViewModels.Abstract; + +namespace Nitrox.Launcher.Views.Abstract; + +public abstract class RoutableViewBase : ReactiveUserControl + where TViewModel : RoutableViewModelBase +{ +} diff --git a/Nitrox.Launcher/Views/Abstract/WindowBase.cs b/Nitrox.Launcher/Views/Abstract/WindowBase.cs new file mode 100644 index 0000000000..33800fe241 --- /dev/null +++ b/Nitrox.Launcher/Views/Abstract/WindowBase.cs @@ -0,0 +1,15 @@ +using System.Runtime.InteropServices; +using Avalonia.Controls; +using Avalonia.ReactiveUI; + +namespace Nitrox.Launcher.Views.Abstract; + +public abstract class WindowBase : ReactiveWindow where TViewModal : class +{ + protected WindowBase() + { + SystemDecorations = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? SystemDecorations.Full : SystemDecorations.None; + } + + protected override void OnInitialized() => this.ApplyOsWindowStyling(); +} diff --git a/Nitrox.Launcher/Views/BackupRestoreModal.axaml b/Nitrox.Launcher/Views/BackupRestoreModal.axaml new file mode 100644 index 0000000000..3b46898635 --- /dev/null +++ b/Nitrox.Launcher/Views/BackupRestoreModal.axaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/Views/BlogView.axaml.cs b/Nitrox.Launcher/Views/BlogView.axaml.cs new file mode 100644 index 0000000000..b2ec3f550b --- /dev/null +++ b/Nitrox.Launcher/Views/BlogView.axaml.cs @@ -0,0 +1,12 @@ +using Nitrox.Launcher.ViewModels; +using Nitrox.Launcher.Views.Abstract; + +namespace Nitrox.Launcher.Views; + +public partial class BlogView : RoutableViewBase +{ + public BlogView() + { + InitializeComponent(); + } +} diff --git a/Nitrox.Launcher/Views/CommunityView.axaml b/Nitrox.Launcher/Views/CommunityView.axaml new file mode 100644 index 0000000000..4b6c5067fe --- /dev/null +++ b/Nitrox.Launcher/Views/CommunityView.axaml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Nitrox.Launcher/Views/LaunchGameView.axaml.cs b/Nitrox.Launcher/Views/LaunchGameView.axaml.cs new file mode 100644 index 0000000000..9d37501b41 --- /dev/null +++ b/Nitrox.Launcher/Views/LaunchGameView.axaml.cs @@ -0,0 +1,12 @@ +using Nitrox.Launcher.ViewModels; +using Nitrox.Launcher.Views.Abstract; + +namespace Nitrox.Launcher.Views; + +public partial class LaunchGameView : RoutableViewBase +{ + public LaunchGameView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Nitrox.Launcher/Views/LibraryView.axaml b/Nitrox.Launcher/Views/LibraryView.axaml new file mode 100644 index 0000000000..f969eacb95 --- /dev/null +++ b/Nitrox.Launcher/Views/LibraryView.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/Nitrox.Launcher/Views/LibraryView.axaml.cs b/Nitrox.Launcher/Views/LibraryView.axaml.cs new file mode 100644 index 0000000000..ece40a708b --- /dev/null +++ b/Nitrox.Launcher/Views/LibraryView.axaml.cs @@ -0,0 +1,15 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Nitrox.Launcher.ViewModels; +using Nitrox.Launcher.Views.Abstract; + +namespace Nitrox.Launcher.Views; + +public partial class LibraryView : RoutableViewBase +{ + public LibraryView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Nitrox.Launcher/Views/ManageServerView.axaml b/Nitrox.Launcher/Views/ManageServerView.axaml new file mode 100644 index 0000000000..dfb2120474 --- /dev/null +++ b/Nitrox.Launcher/Views/ManageServerView.axaml @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/Views/ManageServerView.axaml.cs b/Nitrox.Launcher/Views/ManageServerView.axaml.cs new file mode 100644 index 0000000000..989014a726 --- /dev/null +++ b/Nitrox.Launcher/Views/ManageServerView.axaml.cs @@ -0,0 +1,12 @@ +using Nitrox.Launcher.ViewModels; +using Nitrox.Launcher.Views.Abstract; + +namespace Nitrox.Launcher.Views; + +public partial class ManageServerView : RoutableViewBase +{ + public ManageServerView() + { + InitializeComponent(); + } +} diff --git a/Nitrox.Launcher/Views/ObjectPropertyEditorModal.axaml b/Nitrox.Launcher/Views/ObjectPropertyEditorModal.axaml new file mode 100644 index 0000000000..b112cb5c7d --- /dev/null +++ b/Nitrox.Launcher/Views/ObjectPropertyEditorModal.axaml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Welcome to your Subnautica multiplayer server. For additional information and to learn more about hosting a server refer to the [Nitrox Wiki](nitrox.rux.gg/wiki/article/run-and-host-nitrox-subnautica-server) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + It looks like you aren't using an official version of Nitrox. If you are not a developer, please download the official version to ensure that you have the best experience. + + + Your version: + + Official version: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Launcher/Views/UpdatesView.axaml.cs b/Nitrox.Launcher/Views/UpdatesView.axaml.cs new file mode 100644 index 0000000000..13ba196700 --- /dev/null +++ b/Nitrox.Launcher/Views/UpdatesView.axaml.cs @@ -0,0 +1,12 @@ +using Nitrox.Launcher.ViewModels; +using Nitrox.Launcher.Views.Abstract; + +namespace Nitrox.Launcher.Views; + +public partial class UpdatesView : RoutableViewBase +{ + public UpdatesView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Nitrox.Launcher/app.manifest b/Nitrox.Launcher/app.manifest new file mode 100644 index 0000000000..61f2dc6d87 --- /dev/null +++ b/Nitrox.Launcher/app.manifest @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Nitrox.Shared.props b/Nitrox.Shared.props new file mode 100644 index 0000000000..91b3c46413 --- /dev/null +++ b/Nitrox.Shared.props @@ -0,0 +1,27 @@ + + + + + + + <_OSArchitecture>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) + <_IsWindows>$([System.OperatingSystem]::IsWindows()) + <_IsLinux>$([System.OperatingSystem]::IsLinux()) + <_IsMacOS>$([System.OperatingSystem]::IsMacOS()) + + + <_IsRelease>$([System.String]::Equals($(Configuration), 'Release')) + <_IsDebug>$([System.String]::Equals($(Configuration), 'Debug')) + + + <_IsWindowsTarget>false + <_IsWindowsTarget Condition="'$(RuntimeIdentifier)' == 'win-x64'">true + + <_IsLinuxTarget>false + <_IsLinuxTarget Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64'">true + + <_IsMacOSTarget>false + <_IsMacOSTarget Condition="'$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64'">true + + + diff --git a/Nitrox.Shared.targets b/Nitrox.Shared.targets new file mode 100644 index 0000000000..72530796ce --- /dev/null +++ b/Nitrox.Shared.targets @@ -0,0 +1,180 @@ + + + + + + <_NitroxBeforePublishTaskName>_NitroxBeforePublish + <_NitroxBeforeBuildTaskName>_NitroxBeforeBuild + <_NitroxBuildTaskName>_NitroxBuild + <_NitroxAfterBuildTaskName>_NitroxAfterBuild + <_NitroxReleaseTaskName>_NitroxRelease + + <_NitroxMoveDependenciesToLibTaskName>_NitroxMoveDependenciesToLib + + <_ReleaseMacOSTaskName>_NitroxMacOSRelease + <_VerifyMacOSFilesTaskName>_VerifyMacOSFiles + <_CreateMacOSAppBundleTaskName>_CreateMacOSAppBundle + + + + + false + Nitrox.app + + + + + + + + + + + + + + + + + + + + + + + + + + + true + $(MSBuildProjectDirectory)\Platforms\MacOS + + + + + + + + + <_NitroxOutputFolder>lib\ + $(TargetDir)$(_NitroxOutputFolder) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_ZipDir>$(TargetDir)bundle\ + <_AppBundleDir>$(_ZipDir)$(AppBundleName)\ + <_ContentDir>$(_AppBundleDir)Contents\ + <_MacOSDir>$(_ContentDir)MacOS\ + <_ResourcesDir>$(_ContentDir)Resources\ + + <_ZipFilePath>$(TargetDir)$(AppBundleName).zip + + + + <_AllFiles Include="$(TargetDir)\**\*" Exclude="$(_ZipFilePath);$(_AppBundleDir)\**\*" /> + <_BundleFolders Include="$(_ContentDir);$(_MacOSDir);$(_ResourcesDir)" /> + <_PlatformFiles Include="$(PlatformFolder)\**\*" /> + + + + + + + + + + + + + + + + + + diff --git a/Nitrox.Test/Model/DataStructures/ThreadSafeListTest.cs b/Nitrox.Test/Model/DataStructures/ThreadSafeListTest.cs index 4f7c76c15a..0a226b12d2 100644 --- a/Nitrox.Test/Model/DataStructures/ThreadSafeListTest.cs +++ b/Nitrox.Test/Model/DataStructures/ThreadSafeListTest.cs @@ -61,7 +61,7 @@ public void ReadAndWriteSimultaneous() { int iterations = 500000; - ThreadSafeList comeGetMe = new(iterations); + ThreadSafeList comeGetMe = new(iterations); List countsRead = new(); long addCount = 0; @@ -72,7 +72,7 @@ public void ReadAndWriteSimultaneous() }, i => { - comeGetMe.Add(new string(Enumerable.Repeat(' ', 10).Select(c => (char)r.Next('A', 'Z')).ToArray())); + comeGetMe.Add(r.Next()); Interlocked.Increment(ref addCount); }, iterations); @@ -88,22 +88,21 @@ public void IterateAndAddSimultaneous() { int iterations = 500000; - ThreadSafeList comeGetMe = new(iterations); + ThreadSafeList comeGetMe = new(iterations); long addCount = 0; long iterationsReadMany = 0; Random r = new Random(); DoReaderWriter(() => { - foreach (string item in comeGetMe) + foreach (int unused in comeGetMe) { - item.Length.Should().BeGreaterThan(0); Interlocked.Increment(ref iterationsReadMany); } }, i => { - comeGetMe.Add(new string(Enumerable.Repeat(' ', 10).Select(c => (char)r.Next('A', 'Z')).ToArray())); + comeGetMe.Add(r.Next()); Interlocked.Increment(ref addCount); }, iterations); @@ -134,10 +133,10 @@ public void IterateAndAdd() private void DoReaderWriter(Action reader, Action writer, int iterators) { - ManualResetEvent barrier = new(false); + ManualResetEventSlim barrier = new(false); Thread readerThread = new(() => { - while (!barrier.SafeWaitHandle.IsClosed) + while (!barrier.IsSet) { reader(); Thread.Yield(); @@ -152,12 +151,12 @@ private void DoReaderWriter(Action reader, Action writer, int iterators) { writer(i); } - barrier.Set(); // Signal done + barrier.Set(); }); readerThread.Start(); writerThread.Start(); - barrier.WaitOne(); // Wait for signal + barrier.Wait(); } } } diff --git a/Nitrox.Test/Model/DataStructures/ThreadSafeSetTest.cs b/Nitrox.Test/Model/DataStructures/ThreadSafeSetTest.cs index c643727aa4..ea5eb6acc1 100644 --- a/Nitrox.Test/Model/DataStructures/ThreadSafeSetTest.cs +++ b/Nitrox.Test/Model/DataStructures/ThreadSafeSetTest.cs @@ -77,22 +77,21 @@ public void IterateAndAddSimultaneous() { int iterations = 500000; - ThreadSafeSet comeGetMe = new(); + ThreadSafeSet comeGetMe = new(); long addCount = 0; long iterationsReadMany = 0; - Random r = new Random(); + Random r = new(); DoReaderWriter(() => { - foreach (string item in comeGetMe) + foreach (int item in comeGetMe) { - item.Length.Should().BeGreaterThan(0); Interlocked.Increment(ref iterationsReadMany); } }, i => { - comeGetMe.Add(new string(Enumerable.Repeat(' ', 10).Select(c => (char)r.Next('A', 'Z')).ToArray())); + comeGetMe.Add(r.Next()); Interlocked.Increment(ref addCount); }, iterations); @@ -123,10 +122,10 @@ public void IterateAndAdd() private void DoReaderWriter(Action reader, Action writer, int iterators) { - ManualResetEvent barrier = new(false); + ManualResetEventSlim barrier = new(false); Thread readerThread = new(() => { - while (!barrier.SafeWaitHandle.IsClosed) + while (!barrier.IsSet) { reader(); Thread.Yield(); @@ -141,12 +140,12 @@ private void DoReaderWriter(Action reader, Action writer, int iterators) { writer(i); } - barrier.Set(); // Signal done + barrier.Set(); }); readerThread.Start(); writerThread.Start(); - barrier.WaitOne(); // Wait for signal + barrier.Wait(); } } } diff --git a/Nitrox.Test/Model/DataStructures/Unity/NitroxTransformTest.cs b/Nitrox.Test/Model/DataStructures/Unity/NitroxTransformTest.cs index 867fcb5576..5bf5a00ff1 100644 --- a/Nitrox.Test/Model/DataStructures/Unity/NitroxTransformTest.cs +++ b/Nitrox.Test/Model/DataStructures/Unity/NitroxTransformTest.cs @@ -6,7 +6,7 @@ namespace NitroxModel.DataStructures.Unity [TestClass] public class NitroxTransformTest { - private const float TOLERANCE = 0.00005f; + private const float TOLERANCE = 0.005f; private static readonly NitroxTransform root = new NitroxTransform(new NitroxVector3(1, 1, 1), NitroxQuaternion.FromEuler(0, 0, 0), new NitroxVector3(1, 1, 1)); private static readonly NitroxTransform child1 = new NitroxTransform(new NitroxVector3(5, 3, -6), NitroxQuaternion.FromEuler(30, 0, 10), new NitroxVector3(2, 2, 2)); diff --git a/Nitrox.Test/Model/DataStructures/Util/OptionalTest.cs b/Nitrox.Test/Model/DataStructures/Util/OptionalTest.cs index 8bbae205de..d41d1e3679 100644 --- a/Nitrox.Test/Model/DataStructures/Util/OptionalTest.cs +++ b/Nitrox.Test/Model/DataStructures/Util/OptionalTest.cs @@ -1,4 +1,4 @@ -namespace NitroxModel.DataStructures.Util; +namespace NitroxModel.DataStructures.Util; [TestClass] public class OptionalTest @@ -88,6 +88,7 @@ public void OptionalHasValueDynamicChecks() aAsBase.Value.Threshold.Should().Be(200); // Optional should always do all checks because anything can be in it. + // Note: This test can fail if Optional.ApplyHasValueCondition isn't called early enough. Run this test method directly and it should work. Optional bAsObj = Optional.Of(new B()); bAsObj.HasValue.Should().BeFalse(); @@ -96,6 +97,7 @@ public void OptionalHasValueDynamicChecks() cAsObj.HasValue.Should().BeTrue(); ((C)cAsObj.Value).Threshold.Should().Be(203); } + [TestMethod] public void OptionalEqualsCheck() { diff --git a/Nitrox.Test/Model/Helper/KeyValueStoreTest.cs b/Nitrox.Test/Model/Helper/KeyValueStoreTest.cs new file mode 100644 index 0000000000..2eb3b1e3fa --- /dev/null +++ b/Nitrox.Test/Model/Helper/KeyValueStoreTest.cs @@ -0,0 +1,24 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NitroxModel.Helper; + +[TestClass] +public class KeyValueStoreTest +{ + [TestMethod] + public void SetAndReadValue() + { + const string TEST_KEY = "test"; + + KeyValueStore.Instance.SetValue(TEST_KEY, -50); + Assert.AreEqual(-50, KeyValueStore.Instance.GetValue(TEST_KEY)); + + KeyValueStore.Instance.SetValue(TEST_KEY, 1337); + Assert.AreEqual(1337, KeyValueStore.Instance.GetValue(TEST_KEY)); + + // Cleanup + KeyValueStore.Instance.DeleteKey(TEST_KEY); + Assert.IsNull(KeyValueStore.Instance.GetValue(TEST_KEY)); + Assert.IsFalse(KeyValueStore.Instance.KeyExists(TEST_KEY)); + } +} diff --git a/Nitrox.Test/Model/Helper/NetHelperTest.cs b/Nitrox.Test/Model/Helper/NetHelperTest.cs index 5a4d4a1547..558829024a 100644 --- a/Nitrox.Test/Model/Helper/NetHelperTest.cs +++ b/Nitrox.Test/Model/Helper/NetHelperTest.cs @@ -1,8 +1,5 @@ -using System; -using System.Net; +using System.Net; using System.Net.Sockets; -using FluentAssertions; -using Microsoft.VisualStudio.TestTools.UnitTesting; namespace NitroxModel.Helper; diff --git a/Nitrox.Test/Model/Platforms/OS/Windows/RegistryTest.cs b/Nitrox.Test/Model/Platforms/OS/Windows/RegistryTest.cs index 49392c91a2..818ffc754b 100644 --- a/Nitrox.Test/Model/Platforms/OS/Windows/RegistryTest.cs +++ b/Nitrox.Test/Model/Platforms/OS/Windows/RegistryTest.cs @@ -1,40 +1,37 @@ -using System; using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using NitroxModel.Platforms.OS.Windows.Internal; +using Nitrox.Test.Model.Platforms; -namespace NitroxModel.Platforms.OS.Windows +namespace NitroxModel.Platforms.OS.Windows; + +[TestClass] +public class RegistryTest { - [TestClass] - public class RegistryTest + [OSTestMethod("WINDOWS")] + public async Task WaitsForRegistryKeyToExist() { - [TestMethod] - public async Task WaitsForRegistryKeyToExist() + const string PATH_TO_KEY = @"SOFTWARE\Nitrox\test"; + + RegistryEx.Write(PATH_TO_KEY, 0); + Task readTask = Task.Run(async () => { - const string pathToKey = @"SOFTWARE\Nitrox\test"; - - RegistryEx.Write(pathToKey, 0); - Task readTask = Task.Run(async () => + try + { + await RegistryEx.CompareAsync(PATH_TO_KEY, + v => v == 1337, + TimeSpan.FromSeconds(5)); + return true; + } + catch (TaskCanceledException) { - try - { - await RegistryEx.CompareAsync(pathToKey, - v => v == 1337, - TimeSpan.FromSeconds(5)); - return true; - } - catch (TaskCanceledException) - { - return false; - } - }); - - RegistryEx.Write(pathToKey, 1337); - Assert.IsTrue(await readTask); - - // Cleanup (we can keep "Nitrox" key intact). - RegistryEx.Delete(pathToKey); - Assert.IsNull(RegistryEx.Read(pathToKey)); - } + return false; + } + }); + + RegistryEx.Write(PATH_TO_KEY, 1337); + Assert.IsTrue(await readTask); + + // Cleanup (we can keep "Nitrox" key intact). + RegistryEx.Delete(PATH_TO_KEY); + Assert.IsNull(RegistryEx.Read(PATH_TO_KEY)); } } diff --git a/Nitrox.Test/Model/Platforms/OSTestMethodAttribute.cs b/Nitrox.Test/Model/Platforms/OSTestMethodAttribute.cs new file mode 100644 index 0000000000..dda82583e9 --- /dev/null +++ b/Nitrox.Test/Model/Platforms/OSTestMethodAttribute.cs @@ -0,0 +1,32 @@ +namespace Nitrox.Test.Model.Platforms; + +[AttributeUsage(AttributeTargets.Method)] +public class OSTestMethodAttribute : TestMethodAttribute +{ + public string Platform { get;} + + /// + /// Test method attribute, that will only run the test on the specified platform. + /// + /// case insensitive platform, i.e: linux, windows, osx + public OSTestMethodAttribute(string platform) + { + Platform = platform; + } + + public override TestResult[] Execute(ITestMethod testMethod) + { + if (!OperatingSystem.IsOSPlatform(Platform)) + { + return [ + new TestResult() + { + Outcome = UnitTestOutcome.Inconclusive, + TestContextMessages = $"This test can only be run on {Platform}" + } + ]; + } + + return base.Execute(testMethod); + } +} diff --git a/Nitrox.Test/Nitrox.Test.csproj b/Nitrox.Test/Nitrox.Test.csproj index cb222c3976..d561c96073 100644 --- a/Nitrox.Test/Nitrox.Test.csproj +++ b/Nitrox.Test/Nitrox.Test.csproj @@ -1,8 +1,9 @@  - net472 - disable + net9.0 + true + false false DIMA001 @@ -12,37 +13,33 @@ - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + - + - - - - - - - + diff --git a/Nitrox.Test/Patcher/Patches/Persistent/Language_LoadLanguageFile_PatchTest.cs b/Nitrox.Test/Patcher/Patches/Persistent/Language_LoadLanguageFile_PatchTest.cs index a2088b888c..cb2db38e62 100644 --- a/Nitrox.Test/Patcher/Patches/Persistent/Language_LoadLanguageFile_PatchTest.cs +++ b/Nitrox.Test/Patcher/Patches/Persistent/Language_LoadLanguageFile_PatchTest.cs @@ -1,7 +1,5 @@ -using System; -using System.IO; -using LitJson; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using LitJson; +using NitroxModel.Helper; namespace Nitrox.Test.Patcher.Patches.Persistent; @@ -11,7 +9,7 @@ public class Language_LoadLanguageFile_PatchTest [TestMethod] public void DefaultLanguageSanity() { - string languageFolder = Path.Combine(".", "LanguageFiles"); + string languageFolder = Path.Combine(NitroxUser.AssetsPath, "LanguageFiles"); Assert.IsTrue(Directory.Exists(languageFolder), $"The language files folder does not exist at {languageFolder}."); string defaultLanguageFilePath = Path.Combine(languageFolder, "en.json"); diff --git a/Nitrox.Test/Polyfill/PolyfillTest.cs b/Nitrox.Test/Polyfill/PolyfillTest.cs deleted file mode 100644 index 7b303b0f62..0000000000 --- a/Nitrox.Test/Polyfill/PolyfillTest.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; - -namespace Nitrox.Test.Polyfill; - -[TestClass] -public class PolyfillTest -{ - [TestMethod] - public void Range() - { - string value = "test"[1..]; - value.Should().Be("est"); - } - - [TestMethod] - public void Index() - { - char value = "test"[^2]; - value.Should().Be('s'); - } - - internal class TestClass - { - public required string Name { get; init; } - - public TestClass() - { - - } - - [SetsRequiredMembers] - public TestClass(string name) - { - Name = name; - } - - public string AutomaticName(int number, [CallerArgumentExpression(nameof(number))] string name = "") - { - return name; - } - - [SkipLocalsInit] - public void Stackalloc() - { - _ = stackalloc int[8]; - } - - public void Handler(string name, [InterpolatedStringHandlerArgument(nameof(name))] ref TestHandlerStruct handler) - { - } - } - - internal readonly struct UnscopedRefStruct - { - private readonly int number; - - [UnscopedRef] - public readonly ref readonly int GetRef() - { - return ref number; - } - } - [InterpolatedStringHandler] - internal struct TestHandlerStruct - { - - } -} diff --git a/Nitrox.sln b/Nitrox.sln index cd4f8ae914..09c946e957 100644 --- a/Nitrox.sln +++ b/Nitrox.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.32014.148 @@ -10,10 +9,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionFolder", "SolutionF .editorconfig = .editorconfig Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets + Nitrox.Shared.props = Nitrox.Shared.props + Nitrox.Shared.targets = Nitrox.Shared.targets EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NitroxLauncher", "NitroxLauncher\NitroxLauncher.csproj", "{59EB8953-864F-4147-A210-7FC97E1A5294}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NitroxPatcher", "NitroxPatcher\NitroxPatcher.csproj", "{39E377AD-2163-4428-952D-EBECD402C8F3}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NitroxModel-Subnautica", "NitroxModel-Subnautica\NitroxModel-Subnautica.csproj", "{47D774E0-750C-427B-8C38-F8F985114A2E}" @@ -24,14 +23,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NitroxServer-Subnautica", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NitroxServer", "NitroxServer\NitroxServer.csproj", "{0CD6846B-EDA6-4FFD-A540-2A46CC1074BF}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nitrox.Test", "Nitrox.Test\Nitrox.Test.csproj", "{E4D8C360-34E4-4BE6-909F-3791DD9169B5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nitrox.Launcher", "Nitrox.Launcher\Nitrox.Launcher.csproj", "{30493A43-7EB3-4898-A82F-98A387284EB2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nitrox.Test", "Nitrox.Test\Nitrox.Test.csproj", "{B4C9C786-10A1-4091-A88F-C22AA14C05D4}" EndProject Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Nitrox.Assets.Subnautica", "Nitrox.Assets.Subnautica\Nitrox.Assets.Subnautica.shproj", "{79E92B6D-5D25-4254-AC9F-FA9A1CD3CBC6}" EndProject Global - GlobalSection(SharedMSBuildProjectFiles) = preSolution - Nitrox.Assets.Subnautica\Nitrox.Assets.Subnautica.projitems*{79e92b6d-5d25-4254-ac9f-fa9a1cd3cbc6}*SharedItemsImports = 13 - EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU @@ -41,10 +39,6 @@ Global {EBEBC7DC-FAE3-4FBC-BDD3-38ED8FC072D9}.Debug|Any CPU.Build.0 = Debug|Any CPU {EBEBC7DC-FAE3-4FBC-BDD3-38ED8FC072D9}.Release|Any CPU.ActiveCfg = Release|Any CPU {EBEBC7DC-FAE3-4FBC-BDD3-38ED8FC072D9}.Release|Any CPU.Build.0 = Release|Any CPU - {59EB8953-864F-4147-A210-7FC97E1A5294}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {59EB8953-864F-4147-A210-7FC97E1A5294}.Debug|Any CPU.Build.0 = Debug|Any CPU - {59EB8953-864F-4147-A210-7FC97E1A5294}.Release|Any CPU.ActiveCfg = Release|Any CPU - {59EB8953-864F-4147-A210-7FC97E1A5294}.Release|Any CPU.Build.0 = Release|Any CPU {39E377AD-2163-4428-952D-EBECD402C8F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {39E377AD-2163-4428-952D-EBECD402C8F3}.Debug|Any CPU.Build.0 = Debug|Any CPU {39E377AD-2163-4428-952D-EBECD402C8F3}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -65,10 +59,14 @@ Global {0CD6846B-EDA6-4FFD-A540-2A46CC1074BF}.Debug|Any CPU.Build.0 = Debug|Any CPU {0CD6846B-EDA6-4FFD-A540-2A46CC1074BF}.Release|Any CPU.ActiveCfg = Release|Any CPU {0CD6846B-EDA6-4FFD-A540-2A46CC1074BF}.Release|Any CPU.Build.0 = Release|Any CPU - {E4D8C360-34E4-4BE6-909F-3791DD9169B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E4D8C360-34E4-4BE6-909F-3791DD9169B5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E4D8C360-34E4-4BE6-909F-3791DD9169B5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E4D8C360-34E4-4BE6-909F-3791DD9169B5}.Release|Any CPU.Build.0 = Release|Any CPU + {30493A43-7EB3-4898-A82F-98A387284EB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30493A43-7EB3-4898-A82F-98A387284EB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30493A43-7EB3-4898-A82F-98A387284EB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30493A43-7EB3-4898-A82F-98A387284EB2}.Release|Any CPU.Build.0 = Release|Any CPU + {B4C9C786-10A1-4091-A88F-C22AA14C05D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4C9C786-10A1-4091-A88F-C22AA14C05D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4C9C786-10A1-4091-A88F-C22AA14C05D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4C9C786-10A1-4091-A88F-C22AA14C05D4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -76,4 +74,7 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AC56EA37-FBBC-4D19-8796-29A42A2331A2} EndGlobalSection + GlobalSection(SharedMSBuildProjectFiles) = preSolution + Nitrox.Assets.Subnautica\Nitrox.Assets.Subnautica.projitems*{79e92b6d-5d25-4254-ac9f-fa9a1cd3cbc6}*SharedItemsImports = 13 + EndGlobalSection EndGlobal diff --git a/Nitrox.sln.DotSettings b/Nitrox.sln.DotSettings index 2bdc190952..e4bf9bd6ad 100644 --- a/Nitrox.sln.DotSettings +++ b/Nitrox.sln.DotSettings @@ -4,7 +4,7 @@ True HINT DO_NOT_SHOW - + <?xml version="1.0" encoding="utf-16"?><Profile name="Nitrox"><CSReorderTypeMembers>True</CSReorderTypeMembers><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><HtmlReformatCode>True</HtmlReformatCode><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" /><RemoveCodeRedundanciesVB>True</RemoveCodeRedundanciesVB><CssAlphabetizeProperties>True</CssAlphabetizeProperties><VBOptimizeImports>True</VBOptimizeImports><VBShortenReferences>True</VBShortenReferences><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><VBReformatCode>True</VBReformatCode><VBFormatDocComments>True</VBFormatDocComments><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><IDEA_SETTINGS>&lt;profile version="1.0"&gt; &lt;option name="myName" value="Nitrox" /&gt; &lt;/profile&gt;</IDEA_SETTINGS><CSShortenReferences>True</CSShortenReferences><RIDER_SETTINGS>&lt;profile&gt; @@ -63,6 +63,8 @@ &lt;/profile&gt;</RIDER_SETTINGS></Profile> Built-in: Full Cleanup Nitrox + ExpressionBody + ExpressionBody True True True @@ -111,17 +113,8 @@ <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb" /></Policy> - <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"><ElementKinds><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> - <Policy><Descriptor Staticness="Any" AccessRightKinds="Private" Description="Constant fields (private)"><ElementKinds><Kind Name="CONSTANT_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></Policy> - <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> - <Policy><Descriptor Staticness="Any" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Constant fields (not private)"><ElementKinds><Kind Name="CONSTANT_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></Policy> - <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Parameters"><ElementKinds><Kind Name="PARAMETER" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /><ExtraRule Prefix="__" Suffix="" Style="aaBb" /><ExtraRule Prefix="___" Suffix="" Style="aaBb" /><ExtraRule Prefix="____" Suffix="" Style="aaBb" /></Policy></Policy> - <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Enum members"><ElementKinds><Kind Name="ENUM_MEMBER" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></Policy> - <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb" /></Policy></Policy> - <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local constants"><ElementKinds><Kind Name="LOCAL_CONSTANT" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></Policy> - <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> - <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> RB + No True True True @@ -341,4 +334,3 @@ True True True - diff --git a/NitroxClient/ClientAutoFacRegistrar.cs b/NitroxClient/ClientAutoFacRegistrar.cs index bd9327bd09..a8c0ba7647 100644 --- a/NitroxClient/ClientAutoFacRegistrar.cs +++ b/NitroxClient/ClientAutoFacRegistrar.cs @@ -118,7 +118,7 @@ private void RegisterCoreDependencies(ContainerBuilder containerBuilder) containerBuilder.RegisterType().InstancePerLifetimeScope(); containerBuilder.RegisterType().InstancePerLifetimeScope(); containerBuilder.RegisterType().InstancePerLifetimeScope(); - containerBuilder.Register(_ => new FMODWhitelist(GameInfo.Subnautica)).InstancePerLifetimeScope(); + containerBuilder.Register(_ => FMODWhitelist.Load(GameInfo.Subnautica)).InstancePerLifetimeScope(); containerBuilder.RegisterType().InstancePerLifetimeScope(); containerBuilder.RegisterType().InstancePerLifetimeScope(); containerBuilder.RegisterType().InstancePerLifetimeScope(); diff --git a/NitroxClient/Communication/LANBroadcastClient.cs b/NitroxClient/Communication/LANBroadcastClient.cs index 85c0991642..bfa1403dd1 100644 --- a/NitroxClient/Communication/LANBroadcastClient.cs +++ b/NitroxClient/Communication/LANBroadcastClient.cs @@ -61,7 +61,7 @@ static void ReceivedResponse(IPEndPoint remoteEndPoint, NetPacketReader reader, { return; } - + Log.Info($"Found LAN server at {serverEndPoint}."); discoveredServers.Add(serverEndPoint); OnServerFound(serverEndPoint); @@ -69,8 +69,8 @@ static void ReceivedResponse(IPEndPoint remoteEndPoint, NetPacketReader reader, cancellationToken = cancellationToken == default ? new CancellationTokenSource(TimeSpan.FromMinutes(1)).Token : cancellationToken; EventBasedNetListener listener = new(); - NetManager client = new(listener) { - AutoRecycle = true, + NetManager client = new(listener) { + AutoRecycle = true, BroadcastReceiveEnabled = true, UnconnectedMessagesEnabled = true }; diff --git a/NitroxClient/GameLogic/InitialSync/PdaInitialSyncProcessor.cs b/NitroxClient/GameLogic/InitialSync/PdaInitialSyncProcessor.cs index 973e4a32c3..5316847086 100644 --- a/NitroxClient/GameLogic/InitialSync/PdaInitialSyncProcessor.cs +++ b/NitroxClient/GameLogic/InitialSync/PdaInitialSyncProcessor.cs @@ -32,7 +32,7 @@ public PdaInitialSyncProcessor(IPacketSender packetSender) private static IEnumerator RestoreKnownTech(InitialPlayerSync packet) { List knownTech = packet.PDAData.KnownTechTypes.Select(techType => techType.ToUnity()).ToList(); - HashSet analyzedTech = packet.PDAData.AnalyzedTechTypes.Select(techType => techType.ToUnity()).ToHashSet(); + HashSet analyzedTech = new(packet.PDAData.AnalyzedTechTypes.Select(techType => techType.ToUnity())); Log.Info($"Received initial sync packet with {knownTech.Count} KnownTech.knownTech types and {analyzedTech.Count} KnownTech.analyzedTech types."); using (PacketSuppressor.Suppress()) @@ -79,7 +79,7 @@ private static IEnumerator RestorePDAScanner(InitialPlayerSync packet) { fragments = pdaData.ScannerFragments.ToDictionary(m => m.ToString(), m => 1f), partial = pdaData.ScannerPartial.Select(entry => entry.ToUnity()).ToList(), - complete = pdaData.ScannerComplete.Select(techType => techType.ToUnity()).ToHashSet() + complete = new HashSet(pdaData.ScannerComplete.Select(techType => techType.ToUnity())) }; PDAScanner.Deserialize(data); yield break; diff --git a/NitroxClient/GameLogic/Spawning/Metadata/Processor/Abstract/EntityMetadataProcessor.cs b/NitroxClient/GameLogic/Spawning/Metadata/Processor/Abstract/EntityMetadataProcessor.cs index 20589a51b0..6b11ea27a3 100644 --- a/NitroxClient/GameLogic/Spawning/Metadata/Processor/Abstract/EntityMetadataProcessor.cs +++ b/NitroxClient/GameLogic/Spawning/Metadata/Processor/Abstract/EntityMetadataProcessor.cs @@ -13,8 +13,8 @@ public void ProcessMetadata(GameObject gameObject, EntityMetadata metadata) ProcessMetadata(gameObject, (TMetadata)metadata); } - protected T Resolve() where T : class + protected TService Resolve() where TService : class { - return NitroxServiceLocator.Cache.Value; + return NitroxServiceLocator.Cache.Value; } } diff --git a/NitroxClient/MonoBehaviours/Gui/MainMenu/JoinServer.cs b/NitroxClient/MonoBehaviours/Gui/MainMenu/JoinServer.cs index 38c9145aa0..e026f71158 100644 --- a/NitroxClient/MonoBehaviours/Gui/MainMenu/JoinServer.cs +++ b/NitroxClient/MonoBehaviours/Gui/MainMenu/JoinServer.cs @@ -38,7 +38,7 @@ public class JoinServer : MonoBehaviour private GameObject joinServerMenu; public string MenuName => joinServerMenu.AliveOrNull()?.name ?? throw new Exception("Menu not yet initialized"); - public bool InstantLaunch; + public string InstantLaunchPlayerName; public void Setup(GameObject saveGameMenu) { @@ -49,7 +49,7 @@ public void Setup(GameObject saveGameMenu) Hide(); } - public async Task ShowAsync(string ip, int port, bool instantLaunch = false) + public async Task ShowAsync(string ip, int port, string instantLaunchPlayerName = null) { NitroxServiceLocator.BeginNewLifetimeScope(); multiplayerSession = NitroxServiceLocator.LocateService(); @@ -60,7 +60,7 @@ public async Task ShowAsync(string ip, int port, bool instantLaunch = false) gameObject.SetActive(true); serverIp = ip; serverPort = port; - InstantLaunch = instantLaunch; + InstantLaunchPlayerName = instantLaunchPlayerName; //Set Server IP in info label joinWindow.SetIP(serverIp); @@ -212,8 +212,15 @@ private void SessionConnectionStateChangedHandler(IMultiplayerSessionConnectionS Log.InGame(Language.main.Get("Nitrox_WaitingUserInput")); MainMenuRightSide.main.OpenGroup("Join Server"); FocusPlayerNameTextBox(); - if (InstantLaunch) + if (!string.IsNullOrEmpty(InstantLaunchPlayerName)) { + joinWindow.PlayerName = InstantLaunchPlayerName; + byte[] nameHash = InstantLaunchPlayerName.AsMd5Hash(); + if (nameHash.Length >= 8) + { + float hue = BitConverter.ToUInt64([nameHash[0], nameHash[1], nameHash[2], nameHash[3], nameHash[4], nameHash[5], nameHash[6], nameHash[7]], 0) / (float)ulong.MaxValue; + joinWindow.SetHSB(new Vector3(hue, 1, 1)); + } OnJoinClick(); } break; diff --git a/NitroxClient/MonoBehaviours/Gui/MainMenu/MainMenuMultiplayerPanel.cs b/NitroxClient/MonoBehaviours/Gui/MainMenu/MainMenuMultiplayerPanel.cs index eb16f21a2c..d41fcd3885 100644 --- a/NitroxClient/MonoBehaviours/Gui/MainMenu/MainMenuMultiplayerPanel.cs +++ b/NitroxClient/MonoBehaviours/Gui/MainMenu/MainMenuMultiplayerPanel.cs @@ -111,7 +111,7 @@ await OpenJoinServerMenuAsync(joinIp, joinPort) } } - public static async System.Threading.Tasks.Task OpenJoinServerMenuAsync(string serverIp, string serverPort, bool instantLaunch = false) + public static async System.Threading.Tasks.Task OpenJoinServerMenuAsync(string serverIp, string serverPort, string instantLaunchPlayerName = null) { if (Main == null) { @@ -126,7 +126,7 @@ public static async System.Threading.Tasks.Task OpenJoinServerMenuAsync(string s return; } - await Main.JoinServer.ShowAsync(endpoint.Address.ToString(), endpoint.Port, instantLaunch); + await Main.JoinServer.ShowAsync(endpoint.Address.ToString(), endpoint.Port, instantLaunchPlayerName); } private void ShowAddServerWindow() diff --git a/NitroxClient/NitroxClient.csproj b/NitroxClient/NitroxClient.csproj index 1a53a9c9d6..0c6a6c6691 100644 --- a/NitroxClient/NitroxClient.csproj +++ b/NitroxClient/NitroxClient.csproj @@ -1,12 +1,12 @@ - + - net472 - disable + net472;netstandard2.0 + @@ -14,13 +14,10 @@ $(GameManagedDir)\LitJson.dll false - - ..\Nitrox.Assets.Subnautica\protobuf-net.dll - - + diff --git a/NitroxClient/Unity/Helper/AssetBundleLoader.cs b/NitroxClient/Unity/Helper/AssetBundleLoader.cs index b14e18eeef..b38fcd2a8a 100644 --- a/NitroxClient/Unity/Helper/AssetBundleLoader.cs +++ b/NitroxClient/Unity/Helper/AssetBundleLoader.cs @@ -7,7 +7,7 @@ namespace NitroxClient.Unity.Helper; public static class AssetBundleLoader { - private static readonly string assetRootFolder = NitroxUser.AssetsPath; + private static readonly string assetRootFolder = NitroxUser.AssetBundlePath; private static bool loadedSharedAssets; diff --git a/NitroxLauncher/App.config b/NitroxLauncher/App.config deleted file mode 100644 index 845d688cbe..0000000000 --- a/NitroxLauncher/App.config +++ /dev/null @@ -1,30 +0,0 @@ - - - - -
- - - - - - - - - - - - - - - - - - True - - - -vrmode none - - - - \ No newline at end of file diff --git a/NitroxLauncher/App.xaml b/NitroxLauncher/App.xaml deleted file mode 100644 index ad17c944e8..0000000000 --- a/NitroxLauncher/App.xaml +++ /dev/null @@ -1,606 +0,0 @@ - - - - - - pack://application:,,,/Assets/Fonts/#OpenSans - pack://application:,,,/Assets/Fonts/#Roboto Mono - pack://application:,,,/Assets/Fonts/#Inter - pack://application:,,,/Assets/Fonts/#Inter - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/NitroxLauncher/App.xaml.cs b/NitroxLauncher/App.xaml.cs deleted file mode 100644 index 37e9113c78..0000000000 --- a/NitroxLauncher/App.xaml.cs +++ /dev/null @@ -1,49 +0,0 @@ -global using NitroxModel.Logger; -using System; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Threading; - -namespace NitroxLauncher -{ - /// - /// Use MainWindow.xaml.cs or LauncherLogic.cs for Nitrox init code to make exceptions during startup unlikely. - /// - public partial class App : Application - { - protected override void OnStartup(StartupEventArgs e) - { - // Set default style for all windows to the style with the target type 'Window' (in App.xaml). - FrameworkElement.StyleProperty.OverrideMetadata(typeof(Window), - new FrameworkPropertyMetadata - { - DefaultValue = FindResource(typeof(Window)) - }); - FrameworkElement.StyleProperty.OverrideMetadata(typeof(Page), - new FrameworkPropertyMetadata - { - DefaultValue = FindResource(typeof(Page)) - }); - - base.OnStartup(e); - } - - private void Application_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) - { - // If something went wrong. Close the server if embedded. - LauncherLogic.Instance.Dispose(); - - Log.Error(e.Exception.GetBaseException().ToString()); // Gets the exception that was unhandled, not the "dispatched unhandled" exception. - MessageBox.Show(GetExceptionError(e.Exception), "Error", MessageBoxButton.OK, MessageBoxImage.Error, MessageBoxResult.OK, MessageBoxOptions.DefaultDesktopOnly); - } - - private string GetExceptionError(Exception e) - { -#if RELEASE - return e.GetBaseException().Message; -#else - return e.GetBaseException().ToString(); -#endif - } - } -} \ No newline at end of file diff --git a/NitroxLauncher/Assets/Fonts/Apache License.txt b/NitroxLauncher/Assets/Fonts/Apache License.txt deleted file mode 100644 index 989e2c59e9..0000000000 --- a/NitroxLauncher/Assets/Fonts/Apache License.txt +++ /dev/null @@ -1,201 +0,0 @@ -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 - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - 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. \ No newline at end of file diff --git a/NitroxLauncher/Assets/Fonts/Inter-Black.ttf b/NitroxLauncher/Assets/Fonts/Inter-Black.ttf deleted file mode 100644 index d9739227e8..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-Black.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-BlackItalic.ttf b/NitroxLauncher/Assets/Fonts/Inter-BlackItalic.ttf deleted file mode 100644 index 13768cd63e..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-BlackItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-Bold.ttf b/NitroxLauncher/Assets/Fonts/Inter-Bold.ttf deleted file mode 100644 index 7e1deec31e..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-Bold.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-BoldItalic.ttf b/NitroxLauncher/Assets/Fonts/Inter-BoldItalic.ttf deleted file mode 100644 index bc7f292199..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-BoldItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-ExtraBold.ttf b/NitroxLauncher/Assets/Fonts/Inter-ExtraBold.ttf deleted file mode 100644 index b18b0118c0..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-ExtraBold.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-ExtraBoldItalic.ttf b/NitroxLauncher/Assets/Fonts/Inter-ExtraBoldItalic.ttf deleted file mode 100644 index 12286586df..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-ExtraBoldItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-ExtraLight.ttf b/NitroxLauncher/Assets/Fonts/Inter-ExtraLight.ttf deleted file mode 100644 index f5399e3eec..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-ExtraLight.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-ExtraLightItalic.ttf b/NitroxLauncher/Assets/Fonts/Inter-ExtraLightItalic.ttf deleted file mode 100644 index c694f244b1..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-ExtraLightItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-Italic.ttf b/NitroxLauncher/Assets/Fonts/Inter-Italic.ttf deleted file mode 100644 index e1afbe7edd..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-Italic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-Light.ttf b/NitroxLauncher/Assets/Fonts/Inter-Light.ttf deleted file mode 100644 index ebaa005740..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-Light.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-LightItalic.ttf b/NitroxLauncher/Assets/Fonts/Inter-LightItalic.ttf deleted file mode 100644 index e7ba98429d..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-LightItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-Medium.ttf b/NitroxLauncher/Assets/Fonts/Inter-Medium.ttf deleted file mode 100644 index 7e573f6498..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-Medium.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-MediumItalic.ttf b/NitroxLauncher/Assets/Fonts/Inter-MediumItalic.ttf deleted file mode 100644 index 54607cb285..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-MediumItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-Regular.ttf b/NitroxLauncher/Assets/Fonts/Inter-Regular.ttf deleted file mode 100644 index 012d1b470d..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-Regular.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-SemiBold.ttf b/NitroxLauncher/Assets/Fonts/Inter-SemiBold.ttf deleted file mode 100644 index 4be54399d6..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-SemiBold.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-SemiBoldItalic.ttf b/NitroxLauncher/Assets/Fonts/Inter-SemiBoldItalic.ttf deleted file mode 100644 index 66fddf1a5a..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-SemiBoldItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-Thin.ttf b/NitroxLauncher/Assets/Fonts/Inter-Thin.ttf deleted file mode 100644 index 9b91bc13cf..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-Thin.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Inter-ThinItalic.ttf b/NitroxLauncher/Assets/Fonts/Inter-ThinItalic.ttf deleted file mode 100644 index 407bdfa17e..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Inter-ThinItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/InterLICENSE.txt b/NitroxLauncher/Assets/Fonts/InterLICENSE.txt deleted file mode 100644 index ff80f8c615..0000000000 --- a/NitroxLauncher/Assets/Fonts/InterLICENSE.txt +++ /dev/null @@ -1,94 +0,0 @@ -Copyright (c) 2016-2020 The Inter Project Authors. -"Inter" is trademark of Rasmus Andersson. -https://github.com/rsms/inter - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION AND CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/NitroxLauncher/Assets/Fonts/LICENSE.txt b/NitroxLauncher/Assets/Fonts/LICENSE.txt deleted file mode 100644 index d645695673..0000000000 --- a/NitroxLauncher/Assets/Fonts/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - 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 - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - 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. diff --git a/NitroxLauncher/Assets/Fonts/OpenSans-Bold.ttf b/NitroxLauncher/Assets/Fonts/OpenSans-Bold.ttf deleted file mode 100644 index fd79d43bea..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/OpenSans-Bold.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/OpenSans-BoldItalic.ttf b/NitroxLauncher/Assets/Fonts/OpenSans-BoldItalic.ttf deleted file mode 100644 index 9bc800958a..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/OpenSans-BoldItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/OpenSans-ExtraBold.ttf b/NitroxLauncher/Assets/Fonts/OpenSans-ExtraBold.ttf deleted file mode 100644 index 21f6f84a07..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/OpenSans-ExtraBold.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/OpenSans-ExtraBoldItalic.ttf b/NitroxLauncher/Assets/Fonts/OpenSans-ExtraBoldItalic.ttf deleted file mode 100644 index 31cb688340..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/OpenSans-ExtraBoldItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/OpenSans-Italic.ttf b/NitroxLauncher/Assets/Fonts/OpenSans-Italic.ttf deleted file mode 100644 index c90da48ff3..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/OpenSans-Italic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/OpenSans-Light.ttf b/NitroxLauncher/Assets/Fonts/OpenSans-Light.ttf deleted file mode 100644 index 0d381897da..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/OpenSans-Light.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/OpenSans-LightItalic.ttf b/NitroxLauncher/Assets/Fonts/OpenSans-LightItalic.ttf deleted file mode 100644 index 68299c4bc6..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/OpenSans-LightItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/OpenSans-Regular.ttf b/NitroxLauncher/Assets/Fonts/OpenSans-Regular.ttf deleted file mode 100644 index db433349b7..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/OpenSans-Regular.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/OpenSans-Semibold.ttf b/NitroxLauncher/Assets/Fonts/OpenSans-Semibold.ttf deleted file mode 100644 index 1a7679e394..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/OpenSans-Semibold.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/OpenSans-SemiboldItalic.ttf b/NitroxLauncher/Assets/Fonts/OpenSans-SemiboldItalic.ttf deleted file mode 100644 index 59b6d16b06..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/OpenSans-SemiboldItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-Black.ttf b/NitroxLauncher/Assets/Fonts/Roboto-Black.ttf deleted file mode 100644 index 51c71bbe2d..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-Black.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-BlackItalic.ttf b/NitroxLauncher/Assets/Fonts/Roboto-BlackItalic.ttf deleted file mode 100644 index ca20ca3999..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-BlackItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-Bold.ttf b/NitroxLauncher/Assets/Fonts/Roboto-Bold.ttf deleted file mode 100644 index e612852d25..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-Bold.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-BoldItalic.ttf b/NitroxLauncher/Assets/Fonts/Roboto-BoldItalic.ttf deleted file mode 100644 index 677bc045e5..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-BoldItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-Italic.ttf b/NitroxLauncher/Assets/Fonts/Roboto-Italic.ttf deleted file mode 100644 index 5fd05c3b64..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-Italic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-Light.ttf b/NitroxLauncher/Assets/Fonts/Roboto-Light.ttf deleted file mode 100644 index 4f1fb5805f..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-Light.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-LightItalic.ttf b/NitroxLauncher/Assets/Fonts/Roboto-LightItalic.ttf deleted file mode 100644 index eec0ae9be8..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-LightItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-Medium.ttf b/NitroxLauncher/Assets/Fonts/Roboto-Medium.ttf deleted file mode 100644 index 86d1c52ed5..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-Medium.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-MediumItalic.ttf b/NitroxLauncher/Assets/Fonts/Roboto-MediumItalic.ttf deleted file mode 100644 index 66aa174f05..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-MediumItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-Regular.ttf b/NitroxLauncher/Assets/Fonts/Roboto-Regular.ttf deleted file mode 100644 index cb8ffcf1ad..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-Regular.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-Thin.ttf b/NitroxLauncher/Assets/Fonts/Roboto-Thin.ttf deleted file mode 100644 index a85eb7c295..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-Thin.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/Roboto-ThinItalic.ttf b/NitroxLauncher/Assets/Fonts/Roboto-ThinItalic.ttf deleted file mode 100644 index ac77951b80..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/Roboto-ThinItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/RobotoMono-Bold.ttf b/NitroxLauncher/Assets/Fonts/RobotoMono-Bold.ttf deleted file mode 100644 index 482f028aba..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/RobotoMono-Bold.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/RobotoMono-BoldItalic.ttf b/NitroxLauncher/Assets/Fonts/RobotoMono-BoldItalic.ttf deleted file mode 100644 index 6419a44c74..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/RobotoMono-BoldItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/RobotoMono-Italic.ttf b/NitroxLauncher/Assets/Fonts/RobotoMono-Italic.ttf deleted file mode 100644 index 8281794243..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/RobotoMono-Italic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/RobotoMono-Light.ttf b/NitroxLauncher/Assets/Fonts/RobotoMono-Light.ttf deleted file mode 100644 index 3c845d424b..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/RobotoMono-Light.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/RobotoMono-LightItalic.ttf b/NitroxLauncher/Assets/Fonts/RobotoMono-LightItalic.ttf deleted file mode 100644 index aa3cc9ae0b..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/RobotoMono-LightItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/RobotoMono-Medium.ttf b/NitroxLauncher/Assets/Fonts/RobotoMono-Medium.ttf deleted file mode 100644 index c496725e61..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/RobotoMono-Medium.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/RobotoMono-MediumItalic.ttf b/NitroxLauncher/Assets/Fonts/RobotoMono-MediumItalic.ttf deleted file mode 100644 index caadad68f2..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/RobotoMono-MediumItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/RobotoMono-Regular.ttf b/NitroxLauncher/Assets/Fonts/RobotoMono-Regular.ttf deleted file mode 100644 index 5919b5d1bf..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/RobotoMono-Regular.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/RobotoMono-Thin.ttf b/NitroxLauncher/Assets/Fonts/RobotoMono-Thin.ttf deleted file mode 100644 index 65bf26affb..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/RobotoMono-Thin.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Fonts/RobotoMono-ThinItalic.ttf b/NitroxLauncher/Assets/Fonts/RobotoMono-ThinItalic.ttf deleted file mode 100644 index 171913a4de..0000000000 Binary files a/NitroxLauncher/Assets/Fonts/RobotoMono-ThinItalic.ttf and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/PlayBoxImage.png b/NitroxLauncher/Assets/Images/PlayBoxImage.png deleted file mode 100644 index 6cbef0eb95..0000000000 Binary files a/NitroxLauncher/Assets/Images/PlayBoxImage.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/blog/vines.png b/NitroxLauncher/Assets/Images/blog/vines.png deleted file mode 100644 index 4e44b6f1b3..0000000000 Binary files a/NitroxLauncher/Assets/Images/blog/vines.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/loadingSmall.png b/NitroxLauncher/Assets/Images/loadingSmall.png deleted file mode 100644 index 710e0052bc..0000000000 Binary files a/NitroxLauncher/Assets/Images/loadingSmall.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/material-design-icons/baseline_send_white_24dp.png b/NitroxLauncher/Assets/Images/material-design-icons/baseline_send_white_24dp.png deleted file mode 100644 index 5ff5b2b829..0000000000 Binary files a/NitroxLauncher/Assets/Images/material-design-icons/baseline_send_white_24dp.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/material-design-icons/baseline_stop_white_24dp.png b/NitroxLauncher/Assets/Images/material-design-icons/baseline_stop_white_24dp.png deleted file mode 100644 index 9f6dc9ef5e..0000000000 Binary files a/NitroxLauncher/Assets/Images/material-design-icons/baseline_stop_white_24dp.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/material-design-icons/close.png b/NitroxLauncher/Assets/Images/material-design-icons/close.png deleted file mode 100644 index 5f958c3cab..0000000000 Binary files a/NitroxLauncher/Assets/Images/material-design-icons/close.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/material-design-icons/library.png b/NitroxLauncher/Assets/Images/material-design-icons/library.png deleted file mode 100644 index 10548a7636..0000000000 Binary files a/NitroxLauncher/Assets/Images/material-design-icons/library.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/material-design-icons/minimise.png b/NitroxLauncher/Assets/Images/material-design-icons/minimise.png deleted file mode 100644 index cd71a9ecb3..0000000000 Binary files a/NitroxLauncher/Assets/Images/material-design-icons/minimise.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/nitroxGallery.png b/NitroxLauncher/Assets/Images/nitroxGallery.png deleted file mode 100644 index 3d0fbd04f0..0000000000 Binary files a/NitroxLauncher/Assets/Images/nitroxGallery.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/nitroxIco.png b/NitroxLauncher/Assets/Images/nitroxIco.png deleted file mode 100644 index 476892401e..0000000000 Binary files a/NitroxLauncher/Assets/Images/nitroxIco.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/nitroxLogo.png b/NitroxLauncher/Assets/Images/nitroxLogo.png deleted file mode 100644 index e5040675d6..0000000000 Binary files a/NitroxLauncher/Assets/Images/nitroxLogo.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/old/icon.ico b/NitroxLauncher/Assets/Images/old/icon.ico deleted file mode 100644 index 03f15b71df..0000000000 Binary files a/NitroxLauncher/Assets/Images/old/icon.ico and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/old/oldNitroxLogo.png b/NitroxLauncher/Assets/Images/old/oldNitroxLogo.png deleted file mode 100644 index 98dfd85c34..0000000000 Binary files a/NitroxLauncher/Assets/Images/old/oldNitroxLogo.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/social/discord.png b/NitroxLauncher/Assets/Images/social/discord.png deleted file mode 100644 index ef59e9c732..0000000000 Binary files a/NitroxLauncher/Assets/Images/social/discord.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/social/discordIco.png b/NitroxLauncher/Assets/Images/social/discordIco.png deleted file mode 100644 index 97ed45912f..0000000000 Binary files a/NitroxLauncher/Assets/Images/social/discordIco.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/social/github.png b/NitroxLauncher/Assets/Images/social/github.png deleted file mode 100644 index a5e32e2ffc..0000000000 Binary files a/NitroxLauncher/Assets/Images/social/github.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/social/githubIco.png b/NitroxLauncher/Assets/Images/social/githubIco.png deleted file mode 100644 index b86c503834..0000000000 Binary files a/NitroxLauncher/Assets/Images/social/githubIco.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/social/reddit.png b/NitroxLauncher/Assets/Images/social/reddit.png deleted file mode 100644 index e8c77f2997..0000000000 Binary files a/NitroxLauncher/Assets/Images/social/reddit.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/social/redditIco.png b/NitroxLauncher/Assets/Images/social/redditIco.png deleted file mode 100644 index ffaf832705..0000000000 Binary files a/NitroxLauncher/Assets/Images/social/redditIco.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/social/twitter.png b/NitroxLauncher/Assets/Images/social/twitter.png deleted file mode 100644 index 30c861d802..0000000000 Binary files a/NitroxLauncher/Assets/Images/social/twitter.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/social/twitterIco.png b/NitroxLauncher/Assets/Images/social/twitterIco.png deleted file mode 100644 index cc14239d4a..0000000000 Binary files a/NitroxLauncher/Assets/Images/social/twitterIco.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/social/website.png b/NitroxLauncher/Assets/Images/social/website.png deleted file mode 100644 index efcea7283a..0000000000 Binary files a/NitroxLauncher/Assets/Images/social/website.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/world-manager/details-horizontal-2x.png b/NitroxLauncher/Assets/Images/world-manager/details-horizontal-2x.png deleted file mode 100644 index cfa8653fcb..0000000000 Binary files a/NitroxLauncher/Assets/Images/world-manager/details-horizontal-2x.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/world-manager/details-vertical-2x.png b/NitroxLauncher/Assets/Images/world-manager/details-vertical-2x.png deleted file mode 100644 index 540ebccdcd..0000000000 Binary files a/NitroxLauncher/Assets/Images/world-manager/details-vertical-2x.png and /dev/null differ diff --git a/NitroxLauncher/Assets/Images/world-manager/window-launcher-2x.png b/NitroxLauncher/Assets/Images/world-manager/window-launcher-2x.png deleted file mode 100644 index 4efbfb389c..0000000000 Binary files a/NitroxLauncher/Assets/Images/world-manager/window-launcher-2x.png and /dev/null differ diff --git a/NitroxLauncher/Downloader.cs b/NitroxLauncher/Downloader.cs deleted file mode 100644 index c64dadf933..0000000000 --- a/NitroxLauncher/Downloader.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Cache; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using LitJson; -using NitroxLauncher.Models; - -namespace NitroxLauncher -{ - internal static class Downloader - { - public const string BLOGS_URL = "https://nitroxblog.rux.gg/wp-json/wp/v2/posts?per_page=8&page=1"; - public const string LATEST_VERSION_URL = "https://nitrox.rux.gg/api/version/latest"; - public const string CHANGELOGS_URL = "https://nitrox.rux.gg/api/changelog/releases"; - public const string RELEASES_URL = "https://nitrox.rux.gg/api/version/releases"; - - // Create a policy that allows any cache to supply requested resources if the resource on the server is not newer than the cached copy - private static readonly HttpRequestCachePolicy cachePolicy = new( - HttpCacheAgeControl.MaxAge, - TimeSpan.FromDays(1) - ); - - public static async Task> GetBlogs() - { - IList blogs = new List(); - - try - { - using WebResponse response = await GetResponseFromCache(BLOGS_URL); - -#if DEBUG - if (response == null) - { - Log.Error($"{nameof(Downloader)} : Error while fetching nitrox blogs from {BLOGS_URL}"); - LauncherNotifier.Error("Unable to fetch nitrox blogs"); - return blogs; - } -#endif - - if (response.IsFromCache) - { - Log.Info("Fetched nitrox blogs from the local cache"); - } - - using (StreamReader sr = new(response.GetResponseStream())) - { - string json = sr.ReadToEnd(); - JsonData data = JsonMapper.ToObject(json); - - // TODO : Add a json schema validator - for (int i = 0; i < data.Count; i++) - { - string released = (string)data[i]["date"]; - string url = (string)data[i]["link"]; - string title = (string)data[i]["title"]["rendered"]; - string image = (string)data[i]["jetpack_featured_media_url"]; - - if (!DateTime.TryParse(released, out DateTime dateTime)) - { - dateTime = DateTime.UtcNow; - Log.Error($"Error while trying to parse release time ({released}) of blog {url}"); - } - - blogs.Add(new NitroxBlog(title, dateTime, url, image)); - } - } - } - catch (Exception ex) - { - Log.Error(ex, $"{nameof(Downloader)} : Error while fetching nitrox blogs from {BLOGS_URL}"); - LauncherNotifier.Error("Unable to fetch nitrox blogs"); - } - - return blogs; - } - - public async static Task> GetChangeLogs() - { - IList changelogs = new List(); - - try - { - //https://developer.wordpress.org/rest-api/reference/posts/#arguments - using WebResponse response = await GetResponseFromCache(CHANGELOGS_URL); - - if (response.IsFromCache) - { - Log.Info("Fetched nitrox changelogs from the local cache"); - } - - using (StreamReader sr = new(response.GetResponseStream())) - { - string json = sr.ReadToEnd(); - StringBuilder builder = new(); - JsonData data = JsonMapper.ToObject(json); - - // TODO : Add a json schema validator - for (int i = 0; i < data.Count; i++) - { - string version = (string)data[i]["version"]; - string released = (string)data[i]["released"]; - JsonData patchnotes = data[i]["patchnotes"]; - - if (!DateTime.TryParse(released, out DateTime dateTime)) - { - dateTime = DateTime.UtcNow; - Log.Error($"Error while trying to parse release time ({released}) of nitrox v{version}"); - } - - builder.Clear(); - for (int j = 0; j < patchnotes.Count; j++) - { - builder.AppendLine($"• {(string)patchnotes[j]}"); - } - - changelogs.Add(new NitroxChangelog(version, dateTime, builder.ToString())); - } - } - } - catch (Exception ex) - { - Log.Error(ex, $"{nameof(Downloader)} : Error while fetching nitrox changelogs from {CHANGELOGS_URL}"); - LauncherNotifier.Error("Unable to fetch nitrox changelogs"); - } - - return changelogs; - } - - public async static Task GetNitroxLatestVersion() - { - try - { - using WebResponse response = await GetResponseFromCache(LATEST_VERSION_URL); - - if (response.IsFromCache) - { - Log.Info("Fetched nitrox version from the local cache"); - } - - using (StreamReader sr = new(response.GetResponseStream())) - { - string json = await sr.ReadToEndAsync(); - Regex rx = new(@"""version"":""([^""]*)"""); - Match match = rx.Match(json); - - if (match.Success && match.Groups.Count > 1) - { - return new Version(match.Groups[1].Value); - } - } - } - catch (Exception ex) - { - Log.Error(ex, $"{nameof(Downloader)} : Error while fetching nitrox version from {LATEST_VERSION_URL}"); - LauncherNotifier.Error("Unable to check for updates"); - } - - return new Version(); - } - - private static async Task GetResponseFromCache(string url) - { - Log.Info($"Trying to request data from {url}"); - - HttpWebRequest request = WebRequest.Create(url) as HttpWebRequest; - request.Method = "GET"; - request.UserAgent = "NitroxLauncher"; - request.ContentType = "application/json"; - request.CachePolicy = cachePolicy; - request.Timeout = 5000; - - try - { - return await request.GetResponseAsync(); - } - catch (Exception ex) - { - Log.Error(ex, $"Error while requesting data from {url}"); - } - - return null; - } - } -} diff --git a/NitroxLauncher/LauncherConfig.cs b/NitroxLauncher/LauncherConfig.cs deleted file mode 100644 index 894b978155..0000000000 --- a/NitroxLauncher/LauncherConfig.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; -using NitroxLauncher.Properties; -using NitroxModel.Helper; - -namespace NitroxLauncher -{ - internal sealed class LauncherConfig : INotifyPropertyChanged - { - // Is the Nitrox version the latest available - private bool isUpToDate = true; - public bool IsUpToDate - { - get => isUpToDate; - set - { - isUpToDate = value; - OnPropertyChanged(); - } - } - - // Subnautica game files path - public string SubnauticaPath - { - get => NitroxUser.GamePath; - set - { - // Ensures the path looks alright (no mixed / and \ path separators) - NitroxUser.GamePath = value; - OnPropertyChanged(); - } - } - - public const string DEFAULT_LAUNCH_ARGUMENTS = "-vrmode none"; - // Launch arguments used to launch Subnautica - private string subnauticaLaunchArguments = Settings.Default.LaunchArgs ?? DEFAULT_LAUNCH_ARGUMENTS; - public string SubnauticaLaunchArguments - { - get => subnauticaLaunchArguments; - set - { - if (value != subnauticaLaunchArguments) - { - subnauticaLaunchArguments = value; - Settings.Default.LaunchArgs = value; - Settings.Default.Save(); - OnPropertyChanged(); - } - } - } - - // Is server external by default - private bool isExternalServer = Settings.Default.IsExternalServer; - public bool IsExternalServer - { - get => isExternalServer; - set - { - if (value != isExternalServer) - { - isExternalServer = value; - Settings.Default.IsExternalServer = value; - Settings.Default.Save(); - OnPropertyChanged(); - } - } - } - - public event PropertyChangedEventHandler PropertyChanged; - - private void OnPropertyChanged([CallerMemberName] string propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } -} diff --git a/NitroxLauncher/LauncherLogic.cs b/NitroxLauncher/LauncherLogic.cs deleted file mode 100644 index b175727fb0..0000000000 --- a/NitroxLauncher/LauncherLogic.cs +++ /dev/null @@ -1,289 +0,0 @@ -using System; -using System.ComponentModel; -using System.IO; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using NitroxLauncher.Pages; -using NitroxModel; -using NitroxModel.Helper; -using NitroxModel.Platforms.OS.Shared; -using NitroxModel.Platforms.Store; -using DiscordStore = NitroxModel.Platforms.Store.Discord; -using NitroxModel.Platforms.Store.Interfaces; -using NitroxLauncher.Models.Patching; -using System.Windows; -using System.Windows.Threading; -using NitroxLauncher.Models.Utils; -using System.Windows.Controls; -using System.Diagnostics; - -namespace NitroxLauncher -{ - internal class LauncherLogic : IDisposable - { - public static string ReleasePhase => NitroxEnvironment.ReleasePhase.ToUpper(); - public static string Version => NitroxEnvironment.Version.ToString(); - - public static LauncherLogic Instance { get; private set; } - public static LauncherConfig Config { get; private set; } - public static ServerLogic Server { get; private set; } - - private NitroxEntryPatch nitroxEntryPatch; - private ProcessEx gameProcess; - - private Task lastFindSubnauticaTask; - - public LauncherLogic() - { - Config = new LauncherConfig(); - Server = new ServerLogic(); - Instance = this; - } - - public void Dispose() - { - Application.Current.MainWindow?.Hide(); - - if (nitroxEntryPatch?.IsApplied == true) - { - try - { - nitroxEntryPatch.Remove(); - } - catch (Exception ex) - { - Log.Error(ex, "Error while disposing the launcher"); - } - } - - gameProcess?.Dispose(); - Server?.Dispose(); - LauncherNotifier.Shutdown(); - } - - [Conditional("RELEASE")] - public async void CheckNitroxVersion() - { - await Task.Factory.StartNew(async () => - { - Version latestVersion = await Downloader.GetNitroxLatestVersion(); - Version currentVersion = new(Version); - - if (latestVersion > currentVersion) - { - Config.IsUpToDate = false; - Log.Info($"A new version of the mod ({latestVersion}) is available"); - - LauncherNotifier.Warning($"A new version of the mod ({latestVersion}) is available", new ToastNotifications.Core.MessageOptions() - { - NotificationClickAction = (n) => - { - NavigateTo(); - }, - }); - } - - }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()); - } - - [Conditional("RELEASE")] - public async void ConfigureFirewall() - { - Task task = Task.Run(() => WindowsHelper.CheckFirewallRules()); - await task; - - if (task.Exception != null) - { - MessageBox.Show($"An error occurred configuring the firewall: {task.Exception}"); - } - } - - public async Task SetTargetedSubnauticaPath(string path) - { - if ((string.IsNullOrWhiteSpace(Config.SubnauticaPath) && Config.SubnauticaPath == path) || !Directory.Exists(path)) - { - return null; - } - - Config.SubnauticaPath = path; - if (lastFindSubnauticaTask != null) - { - await lastFindSubnauticaTask; - } - - lastFindSubnauticaTask = Task.Factory.StartNew(() => - { - PirateDetection.TriggerOnDirectory(path); - - if (!FileSystem.Instance.IsWritable(Directory.GetCurrentDirectory()) || !FileSystem.Instance.IsWritable(path)) - { - // TODO: Move this check to another place where Nitrox installation can be verified. (i.e: another page on the launcher in order to check permissions, network setup, ...) - if (!FileSystem.Instance.SetFullAccessToCurrentUser(Directory.GetCurrentDirectory()) || !FileSystem.Instance.SetFullAccessToCurrentUser(path)) - { - Dispatcher.CurrentDispatcher.BeginInvoke(() => - { - MessageBox.Show(Application.Current.MainWindow!, "Restart Nitrox Launcher as admin to allow Nitrox to change permissions as needed. This is only needed once. Nitrox will close after this message.", "Required file permission error", MessageBoxButton.OK, - MessageBoxImage.Error); - Environment.Exit(1); - }, DispatcherPriority.ApplicationIdle); - } - } - - // Save game path as preferred for future sessions. - NitroxUser.PreferredGamePath = path; - - if (nitroxEntryPatch?.IsApplied == true) - { - nitroxEntryPatch.Remove(); - } - nitroxEntryPatch = new NitroxEntryPatch(() => Config.SubnauticaPath); - - if (Path.GetFullPath(path).StartsWith(WindowsHelper.ProgramFileDirectory, StringComparison.OrdinalIgnoreCase)) - { - WindowsHelper.RestartAsAdmin(); - } - - return path; - }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()); - - return await lastFindSubnauticaTask; - } - - public void NavigateTo(Type page) - { - if (page != null && (page.IsSubclassOf(typeof(Page)) || page == typeof(Page))) - { - if (Server.IsManagedByLauncher && page == typeof(ServerPage)) - { - page = typeof(ServerConsolePage); - } - - if (Application.Current.MainWindow is MainWindow window) - { - window.FrameContent = Application.Current.FindResource(page.Name); - } - } - } - - public void NavigateTo() where TPage : Page => NavigateTo(typeof(TPage)); - - public bool NavigationIsOn() where TPage : Page => Application.Current.MainWindow is MainWindow { FrameContent: TPage }; - - internal async Task StartSingleplayerAsync() - { - if (string.IsNullOrWhiteSpace(Config.SubnauticaPath) || !Directory.Exists(Config.SubnauticaPath)) - { - NavigateTo(); - throw new Exception("Location of Subnautica is unknown. Set the path to it in settings."); - } - -#if RELEASE - if (Process.GetProcessesByName("Subnautica").Length > 0) - { - throw new Exception("An instance of Subnautica is already running"); - } -#endif - nitroxEntryPatch.Remove(); - gameProcess = await StartSubnauticaAsync(); - } - - internal async Task StartMultiplayerAsync() - { - if (string.IsNullOrWhiteSpace(Config.SubnauticaPath) || !Directory.Exists(Config.SubnauticaPath)) - { - NavigateTo(); - throw new Exception("Location of Subnautica is unknown. Set the path to it in settings."); - } - - if (PirateDetection.HasTriggered) - { - throw new Exception("Aarrr! Nitrox walked the plank :("); - } - -#if RELEASE - if (Process.GetProcessesByName("Subnautica").Length > 0) - { - throw new Exception("An instance of Subnautica is already running"); - } -#endif - - // TODO: The launcher should override FileRead win32 API for the Subnautica process to give it the modified Assembly-CSharp from memory - string initDllName = "NitroxPatcher.dll"; - try - { - File.Copy( - Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? "", "lib", initDllName), - Path.Combine(Config.SubnauticaPath, "Subnautica_Data", "Managed", initDllName), - true - ); - } - catch (IOException ex) - { - Log.Error(ex, "Unable to move initialization dll to Managed folder. Still attempting to launch because it might exist from previous runs."); - } - - // Try inject Nitrox into Subnautica code. - if (lastFindSubnauticaTask != null) - { - await lastFindSubnauticaTask; - } - if (nitroxEntryPatch == null) - { - throw new Exception("Nitrox was blocked by another program"); - } - nitroxEntryPatch.Remove(); - nitroxEntryPatch.Apply(); - - if (QModHelper.IsQModInstalled(Config.SubnauticaPath)) - { - Log.Warn("Seems like QModManager is Installed"); - LauncherNotifier.Info("Detected QModManager in the game folder"); - } - - gameProcess = await StartSubnauticaAsync(); - } - - private async Task StartSubnauticaAsync() - { - string subnauticaPath = Config.SubnauticaPath; - string subnauticaLaunchArguments = $"{Config.SubnauticaLaunchArguments} {string.Join(" ", Environment.GetCommandLineArgs())}"; - string subnauticaExe = Path.Combine(subnauticaPath, GameInfo.Subnautica.ExeName); - IGamePlatform platform = GamePlatforms.GetPlatformByGameDir(subnauticaPath); - - // Start game & gaming platform if needed. - using ProcessEx game = platform switch - { - Steam s => await s.StartGameAsync(subnauticaExe, GameInfo.Subnautica.SteamAppId, subnauticaLaunchArguments), - EpicGames e => await e.StartGameAsync(subnauticaExe, subnauticaLaunchArguments), - MSStore m => await m.StartGameAsync(subnauticaExe), - DiscordStore d => await d.StartGameAsync(subnauticaExe, subnauticaLaunchArguments), - _ => throw new Exception($"Directory '{subnauticaPath}' is not a valid {GameInfo.Subnautica.Name} game installation or the game's platform is unsupported by Nitrox.") - }; - - return game ?? throw new Exception($"Game failed to start through {platform.Name}"); - } - - private void OnSubnauticaExited(object sender, EventArgs e) - { - try - { - nitroxEntryPatch.Remove(); - Log.Info("Finished removing patches!"); - } - catch (Exception ex) - { - MessageBox.Show(ex.ToString(), "Error", MessageBoxButton.OK, MessageBoxImage.Error); - Log.Error(ex); - } - } - - public event PropertyChangedEventHandler PropertyChanged; - - protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } -} diff --git a/NitroxLauncher/LauncherNotifier.cs b/NitroxLauncher/LauncherNotifier.cs deleted file mode 100644 index 1b992e7138..0000000000 --- a/NitroxLauncher/LauncherNotifier.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Windows; -using ToastNotifications; -using ToastNotifications.Lifetime; -using ToastNotifications.Position; -using ToastNotifications.Messages; -using ToastNotifications.Core; - -namespace NitroxLauncher -{ - internal static class LauncherNotifier - { - private static readonly MessageOptions defaultOptions = new() - { - FreezeOnMouseEnter = true, - UnfreezeOnMouseLeave = true, - ShowCloseButton = true, - }; - - private static Notifier notifier; - - public static void Setup() - { - if (notifier == null) - { - notifier = new(cfg => - { - cfg.PositionProvider = new WindowPositionProvider( - parentWindow: Application.Current.MainWindow, - corner: Corner.BottomRight, - offsetX: 10, - offsetY: 10 - ); - - // Should we display toaster over other applications - cfg.DisplayOptions.TopMost = false; - - cfg.LifetimeSupervisor = new TimeAndCountBasedLifetimeSupervisor( - notificationLifetime: TimeSpan.FromSeconds(5), - maximumNotificationCount: MaximumNotificationCount.FromCount(5) - ); - - cfg.Dispatcher = Application.Current.Dispatcher; - }); - } - else - { - Log.Error("Notifier is already set up"); - } - } - - public static void Shutdown() - { - notifier?.Dispose(); - notifier = null; - } - - public static void Info(string message) => notifier.ShowInformation(message, defaultOptions); - public static void Info(string message, MessageOptions options) => notifier.ShowInformation(message, options); - - public static void Error(string message) => notifier.ShowError(message, defaultOptions); - public static void Error(string message, MessageOptions options) => notifier.ShowError(message, options); - - public static void Success(string message) => notifier.ShowSuccess(message, defaultOptions); - public static void Success(string message, MessageOptions options) => notifier.ShowSuccess(message, options); - - public static void Warning(string message) => notifier.ShowWarning(message, defaultOptions); - public static void Warning(string message, MessageOptions options) => notifier.ShowWarning(message, options); - } -} diff --git a/NitroxLauncher/MainWindow.xaml b/NitroxLauncher/MainWindow.xaml deleted file mode 100644 index 0caaef9120..0000000000 --- a/NitroxLauncher/MainWindow.xaml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/NitroxLauncher/MainWindow.xaml.cs b/NitroxLauncher/MainWindow.xaml.cs deleted file mode 100644 index ab54094dd1..0000000000 --- a/NitroxLauncher/MainWindow.xaml.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System; -using System.ComponentModel; -using System.IO; -using System.Net.NetworkInformation; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Interop; -using NitroxLauncher.Models.Events; -using NitroxLauncher.Models.Properties; -using NitroxLauncher.Pages; -using NitroxModel; -using NitroxModel.Discovery; -using NitroxModel.Helper; -using NitroxModel.Platforms.OS.Windows; - -namespace NitroxLauncher -{ - public partial class MainWindow : Window, INotifyPropertyChanged - { - public string Version => $"{LauncherLogic.ReleasePhase} {LauncherLogic.Version}"; - - public object FrameContent - { - get => frameContent; - set - { - frameContent = value; - OnPropertyChanged(); - } - } - - public bool UpdateAvailable => !LauncherLogic.Config.IsUpToDate; - - private readonly LauncherLogic logic; - private bool isServerEmbedded; - private object frameContent; - - private Button LastButton { get; set; } - - public MainWindow() - { - Log.Setup(); - LauncherNotifier.Setup(); - - logic = new LauncherLogic(); - - MaxHeight = SystemParameters.VirtualScreenHeight; - MaxWidth = SystemParameters.VirtualScreenWidth; - - LauncherLogic.Config.PropertyChanged += (s, e) => OnPropertyChanged(nameof(UpdateAvailable)); - LauncherLogic.Server.ServerStarted += ServerStarted; - LauncherLogic.Server.ServerExited += ServerExited; - - InitializeComponent(); - - // Pirate trigger should happen after UI is loaded. - Loaded += (sender, args) => - { - // Error if running from a temporary directory because Nitrox Launcher won't be able to write files directly to zip/rar - // Tools like WinRAR do this to support running EXE files while it's still zipped. - if (Directory.GetCurrentDirectory().StartsWith(Path.GetTempPath(), StringComparison.OrdinalIgnoreCase)) - { - MessageBox.Show("Nitrox launcher should not be executed from a temporary directory. Install Nitrox launcher properly by extracting ALL files and moving these to a dedicated location on your PC.", - "Invalid working directory", - MessageBoxButton.OK, - MessageBoxImage.Error); - Environment.Exit(1); - } - - // This pirate detection subscriber is immediately invoked if pirate has been detected right now. - PirateDetection.PirateDetected += (o, eventArgs) => - { - LauncherNotifier.Info("Nitrox does not support pirated version of Subnautica"); - LauncherNotifier.Info("Yo ho ho, Ahoy matey! Ye be a pirate!"); - - WebBrowser webBrowser = new() - { - HorizontalAlignment = HorizontalAlignment.Stretch, - VerticalAlignment = VerticalAlignment.Stretch, - Margin = new Thickness(0), - - Height = MinHeight * 0.7, - Width = MinWidth * 0.7 - }; - - FrameContent = webBrowser; - - webBrowser.NavigateToString($""" - - - - - - - - - """); - SideBarPanel.Visibility = Visibility.Hidden; - }; - - if (!NetworkInterface.GetIsNetworkAvailable()) - { - Log.Warn("Launcher may not be connected to internet"); - LauncherNotifier.Error("Launcher may not be connected to internet"); - } - - if (!NitroxEnvironment.IsReleaseMode) - { - LauncherNotifier.Warning("You're now using Nitrox DEV build"); - } - -# if DEBUG - string[] launchArgs = Environment.GetCommandLineArgs(); - for (int i = 0; i < launchArgs.Length; i++) - { - if (launchArgs[i].Equals("-instantlaunch", StringComparison.OrdinalIgnoreCase) && launchArgs.Length > i + 1) - { - LauncherLogic.Instance.StartMultiplayerAsync().ContinueWithHandleError(); - LauncherLogic.Server.StartServer(true, launchArgs[i + 1]); - } - } -#endif - }; - - logic.SetTargetedSubnauticaPath(NitroxUser.GamePath) - .ContinueWith(task => - { - if (GameInstallationHelper.HasGameExecutable(task.Result, GameInfo.Subnautica)) - { - LauncherLogic.Instance.NavigateTo(); - } - else - { - LauncherLogic.Instance.NavigateTo(); - } - - logic.CheckNitroxVersion(); - logic.ConfigureFirewall(); - }, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.FromCurrentSynchronizationContext()); - } - - private bool CanClose() - { - if (LauncherLogic.Server.IsServerRunning && isServerEmbedded) - { - MessageBoxResult userAnswer = MessageBox.Show("The embedded server is still running. Click yes to close.", "Close aborted", MessageBoxButton.YesNo, MessageBoxImage.Warning); - return userAnswer == MessageBoxResult.Yes; - } - - return true; - } - - private void OnClosing(object sender, CancelEventArgs e) - { - if (!CanClose()) - { - e.Cancel = true; - return; - } - - logic.Dispose(); - } - - private void Close_Click(object sender, RoutedEventArgs e) - { - Close(); - } - - private void Minimze_Click(object sender, RoutedEventArgs e) - { - WindowState = WindowState.Minimized; - } - - private void Restore_Click(object sender, RoutedEventArgs e) - { - WindowState = WindowState.Normal; - RestoreButton.Visibility = Visibility.Collapsed; - } - - private void ServerStarted(object sender, ServerStartEventArgs e) - { - isServerEmbedded = e.IsEmbedded; - - if (e.IsEmbedded) - { - LauncherLogic.Instance.NavigateTo(); - } - } - - private void ServerExited(object sender, EventArgs e) - { - Dispatcher?.Invoke(() => - { - if (LauncherLogic.Instance.NavigationIsOn()) - { - LauncherLogic.Instance.NavigateTo(); - } - }); - } - - private void ButtonNavigation_Click(object sender, RoutedEventArgs e) - { - if (sender is not FrameworkElement elem) - { - return; - } - if (sender is not Button button) - { - return; - } - - LauncherLogic.Instance.NavigateTo(elem.Tag?.GetType()); - if (LastButton == null) - { - NavPlayGamePageButton.SetValue(ButtonProperties.SelectedProperty, false); - } - else - { - LastButton.SetValue(ButtonProperties.SelectedProperty, false); - } - LastButton = button; - button.SetValue(ButtonProperties.SelectedProperty, true); - } - - public event PropertyChangedEventHandler PropertyChanged; - - protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - - private void Window_Loaded(object sender, RoutedEventArgs e) - { - WindowsApi.EnableDefaultWindowAnimations(new WindowInteropHelper(this).Handle); - } - } -} diff --git a/NitroxLauncher/Models/Converters/DateToRelativeDateConverter.cs b/NitroxLauncher/Models/Converters/DateToRelativeDateConverter.cs deleted file mode 100644 index 87ae9a60f1..0000000000 --- a/NitroxLauncher/Models/Converters/DateToRelativeDateConverter.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using System.Windows.Markup; - -namespace NitroxLauncher.Models.Converters -{ - [ValueConversion(typeof(DateTime), typeof(string))] - internal class DateToRelativeDateConverter : MarkupExtension, IValueConverter - { - private const int SECOND = 1; - private const int MINUTE = 60 * SECOND; - private const int HOUR = 60 * MINUTE; - private const int DAY = 24 * HOUR; - private const int MONTH = 30 * DAY; - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is not DateTime date) - { - return string.Empty; - } - - TimeSpan ts = new(DateTime.UtcNow.Ticks - date.Ticks); - double delta = Math.Abs(ts.TotalSeconds); - - switch (delta) - { - case < 1 * MINUTE: - return ts.Seconds == 1 ? "one second ago" : $"{ts.Seconds} seconds ago"; - - case < 2 * MINUTE: - return "a minute ago"; - - case < 45 * MINUTE: - return $"{ts.Minutes} minutes ago"; - - case < 90 * MINUTE: - return "an hour ago"; - - case < 24 * HOUR: - return $"{ts.Hours} hours ago"; - - case < 48 * HOUR: - return "yesterday"; - - case < 30 * DAY: - return $"{ts.Days} days ago"; - - case < 12 * MONTH: - { - int months = System.Convert.ToInt32(Math.Floor((double)ts.Days / 30)); - return months <= 1 ? "one month ago" : $"{months} months ago"; - } - - default: - { - int years = System.Convert.ToInt32(Math.Floor((double)ts.Days / 365)); - return years <= 1 ? "one year ago" : $"{years} years ago"; - } - - } - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotSupportedException(); - } - - // We're inhereting from MarkupExtensions to avoid declaring this converter inside the ressources of a window - public override object ProvideValue(IServiceProvider serviceProvider) - { - return this; - } - } -} diff --git a/NitroxLauncher/Models/Converters/ObjectIsInstanceOfTypeConverter.cs b/NitroxLauncher/Models/Converters/ObjectIsInstanceOfTypeConverter.cs deleted file mode 100644 index 5c6844e791..0000000000 --- a/NitroxLauncher/Models/Converters/ObjectIsInstanceOfTypeConverter.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Windows.Data; -using System.Windows.Markup; - -namespace NitroxLauncher.Models.Converters -{ - [ValueConversion(typeof(Type), typeof(bool))] - public class ObjectIsInstanceOfTypeConverter : MarkupExtension, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) - { - if (value == null) - { - return false; - } - if (parameter == null) - { - return false; - } - Type valueType = value as Type ?? value.GetType(); - Type parameterType = parameter as Type ?? parameter.GetType(); - return parameterType.IsAssignableFrom(valueType); - } - - public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) - { - throw new InvalidOperationException(); - } - - public override object ProvideValue(IServiceProvider serviceProvider) - { - return this; - } - } -} diff --git a/NitroxLauncher/Models/Converters/PlatformToIconConverter.cs b/NitroxLauncher/Models/Converters/PlatformToIconConverter.cs deleted file mode 100644 index ab1a9cdf0b..0000000000 --- a/NitroxLauncher/Models/Converters/PlatformToIconConverter.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using System.Windows.Markup; -using NitroxModel.Discovery.Models; - -namespace NitroxLauncher.Models.Converters -{ - [ValueConversion(typeof(Platform), typeof(string))] - internal class PlatformToIconConverter : MarkupExtension, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is not Platform platform) - { - return "pack://application:,,,/Assets/Images/store-icons/missing-2x.png"; - } - - return platform switch - { - Platform.EPIC => "pack://application:,,,/Assets/Images/store-icons/epic-2x.png", - Platform.STEAM => "pack://application:,,,/Assets/Images/store-icons/steam-2x.png", - Platform.MICROSOFT => "pack://application:,,,/Assets/Images/store-icons/xbox-2x.png", - Platform.DISCORD => "pack://application:,,,/Assets/Images/store-icons/discord-2x.png", - _ => "pack://application:,,,/Assets/Images/store-icons/missing-2x.png", - }; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotSupportedException(); - } - - // We're inhereting from MarkupExtensions to avoid declaring this converter inside the ressources of a window - public override object ProvideValue(IServiceProvider serviceProvider) - { - return this; - } - } -} diff --git a/NitroxLauncher/Models/Converters/UpdateToIconConverter.cs b/NitroxLauncher/Models/Converters/UpdateToIconConverter.cs deleted file mode 100644 index 7de0a4512e..0000000000 --- a/NitroxLauncher/Models/Converters/UpdateToIconConverter.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using System.Windows.Markup; - -namespace NitroxLauncher.Models.Converters -{ - [ValueConversion(typeof(bool), typeof(string))] - internal class UpdateToIconConverter : MarkupExtension, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is not bool boolean) - { - return "pack://application:,,,/Assets/Images/material-design-icons/download.png"; - } - - return boolean switch - { - true => "pack://application:,,,/Assets/Images/material-design-icons/downloadDot.png", - false => "pack://application:,,,/Assets/Images/material-design-icons/download.png" - }; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotSupportedException(); - } - - // We're inhereting from MarkupExtensions to avoid declaring this converter inside the ressources of a window - public override object ProvideValue(IServiceProvider serviceProvider) - { - return this; - } - } -} diff --git a/NitroxLauncher/Models/Events/ServerStartEventArgs.cs b/NitroxLauncher/Models/Events/ServerStartEventArgs.cs deleted file mode 100644 index 8a2faff4d5..0000000000 --- a/NitroxLauncher/Models/Events/ServerStartEventArgs.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; - -namespace NitroxLauncher.Models.Events -{ - public sealed class ServerStartEventArgs : EventArgs - { - public bool IsEmbedded { get; } - - public ServerStartEventArgs(bool embedded) - { - IsEmbedded = embedded; - } - - public override string ToString() - { - return $"[ServerStartEventArgs - IsEmbedded: {IsEmbedded}]"; - } - } -} diff --git a/NitroxLauncher/Models/NitroxBlog.cs b/NitroxLauncher/Models/NitroxBlog.cs deleted file mode 100644 index 28762f5c8d..0000000000 --- a/NitroxLauncher/Models/NitroxBlog.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Net; - -namespace NitroxLauncher.Models -{ - [Serializable] - internal class NitroxBlog - { - public string Title { get; } - - public DateTime Date { get; } - - public string Url { get; } - - public string Image { get; } - - protected NitroxBlog() - { - // Constructor for serialization. Has to be "protected" for json serialization. - } - - public NitroxBlog(string title, DateTime date, string url, string image) - { - Title = WebUtility.HtmlDecode(title); - Date = date; - Url = url; - Image = image; - } - - public override string ToString() - { - return $"[{nameof(NitroxBlog)} - Title: {Title}, Date: {Date}, Url: {Url}, Image: {Image}]"; - } - } -} diff --git a/NitroxLauncher/Models/NitroxChangelog.cs b/NitroxLauncher/Models/NitroxChangelog.cs deleted file mode 100644 index 50aa1d1d46..0000000000 --- a/NitroxLauncher/Models/NitroxChangelog.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; - -namespace NitroxLauncher.Models -{ - [Serializable] - internal class NitroxChangelog - { - public string Version { get; } - - public DateTime Released { get; } - - public string PatchNotes { get; } - - protected NitroxChangelog() - { - // Constructor for serialization. Has to be "protected" for json serialization. - } - - public NitroxChangelog(string version, DateTime released, string patchnotes) - { - Version = version; - Released = released; - PatchNotes = patchnotes; - } - - public override string ToString() - { - return $"[{nameof(NitroxChangelog)} - Version: {Version}, Released: {Released}, PatchNotes: {PatchNotes}]"; - } - } -} diff --git a/NitroxLauncher/Models/NitroxRelease.cs b/NitroxLauncher/Models/NitroxRelease.cs deleted file mode 100644 index 24e00c4479..0000000000 --- a/NitroxLauncher/Models/NitroxRelease.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; - -namespace NitroxLauncher.Models -{ - [Serializable] - internal class NitroxRelease - { - public string Url { get; } - - public string Version { get; } - - public string FileSize { get; } - - public string Md5 { get; } - - protected NitroxRelease() - { - // Constructor for serialization. Has to be "protected" for json serialization. - } - - public NitroxRelease(string url, string version, string filesize, string md5) - { - Url = url; - Version = version; - FileSize = filesize; - Md5 = md5; - } - - public override string ToString() - { - return $"[{nameof(NitroxRelease)} - Url: {Url}, Version: {Version}, FileSize: {FileSize}, Md5: {Md5}]"; - } - } -} diff --git a/NitroxLauncher/Models/PageBase.cs b/NitroxLauncher/Models/PageBase.cs deleted file mode 100644 index 6423159367..0000000000 --- a/NitroxLauncher/Models/PageBase.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.ComponentModel; -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Windows.Controls; -using System.Windows.Navigation; - -namespace NitroxLauncher.Models -{ - public abstract class PageBase : Page, INotifyPropertyChanged - { - public event PropertyChangedEventHandler PropertyChanged; - - /// - /// Opens default browser with a specific link - /// - protected void OnRequestNavigate(object sender, RequestNavigateEventArgs e) - { - Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri)); - e.Handled = true; - } - - protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } -} diff --git a/NitroxLauncher/Models/Patching/NitroxEntryPatch.cs b/NitroxLauncher/Models/Patching/NitroxEntryPatch.cs deleted file mode 100644 index f49569d5aa..0000000000 --- a/NitroxLauncher/Models/Patching/NitroxEntryPatch.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using dnlib.DotNet; -using dnlib.DotNet.Emit; -using NitroxModel.Platforms.OS.Shared; -using FileAttributes = System.IO.FileAttributes; - -namespace NitroxLauncher.Models.Patching -{ - internal sealed class NitroxEntryPatch - { - public const string GAME_ASSEMBLY_NAME = "Assembly-CSharp.dll"; - public const string NITROX_ASSEMBLY_NAME = "NitroxPatcher.dll"; - public const string GAME_ASSEMBLY_MODIFIED_NAME = "Assembly-CSharp-Nitrox.dll"; - - private const string NITROX_ENTRY_TYPE_NAME = "Main"; - private const string NITROX_ENTRY_METHOD_NAME = "Execute"; - - private const string GAME_INPUT_TYPE_NAME = "GameInput"; - private const string GAME_INPUT_METHOD_NAME = "Awake"; - - private const string NITROX_EXECUTE_INSTRUCTION = "System.Void NitroxPatcher.Main::Execute()"; - - private readonly Func subnauticaBasePathFunc; - private string subnauticaManagedPath => Path.Combine(subnauticaBasePathFunc(), "Subnautica_Data", "Managed"); - - public bool IsApplied => IsPatchApplied(); - - public NitroxEntryPatch(Func subnauticaBasePathFunc) - { - this.subnauticaBasePathFunc = subnauticaBasePathFunc; - } - - public void Apply() - { - string assemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_NAME); - string nitroxPatcherPath = Path.Combine(subnauticaManagedPath, NITROX_ASSEMBLY_NAME); - string modifiedAssemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_MODIFIED_NAME); - - if (File.Exists(modifiedAssemblyCSharp)) - { - File.Delete(modifiedAssemblyCSharp); - } - - using (ModuleDefMD module = ModuleDefMD.Load(assemblyCSharp)) - using (ModuleDefMD nitroxPatcherAssembly = ModuleDefMD.Load(nitroxPatcherPath)) - { - TypeDef nitroxMainDefinition = nitroxPatcherAssembly.GetTypes().FirstOrDefault(x => x.Name == NITROX_ENTRY_TYPE_NAME); - MethodDef executeMethodDefinition = nitroxMainDefinition.Methods.FirstOrDefault(x => x.Name == NITROX_ENTRY_METHOD_NAME); - - MemberRef executeMethodReference = module.Import(executeMethodDefinition); - - TypeDef gameInputType = module.GetTypes().First(x => x.FullName == GAME_INPUT_TYPE_NAME); - MethodDef awakeMethod = gameInputType.Methods.First(x => x.Name == GAME_INPUT_METHOD_NAME); - - Instruction callNitroxExecuteInstruction = OpCodes.Call.ToInstruction(executeMethodReference); - - awakeMethod.Body.Instructions.Insert(0, callNitroxExecuteInstruction); - module.Write(modifiedAssemblyCSharp); - } - - // The assembly might be used by other code or some other program might work in it. Retry to be on the safe side. - Exception error = RetryWait(() => File.Delete(assemblyCSharp), 100, 5); - if (error != null) - { - throw error; - } - FileSystem.Instance.ReplaceFile(modifiedAssemblyCSharp, assemblyCSharp); - } - - private Exception RetryWait(Action action, int interval, int retries = 0) - { - Exception lastException = null; - while (retries >= 0) - { - try - { - retries--; - action(); - return null; - } - catch (Exception ex) - { - lastException = ex; - Task.Delay(interval).Wait(); - } - } - return lastException; - } - - public void Remove() - { - string assemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_NAME); - string modifiedAssemblyCSharp = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_MODIFIED_NAME); - - using (ModuleDefMD module = ModuleDefMD.Load(assemblyCSharp)) - { - TypeDef gameInputType = module.GetTypes().First(x => x.FullName == GAME_INPUT_TYPE_NAME); - MethodDef awakeMethod = gameInputType.Methods.First(x => x.Name == GAME_INPUT_METHOD_NAME); - - IList methodInstructions = awakeMethod.Body.Instructions; - int nitroxExecuteInstructionIndex = FindNitroxExecuteInstructionIndex(methodInstructions); - - if (nitroxExecuteInstructionIndex == -1) - { - return; - } - - methodInstructions.RemoveAt(nitroxExecuteInstructionIndex); - module.Write(modifiedAssemblyCSharp); - - File.SetAttributes(assemblyCSharp, FileAttributes.Normal); - } - - FileSystem.Instance.ReplaceFile(modifiedAssemblyCSharp, assemblyCSharp); - } - - private static int FindNitroxExecuteInstructionIndex(IList methodInstructions) - { - for (int instructionIndex = 0; instructionIndex < methodInstructions.Count; instructionIndex++) - { - string instruction = methodInstructions[instructionIndex].Operand?.ToString(); - - if (instruction == NITROX_EXECUTE_INSTRUCTION) - { - return instructionIndex; - } - } - - return -1; - } - - private bool IsPatchApplied() - { - string gameInputPath = Path.Combine(subnauticaManagedPath, GAME_ASSEMBLY_NAME); - - using (ModuleDefMD module = ModuleDefMD.Load(gameInputPath)) - { - TypeDef gameInputType = module.GetTypes().First(x => x.FullName == GAME_INPUT_TYPE_NAME); - MethodDef awakeMethod = gameInputType.Methods.First(x => x.Name == GAME_INPUT_METHOD_NAME); - - return awakeMethod.Body.Instructions.Any(instruction => instruction.Operand?.ToString() == NITROX_EXECUTE_INSTRUCTION); - } - } - } -} diff --git a/NitroxLauncher/Models/Properties/ButtonProperties.cs b/NitroxLauncher/Models/Properties/ButtonProperties.cs deleted file mode 100644 index 704fe46836..0000000000 --- a/NitroxLauncher/Models/Properties/ButtonProperties.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Windows; - -namespace NitroxLauncher.Models.Properties -{ - public static class ButtonProperties - { - public static readonly DependencyProperty SelectedProperty = - DependencyProperty.RegisterAttached("Selected", typeof(bool), typeof(ButtonProperties), new UIPropertyMetadata(default(bool))); - - public static bool GetSelected(DependencyObject obj) - { - return (bool)obj.GetValue(SelectedProperty); - } - - public static void SetSelected(DependencyObject obj, bool value) - { - obj.SetValue(SelectedProperty, value); - } - } -} diff --git a/NitroxLauncher/Models/Utils/QModHelper.cs b/NitroxLauncher/Models/Utils/QModHelper.cs deleted file mode 100644 index 5b52af53d0..0000000000 --- a/NitroxLauncher/Models/Utils/QModHelper.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.IO; - -namespace NitroxLauncher.Models.Utils -{ - internal static class QModHelper - { - internal static bool IsQModInstalled(string subnauticaBasePath) - { - string subnauticaQModManagerPath = Path.Combine(subnauticaBasePath, "Bepinex/plugins/QModManager"); - return Directory.Exists(subnauticaQModManagerPath); - } - } -} diff --git a/NitroxLauncher/Models/Utils/WindowsHelper.cs b/NitroxLauncher/Models/Utils/WindowsHelper.cs deleted file mode 100644 index b1b8cbda18..0000000000 --- a/NitroxLauncher/Models/Utils/WindowsHelper.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; -using System.Reflection; -using System.Security.Principal; -using System.Windows; -using WindowsFirewallHelper; -using WindowsFirewallHelper.Addresses; -using WindowsFirewallHelper.FirewallRules; -using NitroxModel; -using NitroxModel.Helper; - -namespace NitroxLauncher.Models.Utils -{ - internal static class WindowsHelper - { - public static string ProgramFileDirectory = Environment.ExpandEnvironmentVariables("%ProgramW6432%"); - - private const string HAMACHI_FIREWALL_RULE_NAME = "HamachiNitrox"; - - internal static bool IsAppRunningInAdmin() - { - WindowsPrincipal wp = new(WindowsIdentity.GetCurrent()); - return wp.IsInRole(WindowsBuiltInRole.Administrator); - } - - internal static void RestartAsAdmin() - { - if (!IsAppRunningInAdmin()) - { - MessageBoxResult result = MessageBox.Show( - "Nitrox launcher should be executed with administrator permissions in order to properly patch Subnautica while in Program Files directory, do you want to restart ?", - "Nitrox needs permissions", - MessageBoxButton.YesNo, - MessageBoxImage.Question, - MessageBoxResult.Yes, - MessageBoxOptions.DefaultDesktopOnly - ); - - if (result == MessageBoxResult.Yes) - { - try - { - // Setting up start info of the new process of the same application - ProcessStartInfo processStartInfo = new(Assembly.GetEntryAssembly().CodeBase); - - // Using operating shell and setting the ProcessStartInfo.Verb to “runas” will let it run as admin - processStartInfo.UseShellExecute = true; - processStartInfo.Verb = "runas"; - - // Start the application as new process - Process.Start(processStartInfo); - Environment.Exit(1); - } - catch (Exception ex) - { - Log.Error(ex, "Error while trying to instance an admin processus of the launcher, aborting"); - } - } - } - else - { - Log.Info("Can't restart the launcher as administrator, we already have permissions"); - } - } - - internal static void CheckFirewallRules() - { - try - { - CheckFirewallRules(FirewallDirection.Inbound); - CheckFirewallRules(FirewallDirection.Outbound); - } - catch (FileNotFoundException ex) - { - Log.Warn($"Tried to add firewall rule for program that does not exist: {ex.FileName}"); - } - catch (UnauthorizedAccessException) - { - MessageBox.Show("Try restarting the launcher as administrator or manually adding firewall rules for Nitrox programs. This warning won't be shown again.", "Error adding Windows Firewall rules", MessageBoxButton.OK, MessageBoxImage.Warning); - } - } - - private static void CheckFirewallRules(FirewallDirection direction) - { - CheckClientFirewallRules(direction); - CheckServerFirewallRules(direction); - CheckHamachiFirewallRules(direction); - } - - private static void CheckServerFirewallRules(FirewallDirection direction) - { - string serverPath = Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), ServerLogic.SERVER_EXECUTABLE); - - AddExclusiveFirewallRule(Path.GetFileName(serverPath), serverPath, direction); - } - - private static void CheckClientFirewallRules(FirewallDirection direction) - { - string clientPath = Path.Combine(NitroxUser.GamePath, GameInfo.Subnautica.ExeName); - - AddExclusiveFirewallRule(Path.GetFileName(clientPath), clientPath, direction); - } - - private static void CheckHamachiFirewallRules(FirewallDirection direction) - { - static IPAddress GetHamachiAddress() - { - foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) - { - if (networkInterface.Name == "Hamachi") - { - foreach (UnicastIPAddressInformation addressInfo in networkInterface.GetIPProperties().UnicastAddresses) - { - if (addressInfo.Address.AddressFamily == AddressFamily.InterNetwork) - { - return addressInfo.Address; - } - } - } - } - - return null; - } - - if (GetHamachiAddress() == null || !FirewallWASRule.IsLocallySupported) - { - return; - } - - foreach (IFirewallRule firewallRule in FirewallManager.Instance.Rules) - { - if (firewallRule.Name == HAMACHI_FIREWALL_RULE_NAME) - { - return; - } - } - - FirewallWASRule rule = new(HAMACHI_FIREWALL_RULE_NAME, FirewallAction.Allow, direction, FirewallManager.Instance.GetActiveProfile().Type) - { - Description = "Ignore Hamachi network", - Protocol = FirewallProtocol.Any, - RemoteAddresses = new[] { new NetworkAddress(GetHamachiAddress()) }, - IsEnable = true - }; - - FirewallManager.Instance.Rules.Add(rule); - } - - private static void AddExclusiveFirewallRule(string name, string filePath, FirewallDirection direction) - { - if (!File.Exists(filePath)) - { - throw new FileNotFoundException("Unable to add firewall rule to non-existent program", filePath); - } - if (!FirewallRuleExists(name, filePath, direction)) - { - AddFirewallRule(name, filePath, direction); - } - } - - private static bool FirewallRuleExists(string name, string programPath, FirewallDirection direction) => FirewallManager.Instance.Rules.Any(rule => rule.FriendlyName == name && rule.Direction == direction && (programPath?.Equals(rule.ApplicationName, StringComparison.InvariantCultureIgnoreCase) ?? true)); - - private static void AddFirewallRule(string name, string filePath, FirewallDirection direction) - { - IFirewallRule rule = FirewallManager.Instance.CreateApplicationRule(name, FirewallAction.Allow, filePath); - rule.Direction = direction; - rule.Protocol = FirewallProtocol.Any; - rule.IsEnable = true; - - FirewallManager.Instance.Rules.Add(rule); - } - } -} diff --git a/NitroxLauncher/Models/WpfControlHelper.cs b/NitroxLauncher/Models/WpfControlHelper.cs deleted file mode 100644 index 97aa13131a..0000000000 --- a/NitroxLauncher/Models/WpfControlHelper.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Windows.Controls; -using System.Windows.Media; - -namespace NitroxLauncher.Models -{ - public static class WpfControlHelper - { - public static T FindDataContextInAncestors(this Control control) - { - do - { - if (control.DataContext is T) - { - return (T)control.DataContext; - } - control = VisualTreeHelper.GetParent(control) as Control; - } while (control != null); - - return default; - } - } -} diff --git a/NitroxLauncher/NitroxLauncher.csproj b/NitroxLauncher/NitroxLauncher.csproj deleted file mode 100644 index 966d321e9d..0000000000 --- a/NitroxLauncher/NitroxLauncher.csproj +++ /dev/null @@ -1,72 +0,0 @@ - - - - - $(LangVersion) - WinExe - net472 - disable - true - icon.ico - True - Nitrox - Nitrox - https://github.com/SubnauticaNitrox/Nitrox - - - - - - - - - - - - - - PreserveNewest - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/NitroxLauncher/Pages/BlogPage.xaml b/NitroxLauncher/Pages/BlogPage.xaml deleted file mode 100644 index 0f83878ced..0000000000 --- a/NitroxLauncher/Pages/BlogPage.xaml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - Read the latest news from the Nitrox team! You can view all our blogs here - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/NitroxLauncher/Pages/BlogPage.xaml.cs b/NitroxLauncher/Pages/BlogPage.xaml.cs deleted file mode 100644 index 649c1ba7c0..0000000000 --- a/NitroxLauncher/Pages/BlogPage.xaml.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using NitroxLauncher.Models; - -namespace NitroxLauncher.Pages -{ - public partial class BlogPage : PageBase - { - public static readonly Uri BLOGS_LINK = new("https://nitroxblog.rux.gg/"); - - private readonly ObservableCollection nitroxBlogs = new(); - - public BlogPage() - { - InitializeComponent(); - - Blogs.ItemsSource = nitroxBlogs; - - Dispatcher?.BeginInvoke(new Action(async () => - { - try - { - IList blogs = await Downloader.GetBlogs(); - - foreach (NitroxBlog blog in blogs) - { - nitroxBlogs.Add(blog); - } - } - catch (Exception ex) - { - Log.Error(ex, "Error while trying to display nitrox blogs"); - } - } - )); - } - } -} diff --git a/NitroxLauncher/Pages/CommunityPage.xaml b/NitroxLauncher/Pages/CommunityPage.xaml deleted file mode 100644 index 2529df520a..0000000000 --- a/NitroxLauncher/Pages/CommunityPage.xaml +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/NitroxLauncher/Pages/CommunityPage.xaml.cs b/NitroxLauncher/Pages/CommunityPage.xaml.cs deleted file mode 100644 index 28ff7ba046..0000000000 --- a/NitroxLauncher/Pages/CommunityPage.xaml.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using NitroxLauncher.Models; - -namespace NitroxLauncher.Pages -{ - public partial class CommunityPage : PageBase - { - public static readonly Uri DISCORD_LINK = new("https://discord.gg/E8B4X9s"); - public static readonly Uri TWITTER_LINK = new("https://twitter.com/modnitrox"); - public static readonly Uri REDDIT_LINK = new("https://reddit.com/r/SubnauticaNitrox"); - public static readonly Uri GITHUB_LINK = new("https://github.com/SubnauticaNitrox/Nitrox"); - - public CommunityPage() - { - InitializeComponent(); - } - } -} diff --git a/NitroxLauncher/Pages/LaunchGamePage.xaml b/NitroxLauncher/Pages/LaunchGamePage.xaml deleted file mode 100644 index 24a5644867..0000000000 --- a/NitroxLauncher/Pages/LaunchGamePage.xaml +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Sunrunner, Killzoms, Jannify, Marijn, Measurity, MadMax, Nes, _HeN_, dartasen, CatSZekely, Garsia, RabidCrab, AquariusSidhe, Amarok, iCleeem, Tornac, TwinBuilderOne - - - - - Werewolfs, Shalix, CriticalCookie, InfamousJay, and a big thanks to the discord support team (Artic-Peepers, Peepers) - - - - - Rux - - - - - - - - \ No newline at end of file diff --git a/NitroxLauncher/Pages/LaunchGamePage.xaml.cs b/NitroxLauncher/Pages/LaunchGamePage.xaml.cs deleted file mode 100644 index 365cbb3504..0000000000 --- a/NitroxLauncher/Pages/LaunchGamePage.xaml.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.ComponentModel; -using System.Windows; -using NitroxLauncher.Models; -using NitroxModel; -using NitroxModel.Discovery.Models; -using NitroxModel.Helper; - -namespace NitroxLauncher.Pages -{ - public partial class LaunchGamePage : PageBase - { - public string PlatformToolTip => GamePlatform.GetAttribute()?.Description ?? "Unknown"; - public Platform GamePlatform => NitroxUser.GamePlatform?.Platform ?? Platform.NONE; - public string Version => $"{LauncherLogic.ReleasePhase} {LauncherLogic.Version}"; - - public LaunchGamePage() - { - InitializeComponent(); - - Loaded += (s, e) => - { - LauncherLogic.Config.PropertyChanged += LogicPropertyChanged; - LogicPropertyChanged(null, null); - }; - - Unloaded += (s, e) => - { - LauncherLogic.Config.PropertyChanged -= LogicPropertyChanged; - }; - } - - private async void SinglePlayerButton_Click(object sender, RoutedEventArgs e) - { - try - { - await LauncherLogic.Instance.StartSingleplayerAsync(); - } - catch (Exception ex) - { - MessageBox.Show(ex.ToString(), "Error while starting in singleplayer mode", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private async void MultiplayerButton_Click(object sender, RoutedEventArgs e) - { - try - { - await LauncherLogic.Instance.StartMultiplayerAsync(); - } - catch (Exception ex) - { - MessageBox.Show(ex.ToString(), "Error while starting in multiplayer mode", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private void LogicPropertyChanged(object sender, PropertyChangedEventArgs args) - { - OnPropertyChanged(nameof(GamePlatform)); - OnPropertyChanged(nameof(PlatformToolTip)); - } - } -} diff --git a/NitroxLauncher/Pages/LibraryPage.xaml b/NitroxLauncher/Pages/LibraryPage.xaml deleted file mode 100644 index 48cf02c609..0000000000 --- a/NitroxLauncher/Pages/LibraryPage.xaml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/NitroxLauncher/Pages/LibraryPage.xaml.cs b/NitroxLauncher/Pages/LibraryPage.xaml.cs deleted file mode 100644 index d7696c7c97..0000000000 --- a/NitroxLauncher/Pages/LibraryPage.xaml.cs +++ /dev/null @@ -1,12 +0,0 @@ -using NitroxLauncher.Models; - -namespace NitroxLauncher.Pages -{ - public partial class LibraryPage : PageBase - { - public LibraryPage() - { - InitializeComponent(); - } - } -} diff --git a/NitroxLauncher/Pages/OptionPage.xaml b/NitroxLauncher/Pages/OptionPage.xaml deleted file mode 100644 index 74c37095b3..0000000000 --- a/NitroxLauncher/Pages/OptionPage.xaml +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/NitroxLauncher/Pages/ServerConsolePage.xaml.cs b/NitroxLauncher/Pages/ServerConsolePage.xaml.cs deleted file mode 100644 index b675f15d5e..0000000000 --- a/NitroxLauncher/Pages/ServerConsolePage.xaml.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text; -using System.Windows; -using System.Windows.Input; -using NitroxLauncher.Models; -using NitroxLauncher.Models.Events; - -namespace NitroxLauncher.Pages -{ - public partial class ServerConsolePage : PageBase - { - private readonly List commandLinesHistory = new(); - private readonly StringBuilder serverOutput = new(""); - private string commandInputText; - private int commandHistoryIndex; - - public string ServerOutput - { - get => serverOutput.ToString(); - set - { - serverOutput.AppendLine(value); - OnPropertyChanged(); - Dispatcher?.BeginInvoke(new Action(() => ConsoleWindowScrollView.ScrollToEnd())); - } - } - - public string CommandInputText - { - get => commandInputText; - set - { - commandInputText = value; - OnPropertyChanged(); - } - } - - public int CommandHistoryIndex - { - get => commandHistoryIndex; - set - { - commandHistoryIndex = Math.Min(Math.Max(value, 0), commandLinesHistory.Count); - if (commandHistoryIndex >= commandLinesHistory.Count) - { - // Out of bounds index means command history is disabled - CommandInputText = string.Empty; - } - else - { - CommandInputText = commandLinesHistory[commandHistoryIndex]; - // Move cursor at the end of the text - CommandInput.SelectionStart = CommandInputText.Length; - CommandInput.SelectionLength = 0; - } - - OnPropertyChanged(); - } - } - - public ServerConsolePage() - { - InitializeComponent(); - - LauncherLogic.Server.ServerStarted += ServerStarted; - LauncherLogic.Server.ServerDataReceived += ServerDataReceived; - - Loaded += (sender, args) => - { - CommandInput.Focus(); - }; - } - - private void ServerStarted(object sender, ServerStartEventArgs e) - { - ServerOutput = string.Empty; - } - - private void ServerDataReceived(object sender, DataReceivedEventArgs e) - { - // TODO: Change to virtualized textboxes per line. - // This sucks for performance reasons. Every string concat in .NET will create a NEW string in memory. - ServerOutput = e.Data; - } - - private void SendCommandInputToServer() - { - ServerOutput = CommandInputText; - LauncherLogic.Server.SendServerCommand(CommandInputText); - - // Deduplication of command history - if (!string.IsNullOrWhiteSpace(CommandInputText) && CommandInputText != commandLinesHistory.LastOrDefault()) - { - commandLinesHistory.Add(CommandInputText); - } - - HideCommandHistory(); - } - - private void HideCommandHistory() - { - CommandHistoryIndex = commandLinesHistory.Count; - } - - private void CommandButton_OnClick(object sender, RoutedEventArgs e) - { - SendCommandInputToServer(); - } - - private void StopButton_Click(object sender, RoutedEventArgs e) - { - // Suggest referencing NitroxServer.ConsoleCommands.ExitCommand.name, but the class is internal - LauncherLogic.Server.SendServerCommand("stop"); - commandLinesHistory.Add("stop"); - HideCommandHistory(); - } - - private void CommandLine_PreviewKeyDown(object sender, KeyEventArgs e) - { - e.Handled = true; - - switch (e.Key) - { - case Key.Enter: - SendCommandInputToServer(); - break; - - case Key.Escape: - HideCommandHistory(); - break; - - case Key.Up: - CommandHistoryIndex--; - break; - - case Key.Down: - CommandHistoryIndex++; - break; - - default: - e.Handled = false; - break; - } - } - } -} diff --git a/NitroxLauncher/Pages/ServerPage.xaml b/NitroxLauncher/Pages/ServerPage.xaml deleted file mode 100644 index 41506c8e3f..0000000000 --- a/NitroxLauncher/Pages/ServerPage.xaml +++ /dev/null @@ -1,987 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Welcome to your Nitrox server! For more information, please refer to the Wiki - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/NitroxLauncher/Pages/UpdatePage.xaml.cs b/NitroxLauncher/Pages/UpdatePage.xaml.cs deleted file mode 100644 index 47e083650b..0000000000 --- a/NitroxLauncher/Pages/UpdatePage.xaml.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Windows; -using NitroxLauncher.Models; - -namespace NitroxLauncher.Pages -{ - public partial class UpdatePage : PageBase - { - public static readonly Uri WEBSITE_LINK = new("https://nitrox.rux.gg/download"); - - private readonly ObservableCollection nitroxChangelogs = new(); - - public string Version => LauncherLogic.Version; - - public UpdatePage() - { - InitializeComponent(); - - Changelogs.ItemsSource = nitroxChangelogs; - - Dispatcher?.BeginInvoke(new Action(async () => - { - try - { - IList changelogs = await Downloader.GetChangeLogs(); - - foreach (NitroxChangelog changelog in changelogs) - { - nitroxChangelogs.Add(changelog); - } - } - catch (Exception ex) - { - Log.Error(ex, "Error while trying to display nitrox changelogs"); - } - })); - - Loaded += (s, e) => - { - LauncherLogic.Config.PropertyChanged += OnLogicPropertyChanged; - OnLogicPropertyChanged(null, null); - }; - - Unloaded += (s, e) => - { - LauncherLogic.Config.PropertyChanged -= OnLogicPropertyChanged; - }; - } - - private void OnLogicPropertyChanged(object sender, PropertyChangedEventArgs args) - { - if (LauncherLogic.Config.IsUpToDate) - { - UpdateAvailableBox.Visibility = Visibility.Hidden; - NoUpdateAvailableBox.Visibility = Visibility.Visible; - } - else - { - NoUpdateAvailableBox.Visibility = Visibility.Hidden; - UpdateAvailableBox.Visibility = Visibility.Visible; - } - } - } -} diff --git a/NitroxLauncher/Properties/AssemblyInfo.cs b/NitroxLauncher/Properties/AssemblyInfo.cs deleted file mode 100644 index 4a05c7d474..0000000000 --- a/NitroxLauncher/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Windows; - -[assembly: ThemeInfo( - ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located - //(used if a resource is not found in the page, - // or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located - //(used if a resource is not found in the page, - // app, or any theme specific resource dictionaries) -)] \ No newline at end of file diff --git a/NitroxLauncher/Properties/Resources.Designer.cs b/NitroxLauncher/Properties/Resources.Designer.cs deleted file mode 100644 index 5fd3e6c9fc..0000000000 --- a/NitroxLauncher/Properties/Resources.Designer.cs +++ /dev/null @@ -1,60 +0,0 @@ -//------------------------------------------------------------------------------ -// -// Dieser Code wurde von einem Tool generiert. -// Laufzeitversion:4.0.30319.42000 -// -// Änderungen an dieser Datei können falsches Verhalten verursachen und gehen verloren, wenn -// der Code erneut generiert wird. -// -//------------------------------------------------------------------------------ - -namespace NitroxLauncher.Properties { - /// - /// Eine stark typisierte Ressourcenklasse zum Suchen von lokalisierten Zeichenfolgen usw. - /// - // Diese Klasse wurde von der StronglyTypedResourceBuilder automatisch generiert - // -Klasse über ein Tool wie ResGen oder Visual Studio automatisch generiert. - // Um einen Member hinzuzufügen oder zu entfernen, bearbeiten Sie die .ResX-Datei und führen dann ResGen - // mit der /str-Option erneut aus, oder Sie erstellen Ihr VS-Projekt neu. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Gibt die zwischengespeicherte ResourceManager-Instanz zurück, die von dieser Klasse verwendet wird. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("NitroxLauncher.Properties.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Überschreibt die CurrentUICulture-Eigenschaft des aktuellen Threads für alle - /// Ressourcenzuordnungen, die diese stark typisierte Ressourcenklasse verwenden. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/NitroxLauncher/Properties/Resources.resx b/NitroxLauncher/Properties/Resources.resx deleted file mode 100644 index af7dbebbac..0000000000 --- a/NitroxLauncher/Properties/Resources.resx +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/NitroxLauncher/Properties/Settings.Designer.cs b/NitroxLauncher/Properties/Settings.Designer.cs deleted file mode 100644 index c544e06a60..0000000000 --- a/NitroxLauncher/Properties/Settings.Designer.cs +++ /dev/null @@ -1,50 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace NitroxLauncher.Properties { - - - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "15.9.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { - - private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default { - get { - return defaultInstance; - } - } - - [global::System.Configuration.UserScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("True")] - public bool IsExternalServer { - get { - return ((bool)(this["IsExternalServer"])); - } - set { - this["IsExternalServer"] = value; - } - } - - [global::System.Configuration.UserScopedSettingAttribute()] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("-vrmode none")] - public string LaunchArgs { - get { - return ((string)(this["LaunchArgs"])); - } - set { - this["LaunchArgs"] = value; - } - } - } -} diff --git a/NitroxLauncher/Properties/Settings.settings b/NitroxLauncher/Properties/Settings.settings deleted file mode 100644 index 8ac9feba4d..0000000000 --- a/NitroxLauncher/Properties/Settings.settings +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - True - - - -vrmode none - - - \ No newline at end of file diff --git a/NitroxLauncher/Properties/launchSettings.json b/NitroxLauncher/Properties/launchSettings.json deleted file mode 100644 index e169bceeec..0000000000 --- a/NitroxLauncher/Properties/launchSettings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "profiles": { - "NitroxLauncher": { - "commandName": "Project" - }, - "InstantLaunch": { - "commandName": "Project", - "commandLineArgs": "-instantlaunch \"world\"" - } - } -} \ No newline at end of file diff --git a/NitroxLauncher/ServerLogic.cs b/NitroxLauncher/ServerLogic.cs deleted file mode 100644 index a7b28c3599..0000000000 --- a/NitroxLauncher/ServerLogic.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Reflection; -using NitroxLauncher.Models.Events; - -namespace NitroxLauncher -{ - internal sealed class ServerLogic : IDisposable - { - public const string SERVER_EXECUTABLE = "NitroxServer-Subnautica.exe"; - - public bool IsManagedByLauncher => IsEmbedded && IsServerRunning; - public bool IsServerRunning => !serverProcess?.HasExited ?? false; - public bool IsEmbedded { get; private set; } - - public event EventHandler ServerStarted; - public event DataReceivedEventHandler ServerDataReceived; - public event EventHandler ServerExited; - - private Process serverProcess; - - public void Dispose() - { - if (IsEmbedded) - { - SendServerCommand("stop\n"); - } - - serverProcess?.Dispose(); - serverProcess = null; - } - - internal Process StartServer(bool standalone, string saveDir) - { - if (IsServerRunning) - { - throw new Exception("An instance of Nitrox Server is already running"); - } - - string launcherDir = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); - string serverPath = Path.Combine(launcherDir, SERVER_EXECUTABLE); - ProcessStartInfo startInfo = new(serverPath); - startInfo.WorkingDirectory = launcherDir; - - if (!standalone) - { - startInfo.UseShellExecute = false; - startInfo.RedirectStandardOutput = true; - startInfo.RedirectStandardInput = true; - startInfo.CreateNoWindow = true; - } - - startInfo.Arguments = $@"""{saveDir}"""; - - serverProcess = Process.Start(startInfo); - if (serverProcess != null) - { - serverProcess.EnableRaisingEvents = true; // Required for 'Exited' event from process. - - if (!standalone) - { - serverProcess.OutputDataReceived += ServerProcessOnOutputDataReceived; - serverProcess.BeginOutputReadLine(); - } - - serverProcess.Exited += (sender, args) => OnEndServer(); - OnStartServer(!standalone); - } - - return serverProcess; - } - - internal void SendServerCommand(string inputText) - { - if (!IsServerRunning) - { - return; - } - - try - { - serverProcess.StandardInput.WriteLine(inputText); - } - catch (Exception ex) - { - Log.Error(ex); - } - } - - private void OnEndServer() - { - IsEmbedded = false; - ServerExited?.Invoke(serverProcess, new EventArgs()); - } - - private void OnStartServer(bool embedded) - { - IsEmbedded = embedded; - ServerStarted?.Invoke(serverProcess, new ServerStartEventArgs(embedded)); - } - - private void ServerProcessOnOutputDataReceived(object sender, DataReceivedEventArgs e) - { - ServerDataReceived?.Invoke(sender, e); - } - } -} diff --git a/NitroxLauncher/icon.ico b/NitroxLauncher/icon.ico deleted file mode 100644 index 65f8a13945..0000000000 Binary files a/NitroxLauncher/icon.ico and /dev/null differ diff --git a/NitroxModel-Subnautica/DataStructures/GameLogic/Entities/SubnauticaUwePrefabFactory.cs b/NitroxModel-Subnautica/DataStructures/GameLogic/Entities/SubnauticaUwePrefabFactory.cs index c9820d1c72..416523a7f2 100644 --- a/NitroxModel-Subnautica/DataStructures/GameLogic/Entities/SubnauticaUwePrefabFactory.cs +++ b/NitroxModel-Subnautica/DataStructures/GameLogic/Entities/SubnauticaUwePrefabFactory.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.Threading; using LitJson; using NitroxModel.DataStructures.GameLogic.Entities; +using NitroxModel.Helper; using static LootDistributionData; namespace NitroxModel_Subnautica.DataStructures.GameLogic.Entities; @@ -52,7 +51,10 @@ public bool TryGetPossiblePrefabs(string biome, out List prefabs) private LootDistributionData GetLootDistributionData(string lootDistributionJson) { - ForceCultureOverride(); + // LitJson uses the computer's local CultureInfo when parsing the JSON files. + // However, these json files were saved in en_US. Ensure that this is done for the current thread. + CultureManager.ConfigureCultureInfo(); + JsonMapper.RegisterImporter((double value) => Convert.ToSingle(value)); Dictionary result = JsonMapper.ToObject>(lootDistributionJson); @@ -62,19 +64,4 @@ private LootDistributionData GetLootDistributionData(string lootDistributionJson return lootDistributionData; } - - // LitJson uses the computers local CultureInfo when parsing the JSON files. However, - // these json files were saved in en_US. Ensure that this is done for the current thread. - private void ForceCultureOverride() - { - CultureInfo cultureInfo = new CultureInfo("en-US"); - - // Although we loaded the en-US cultureInfo, let's make sure to set these incase the - // default was overriden by the user. - cultureInfo.NumberFormat.NumberDecimalSeparator = "."; - cultureInfo.NumberFormat.NumberGroupSeparator = ","; - - Thread.CurrentThread.CurrentCulture = cultureInfo; - Thread.CurrentThread.CurrentUICulture = cultureInfo; - } } diff --git a/NitroxModel-Subnautica/NitroxModel-Subnautica.csproj b/NitroxModel-Subnautica/NitroxModel-Subnautica.csproj index 8674facb17..54cf29774e 100644 --- a/NitroxModel-Subnautica/NitroxModel-Subnautica.csproj +++ b/NitroxModel-Subnautica/NitroxModel-Subnautica.csproj @@ -1,19 +1,16 @@  - - net472 - NitroxModel_Subnautica - disable - + + net472;netstandard2.0 + NitroxModel_Subnautica + - - - ..\Nitrox.Assets.Subnautica\protobuf-net.dll - - + + + - - - + + + diff --git a/NitroxModel/CallerArgumentExpressionAttribute.cs b/NitroxModel/CallerArgumentExpressionAttribute.cs new file mode 100644 index 0000000000..a8380fe68b --- /dev/null +++ b/NitroxModel/CallerArgumentExpressionAttribute.cs @@ -0,0 +1,12 @@ +namespace System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +internal sealed class CallerArgumentExpressionAttribute : Attribute +{ + public CallerArgumentExpressionAttribute(string parameterName) + { + ParameterName = parameterName; + } + + public string ParameterName { get; } +} diff --git a/NitroxModel/Core/NitroxEnvironment.cs b/NitroxModel/Core/NitroxEnvironment.cs index a17e132d6a..e67679e6cf 100644 --- a/NitroxModel/Core/NitroxEnvironment.cs +++ b/NitroxModel/Core/NitroxEnvironment.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; using System.Reflection; @@ -17,6 +18,15 @@ public static class NitroxEnvironment public static bool IsTesting => Type == Types.TESTING; public static bool IsNormal => Type == Types.NORMAL; + public static int CurrentProcessId + { + get + { + using Process process = Process.GetCurrentProcess(); + return process.Id; + } + } + public static bool IsReleaseMode { get diff --git a/NitroxModel/DataStructures/GameLogic/RandomStartGenerator.cs b/NitroxModel/DataStructures/GameLogic/RandomStartGenerator.cs index bc8b8afd07..a139cb2f1c 100644 --- a/NitroxModel/DataStructures/GameLogic/RandomStartGenerator.cs +++ b/NitroxModel/DataStructures/GameLogic/RandomStartGenerator.cs @@ -1,67 +1,72 @@ using System; using System.Collections.Generic; -using System.Drawing; using NitroxModel.DataStructures.Unity; -namespace NitroxModel.DataStructures.GameLogic +namespace NitroxModel.DataStructures.GameLogic; + +public class RandomStartGenerator { - public class RandomStartGenerator + private readonly IPixelProvider pixelProvider; + + public RandomStartGenerator(IPixelProvider pixelProvider) { - private readonly Bitmap randomStartTexture; + this.pixelProvider = pixelProvider; + } - public RandomStartGenerator(Bitmap randomStartTexture) + public NitroxVector3 GenerateRandomStartPosition(Random rnd) + { + for (int i = 0; i < 1000; i++) { - this.randomStartTexture = randomStartTexture; - } + float normalizedX = (float)rnd.NextDouble(); + float normalizedZ = (float)rnd.NextDouble(); - public NitroxVector3 GenerateRandomStartPosition(Random rnd) - { - for (int i = 0; i < 1000; i++) + if (IsStartPointValid(normalizedX, normalizedZ)) { - float normalizedX = (float)rnd.NextDouble(); - float normalizedZ = (float)rnd.NextDouble(); - - if (IsStartPointValid(normalizedX, normalizedZ)) - { - float x = 4096f * normalizedX - 2048f; // normalizedX = (x + 2048) / 4096 - float z = 4096f * normalizedZ - 2048f; - return new NitroxVector3(x, 0, z); - } + float x = 4096f * normalizedX - 2048f; // normalizedX = (x + 2048) / 4096 + float z = 4096f * normalizedZ - 2048f; + return new NitroxVector3(x, 0, z); } - - return NitroxVector3.Zero; } - public List GenerateRandomStartPositions(string seed) - { + return NitroxVector3.Zero; + } + + public List GenerateRandomStartPositions(string seed) + { + Random rnd = new(seed.GetHashCode()); + List list = new(); - Random rnd = new Random(seed.GetHashCode()); - List list = new List(); + for (int i = 0; i < 1000; i++) + { + float normalizedX = (float)rnd.NextDouble(); + float normalizedZ = (float)rnd.NextDouble(); - for (int i = 0; i < 1000; i++) + if (IsStartPointValid(normalizedX, normalizedZ)) { - float normalizedX = (float)rnd.NextDouble(); - float normalizedZ = (float)rnd.NextDouble(); - - if (IsStartPointValid(normalizedX, normalizedZ)) - { - float x = 4096f * normalizedX - 2048f; // normalizedX = (x + 2048) / 4096 - float z = 4096f * normalizedZ - 2048f; - list.Add(new NitroxVector3(x, 0, z)); - } + float x = 4096f * normalizedX - 2048f; // normalizedX = (x + 2048) / 4096 + float z = 4096f * normalizedZ - 2048f; + list.Add(new NitroxVector3(x, 0, z)); } - - return list; } - private bool IsStartPointValid(float normalizedX, float normalizedZ) - { - int textureX = (int)(normalizedX * (float)512); - int textureZ = (int)(normalizedZ * (float)512); + return list; + } - Color pixelColor = randomStartTexture.GetPixel(textureX, textureZ); + private bool IsStartPointValid(float normalizedX, float normalizedZ) + { + int textureX = (int)(normalizedX * 512); + int textureZ = (int)(normalizedZ * 512); - return pixelColor.G > 127; - } + return pixelProvider.GetGreen(textureX, textureZ) > 127; + } + + /// + /// API for getting pixels from an underlying texture. + /// + public interface IPixelProvider + { + byte GetRed(int x, int y); + byte GetGreen(int x, int y); + byte GetBlue(int x, int y); } } diff --git a/NitroxModel/Discovery/GameInstallationFinder.cs b/NitroxModel/Discovery/GameInstallationFinder.cs index 4b64f2ccb2..b0a4deffba 100644 --- a/NitroxModel/Discovery/GameInstallationFinder.cs +++ b/NitroxModel/Discovery/GameInstallationFinder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using NitroxModel.Discovery.InstallationFinders; using NitroxModel.Discovery.InstallationFinders.Core; using NitroxModel.Discovery.Models; @@ -49,6 +50,14 @@ public IEnumerable FindGame(GameInfo gameInfo, GameLibraries g { result = result with { ErrorMessage = $"It appears you don't have {gameInfo.Name} installed" }; } + if (result.Origin == default) + { + result = result with { Origin = wantedFinder }; + } + if (result.Path != null) + { + result = result with { Path = Path.GetFullPath(result.Path) }; + } yield return result; } } diff --git a/NitroxModel/Discovery/GameInstallationHelper.cs b/NitroxModel/Discovery/GameInstallationHelper.cs index 4885e9b272..72bd1cbdd7 100644 --- a/NitroxModel/Discovery/GameInstallationHelper.cs +++ b/NitroxModel/Discovery/GameInstallationHelper.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Runtime.InteropServices; namespace NitroxModel.Discovery; @@ -10,6 +11,10 @@ public static bool HasGameExecutable(string path, GameInfo gameInfo) { return false; } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return File.Exists(Path.Combine(path, "MacOS", gameInfo.ExeName)); + } return File.Exists(Path.Combine(path, gameInfo.ExeName)); } diff --git a/NitroxModel/Discovery/InstallationFinders/ConfigFinder.cs b/NitroxModel/Discovery/InstallationFinders/ConfigFinder.cs index 239de23089..5cf3c837b9 100644 --- a/NitroxModel/Discovery/InstallationFinders/ConfigFinder.cs +++ b/NitroxModel/Discovery/InstallationFinders/ConfigFinder.cs @@ -1,5 +1,4 @@ using NitroxModel.Discovery.InstallationFinders.Core; -using NitroxModel.Discovery.Models; using NitroxModel.Helper; using static NitroxModel.Discovery.InstallationFinders.Core.GameFinderResult; @@ -24,11 +23,6 @@ public GameFinderResult FindGame(GameInfo gameInfo) return Error($"Game installation directory config '{path}' is invalid. Please enter the path to the '{gameInfo.Name}' installation"); } - return Ok(new GameInstallation - { - Path = path, - GameInfo = gameInfo, - Origin = GameLibraries.CONFIG - }); + return Ok(path); } } diff --git a/NitroxModel/Discovery/InstallationFinders/Core/GameFinderResult.cs b/NitroxModel/Discovery/InstallationFinders/Core/GameFinderResult.cs index 846eeaedd6..b78e8b55a8 100644 --- a/NitroxModel/Discovery/InstallationFinders/Core/GameFinderResult.cs +++ b/NitroxModel/Discovery/InstallationFinders/Core/GameFinderResult.cs @@ -9,15 +9,16 @@ namespace NitroxModel.Discovery.InstallationFinders.Core; public sealed record GameFinderResult { - public GameInstallation Installation { get; init; } public string ErrorMessage { get; init; } + public GameLibraries Origin { get; init; } + public string Path { get; init; } /// /// Gets the name of type that made the result. /// public string FinderName { get; init; } = ""; - public bool IsOk => string.IsNullOrWhiteSpace(ErrorMessage) && Installation != null; + public bool IsOk => string.IsNullOrWhiteSpace(ErrorMessage) && !string.IsNullOrWhiteSpace(Path); private GameFinderResult() { @@ -43,13 +44,13 @@ public static GameFinderResult NotFound([CallerFilePath] string callerCodeFile = }; } - public static GameFinderResult Ok([NotNull] GameInstallation installation, [CallerFilePath] string callerCodeFile = "") + public static GameFinderResult Ok([NotNull] string path, [CallerFilePath] string callerCodeFile = "") { - Validate.NotNull(installation); + Validate.NotNull(path); return new GameFinderResult { FinderName = callerCodeFile[(callerCodeFile.LastIndexOf("\\", StringComparison.Ordinal) + 1)..^3], - Installation = installation + Path = path }; } } diff --git a/NitroxModel/Discovery/InstallationFinders/DiscordFinder.cs b/NitroxModel/Discovery/InstallationFinders/DiscordFinder.cs index aadad05f30..bb3a01e656 100644 --- a/NitroxModel/Discovery/InstallationFinders/DiscordFinder.cs +++ b/NitroxModel/Discovery/InstallationFinders/DiscordFinder.cs @@ -1,7 +1,6 @@ using System; using System.IO; using NitroxModel.Discovery.InstallationFinders.Core; -using NitroxModel.Discovery.Models; using static NitroxModel.Discovery.InstallationFinders.Core.GameFinderResult; namespace NitroxModel.Discovery.InstallationFinders; @@ -19,23 +18,13 @@ public GameFinderResult FindGame(GameInfo gameInfo) string path = Path.Combine(localAppdataDirectory, "DiscordGames", gameInfo.Name, "content"); if (GameInstallationHelper.HasValidGameFolder(path, gameInfo)) { - return Ok(new GameInstallation - { - Path = path, - GameInfo = gameInfo, - Origin = GameLibraries.DISCORD - }); + return Ok(path); } path = Path.Combine("C:", "Games", gameInfo.Name, "content"); if (GameInstallationHelper.HasValidGameFolder(path, gameInfo)) { - return Ok(new GameInstallation - { - Path = path, - GameInfo = gameInfo, - Origin = GameLibraries.DISCORD - }); + return Ok(path); } return NotFound(); diff --git a/NitroxModel/Discovery/InstallationFinders/EnvironmentFinder.cs b/NitroxModel/Discovery/InstallationFinders/EnvironmentFinder.cs index dca35fe246..87596b97b2 100644 --- a/NitroxModel/Discovery/InstallationFinders/EnvironmentFinder.cs +++ b/NitroxModel/Discovery/InstallationFinders/EnvironmentFinder.cs @@ -1,6 +1,5 @@ using System; using NitroxModel.Discovery.InstallationFinders.Core; -using NitroxModel.Discovery.Models; using static NitroxModel.Discovery.InstallationFinders.Core.GameFinderResult; namespace NitroxModel.Discovery.InstallationFinders; @@ -27,11 +26,6 @@ public GameFinderResult FindGame(GameInfo gameInfo) return Error($"Game installation directory '{path}' is invalid. Please enter the path to the '{gameInfo.Name}' installation"); } - return Ok(new GameInstallation - { - Path = path, - GameInfo = gameInfo, - Origin = GameLibraries.ENVIRONMENT - }); + return Ok(path); } } diff --git a/NitroxModel/Discovery/InstallationFinders/EpicGamesFinder.cs b/NitroxModel/Discovery/InstallationFinders/EpicGamesFinder.cs index 692ff00a70..e718ea059c 100644 --- a/NitroxModel/Discovery/InstallationFinders/EpicGamesFinder.cs +++ b/NitroxModel/Discovery/InstallationFinders/EpicGamesFinder.cs @@ -2,7 +2,6 @@ using System.IO; using System.Text.RegularExpressions; using NitroxModel.Discovery.InstallationFinders.Core; -using NitroxModel.Discovery.Models; using static NitroxModel.Discovery.InstallationFinders.Core.GameFinderResult; namespace NitroxModel.Discovery.InstallationFinders; @@ -38,12 +37,7 @@ public GameFinderResult FindGame(GameInfo gameInfo) continue; } - return Ok(new GameInstallation - { - Path = matchedPath, - GameInfo = gameInfo, - Origin = GameLibraries.EPIC - }); + return Ok(matchedPath); } } diff --git a/NitroxModel/Discovery/InstallationFinders/MicrosoftFinder.cs b/NitroxModel/Discovery/InstallationFinders/MicrosoftFinder.cs index 493147e54f..714bc3ac50 100644 --- a/NitroxModel/Discovery/InstallationFinders/MicrosoftFinder.cs +++ b/NitroxModel/Discovery/InstallationFinders/MicrosoftFinder.cs @@ -1,6 +1,5 @@ using System.IO; using NitroxModel.Discovery.InstallationFinders.Core; -using NitroxModel.Discovery.Models; using static NitroxModel.Discovery.InstallationFinders.Core.GameFinderResult; namespace NitroxModel.Discovery.InstallationFinders; @@ -19,11 +18,6 @@ public GameFinderResult FindGame(GameInfo gameInfo) return Error($"Game installation directory '{path}' is invalid. Please enter the path to the '{gameInfo.Name}' installation"); } - return Ok(new GameInstallation - { - Path = path, - GameInfo = gameInfo, - Origin = GameLibraries.MICROSOFT - }); + return Ok(path); } } diff --git a/NitroxModel/Discovery/InstallationFinders/SteamFinder.cs b/NitroxModel/Discovery/InstallationFinders/SteamFinder.cs index 32c223cc19..14fc129de0 100644 --- a/NitroxModel/Discovery/InstallationFinders/SteamFinder.cs +++ b/NitroxModel/Discovery/InstallationFinders/SteamFinder.cs @@ -3,7 +3,7 @@ using System.Runtime.InteropServices; using System.Text.RegularExpressions; using NitroxModel.Discovery.InstallationFinders.Core; -using NitroxModel.Discovery.Models; +using NitroxModel.Platforms.OS.Windows; using static NitroxModel.Discovery.InstallationFinders.Core.GameFinderResult; namespace NitroxModel.Discovery.InstallationFinders; @@ -38,24 +38,23 @@ public GameFinderResult FindGame(GameInfo gameInfo) } } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + path = Path.Combine(path, $"{gameInfo.Name}.app", "Contents"); + } if (!GameInstallationHelper.HasValidGameFolder(path, gameInfo)) { return Error($"Path '{path}' known by Steam for '{gameInfo.FullName}' does not point to a valid game file structure"); } - return Ok(new GameInstallation - { - Path = path, - GameInfo = gameInfo, - Origin = GameLibraries.STEAM - }); + return Ok(path); } private static string GetSteamPath() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - string steamPath = Platforms.OS.Windows.Internal.RegistryEx.Read(@"Software\Valve\Steam\SteamPath"); + string steamPath = RegistryEx.Read(@"Software\Valve\Steam\SteamPath"); if (string.IsNullOrWhiteSpace(steamPath)) { diff --git a/NitroxModel/Discovery/Models/GameInstallation.cs b/NitroxModel/Discovery/Models/GameInstallation.cs deleted file mode 100644 index 71c77e5490..0000000000 --- a/NitroxModel/Discovery/Models/GameInstallation.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; - -namespace NitroxModel.Discovery.Models; - -[Serializable] -public sealed class GameInstallation -{ - public string Path { get; init; } - - public GameInfo GameInfo { get; init; } - - public GameLibraries Origin { get; init; } - - public GameInstallation() - { - - } - - public GameInstallation(string path, GameInfo gameInfo, GameLibraries origin) - { - Path = path; - GameInfo = gameInfo; - Origin = origin; - } - - public override string ToString() - { - return $"[Path: '{Path}', Game: '{GameInfo.Name}', Origin: '{Origin}']"; - } -} diff --git a/NitroxModel/Extensions.cs b/NitroxModel/Extensions.cs index d8cb88cfe2..95f78e63c2 100644 --- a/NitroxModel/Extensions.cs +++ b/NitroxModel/Extensions.cs @@ -1,13 +1,27 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; +using NitroxModel.Helper; +using NitroxModel.Serialization; +using NitroxModel.Server; namespace NitroxModel; public static class Extensions { + public static string GetSavesFolderDir(this IKeyValueStore store) + { + if (store == null) + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Nitrox", "saves"); + } + return store.GetValue("SavesFolderDir", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Nitrox", "saves")); + } + public static TAttribute GetAttribute(this Enum value) where TAttribute : Attribute { Type type = value.GetType(); @@ -46,7 +60,7 @@ public static IEnumerable GetUniqueNonCombinatoryFlags(this T flags) where } /// - public static bool IsDefined(this TEnum value) + public static bool IsDefined(this TEnum value) where TEnum : Enum { return Enum.IsDefined(typeof(TEnum), value); } @@ -157,4 +171,14 @@ public static void RemoveWhere(this IDictionary.Shared.Return(toRemove, true); } } + + public static byte[] AsMd5Hash(this string input) + { + using System.Security.Cryptography.MD5 md5 = System.Security.Cryptography.MD5.Create(); + byte[] inputBytes = Encoding.ASCII.GetBytes(input); + return md5.ComputeHash(inputBytes); + } + + public static bool IsHardcore(this SubnauticaServerConfig config) => config.GameMode == NitroxGameMode.HARDCORE; + public static bool IsPasswordRequired(this SubnauticaServerConfig config) => config.ServerPassword != ""; } diff --git a/NitroxModel/GameInfo.cs b/NitroxModel/GameInfo.cs index 38fb6decae..a784efb09e 100644 --- a/NitroxModel/GameInfo.cs +++ b/NitroxModel/GameInfo.cs @@ -1,8 +1,29 @@ -namespace NitroxModel +using System.IO; +using System.Runtime.InteropServices; + +namespace NitroxModel; + +public sealed class GameInfo { - public sealed class GameInfo + public static readonly GameInfo Subnautica; + + public static readonly GameInfo SubnauticaBelowZero; + + public string Name { get; private set; } + + public string FullName { get; private set; } + + public string DataFolder { get; private set; } + + public string ExeName { get; private set; } + + public int SteamAppId { get; private set; } + + public string MsStoreStartUrl { get; private set; } + + static GameInfo() { - public static readonly GameInfo Subnautica = new() + Subnautica = new GameInfo { Name = "Subnautica", FullName = "Subnautica", @@ -12,7 +33,7 @@ public sealed class GameInfo MsStoreStartUrl = @"ms-xbl-38616e6e:\\" }; - public static readonly GameInfo SubnauticaBelowZero = new() + SubnauticaBelowZero = new GameInfo { Name = "SubnauticaZero", FullName = "Subnautica: Below Zero", @@ -22,20 +43,15 @@ public sealed class GameInfo MsStoreStartUrl = @"ms-xbl-6e27970f:\\" }; - public string Name { get; private set; } - - public string FullName { get; private set; } - - public string DataFolder { get; private set; } - - public string ExeName { get; private set; } - - public int SteamAppId { get; private set; } - - public string MsStoreStartUrl { get; private set; } - - private GameInfo() + // Fixup for OSX + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { + Subnautica.ExeName = "Subnautica"; + Subnautica.DataFolder = Path.Combine("Resources", "Data"); } } + + private GameInfo() + { + } } diff --git a/NitroxModel/GameLogic/FMOD/FMODWhitelist.cs b/NitroxModel/GameLogic/FMOD/FMODWhitelist.cs index 4e7888ee78..084a232ccc 100644 --- a/NitroxModel/GameLogic/FMOD/FMODWhitelist.cs +++ b/NitroxModel/GameLogic/FMOD/FMODWhitelist.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; @@ -11,14 +12,27 @@ public class FMODWhitelist private readonly HashSet whitelistedPaths = new(); private readonly Dictionary soundsWhitelist = new(); - public FMODWhitelist(GameInfo game) + public static FMODWhitelist Load(GameInfo game) { - string filePath = Path.Combine(NitroxUser.LauncherPath, "Resources", $"SoundWhitelist_{game.Name}.csv"); - string fileData = File.ReadAllText(filePath); + return new FMODWhitelist(game); + } + + private FMODWhitelist(GameInfo game) + { + string filePath = Path.Combine(NitroxUser.AssetsPath, "Resources", $"SoundWhitelist_{game.Name}.csv"); + string fileData = ""; + try + { + fileData = File.ReadAllText(filePath); + } + catch (Exception) + { + // ignored + } if (string.IsNullOrWhiteSpace(fileData)) { - Log.Error($"[{nameof(FMODWhitelist)}]: Provided sound whitelist at {filePath} is null or whitespace"); + Log.Error($"[{nameof(FMODWhitelist)}]: Provided sound whitelist at '{filePath}' is null or whitespace"); return; } diff --git a/NitroxModel/Helper/CultureManager.cs b/NitroxModel/Helper/CultureManager.cs new file mode 100644 index 0000000000..f08dd3a6e8 --- /dev/null +++ b/NitroxModel/Helper/CultureManager.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using System.Threading; + +namespace NitroxModel.Helper; + +public static class CultureManager +{ + /// + /// Internal Subnautica files are setup using US english number formats and dates. To ensure + /// that we parse all of these appropriately, we will set the default cultureInfo to en-US. + /// This must best done for any thread that is spun up and needs to read from files (unless + /// we were to migrate to 4.5.) Failure to set the context can result in very strange behaviour + /// throughout the entire application. This originally manifested itself as a duplicate spawning + /// issue for players in Europe. This was due to incorrect parsing of probability tables. + /// + public static void ConfigureCultureInfo() + { + CultureInfo cultureInfo = new("en-US"); + + // Although we loaded the en-US cultureInfo, let's make sure to set these in case the + // default was overriden by the user. + cultureInfo.NumberFormat.NumberDecimalSeparator = "."; + cultureInfo.NumberFormat.NumberGroupSeparator = ","; + + Thread.CurrentThread.CurrentCulture = cultureInfo; + Thread.CurrentThread.CurrentUICulture = cultureInfo; + CultureInfo.DefaultThreadCurrentCulture = cultureInfo; + CultureInfo.DefaultThreadCurrentUICulture = cultureInfo; + } +} diff --git a/NitroxModel/Helper/IKeyValueStore.cs b/NitroxModel/Helper/IKeyValueStore.cs new file mode 100644 index 0000000000..e8b7d22c61 --- /dev/null +++ b/NitroxModel/Helper/IKeyValueStore.cs @@ -0,0 +1,34 @@ +namespace NitroxModel.Helper; + +public interface IKeyValueStore +{ + /// + /// Gets a value for a key. + /// + /// Key to get value of. + /// Default value to return if key does not exist or type conversion failed. + /// The value or null if key was not found or conversion failed. + T GetValue(string key, T defaultValue = default); + + /// + /// Sets a value for a key. + /// + /// Key to set value of. + /// Value to set for the key. + /// True if the value was found. + bool SetValue(string key, T value); + + /// + /// Deletes a key along with its value. + /// + /// Key to delete. + /// True if the key was deleted. + bool DeleteKey(string key); + + /// + /// Check if a key exists. + /// + /// Key to check. + /// True if the key exists. + bool KeyExists(string key); +} diff --git a/NitroxModel/Helper/KeyValueStore.cs b/NitroxModel/Helper/KeyValueStore.cs new file mode 100644 index 0000000000..9511643801 --- /dev/null +++ b/NitroxModel/Helper/KeyValueStore.cs @@ -0,0 +1,39 @@ +using System; +using System.Runtime.InteropServices; +using NitroxModel.Platforms.OS.Shared; +using NitroxModel.Platforms.OS.Windows; + +namespace NitroxModel.Helper; + +/// +/// Simple Key-Value store that works cross-platform.
+///
+/// +/// +/// On Windows:
+/// Backend is , which uses the registry. +/// If you want to view/edit the KeyStore, open regedit and navigate to HKEY_CURRENT_USER\SOFTWARE\Nitrox\(keyname) +///
+/// +/// On Linux:
+/// Backend is , which uses a file. +/// If you want to view/edit the KeyStore, open $HOME/.config/Nitrox/nitrox.cfg in your favourite text editor. +///
+///
+public static class KeyValueStore +{ + public static IKeyValueStore Instance { get; } = GetKeyValueStoreForPlatform(); + + private static IKeyValueStore GetKeyValueStoreForPlatform() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Use registry on Windows + return new RegistryKeyValueStore(); + } + + // if platform isn't Windows, it doesn't have a registry + // use a config file for storage this should work on most platforms + return new ConfigFileKeyValueStore(); + } +} diff --git a/NitroxModel/Helper/NatHelper.cs b/NitroxModel/Helper/NatHelper.cs index ce6eb67bcc..46e52510d0 100644 --- a/NitroxModel/Helper/NatHelper.cs +++ b/NitroxModel/Helper/NatHelper.cs @@ -23,7 +23,7 @@ public static async Task GetExternalIpAsync() => await MonoNatHelper. } }).ConfigureAwait(false); - public static async Task DeletePortMappingAsync(ushort port, Protocol protocol) + public static async Task DeletePortMappingAsync(ushort port, Protocol protocol, CancellationToken ct = default) { return await MonoNatHelper.GetFirstAsync(static async (device, mapping) => { @@ -35,10 +35,10 @@ public static async Task DeletePortMappingAsync(ushort port, Protocol prot { return false; } - }, new Mapping(protocol, port, port)).ConfigureAwait(false); + }, new Mapping(protocol, port, port), ct).ConfigureAwait(false); } - public static async Task GetPortMappingAsync(ushort port, Protocol protocol) + public static async Task GetPortMappingAsync(ushort port, Protocol protocol, CancellationToken ct = default) { return await MonoNatHelper.GetFirstAsync(static async (device, protocolAndPort) => { @@ -50,10 +50,10 @@ public static async Task GetPortMappingAsync(ushort port, Protocol prot { return null; } - }, (port, protocol)).ConfigureAwait(false); + }, (port, protocol), ct).ConfigureAwait(false); } - public static async Task AddPortMappingAsync(ushort port, Protocol protocol) + public static async Task AddPortMappingAsync(ushort port, Protocol protocol, CancellationToken ct = default) { Mapping mapping = new(protocol, port, port); return await MonoNatHelper.GetFirstAsync(static async (device, mapping) => @@ -66,7 +66,7 @@ public static async Task AddPortMappingAsync(ushort port, Protocol { return ExceptionToCode(ex); } - }, mapping).ConfigureAwait(false); + }, mapping, ct).ConfigureAwait(false); } public enum ResultCodes @@ -147,8 +147,13 @@ void Handler(object sender, DeviceEventArgs args) public static async Task GetFirstAsync(Func> predicate) => await GetFirstAsync(static (device, p) => p(device), predicate); - public static async Task GetFirstAsync(Func> predicate, TExtraParam parameter) + public static async Task GetFirstAsync(Func> predicate, TExtraParam parameter, CancellationToken ct = default) { + if (ct.IsCancellationRequested) + { + return default; + } + // Start NAT discovery (if it hasn't started yet). Task> discoverTask = DiscoverAsync(); if (discoverTask.IsCompleted && discoveredDevices.IsEmpty) @@ -163,12 +168,23 @@ public static async Task GetFirstAsync(Func> unhandledDevices = discoveredDevices.Except(handledDevices).ToArray(); if (!unhandledDevices.Any()) { - await Task.Delay(10).ConfigureAwait(false); + try + { + await Task.Delay(10, ct); + } + catch (OperationCanceledException) + { + // ignored + } continue; } foreach (KeyValuePair pair in unhandledDevices) { + if (ct.IsCancellationRequested) + { + return default; + } if (handledDevices.TryAdd(pair.Key, pair.Value)) { TResult result = await predicate(pair.Value, parameter); @@ -178,7 +194,7 @@ public static async Task GetFirstAsync(Func GetInternetInterfaces() { return NetworkInterface.GetAllNetworkInterfaces() - .Where(n => n.Name is "Ethernet" or "Wi-Fi" && n.OperationalStatus is OperationalStatus.Up && n.NetworkInterfaceType is NetworkInterfaceType.Wireless80211 or NetworkInterfaceType.Ethernet) - .OrderBy(n => n.Name == "Ethernet" ? 1 : 0) + .Where(n => n.OperationalStatus is OperationalStatus.Up + && n.NetworkInterfaceType is not (NetworkInterfaceType.Tunnel or NetworkInterfaceType.Loopback) + && n.NetworkInterfaceType is (NetworkInterfaceType.Wireless80211 or NetworkInterfaceType.Ethernet)) + .OrderBy(n => n.NetworkInterfaceType is NetworkInterfaceType.Ethernet ? 1 : 0) .ThenBy(n => n.Name); } @@ -65,6 +67,7 @@ public static IPAddress GetLanIp() } } } + return null; } @@ -136,6 +139,17 @@ public static IPAddress GetHamachiIp() return null; } + public static async Task HasInternetConnectivityAsync() + { + if (!NetworkInterface.GetIsNetworkAvailable()) + { + return false; + } + using Ping ping = new(); + PingReply reply = await ping.SendPingAsync(new IPAddress([8, 8, 8, 8]),2000); + return reply.Status == IPStatus.Success; + } + /// /// Returns true if the given IP address is reserved for private networks. /// @@ -175,6 +189,7 @@ public static bool IsLocalhost(this IPAddress address) { return true; } + foreach (NetworkInterface ni in GetInternetInterfaces()) { foreach (UnicastIPAddressInformation ip in ni.GetIPProperties().UnicastAddresses) diff --git a/NitroxModel/Helper/NitroxUser.cs b/NitroxModel/Helper/NitroxUser.cs index 01eafc46cc..cce99b48b7 100644 --- a/NitroxModel/Helper/NitroxUser.cs +++ b/NitroxModel/Helper/NitroxUser.cs @@ -3,9 +3,10 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using NitroxModel.Discovery; using NitroxModel.Discovery.InstallationFinders.Core; -using NitroxModel.Platforms.OS.Windows.Internal; +using NitroxModel.Platforms.OS.Shared; using NitroxModel.Platforms.Store; using NitroxModel.Platforms.Store.Interfaces; @@ -14,10 +15,12 @@ namespace NitroxModel.Helper public static class NitroxUser { public const string LAUNCHER_PATH_ENV_KEY = "NITROX_LAUNCHER_PATH"; - private const string PREFERRED_GAMEPATH_REGKEY = @"SOFTWARE\Nitrox\PreferredGamePath"; + private const string PREFERRED_GAMEPATH_KEY = "PreferredGamePath"; private static string appDataPath; private static string launcherPath; private static string gamePath; + private static string currentExecutablePath; + private static string assetsPath; private static readonly IEnumerable> launcherPathDataSources = new List> { @@ -25,13 +28,21 @@ public static class NitroxUser () => { Assembly currentAsm = Assembly.GetEntryAssembly(); - if (currentAsm?.GetName().Name.Equals("NitroxLauncher") ?? false) + if (currentAsm?.GetName().Name.Equals("Nitrox.Launcher") ?? false) { return Path.GetDirectoryName(currentAsm.Location); } + DirectoryInfo execParentDir; Assembly execAsm = Assembly.GetExecutingAssembly(); - DirectoryInfo execParentDir = Directory.GetParent(execAsm.Location); + if (string.IsNullOrEmpty(execAsm.Location)) + { + execParentDir = Directory.GetParent(Directory.GetCurrentDirectory()); + } + else + { + execParentDir = Directory.GetParent(execAsm.Location); + } // When running tests LanguageFiles is in same directory if (execParentDir != null && Directory.Exists(Path.Combine(execParentDir.FullName, "LanguageFiles"))) @@ -39,13 +50,19 @@ public static class NitroxUser return execParentDir.FullName; } - // NitroxModel, NitroxServer and other assemblies are stored in NitroxLauncher/lib - if (execParentDir?.Parent != null && Directory.Exists(Path.Combine(execParentDir.Parent.FullName, "LanguageFiles"))) + // NitroxModel, NitroxServer and other assemblies are stored in Nitrox.Launcher/lib + if (execParentDir?.Parent != null && Directory.Exists(Path.Combine(execParentDir.Parent.FullName, "Resources", "LanguageFiles"))) { return execParentDir.Parent.FullName; } return null; + }, + () => + { + using ProcessEx proc = ProcessEx.GetFirstProcess("Nitrox.Launcher"); + string executable = proc?.MainModule?.FileName; + return !string.IsNullOrWhiteSpace(executable) ? Path.GetDirectoryName(executable) : null; } }; @@ -76,15 +93,36 @@ public static string LauncherPath } } - public static string AssetsPath => Path.Combine(LauncherPath, "AssetBundles"); + public static string AssetBundlePath => Path.Combine(LauncherPath, "Resources", "AssetBundles"); + public static string LanguageFilesPath => Path.Combine(LauncherPath, "Resources", "LanguageFiles"); public static string PreferredGamePath { - get => RegistryEx.Read(PREFERRED_GAMEPATH_REGKEY); - set => RegistryEx.Write(PREFERRED_GAMEPATH_REGKEY, value); + get => KeyValueStore.Instance.GetValue(PREFERRED_GAMEPATH_KEY); + set => KeyValueStore.Instance.SetValue(PREFERRED_GAMEPATH_KEY, value); } - public static IGamePlatform GamePlatform { get; private set; } + private static IGamePlatform gamePlatform; + public static event Action GamePlatformChanged; + public static IGamePlatform GamePlatform + { + get + { + if (gamePlatform == null) + { + _ = GamePath; // Ensure gamePath is set + } + return gamePlatform; + } + set + { + if (gamePlatform != value) + { + gamePlatform = value; + GamePlatformChanged?.Invoke(); + } + } + } public static string GamePath { @@ -99,8 +137,10 @@ public static string GamePath GameFinderResult potentiallyValidResult = finderResults.LastOrDefault(); if (potentiallyValidResult?.IsOk == true) { - Log.Debug($"Game installation was found by {potentiallyValidResult.FinderName} at '{potentiallyValidResult.Installation.Path}'"); - return gamePath = potentiallyValidResult.Installation.Path; + Log.Debug($"Game installation was found by {potentiallyValidResult.FinderName} at '{potentiallyValidResult.Path}'"); + gamePath = potentiallyValidResult.Path; + GamePlatform = GamePlatforms.GetPlatformByGameDir(gamePath); + return gamePath; } Log.Error($"Could not locate Subnautica installation directory: {Environment.NewLine}{string.Join(Environment.NewLine, finderResults.Select(i => $"{i.FinderName}: {i.ErrorMessage}"))}"); @@ -122,5 +162,53 @@ public static string GamePath GamePlatform = GamePlatforms.GetPlatformByGameDir(gamePath); } } + + public static string CurrentExecutablePath + { + get + { + if (!string.IsNullOrWhiteSpace(currentExecutablePath)) + { + return currentExecutablePath; + } + + // File URI works different on Linux/OSX so just return path directly. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location ?? ".") ?? Directory.GetCurrentDirectory(); + } + return currentExecutablePath = new Uri(Path.GetDirectoryName(Assembly.GetEntryAssembly()?.CodeBase ?? Assembly.GetEntryAssembly()?.Location ?? ".") ?? Directory.GetCurrentDirectory()).LocalPath; + } + } + + public static string AssetsPath + { + get + { + if (!string.IsNullOrWhiteSpace(assetsPath)) + { + return assetsPath; + } + + string nitroxAssets; + if (NitroxEnvironment.IsTesting) + { + nitroxAssets = Directory.GetCurrentDirectory(); + while (nitroxAssets != null && Path.GetFileName(nitroxAssets) != "Nitrox.Test") + { + nitroxAssets = Directory.GetParent(nitroxAssets)?.FullName; + } + if (nitroxAssets != null) + { + nitroxAssets = Path.Combine(Directory.GetParent(nitroxAssets)?.FullName ?? throw new Exception("Failed to get Nitrox assets during tests"), "Nitrox.Assets.Subnautica"); + } + } + else + { + nitroxAssets = LauncherPath ?? CurrentExecutablePath; + } + return assetsPath = nitroxAssets; + } + } } } diff --git a/NitroxModel/Helper/PirateDetection.cs b/NitroxModel/Helper/PirateDetection.cs index e36ec3acfc..9d953f73ae 100644 --- a/NitroxModel/Helper/PirateDetection.cs +++ b/NitroxModel/Helper/PirateDetection.cs @@ -42,7 +42,7 @@ public static bool TriggerOnDirectory(string subnauticaRoot) private static bool IsPirateByDirectory(string subnauticaRoot) { - string subdirDll = Path.Combine(subnauticaRoot, "Subnautica_Data", "Plugins", "x86_64", "steam_api64.dll"); + string subdirDll = Path.Combine(subnauticaRoot, GameInfo.Subnautica.DataFolder, "Plugins", "x86_64", "steam_api64.dll"); if (File.Exists(subdirDll) && !FileSystem.Instance.IsTrustedFile(subdirDll)) { return true; diff --git a/NitroxModel/Logger/ConditionalValveSink.cs b/NitroxModel/Logger/ConditionalValveSink.cs new file mode 100644 index 0000000000..10c2ce65d0 --- /dev/null +++ b/NitroxModel/Logger/ConditionalValveSink.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting; + +namespace NitroxModel.Logger; + +public class ConditionalValveSink : ILogEventSink +{ + private readonly Func thresholdPredicate; + private readonly ILogEventSink wrappedSink; + private readonly Queue queue = new(); + + public ConditionalValveSink(Func thresholdPredicate, ILogEventSink wrappedSink) + { + this.thresholdPredicate = thresholdPredicate; + this.wrappedSink = wrappedSink; + } + + public void Emit(LogEvent logEvent) + { + if (thresholdPredicate?.Invoke(logEvent) == true) + { + while (queue.Count > 0 && queue.Dequeue() is { } dequeuedEvent) + { + foreach (KeyValuePair pair in logEvent.Properties) + { + dequeuedEvent.AddOrUpdateProperty(new LogEventProperty(pair.Key, pair.Value)); + } + wrappedSink.Emit(dequeuedEvent); + } + + wrappedSink.Emit(logEvent); + } + else + { + queue.Enqueue(logEvent); + } + } +} \ No newline at end of file diff --git a/NitroxModel/Logger/Log.cs b/NitroxModel/Logger/Log.cs index f0e5e3fbe1..247bb6bd61 100644 --- a/NitroxModel/Logger/Log.cs +++ b/NitroxModel/Logger/Log.cs @@ -9,6 +9,7 @@ using LiteNetLib; using NitroxModel.Helper; using Serilog; +using Serilog.Configuration; using Serilog.Context; using Serilog.Core; using Serilog.Events; @@ -21,21 +22,49 @@ public static class Log private static ILogger inGameLogger = Serilog.Core.Logger.None; private static readonly HashSet logOnceCache = new(); private static bool isSetup; + private static string logFileName; public static string PlayerName { - set => SetPlayerName(value); + set + { + if (string.IsNullOrEmpty(value)) + { + LogContext.PushProperty(nameof(PlayerName), ""); + return; + } + LogContext.PushProperty(nameof(PlayerName), $"[{value}]"); + + if (logger != null) + { + Info($"Setting player name to {value}"); + } + } + } + + public static string SaveName + { + set + { + if (string.IsNullOrEmpty(value)) + { + LogContext.PushProperty(nameof(SaveName), ""); + return; + } + + LogContext.PushProperty(nameof(SaveName), @$"[{value}]"); + } } - public static string LogDirectory { get; } = Path.GetFullPath(Path.Combine(NitroxUser.LauncherPath ?? "", "Nitrox Logs")); + public static string LogDirectory { get; } = Path.GetFullPath(Path.Combine(NitroxUser.AppDataPath ?? "", "Logs")); - public static string GetMostRecentLogFile() => new DirectoryInfo(LogDirectory).GetFiles().OrderByDescending(f => f.CreationTimeUtc).FirstOrDefault()?.FullName; + public static string GetMostRecentLogFile() => new DirectoryInfo(LogDirectory).GetFiles().OrderByDescending(f => f.CreationTimeUtc).FirstOrDefault()?.FullName; // TODO: Filter by servername ( .Where(f => f.Name.Contains($"[{SaveName}]")) ) - public static void Setup(bool asyncConsoleWriter = false, InGameLogger gameLogger = null, bool isConsoleApp = false, bool useConsoleLogging = true) + public static void Setup(bool asyncConsoleWriter = false, InGameLogger gameLogger = null, bool isConsoleApp = false, bool useConsoleLogging = true, bool useFileLogging = true) { if (isSetup) { - Log.Warn($"{nameof(Log)} setup should only be executed once."); + Warn($"{nameof(Log)} setup should only be executed once."); return; } @@ -43,44 +72,19 @@ public static void Setup(bool asyncConsoleWriter = false, InGameLogger gameLogge NetDebug.Logger = new LiteNetLibLogger(); PlayerName = ""; - logger = new LoggerConfiguration() - .MinimumLevel.Debug() - .WriteTo.Logger(cnf => - { - if (!useConsoleLogging) - { - return; - } - - string consoleTemplate = isConsoleApp switch - { - false => $"[{{Timestamp:HH:mm:ss.fff}}] {{{nameof(PlayerName)}:l}}[{{Level:u3}}] {{Message}}{{NewLine}}{{Exception}}", - _ => "[{Timestamp:HH:mm:ss.fff}] {Message}{NewLine}{Exception}" - }; - - if (asyncConsoleWriter) - { - cnf.WriteTo.Async(a => a.ColoredConsole(outputTemplate: consoleTemplate)); - } - else - { - cnf.WriteTo.ColoredConsole(outputTemplate: consoleTemplate); - } - }) - .WriteTo.Logger(cnf => cnf - .Enrich.FromLogContext() - .WriteTo -#if DEBUG - .Map(nameof(PlayerName), "", (playerName, sinkCnf) => sinkCnf.Async(a => a.File(Path.Combine(LogDirectory, $"{GetLogFileName()}{playerName}-.log"), -#else - .Async((a => a.File(Path.Combine(LogDirectory, $"{GetLogFileName()}-.log"), -#endif - outputTemplate: "[{Timestamp:HH:mm:ss.fff}] [{Level:u3}{IsUnity}] {Message}{NewLine}{Exception}", - rollingInterval: RollingInterval.Day, - retainedFileCountLimit: 10, - fileSizeLimitBytes: 200000000, // 200MB - shared: true)))) - .CreateLogger(); + SaveName = ""; + + // Configure logger and create an instance of it. + LoggerConfiguration loggerConfig = new LoggerConfiguration().MinimumLevel.Debug(); + if (useConsoleLogging) + { + loggerConfig = loggerConfig.WriteTo.AppendConsoleSink(asyncConsoleWriter, isConsoleApp); + } + if (useFileLogging) + { + loggerConfig = loggerConfig.WriteTo.AppendFileSink(); + } + logger = loggerConfig.CreateLogger(); if (gameLogger != null) { @@ -90,6 +94,66 @@ public static void Setup(bool asyncConsoleWriter = false, InGameLogger gameLogge } } + private static LoggerConfiguration AppendFileSink(this LoggerSinkConfiguration sinkConfig) => sinkConfig.Logger(cnf => + { + static bool LogEventHasPropertiesAny(LogEvent @event, params string[] propertyKeys) + { + foreach (string key in propertyKeys) + { + if (!@event.Properties.TryGetValue(key, out LogEventPropertyValue propValue)) + { + continue; + } + string propValueStr = propValue.ToString().Trim('\"'); // ToString of Serilog properties returns \"\" when empty string. + if (string.IsNullOrWhiteSpace(propValueStr)) + { + continue; + } + return true; + } + return false; + } + + cnf.Enrich.FromLogContext() + .WriteTo + .Valve(v => + { + v.Async(a => + { + a.Map(nameof(SaveName), "", (saveName, m) => + { + m.Map(nameof(PlayerName), "", (playerName, m2) => + { + m2.File(Path.Combine(LogDirectory, $"{GetLogFileName()}{saveName}{playerName}-.log"), + outputTemplate: "[{Timestamp:HH:mm:ss.fff}] [{Level:u3}{IsUnity}] {Message}{NewLine}{Exception}", + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 10, + fileSizeLimitBytes: 200_000_000, // 200MB + shared: true); + }); + }); + }); + }, e => LogEventHasPropertiesAny(e, nameof(SaveName), nameof(PlayerName)) || GetLogFileName() is "launcher"); + }); + + private static LoggerConfiguration AppendConsoleSink(this LoggerSinkConfiguration sinkConfig, bool makeAsync, bool useShorterTemplate) => sinkConfig.Logger(cnf => + { + string consoleTemplate = useShorterTemplate switch + { + false => $"[{{Timestamp:HH:mm:ss.fff}}] {{{nameof(PlayerName)}:l}}[{{Level:u3}}] {{Message}}{{NewLine}}{{Exception}}", + _ => "[{Timestamp:HH:mm:ss.fff}] {Message}{NewLine}{Exception}" + }; + + if (makeAsync) + { + cnf.WriteTo.Async(a => a.ColoredConsole(outputTemplate: consoleTemplate)); + } + else + { + cnf.WriteTo.ColoredConsole(outputTemplate: consoleTemplate); + } + }); + [Conditional("DEBUG")] public static void Debug(string message) { @@ -225,50 +289,26 @@ public static void ErrorUnity(string message) } } - // Player name in log file is only important with running two instances of Nitrox. - [Conditional("DEBUG")] - private static void SetPlayerName(string value) - { - if (string.IsNullOrEmpty(value)) - { - LogContext.PushProperty(nameof(PlayerName), ""); - return; - } - - if (logger != null) - { - Info($"Setting player name to {value}"); - } - - LogContext.PushProperty(nameof(PlayerName), @$"[{value}]"); - } - /// /// Get log file friendly name of the application that is currently logging. /// /// Friendly display name of the current application. private static string GetLoggerName() { - string name = Assembly.GetEntryAssembly()?.GetName().Name ?? "Client"; // Unity Engine does not set Assembly name so lets default to 'Client'. - return name.IndexOf("server", StringComparison.InvariantCultureIgnoreCase) >= 0 ? "Server" : name; + string name = Assembly.GetEntryAssembly()?.GetName().Name ?? "game"; // Unity Engine does not set Assembly name + return name.IndexOf("server", StringComparison.InvariantCultureIgnoreCase) >= 0 ? "server" : name; } private static string GetLogFileName() { static bool Contains(string haystack, string needle) => haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0; - string loggerName = GetLoggerName(); - if (Contains(loggerName, "server")) - { - return "server"; - } - - if (Contains(loggerName, "launch")) + return logFileName ??= GetLoggerName() switch { - return "launcher"; - } - - return "game"; + { } s when Contains(s, "server") => "server", + { } s when Contains(s, "launch") => "launcher", + _ => "game" + }; } private class SensitiveEnricher : ILogEventEnricher diff --git a/NitroxModel/Logger/LoggerSinkConfigurationExtensions.cs b/NitroxModel/Logger/LoggerSinkConfigurationExtensions.cs new file mode 100644 index 0000000000..4343f22e98 --- /dev/null +++ b/NitroxModel/Logger/LoggerSinkConfigurationExtensions.cs @@ -0,0 +1,34 @@ +using System; +using Serilog; +using Serilog.Configuration; +using Serilog.Events; +using Serilog.Formatting.Display; + +namespace NitroxModel.Logger; + +public static class LoggerSinkConfigurationExtensions +{ + /// + /// Buffers messages while the returns true. If false, resumes buffering. + /// + public static LoggerConfiguration Valve( + this LoggerSinkConfiguration loggerConfiguration, + Action configure, + Func predicate, + LogEventLevel minimumLevel = LogEventLevel.Verbose, + string outputTemplate = "{Message}", + IFormatProvider formatProvider = null) + { + return LoggerSinkConfiguration.Wrap(loggerConfiguration, wrappedSink => new ConditionalValveSink(predicate, wrappedSink), configure, LogEventLevel.Verbose, null); + } + + public static LoggerConfiguration Message( + this LoggerSinkConfiguration loggerConfiguration, + Action writer, + LogEventLevel minimumLevel = LogEventLevel.Verbose, + string outputTemplate = "{Message}", + IFormatProvider formatProvider = null) + { + return loggerConfiguration.Sink(new MessageSink(new MessageTemplateTextFormatter(outputTemplate, formatProvider), writer), minimumLevel); + } +} diff --git a/NitroxModel/Logger/MessageSink.cs b/NitroxModel/Logger/MessageSink.cs index 32b7a7f1c6..3b5e29fa41 100644 --- a/NitroxModel/Logger/MessageSink.cs +++ b/NitroxModel/Logger/MessageSink.cs @@ -1,11 +1,8 @@ using System; using System.IO; -using Serilog; -using Serilog.Configuration; using Serilog.Core; using Serilog.Events; using Serilog.Formatting; -using Serilog.Formatting.Display; namespace NitroxModel.Logger { @@ -22,23 +19,10 @@ public MessageSink(ITextFormatter formatter, Action writer) public void Emit(LogEvent logEvent) { - using StringWriter stringWriter = new StringWriter(); + using StringWriter stringWriter = new(); formatter.Format(logEvent, stringWriter); stringWriter.Flush(); writer(stringWriter.GetStringBuilder().ToString()); } } - - public static class MessageSinkExtensions - { - public static LoggerConfiguration Message( - this LoggerSinkConfiguration loggerConfiguration, - Action writer, - LogEventLevel minimumLevel = LogEventLevel.Verbose, - string outputTemplate = "{Message}", - IFormatProvider formatProvider = null) - { - return loggerConfiguration.Sink(new MessageSink(new MessageTemplateTextFormatter(outputTemplate, formatProvider), writer), minimumLevel); - } - } } diff --git a/NitroxModel/NitroxModel.csproj b/NitroxModel/NitroxModel.csproj index bcfaf2db4f..bf961fc946 100644 --- a/NitroxModel/NitroxModel.csproj +++ b/NitroxModel/NitroxModel.csproj @@ -1,12 +1,12 @@  - net472 - disable + net472;netstandard2.0 - + + @@ -16,8 +16,6 @@ - - @@ -33,6 +31,21 @@ ..\Nitrox.Assets.Subnautica\Serilog.Sinks.Map.dll - + + + + + + + + + + + + + + + + diff --git a/NitroxModel/Packets/Packet.cs b/NitroxModel/Packets/Packet.cs index b49142aa3f..b0d2339d25 100644 --- a/NitroxModel/Packets/Packet.cs +++ b/NitroxModel/Packets/Packet.cs @@ -59,7 +59,7 @@ static IEnumerable FindUnionBaseTypes() => FindTypesInModelAssemblies() return levels; }) - .ThenBy(t => t.FullName) + .ThenBy(t => t.FullName, StringComparer.Ordinal) .ToArray()); } diff --git a/NitroxModel/Platforms/OS/MacOS/MacFileSystem.cs b/NitroxModel/Platforms/OS/MacOS/MacFileSystem.cs index c85389df3d..5aee62eedb 100644 --- a/NitroxModel/Platforms/OS/MacOS/MacFileSystem.cs +++ b/NitroxModel/Platforms/OS/MacOS/MacFileSystem.cs @@ -14,7 +14,5 @@ public override bool SetFullAccessToCurrentUser(string directory) { throw new System.NotImplementedException(); } - - public override bool IsTrustedFile(string file) => throw new System.NotImplementedException(); } } diff --git a/NitroxModel/Platforms/OS/Shared/ConfigFileKeyValueStore.cs b/NitroxModel/Platforms/OS/Shared/ConfigFileKeyValueStore.cs new file mode 100644 index 0000000000..b2f1b8edd9 --- /dev/null +++ b/NitroxModel/Platforms/OS/Shared/ConfigFileKeyValueStore.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using NitroxModel.Helper; + +namespace NitroxModel.Platforms.OS.Shared; + +public class ConfigFileKeyValueStore : IKeyValueStore +{ + private bool hasLoaded = false; + private readonly Dictionary keyValuePairs = new(); + public string FolderPath { get; } + public string FilePath => Path.Combine(FolderPath, "nitrox.cfg"); + + public ConfigFileKeyValueStore() + { + // LocalApplicationData's default is $HOME/.config under linux and XDG_CONFIG_HOME if set + // What is the difference between .config and .local/share? + // .config should contain all config files. + // .local/share should contain data that isn't config files (binary blobs, downloaded data, server saves). + // .cache should house all cache files (files that can be safely deleted to free up space) + string localShare = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrEmpty(localShare)) + { + throw new Exception("Could not determine where to save configs. Check HOME and XDG_CONFIG_HOME variables."); + } + FolderPath = Path.Combine(localShare, "Nitrox"); + } + + public T GetValue(string key, T defaultValue) + { + TryLoadConfig(); + bool succeeded = keyValuePairs.TryGetValue(key, out object obj); + if (!succeeded) + { + return defaultValue; + } + if (obj is JsonElement element) + { + // System.Text.Json stores objects as JsonElement + try + { + return element.Deserialize(); + } + catch (Exception) + { + return defaultValue; + } + } + // if a value has been added at runtime and not deserialized, it should be casted directly + try + { + return (T)obj; + } + catch (Exception) + { + return defaultValue; + } + } + + public bool SetValue(string key, T value) + { + TryLoadConfig(); + keyValuePairs[key] = value; + TrySaveConfig(); + return true; + } + + public (bool success, Exception error) TrySaveConfig() + { + // saving configs isn't critical, if it fails the values will still exists at runtime, but won't be loaded the next time you start up Nitrox. + try + { + // Create directories if they don't already exist + Directory.CreateDirectory(FolderPath); + + // serialize the keyValuePairs + string serialized = JsonSerializer.Serialize(keyValuePairs, new JsonSerializerOptions { WriteIndented = true }); + + // try to write the file + File.WriteAllText(FilePath, serialized); + return (true, null); + } + catch (Exception e) + { + return (false, e); + } + } + + private (bool success, Exception error) TryLoadConfig() + { + if (hasLoaded) + { + return (true, null); + } + Dictionary deserialized; + try + { + deserialized = JsonSerializer.Deserialize>(File.ReadAllText(FilePath)); + } + catch (Exception e) + { + return (false, e); + } + if (deserialized == null) + { + return (false, new Exception("Deserialized object was null")); + } + + foreach (KeyValuePair item in deserialized) + { + keyValuePairs[item.Key] = item.Value; + } + hasLoaded = true; + return (true, null); + } + + public bool DeleteKey(string key) + { + if (!keyValuePairs.Remove(key)) + { + return false; + } + TrySaveConfig(); + return true; + } + + public bool KeyExists(string key) => keyValuePairs.ContainsKey(key); +} diff --git a/NitroxModel/Platforms/OS/Shared/FileSystem.cs b/NitroxModel/Platforms/OS/Shared/FileSystem.cs index 3b121d26a8..1b46a887fe 100644 --- a/NitroxModel/Platforms/OS/Shared/FileSystem.cs +++ b/NitroxModel/Platforms/OS/Shared/FileSystem.cs @@ -261,6 +261,7 @@ public bool IsWritable(string directory) } public abstract bool SetFullAccessToCurrentUser(string directory); - public abstract bool IsTrustedFile(string file); + + public virtual bool IsTrustedFile(string file) => true; } } diff --git a/NitroxModel/Platforms/OS/Shared/ProcessEx.cs b/NitroxModel/Platforms/OS/Shared/ProcessEx.cs index b000c1cf17..2a910248fd 100644 --- a/NitroxModel/Platforms/OS/Shared/ProcessEx.cs +++ b/NitroxModel/Platforms/OS/Shared/ProcessEx.cs @@ -1,684 +1,718 @@ -using System; -using System.Collections; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Security.Principal; using System.Text; -using Microsoft.Win32.SafeHandles; -using NitroxModel.Platforms.OS.Windows.Internal; -namespace NitroxModel.Platforms.OS.Shared -{ - /// - /// Lower-level wrapper for an OS process than normal to support memory access, DLLs discovery and exported functions listing. - /// - /// TODO: Turn this class into abstract that is used by OS specific implementations. Right now it's Windows only. - /// - public sealed class ProcessEx : IDisposable - { - private readonly Process optionalInnerProcess; - private readonly Dictionary threadHandles = new(); - private SafeHandle handle; - - private int id; +namespace NitroxModel.Platforms.OS.Shared; - private bool? is32Bit; - private bool isDisposed; - private Module mainModule; +public class ProcessEx : IDisposable +{ + private readonly ProcessExBase implementation; - private string mainModuleFileName; + public int Id => implementation.Id; + public string Name => implementation.Name; + public IntPtr Handle => implementation.Handle; + public ProcessModuleEx MainModule => implementation.MainModule; + public string MainModuleFileName => implementation.MainModuleFileName; + public IntPtr MainWindowHandle => implementation.MainWindowHandle; + public string MainWindowTitle => implementation.MainWindowTitle; - private Dictionary modules; + public ProcessEx(int pid) + { + implementation = ProcessExFactory.Create(pid); + } - private IntPtr pebAddress; + public ProcessEx(Process process) + { + implementation = ProcessExFactory.Create(process.Id); + } - public SafeHandle Handle + public static bool ProcessExists(string procName, Func predicate = null) + { + ProcessEx proc = null; + try { - get - { - if (handle != null) - { - return handle; - } - - try - { - handle = optionalInnerProcess?.SafeHandle; - } - catch (Exception ex) - { - if (ex is InvalidOperationException || ex is Win32Exception) - { - handle = new SafeProcessHandle(IntPtr.Zero, true); - } - else - { - throw; - } - } - return handle; - } - init - { - handle?.Dispose(); - handle = value; - } + proc = GetFirstProcess(procName, predicate); + return proc != null; + } + catch (Exception) + { + return false; } + finally + { + proc?.Dispose(); + } + } - public SafeHandle MainThreadHandle { get; } - public Dictionary Modules => modules ?? GetModules(); + public static ProcessEx Start(string fileName = null, IEnumerable<(string, string)> environmentVariables = null, string workingDirectory = null, string commandLine = null, bool createWindow = true) + { + ProcessStartInfo startInfo = new() + { + FileName = fileName, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + CreateNoWindow = !createWindow, + }; - public Module MainModule + if (environmentVariables != null) { - get + foreach ((string key, string value) in environmentVariables) { - if (mainModule != null) - { - return mainModule; - } - - GetModules(); - return mainModule; + startInfo.EnvironmentVariables[key] = value; } } - public int Id + if (!string.IsNullOrEmpty(commandLine)) { - get => id > 0 ? id : optionalInnerProcess?.Id ?? 0; - private init => id = value; + startInfo.Arguments = commandLine; } - /// - /// True if targeted process is 32 bit. If it fails it will default to the bitness of the OS. - /// - public bool Is32Bit => is32Bit ??= !Win32Native.IsWow64Process(Handle, out bool isWowProcess) ? !Environment.Is64BitOperatingSystem : isWowProcess; + Process process = Process.Start(startInfo); + return new ProcessEx(process); + } - public IntPtr PebAddress - { - get - { - if (pebAddress != IntPtr.Zero) - { - return pebAddress; - } + public byte[] ReadMemory(IntPtr address, int size) + { + return implementation.ReadMemory(address, size); + } - ProcessBasicInformation pbi = default; - NtStatus queryStatus = Win32Native.NtQueryInformationProcess(Handle, 0, ref pbi, Marshal.SizeOf(typeof(ProcessBasicInformation)), IntPtr.Zero); - return pebAddress = queryStatus == NtStatus.SUCCESS ? pbi.PebBaseAddress : IntPtr.Zero; - } - } + public int WriteMemory(IntPtr address, byte[] data) + { + return implementation.WriteMemory(address, data); + } - public IntPtr this[IntPtr address] => new(BitConverter.ToInt32(ReadMemory(address, is32Bit == true ? 4 : 8), 0)); + public IEnumerable GetModules() + { + return implementation.GetModules(); + } - public string Name => optionalInnerProcess?.ProcessName; + public void Suspend() + { + implementation.Suspend(); + } + + public void Resume() + { + implementation.Resume(); + } - /// - /// Tries to get the path to main executable of the process. Returns null if insufficient access. - /// - public string MainModuleFileName + public void Terminate() + { + implementation.Terminate(); + } + + public void Dispose() + { + implementation.Dispose(); + } + + public static ProcessEx GetFirstProcess(string procName, Func predicate = null) + { + ProcessEx found = null; + foreach (Process proc in Process.GetProcessesByName(procName)) { - get + // Already found, dispose all other process handles. + if (found != null) { - if (mainModuleFileName != null) - { - return mainModuleFileName; - } - if (Handle.IsInvalid) - { - return null; - } - - return mainModuleFileName = Win32Native.QueryFullProcessImageName(Handle); + proc.Dispose(); + continue; } - } - public string MainModuleDirectory - { - get + ProcessEx procEx = new(proc); + if (predicate != null && !predicate(procEx)) { - string fileName = MainModuleFileName; - if (fileName == null) - { - return null; - } - return Path.GetDirectoryName(MainModuleFileName); + procEx.Dispose(); + continue; } + + found = procEx; } - public ProcessEx(Process proc) + return found; + } +} + +public abstract class ProcessExBase : IDisposable +{ + protected readonly Process Process; + public virtual int Id => Process?.Id ?? -1; + public virtual string Name => Process?.ProcessName; + public virtual IntPtr Handle => Process?.Handle ?? IntPtr.Zero; + public abstract ProcessModuleEx MainModule { get; } + public virtual string MainModuleFileName => Process?.MainModule?.FileName; + public virtual IntPtr MainWindowHandle => Process?.MainWindowHandle ?? IntPtr.Zero; + public virtual string MainWindowTitle => Process?.MainWindowTitle; + public abstract byte[] ReadMemory(IntPtr address, int size); + public abstract int WriteMemory(IntPtr address, byte[] data); + public abstract IEnumerable GetModules(); + public abstract void Suspend(); + public abstract void Resume(); + public abstract void Terminate(); + + protected ProcessExBase(int id) + { + try { - optionalInnerProcess = proc; - MainThreadHandle = new SafeProcessHandle(IntPtr.Zero, true); + Process = Process.GetProcessById(id); } - - private ProcessEx(IntPtr handle, int processId, IntPtr mainThreadHandle = default) + catch (Exception) { - Handle = new SafeProcessHandle(handle, true); - MainThreadHandle = new SafeProcessHandle(mainThreadHandle, true); - Id = processId; + // ignored } + } - /// - /// Starts a process. - /// - /// Path to the executable file. Without any arguments. - /// - /// - /// Arguments for the executable. - /// - public static ProcessEx Start(string fileName = null, IEnumerable<(string, string)> environmentVariables = null, string workingDirectory = null, string commandLine = null, bool createWindow = true) + public static bool IsElevated() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return StartInternal(fileName, false, environmentVariables, workingDirectory, commandLine, createWindow); + return WindowsProcessEx.IsElevated(); } - - public static ProcessEx GetFirstProcess(string procName, Func predicate, StringComparer comparer = null) + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - comparer ??= StringComparer.OrdinalIgnoreCase; - ProcessEx found = null; - foreach (Process proc in Process.GetProcesses()) - { - // Already found, dispose all other resources to processes. - if (found != null) - { - proc.Dispose(); - continue; - } + return LinuxProcessEx.IsElevated(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return MacOSProcessEx.IsElevated(); + } - if (comparer.Compare(procName, proc.ProcessName) != 0) - { - proc.Dispose(); - continue; - } - ProcessEx procEx = new(proc); - if (!predicate(procEx)) - { - procEx.Dispose(); - continue; - } + return false; + } - found = procEx; - } + public virtual void Dispose() + { + } +} - return found; - } +public class ProcessModuleEx +{ + public IntPtr BaseAddress { get; set; } + public string ModuleName { get; set; } + public string FileName { get; set; } + public int ModuleMemorySize { get; set; } +} - public IntPtr CreateThread(IntPtr start, IntPtr parameter) - { - return Win32Native.CreateRemoteThread(Handle, IntPtr.Zero, 0, start, parameter, 0, out _); - } +public class WindowsProcessEx : ProcessExBase +{ + private bool disposed; + private IntPtr handle; + + public override IntPtr Handle => handle; - public bool Suspend() + public override ProcessModuleEx MainModule + { + get { - if (Handle.IsInvalid) + ProcessModule mainModule = Process.MainModule; + if (mainModule == null) { - return false; + return null; } - Win32Native.NtSuspendProcess(Handle); - return true; + return new ProcessModuleEx + { + BaseAddress = mainModule.BaseAddress, + ModuleName = mainModule.ModuleName, + FileName = mainModule.FileName, + ModuleMemorySize = mainModule.ModuleMemorySize + }; } + } - public bool Resume() + public override string MainModuleFileName => Process.MainModule?.FileName; + public override IntPtr MainWindowHandle => Process.MainWindowHandle; + public override string MainWindowTitle => Process.MainWindowTitle; + + public WindowsProcessEx(int id) : base(id) + { + if (!IsElevated()) { - if (Handle.IsInvalid) - { - return false; - } - Win32Native.NtResumeProcess(Handle); - return true; + throw new UnauthorizedAccessException("Elevated privileges required."); } - public void Kill() + handle = OpenProcess(0x1F0FFF, false, id); + if (handle == IntPtr.Zero) { - if (Win32Native.TerminateProcess(Handle, 0)) - { - Dispose(); - } + throw new Win32Exception(Marshal.GetLastWin32Error()); } + } - public void Dispose() + public static new bool IsElevated() + { + try { - if (isDisposed) - { - return; - } - isDisposed = true; + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); - foreach (ThreadHandle threadHandle in threadHandles.Values) - { - threadHandle.Dispose(); - } - threadHandles.Clear(); - optionalInnerProcess?.Dispose(); - if (!MainThreadHandle.IsInvalid && !MainThreadHandle.IsClosed) - { - MainThreadHandle.Dispose(); - } - if (!Handle.IsInvalid && !Handle.IsClosed) + WindowsPrincipal principal = new(identity); + // If process has explicit admin privileges + if (principal.IsInRole(WindowsBuiltInRole.Administrator)) { - Handle.Dispose(); + return true; } - } - /// - /// Gets the base address of a loaded module or an exported function if found. - /// Returns if module or exported function name was not found. - /// - /// Name of the loaded module to get the base address from. - /// Name of the exported function on the module to get the address from. - /// - public IntPtr GetAddress(string moduleName, string exportedFunctionName = null) + // Otherwise check if user is in admin group (https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers) + string admininistratorSID = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null).Value; + return principal.Claims.Any(claim => claim.Value == admininistratorSID); + } + catch (Exception) { - moduleName = moduleName?.ToLowerInvariant() ?? ""; - if (!Modules.TryGetValue(moduleName, out Module module)) - { - GetModules(); - if (!Modules.TryGetValue(moduleName, out module)) - { - return IntPtr.Zero; - } - } - if (exportedFunctionName == null) - { - return module.BaseAddress; - } + return false; + } + } - if (!module.ExportedFunctions.TryGetValue(exportedFunctionName, out ExportedFunction func)) - { - module.GetExportedFunctions(); - if (!module.ExportedFunctions.TryGetValue(exportedFunctionName, out func)) - { - return IntPtr.Zero; - } - } - return func.Address; + public override byte[] ReadMemory(IntPtr address, int size) + { + byte[] buffer = new byte[size]; + if (!ReadProcessMemory(handle, address, buffer, size, out int bytesRead) || bytesRead != size) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); } + return buffer; + } - public byte[] ReadMemory(IntPtr address, int size) + public override int WriteMemory(IntPtr address, byte[] data) + { + if (!WriteProcessMemory(handle, address, data, data.Length, out int bytesWritten)) { - byte[] buffer = new byte[size]; - Win32Native.ReadProcessMemory(Handle, address, buffer, size, out _); - return buffer; + throw new Win32Exception(Marshal.GetLastWin32Error()); } + return bytesWritten; + } - public byte[] ReadMemory(IntPtr address, int size, params int[] offsets) + public override IEnumerable GetModules() + { + return Process.Modules.Cast().Select(m => new ProcessModuleEx + { + BaseAddress = m.BaseAddress, + ModuleName = m.ModuleName, + FileName = m.FileName, + ModuleMemorySize = m.ModuleMemorySize + }); + } + + public override void Suspend() + { + foreach (ProcessThread thread in Process.Threads) { - IntPtr ptr = address; - if (offsets.Length > 1) + IntPtr threadHandle = OpenThread(ThreadAccess.SUSPEND_RESUME, false, (uint)thread.Id); + if (threadHandle != IntPtr.Zero) { - ptr = ReadPointer(address, offsets.TakeWhile((val, i) => i < offsets.Length - 1)); + try + { + if (SuspendThread(threadHandle) == uint.MaxValue) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + finally + { + CloseHandle(threadHandle); + } } - int lastOffset = offsets.Length > 0 ? offsets[offsets.Length - 1] : 0; - return ReadMemory(ptr + lastOffset, size); } + } - public int WriteMemory(IntPtr address, byte[] data, bool flushInstructionCache = false) + public override void Resume() + { + foreach (ProcessThread thread in Process.Threads) { - Win32Native.WriteProcessMemory(Handle, address, data, data.Length, out int written); - if (flushInstructionCache) + IntPtr threadHandle = OpenThread(ThreadAccess.SUSPEND_RESUME, false, (uint)thread.Id); + if (threadHandle != IntPtr.Zero) { - Win32Native.FlushInstructionCache(Handle, address, (uint)written); + try + { + if (ResumeThread(threadHandle) == -1) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + finally + { + CloseHandle(threadHandle); + } } - return written; } + } - /// - /// Reads the pointer, then reads again after applying an offset each time. - /// - /// - /// - /// - public IntPtr ReadPointer(IntPtr basePointerAddress, params int[] offsets) + public override void Terminate() + { + Process.Kill(); + } + + public override void Dispose() + { + if (!disposed) { - IntPtr address = basePointerAddress; - address = new IntPtr(BitConverter.ToInt64(ReadMemory(address, 8), 0)); - foreach (int t in offsets) + if (handle != IntPtr.Zero) { - address = new IntPtr(BitConverter.ToInt64(ReadMemory(address + t, 8), 0)); + CloseHandle(handle); + handle = IntPtr.Zero; } - return address; + Process.Dispose(); + disposed = true; } + GC.SuppressFinalize(this); + } - public IntPtr ReadPointer(IntPtr basePointerAddress, IEnumerable offsets) - { - return ReadPointer(basePointerAddress, offsets.ToArray()); - } + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); - public string ReadString(UIntPtr address, Encoding encoding = null, int maxBytesRead = 1024) - { - return ReadString(new IntPtr((long)address.ToUInt64()), encoding, maxBytesRead); - } + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); - public string ReadString(IntPtr address, Encoding encoding = null, int maxBytesRead = 1024) - { - encoding ??= Encoding.ASCII; - int bytesRead = 0; - List buffer = new(256); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, [Out] byte[] lpBuffer, int dwSize, out int lpNumberOfBytesRead); - bool FillBufferNext() - { - int bytesToRead = maxBytesRead - bytesRead > 256 ? 256 : maxBytesRead - bytesRead; - if (bytesToRead < 1) - { - return false; - } + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int nSize, out int lpNumberOfBytesWritten); - buffer.AddRange(ReadMemory(address + bytesRead, bytesToRead)); - bytesRead += bytesToRead; - return true; - } + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenThread(ThreadAccess dwDesiredAccess, bool bInheritHandle, uint dwThreadId); - // Decide end-of-string pattern based on string encoding in memory. - string endOfStringPattern = encoding switch - { - ASCIIEncoding _ => "\0", - UnicodeEncoding _ => "\0\0", // UTF-16 - UTF32Encoding _ => throw new NotImplementedException(), - UTF7Encoding _ => throw new NotImplementedException(), - UTF8Encoding _ => throw new NotImplementedException(), - _ => throw new ArgumentOutOfRangeException(nameof(encoding)) - }; + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint SuspendThread(IntPtr hThread); - // Loop over string, stop on end character or when max bytes is reached. - int unbufferedIndex = 0; - bool reachedEnd = false; - int encodingCodePointSize = endOfStringPattern.Length; // TODO: Get from encoding instead (2 for UTF-16, 1 for ascii) - while (!reachedEnd && FillBufferNext()) - { - for (int i = unbufferedIndex; i < buffer.Count - buffer.Count % encodingCodePointSize; i += encodingCodePointSize) - { - for (int j = 0; j < endOfStringPattern.Length; j++) - { - if (i + j >= buffer.Count || buffer[i + j] != endOfStringPattern[j]) - { - goto nextChar; - } - } - reachedEnd = true; - break; + [DllImport("kernel32.dll", SetLastError = true)] + private static extern int ResumeThread(IntPtr hThread); +} - nextChar: - unbufferedIndex += encodingCodePointSize; - } - } +public class LinuxProcessEx : ProcessExBase +{ + private readonly int pid; - return encoding.GetString(buffer.ToArray(), 0, unbufferedIndex); - } + public override int Id => pid; + public override IntPtr Handle => IntPtr.Zero; // Linux doesn't use handles - private static ProcessEx StartInternal(string fileName, bool withDebugger, IEnumerable<(string, string)> environmentVariables = null, string workingDirectory = null, string commandLine = null, bool createWindow = true) + public override string Name + { + get { - RuntimeHelpers.PrepareConstrainedRegions(); - ProcessCreationFlags creationFlags = ProcessCreationFlags.CREATE_UNICODE_ENVIRONMENT; - if (withDebugger) + try { - creationFlags |= ProcessCreationFlags.DEBUG_ONLY_THIS_PROCESS; + string status = File.ReadAllText($"/proc/{pid}/status"); + string[] lines = status.Split('\n'); + return lines.FirstOrDefault(l => l.StartsWith("Name:"))?.Substring(5).Trim(); } - if (!createWindow) + catch (UnauthorizedAccessException) { - creationFlags |= ProcessCreationFlags.CREATE_NO_WINDOW; - } - - StartupInfo startupInfo = new() { cb = Marshal.SizeOf() }; - - // Inherit environment variables from main process - IDictionary newProcessEnvironment = Environment.GetEnvironmentVariables(); - if (environmentVariables != null) - { - foreach ((string, string) pair in environmentVariables) + // If we can't read the status file, try to get the name from the command line + try { - newProcessEnvironment[pair.Item1] = pair.Item2; + string cmdline = File.ReadAllText($"/proc/{pid}/cmdline"); + return Path.GetFileName(cmdline.Split('\0')[0]); + } + catch + { + return null; } } - StringBuilder sb = new(); - foreach (DictionaryEntry entry in newProcessEnvironment) + } + } + + public override ProcessModuleEx MainModule => + // This is a simplified implementation. You might need to parse /proc/{pid}/maps + // to get more accurate information about the main module. + new() { - sb.Append(entry.Key); - sb.Append('='); - sb.Append(entry.Value); - sb.Append(char.MinValue); - } - GCHandle environmentHandle = GCHandle.Alloc(Encoding.Unicode.GetBytes(sb.ToString()), GCHandleType.Pinned); - IntPtr environment = environmentHandle.AddrOfPinnedObject(); + BaseAddress = IntPtr.Zero, + ModuleName = Name, + FileName = MainModuleFileName, + ModuleMemorySize = 0 + }; - // Create the process. - bool created; - ProcessInfo procInfo; + public override string MainModuleFileName + { + get + { try { - created = Win32Native.CreateProcess(fileName, // Use this if pointing to a file - commandLine, // Use this to run it like a shell command - IntPtr.Zero, - IntPtr.Zero, - true, - creationFlags, - environment, - workingDirectory, - ref startupInfo, - out procInfo - ); + return ReadSymbolicLink($"/proc/{pid}/exe"); } - finally + catch (UnauthorizedAccessException) { - if (environmentHandle.IsAllocated) - { - environmentHandle.Free(); - } + // If we don't have permission to read the symlink, return null + return null; } - if (!created) + catch { return null; } + } + } - return new ProcessEx(procInfo.hProcess, procInfo.ProcessId, procInfo.hThread); + public LinuxProcessEx(int pid) : base(pid) + { + this.pid = pid; + if (!File.Exists($"/proc/{this.pid}/status")) + { + throw new ArgumentException("Process does not exist.", nameof(pid)); } + } - private SafeAccessTokenHandle OpenThread(int threadId, ThreadAccess requiredAccess) + public static new bool IsElevated() + { + return geteuid() == 0; + } + + public override byte[] ReadMemory(IntPtr address, int size) + { + byte[] buffer = new byte[size]; + try { - if (!threadHandles.TryGetValue(threadId, out ThreadHandle thread) || !thread.Access.HasFlag(requiredAccess)) + using FileStream fs = new($"/proc/{pid}/mem", FileMode.Open, FileAccess.Read); + fs.Seek((long)address, SeekOrigin.Begin); + if (fs.Read(buffer, 0, size) != size) { - if (thread != null) - { - thread.Dispose(); - requiredAccess |= thread.Access; // Combine access from old handle to (potentially) massively reduce handle creation. - } - threadHandles[threadId] = thread = new ThreadHandle(threadId, Win32Native.OpenThread(requiredAccess, false, (uint)threadId), requiredAccess); + throw new IOException("Failed to read the specified amount of memory."); } - - return thread.Handle; } - - /// - /// Refreshes the cached with the loaded modules in the target process. - /// - /// - private Dictionary GetModules() + catch (Exception ex) { - (modules ??= new Dictionary()).Clear(); + throw new InvalidOperationException("Failed to read process memory.", ex); + } + return buffer; + } - // 0x18 = LDR_DATA - // 0x20 = IN_MEMORY_ORDER_MODULES_LINKED_LIST - List offsets = new() { 0x20 }; - Module mod = Module.FromPebRecordPointer64(this, ReadPointer(PebAddress + 0x18, offsets)); - modules[Path.GetFileName(mod.FileName).ToLowerInvariant()] = mod; - mainModule = mod; + public override int WriteMemory(IntPtr address, byte[] data) + { + int result = ptrace(PtraceRequest.PTRACE_ATTACH, pid, IntPtr.Zero, IntPtr.Zero); + if (result < 0) + { + throw new InvalidOperationException("Failed to attach to the process."); + } - // Follow the linked list of modules inside the Process Environment Block - while (true) + try + { + for (int i = 0; i < data.Length; i += sizeof(long)) { - offsets.Add(0); // Next record in linked list (first field, so offset == 0) - mod = Module.FromPebRecordPointer64(this, ReadPointer(PebAddress + 0x18, offsets)); - if (mod.BaseAddress == IntPtr.Zero) - { - break; - } - string key = Path.GetFileName(mod.FileName).ToLowerInvariant(); - if (Modules.ContainsKey(key)) + long value = BitConverter.ToInt64(data, i); + if (ptrace(PtraceRequest.PTRACE_POKEDATA, pid, address + i, (IntPtr)value) < 0) { - break; + throw new InvalidOperationException("Failed to write memory."); } - if (mod.BaseAddress == IntPtr.Zero) - { - continue; - } - - modules[key] = mod; } - - return modules; } + finally + { + ptrace(PtraceRequest.PTRACE_DETACH, pid, IntPtr.Zero, IntPtr.Zero); + } + return data.Length; + } - private T ReadStruct(IntPtr address, params int[] offsets) where T : struct + public override IEnumerable GetModules() + { + List modules = []; + string[] lines = File.ReadAllLines($"/proc/{pid}/maps"); + foreach (string line in lines) { - int size = Marshal.SizeOf(); - IntPtr ptr = Marshal.AllocHGlobal(size); - try - { - Marshal.Copy(ReadMemory(address, size, offsets), 0, ptr, size); - return Marshal.PtrToStructure(ptr); - } - finally + string[] parts = line.Split(' '); + if (parts.Length >= 6) { - Marshal.FreeHGlobal(ptr); + string[] addresses = parts[0].Split('-'); + modules.Add(new ProcessModuleEx + { + BaseAddress = (IntPtr)long.Parse(addresses[0], NumberStyles.HexNumber), + ModuleName = parts[5], + FileName = parts[5], + ModuleMemorySize = (int)(long.Parse(addresses[1], NumberStyles.HexNumber) - long.Parse(addresses[0], NumberStyles.HexNumber)) + }); } } + return modules; + } + + public override void Suspend() + { + if (kill(pid, 19) != 0) // SIGSTOP + { + throw new InvalidOperationException("Failed to suspend the process."); + } + } + + public override void Resume() + { + if (kill(pid, 18) != 0) // SIGCONT + { + throw new InvalidOperationException("Failed to resume the process."); + } + } - /// - /// Represents a loaded .exe or .dll in a system process. - /// - public sealed class Module + public override void Terminate() + { + if (kill(pid, 9) != 0) // SIGKILL { - private readonly ProcessEx process; + throw new InvalidOperationException("Failed to terminate the process."); + } + } - private ImageExportDirectory? exportDirectory; + [DllImport("libc", SetLastError = true)] + private static extern uint geteuid(); - private Dictionary exportedFunctions; - public string FileName { get; } - public IntPtr BaseAddress { get; } - public IntPtr EntryPoint { get; } + [DllImport("libc", SetLastError = true)] + private static extern int ptrace(PtraceRequest request, int pid, IntPtr addr, IntPtr data); - private ImageExportDirectory ExportDirectory - { - get - { - if (exportDirectory != null) - { - return exportDirectory.Value; - } + [DllImport("libc", SetLastError = true)] + private static extern int kill(int pid, int sig); - ImageDosHeader exeHeader = process.ReadStruct(BaseAddress); - ImageNtHeader64 header = process.ReadStruct(BaseAddress, exeHeader.e_lfanew); - IntPtr exportTablePtr = new(BaseAddress.ToInt64() + header.OptionalHeader.ExportTable.VirtualAddress); - return (exportDirectory = process.ReadStruct(exportTablePtr)).Value; - } - } + [DllImport("libc", SetLastError = true)] + private static extern int readlink(string path, byte[] buf, int bufsiz); - /// - /// source - /// - public Dictionary ExportedFunctions - { - get - { - if (exportedFunctions != null) - { - return exportedFunctions; - } + private static string ReadSymbolicLink(string path) + { + const int BUFFER_SIZE = 1024; + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead = readlink(path, buffer, BUFFER_SIZE); + if (bytesRead < 0) + { + throw new IOException("Failed to read symbolic link."); + } + return Encoding.UTF8.GetString(buffer, 0, bytesRead); + } +} - return GetExportedFunctions(); - } - } +public class MacOSProcessEx : ProcessExBase +{ + public override IntPtr Handle => IntPtr.Zero; - private Module(ProcessEx process, string fileName, IntPtr baseAddress, IntPtr entryPoint) + public override ProcessModuleEx MainModule => + // This is a placeholder implementation. You'll need to use macOS-specific APIs + // to get accurate information about the main module. + new() { - this.process = process; - FileName = fileName; - BaseAddress = baseAddress; - EntryPoint = entryPoint; - } + BaseAddress = IntPtr.Zero, + ModuleName = Name, + FileName = MainModuleFileName, + ModuleMemorySize = 0 + }; - public static Module FromPebRecordPointer64(ProcessEx process, IntPtr pebRecord) - { - IntPtr baseAdress = process.ReadPointer(pebRecord + 0x20); - IntPtr entryPoint = process.ReadPointer(pebRecord + 0x28); - IntPtr fileNamePtr = process.ReadPointer(pebRecord + 0x40); - string fileName = process.ReadString(fileNamePtr, Encoding.Unicode); + private bool disposed; - return new Module(process, fileName, baseAdress, entryPoint); - } + public MacOSProcessEx(int pid) : base(pid) + { + + } - public Dictionary GetExportedFunctions() + public static new bool IsElevated() + { + return geteuid() == 0; + } + + public override byte[] ReadMemory(IntPtr address, int size) + { + byte[] buffer = new byte[size]; + GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + if (vm_read_overwrite(Handle, address, (IntPtr)size, handle.AddrOfPinnedObject(), out IntPtr _) != 0) { - Dictionary result = new(); - IntPtr functionNamesPtr = IntPtr.Add(BaseAddress, (int)ExportDirectory.AddressOfNames); - IntPtr functionAddressesPtr = IntPtr.Add(BaseAddress, (int)ExportDirectory.AddressOfFunctions); - IntPtr functionOrdinalPtr = IntPtr.Add(BaseAddress, (int)ExportDirectory.AddressOfNameOrdinals); - for (int i = 0; i < ExportDirectory.NumberOfNames; i++) - { - uint funcNameRva = process.ReadStruct(functionNamesPtr + i * 4); - string name = process.ReadString(IntPtr.Add(BaseAddress, (int)funcNameRva), Encoding.ASCII); - ushort ordinal = process.ReadStruct(functionOrdinalPtr + i * 2); - IntPtr functionAddress = new(process.ReadStruct(functionAddressesPtr, ordinal * 4)); - result[name] = new ExportedFunction(this, (ushort)(ExportDirectory.Base + ordinal), name, functionAddress); - } - exportedFunctions = result; - return result; + throw new InvalidOperationException("Failed to read process memory."); } + } + finally + { + handle.Free(); + } + return buffer; + } - public override string ToString() + public override int WriteMemory(IntPtr address, byte[] data) + { + GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); + try + { + if (vm_write(Handle, address, handle.AddrOfPinnedObject(), (IntPtr)data.Length) != 0) { - return $"{nameof(FileName)}: {FileName}, {nameof(BaseAddress)}: 0x{BaseAddress.ToString("X")}, {nameof(EntryPoint)}: 0x{EntryPoint.ToString("X")}"; + throw new InvalidOperationException("Failed to write process memory."); } } - - public sealed class ExportedFunction + finally { - private readonly Module module; - public string Name { get; private set; } - - /// - /// Address of the function, relative to base address of the image. - /// Use to get the computed (base + offset) address to function. - /// - public IntPtr Offset { get; private set; } + handle.Free(); + } + return data.Length; + } - public ushort Ordinal { get; private set; } + public override IEnumerable GetModules() + { + // This is a simplified implementation. In a real scenario, you'd use dyld APIs to get the loaded modules. + throw new NotImplementedException("Getting modules is not implemented for macOS."); + } - public IntPtr Address => IntPtr.Add(module.BaseAddress, Offset.ToInt32()); + public override void Suspend() + { + if (task_suspend(Handle) != 0) + { + throw new InvalidOperationException("Failed to suspend the process."); + } + } - internal ExportedFunction(Module module, ushort ordinal, string name, IntPtr offset) - { - this.module = module; - Ordinal = ordinal; - Name = name; - Offset = offset; - } + public override void Resume() + { + if (task_resume(Handle) != 0) + { + throw new InvalidOperationException("Failed to resume the process."); + } + } - public override string ToString() - { - return $"{nameof(Ordinal)}: {Ordinal}, {nameof(Name)}: {Name}, {nameof(Offset)}: 0x{Offset.ToInt64():X}, {nameof(Address)}: 0x{Address.ToInt64():X}"; - } + public override void Terminate() + { + if (task_terminate(Handle) != 0) + { + throw new InvalidOperationException("Failed to terminate the process."); } + } - private sealed class ThreadHandle : IDisposable + public override void Dispose() + { + if (!disposed) { - public int Id { get; } - public SafeAccessTokenHandle Handle { get; } - public ThreadAccess Access { get; } + // In a real implementation, you'd release the task port here + disposed = true; + } + GC.SuppressFinalize(this); + } - public ThreadHandle(int id, SafeAccessTokenHandle handle, ThreadAccess access) - { - Id = id; - Handle = handle; - Access = access; - } + [DllImport("libc", SetLastError = true)] + private static extern uint geteuid(); - public void Dispose() - { - Handle?.Dispose(); - } + [DllImport("libSystem.dylib")] + private static extern int vm_read_overwrite(IntPtr targetTask, IntPtr address, IntPtr size, IntPtr data, out IntPtr outsize); + + [DllImport("libSystem.dylib")] + private static extern int vm_write(IntPtr targetTask, IntPtr address, IntPtr data, IntPtr size); + + [DllImport("libSystem.dylib")] + private static extern int task_suspend(IntPtr task); + + [DllImport("libSystem.dylib")] + private static extern int task_resume(IntPtr task); + + [DllImport("libSystem.dylib")] + private static extern int task_terminate(IntPtr task); +} + +public static class ProcessExFactory +{ + public static ProcessExBase Create(int pid) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new WindowsProcessEx(pid); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return new LinuxProcessEx(pid); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return new MacOSProcessEx(pid); } + throw new PlatformNotSupportedException(); } } diff --git a/NitroxModel/Platforms/OS/Unix/UnixFileSystem.cs b/NitroxModel/Platforms/OS/Unix/UnixFileSystem.cs index afceb88506..e4308a2d36 100644 --- a/NitroxModel/Platforms/OS/Unix/UnixFileSystem.cs +++ b/NitroxModel/Platforms/OS/Unix/UnixFileSystem.cs @@ -14,7 +14,5 @@ public override bool SetFullAccessToCurrentUser(string directory) { throw new System.NotImplementedException(); } - - public override bool IsTrustedFile(string file) => throw new System.NotImplementedException(); } } diff --git a/NitroxModel/Platforms/OS/Windows/Internal/ProcessThreadEnums.cs b/NitroxModel/Platforms/OS/Windows/Internal/ProcessThreadEnums.cs new file mode 100644 index 0000000000..9b9d64e113 --- /dev/null +++ b/NitroxModel/Platforms/OS/Windows/Internal/ProcessThreadEnums.cs @@ -0,0 +1,14 @@ +using System; + +public enum PtraceRequest : int +{ + PTRACE_ATTACH = 16, + PTRACE_DETACH = 17, + PTRACE_POKEDATA = 5 +} + +[Flags] +public enum ThreadAccess : int +{ + SUSPEND_RESUME = 0x0002 +} diff --git a/NitroxModel/Platforms/OS/Windows/Internal/RegistryEx.cs b/NitroxModel/Platforms/OS/Windows/Internal/RegistryEx.cs deleted file mode 100644 index babce5e17a..0000000000 --- a/NitroxModel/Platforms/OS/Windows/Internal/RegistryEx.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System; -using System.ComponentModel; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Win32; - -namespace NitroxModel.Platforms.OS.Windows.Internal -{ - public static class RegistryEx - { - /// - /// Reads the value of the registry key or returns the default value of . - /// - /// - /// Full path to the registry key. If the registry hive is omitted then "current user" is used. - /// - /// The default value if the registry key is not found or failed to convert to . - /// Type of value to read. If the value in the registry key does not match it will try to convert. - /// Value as read from registry or null if not found. - public static T Read(string pathWithValue, T defaultValue = default) - { - (RegistryKey baseKey, string valueKey) = GetKey(pathWithValue, false); - if (baseKey == null) - { - return defaultValue; - } - - try - { - object value = baseKey.GetValue(valueKey); - if (value == null) - { - return defaultValue; - } - return (T)TypeDescriptor.GetConverter(typeof(T)).ConvertFrom(value); - } - catch (Exception) - { - return defaultValue; - } - finally - { - baseKey.Dispose(); - } - } - - /// - /// Deletes the whole subtree or value, whichever exists. - /// - /// If no value name is given it will delete the key instead. - /// True if something was deleted. - public static bool Delete(string pathWithOptionalValue) - { - (RegistryKey key, string valueKey) = GetKey(pathWithOptionalValue); - if (key == null) - { - return false; - } - - // Try delete the key. - RegistryKey prev = key; - key = key.OpenSubKey(valueKey); - if (key != null) - { - key.DeleteSubKeyTree(valueKey); - key.Dispose(); - prev.Dispose(); - return true; - } - key = prev; // Restore state for next step - - // Not a key, delete the value if it exists. - if (key.GetValue(valueKey) != null) - { - key.DeleteValue(valueKey); - key.Dispose(); - return true; - } - - // Nothing to delete. - return false; - } - - public static void Write(string pathWithKey, T value) - { - if (value == null) - { - throw new ArgumentNullException(nameof(value)); - } - (RegistryKey baseKey, string valueKey) = GetKey(pathWithKey, true, true); - if (baseKey == null) - { - return; - } - - // Figure out what kind of value to store. - RegistryValueKind? kind = value switch - { - int => RegistryValueKind.DWord, - long => RegistryValueKind.QWord, - byte[] => RegistryValueKind.Binary, - string => RegistryValueKind.String, - _ => null - }; - // If regKey already exists and we don't know how to parse the value, use existing kind. - if (!kind.HasValue) - { - try - { - kind = baseKey.GetValueKind(valueKey); - } - catch (Exception) - { - // ignored - thrown when key does not exist.. - } - } - - try - { - baseKey.SetValue(valueKey, value, kind.GetValueOrDefault(RegistryValueKind.String)); - } - finally - { - baseKey.Dispose(); - } - } - - /// - /// Waits for a registry value to have the given value. - /// - public static Task CompareAsync(string pathWithKey, Func predicate, CancellationToken token) - { - static bool Test(RegistryKey regKey, string regKeyName, Func testPredicate) - { - T preTestVal = regKey?.GetValue(regKeyName) is T typedValue ? typedValue : default; - return testPredicate(preTestVal); - } - - if (token == default) - { - CancellationTokenSource source = new(TimeSpan.FromSeconds(10)); - token = source.Token; - } - - // Test once before in-case it is already successful. - (RegistryKey baseKey, string valueKey) = GetKey(pathWithKey, false); - if (Test(baseKey, valueKey, predicate)) - { - baseKey.Dispose(); - return Task.CompletedTask; - } - - // Wait for predicate to be successful. - return Task.Run(async () => - { - try - { - while (!token.IsCancellationRequested) - { - // If regkey didn't exist yet it might later. - if (baseKey == null) - { - (baseKey, valueKey) = GetKey(pathWithKey, false); - } - - if (!Test(baseKey, valueKey, predicate)) - { - await Task.Delay(100, token); - continue; - } - - break; - } - } - finally - { - baseKey?.Dispose(); - } - }, - token); - } - - public static Task CompareAsync(string pathWithKey, Func predicate, TimeSpan timeout = default) - { - CancellationTokenSource source = new(timeout == default ? TimeSpan.FromSeconds(10) : timeout); - return CompareAsync(pathWithKey, predicate, source.Token); - } - - private static (RegistryKey baseKey, string valueKey) GetKey(string path, bool needsWriteAccess = true, bool createIfNotExists = false) - { - if (string.IsNullOrWhiteSpace(path)) - { - return (null, null); - } - path = path.Trim(); - - // Parse path to get the registry key instance and the name of the . - Span parts = path.Split(Path.DirectorySeparatorChar); - Span partsWithoutHive; - RegistryHive hive = RegistryHive.CurrentUser; - string regPathWithoutHiveOrKey; - if (path.IndexOf("Computer", StringComparison.OrdinalIgnoreCase) < 0) - { - partsWithoutHive = parts[..^1]; - regPathWithoutHiveOrKey = string.Join(Path.DirectorySeparatorChar.ToString(), partsWithoutHive.ToArray()); - } - else - { - partsWithoutHive = parts[2..^1]; - regPathWithoutHiveOrKey = string.Join(Path.DirectorySeparatorChar.ToString(), partsWithoutHive.ToArray()); - hive = parts[1].ToLowerInvariant() switch - { - "hkey_classes_root" => RegistryHive.ClassesRoot, - "hkey_local_machine" => RegistryHive.LocalMachine, - "hkey_current_user" => RegistryHive.CurrentUser, - "hkey_users" => RegistryHive.Users, - "hkey_current_config" => RegistryHive.CurrentConfig, - _ => throw new ArgumentException($"Path must contain a valid registry hive but was given '{parts[1]}'", nameof(path)) - }; - } - - RegistryKey hiveRef = RegistryKey.OpenBaseKey(hive, RegistryView.Registry64); - RegistryKey key = hiveRef.OpenSubKey(regPathWithoutHiveOrKey, needsWriteAccess); - // Should the key (and its path leading to it) be created? - if (key == null && createIfNotExists) - { - key = hiveRef; - foreach (string part in partsWithoutHive) - { - RegistryKey prev = key; - key = key?.OpenSubKey(part, needsWriteAccess) ?? key?.CreateSubKey(part, needsWriteAccess); - - // Cleanup old/parent key reference - prev?.Dispose(); - } - } - - return (key, parts[^1]); - } - } -} diff --git a/NitroxModel/Platforms/OS/Windows/Internal/Win32Native.cs b/NitroxModel/Platforms/OS/Windows/Internal/Win32Native.cs index 66835367b8..c112eae4cc 100644 --- a/NitroxModel/Platforms/OS/Windows/Internal/Win32Native.cs +++ b/NitroxModel/Platforms/OS/Windows/Internal/Win32Native.cs @@ -338,7 +338,7 @@ private enum UIContext internal static extern int SetWindowLong32(HandleRef hWnd, int nIndex, int dwNewLong); [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] - internal static extern IntPtr SetWindowLongPtr64(HandleRef hWnd, int nIndex, IntPtr dwNewLong); + internal static extern IntPtr SetWindowLongPtr64(HandleRef hWnd, int nIndex, long dwNewLong); [Flags] public enum WS : long diff --git a/NitroxModel/Platforms/OS/Windows/RegistryEx.cs b/NitroxModel/Platforms/OS/Windows/RegistryEx.cs new file mode 100644 index 0000000000..958e8b27ea --- /dev/null +++ b/NitroxModel/Platforms/OS/Windows/RegistryEx.cs @@ -0,0 +1,263 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32; + +namespace NitroxModel.Platforms.OS.Windows; + +public static class RegistryEx +{ + /// + /// Reads the value of the registry key or returns the default value of . + /// + /// + /// Full path to the registry key. If the registry hive is omitted then "current user" is used. + /// + /// The default value if the registry key is not found or failed to convert to . + /// Type of value to read. If the value in the registry key does not match it will try to convert. + /// Value as read from registry or null if not found. + public static T Read(string pathWithValue, T defaultValue = default) + { + (RegistryKey baseKey, string valueKey) = GetKey(pathWithValue, false); + if (baseKey == null) + { + return defaultValue; + } + + try + { + object value = baseKey.GetValue(valueKey); + if (value == null) + { + return defaultValue; + } + Type typeOfT = typeof(T); + return value.GetType() == typeOfT ? (T)value : (T)TypeDescriptor.GetConverter(typeOfT).ConvertFrom(value); + } + catch (Exception) + { + return defaultValue; + } + finally + { + baseKey.Dispose(); + } + } + + /// + /// Deletes the whole subtree or value, whichever exists. + /// + /// If no value name is given it will delete the key instead. + /// True if something was deleted. + public static bool Delete(string pathWithOptionalValue) + { + (RegistryKey key, string valueKey) = GetKey(pathWithOptionalValue); + if (key == null) + { + return false; + } + + // Try delete the key. + RegistryKey prev = key; + key = key.OpenSubKey(valueKey); + if (key != null) + { + key.DeleteSubKeyTree(valueKey); + key.Dispose(); + prev.Dispose(); + return true; + } + key = prev; // Restore state for next step + + // Not a key, delete the value if it exists. + if (key.GetValue(valueKey) != null) + { + key.DeleteValue(valueKey); + key.Dispose(); + return true; + } + + // Nothing to delete. + return false; + } + + public static void Write(string pathWithKey, T value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + (RegistryKey baseKey, string valueKey) = GetKey(pathWithKey, true, true); + if (baseKey == null) + { + return; + } + + // Figure out what kind of value to store. + RegistryValueKind? kind = value switch + { + int => RegistryValueKind.DWord, + long => RegistryValueKind.QWord, + byte[] => RegistryValueKind.Binary, + string => RegistryValueKind.String, + _ => null + }; + // If regKey already exists and we don't know how to parse the value, use existing kind. + if (!kind.HasValue) + { + try + { + kind = baseKey.GetValueKind(valueKey); + } + catch (Exception) + { + // ignored - thrown when key does not exist.. + } + } + + try + { + baseKey.SetValue(valueKey, value, kind.GetValueOrDefault(RegistryValueKind.String)); + } + finally + { + baseKey.Dispose(); + } + } + + /// + /// Checks if a key exists. + /// + public static bool Exists(string pathWithValue) + { + (RegistryKey baseKey, string valueKey) = GetKey(pathWithValue, false); + if (baseKey == null) + { + baseKey.Dispose(); + return false; + } + object value = baseKey.GetValue(valueKey); + if (value == null) + { + baseKey.Dispose(); + return false; + } + baseKey.Dispose(); + return true; + } + + /// + /// Waits for a registry value to have the given value. + /// + public static Task CompareAsync(string pathWithKey, Func predicate, CancellationToken token) + { + static bool Test(RegistryKey regKey, string regKeyName, Func testPredicate) + { + T preTestVal = regKey?.GetValue(regKeyName) is T typedValue ? typedValue : default(T); + return testPredicate(preTestVal); + } + + if (token == default(CancellationToken)) + { + CancellationTokenSource source = new(TimeSpan.FromSeconds(10)); + token = source.Token; + } + + // Test once before in-case it is already successful. + (RegistryKey baseKey, string valueKey) = GetKey(pathWithKey, false); + if (Test(baseKey, valueKey, predicate)) + { + baseKey.Dispose(); + return Task.CompletedTask; + } + + // Wait for predicate to be successful. + return Task.Run(async () => + { + try + { + while (!token.IsCancellationRequested) + { + // If regkey didn't exist yet it might later. + if (baseKey == null) + { + (baseKey, valueKey) = GetKey(pathWithKey, false); + } + + if (!Test(baseKey, valueKey, predicate)) + { + await Task.Delay(100, token); + continue; + } + + break; + } + } + finally + { + baseKey?.Dispose(); + } + }, + token); + } + + public static Task CompareAsync(string pathWithKey, Func predicate, TimeSpan timeout = default) + { + CancellationTokenSource source = new(timeout == default ? TimeSpan.FromSeconds(10) : timeout); + return CompareAsync(pathWithKey, predicate, source.Token); + } + + private static (RegistryKey baseKey, string valueKey) GetKey(string path, bool needsWriteAccess = true, bool createIfNotExists = false) + { + if (string.IsNullOrWhiteSpace(path)) + { + return (null, null); + } + path = path.Trim(); + + // Parse path to get the registry key instance and the name of the . + Span parts = path.Split(Path.DirectorySeparatorChar); + Span partsWithoutHive; + RegistryHive hive = RegistryHive.CurrentUser; + string regPathWithoutHiveOrKey; + if (path.IndexOf("Computer", StringComparison.OrdinalIgnoreCase) < 0) + { + partsWithoutHive = parts[..^1]; + regPathWithoutHiveOrKey = string.Join(Path.DirectorySeparatorChar.ToString(), partsWithoutHive.ToArray()); + } + else + { + partsWithoutHive = parts[2..^1]; + regPathWithoutHiveOrKey = string.Join(Path.DirectorySeparatorChar.ToString(), partsWithoutHive.ToArray()); + hive = parts[1].ToLowerInvariant() switch + { + "hkey_classes_root" => RegistryHive.ClassesRoot, + "hkey_local_machine" => RegistryHive.LocalMachine, + "hkey_current_user" => RegistryHive.CurrentUser, + "hkey_users" => RegistryHive.Users, + "hkey_current_config" => RegistryHive.CurrentConfig, + _ => throw new ArgumentException($"Path must contain a valid registry hive but was given '{parts[1]}'", nameof(path)) + }; + } + + RegistryKey hiveRef = RegistryKey.OpenBaseKey(hive, RegistryView.Registry64); + RegistryKey key = hiveRef.OpenSubKey(regPathWithoutHiveOrKey, needsWriteAccess); + // Should the key (and its path leading to it) be created? + if (key == null && createIfNotExists) + { + key = hiveRef; + foreach (string part in partsWithoutHive) + { + RegistryKey prev = key; + key = key?.OpenSubKey(part, needsWriteAccess) ?? key?.CreateSubKey(part, needsWriteAccess); + + // Cleanup old/parent key reference + prev?.Dispose(); + } + } + + return (key, parts[^1]); + } +} diff --git a/NitroxModel/Platforms/OS/Windows/RegistryKeyValueStore.cs b/NitroxModel/Platforms/OS/Windows/RegistryKeyValueStore.cs new file mode 100644 index 0000000000..392f687989 --- /dev/null +++ b/NitroxModel/Platforms/OS/Windows/RegistryKeyValueStore.cs @@ -0,0 +1,29 @@ +using System; +using NitroxModel.Helper; +using NitroxModel.Platforms.OS.Windows.Internal; + +namespace NitroxModel.Platforms.OS.Windows; + +public class RegistryKeyValueStore : IKeyValueStore +{ + public static string KeyToRegistryPath(string key) => @$"SOFTWARE\Nitrox\{key}"; + + public T GetValue(string key, T defaultValue) => RegistryEx.Read(KeyToRegistryPath(key), defaultValue); + + public bool SetValue(string key, T value) + { + try + { + RegistryEx.Write(KeyToRegistryPath(key), value); + return true; + } + catch (Exception) + { + return false; + } + } + + public bool DeleteKey(string key) => RegistryEx.Delete(KeyToRegistryPath(key)); + + public bool KeyExists(string key) => RegistryEx.Exists(KeyToRegistryPath(key)); +} diff --git a/NitroxModel/Platforms/OS/Windows/WindowsApi.cs b/NitroxModel/Platforms/OS/Windows/WindowsApi.cs index ff3493215b..7a4578dd4e 100644 --- a/NitroxModel/Platforms/OS/Windows/WindowsApi.cs +++ b/NitroxModel/Platforms/OS/Windows/WindowsApi.cs @@ -1,26 +1,63 @@ using System; using System.Runtime.InteropServices; -using NitroxModel.Platforms.OS.Windows.Internal; +using static NitroxModel.Platforms.OS.Windows.Internal.Win32Native; namespace NitroxModel.Platforms.OS.Windows; public class WindowsApi { - public static void EnableDefaultWindowAnimations(IntPtr hWnd, int nIndex = -16) + /// + /// Applies default OS animations to the window handle. + /// + /// + /// Note on Windows OS: it will force enable resizing of a Window if is true. Make sure to set it correctly. + /// + public static void EnableDefaultWindowAnimations(nint windowHandle, bool canResize) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - IntPtr dwNewLong = new((long)(Win32Native.WS.WS_CAPTION | Win32Native.WS.WS_CLIPCHILDREN | Win32Native.WS.WS_MINIMIZEBOX | Win32Native.WS.WS_MAXIMIZEBOX | Win32Native.WS.WS_SYSMENU | Win32Native.WS.WS_SIZEBOX)); - HandleRef handle = new(null, hWnd); - switch (IntPtr.Size) - { - case 8: - Win32Native.SetWindowLongPtr64(handle, nIndex, dwNewLong); - break; - default: - Win32Native.SetWindowLong32(handle, nIndex, dwNewLong.ToInt32()); - break; - } + return; + } + + WS dwNewLong = WS.WS_CAPTION | WS.WS_CLIPCHILDREN | WS.WS_MINIMIZEBOX | WS.WS_MAXIMIZEBOX | WS.WS_SYSMENU; + if (canResize) + { + dwNewLong |= WS.WS_SIZEBOX; + } + + HandleRef handle = new(null, windowHandle); + switch (IntPtr.Size) + { + case 8: + SetWindowLongPtr64(handle, -16, (long)dwNewLong); + break; + default: + SetWindowLong32(handle, -16, (int)dwNewLong); + break; } } + + public static void BringProcessToFront(IntPtr windowHandle) + { + if (windowHandle == IntPtr.Zero) + { + return; + } + const int SW_RESTORE = 9; + if (IsIconic(windowHandle)) + { + ShowWindow(windowHandle, SW_RESTORE); + } + + SetForegroundWindow(windowHandle); + } + + [DllImport("User32.dll")] + private static extern bool SetForegroundWindow(IntPtr handle); + [DllImport("User32.dll")] + private static extern bool ShowWindow(IntPtr handle, int nCmdShow); + [DllImport("User32.dll")] + private static extern bool IsIconic(IntPtr handle); + [DllImport("User32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr FindWindow(string lpClassName, string lpWindowName); } diff --git a/NitroxModel/Platforms/Store/GamePlatforms.cs b/NitroxModel/Platforms/Store/GamePlatforms.cs index 0c1665a75d..ca574201df 100644 --- a/NitroxModel/Platforms/Store/GamePlatforms.cs +++ b/NitroxModel/Platforms/Store/GamePlatforms.cs @@ -1,28 +1,27 @@ using System.IO; using NitroxModel.Platforms.Store.Interfaces; -namespace NitroxModel.Platforms.Store +namespace NitroxModel.Platforms.Store; + +public static class GamePlatforms { - public static class GamePlatforms - { - public static readonly IGamePlatform[] AllPlatforms = { Steam.Instance, EpicGames.Instance, Discord.Instance, MSStore.Instance }; + public static readonly IGamePlatform[] AllPlatforms = [Steam.Instance, EpicGames.Instance, Discord.Instance, MSStore.Instance]; - public static IGamePlatform GetPlatformByGameDir(string gameDirectory) + public static IGamePlatform GetPlatformByGameDir(string gameDirectory) + { + if (!Directory.Exists(gameDirectory)) { - if (!Directory.Exists(gameDirectory)) - { - return null; - } + return null; + } - foreach (IGamePlatform platform in AllPlatforms) + foreach (IGamePlatform platform in AllPlatforms) + { + if (platform.OwnsGame(gameDirectory)) { - if (platform.OwnsGame(gameDirectory)) - { - return platform; - } + return platform; } - - return null; } + + return null; } } diff --git a/NitroxModel/Platforms/Store/Interfaces/IGamePlatform.cs b/NitroxModel/Platforms/Store/Interfaces/IGamePlatform.cs index 2cf989c473..12761786cc 100644 --- a/NitroxModel/Platforms/Store/Interfaces/IGamePlatform.cs +++ b/NitroxModel/Platforms/Store/Interfaces/IGamePlatform.cs @@ -9,7 +9,7 @@ public interface IGamePlatform string Name { get; } Platform Platform { get; } - + /// /// Tries to start the platform and waits for it to be ready to launch games. If it has already been started it will return true. /// @@ -28,7 +28,5 @@ public interface IGamePlatform /// Directory to a game, usually where the exe file is. /// Returns true if the game platform owns this game. bool OwnsGame(string gameDirectory); - - } } diff --git a/NitroxModel/Platforms/Store/MSStore.cs b/NitroxModel/Platforms/Store/MSStore.cs index 241616fdff..eda9bdd652 100644 --- a/NitroxModel/Platforms/Store/MSStore.cs +++ b/NitroxModel/Platforms/Store/MSStore.cs @@ -39,7 +39,7 @@ public async Task StartGameAsync(string pathToGameExe) @"C:\Windows\System32\cmd.exe", null, Path.GetDirectoryName(pathToGameExe), - @$"/C start /b {pathToGameExe} -nitrox ""{NitroxUser.LauncherPath}""", + @$"/C start /b {pathToGameExe} --nitrox ""{NitroxUser.LauncherPath}""", createWindow: false) ); } diff --git a/NitroxModel/Platforms/Store/Steam.cs b/NitroxModel/Platforms/Store/Steam.cs index c4fd50e5ab..1a902d45fa 100644 --- a/NitroxModel/Platforms/Store/Steam.cs +++ b/NitroxModel/Platforms/Store/Steam.cs @@ -1,93 +1,358 @@ -using System; +using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using NitroxModel.Discovery.Models; using NitroxModel.Helper; using NitroxModel.Platforms.OS.Shared; -using NitroxModel.Platforms.OS.Windows.Internal; +using NitroxModel.Platforms.OS.Windows; using NitroxModel.Platforms.Store.Exceptions; using NitroxModel.Platforms.Store.Interfaces; -namespace NitroxModel.Platforms.Store +namespace NitroxModel.Platforms.Store; + +public sealed class Steam : IGamePlatform { - public sealed class Steam : IGamePlatform + private static Steam instance; + public static Steam Instance => instance ??= new Steam(); + public string Name => nameof(Steam); + public Platform Platform => Platform.STEAM; + + private string SteamProcessName => RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "steam_osx" : "steam"; + + public bool OwnsGame(string gameDirectory) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return Directory.Exists(Path.Combine(gameDirectory, "Plugins", "steam_api.bundle")); + } + + if (File.Exists(Path.Combine(gameDirectory, GameInfo.Subnautica.DataFolder, "Plugins", "x86_64", "steam_api64.dll"))) + { + return true; + } + + return File.Exists(Path.Combine(gameDirectory, GameInfo.Subnautica.DataFolder, "Plugins", "steam_api64.dll")); + } + + public async Task StartPlatformAsync() { - private static Steam instance; - public static Steam Instance => instance ??= new Steam(); + // If steam is already running, do not start it. + ProcessEx steam = ProcessEx.GetFirstProcess(SteamProcessName); + if (steam is not null) + { + return steam; + } + + // Steam is not running, start it. + string exe = GetExeFile(); + if (exe is null) + { + return null; + } - public string Name => nameof(Steam); - public Platform Platform => Platform.STEAM; + Process process = Process.Start(new ProcessStartInfo + { + WorkingDirectory = Path.GetDirectoryName(exe) ?? Directory.GetCurrentDirectory(), + FileName = exe, + WindowStyle = ProcessWindowStyle.Minimized, + UseShellExecute = true, + Arguments = "-silent" // Don't show Steam window + }); - public bool OwnsGame(string gameDirectory) + if (process is not { HasExited: false }) { - return File.Exists(Path.Combine(gameDirectory, "Subnautica_Data", "Plugins", "x86_64", "steam_api64.dll")); + return null; + } + + steam = new ProcessEx(process); + // Wait for steam to write to its log file, which indicates it's ready to start games. + using CancellationTokenSource steamReadyCts = new(TimeSpan.FromSeconds(30)); + try + { + DateTime consoleLogFileLastWrite = GetSteamConsoleLogLastWrite(Path.GetDirectoryName(exe)); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + await RegistryEx.CompareAsync(@"SOFTWARE\Valve\Steam\ActiveProcess\ActiveUser", + v => v > 0, + steamReadyCts.Token); + } + while (consoleLogFileLastWrite == GetSteamConsoleLogLastWrite(Path.GetDirectoryName(exe)) && !steamReadyCts.IsCancellationRequested) + { + try + { + await Task.Delay(250, steamReadyCts.Token); + } + catch (OperationCanceledException) + { + // ignored + } + } + } + catch (Exception ex) + { + Log.Error(ex); } - public async Task StartPlatformAsync() + return steam; + } + + public string GetExeFile() + { + string exe = ""; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // If steam is already running, do not start it. - ProcessEx steam = ProcessEx.GetFirstProcess("steam", p => p.MainModuleDirectory != null && File.Exists(Path.Combine(p.MainModuleDirectory, "steamclient.dll"))); - if (steam != null) + exe = Path.Combine(RegistryEx.Read(@"SOFTWARE\Valve\Steam\SteamPath", ""), "steam.exe"); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + exe = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Steam", "Steam.AppBundle", "Steam", "Contents", "MacOS", "steam_osx"); + } + + return File.Exists(exe) ? Path.GetFullPath(exe) : null; + } + + public async Task StartGameAsync(string pathToGameExe, int steamAppId, string launchArguments) + { + try + { + using ProcessEx steam = await StartPlatformAsync(); + if (steam == null) { - return steam; + throw new PlatformException(Instance, "Steam is not running and could not be found."); } + } + catch (OperationCanceledException ex) + { + throw new PlatformException(Instance, "Timeout reached while waiting for platform to start. Try again once platform has finished loading.", ex); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return ProcessEx.Start( + pathToGameExe, + [("SteamGameId", steamAppId.ToString()), ("SteamAppID", steamAppId.ToString()), (NitroxUser.LAUNCHER_PATH_ENV_KEY, NitroxUser.LauncherPath)], + Path.GetDirectoryName(pathToGameExe), + launchArguments + ); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return StartGameWithProton(pathToGameExe, steamAppId, launchArguments); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return ProcessEx.Start( + pathToGameExe, + [("SteamGameId", steamAppId.ToString()), ("SteamAppID", steamAppId.ToString()), (NitroxUser.LAUNCHER_PATH_ENV_KEY, NitroxUser.LauncherPath)], + Path.GetDirectoryName(pathToGameExe), + launchArguments + ); + } + + throw new PlatformException(Instance, "Platform is not supported."); + } - // Steam is not running, start it. - string exe = GetExeFile(); - if (exe == null) + private static ProcessEx StartGameWithProton(string pathToGameExe, int steamAppId, string launchArguments) + { + // function to get library path for given game id + static string GetLibraryPath(string steamPath, string gameId) + { + string libraryFoldersPath = Path.Combine(steamPath, "config", "libraryfolders.vdf"); + string content = File.ReadAllText(libraryFoldersPath); + + // Regex to match library folder entries + Regex folderRegex = new(@"""(\d+)""\s*\{[^}]*""path""\s*""([^""]+)""[^}]*""apps""\s*\{([^}]+)\}", RegexOptions.Singleline); + MatchCollection matches = folderRegex.Matches(content); + + foreach (Match match in matches) { - return null; + string path = match.Groups[2].Value; + string apps = match.Groups[3].Value; + + // Check if the gameId exists in the apps section + if (Regex.IsMatch(apps, $@"""{gameId}""\s*""[^""]+""")) + { + return path; + } } - steam = new ProcessEx(Process.Start(new ProcessStartInfo + + return ""; // Return empty string if not found + } + + static List GetAllLibraryPaths(string steamPath) + { + string libraryFoldersPath = Path.Combine(steamPath, "config", "libraryfolders.vdf"); + string content = File.ReadAllText(libraryFoldersPath); + + // Regex to match library folder entries + Regex folderRegex = new(@"""(\d+)""\s*\{[^}]*""path""\s*""([^""]+)""", RegexOptions.Singleline); + MatchCollection matches = folderRegex.Matches(content); + + List libraryPaths = []; + foreach (Match match in matches) { - WorkingDirectory = Path.GetDirectoryName(exe) ?? Directory.GetCurrentDirectory(), - FileName = exe, - WindowStyle = ProcessWindowStyle.Minimized, - Arguments = "-silent" // Don't show Steam window - })); - - // Wait for Steam to get ready. Steam will update the PID and set the ActiveUser to 0 while starting. Once UI is loaded it will update ActiveUser to > 0 value. - await RegistryEx.CompareAsync(@"SOFTWARE\Valve\Steam\ActiveProcess\pid", - v => v == steam.Id, - TimeSpan.FromSeconds(45)); - await RegistryEx.CompareAsync(@"SOFTWARE\Valve\Steam\ActiveProcess\ActiveUser", - v => v == 0, - TimeSpan.FromSeconds(20)); - await RegistryEx.CompareAsync(@"SOFTWARE\Valve\Steam\ActiveProcess\ActiveUser", - v => v > 0, - TimeSpan.FromSeconds(20)); - return steam; + string path = match.Groups[2].Value.Replace("\\\\", "\\"); + if (Directory.Exists(Path.Combine(path, "steamapps", "common"))) + { + libraryPaths.Add(path); + } + } + // Add the default Steam library path + string defaultLibraryPath = Path.Combine(steamPath); + if (!libraryPaths.Contains(defaultLibraryPath)) + { + libraryPaths.Add(defaultLibraryPath); + } + + return libraryPaths; + } + + static string GetProtonVersionFromConfigVdf(string configVdfFile, string appId) + { + try + { + string fileContent = File.ReadAllText(configVdfFile); + Match compatToolMatch = Regex.Match(fileContent, @"""CompatToolMapping""\s*{((?:\s*""\d+""[^{]+[^}]+})*)\s*}"); + + if (compatToolMatch.Success) + { + string compatToolMapping = compatToolMatch.Groups[1].Value; + string appIdPattern = $@"""{appId}""[^{{]*\{{[^}}]*""name""\s*""([^""]+)"""; + Match appIdMatch = Regex.Match(compatToolMapping, appIdPattern); + + if (appIdMatch.Success) + { + return appIdMatch.Groups[1].Value; + } + } + + return "Proton version not found for the given appId"; + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } } - public string GetExeFile() + string userHomePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(userHomePath)) + { + userHomePath = Environment.GetEnvironmentVariable("HOME"); + } + if (!Directory.Exists(userHomePath)) { - string steamPath = RegistryEx.Read(@"SOFTWARE\Valve\Steam\SteamPath", ""); - string exe = Path.Combine(steamPath, "steam.exe"); - return File.Exists(exe) ? Path.GetFullPath(exe) : null; + throw new Exception("User HOME is not known"); } - public async Task StartGameAsync(string pathToGameExe, int steamAppId, string launchArguments) + string compatdataPath = ""; + if (!string.IsNullOrEmpty(pathToGameExe)) { - try + string[] pathComponents = pathToGameExe.Split(Path.DirectorySeparatorChar); + int steamAppsIndex = pathComponents.GetIndex("steamapps"); + if (steamAppsIndex != -1) + { + string steamAppsPath = string.Join(Path.DirectorySeparatorChar.ToString(), pathComponents, 0, steamAppsIndex + 1); + compatdataPath = Path.Combine(steamAppsPath, "compatdata", steamAppId.ToString()); + } + } + string steamPath = Path.Combine(userHomePath, ".steam/steam"); + string geProtonPath = Path.Combine(userHomePath, ".steam/root/compatibilitytools.d/"); + // support flatpak + if (!Directory.Exists(steamPath)) + { + steamPath = Path.Combine(userHomePath, ".var/app/com.valvesoftware.Steam/data/Steam/"); + geProtonPath = Path.Combine(userHomePath, ".var/app/com.valvesoftware.Steam/data/Steam/compatibilitytools.d/"); + } + + string sniperappid = "1628350"; + string sniperruntimepath = Path.Combine(GetLibraryPath(steamPath, sniperappid), "steamapps", "common", "SteamLinuxRuntime_sniper"); + + string protonVersion = GetProtonVersionFromConfigVdf(Path.Combine(steamPath, "config", "config.vdf"), steamAppId.ToString()); + if (protonVersion == "Proton version not found for the given appId") + { + protonVersion = "proton_9"; + } + string protonDir = null; + bool isValveProton = protonVersion.StartsWith("proton_", StringComparison.OrdinalIgnoreCase); + if (isValveProton) + { + int index = protonVersion.IndexOf("proton_", StringComparison.OrdinalIgnoreCase); + if (index != -1) { - using ProcessEx steam = await StartPlatformAsync(); - if (steam == null) + protonVersion = protonVersion.Substring(index + "proton_".Length); + } + if (protonVersion == "experimental") + { + protonVersion = "-"; + } + + foreach (string path in GetAllLibraryPaths(steamPath)) + { + foreach (string dir in Directory.EnumerateDirectories(Path.Combine(path, "steamapps", "common"))) + { + if (dir.Contains($"Proton {protonVersion}")) + { + protonDir = dir; + break; + } + } + if (protonDir != null) { - throw new PlatformException(Instance, "Steam is not running and could not be found."); + break; } } - catch (OperationCanceledException ex) + } + else + { + protonDir = Path.Combine(geProtonPath, protonVersion); + } + if (protonDir == null) + { + throw new Exception("Game is not using Proton. Please change game properties in Steam to use the Proton compatibility layer."); + } + + ProcessStartInfo startInfo = new() + { + FileName = Path.Combine(sniperruntimepath, "_v2-entry-point"), + Arguments = $" --verb=run -- \"{Path.Combine(protonDir, "proton")}\" run \"{pathToGameExe}\" {launchArguments}", + WorkingDirectory = Path.GetDirectoryName(pathToGameExe) ?? "", + UseShellExecute = false, + Environment = { - throw new PlatformException(Instance, "Timeout reached while waiting for platform to start. Try again once platform has finished loading.", ex); + [NitroxUser.LAUNCHER_PATH_ENV_KEY] = NitroxUser.LauncherPath, + ["SteamGameId"] = steamAppId.ToString(), + ["SteamAppID"] = steamAppId.ToString(), + ["STEAM_COMPAT_APP_ID"] = steamAppId.ToString(), + ["WINEPREFIX"] = compatdataPath, + ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = steamPath, + ["STEAM_COMPAT_DATA_PATH"] = compatdataPath, } + }; + + return new ProcessEx(Process.Start(startInfo)); + } - return ProcessEx.Start( - pathToGameExe, - new[] { ("SteamGameId", steamAppId.ToString()), ("SteamAppID", steamAppId.ToString()), (NitroxUser.LAUNCHER_PATH_ENV_KEY, NitroxUser.LauncherPath) }, - Path.GetDirectoryName(pathToGameExe), - launchArguments - ); + private static DateTime GetSteamConsoleLogLastWrite(string exePath) + { + string path; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Steam", "logs", "console_log.txt"); } + else + { + path = Path.Combine(Path.GetDirectoryName(exePath), "logs", "console_log.txt"); + } + + return File.GetLastWriteTime(path); } } diff --git a/NitroxModel/Serialization/IProperties.cs b/NitroxModel/Serialization/IProperties.cs index 3dc910f9d9..9427d0178b 100644 --- a/NitroxModel/Serialization/IProperties.cs +++ b/NitroxModel/Serialization/IProperties.cs @@ -1,27 +1,21 @@ using System; using System.ComponentModel; -namespace NitroxModel.Serialization +namespace NitroxModel.Serialization; + +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Class, Inherited = false)] +public sealed class PropertyDescriptionAttribute : DescriptionAttribute { - public interface IProperties + public PropertyDescriptionAttribute(string desc) : base(desc) { - public string FileName { get; } } - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Class, Inherited = false)] - public sealed class PropertyDescriptionAttribute : DescriptionAttribute + public PropertyDescriptionAttribute(string desc, Type type) { - public PropertyDescriptionAttribute(string desc) : base(desc) - { - } - - public PropertyDescriptionAttribute(string desc, Type type) + if (type.IsEnum) { - if (type.IsEnum) - { - desc += $" {string.Join(", ", type.GetEnumNames())}"; - DescriptionValue = desc; - } + desc += $" {string.Join(", ", type.GetEnumNames())}"; + DescriptionValue = desc; } } } diff --git a/NitroxModel/Serialization/NitroxConfig.cs b/NitroxModel/Serialization/NitroxConfig.cs index 0058eb2b75..2b7f10a219 100644 --- a/NitroxModel/Serialization/NitroxConfig.cs +++ b/NitroxModel/Serialization/NitroxConfig.cs @@ -1,253 +1,265 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; -namespace NitroxModel.Serialization +namespace NitroxModel.Serialization; + +public abstract class NitroxConfig where T : NitroxConfig, new() { - public abstract class NitroxConfig where T : NitroxConfig, new() - { - // ReSharper disable once StaticMemberInGenericType - private static readonly Dictionary typeCache = new(); - private readonly object locker = new(); - private readonly char[] newlineChars = Environment.NewLine.ToCharArray(); + private static readonly Dictionary unserializedMembersWarnOnceCache = []; + private static readonly Dictionary typeCache = []; + + private readonly char[] newlineChars = Environment.NewLine.ToCharArray(); + private readonly object locker = new(); - public abstract string FileName { get; } + public abstract string FileName { get; } + + public static T Load(string saveDir) + { + T config = new(); + config.Update(saveDir); + return config; + } - public static T Load(string saveDir) + public void Deserialize(string saveDir) + { + if (!File.Exists(Path.Combine(saveDir, FileName))) { - T config = new(); - config.Update(saveDir); - return config; + return; } - public void Deserialize(string saveDir) + lock (locker) { - if (!File.Exists(Path.Combine(saveDir, FileName))) - { - return; - } + Type type = GetType(); + Dictionary typeCachedDict = GetTypeCacheDictionary(); + using StreamReader reader = new(new FileStream(Path.Combine(saveDir, FileName), FileMode.Open, FileAccess.Read, FileShare.Read), Encoding.UTF8); - lock (locker) - { - Type type = GetType(); - Dictionary typeCachedDict = GetTypeCacheDictionary(); - using StreamReader reader = new(new FileStream(Path.Combine(saveDir, FileName), FileMode.Open, FileAccess.Read, FileShare.Read), Encoding.UTF8); + HashSet unserializedMembers = new(typeCachedDict.Values); + char[] lineSeparator = { '=' }; + int lineNum = 0; + string readLine; - HashSet unserializedMembers = new(typeCachedDict.Values); - char[] lineSeparator = { '=' }; - int lineNum = 0; - string readLine; + while ((readLine = reader.ReadLine()) != null) + { + lineNum++; + if (readLine.Length < 1 || readLine[0] == '#') + { + continue; + } - while ((readLine = reader.ReadLine()) != null) + if (readLine.Contains('=')) { - lineNum++; - if (readLine.Length < 1 || readLine[0] == '#') + string[] keyValuePair = readLine.Split(lineSeparator, 2); + // Ignore case for property names in file. + if (!typeCachedDict.TryGetValue(keyValuePair[0].ToLowerInvariant(), out MemberInfo member)) { + Log.Warn($"Property or field {keyValuePair[0]} does not exist on type {type.FullName}!"); continue; } - if (readLine.Contains('=')) - { - string[] keyValuePair = readLine.Split(lineSeparator, 2); - // Ignore case for property names in file. - if (!typeCachedDict.TryGetValue(keyValuePair[0].ToLowerInvariant(), out MemberInfo member)) - { - Log.Warn($"Property or field {keyValuePair[0]} does not exist on type {type.FullName}!"); - continue; - } - - unserializedMembers.Remove(member); // This member was serialized in the file + unserializedMembers.Remove(member); // This member was serialized in the file - if (!SetMemberValue(this, member, keyValuePair[1])) - { - (Type type, object value) data = member switch - { - FieldInfo field => (field.FieldType, field.GetValue(this)), - PropertyInfo prop => (prop.PropertyType, prop.GetValue(this)), - _ => (typeof(string), "") - }; - Log.Warn($@"Property ""({data.type.Name}) {member.Name}"" has an invalid value {StringifyValue(keyValuePair[1])} on line {lineNum}. Using default value: {StringifyValue(data.value)}"); - } - } - else + if (!NitroxConfig.SetMemberValue(this, member, keyValuePair[1])) { - Log.Error($"Incorrect format detected on line {lineNum} in {Path.GetFullPath(Path.Combine(saveDir, FileName))}:{Environment.NewLine}{readLine}"); + (Type type, object value) logData = member switch + { + FieldInfo field => (field.FieldType, field.GetValue(this)), + PropertyInfo prop => (prop.PropertyType, prop.GetValue(this)), + _ => (typeof(string), "") + }; + Log.Warn($@"Property ""({logData.type.Name}) {member.Name}"" has an invalid value {NitroxConfig.StringifyValue(keyValuePair[1])} on line {lineNum}. Using default value: {NitroxConfig.StringifyValue(logData.value)}"); } } - - if (unserializedMembers.Any()) + else { - IEnumerable unserializedProps = unserializedMembers.Select(m => - { - object value = null; - if (m is FieldInfo field) - { - value = field.GetValue(this); - } - else if (m is PropertyInfo prop) - { - value = prop.GetValue(this); - } + Log.Error($"Incorrect format detected on line {lineNum} in {Path.GetFullPath(Path.Combine(saveDir, FileName))}:{Environment.NewLine}{readLine}"); + } + } - return $" - {m.Name}: {value}"; - }); + if (unserializedMembers.Count != 0) + { + string[] unserializedProps = unserializedMembers + .Select(m => + { + object value = null; + if (m is FieldInfo field) + { + value = field.GetValue(this); + } + else if (m is PropertyInfo prop) + { + value = prop.GetValue(this); + } - Log.Warn($@"{FileName} is using default values for the missing properties:{Environment.NewLine}{string.Join(Environment.NewLine, unserializedProps)}"); + if (unserializedMembersWarnOnceCache.TryGetValue(m.Name, out object cachedValue)) + { + if (Equals(value, cachedValue)) + { + return null; + } + } + unserializedMembersWarnOnceCache[m.Name] = value; + return $" - {m.Name}: {value}"; + }) + .Where(i => i != null) + .ToArray(); + if (unserializedProps.Length > 0) + { + Log.Warn($"{FileName} is using default values for the missing properties:{Environment.NewLine}{string.Join(Environment.NewLine, unserializedProps)}"); } } } + } - public void Serialize(string saveDir) + public void Serialize(string saveDir) + { + lock (locker) { - lock (locker) + Type type = GetType(); + Dictionary typeCachedDict = GetTypeCacheDictionary(); + try { - Type type = GetType(); - Dictionary typeCachedDict = GetTypeCacheDictionary(); - try + Directory.CreateDirectory(saveDir); + using StreamWriter stream = new(new FileStream(Path.Combine(saveDir, FileName), FileMode.Create, FileAccess.Write), Encoding.UTF8); + WritePropertyDescription(type, stream); + + foreach (string name in typeCachedDict.Keys) { - using StreamWriter stream = new(new FileStream(Path.Combine(saveDir, FileName), FileMode.Create, FileAccess.Write), Encoding.UTF8); - WritePropertyDescription(type, stream); + MemberInfo member = typeCachedDict[name]; - foreach (string name in typeCachedDict.Keys) + FieldInfo field = member as FieldInfo; + if (field != null) { - MemberInfo member = typeCachedDict[name]; - - FieldInfo field = member as FieldInfo; - if (field != null) - { - WritePropertyDescription(member, stream); - WriteProperty(field, field.GetValue(this), stream); - } + WritePropertyDescription(member, stream); + NitroxConfig.WriteProperty(field, field.GetValue(this), stream); + } - PropertyInfo property = member as PropertyInfo; - if (property != null) - { - WritePropertyDescription(member, stream); - WriteProperty(property, property.GetValue(this), stream); - } + PropertyInfo property = member as PropertyInfo; + if (property != null) + { + WritePropertyDescription(member, stream); + NitroxConfig.WriteProperty(property, property.GetValue(this), stream); } } - catch (UnauthorizedAccessException) - { - Log.Error($"Config file {FileName} exists but is a hidden file and cannot be modified, config file will not be updated. Please make file accessible"); - } + } + catch (UnauthorizedAccessException) + { + Log.Error($"Config file {FileName} exists but is a hidden file and cannot be modified, config file will not be updated. Please make file accessible"); } } + } - /// - /// Ensures updates are properly persisted to the backing config file without overwriting user edits. - /// - public UpdateDiposable Update(string saveDir) - { - return new UpdateDiposable(this, saveDir); - } + /// + /// Ensures updates are properly persisted to the backing config file without overwriting user edits. + /// + public UpdateDiposable Update(string saveDir) + { + return new UpdateDiposable(this, saveDir); + } - private static Dictionary GetTypeCacheDictionary() + private static Dictionary GetTypeCacheDictionary() + { + Type type = typeof(T); + if (typeCache.Count == 0) { - Type type = typeof(T); - if (!typeCache.Any()) - { - IEnumerable members = type.GetFields() - .Where(f => f.Attributes != FieldAttributes.NotSerialized) - .Concat(type.GetProperties() - .Where(p => p.CanWrite) - .Cast()); + IEnumerable members = type.GetFields() + .Where(f => f.Attributes != FieldAttributes.NotSerialized) + .Concat(type.GetProperties() + .Where(p => p.CanWrite) + .Cast()); - try - { - foreach (MemberInfo member in members) - { - typeCache.Add(member.Name.ToLowerInvariant(), member); - } - } - catch (ArgumentException e) + try + { + foreach (MemberInfo member in members) { - Log.Error(e, $"Type {type.FullName} has properties that require case-sensitivity to be unique which is unsuitable for .properties format."); - throw; + typeCache.Add(member.Name.ToLowerInvariant(), member); } } - - return typeCache; - } - - private string StringifyValue(object value) - { - return value switch + catch (ArgumentException e) { - string _ => $@"""{value}""", - null => @"""""", - _ => value.ToString() - }; + Log.Error(e, $"Type {type.FullName} has properties that require case-sensitivity to be unique which is unsuitable for .properties format."); + throw; + } } - private bool SetMemberValue(NitroxConfig instance, MemberInfo member, string valueFromFile) + return typeCache; + } + + private static string StringifyValue(object value) => value switch + { + string _ => $@"""{value}""", + null => @"""""", + _ => value.ToString() + }; + + private static bool SetMemberValue(NitroxConfig instance, MemberInfo member, string valueFromFile) + { + object ConvertFromStringOrDefault(Type typeOfValue, out bool isDefault, object defaultValue = default) { - object ConvertFromStringOrDefault(Type typeOfValue, out bool isDefault, object defaultValue = default) + try { - try - { - object newValue = TypeDescriptor.GetConverter(typeOfValue).ConvertFrom(valueFromFile); - isDefault = false; - return newValue; - } - catch (Exception) - { - isDefault = true; - return defaultValue; - } + object newValue = TypeDescriptor.GetConverter(typeOfValue).ConvertFrom(null!, CultureInfo.InvariantCulture, valueFromFile); + isDefault = false; + return newValue; } - - bool usedDefault; - switch (member) + catch (Exception) { - case FieldInfo field: - field.SetValue(instance, ConvertFromStringOrDefault(field.FieldType, out usedDefault, field.GetValue(instance))); - return !usedDefault; - case PropertyInfo prop: - prop.SetValue(instance, ConvertFromStringOrDefault(prop.PropertyType, out usedDefault, prop.GetValue(instance))); - return !usedDefault; - default: - throw new Exception($"Serialized member must be field or property: {member}."); + isDefault = true; + return defaultValue; } } - private void WriteProperty(TMember member, object value, StreamWriter stream) where TMember : MemberInfo + bool usedDefault; + switch (member) { - stream.Write(member.Name); - stream.Write('='); - stream.WriteLine(value); + case FieldInfo field: + field.SetValue(instance, ConvertFromStringOrDefault(field.FieldType, out usedDefault, field.GetValue(instance))); + return !usedDefault; + case PropertyInfo prop: + prop.SetValue(instance, ConvertFromStringOrDefault(prop.PropertyType, out usedDefault, prop.GetValue(instance))); + return !usedDefault; + default: + throw new Exception($"Serialized member must be field or property: {member}."); } + } - private void WritePropertyDescription(MemberInfo member, StreamWriter stream) + private static void WriteProperty(TMember member, object value, StreamWriter stream) where TMember : MemberInfo + { + stream.Write(member.Name); + stream.Write('='); + stream.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture)); + } + + private void WritePropertyDescription(MemberInfo member, StreamWriter stream) + { + PropertyDescriptionAttribute attribute = member.GetCustomAttribute(); + if (attribute != null) { - PropertyDescriptionAttribute attribute = member.GetCustomAttribute(); - if (attribute != null) + foreach (string line in attribute.Description.Split(newlineChars)) { - foreach (string line in attribute.Description.Split(newlineChars)) - { - stream.Write("# "); - stream.WriteLine(line); - } + stream.Write("# "); + stream.WriteLine(line); } } + } - public struct UpdateDiposable : IDisposable - { - private string SaveDir { get; } - private NitroxConfig Config { get; } - - public UpdateDiposable(NitroxConfig config, string saveDir) - { - config.Deserialize(saveDir); - SaveDir = saveDir; - Config = config; - } + public readonly struct UpdateDiposable : IDisposable + { + private string SaveDir { get; } + private NitroxConfig Config { get; } - public void Dispose() => Config.Serialize(SaveDir); + public UpdateDiposable(NitroxConfig config, string saveDir) + { + config.Deserialize(saveDir); + SaveDir = saveDir; + Config = config; } + + public void Dispose() => Config.Serialize(SaveDir); } } diff --git a/NitroxServer/Serialization/ServerConfig.cs b/NitroxModel/Serialization/SubnauticaServerConfig.cs similarity index 87% rename from NitroxServer/Serialization/ServerConfig.cs rename to NitroxModel/Serialization/SubnauticaServerConfig.cs index cab3e60f65..b75df797c2 100644 --- a/NitroxServer/Serialization/ServerConfig.cs +++ b/NitroxModel/Serialization/SubnauticaServerConfig.cs @@ -1,25 +1,25 @@ using NitroxModel.DataStructures.GameLogic; using NitroxModel.Helper; -using NitroxModel.Serialization; using NitroxModel.Server; -namespace NitroxServer.Serialization +namespace NitroxModel.Serialization { [PropertyDescription("Server settings can be changed here")] - public class ServerConfig : NitroxConfig + public class SubnauticaServerConfig : NitroxConfig { private int maxConnectionsSetting = 100; private int initialSyncTimeoutSetting = 300000; [PropertyDescription("Set to true to Cache entities for the whole map on next run. \nWARNING! Will make server load take longer on the cache run but players will gain a performance boost when entering new areas.")] - public bool CreateFullEntityCache = false; + public bool CreateFullEntityCache { get; set; } = false; private int saveIntervalSetting = 120000; + private int maxBackupsSetting = 10; + private string postSaveCommandPath = string.Empty; - private string saveNameSetting = "My World"; public override string FileName => "server.cfg"; [PropertyDescription("Leave blank for a random spawn position")] @@ -39,6 +39,17 @@ public int SaveInterval } } + public int MaxBackups + { + get => maxBackupsSetting; + + set + { + Validate.IsTrue(value >= 0, "MaxBackups must be greater than or equal to 0"); + maxBackupsSetting = value; + } + } + [PropertyDescription("Command to run following a successful world save (e.g. .exe, .bat, or PowerShell script). ")] public string PostSaveCommandPath { @@ -72,16 +83,7 @@ public int InitialSyncTimeout public bool DisableAutoSave { get; set; } - public string SaveName - { - get => saveNameSetting; - - set - { - Validate.IsFalse(string.IsNullOrWhiteSpace(value), "SaveName can't be an empty string"); - saveNameSetting = value; - } - } + public bool DisableAutoBackup { get; set; } public string ServerPassword { get; set; } = string.Empty; @@ -107,8 +109,6 @@ public string SaveName [PropertyDescription("Recommended to keep at 0.1f which is the default starting value. If set to 0 then new players are cured by default.")] public float DefaultInfectionValue { get; set; } = 0.1f; - public bool IsHardcore => GameMode == NitroxGameMode.HARDCORE; - public bool IsPasswordRequired => ServerPassword != string.Empty; public PlayerStatsData DefaultPlayerStats => new(DefaultOxygenValue, DefaultMaxOxygenValue, DefaultHealthValue, DefaultHungerValue, DefaultThirstValue, DefaultInfectionValue); [PropertyDescription("If set to true, the server will try to open port on your router via UPnP")] public bool AutoPortForward { get; set; } = true; diff --git a/NitroxPatcher/Main.cs b/NitroxPatcher/Main.cs index 377b07d860..65ef480c7d 100644 --- a/NitroxPatcher/Main.cs +++ b/NitroxPatcher/Main.cs @@ -1,11 +1,11 @@ extern alias JB; -global using static NitroxModel.Extensions; global using NitroxModel.Logger; global using static NitroxClient.Helpers.NitroxEntityExtensions; using System; using System.IO; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using JB::JetBrains.Annotations; using Microsoft.Win32; using NitroxModel.Helper; @@ -26,28 +26,31 @@ public static class Main string[] args = Environment.GetCommandLineArgs(); for (int i = 0; i < args.Length - 1; i++) { - if (args[i].Equals("-nitrox", StringComparison.OrdinalIgnoreCase) && Directory.Exists(args[i + 1])) + if (args[i].Equals("--nitrox", StringComparison.OrdinalIgnoreCase) && Directory.Exists(args[i + 1])) { return Path.GetFullPath(args[i + 1]); } } // Get path from environment variable. - string envPath = Environment.GetEnvironmentVariable("NITROX_LAUNCHER_PATH"); + string envPath = Environment.GetEnvironmentVariable("NITROX_LAUNCHER_PATH", EnvironmentVariableTarget.Process); if (Directory.Exists(envPath)) { return envPath; } - // Get path from windows registry. - using RegistryKey nitroxRegKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Nitrox"); - if (nitroxRegKey == null) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return null; + // Get path from windows registry. + using RegistryKey nitroxRegKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Nitrox"); + if (nitroxRegKey == null) + { + return null; + } + string path = nitroxRegKey.GetValue("LauncherPath") as string; + return Directory.Exists(path) ? path : null; } - - string path = nitroxRegKey.GetValue("LauncherPath") as string; - return Directory.Exists(path) ? path : null; + return null; }); private static readonly char[] newLineChars = Environment.NewLine.ToCharArray(); @@ -63,14 +66,6 @@ public static void Execute() AppDomain.CurrentDomain.AssemblyResolve += CurrentDomainOnAssemblyResolve; AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += CurrentDomainOnAssemblyResolve; - if (nitroxLauncherDir.Value == null) - { - Console.WriteLine("Nitrox will not load because launcher path was not provided."); - return; - } - - Environment.SetEnvironmentVariable("NITROX_LAUNCHER_PATH", nitroxLauncherDir.Value); - Init(); } @@ -83,6 +78,15 @@ public static void Execute() private static void Init() { Log.Setup(gameLogger: new SubnauticaInGameLogger(), useConsoleLogging: false); + + if (nitroxLauncherDir.Value == null) + { + Console.WriteLine("Nitrox will not load because launcher path was not provided."); + return; + } + + Environment.SetEnvironmentVariable("NITROX_LAUNCHER_PATH", nitroxLauncherDir.Value, EnvironmentVariableTarget.Process); + // Capture unity errors to be logged by our logging framework. Application.logMessageReceived += (condition, stackTrace, type) => { @@ -133,7 +137,7 @@ private static Assembly CurrentDomainOnAssemblyResolve(object sender, ResolveEve } // Load DLLs where Nitrox launcher is first, if not found, use Subnautica's DLLs. - string dllPath = Path.Combine(nitroxLauncherDir.Value, "lib", dllFileName); + string dllPath = Path.Combine(nitroxLauncherDir.Value, "lib", "net472", dllFileName); if (!File.Exists(dllPath)) { dllPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), dllFileName); diff --git a/NitroxPatcher/NitroxPatcher.csproj b/NitroxPatcher/NitroxPatcher.csproj index 80498f73c8..fcd07b14e0 100644 --- a/NitroxPatcher/NitroxPatcher.csproj +++ b/NitroxPatcher/NitroxPatcher.csproj @@ -1,17 +1,16 @@ - + - net472 - disable + net472;netstandard2.0 - + - - + + diff --git a/NitroxPatcher/Patches/Persistent/Language_LoadLanguageFile_Patch.cs b/NitroxPatcher/Patches/Persistent/Language_LoadLanguageFile_Patch.cs index 5dfd093828..276196b0f9 100644 --- a/NitroxPatcher/Patches/Persistent/Language_LoadLanguageFile_Patch.cs +++ b/NitroxPatcher/Patches/Persistent/Language_LoadLanguageFile_Patch.cs @@ -37,7 +37,7 @@ public static void Postfix(string language, Dictionary ___string private static bool TryLoadLanguageFile(string fileName, IDictionary strings) { - string filePath = Path.Combine(NitroxUser.LauncherPath, "LanguageFiles", $"{fileName}.json"); + string filePath = Path.Combine(NitroxUser.LanguageFilesPath, $"{fileName}.json"); if (!File.Exists(filePath)) { diff --git a/NitroxPatcher/Patches/Persistent/uGUI_MainMenu_Start_Patch.cs b/NitroxPatcher/Patches/Persistent/uGUI_MainMenu_Start_Patch.cs index 087ab93bf8..bac37f7413 100644 --- a/NitroxPatcher/Patches/Persistent/uGUI_MainMenu_Start_Patch.cs +++ b/NitroxPatcher/Patches/Persistent/uGUI_MainMenu_Start_Patch.cs @@ -1,8 +1,9 @@ -#if DEBUG +#if DEBUG using System; using System.Reflection; using HarmonyLib; using NitroxClient.MonoBehaviours.Gui.MainMenu; +using NitroxModel; using NitroxModel.Helper; namespace NitroxPatcher.Patches.Persistent; @@ -27,10 +28,10 @@ public static void Postfix() Log.Info(string.Join(" ", args)); for (int i = 0; i < args.Length; i++) { - if (args[i].Equals("-instantlaunch", StringComparison.OrdinalIgnoreCase) && args.Length > i + 1) + if (args[i].Equals("--instantlaunch", StringComparison.OrdinalIgnoreCase) && args.Length > i + 1) { - Log.Info($"Detected instant launch, connecting to 127.0.0.1:11000"); - MainMenuMultiplayerPanel.OpenJoinServerMenuAsync("127.0.0.1", "11000", true).ContinueWithHandleError(ex => + Log.Info("Detected instant launch, connecting to 127.0.0.1:11000"); + MainMenuMultiplayerPanel.OpenJoinServerMenuAsync("127.0.0.1", "11000", args[i + 1]).ContinueWithHandleError(ex => { Log.Error(ex); Log.InGame(ex.Message); diff --git a/NitroxServer-Subnautica/AppMutex.cs b/NitroxServer-Subnautica/AppMutex.cs index c0885a9693..fd92aeb0ec 100644 --- a/NitroxServer-Subnautica/AppMutex.cs +++ b/NitroxServer-Subnautica/AppMutex.cs @@ -1,75 +1,56 @@ using System; -using System.Reflection; -using System.Runtime.InteropServices; -using System.Security.AccessControl; -using System.Security.Principal; using System.Threading; -using NitroxModel.Helper; -namespace NitroxServer_Subnautica +namespace NitroxServer_Subnautica; + +public static class AppMutex { - public static class AppMutex - { - private static readonly SemaphoreSlim mutexReleaseGate = new SemaphoreSlim(1); - private static readonly SemaphoreSlim callerGate = new SemaphoreSlim(1); + private static readonly SemaphoreSlim mutexReleaseGate = new(1); + private static readonly SemaphoreSlim callerGate = new(1); - public static void Hold(Action onWaitingForMutex = null, int timeoutInMs = 5000) + public static void Hold(Action onWaitingForMutex = null, CancellationToken ct = default) + { + Thread thread = new(o => { - Validate.IsTrue(timeoutInMs >= 5000, "Timeout must be at least 5 seconds."); - - using CancellationTokenSource acquireSource = new CancellationTokenSource(timeoutInMs); - CancellationToken token = acquireSource.Token; - Thread thread = new Thread(o => + bool first = true; + Mutex mutex = new(false, typeof(AppMutex).Assembly.FullName, out bool _); + try { - bool first = true; - string appGuid = ((GuidAttribute)Assembly.GetExecutingAssembly().GetCustomAttributes(typeof(GuidAttribute), false).GetValue(0)).Value; - string mutexId = $@"Global\{{{appGuid}}}"; - MutexAccessRule allowEveryoneRule = new MutexAccessRule(new SecurityIdentifier(WellKnownSidType.WorldSid, null), - MutexRights.FullControl, - AccessControlType.Allow - ); - MutexSecurity securitySettings = new MutexSecurity(); - securitySettings.AddAccessRule(allowEveryoneRule); - - Mutex mutex = new Mutex(false, mutexId, out bool _, securitySettings); try { - try + while (!mutex.WaitOne(100, false)) { - while (!mutex.WaitOne(100, false)) + ct.ThrowIfCancellationRequested(); + if (first) { - token.ThrowIfCancellationRequested(); - if (first) - { - first = false; - onWaitingForMutex?.Invoke(); - } + first = false; + onWaitingForMutex?.Invoke(); } } - catch (AbandonedMutexException) - { - // Mutex was abandoned in another process, it will still get acquired - } } - finally + catch (AbandonedMutexException) { - callerGate.Release(); - mutexReleaseGate.Wait(-1); - mutex.ReleaseMutex(); + // Mutex was abandoned in another process, it will still get acquired } - }); - mutexReleaseGate.Wait(-1, token); - callerGate.Wait(0, token); - thread.Start(); - - while (!callerGate.Wait(100, token)) + } + finally { + callerGate.Release(); + mutexReleaseGate.Wait(-1); + mutex.ReleaseMutex(); } - } + }); + mutexReleaseGate.Wait(-1, ct); + callerGate.Wait(0, ct); + thread.Start(); - public static void Release() + while (!callerGate.Wait(100, ct)) { - mutexReleaseGate.Release(); } } + + public static void Release() + { + mutexReleaseGate.Release(); + } } diff --git a/NitroxServer-Subnautica/Communication/IpcHost.cs b/NitroxServer-Subnautica/Communication/IpcHost.cs new file mode 100644 index 0000000000..aa31c477fc --- /dev/null +++ b/NitroxServer-Subnautica/Communication/IpcHost.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NitroxModel.Helper; + +namespace NitroxServer_Subnautica.Communication; + +/// +/// Exposes an IPC channel for other local processes to communicate with the server. +/// +public class IpcHost : IDisposable +{ + private readonly CancellationTokenSource commandReadCancellation; + private readonly NamedPipeServerStream server = new($"Nitrox Server {NitroxEnvironment.CurrentProcessId}", PipeDirection.In, 1); + + private IpcHost(CancellationTokenSource commandReadCancellation) + { + this.commandReadCancellation = commandReadCancellation; + } + + public static IpcHost StartReadingCommands(Action onCommandReceived, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(onCommandReceived); + + IpcHost host = new(CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)); + Thread thread = new(async () => + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + string command = await host.ReadStringAsync(cancellationToken); + onCommandReceived(command); + } + catch (OperationCanceledException) + { + // ignored + } + } + }); + thread.IsBackground = true; + thread.Start(); + return host; + } + + public async Task ReadStringAsync(CancellationToken cancellationToken = default) + { + if (!await WaitForConnection()) + { + return ""; + } + + try + { + byte[] sizeBytes = new byte[4]; + await server.ReadExactlyAsync(sizeBytes, cancellationToken); + byte[] stringBytes = new byte[BitConverter.ToUInt32(sizeBytes)]; + await server.ReadExactlyAsync(stringBytes, cancellationToken); + + return Encoding.UTF8.GetString(stringBytes); + } + catch (Exception) + { + return ""; + } + } + + public void Dispose() + { + commandReadCancellation?.Cancel(); + server.Dispose(); + } + + private async Task WaitForConnection() + { + if (server.IsConnected) + { + return true; + } + try + { + await server.WaitForConnectionAsync(); + return true; + } + catch (IOException) + { + try + { + server.Disconnect(); + } + catch (Exception) + { + // ignored + } + } + return false; + } +} diff --git a/NitroxServer-Subnautica/NitroxServer-Subnautica.csproj b/NitroxServer-Subnautica/NitroxServer-Subnautica.csproj index b7268ed09e..7994f25c0c 100644 --- a/NitroxServer-Subnautica/NitroxServer-Subnautica.csproj +++ b/NitroxServer-Subnautica/NitroxServer-Subnautica.csproj @@ -1,11 +1,9 @@ - + - net472 + net9.0 Exe NitroxServer_Subnautica - disable - true @@ -15,18 +13,31 @@ + - + ..\Nitrox.Assets.Subnautica\protobuf-net.dll + + + ..\Nitrox.Assets.Subnautica\Serilog.Sinks.Map.dll + - - - - - - + + + + + + + + + + diff --git a/NitroxServer-Subnautica/Program.cs b/NitroxServer-Subnautica/Program.cs index c1a6d12d38..a4342f6bfb 100644 --- a/NitroxServer-Subnautica/Program.cs +++ b/NitroxServer-Subnautica/Program.cs @@ -1,14 +1,16 @@ -global using NitroxModel.Logger; +global using NitroxModel.Logger; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net; using System.Net.NetworkInformation; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -17,24 +19,24 @@ using NitroxModel.DataStructures.GameLogic; using NitroxModel.DataStructures.Util; using NitroxModel.Helper; -using NitroxModel.Platforms.OS.Shared; using NitroxServer; +using NitroxServer_Subnautica.Communication; using NitroxServer.ConsoleCommands.Processor; namespace NitroxServer_Subnautica; -[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "DIMA001:Dependency Injection container is used directly")] +[SuppressMessage("Usage", "DIMA001:Dependency Injection container is used directly")] public class Program { - private static readonly Dictionary resolvedAssemblyCache = new(); private static Lazy gameInstallDir; private static readonly CircularBuffer inputHistory = new(1000); private static int currentHistoryIndex; + private static readonly CancellationTokenSource serverCts = new(); private static async Task Main(string[] args) { - AppDomain.CurrentDomain.AssemblyResolve += CurrentDomainOnAssemblyResolve; - AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += CurrentDomainOnAssemblyResolve; + AppDomain.CurrentDomain.AssemblyResolve += AssemblyResolver.Handler; + AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += AssemblyResolver.Handler; await StartServer(args); } @@ -69,61 +71,69 @@ Action ConsoleCommandHandler() // The thread that writers to console is paused while selecting text in console. So console writer needs to be async. Log.Setup(true, isConsoleApp: true); AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException; + PosixSignalRegistration.Create(PosixSignal.SIGTERM, CloseWindowHandler); + PosixSignalRegistration.Create(PosixSignal.SIGQUIT, CloseWindowHandler); + PosixSignalRegistration.Create(PosixSignal.SIGINT, CloseWindowHandler); + PosixSignalRegistration.Create(PosixSignal.SIGHUP, CloseWindowHandler); - ConfigureCultureInfo(); + CultureManager.ConfigureCultureInfo(); if (!Console.IsInputRedirected) { Console.TreatControlCAsInput = true; } + Log.Info($"Starting NitroxServer {NitroxEnvironment.ReleasePhase} v{NitroxEnvironment.Version} for Subnautica"); - Server server; Task handleConsoleInputTask; - CancellationTokenSource cancellationToken = new(); + Server server; try { - handleConsoleInputTask = HandleConsoleInputAsync(ConsoleCommandHandler(), cancellationToken); - AppMutex.Hold(() => Log.Info("Waiting on other Nitrox servers to initialize before starting.."), 120000); + handleConsoleInputTask = HandleConsoleInputAsync(ConsoleCommandHandler(), serverCts.Token); + AppMutex.Hold(() => Log.Info("Waiting on other Nitrox servers to initialize before starting.."), serverCts.Token); Stopwatch watch = Stopwatch.StartNew(); // Allow game path to be given as command argument + string gameDir = ""; if (args.Length > 0 && Directory.Exists(args[0]) && File.Exists(Path.Combine(args[0], "Subnautica.exe"))) { - string gameDir = Path.GetFullPath(args[0]); - Log.Info($"Using game files from: {gameDir}"); + gameDir = Path.GetFullPath(args[0]); gameInstallDir = new Lazy(() => gameDir); } else { gameInstallDir = new Lazy(() => { - string gameDir = NitroxUser.GamePath; - Log.Info($"Using game files from: {gameDir}"); - return gameDir; + return gameDir = NitroxUser.GamePath; }); } + Log.Info($"Using game files from: \'{gameInstallDir.Value}\'"); + // TODO: Fix DI to not be slow (should not use IO in type constructors). Instead, use Lazy (et al). This way, cancellation can be faster. NitroxServiceLocator.InitializeDependencyContainer(new SubnauticaServerAutoFacRegistrar()); NitroxServiceLocator.BeginNewLifetimeScope(); - server = NitroxServiceLocator.LocateService(); + string serverSaveName = Server.GetSaveName(args); + Log.SaveName = serverSaveName; - await WaitForAvailablePortAsync(server.Port); - - if (!server.Start(cancellationToken) && !cancellationToken.IsCancellationRequested) + using (CancellationTokenSource portWaitCts = CancellationTokenSource.CreateLinkedTokenSource(serverCts.Token)) { - throw new Exception("Unable to start server."); + TimeSpan portWaitTimeout = TimeSpan.FromSeconds(30); + portWaitCts.CancelAfter(portWaitTimeout); + await WaitForAvailablePortAsync(server.Port, portWaitTimeout, portWaitCts.Token); } - else if (cancellationToken.IsCancellationRequested) - { - watch.Stop(); - } - else + + if (!serverCts.IsCancellationRequested) { - watch.Stop(); - Log.Info($"Server started ({Math.Round(watch.Elapsed.TotalSeconds, 1)}s)"); - Log.Info("To get help for commands, run help in console or /help in chatbox"); + if (!server.Start(serverSaveName, serverCts)) + { + throw new Exception("Unable to start server."); + } + else + { + Log.Info($"Server started ({Math.Round(watch.Elapsed.TotalSeconds, 1)}s)"); + Log.Info("To get help for commands, run help in console or /help in chatbox"); + } } } finally @@ -133,203 +143,278 @@ Action ConsoleCommandHandler() } await handleConsoleInputTask; + server.Stop(true); - Console.WriteLine($"{Environment.NewLine}Server is closing.."); - } - - /// - /// Handles per-key input of the console and passes input submit to . - /// - private static async Task HandleConsoleInputAsync(Action submitHandler, CancellationTokenSource cancellation = default) - { - if (Console.IsInputRedirected) + try { - while (!cancellation?.IsCancellationRequested ?? false) + if (Environment.UserInteractive && Console.In != StreamReader.Null && Debugger.IsAttached) { - submitHandler(await Task.Run(Console.ReadLine)); + Task.Delay(100).Wait(); // Wait for async logs to flush to console + Console.WriteLine($"{Environment.NewLine}Press any key to continue . . ."); + Console.ReadKey(true); } - return; } - - StringBuilder inputLineBuilder = new(); - - void ClearInputLine() + catch { - currentHistoryIndex = 0; - inputLineBuilder.Clear(); - Console.Write($"\r{new string(' ', Console.WindowWidth - 1)}\r"); + // ignored } + } - void RedrawInput(int start = 0, int end = 0) - { - int lastPosition = Console.CursorLeft; - // Expand range to end if end value is -1 - if (start > -1 && end == -1) - { - end = Math.Max(inputLineBuilder.Length - start, 0); - } + private static void CloseWindowHandler(PosixSignalContext context) + { + context.Cancel = false; + serverCts?.Cancel(); + } + + /// + /// Handles per-key input of the console and passes input submit to . + /// + private static async Task HandleConsoleInputAsync(Action submitHandler, CancellationToken ct = default) + { + ConcurrentQueue commandQueue = new(); - if (start == 0 && end == 0) + if (Console.IsInputRedirected) + { + _ = Task.Run(() => { - // Redraw entire line - Console.Write($"\r{new string(' ', Console.WindowWidth - 1)}\r{inputLineBuilder}"); - } - else + while (!ct.IsCancellationRequested) + { + string commandRead = Console.ReadLine(); + commandQueue.Enqueue(commandRead); + } + }, ct).ContinueWith(t => { - // Redraw part of line - string changedInputSegment = inputLineBuilder.ToString(start, end); - Console.CursorVisible = false; - Console.Write($"{changedInputSegment}{new string(' ', inputLineBuilder.Length - changedInputSegment.Length - Console.CursorLeft + 1)}"); - Console.CursorVisible = true; - } - Console.CursorLeft = lastPosition; + if (t.IsFaulted) + { + Log.Error(t.Exception); + } + }, ct); } - - while (!cancellation?.IsCancellationRequested ?? false) + else { - if (!Console.KeyAvailable) + StringBuilder inputLineBuilder = new(); + + void ClearInputLine() { - await Task.Delay(10, cancellation.Token); - continue; + currentHistoryIndex = 0; + inputLineBuilder.Clear(); + Console.Write($"\r{new string(' ', Console.WindowWidth - 1)}\r"); } - ConsoleKeyInfo keyInfo = Console.ReadKey(true); - // Handle (ctrl) hotkeys - if ((keyInfo.Modifiers & ConsoleModifiers.Control) != 0) + void RedrawInput(int start = 0, int end = 0) { - switch (keyInfo.Key) + int lastPosition = Console.CursorLeft; + // Expand range to end if end value is -1 + if (start > -1 && end == -1) { - case ConsoleKey.C: - if (inputLineBuilder.Length > 0) - { - ClearInputLine(); - continue; - } + end = Math.Max(inputLineBuilder.Length - start, 0); + } - cancellation.Cancel(); - return; - case ConsoleKey.D: - cancellation.Cancel(); - return; - default: - // Unhandled modifier key - continue; + if (start == 0 && end == 0) + { + // Redraw entire line + Console.Write($"\r{new string(' ', Console.WindowWidth - 1)}\r{inputLineBuilder}"); } + else + { + // Redraw part of line + string changedInputSegment = inputLineBuilder.ToString(start, end); + Console.CursorVisible = false; + Console.Write($"{changedInputSegment}{new string(' ', inputLineBuilder.Length - changedInputSegment.Length - Console.CursorLeft + 1)}"); + Console.CursorVisible = true; + } + Console.CursorLeft = lastPosition; } - if (keyInfo.Modifiers == 0) + _ = Task.Run(async () => { - switch (keyInfo.Key) + while (!ct.IsCancellationRequested) { - case ConsoleKey.LeftArrow when Console.CursorLeft > 0: - Console.CursorLeft--; - continue; - case ConsoleKey.RightArrow when Console.CursorLeft < inputLineBuilder.Length: - Console.CursorLeft++; - continue; - case ConsoleKey.Backspace: - if (inputLineBuilder.Length > Console.CursorLeft - 1 && Console.CursorLeft > 0) + if (!Console.KeyAvailable) + { + try { - inputLineBuilder.Remove(Console.CursorLeft - 1, 1); - Console.CursorLeft--; - Console.Write(' '); - Console.CursorLeft--; - RedrawInput(); + await Task.Delay(10, ct); } - continue; - case ConsoleKey.Delete: - if (inputLineBuilder.Length > 0 && Console.CursorLeft < inputLineBuilder.Length) + catch (TaskCanceledException) { - inputLineBuilder.Remove(Console.CursorLeft, 1); - RedrawInput(Console.CursorLeft, inputLineBuilder.Length - Console.CursorLeft); + // ignored } continue; - case ConsoleKey.Home: - Console.CursorLeft = 0; - continue; - case ConsoleKey.End: - Console.CursorLeft = inputLineBuilder.Length; - continue; - case ConsoleKey.Escape: - ClearInputLine(); - continue; - case ConsoleKey.Tab: - if (Console.CursorLeft + 4 < Console.WindowWidth) + } + + ConsoleKeyInfo keyInfo = Console.ReadKey(true); + // Handle (ctrl) hotkeys + if ((keyInfo.Modifiers & ConsoleModifiers.Control) != 0) + { + switch (keyInfo.Key) { - inputLineBuilder.Insert(Console.CursorLeft, " "); - RedrawInput(Console.CursorLeft, -1); - Console.CursorLeft += 4; + case ConsoleKey.C: + if (inputLineBuilder.Length > 0) + { + ClearInputLine(); + continue; + } + + await serverCts.CancelAsync(); + return; + case ConsoleKey.D: + await serverCts.CancelAsync(); + return; + default: + // Unhandled modifier key + continue; } - continue; - case ConsoleKey.UpArrow when inputHistory.Count > 0 && currentHistoryIndex > -inputHistory.Count: - inputLineBuilder.Clear(); - inputLineBuilder.Append(inputHistory[--currentHistoryIndex]); - RedrawInput(); - Console.CursorLeft = Math.Min(inputLineBuilder.Length, Console.WindowWidth); - continue; - case ConsoleKey.DownArrow when inputHistory.Count > 0 && currentHistoryIndex < 0: - if (currentHistoryIndex == -1) + } + + if (keyInfo.Modifiers == 0) + { + switch (keyInfo.Key) { - ClearInputLine(); - continue; + case ConsoleKey.LeftArrow when Console.CursorLeft > 0: + Console.CursorLeft--; + continue; + case ConsoleKey.RightArrow when Console.CursorLeft < inputLineBuilder.Length: + Console.CursorLeft++; + continue; + case ConsoleKey.Backspace: + if (inputLineBuilder.Length > Console.CursorLeft - 1 && Console.CursorLeft > 0) + { + inputLineBuilder.Remove(Console.CursorLeft - 1, 1); + Console.CursorLeft--; + Console.Write(' '); + Console.CursorLeft--; + RedrawInput(); + } + continue; + case ConsoleKey.Delete: + if (inputLineBuilder.Length > 0 && Console.CursorLeft < inputLineBuilder.Length) + { + inputLineBuilder.Remove(Console.CursorLeft, 1); + RedrawInput(Console.CursorLeft, inputLineBuilder.Length - Console.CursorLeft); + } + continue; + case ConsoleKey.Home: + Console.CursorLeft = 0; + continue; + case ConsoleKey.End: + Console.CursorLeft = inputLineBuilder.Length; + continue; + case ConsoleKey.Escape: + ClearInputLine(); + continue; + case ConsoleKey.Tab: + if (Console.CursorLeft + 4 < Console.WindowWidth) + { + inputLineBuilder.Insert(Console.CursorLeft, " "); + RedrawInput(Console.CursorLeft, -1); + Console.CursorLeft += 4; + } + continue; + case ConsoleKey.UpArrow when inputHistory.Count > 0 && currentHistoryIndex > -inputHistory.Count: + inputLineBuilder.Clear(); + inputLineBuilder.Append(inputHistory[--currentHistoryIndex]); + RedrawInput(); + Console.CursorLeft = Math.Min(inputLineBuilder.Length, Console.WindowWidth); + continue; + case ConsoleKey.DownArrow when inputHistory.Count > 0 && currentHistoryIndex < 0: + if (currentHistoryIndex == -1) + { + ClearInputLine(); + continue; + } + inputLineBuilder.Clear(); + inputLineBuilder.Append(inputHistory[++currentHistoryIndex]); + RedrawInput(); + Console.CursorLeft = Math.Min(inputLineBuilder.Length, Console.WindowWidth); + continue; } + } + // Handle input submit to submit handler + if (keyInfo.Key == ConsoleKey.Enter) + { + string submit = inputLineBuilder.ToString(); + if (inputHistory.Count == 0 || inputHistory[inputHistory.LastChangedIndex] != submit) + { + inputHistory.Add(submit); + } + currentHistoryIndex = 0; + commandQueue.Enqueue(submit); inputLineBuilder.Clear(); - inputLineBuilder.Append(inputHistory[++currentHistoryIndex]); - RedrawInput(); - Console.CursorLeft = Math.Min(inputLineBuilder.Length, Console.WindowWidth); + Console.WriteLine(); continue; + } + + // If unhandled key, append as input. + if (keyInfo.KeyChar != 0) + { + Console.Write(keyInfo.KeyChar); + if (Console.CursorLeft - 1 < inputLineBuilder.Length) + { + inputLineBuilder.Insert(Console.CursorLeft - 1, keyInfo.KeyChar); + RedrawInput(Console.CursorLeft, -1); + } + else + { + inputLineBuilder.Append(keyInfo.KeyChar); + } + } } - } - // Handle input submit to submit handler - if (keyInfo.Key == ConsoleKey.Enter) + }, ct).ContinueWith(t => { - string submit = inputLineBuilder.ToString(); - if (inputHistory.Count == 0 || inputHistory[inputHistory.LastChangedIndex] != submit) + if (t.IsFaulted) { - inputHistory.Add(submit); + Log.Error(t.Exception); } - currentHistoryIndex = 0; - submitHandler?.Invoke(submit); - inputLineBuilder.Clear(); - Console.WriteLine(); - continue; - } + }, ct); + } + + using IpcHost ipcHost = IpcHost.StartReadingCommands(command => commandQueue.Enqueue(command), ct); - // If unhandled key, append as input. - if (keyInfo.KeyChar != 0) + // Important: keep command handler on the main thread (i.e. don't Task.Run) + while (!ct.IsCancellationRequested) + { + while (commandQueue.TryDequeue(out string command)) { - Console.Write(keyInfo.KeyChar); - if (Console.CursorLeft - 1 < inputLineBuilder.Length) - { - inputLineBuilder.Insert(Console.CursorLeft - 1, keyInfo.KeyChar); - RedrawInput(Console.CursorLeft, -1); - } - else - { - inputLineBuilder.Append(keyInfo.KeyChar); - } + submitHandler(command); + } + try + { + await Task.Delay(10, ct); + } + catch (OperationCanceledException) + { + // ignored } } } - private static async Task WaitForAvailablePortAsync(int port, int timeoutInSeconds = 30) + private static async Task WaitForAvailablePortAsync(int port, TimeSpan timeout = default, CancellationToken ct = default) { - void PrintPortWarn(int timeRemaining) + if (timeout == default) + { + timeout = TimeSpan.FromSeconds(30); + } + else { - Log.Warn($"Port {port} UDP is already in use. Retrying for {timeRemaining} seconds until it is available.."); + Validate.IsTrue(timeout.TotalSeconds >= 5, "Timeout must be at least 5 seconds."); } - Validate.IsTrue(timeoutInSeconds >= 5, "Timeout must be at least 5 seconds."); + int messageLength = 0; + void PrintPortWarn(TimeSpan timeRemaining) + { + string message = $"Port {port} UDP is already in use. Please change the server port or close out any program that may be using it. Retrying for {Math.Floor(timeRemaining.TotalSeconds)} seconds until it is available..."; + messageLength = message.Length; + Log.Warn(message); + } DateTimeOffset time = DateTimeOffset.UtcNow; bool first = true; - using CancellationTokenSource source = new(timeoutInSeconds * 1000); - try { while (true) { - source.Token.ThrowIfCancellationRequested(); + ct.ThrowIfCancellationRequested(); IPEndPoint endPoint = IPGlobalProperties.GetIPGlobalProperties().GetActiveUdpListeners().FirstOrDefault(ip => ip.Port == port); if (endPoint == null) { @@ -339,22 +424,30 @@ void PrintPortWarn(int timeRemaining) if (first) { first = false; - PrintPortWarn(timeoutInSeconds); + PrintPortWarn(timeout); } else if (Environment.UserInteractive) { - Console.CursorTop--; + // If not first time, move cursor up the number of lines it takes up to overwrite previous message + int numberOfLines = (int)Math.Ceiling( ((double)messageLength + 15) / Console.BufferWidth ); + for (int i = 0; i < numberOfLines; i++) + { + if (Console.CursorTop > 0) // Check to ensure we don't go out of bounds + { + Console.CursorTop--; + } + } Console.CursorLeft = 0; - PrintPortWarn(timeoutInSeconds - (DateTimeOffset.UtcNow - time).Seconds); + + PrintPortWarn(timeout - (DateTimeOffset.UtcNow - time)); } - await Task.Delay(500, source.Token); + await Task.Delay(500, ct); } } - catch (OperationCanceledException ex) + catch (OperationCanceledException) { - Log.Error(ex, "Port availability timeout reached."); - throw; + // ignored } } @@ -370,71 +463,112 @@ private static void CurrentDomainOnUnhandledException(object sender, UnhandledEx return; } - string mostRecentLogFile = Log.GetMostRecentLogFile(); + // TODO: Implement log file opening by server name + /*string mostRecentLogFile = Log.GetMostRecentLogFile(); // Log.SaveName if (mostRecentLogFile == null) { return; } - Log.Info("Press L to open log file before closing. Press any other key to close . . ."); + Log.Info("Press L to open log file before closing. Press any other key to close . . .");*/ + Log.Info("Press L to open log folder before closing. Press any other key to close . . ."); ConsoleKeyInfo key = Console.ReadKey(true); if (key.Key == ConsoleKey.L) { - Log.Info($"Opening log file at: {mostRecentLogFile}.."); - using Process process = FileSystem.Instance.OpenOrExecuteFile(mostRecentLogFile); + // Log.Info($"Opening log file at: {mostRecentLogFile}.."); + // using Process process = FileSystem.Instance.OpenOrExecuteFile(mostRecentLogFile); + + Process.Start(new ProcessStartInfo + { + FileName = Log.LogDirectory, + Verb = "open", + UseShellExecute = true + })?.Dispose(); } Environment.Exit(1); } - private static Assembly CurrentDomainOnAssemblyResolve(object sender, ResolveEventArgs args) + private static class AssemblyResolver { - string dllFileName = args.Name.Split(',')[0]; - if (!dllFileName.EndsWith(".dll", StringComparison.InvariantCultureIgnoreCase)) - { - dllFileName += ".dll"; - } - // If available, return cached assembly - if (resolvedAssemblyCache.TryGetValue(dllFileName, out Assembly val)) - { - return val; - } + private static string currentExecutableDirectory; + private static readonly Dictionary resolvedAssemblyCache = []; - // Load DLLs where this program (exe) is located - string dllPath = Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location) ?? "", "lib", dllFileName); - // Prefer to use Newtonsoft dll from game instead of our own due to protobuf issues. TODO: Remove when we do our own deserialization of game data instead of using the game's protobuf. - if (dllPath.IndexOf("Newtonsoft.Json.dll", StringComparison.OrdinalIgnoreCase) >= 0 || !File.Exists(dllPath)) + public static Assembly Handler(object sender, ResolveEventArgs args) { - // Try find game managed libraries - dllPath = Path.Combine(gameInstallDir.Value, "Subnautica_Data", "Managed", dllFileName); - } + static Assembly ResolveFromLib(ReadOnlySpan dllName) + { + dllName = dllName.Slice(0, Math.Max(dllName.IndexOf(','), 0)); + if (dllName.IsEmpty) + { + return null; + } + if (!dllName.EndsWith(".dll")) + { + dllName = string.Concat(dllName, ".dll"); + } + if (dllName.EndsWith(".resources.dll")) + { + return null; + } + string dllNameStr = dllName.ToString(); + // If available, return cached assembly + if (resolvedAssemblyCache.TryGetValue(dllNameStr, out Assembly val)) + { + return val; + } - // Read assemblies as bytes as to not lock the file so that Nitrox can patch assemblies while server is running. - Assembly assembly = Assembly.Load(File.ReadAllBytes(dllPath)); - return resolvedAssemblyCache[dllFileName] = assembly; - } + // Load DLLs where this program (exe) is located + string dllPath = Path.Combine(GetExecutableDirectory(), "lib", dllNameStr); + // Prefer to use Newtonsoft dll from game instead of our own due to protobuf issues. TODO: Remove when we do our own deserialization of game data instead of using the game's protobuf. + if (dllPath.IndexOf("Newtonsoft.Json.dll", StringComparison.OrdinalIgnoreCase) >= 0 || !File.Exists(dllPath)) + { + // Try find game managed libraries + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + dllPath = Path.Combine(gameInstallDir.Value, "Resources", "Data", "Managed", dllNameStr); + } + else + { + dllPath = Path.Combine(gameInstallDir.Value, "Subnautica_Data", "Managed", dllNameStr); + } + } - /** - * Internal subnautica files are setup using US english number formats and dates. To ensure - * that we parse all of these appropriately, we will set the default cultureInfo to en-US. - * This must best done for any thread that is spun up and needs to read from files (unless - * we were to migrate to 4.5.) Failure to set the context can result in very strange behaviour - * throughout the entire application. This originally manifested itself as a duplicate spawning - * issue for players in Europe. This was due to incorrect parsing of probability tables. - */ - private static void ConfigureCultureInfo() - { - CultureInfo cultureInfo = new("en-US"); + try + { + // Read assemblies as bytes as to not lock the file so that Nitrox can patch assemblies while server is running. + Assembly assembly = Assembly.Load(File.ReadAllBytes(dllPath)); + return resolvedAssemblyCache[dllNameStr] = assembly; + } + catch + { + return null; + } + } + + Assembly assembly = ResolveFromLib(args.Name); + if (assembly == null && !args.Name.Contains(".resources")) + { + assembly = Assembly.Load(args.Name); + } - // Although we loaded the en-US cultureInfo, let's make sure to set these incase the - // default was overriden by the user. - cultureInfo.NumberFormat.NumberDecimalSeparator = "."; - cultureInfo.NumberFormat.NumberGroupSeparator = ","; + return assembly; + } - Thread.CurrentThread.CurrentCulture = cultureInfo; - Thread.CurrentThread.CurrentUICulture = cultureInfo; - CultureInfo.DefaultThreadCurrentCulture = cultureInfo; - CultureInfo.DefaultThreadCurrentUICulture = cultureInfo; + private static string GetExecutableDirectory() + { + if (currentExecutableDirectory != null) + { + return currentExecutableDirectory; + } + string pathAttempt = Assembly.GetEntryAssembly()?.Location; + if (string.IsNullOrWhiteSpace(pathAttempt)) + { + using Process proc = Process.GetCurrentProcess(); + pathAttempt = proc.MainModule?.FileName; + } + return currentExecutableDirectory = new Uri(Path.GetDirectoryName(pathAttempt ?? ".") ?? Directory.GetCurrentDirectory()).LocalPath; + } } } diff --git a/NitroxServer-Subnautica/Resources/AddressablesTools/Catalog/SerializedObjectDecoder.cs b/NitroxServer-Subnautica/Resources/AddressablesTools/Catalog/SerializedObjectDecoder.cs index 63cf94288f..59ec1da2b4 100644 --- a/NitroxServer-Subnautica/Resources/AddressablesTools/Catalog/SerializedObjectDecoder.cs +++ b/NitroxServer-Subnautica/Resources/AddressablesTools/Catalog/SerializedObjectDecoder.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Runtime.InteropServices; using System.Text; namespace AddressablesTools.Catalog @@ -21,7 +22,7 @@ internal enum ObjectType internal static object Decode(BinaryReader br) { ObjectType type = (ObjectType)br.ReadByte(); - + switch (type) { case ObjectType.AsciiString: @@ -60,6 +61,10 @@ internal static object Decode(BinaryReader br) case ObjectType.Type: { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + throw new NotSupportedException($"{nameof(ObjectType)}.{nameof(ObjectType.Type)} is only supported on windows because it uses {nameof(Type.GetTypeFromCLSID)}"); + } string str = ReadString1(br); return Type.GetTypeFromCLSID(new Guid(str)); } diff --git a/NitroxServer-Subnautica/Resources/Parsers/Abstract/AssetParser.cs b/NitroxServer-Subnautica/Resources/Parsers/Abstract/AssetParser.cs index 6717da233d..9fd990513e 100644 --- a/NitroxServer-Subnautica/Resources/Parsers/Abstract/AssetParser.cs +++ b/NitroxServer-Subnautica/Resources/Parsers/Abstract/AssetParser.cs @@ -1,5 +1,6 @@ using System.IO; using AssetsTools.NET.Extra; +using NitroxModel.Helper; using NitroxServer_Subnautica.Resources.Parsers.Helper; namespace NitroxServer_Subnautica.Resources.Parsers; @@ -15,7 +16,7 @@ static AssetParser() { rootPath = ResourceAssetsParser.FindDirectoryContainingResourceAssets(); assetsManager = new AssetsManager(); - assetsManager.LoadClassPackage(Path.Combine("Resources", "classdata.tpk")); + assetsManager.LoadClassPackage(Path.Combine(NitroxUser.AssetsPath, "Resources", "classdata.tpk")); assetsManager.LoadClassDatabaseFromPackage("2019.4.36f1"); assetsManager.SetMonoTempGenerator(monoGen = new ThreadSafeMonoCecilTempGenerator(Path.Combine(rootPath, "Managed"))); } diff --git a/NitroxServer-Subnautica/Resources/Parsers/Abstract/BundleFileParser.cs b/NitroxServer-Subnautica/Resources/Parsers/Abstract/BundleFileParser.cs index 7da7af4bfc..05e2a4f0f5 100644 --- a/NitroxServer-Subnautica/Resources/Parsers/Abstract/BundleFileParser.cs +++ b/NitroxServer-Subnautica/Resources/Parsers/Abstract/BundleFileParser.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Runtime.InteropServices; using AssetsTools.NET; using AssetsTools.NET.Extra; @@ -11,7 +12,12 @@ public abstract class BundleFileParser : AssetParser protected BundleFileParser(string bundleName, int index) { - string bundlePath = Path.Combine(ResourceAssetsParser.FindDirectoryContainingResourceAssets(), "StreamingAssets", "aa", "StandaloneWindows64", bundleName); + string standaloneFolderName = "StandaloneWindows64"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + standaloneFolderName = "StandaloneOSX"; + } + string bundlePath = Path.Combine(ResourceAssetsParser.FindDirectoryContainingResourceAssets(), "StreamingAssets", "aa", standaloneFolderName, bundleName); BundleFileInstance bundleFileInst = assetsManager.LoadBundleFile(bundlePath); assetFileInst = assetsManager.LoadAssetsFileFromBundle(bundleFileInst, index, true); bundleFile = assetFileInst.file; diff --git a/NitroxServer-Subnautica/Resources/Parsers/Helper/AssetsBundleManager.cs b/NitroxServer-Subnautica/Resources/Parsers/Helper/AssetsBundleManager.cs index b51d4a7b55..7d3ecabf50 100644 --- a/NitroxServer-Subnautica/Resources/Parsers/Helper/AssetsBundleManager.cs +++ b/NitroxServer-Subnautica/Resources/Parsers/Helper/AssetsBundleManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Runtime.InteropServices; using AssetsTools.NET; using AssetsTools.NET.Extra; using NitroxModel.DataStructures.Unity; @@ -8,16 +9,24 @@ namespace NitroxServer_Subnautica.Resources.Parsers.Helper; public class AssetsBundleManager : AssetsManager { - private ThreadSafeMonoCecilTempGenerator monoTempGenerator; private readonly string aaRootPath; private readonly Dictionary dependenciesByAssetFileInst = new(); + private ThreadSafeMonoCecilTempGenerator monoTempGenerator; public AssetsBundleManager(string aaRootPath) { this.aaRootPath = aaRootPath; } - public string CleanBundlePath(string bundlePath) => aaRootPath + bundlePath.Substring(bundlePath.IndexOf('}') + 1); + public string CleanBundlePath(string bundlePath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + bundlePath = bundlePath.Replace('\\', '/'); + } + + return aaRootPath + bundlePath.Substring(bundlePath.IndexOf('}') + 1); + } public AssetsFileInstance LoadBundleWithDependencies(string[] bundlePaths) { @@ -28,35 +37,11 @@ public AssetsFileInstance LoadBundleWithDependencies(string[] bundlePaths) return assetFileInstance; } - private AssetExternal GetExtAssetSafe(AssetsFileInstance relativeTo, AssetTypeValueField valueField) - { - string[] bundlePaths = dependenciesByAssetFileInst[relativeTo]; - for (int i = 0; i < bundlePaths.Length; i++) - { - if (i != 0) - { - BundleFileInstance dependenciesBundleFile = LoadBundleFile(CleanBundlePath(bundlePaths[i])); - LoadAssetsFileFromBundle(dependenciesBundleFile, 0); - } - - try - { - return GetExtAsset(relativeTo, valueField); - } - catch - { - // ignored - } - } - - throw new InvalidOperationException("Could find AssetTypeValueField in given dependencies"); - } - /// - /// Copied from https://github.com/nesrak1/AssetsTools.NET#full-monobehaviour-writing-example + /// Copied from https://github.com/nesrak1/AssetsTools.NET#full-monobehaviour-writing-example /// - /// instance currently used - /// of the target GameObject + /// instance currently used + /// of the target GameObject /// Class name of the target MonoBehaviour public AssetFileInfo GetMonoBehaviourFromGameObject(AssetsFileInstance inst, AssetFileInfo targetGameObjectValue, string targetClassName) { @@ -113,21 +98,19 @@ public NitroxTransform GetTransformFromGameObject(AssetsFileInstance assetFileIn monoTempGenerator = (ThreadSafeMonoCecilTempGenerator)generator; base.SetMonoTempGenerator(generator); } + /// - /// Returns a ready to use with loaded , and . + /// Returns a ready to use with loaded , and + /// . /// public AssetsBundleManager Clone() { - AssetsBundleManager bundleManagerInst = new(aaRootPath) - { - classDatabase = classDatabase, - classPackage = classPackage - }; + AssetsBundleManager bundleManagerInst = new(aaRootPath) { classDatabase = classDatabase, classPackage = classPackage }; bundleManagerInst.SetMonoTempGenerator(monoTempGenerator); return bundleManagerInst; } - /// + /// public new void UnloadAll(bool unloadClassData = false) { if (unloadClassData) @@ -137,4 +120,28 @@ public AssetsBundleManager Clone() dependenciesByAssetFileInst.Clear(); base.UnloadAll(unloadClassData); } + + private AssetExternal GetExtAssetSafe(AssetsFileInstance relativeTo, AssetTypeValueField valueField) + { + string[] bundlePaths = dependenciesByAssetFileInst[relativeTo]; + for (int i = 0; i < bundlePaths.Length; i++) + { + if (i != 0) + { + BundleFileInstance dependenciesBundleFile = LoadBundleFile(CleanBundlePath(bundlePaths[i])); + LoadAssetsFileFromBundle(dependenciesBundleFile, 0); + } + + try + { + return GetExtAsset(relativeTo, valueField); + } + catch (Exception) + { + // ignored + } + } + + throw new InvalidOperationException("Could find AssetTypeValueField in given dependencies"); + } } diff --git a/NitroxServer-Subnautica/Resources/Parsers/Helper/ThreadSafeMonoCecilTempGenerator.cs b/NitroxServer-Subnautica/Resources/Parsers/Helper/ThreadSafeMonoCecilTempGenerator.cs index a7c7819027..54a82a783d 100644 --- a/NitroxServer-Subnautica/Resources/Parsers/Helper/ThreadSafeMonoCecilTempGenerator.cs +++ b/NitroxServer-Subnautica/Resources/Parsers/Helper/ThreadSafeMonoCecilTempGenerator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using AssetsTools.NET; using AssetsTools.NET.Extra; using Mono.Cecil; @@ -9,7 +10,7 @@ namespace NitroxServer_Subnautica.Resources.Parsers.Helper; public class ThreadSafeMonoCecilTempGenerator : IMonoBehaviourTemplateGenerator, IDisposable { private readonly MonoCecilTempGenerator generator; - private readonly object locker = new(); + private readonly Lock locker = new(); public ThreadSafeMonoCecilTempGenerator(string managedPath) { diff --git a/NitroxServer-Subnautica/Resources/Parsers/PrefabPlaceholderGroupsParser.cs b/NitroxServer-Subnautica/Resources/Parsers/PrefabPlaceholderGroupsParser.cs index aa24093811..20a0921598 100644 --- a/NitroxServer-Subnautica/Resources/Parsers/PrefabPlaceholderGroupsParser.cs +++ b/NitroxServer-Subnautica/Resources/Parsers/PrefabPlaceholderGroupsParser.cs @@ -9,9 +9,10 @@ using AssetsTools.NET; using AssetsTools.NET.Extra; using NitroxModel.DataStructures.Unity; +using NitroxModel.Helper; +using NitroxServer_Subnautica.Resources.Parsers.Helper; using NitroxServer.GameLogic.Entities; using NitroxServer.Resources; -using NitroxServer_Subnautica.Resources.Parsers.Helper; namespace NitroxServer_Subnautica.Resources.Parsers; @@ -37,8 +38,9 @@ public PrefabPlaceholderGroupsParser() aaRootPath = Path.Combine(streamingAssetsPath, "aa"); am = new AssetsBundleManager(aaRootPath); - // ReSharper disable once StringLiteralTypo - am.LoadClassPackage(Path.Combine("Resources", "classdata.tpk")); + + // ReSharper disable once StringLiteralTypo) + am.LoadClassPackage(Path.Combine(NitroxUser.AssetsPath, "Resources", "classdata.tpk")); am.LoadClassDatabaseFromPackage("2019.4.36f1"); am.SetMonoTempGenerator(monoGen = new(managedPath)); } @@ -223,7 +225,7 @@ private PrefabPlaceholdersGroupAsset GetAndCachePrefabPlaceholdersGroupGroup(Ass List prefabPlaceholdersOnGroup = prefabPlaceholdersGroupScript["prefabPlaceholders"].Children; IPrefabAsset[] prefabPlaceholders = new IPrefabAsset[prefabPlaceholdersOnGroup.Count]; - + for (int index = 0; index < prefabPlaceholdersOnGroup.Count; index++) { AssetTypeValueField prefabPlaceholderPtr = prefabPlaceholdersOnGroup[index]; diff --git a/NitroxServer-Subnautica/Resources/Parsers/RandomStartParser.cs b/NitroxServer-Subnautica/Resources/Parsers/RandomStartParser.cs index 02efefdb6a..eb52964017 100644 --- a/NitroxServer-Subnautica/Resources/Parsers/RandomStartParser.cs +++ b/NitroxServer-Subnautica/Resources/Parsers/RandomStartParser.cs @@ -1,11 +1,12 @@ -using System.Drawing; -using System.Drawing.Imaging; -using System.Runtime.InteropServices; -using AssetsTools.NET; +using AssetsTools.NET; using AssetsTools.NET.Extra; using NitroxModel.DataStructures.GameLogic; +using NitroxModel.Helper; using NitroxServer_Subnautica.Resources.Parsers.Abstract; using NitroxServer_Subnautica.Resources.Parsers.Helper; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; namespace NitroxServer_Subnautica.Resources.Parsers; @@ -21,16 +22,30 @@ public override RandomStartGenerator ParseFile() byte[] texDat = textureFile.GetTextureData(assetFileInst); assetsManager.UnloadAll(); - if (texDat == null || texDat.Length <= 0) + if (texDat is not { Length: > 0 }) { return null; } - - Bitmap texture = new(textureFile.m_Width, textureFile.m_Height, textureFile.m_Width * 4, PixelFormat.Format32bppArgb, - Marshal.UnsafeAddrOfPinnedArrayElement(texDat, 0)); - texture.RotateFlip(RotateFlipType.RotateNoneFlipY); - return new RandomStartGenerator(texture); + Image texture = Image.LoadPixelData(texDat, textureFile.m_Width, textureFile.m_Height); + texture.Mutate(x => x.Flip(FlipMode.Vertical)); + return new RandomStartGenerator(new PixelProvider(texture)); + } + + private class PixelProvider : RandomStartGenerator.IPixelProvider + { + private readonly Image texture; + + public PixelProvider(Image texture) + { + Validate.NotNull(texture); + this.texture = texture; + } + + public byte GetRed(int x, int y) => texture[x, y].R; + + public byte GetGreen(int x, int y) => texture[x, y].G; + public byte GetBlue(int x, int y) => texture[x, y].B; } } diff --git a/NitroxServer-Subnautica/Resources/ResourceAssetsParser.cs b/NitroxServer-Subnautica/Resources/ResourceAssetsParser.cs index 3d3c05d9f3..7489cd6060 100644 --- a/NitroxServer-Subnautica/Resources/ResourceAssetsParser.cs +++ b/NitroxServer-Subnautica/Resources/ResourceAssetsParser.cs @@ -1,4 +1,5 @@ using System.IO; +using NitroxModel; using NitroxModel.Helper; using NitroxServer_Subnautica.Resources.Parsers; @@ -26,7 +27,7 @@ public static ResourceAssets Parse() }; } AssetParser.Dispose(); - + ResourceAssets.ValidateMembers(resourceAssets); return resourceAssets; } @@ -39,17 +40,17 @@ public static string FindDirectoryContainingResourceAssets() throw new DirectoryNotFoundException("Could not locate Subnautica installation directory for resource parsing."); } - if (File.Exists(Path.Combine(subnauticaPath, "Subnautica_Data", "resources.assets"))) + if (File.Exists(Path.Combine(subnauticaPath, GameInfo.Subnautica.DataFolder, "resources.assets"))) { - return Path.Combine(subnauticaPath, "Subnautica_Data"); + return Path.Combine(subnauticaPath, GameInfo.Subnautica.DataFolder); } if (File.Exists(Path.Combine("..", "resources.assets"))) // SubServer => Subnautica/Subnautica_Data/SubServer { return Path.GetFullPath(Path.Combine("..")); } - if (File.Exists(Path.Combine("..", "Subnautica_Data", "resources.assets"))) // SubServer => Subnautica/SubServer + if (File.Exists(Path.Combine("..", GameInfo.Subnautica.DataFolder, "resources.assets"))) // SubServer => Subnautica/SubServer { - return Path.GetFullPath(Path.Combine("..", "Subnautica_Data")); + return Path.GetFullPath(Path.Combine("..", GameInfo.Subnautica.DataFolder)); } if (File.Exists("resources.assets")) // SubServer/* => Subnautica/Subnautica_Data/ { diff --git a/NitroxServer-Subnautica/SubnauticaServerAutoFacRegistrar.cs b/NitroxServer-Subnautica/SubnauticaServerAutoFacRegistrar.cs index d91015f299..d16081f613 100644 --- a/NitroxServer-Subnautica/SubnauticaServerAutoFacRegistrar.cs +++ b/NitroxServer-Subnautica/SubnauticaServerAutoFacRegistrar.cs @@ -61,7 +61,7 @@ public override void RegisterDependencies(ContainerBuilder containerBuilder) containerBuilder.RegisterType().As().InstancePerLifetimeScope(); containerBuilder.RegisterType().AsSelf().InstancePerLifetimeScope(); containerBuilder.RegisterType().As().InstancePerLifetimeScope(); - containerBuilder.Register(c => new FMODWhitelist(GameInfo.Subnautica)).InstancePerLifetimeScope(); + containerBuilder.Register(c => FMODWhitelist.Load(GameInfo.Subnautica)).InstancePerLifetimeScope(); } } } diff --git a/NitroxServer/Communication/LANBroadcastServer.cs b/NitroxServer/Communication/LANBroadcastServer.cs index b6f7d0727f..f19dff19fd 100644 --- a/NitroxServer/Communication/LANBroadcastServer.cs +++ b/NitroxServer/Communication/LANBroadcastServer.cs @@ -10,33 +10,36 @@ public static class LANBroadcastServer { private static NetManager server; private static EventBasedNetListener listener; - private static Timer pollTimer; - public static void Start() + public static void Start(CancellationToken ct) { listener = new EventBasedNetListener(); + listener.NetworkReceiveUnconnectedEvent += NetworkReceiveUnconnected; server = new NetManager(listener); - server.AutoRecycle = true; server.BroadcastReceiveEnabled = true; server.UnconnectedMessagesEnabled = true; - for (int i = 0; i < LANDiscoveryConstants.BROADCAST_PORTS.Length; i++) + foreach (int port in LANDiscoveryConstants.BROADCAST_PORTS) { - if (server.Start(LANDiscoveryConstants.BROADCAST_PORTS[i])) + if (server.Start(port)) { break; } } - listener.NetworkReceiveUnconnectedEvent += NetworkReceiveUnconnected; - - pollTimer = new Timer((state) => - { - server.PollEvents(); - }); + pollTimer = new Timer(_ => server.PollEvents()); pollTimer.Change(0, 100); + Log.Debug($"{nameof(LANBroadcastServer)} started"); + } + + public static void Stop() + { + listener?.ClearNetworkReceiveUnconnectedEvent(); + server?.Stop(); + pollTimer?.Dispose(); + Log.Debug($"{nameof(LANBroadcastServer)} stopped"); } private static void NetworkReceiveUnconnected(IPEndPoint remoteEndPoint, NetPacketReader reader, UnconnectedMessageType messageType) @@ -54,11 +57,4 @@ private static void NetworkReceiveUnconnected(IPEndPoint remoteEndPoint, NetPack } } } - - public static void Stop() - { - listener?.ClearNetworkReceiveUnconnectedEvent(); - server?.Stop(); - pollTimer?.Dispose(); - } } diff --git a/NitroxServer/Communication/LiteNetLib/LiteNetLibServer.cs b/NitroxServer/Communication/LiteNetLib/LiteNetLibServer.cs index c8d45f0190..b30464fb1b 100644 --- a/NitroxServer/Communication/LiteNetLib/LiteNetLibServer.cs +++ b/NitroxServer/Communication/LiteNetLib/LiteNetLibServer.cs @@ -6,10 +6,10 @@ using Mono.Nat; using NitroxModel.Helper; using NitroxModel.Packets; +using NitroxModel.Serialization; using NitroxServer.Communication.Packets; using NitroxServer.GameLogic; using NitroxServer.GameLogic.Entities; -using NitroxServer.Serialization; namespace NitroxServer.Communication.LiteNetLib; @@ -18,13 +18,13 @@ public class LiteNetLibServer : NitroxServer private readonly EventBasedNetListener listener; private readonly NetManager server; - public LiteNetLibServer(PacketHandler packetHandler, PlayerManager playerManager, EntitySimulation entitySimulation, ServerConfig serverConfig) : base(packetHandler, playerManager, entitySimulation, serverConfig) + public LiteNetLibServer(PacketHandler packetHandler, PlayerManager playerManager, EntitySimulation entitySimulation, SubnauticaServerConfig serverConfig) : base(packetHandler, playerManager, entitySimulation, serverConfig) { listener = new EventBasedNetListener(); server = new NetManager(listener); } - public override bool Start() + public override bool Start(CancellationToken ct = default) { listener.PeerConnectedEvent += PeerConnected; listener.PeerDisconnectedEvent += PeerDisconnected; @@ -43,54 +43,66 @@ public override bool Start() { return false; } - if (useUpnpPortForwarding) { - PortForwardAsync((ushort)portNumber).ConfigureAwait(false); + _ = PortForwardAsync((ushort)portNumber, ct); } - if (useLANBroadcast) { - LANBroadcastServer.Start(); + LANBroadcastServer.Start(ct); } return true; } - private async Task PortForwardAsync(ushort port) + private async Task PortForwardAsync(ushort port, CancellationToken ct = default) { - if (await NatHelper.GetPortMappingAsync(port, Protocol.Udp) != null) + if (await NatHelper.GetPortMappingAsync(port, Protocol.Udp, ct) != null) { Log.Info($"Port {port} UDP is already port forwarded"); return; } - NatHelper.ResultCodes mappingResult = await NatHelper.AddPortMappingAsync(port, Protocol.Udp); - switch (mappingResult) + NatHelper.ResultCodes mappingResult = await NatHelper.AddPortMappingAsync(port, Protocol.Udp, ct); + if (!ct.IsCancellationRequested) { - case NatHelper.ResultCodes.SUCCESS: - Log.Info($"Server port {port} UDP has been automatically opened on your router (port is closed when server closes)"); - break; - case NatHelper.ResultCodes.CONFLICT_IN_MAPPING_ENTRY: - Log.Warn($"Port forward for {port} UDP failed. It appears to already be port forwarded or it conflicts with another port forward rule."); - break; - case NatHelper.ResultCodes.UNKNOWN_ERROR: - Log.Warn($"Failed to port forward {port} UDP through UPnP. If using Hamachi or you've manually port-forwarded, please disregard this warning. To disable this feature you can go into the server settings."); - break; + switch (mappingResult) + { + case NatHelper.ResultCodes.SUCCESS: + Log.Info($"Server port {port} UDP has been automatically opened on your router (port is closed when server closes)"); + break; + case NatHelper.ResultCodes.CONFLICT_IN_MAPPING_ENTRY: + Log.Warn($"Port forward for {port} UDP failed. It appears to already be port forwarded or it conflicts with another port forward rule."); + break; + case NatHelper.ResultCodes.UNKNOWN_ERROR: + Log.Warn($"Failed to port forward {port} UDP through UPnP. If using Hamachi or you've manually port-forwarded, please disregard this warning. To disable this feature you can go into the server settings."); + break; + } } } public override void Stop() { + if (!server.IsRunning) + { + return; + } + playerManager.SendPacketToAllPlayers(new ServerStopped()); // We want every player to receive this packet Thread.Sleep(500); server.Stop(); if (useUpnpPortForwarding) { - NatHelper.DeletePortMappingAsync((ushort)portNumber, Protocol.Udp).ConfigureAwait(false).GetAwaiter().GetResult(); + if (NatHelper.DeletePortMappingAsync((ushort)portNumber, Protocol.Udp, CancellationToken.None).GetAwaiter().GetResult()) + { + Log.Debug($"Port forward rule removed for {portNumber} UDP"); + } + else + { + Log.Warn($"Failed to remove port forward rule {portNumber} UDP"); + } } - if (useLANBroadcast) { LANBroadcastServer.Stop(); diff --git a/NitroxServer/Communication/NitroxServer.cs b/NitroxServer/Communication/NitroxServer.cs index e653fa9381..3813424bed 100644 --- a/NitroxServer/Communication/NitroxServer.cs +++ b/NitroxServer/Communication/NitroxServer.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; +using System.Threading; using NitroxModel.DataStructures; using NitroxModel.Packets; +using NitroxModel.Serialization; using NitroxServer.Communication.Packets; using NitroxServer.GameLogic; using NitroxServer.GameLogic.Entities; -using NitroxServer.Serialization; namespace NitroxServer.Communication { @@ -26,7 +27,7 @@ static NitroxServer() protected readonly Dictionary connectionsByRemoteIdentifier = new(); protected readonly PlayerManager playerManager; - public NitroxServer(PacketHandler packetHandler, PlayerManager playerManager, EntitySimulation entitySimulation, ServerConfig serverConfig) + public NitroxServer(PacketHandler packetHandler, PlayerManager playerManager, EntitySimulation entitySimulation, SubnauticaServerConfig serverConfig) { this.packetHandler = packetHandler; this.playerManager = playerManager; @@ -38,7 +39,7 @@ public NitroxServer(PacketHandler packetHandler, PlayerManager playerManager, En useLANBroadcast = serverConfig.LANDiscoveryEnabled; } - public abstract bool Start(); + public abstract bool Start(CancellationToken ct = default); public abstract void Stop(); diff --git a/NitroxServer/Communication/Packets/Processors/DiscordRequestIPProcessor.cs b/NitroxServer/Communication/Packets/Processors/DiscordRequestIPProcessor.cs index 1c65df0b61..7c9eb8597f 100644 --- a/NitroxServer/Communication/Packets/Processors/DiscordRequestIPProcessor.cs +++ b/NitroxServer/Communication/Packets/Processors/DiscordRequestIPProcessor.cs @@ -3,18 +3,18 @@ using System.Threading.Tasks; using NitroxModel.Helper; using NitroxModel.Packets; +using NitroxModel.Serialization; using NitroxServer.Communication.Packets.Processors.Abstract; -using NitroxServer.Serialization; namespace NitroxServer.Communication.Packets.Processors; public class DiscordRequestIPProcessor : AuthenticatedPacketProcessor { - private readonly ServerConfig serverConfig; + private readonly SubnauticaServerConfig serverConfig; private string ipPort; - public DiscordRequestIPProcessor(ServerConfig serverConfig) + public DiscordRequestIPProcessor(SubnauticaServerConfig serverConfig) { this.serverConfig = serverConfig; } diff --git a/NitroxServer/Communication/Packets/Processors/MultiplayerSessionPolicyRequestProcessor.cs b/NitroxServer/Communication/Packets/Processors/MultiplayerSessionPolicyRequestProcessor.cs index 630abebeaa..5e2c245395 100644 --- a/NitroxServer/Communication/Packets/Processors/MultiplayerSessionPolicyRequestProcessor.cs +++ b/NitroxServer/Communication/Packets/Processors/MultiplayerSessionPolicyRequestProcessor.cs @@ -1,14 +1,14 @@ using NitroxModel.Packets; +using NitroxModel.Serialization; using NitroxServer.Communication.Packets.Processors.Abstract; -using NitroxServer.Serialization; namespace NitroxServer.Communication.Packets.Processors { public class MultiplayerSessionPolicyRequestProcessor : UnauthenticatedPacketProcessor { - private readonly ServerConfig config; + private readonly SubnauticaServerConfig config; - public MultiplayerSessionPolicyRequestProcessor(ServerConfig config) + public MultiplayerSessionPolicyRequestProcessor(SubnauticaServerConfig config) { this.config = config; } @@ -17,7 +17,7 @@ public MultiplayerSessionPolicyRequestProcessor(ServerConfig config) public override void Process(MultiplayerSessionPolicyRequest packet, INitroxConnection connection) { Log.Info("Providing session policies..."); - connection.SendPacket(new MultiplayerSessionPolicy(packet.CorrelationId, config.DisableConsole, config.MaxConnections, config.IsPasswordRequired)); + connection.SendPacket(new MultiplayerSessionPolicy(packet.CorrelationId, config.DisableConsole, config.MaxConnections, config.IsPasswordRequired())); } } } diff --git a/NitroxServer/Communication/Packets/Processors/PlayerDeathEventProcessor.cs b/NitroxServer/Communication/Packets/Processors/PlayerDeathEventProcessor.cs index 2b471c1517..d2c57dcad1 100644 --- a/NitroxServer/Communication/Packets/Processors/PlayerDeathEventProcessor.cs +++ b/NitroxServer/Communication/Packets/Processors/PlayerDeathEventProcessor.cs @@ -1,17 +1,17 @@ using NitroxModel.DataStructures.GameLogic; using NitroxModel.Packets; +using NitroxModel.Serialization; using NitroxServer.Communication.Packets.Processors.Abstract; using NitroxServer.GameLogic; -using NitroxServer.Serialization; namespace NitroxServer.Communication.Packets.Processors { class PlayerDeathEventProcessor : AuthenticatedPacketProcessor { private readonly PlayerManager playerManager; - private readonly ServerConfig serverConfig; + private readonly SubnauticaServerConfig serverConfig; - public PlayerDeathEventProcessor(PlayerManager playerManager, ServerConfig config) + public PlayerDeathEventProcessor(PlayerManager playerManager, SubnauticaServerConfig config) { this.playerManager = playerManager; this.serverConfig = config; @@ -19,7 +19,7 @@ public PlayerDeathEventProcessor(PlayerManager playerManager, ServerConfig confi public override void Process(PlayerDeathEvent packet, Player player) { - if (serverConfig.IsHardcore) + if (serverConfig.IsHardcore()) { player.IsPermaDeath = true; PlayerKicked playerKicked = new PlayerKicked("Permanent death from hardcore mode"); diff --git a/NitroxServer/ConsoleCommands/AutosaveCommand.cs b/NitroxServer/ConsoleCommands/AutosaveCommand.cs index 790c0072e2..304f950e8a 100644 --- a/NitroxServer/ConsoleCommands/AutosaveCommand.cs +++ b/NitroxServer/ConsoleCommands/AutosaveCommand.cs @@ -1,20 +1,21 @@ using System.IO; using NitroxModel.DataStructures.GameLogic; +using NitroxModel.Serialization; using NitroxServer.ConsoleCommands.Abstract; using NitroxServer.ConsoleCommands.Abstract.Type; -using NitroxServer.Serialization; -using NitroxServer.Serialization.World; namespace NitroxServer.ConsoleCommands { internal class AutoSaveCommand : Command { - private readonly ServerConfig serverConfig; + private readonly Server server; + private readonly SubnauticaServerConfig serverConfig; - public AutoSaveCommand(ServerConfig serverConfig) : base("autosave", Perms.ADMIN, "Toggles the map autosave") + public AutoSaveCommand(Server server, SubnauticaServerConfig serverConfig) : base("autosave", Perms.ADMIN, "Toggles the map autosave") { AddParameter(new TypeBoolean("on/off", true, "Whether autosave should be on or off")); + this.server = server; this.serverConfig = serverConfig; } @@ -22,7 +23,7 @@ protected override void Execute(CallArgs args) { bool toggle = args.Get(0); - using (serverConfig.Update(Path.Combine(WorldManager.SavesFolderDir, serverConfig.SaveName))) + using (serverConfig.Update(Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), server.Name))) { if (toggle) { diff --git a/NitroxServer/ConsoleCommands/BackupCommand.cs b/NitroxServer/ConsoleCommands/BackupCommand.cs new file mode 100644 index 0000000000..868cc2a7ca --- /dev/null +++ b/NitroxServer/ConsoleCommands/BackupCommand.cs @@ -0,0 +1,18 @@ +using NitroxModel.DataStructures.GameLogic; +using NitroxServer.ConsoleCommands.Abstract; + +namespace NitroxServer.ConsoleCommands +{ + internal class BackupCommand : Command + { + public BackupCommand() : base("backup", Perms.ADMIN, "Creates a backup of the save") + { + } + + protected override void Execute(CallArgs args) + { + Server.Instance.BackUp(); + SendMessageToPlayer(args.Sender, "World has been backed up"); + } + } +} diff --git a/NitroxServer/ConsoleCommands/ChangeAdminPasswordCommand.cs b/NitroxServer/ConsoleCommands/ChangeAdminPasswordCommand.cs index a53601edbd..a68c928c78 100644 --- a/NitroxServer/ConsoleCommands/ChangeAdminPasswordCommand.cs +++ b/NitroxServer/ConsoleCommands/ChangeAdminPasswordCommand.cs @@ -1,26 +1,27 @@ using System.IO; using NitroxModel.DataStructures.GameLogic; +using NitroxModel.Serialization; using NitroxServer.ConsoleCommands.Abstract; using NitroxServer.ConsoleCommands.Abstract.Type; -using NitroxServer.Serialization; -using NitroxServer.Serialization.World; namespace NitroxServer.ConsoleCommands { internal class ChangeAdminPasswordCommand : Command { - private readonly ServerConfig serverConfig; + private readonly Server server; + private readonly SubnauticaServerConfig serverConfig; - public ChangeAdminPasswordCommand(ServerConfig serverConfig) : base("changeadminpassword", Perms.ADMIN, "Changes admin password") + public ChangeAdminPasswordCommand(Server server, SubnauticaServerConfig serverConfig) : base("changeadminpassword", Perms.ADMIN, "Changes admin password") { AddParameter(new TypeString("password", true, "The new admin password")); + this.server = server; this.serverConfig = serverConfig; } protected override void Execute(CallArgs args) { - string saveDir = Path.Combine(WorldManager.SavesFolderDir, serverConfig.SaveName); + string saveDir = Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), server.Name); using (serverConfig.Update(saveDir)) { string newPassword = args.Get(0); diff --git a/NitroxServer/ConsoleCommands/ChangeServerGamemodeCommand.cs b/NitroxServer/ConsoleCommands/ChangeServerGamemodeCommand.cs index 7064016f9a..0e737097bb 100644 --- a/NitroxServer/ConsoleCommands/ChangeServerGamemodeCommand.cs +++ b/NitroxServer/ConsoleCommands/ChangeServerGamemodeCommand.cs @@ -1,24 +1,25 @@ -using System.IO; +using System.IO; using NitroxModel.DataStructures.GameLogic; using NitroxModel.Packets; +using NitroxModel.Serialization; using NitroxModel.Server; using NitroxServer.ConsoleCommands.Abstract; using NitroxServer.ConsoleCommands.Abstract.Type; using NitroxServer.GameLogic; -using NitroxServer.Serialization; -using NitroxServer.Serialization.World; namespace NitroxServer.ConsoleCommands; internal class ChangeServerGamemodeCommand : Command { + private readonly Server server; private readonly PlayerManager playerManager; - private readonly ServerConfig serverConfig; + private readonly SubnauticaServerConfig serverConfig; - public ChangeServerGamemodeCommand(PlayerManager playerManager, ServerConfig serverConfig) : base("changeservergamemode", Perms.ADMIN, "Changes server gamemode") + public ChangeServerGamemodeCommand(Server server, PlayerManager playerManager, SubnauticaServerConfig serverConfig) : base("changeservergamemode", Perms.ADMIN, "Changes server gamemode") { AddParameter(new TypeEnum("gamemode", true, "Gamemode to change to")); + this.server = server; this.playerManager = playerManager; this.serverConfig = serverConfig; } @@ -27,7 +28,7 @@ protected override void Execute(CallArgs args) { NitroxGameMode sgm = args.Get(0); - using (serverConfig.Update(Path.Combine(WorldManager.SavesFolderDir, serverConfig.SaveName))) + using (serverConfig.Update(Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), server.Name))) { if (serverConfig.GameMode != sgm) { diff --git a/NitroxServer/ConsoleCommands/ChangeServerPasswordCommand.cs b/NitroxServer/ConsoleCommands/ChangeServerPasswordCommand.cs index abd1a833fe..61025514d5 100644 --- a/NitroxServer/ConsoleCommands/ChangeServerPasswordCommand.cs +++ b/NitroxServer/ConsoleCommands/ChangeServerPasswordCommand.cs @@ -1,20 +1,21 @@ using System.IO; using NitroxModel.DataStructures.GameLogic; +using NitroxModel.Serialization; using NitroxServer.ConsoleCommands.Abstract; using NitroxServer.ConsoleCommands.Abstract.Type; -using NitroxServer.Serialization; -using NitroxServer.Serialization.World; namespace NitroxServer.ConsoleCommands { internal class ChangeServerPasswordCommand : Command { - private readonly ServerConfig serverConfig; + private readonly Server server; + private readonly SubnauticaServerConfig serverConfig; - public ChangeServerPasswordCommand(ServerConfig serverConfig) : base("changeserverpassword", Perms.ADMIN, "Changes server password. Clear it without argument") + public ChangeServerPasswordCommand(Server server, SubnauticaServerConfig serverConfig) : base("changeserverpassword", Perms.ADMIN, "Changes server password. Clear it without argument") { AddParameter(new TypeString("password", false, "The new server password")); + this.server = server; this.serverConfig = serverConfig; } @@ -22,7 +23,7 @@ protected override void Execute(CallArgs args) { string password = args.Get(0) ?? string.Empty; - using (serverConfig.Update(Path.Combine(WorldManager.SavesFolderDir, serverConfig.SaveName))) + using (serverConfig.Update(Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), server.Name))) { serverConfig.ServerPassword = password; } diff --git a/NitroxServer/ConsoleCommands/ConfigCommand.cs b/NitroxServer/ConsoleCommands/ConfigCommand.cs index 622aeb820e..721cafa23c 100644 --- a/NitroxServer/ConsoleCommands/ConfigCommand.cs +++ b/NitroxServer/ConsoleCommands/ConfigCommand.cs @@ -5,19 +5,20 @@ using System.Threading.Tasks; using NitroxModel.DataStructures.GameLogic; using NitroxModel.Platforms.OS.Shared; +using NitroxModel.Serialization; using NitroxServer.ConsoleCommands.Abstract; -using NitroxServer.Serialization; -using NitroxServer.Serialization.World; namespace NitroxServer.ConsoleCommands { internal class ConfigCommand : Command { private readonly SemaphoreSlim configOpenLock = new(1); - private readonly ServerConfig serverConfig; + private readonly Server server; + private readonly SubnauticaServerConfig serverConfig; - public ConfigCommand(ServerConfig serverConfig) : base("config", Perms.CONSOLE, "Opens the server configuration file") + public ConfigCommand(Server server, SubnauticaServerConfig serverConfig) : base("config", Perms.CONSOLE, "Opens the server configuration file") { + this.server = server; this.serverConfig = serverConfig; } @@ -30,7 +31,7 @@ protected override void Execute(CallArgs args) } // Save config file if it doesn't exist yet. - string saveDir = Path.Combine(WorldManager.SavesFolderDir, serverConfig.SaveName); + string saveDir = Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), server.Name); string configFile = Path.Combine(saveDir, serverConfig.FileName); if (!File.Exists(configFile)) { diff --git a/NitroxServer/ConsoleCommands/DirectoryCommand.cs b/NitroxServer/ConsoleCommands/DirectoryCommand.cs index 7e72ea5f6b..574103c2ea 100644 --- a/NitroxServer/ConsoleCommands/DirectoryCommand.cs +++ b/NitroxServer/ConsoleCommands/DirectoryCommand.cs @@ -27,7 +27,7 @@ protected override void Execute(CallArgs args) } Log.InfoSensitive("Opening directory {path}", path); - Process.Start(path); + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true, Verb = "open" })?.Dispose(); } } } diff --git a/NitroxServer/ConsoleCommands/ListCommand.cs b/NitroxServer/ConsoleCommands/ListCommand.cs index b6f742d695..69c7575d37 100644 --- a/NitroxServer/ConsoleCommands/ListCommand.cs +++ b/NitroxServer/ConsoleCommands/ListCommand.cs @@ -2,18 +2,18 @@ using System.Linq; using System.Text; using NitroxModel.DataStructures.GameLogic; +using NitroxModel.Serialization; using NitroxServer.ConsoleCommands.Abstract; using NitroxServer.GameLogic; -using NitroxServer.Serialization; namespace NitroxServer.ConsoleCommands { internal class ListCommand : Command { private readonly PlayerManager playerManager; - private readonly ServerConfig serverConfig; + private readonly SubnauticaServerConfig serverConfig; - public ListCommand(ServerConfig serverConfig, PlayerManager playerManager) : base("list", Perms.PLAYER, "Shows who's online") + public ListCommand(SubnauticaServerConfig serverConfig, PlayerManager playerManager) : base("list", Perms.PLAYER, "Shows who's online") { this.playerManager = playerManager; this.serverConfig = serverConfig; diff --git a/NitroxServer/ConsoleCommands/LoginCommand.cs b/NitroxServer/ConsoleCommands/LoginCommand.cs index 66b4786cc8..59d7c65dc9 100644 --- a/NitroxServer/ConsoleCommands/LoginCommand.cs +++ b/NitroxServer/ConsoleCommands/LoginCommand.cs @@ -1,15 +1,15 @@ using NitroxModel.DataStructures.GameLogic; +using NitroxModel.Serialization; using NitroxServer.ConsoleCommands.Abstract; using NitroxServer.ConsoleCommands.Abstract.Type; -using NitroxServer.Serialization; namespace NitroxServer.ConsoleCommands { internal class LoginCommand : Command { - private readonly ServerConfig serverConfig; + private readonly SubnauticaServerConfig serverConfig; - public LoginCommand(ServerConfig serverConfig) : base("login", Perms.PLAYER, PermsFlag.NO_CONSOLE, "Log in to server as admin (requires password)") + public LoginCommand(SubnauticaServerConfig serverConfig) : base("login", Perms.PLAYER, PermsFlag.NO_CONSOLE, "Log in to server as admin (requires password)") { AddParameter(new TypeString("password", true, "The admin password for the server")); diff --git a/NitroxServer/ConsoleCommands/StopCommand.cs b/NitroxServer/ConsoleCommands/StopCommand.cs index fc29a0294c..4b33dbc6e2 100644 --- a/NitroxServer/ConsoleCommands/StopCommand.cs +++ b/NitroxServer/ConsoleCommands/StopCommand.cs @@ -14,8 +14,13 @@ public StopCommand() : base("stop", Perms.ADMIN, "Stops the server") protected override void Execute(CallArgs args) { + Server server = Server.Instance; + if (!server.IsRunning) + { + return; + } SendMessageToAllPlayers("Server is shutting down..."); - Server.Instance.Stop(shouldSave: true); + server.Stop(shouldSave: true); } } } diff --git a/NitroxServer/ConsoleCommands/SwapSerializerCommand.cs b/NitroxServer/ConsoleCommands/SwapSerializerCommand.cs index 3da410c756..e9d2738c9a 100644 --- a/NitroxServer/ConsoleCommands/SwapSerializerCommand.cs +++ b/NitroxServer/ConsoleCommands/SwapSerializerCommand.cs @@ -1,22 +1,24 @@ using System.IO; using NitroxModel.DataStructures.GameLogic; +using NitroxModel.Serialization; using NitroxModel.Server; using NitroxServer.ConsoleCommands.Abstract; using NitroxServer.ConsoleCommands.Abstract.Type; -using NitroxServer.Serialization; using NitroxServer.Serialization.World; namespace NitroxServer.ConsoleCommands { internal class SwapSerializerCommand : Command { + private readonly Server server; private readonly WorldPersistence worldPersistence; - private readonly ServerConfig serverConfig; + private readonly SubnauticaServerConfig serverConfig; - public SwapSerializerCommand(ServerConfig serverConfig, WorldPersistence worldPersistence) : base("swapserializer", Perms.CONSOLE, "Allows to change the save format") + public SwapSerializerCommand(Server server, SubnauticaServerConfig serverConfig, WorldPersistence worldPersistence) : base("swapserializer", Perms.CONSOLE, "Allows to change the save format") { AddParameter(new TypeEnum("serializer", true, "Save format to change to")); + this.server = server; this.worldPersistence = worldPersistence; this.serverConfig = serverConfig; } @@ -25,7 +27,7 @@ protected override void Execute(CallArgs args) { ServerSerializerMode serializerMode = args.Get(0); - using (serverConfig.Update(Path.Combine(WorldManager.SavesFolderDir, serverConfig.SaveName))) + using (serverConfig.Update(Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), server.Name))) { if (serializerMode != serverConfig.SerializerMode) { diff --git a/NitroxServer/GameLogic/Bases/BuildingManager.cs b/NitroxServer/GameLogic/Bases/BuildingManager.cs index b71f478297..f9ca6087a7 100644 --- a/NitroxServer/GameLogic/Bases/BuildingManager.cs +++ b/NitroxServer/GameLogic/Bases/BuildingManager.cs @@ -7,6 +7,7 @@ using NitroxModel.DataStructures.GameLogic.Entities; using NitroxModel.DataStructures.GameLogic.Entities.Bases; using NitroxModel.Packets; +using NitroxModel.Serialization; using NitroxServer.GameLogic.Entities; using NitroxServer.Serialization; @@ -16,9 +17,9 @@ public class BuildingManager { private readonly EntityRegistry entityRegistry; private readonly WorldEntityManager worldEntityManager; - private readonly ServerConfig config; + private readonly SubnauticaServerConfig config; - public BuildingManager(EntityRegistry entityRegistry, WorldEntityManager worldEntityManager, ServerConfig config) + public BuildingManager(EntityRegistry entityRegistry, WorldEntityManager worldEntityManager, SubnauticaServerConfig config) { this.entityRegistry = entityRegistry; this.worldEntityManager = worldEntityManager; diff --git a/NitroxServer/GameLogic/PlayerManager.cs b/NitroxServer/GameLogic/PlayerManager.cs index 65055a912f..5dad6a8a3a 100644 --- a/NitroxServer/GameLogic/PlayerManager.cs +++ b/NitroxServer/GameLogic/PlayerManager.cs @@ -7,12 +7,11 @@ using NitroxModel.DataStructures.GameLogic; using NitroxModel.DataStructures.Unity; using NitroxModel.DataStructures.Util; -using NitroxModel.Helper; using NitroxModel.MultiplayerSession; using NitroxModel.Packets; using NitroxModel.Server; +using NitroxModel.Serialization; using NitroxServer.Communication; -using NitroxServer.Serialization; namespace NitroxServer.GameLogic { @@ -29,10 +28,10 @@ public class PlayerManager private Timer initialSyncTimer; - private readonly ServerConfig serverConfig; + private readonly SubnauticaServerConfig serverConfig; private ushort currentPlayerId; - public PlayerManager(List players, ServerConfig serverConfig) + public PlayerManager(List players, SubnauticaServerConfig serverConfig) { allPlayersByName = new ThreadSafeDictionary(players.ToDictionary(x => x.Name), false); currentPlayerId = players.Count == 0 ? (ushort)0 : players.Max(x => x.Id); @@ -98,7 +97,7 @@ public MultiplayerSessionReservation ReservePlayerContext( string playerName = authenticationContext.Username; allPlayersByName.TryGetValue(playerName, out Player player); - if (player?.IsPermaDeath == true && serverConfig.IsHardcore) + if (player?.IsPermaDeath == true && serverConfig.IsHardcore()) { MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.HARDCORE_PLAYER_DEAD; return new MultiplayerSessionReservation(correlationId, rejectedState); @@ -341,7 +340,7 @@ private IEnumerable ConnectedPlayers() .Where(assetPackage => assetPackage.Player != null) .Select(assetPackage => assetPackage.Player); } - + public void BroadcastPlayerJoined(Player player) { PlayerJoinedMultiplayerSession playerJoinedPacket = new(player.PlayerContext, player.SubRootId, player.Entity); diff --git a/NitroxServer/GlobalUsings.cs b/NitroxServer/GlobalUsings.cs new file mode 100644 index 0000000000..103576a4ac --- /dev/null +++ b/NitroxServer/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using NitroxModel; +global using NitroxModel.Helper; diff --git a/NitroxServer/NitroxServer.csproj b/NitroxServer/NitroxServer.csproj index 7d7ceb4f6c..061ac2bf5f 100644 --- a/NitroxServer/NitroxServer.csproj +++ b/NitroxServer/NitroxServer.csproj @@ -1,8 +1,7 @@ - + - net472 - disable + netstandard2.0 @@ -10,13 +9,8 @@ - + ..\Nitrox.Assets.Subnautica\protobuf-net.dll - - - - - diff --git a/NitroxServer/Serialization/BatchCellsParser.cs b/NitroxServer/Serialization/BatchCellsParser.cs index fde612c951..d7a36556f6 100644 --- a/NitroxServer/Serialization/BatchCellsParser.cs +++ b/NitroxServer/Serialization/BatchCellsParser.cs @@ -56,7 +56,7 @@ public void ParseFile(NitroxInt3 batchId, string pathPrefix, string prefix, stri return; } - string path = Path.Combine(subnauticaPath, "Subnautica_Data", "StreamingAssets", "SNUnmanagedData", "Build18"); + string path = Path.Combine(subnauticaPath, GameInfo.Subnautica.DataFolder, "StreamingAssets", "SNUnmanagedData", "Build18"); string fileName = Path.Combine(path, pathPrefix, $"{prefix}batch-cells-{batchId.X}-{batchId.Y}-{batchId.Z}{suffix}.bin"); if (!File.Exists(fileName)) @@ -70,7 +70,7 @@ public void ParseFile(NitroxInt3 batchId, string pathPrefix, string prefix, stri /** * It is suspected that 'cache' is a misnomer carried over from when UWE was actually doing procedurally * generated worlds. In the final release, this 'cache' has simply been baked into a final version that - * we can parse. + * we can parse. */ private void ParseCacheCells(NitroxInt3 batchId, string fileName, List spawnPoints) { diff --git a/NitroxServer/Serialization/SaveDataUpgrades/SaveDataUpgrade.cs b/NitroxServer/Serialization/SaveDataUpgrades/SaveDataUpgrade.cs index 5b33d250d8..4376dffbd8 100644 --- a/NitroxServer/Serialization/SaveDataUpgrades/SaveDataUpgrade.cs +++ b/NitroxServer/Serialization/SaveDataUpgrades/SaveDataUpgrade.cs @@ -13,6 +13,8 @@ public abstract class SaveDataUpgrade public abstract Version TargetVersion { get; } + public static readonly Version MinimumSaveVersion = new(1, 8, 0, 0); + public void UpgradeSaveFiles(string saveDir, string fileEnding) { Log.Info($"┌── Executing {GetType().Name}"); diff --git a/NitroxServer/Serialization/World/WorldManager.cs b/NitroxServer/Serialization/World/WorldManager.cs deleted file mode 100644 index 4ba96c2b6d..0000000000 --- a/NitroxServer/Serialization/World/WorldManager.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using NitroxModel.Helper; -using NitroxModel.Server; - -namespace NitroxServer.Serialization.World; - -public static class WorldManager -{ - public static readonly string SavesFolderDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Nitrox", "saves"); - - private static readonly List savesCache = new(); - - static WorldManager() - { - try - { - Directory.CreateDirectory(SavesFolderDir); - } - catch (Exception ex) - { - Log.Error(ex, "Couldn't create \"saves\" folder"); - throw new Exception(ex.ToString()); - } - } - - public static IEnumerable GetSaves() - { - if (savesCache.Count != 0) - { - return savesCache; - } - - foreach (string folder in Directory.EnumerateDirectories(SavesFolderDir)) - { - try - { - // Don't add the file to the list if it doesn't validate - if (!ValidateSave(folder)) - { - continue; - } - - Version version; - ServerConfig serverConfig = ServerConfig.Load(folder); - - string fileEnding = "json"; - if (serverConfig.SerializerMode == ServerSerializerMode.PROTOBUF) - { - fileEnding = "nitrox"; - } - - using (FileStream stream = new(Path.Combine(folder, $"Version.{fileEnding}"), FileMode.Open, FileAccess.Read, FileShare.Read)) - { - version = new ServerJsonSerializer().Deserialize(stream)?.Version ?? NitroxEnvironment.Version; - } - - DateTime fileLastAccessedTime; - if (File.Exists(Path.Combine(folder, $"WorldData.{fileEnding}"))) - { - fileLastAccessedTime = File.GetLastWriteTime(Path.Combine(folder, $"WorldData.{fileEnding}")); - } - else - { - fileLastAccessedTime = - File.GetLastWriteTime( - Path.Combine(folder, $"Version.{fileEnding}")); // This file was created when the save was created, so it can be used as the backup to get this write time if this is a new save (the WorldData file wouldn't exist) - } - - // Change the paramaters here to define what save file versions are eligible for use/upgrade - bool isValidVersion = version >= new Version(1, 7, 0, 0) && version <= NitroxEnvironment.Version; - - savesCache.Add(new Listing - { - WorldName = Path.GetFileName(folder), - WorldGamemode = Convert.ToString(serverConfig.GameMode), - WorldVersion = $"v{version}", - WorldSaveDir = folder, - IsValidSave = isValidVersion, - FileLastAccessed = fileLastAccessedTime - }); - - // Set the server.cfg name value to the folder name - if (Path.GetFileName(folder) != serverConfig.SaveName) - { - using (serverConfig.Update(folder)) - { - serverConfig.SaveName = Path.GetFileName(folder); - } - } - } - catch - { - Log.Error($"World \"{folder}\" could not be processed"); - } - } - // Order listing based on FileLastAccessed time - savesCache.Sort((x, y) => y.FileLastAccessed.CompareTo(x.FileLastAccessed)); - - return savesCache; - } - - public static void Refresh() - { - savesCache.Clear(); - GetSaves(); - } - - public static string CreateEmptySave(string name) - { - string saveDir = Path.Combine(SavesFolderDir, name); - - // Check save path for other "My World" files and increment the end value if there is, so as to prevent duplication - if (Directory.Exists(saveDir)) - { - int i = 1; - string newSelectedWorldName = name; - while (Directory.Exists(saveDir)) - { - // Add a number to the end of the name - newSelectedWorldName = $"{name} ({i})"; - saveDir = Path.Combine(SavesFolderDir, newSelectedWorldName); - i++; - } - name = newSelectedWorldName; - } - - Directory.CreateDirectory(saveDir); - - ServerConfig serverConfig = ServerConfig.Load(saveDir); - - string fileEnding = "json"; - if (serverConfig.SerializerMode == ServerSerializerMode.PROTOBUF) - { - fileEnding = "nitrox"; - } - File.Create(Path.Combine(saveDir, $"Version.{fileEnding}")).Close(); - - serverConfig.SaveName = name; - - return saveDir; - } - - public static bool ValidateSave(string saveFileDirectory, bool isImporting = false) - { - if (!Directory.Exists(saveFileDirectory)) - { - return false; - } - - // A save file is valid when it has a server.cfg file in it (if not importing a file) and if it has at least a Version.(ext) save file in it - if (isImporting && !File.Exists(Path.Combine(saveFileDirectory, "server.cfg"))) - { - return false; - } - - if (!File.Exists(Path.Combine(saveFileDirectory, "Version.json"))) - { - return false; - } - - return true; - } - - public class Listing - { - public string WorldName { get; set; } - public string WorldGamemode { get; set; } - public string WorldVersion { get; set; } - public string WorldSaveDir { get; set; } - public bool IsValidSave { get; set; } - public DateTime FileLastAccessed { get; set; } - } -} diff --git a/NitroxServer/Serialization/World/WorldPersistence.cs b/NitroxServer/Serialization/World/WorldPersistence.cs index 5bca7429aa..190d36487e 100644 --- a/NitroxServer/Serialization/World/WorldPersistence.cs +++ b/NitroxServer/Serialization/World/WorldPersistence.cs @@ -7,8 +7,8 @@ using NitroxModel.DataStructures.GameLogic; using NitroxModel.DataStructures.GameLogic.Entities; using NitroxModel.DataStructures.Util; -using NitroxModel.Helper; using NitroxModel.Platforms.OS.Shared; +using NitroxModel.Serialization; using NitroxModel.Server; using NitroxServer.GameLogic; using NitroxServer.GameLogic.Bases; @@ -20,274 +20,323 @@ using NitroxServer.Resources; using NitroxServer.Serialization.Upgrade; -namespace NitroxServer.Serialization.World +namespace NitroxServer.Serialization.World; + +public class WorldPersistence { - public class WorldPersistence - { - public IServerSerializer Serializer { get; private set; } - private string FileEnding => Serializer?.FileEnding ?? ""; + public const string BACKUP_DATE_TIME_FORMAT = "yyyy-MM-dd HH.mm.ss"; + private readonly SubnauticaServerConfig config; + private readonly ServerJsonSerializer jsonSerializer; - private readonly ServerProtoBufSerializer protoBufSerializer; - private readonly ServerJsonSerializer jsonSerializer; - private readonly ServerConfig config; - private readonly RandomStartGenerator randomStart; - private readonly IWorldModifier worldModifier; - private readonly SaveDataUpgrade[] upgrades; + private readonly ServerProtoBufSerializer protoBufSerializer; + private readonly RandomStartGenerator randomStart; + private readonly SaveDataUpgrade[] upgrades; + private readonly IWorldModifier worldModifier; - public WorldPersistence(ServerProtoBufSerializer protoBufSerializer, ServerJsonSerializer jsonSerializer, ServerConfig config, RandomStartGenerator randomStart, IWorldModifier worldModifier, SaveDataUpgrade[] upgrades) - { - this.protoBufSerializer = protoBufSerializer; - this.jsonSerializer = jsonSerializer; - this.config = config; - this.randomStart = randomStart; - this.worldModifier = worldModifier; - this.upgrades = upgrades; - - UpdateSerializer(config.SerializerMode); - } + public IServerSerializer Serializer { get; private set; } - internal void UpdateSerializer(IServerSerializer serverSerializer) - { - Validate.NotNull(serverSerializer, "Serializer cannot be null"); - Serializer = serverSerializer; - } + private string FileEnding => Serializer?.FileEnding ?? ""; + + public WorldPersistence(ServerProtoBufSerializer protoBufSerializer, ServerJsonSerializer jsonSerializer, SubnauticaServerConfig config, RandomStartGenerator randomStart, IWorldModifier worldModifier, SaveDataUpgrade[] upgrades) + { + this.protoBufSerializer = protoBufSerializer; + this.jsonSerializer = jsonSerializer; + this.config = config; + this.randomStart = randomStart; + this.worldModifier = worldModifier; + this.upgrades = upgrades; + + UpdateSerializer(config.SerializerMode); + } + + public bool Save(World world, string saveDir) => Save(PersistedWorldData.From(world), saveDir); - internal void UpdateSerializer(ServerSerializerMode mode) + public void BackUp(string saveDir) + { + if (config.MaxBackups < 1) { - Serializer = (mode == ServerSerializerMode.PROTOBUF) ? protoBufSerializer : jsonSerializer; + Log.Info($"No backup was made (\"{nameof(config.MaxBackups)}\" is equal to 0)"); + return; } + string backupDir = Path.Combine(saveDir, "Backups"); + string tempOutDir = Path.Combine(backupDir, $"Backup - {DateTime.Now.ToString(BACKUP_DATE_TIME_FORMAT)}"); + Directory.CreateDirectory(backupDir); - public bool Save(World world, string saveDir) => Save(PersistedWorldData.From(world), saveDir); - - internal bool Save(PersistedWorldData persistedData, string saveDir) + try { - try + // Prepare backup location + Directory.CreateDirectory(tempOutDir); + string newZipFile = $"{tempOutDir}.zip"; + if (File.Exists(newZipFile)) { - if (!Directory.Exists(saveDir)) - { - Directory.CreateDirectory(saveDir); - } - - Serializer.Serialize(Path.Combine(saveDir, $"Version{FileEnding}"), new SaveFileVersion()); - Serializer.Serialize(Path.Combine(saveDir, $"PlayerData{FileEnding}"), persistedData.PlayerData); - Serializer.Serialize(Path.Combine(saveDir, $"WorldData{FileEnding}"), persistedData.WorldData); - Serializer.Serialize(Path.Combine(saveDir, $"GlobalRootData{FileEnding}"), persistedData.GlobalRootData); - Serializer.Serialize(Path.Combine(saveDir, $"EntityData{FileEnding}"), persistedData.EntityData); + File.Delete(newZipFile); + } + foreach (string file in Directory.GetFiles(saveDir)) + { + File.Copy(file, Path.Combine(tempOutDir, Path.GetFileName(file))); + } - using (config.Update(saveDir)) + FileSystem.Instance.ZipFilesInDirectory(tempOutDir, newZipFile); + Directory.Delete(tempOutDir, true); + Log.Info("World backed up"); + + // Prune old backups + FileInfo[] backups = Directory.EnumerateFiles(backupDir) + .Select(f => new FileInfo(f)) + .Where(f => f is { Extension: ".zip" } info && info.Name.Contains("Backup - ")) + .OrderBy(f => File.GetCreationTime(f.FullName)) + .ToArray(); + if (backups.Length > config.MaxBackups) + { + int numBackupsToDelete = backups.Length - Math.Max(1, config.MaxBackups); + for (int i = 0; i < numBackupsToDelete; i++) { - config.Seed = persistedData.WorldData.Seed; + backups[i].Delete(); } - - Log.Info("World state saved"); - return true; - } - catch (Exception ex) - { - Log.Error(ex, $"Could not save world :"); - return false; } } - - internal Optional LoadFromFile(string saveDir) + catch (Exception ex) { - if (!Directory.Exists(saveDir) || !File.Exists(Path.Combine(saveDir, $"Version{FileEnding}"))) + Log.Error(ex, "Error while backing up world"); + if (Directory.Exists(tempOutDir)) { - Log.Warn("No previous save file found, creating a new one"); - return Optional.Empty; + Directory.Delete(tempOutDir, true); // Delete the outZip folder that is sometimes left } + } + } - UpgradeSave(saveDir); + public World Load(string saveName) + { + Optional fileLoadedWorld = LoadFromFile(Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), saveName)); + if (fileLoadedWorld.HasValue) + { + return fileLoadedWorld.Value; + } - PersistedWorldData persistedData = LoadDataFromPath(saveDir); + return CreateFreshWorld(); + } - if (persistedData == null) - { - return Optional.Empty; - } + public World CreateWorld(PersistedWorldData pWorldData, NitroxGameMode gameMode) + { + string seed = pWorldData.WorldData.Seed; + if (string.IsNullOrWhiteSpace(seed)) + { +#if DEBUG + seed = "TCCBIBZXAB"; +#else + seed = StringHelper.GenerateRandomString(10); +#endif + } + // Initialized only once, just like UnityEngine.Random + XORRandom.InitSeed(seed.GetHashCode()); - World world = CreateWorld(persistedData, config.GameMode); + Log.Info($"Loading world with seed {seed}"); - return Optional.Of(world); - } + EntityRegistry entityRegistry = NitroxServiceLocator.LocateService(); + entityRegistry.AddEntities(pWorldData.EntityData.Entities); + entityRegistry.AddEntitiesIgnoringDuplicate(pWorldData.GlobalRootData.Entities.OfType().ToList()); - internal PersistedWorldData LoadDataFromPath(string saveDir) + World world = new() { - try - { - PersistedWorldData persistedData = new() - { - PlayerData = Serializer.Deserialize(Path.Combine(saveDir, $"PlayerData{FileEnding}")), - WorldData = Serializer.Deserialize(Path.Combine(saveDir, $"WorldData{FileEnding}")), - GlobalRootData = Serializer.Deserialize(Path.Combine(saveDir, $"GlobalRootData{FileEnding}")), - EntityData = Serializer.Deserialize(Path.Combine(saveDir, $"EntityData{FileEnding}")) - }; + SimulationOwnershipData = new SimulationOwnershipData(), + PlayerManager = new PlayerManager(pWorldData.PlayerData.GetPlayers(), config), + EscapePodManager = new EscapePodManager(entityRegistry, randomStart, seed), + EntityRegistry = entityRegistry, + GameData = pWorldData.WorldData.GameData, + GameMode = gameMode, + Seed = seed + }; + + world.TimeKeeper = new TimeKeeper(world.PlayerManager, pWorldData.WorldData.GameData.StoryTiming.ElapsedSeconds, pWorldData.WorldData.GameData.StoryTiming.RealTimeElapsed); + world.StoryManager = new StoryManager(world.PlayerManager, pWorldData.WorldData.GameData.PDAState, pWorldData.WorldData.GameData.StoryGoals, world.TimeKeeper, seed, pWorldData.WorldData.GameData.StoryTiming.AuroraCountdownTime, + pWorldData.WorldData.GameData.StoryTiming.AuroraWarningTime, pWorldData.WorldData.GameData.StoryTiming.AuroraRealExplosionTime); + world.ScheduleKeeper = new ScheduleKeeper(pWorldData.WorldData.GameData.PDAState, pWorldData.WorldData.GameData.StoryGoals, world.TimeKeeper, world.PlayerManager); + + world.BatchEntitySpawner = new BatchEntitySpawner( + NitroxServiceLocator.LocateService(), + NitroxServiceLocator.LocateService(), + NitroxServiceLocator.LocateService(), + pWorldData.WorldData.ParsedBatchCells, + protoBufSerializer, + NitroxServiceLocator.LocateService(), + NitroxServiceLocator.LocateService>(), + pWorldData.WorldData.GameData.PDAState, + world.Seed + ); + + world.WorldEntityManager = new WorldEntityManager(world.EntityRegistry, world.BatchEntitySpawner); + + world.BuildingManager = new BuildingManager(world.EntityRegistry, world.WorldEntityManager, config); + + ISimulationWhitelist simulationWhitelist = NitroxServiceLocator.LocateService(); + world.EntitySimulation = new EntitySimulation(world.EntityRegistry, world.WorldEntityManager, world.SimulationOwnershipData, world.PlayerManager, simulationWhitelist); + + return world; + } - if (!persistedData.IsValid()) - { - throw new InvalidDataException("Save files are not valid"); - } + internal void UpdateSerializer(IServerSerializer serverSerializer) + { + Validate.NotNull(serverSerializer, "Serializer cannot be null"); + Serializer = serverSerializer; + } - return persistedData; - } - catch (Exception ex) - { - // Check if the world was newly created using the world manager - if (new FileInfo(Path.Combine(saveDir, $"Version{FileEnding}")).Length > 0) - { - Log.Error($"Could not load world, creating a new one : {ex.GetType()} {ex.Message}"); + internal void UpdateSerializer(ServerSerializerMode mode) => Serializer = mode == ServerSerializerMode.PROTOBUF ? protoBufSerializer : jsonSerializer; - // Backup world if loading fails - string outZip = Path.Combine(saveDir, "worldBackup.zip"); - Log.WarnSensitive("Creating a backup at {path}", Path.GetFullPath(outZip)); - FileSystem.Instance.ZipFilesInDirectory(saveDir, outZip, $"*{FileEnding}", true); - } + internal bool Save(PersistedWorldData persistedData, string saveDir) + { + try + { + if (!Directory.Exists(saveDir)) + { + Directory.CreateDirectory(saveDir); } - return null; - } + Serializer.Serialize(Path.Combine(saveDir, $"Version{FileEnding}"), new SaveFileVersion()); + Serializer.Serialize(Path.Combine(saveDir, $"PlayerData{FileEnding}"), persistedData.PlayerData); + Serializer.Serialize(Path.Combine(saveDir, $"WorldData{FileEnding}"), persistedData.WorldData); + Serializer.Serialize(Path.Combine(saveDir, $"GlobalRootData{FileEnding}"), persistedData.GlobalRootData); + Serializer.Serialize(Path.Combine(saveDir, $"EntityData{FileEnding}"), persistedData.EntityData); - public World Load() - { - Optional fileLoadedWorld = LoadFromFile(Path.Combine(WorldManager.SavesFolderDir, config.SaveName)); - if (fileLoadedWorld.HasValue) + using (config.Update(saveDir)) { - return fileLoadedWorld.Value; + config.Seed = persistedData.WorldData.Seed; } - return CreateFreshWorld(); + Log.Info("World state saved"); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Could not save world :"); + return false; } + } - private World CreateFreshWorld() + internal Optional LoadFromFile(string saveDir) + { + if (!Directory.Exists(saveDir) || !File.Exists(Path.Combine(saveDir, $"Version{FileEnding}"))) { - PersistedWorldData pWorldData = new() - { - EntityData = EntityData.From(new List()), - PlayerData = PlayerData.From(new List()), - WorldData = new WorldData() - { - GameData = new GameData - { - PDAState = new PDAStateData(), - StoryGoals = new StoryGoalData(), - StoryTiming = new StoryTimingData() - }, - ParsedBatchCells = new List(), - Seed = config.Seed - }, - GlobalRootData = new() - }; + Log.Warn("No previous save file found, creating a new one"); + return Optional.Empty; + } - World newWorld = CreateWorld(pWorldData, config.GameMode); - worldModifier.ModifyWorld(newWorld); + UpgradeSave(saveDir); - return newWorld; - } + PersistedWorldData persistedData = LoadDataFromPath(saveDir); - public World CreateWorld(PersistedWorldData pWorldData, NitroxGameMode gameMode) + if (persistedData == null) { - string seed = pWorldData.WorldData.Seed; - if (string.IsNullOrWhiteSpace(seed)) - { -#if DEBUG - seed = "TCCBIBZXAB"; -#else - seed = StringHelper.GenerateRandomString(10); -#endif - } - // Initialized only once, just like UnityEngine.Random - XORRandom.InitSeed(seed.GetHashCode()); + return Optional.Empty; + } - Log.Info($"Loading world with seed {seed}"); + World world = CreateWorld(persistedData, config.GameMode); - EntityRegistry entityRegistry = NitroxServiceLocator.LocateService(); - entityRegistry.AddEntities(pWorldData.EntityData.Entities); - entityRegistry.AddEntitiesIgnoringDuplicate(pWorldData.GlobalRootData.Entities.OfType().ToList()); + return Optional.Of(world); + } - World world = new() + internal PersistedWorldData LoadDataFromPath(string saveDir) + { + try + { + PersistedWorldData persistedData = new() { - SimulationOwnershipData = new SimulationOwnershipData(), - PlayerManager = new PlayerManager(pWorldData.PlayerData.GetPlayers(), config), + PlayerData = Serializer.Deserialize(Path.Combine(saveDir, $"PlayerData{FileEnding}")), + WorldData = Serializer.Deserialize(Path.Combine(saveDir, $"WorldData{FileEnding}")), + GlobalRootData = Serializer.Deserialize(Path.Combine(saveDir, $"GlobalRootData{FileEnding}")), + EntityData = Serializer.Deserialize(Path.Combine(saveDir, $"EntityData{FileEnding}")) + }; - EscapePodManager = new EscapePodManager(entityRegistry, randomStart, seed), + if (!persistedData.IsValid()) + { + throw new InvalidDataException("Save files are not valid"); + } - EntityRegistry = entityRegistry, + return persistedData; + } + catch (Exception ex) + { + // Check if the world was newly created using the world manager + if (new FileInfo(Path.Combine(saveDir, $"Version{FileEnding}")).Length > 0) + { + // Give error saying that world could not be used, and to restore a backup + Log.Error($"Could not load world, please restore one of your backups to continue using this world. : {ex.GetType()} {ex.Message}"); - GameData = pWorldData.WorldData.GameData, - GameMode = gameMode, - Seed = seed - }; + throw; + } + } - world.TimeKeeper = new(world.PlayerManager, pWorldData.WorldData.GameData.StoryTiming.ElapsedSeconds, pWorldData.WorldData.GameData.StoryTiming.RealTimeElapsed); - world.StoryManager = new(world.PlayerManager, pWorldData.WorldData.GameData.PDAState, pWorldData.WorldData.GameData.StoryGoals, world.TimeKeeper, seed, pWorldData.WorldData.GameData.StoryTiming.AuroraCountdownTime, pWorldData.WorldData.GameData.StoryTiming.AuroraWarningTime, pWorldData.WorldData.GameData.StoryTiming.AuroraRealExplosionTime); - world.ScheduleKeeper = new ScheduleKeeper(pWorldData.WorldData.GameData.PDAState, pWorldData.WorldData.GameData.StoryGoals, world.TimeKeeper, world.PlayerManager); + return null; + } - world.BatchEntitySpawner = new BatchEntitySpawner( - NitroxServiceLocator.LocateService(), - NitroxServiceLocator.LocateService(), - NitroxServiceLocator.LocateService(), - pWorldData.WorldData.ParsedBatchCells, - protoBufSerializer, - NitroxServiceLocator.LocateService(), - NitroxServiceLocator.LocateService>(), - pWorldData.WorldData.GameData.PDAState, - world.Seed - ); + private World CreateFreshWorld() + { + PersistedWorldData pWorldData = new() + { + EntityData = EntityData.From(new List()), + PlayerData = PlayerData.From(new List()), + WorldData = new WorldData + { + GameData = new GameData + { + PDAState = new PDAStateData(), + StoryGoals = new StoryGoalData(), + StoryTiming = new StoryTimingData() + }, + ParsedBatchCells = new List(), + Seed = config.Seed + }, + GlobalRootData = new GlobalRootData() + }; - world.WorldEntityManager = new WorldEntityManager(world.EntityRegistry, world.BatchEntitySpawner); + World newWorld = CreateWorld(pWorldData, config.GameMode); + worldModifier.ModifyWorld(newWorld); - world.BuildingManager = new(world.EntityRegistry, world.WorldEntityManager, config); + return newWorld; + } - ISimulationWhitelist simulationWhitelist = NitroxServiceLocator.LocateService(); - world.EntitySimulation = new EntitySimulation(world.EntityRegistry, world.WorldEntityManager, world.SimulationOwnershipData, world.PlayerManager, simulationWhitelist); + private void UpgradeSave(string saveDir) + { + SaveFileVersion saveFileVersion; - return world; + try + { + saveFileVersion = Serializer.Deserialize(Path.Combine(saveDir, $"Version{FileEnding}")); + } + catch (Exception ex) + { + Log.Error(ex, $"Error while upgrading save file. \"Version{FileEnding}\" couldn't be read."); + return; } - private void UpgradeSave(string saveDir) + if (saveFileVersion == null || saveFileVersion.Version == NitroxEnvironment.Version) { - SaveFileVersion saveFileVersion; + return; + } + if (config.SerializerMode == ServerSerializerMode.PROTOBUF) + { + Log.Info("Can't upgrade while using ProtoBuf as serializer"); + } + else + { try { - saveFileVersion = Serializer.Deserialize(Path.Combine(saveDir, $"Version{FileEnding}")); + foreach (SaveDataUpgrade upgrade in upgrades) + { + if (upgrade.TargetVersion > saveFileVersion.Version) + { + upgrade.UpgradeSaveFiles(saveDir, FileEnding); + } + } } catch (Exception ex) { - Log.Error(ex, $"Error while upgrading save file. \"Version{FileEnding}\" couldn't be read."); + Log.Error(ex, "Error while upgrading save file."); return; } - if (saveFileVersion == null || saveFileVersion.Version == NitroxEnvironment.Version) - { - return; - } - - if (config.SerializerMode == ServerSerializerMode.PROTOBUF) - { - Log.Info("Can't upgrade while using ProtoBuf as serializer"); - } - else - { - try - { - foreach (SaveDataUpgrade upgrade in upgrades) - { - if (upgrade.TargetVersion > saveFileVersion.Version) - { - upgrade.UpgradeSaveFiles(saveDir, FileEnding); - } - } - } - catch (Exception ex) - { - Log.Error(ex, "Error while upgrading save file."); - return; - } - - Serializer.Serialize(Path.Combine(saveDir, $"Version{FileEnding}"), new SaveFileVersion()); - Log.Info($"Save file was upgraded to {NitroxEnvironment.Version}"); - } + Serializer.Serialize(Path.Combine(saveDir, $"Version{FileEnding}"), new SaveFileVersion()); + Log.Info($"Save file was upgraded to {NitroxEnvironment.Version}"); } } } diff --git a/NitroxServer/Server.cs b/NitroxServer/Server.cs index 4f9164486f..c1e729ffca 100644 --- a/NitroxServer/Server.cs +++ b/NitroxServer/Server.cs @@ -7,278 +7,433 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using NitroxModel; using NitroxModel.DataStructures.GameLogic; -using NitroxModel.Helper; +using NitroxModel.Serialization; +using NitroxModel.Server; using NitroxServer.GameLogic.Entities; using NitroxServer.Serialization; using NitroxServer.Serialization.World; using Timer = System.Timers.Timer; -namespace NitroxServer +namespace NitroxServer; + +public class Server { - public class Server - { - private readonly Communication.NitroxServer server; - private readonly WorldPersistence worldPersistence; - private readonly ServerConfig serverConfig; - private readonly Timer saveTimer; - private readonly World world; - private readonly WorldEntityManager worldEntityManager; - private readonly EntityRegistry entityRegistry; + private readonly Communication.NitroxServer server; + private readonly WorldPersistence worldPersistence; + private readonly SubnauticaServerConfig serverConfig; + private readonly Timer saveTimer; + private readonly World world; + private readonly WorldEntityManager worldEntityManager; + private readonly EntityRegistry entityRegistry; + + private CancellationTokenSource serverCancelSource; - private CancellationTokenSource serverCancelSource; + public static Server Instance { get; private set; } - public static Server Instance { get; private set; } + public bool IsRunning { get; private set; } - public bool IsRunning => serverCancelSource?.IsCancellationRequested == false; - public bool IsSaving { get; private set; } + public bool IsSaving { get; private set; } - public int Port => serverConfig?.ServerPort ?? -1; + public string Name { get; private set; } = "My World"; + public int Port => serverConfig?.ServerPort ?? -1; - public Server(WorldPersistence worldPersistence, World world, ServerConfig serverConfig, Communication.NitroxServer server, WorldEntityManager worldEntityManager, EntityRegistry entityRegistry) + public Server(WorldPersistence worldPersistence, World world, SubnauticaServerConfig serverConfig, Communication.NitroxServer server, WorldEntityManager worldEntityManager, EntityRegistry entityRegistry) + { + this.worldPersistence = worldPersistence; + this.serverConfig = serverConfig; + this.server = server; + this.world = world; + this.worldEntityManager = worldEntityManager; + this.entityRegistry = entityRegistry; + + Instance = this; + + saveTimer = new Timer(); + saveTimer.Interval = serverConfig.SaveInterval; + saveTimer.AutoReset = true; + saveTimer.Elapsed += delegate { - this.worldPersistence = worldPersistence; - this.serverConfig = serverConfig; - this.server = server; - this.world = world; - this.worldEntityManager = worldEntityManager; - this.entityRegistry = entityRegistry; - - Instance = this; - - saveTimer = new Timer(); - saveTimer.Interval = serverConfig.SaveInterval; - saveTimer.AutoReset = true; - saveTimer.Elapsed += delegate + if (!serverConfig.DisableAutoBackup && serverConfig.MaxBackups != 0) + { + BackUp(); + } + else { Save(); - }; - } + } + }; + } - public string GetSaveSummary(Perms viewerPerms = Perms.CONSOLE) + public string GetSaveSummary(Perms viewerPerms = Perms.CONSOLE) + { + // TODO: Extend summary with more useful save file data + // Note for later additions: order these lines by their length + StringBuilder builder = new("\n"); + if (viewerPerms is Perms.CONSOLE) { - // TODO: Extend summary with more useful save file data - // Note for later additions: order these lines by their length - StringBuilder builder = new("\n"); - if (viewerPerms is Perms.CONSOLE) - { - builder.AppendLine($" - Save location: {Path.Combine(WorldManager.SavesFolderDir, serverConfig.SaveName)}"); - } - builder.AppendLine($""" - - Aurora's state: {world.StoryManager.GetAuroraStateSummary()} - - Current time: day {world.TimeKeeper.Day} ({Math.Floor(world.TimeKeeper.ElapsedSeconds)}s) - - Scheduled goals stored: {world.GameData.StoryGoals.ScheduledGoals.Count} - - Story goals completed: {world.GameData.StoryGoals.CompletedGoals.Count} - - Radio messages stored: {world.GameData.StoryGoals.RadioQueue.Count} - - World gamemode: {serverConfig.GameMode} - - Story goals unlocked: {world.GameData.StoryGoals.GoalUnlocks.Count} - - Encyclopedia entries: {world.GameData.PDAState.EncyclopediaEntries.Count} - - Known tech: {world.GameData.PDAState.KnownTechTypes.Count} - """); - - return builder.ToString(); + builder.AppendLine($" - Save location: {Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), Name)}"); } + builder.AppendLine($""" + - Aurora's state: {world.StoryManager.GetAuroraStateSummary()} + - Current time: day {world.TimeKeeper.Day} ({Math.Floor(world.TimeKeeper.ElapsedSeconds)}s) + - Scheduled goals stored: {world.GameData.StoryGoals.ScheduledGoals.Count} + - Story goals completed: {world.GameData.StoryGoals.CompletedGoals.Count} + - Radio messages stored: {world.GameData.StoryGoals.RadioQueue.Count} + - World gamemode: {serverConfig.GameMode} + - Story goals unlocked: {world.GameData.StoryGoals.GoalUnlocks.Count} + - Encyclopedia entries: {world.GameData.PDAState.EncyclopediaEntries.Count} + - Known tech: {world.GameData.PDAState.KnownTechTypes.Count} + """); + + return builder.ToString(); + } - public static ServerConfig ServerStartHandler() + // TODO : Remove this method once server hosting/loading happens as a service (see '.NET Generic Host' on msdn) + public static SubnauticaServerConfig CreateOrLoadConfig() + { + string saveDir = null; + string[] args = Environment.GetCommandLineArgs(); + foreach (string arg in args) { - string saveDir = null; - foreach (string arg in Environment.GetCommandLineArgs()) + if (arg.StartsWith(KeyValueStore.Instance.GetSavesFolderDir(), StringComparison.OrdinalIgnoreCase) && Directory.Exists(arg)) { - if (arg.StartsWith(WorldManager.SavesFolderDir, StringComparison.OrdinalIgnoreCase) && Directory.Exists(arg)) - { - saveDir = arg; - break; - } + saveDir = arg; + break; } - if (saveDir == null) + } + if (saveDir == null) + { + // Check if there are any save files + List saves = GetSaves(); + if (saves.Any()) { - // Check if there are any save files - WorldManager.Listing[] worldList = WorldManager.GetSaves().ToArray(); - if (worldList.Any()) + // Get last save file used + string lastSaveAccessed = saves[0].SaveDir; + if (saves.Count > 1) { - // Get last save file used - string lastSaveAccessed = worldList[0].WorldSaveDir; - if (worldList.Length > 1) + for (int i = 1; i < saves.Count; i++) { - for (int i = 1; i < worldList.Length; i++) + if (File.GetLastWriteTime(Path.Combine(saves[i].SaveDir, "WorldData.json")) > File.GetLastWriteTime(lastSaveAccessed)) { - if (File.GetLastWriteTime(Path.Combine(worldList[i].WorldSaveDir, "WorldData.json")) > File.GetLastWriteTime(lastSaveAccessed)) - { - lastSaveAccessed = worldList[i].WorldSaveDir; - } + lastSaveAccessed = saves[i].SaveDir; } } - saveDir = lastSaveAccessed; } - else - { - // Create new save file - saveDir = Path.Combine(WorldManager.SavesFolderDir, "My World"); - Directory.CreateDirectory(saveDir); - ServerConfig serverConfig = ServerConfig.Load(saveDir); - Log.Debug($"No save file was found, creating a new one..."); - } - + saveDir = lastSaveAccessed; + } + else + { + // Create new save file + Log.Debug("No save file was found, creating a new one..."); + saveDir = Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), GetSaveName(args)); + Directory.CreateDirectory(saveDir); } - - return ServerConfig.Load(saveDir); } - public void Save() + return SubnauticaServerConfig.Load(saveDir); + } + + public void Save() + { + if (IsSaving) { - if (IsSaving) - { - return; - } + return; + } - IsSaving = true; + IsSaving = true; - bool savedSuccessfully = worldPersistence.Save(world, Path.Combine(WorldManager.SavesFolderDir, serverConfig.SaveName)); - if (savedSuccessfully && !string.IsNullOrWhiteSpace(serverConfig.PostSaveCommandPath)) + bool savedSuccessfully = worldPersistence.Save(world, Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), Name)); + if (savedSuccessfully && !string.IsNullOrWhiteSpace(serverConfig.PostSaveCommandPath)) + { + try { - try + // Call external tool for backups, etc + if (File.Exists(serverConfig.PostSaveCommandPath)) { - // Call external tool for backups, etc - if (File.Exists(serverConfig.PostSaveCommandPath)) - { - using Process process = Process.Start(serverConfig.PostSaveCommandPath); - Log.Info($"Post-save command completed successfully: {serverConfig.PostSaveCommandPath}"); - } - else - { - Log.Error($"Post-save file does not exist: {serverConfig.PostSaveCommandPath}"); - } + using Process process = Process.Start(serverConfig.PostSaveCommandPath); + Log.Info($"Post-save command completed successfully: {serverConfig.PostSaveCommandPath}"); } - catch (Exception ex) + else { - Log.Error(ex, "Post-save command failed"); + Log.Error($"Post-save file does not exist: {serverConfig.PostSaveCommandPath}"); } } - IsSaving = false; + catch (Exception ex) + { + Log.Error(ex, "Post-save command failed"); + } } + IsSaving = false; + } - public bool Start(CancellationTokenSource cancellationToken) + public bool Start(string saveName, CancellationTokenSource ct) + { + Debug.Assert(serverCancelSource == null); + + Validate.NotNull(ct); + if (ct.IsCancellationRequested) { - serverCancelSource = cancellationToken; - if (!server.Start()) - { - return false; - } + return false; + } + if (!server.Start(ct.Token)) + { + return false; + } + Name = saveName; + serverCancelSource = ct; + IsRunning = true; - try + if (!serverConfig.DisableAutoBackup) + { + worldPersistence.BackUp(Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), saveName)); + } + + try + { + if (serverConfig.CreateFullEntityCache) { - if (serverConfig.CreateFullEntityCache) + Log.Info("Starting to load all batches up front."); + Log.Info("This can take up to several minutes and you can't join until it's completed."); + Log.Info($"{entityRegistry.GetAllEntities().Count} entities already cached"); + if (entityRegistry.GetAllEntities().Count < 504732) { - Log.Info("Starting to load all batches up front."); - Log.Info("This can take up to several minutes and you can't join until it's completed."); - Log.Info($"{entityRegistry.GetAllEntities().Count} entities already cached"); - if (entityRegistry.GetAllEntities().Count < 504732) - { - worldEntityManager.LoadAllUnspawnedEntities(serverCancelSource.Token); + worldEntityManager.LoadAllUnspawnedEntities(serverCancelSource.Token); - Log.Info("Saving newly cached entities."); - Save(); - } - Log.Info("All batches have now been loaded."); + Log.Info("Saving newly cached entities."); + Save(); } + Log.Info("All batches have now been loaded."); } - catch (OperationCanceledException ex) - { - Log.Warn($"Server start was cancelled by user:{Environment.NewLine}{ex.Message}"); - return false; - } + } + catch (OperationCanceledException ex) + { + Log.Warn($"Server start was cancelled by user:{Environment.NewLine}{ex.Message}"); + return false; + } - LogHowToConnectAsync().ContinueWithHandleError(ex => Log.Warn($"Failed to show how to connect: {ex.GetFirstNonAggregateMessage()}")); - Log.Info($"Server is listening on port {Port} UDP"); - Log.Info($"Using {serverConfig.SerializerMode} as save file serializer"); - Log.InfoSensitive("Server Password: {password}", string.IsNullOrEmpty(serverConfig.ServerPassword) ? "None. Public Server." : serverConfig.ServerPassword); - Log.InfoSensitive("Admin Password: {password}", serverConfig.AdminPassword); - Log.Info($"Autosave: {(serverConfig.DisableAutoSave ? "DISABLED" : $"ENABLED ({serverConfig.SaveInterval / 60000} min)")}"); - Log.Info($"Loaded save\n{GetSaveSummary()}"); + LogHowToConnectAsync().ContinueWithHandleError(ex => Log.Warn($"Failed to show how to connect: {ex.GetFirstNonAggregateMessage()}")); + Log.Info($"Server is listening on port {Port} UDP"); + Log.Info($"Using {serverConfig.SerializerMode} as save file serializer"); + Log.InfoSensitive("Server Password: {password}", string.IsNullOrEmpty(serverConfig.ServerPassword) ? "None. Public Server." : serverConfig.ServerPassword); + Log.InfoSensitive("Admin Password: {password}", serverConfig.AdminPassword); + Log.Info($"Autosave: {(serverConfig.DisableAutoSave ? "DISABLED" : $"ENABLED ({serverConfig.SaveInterval / 60000} min)")}"); + Log.Info($"Autobackup: {(serverConfig.DisableAutoBackup || serverConfig.MaxBackups == 0 ? "DISABLED" : "ENABLED")} (Max Backups: {serverConfig.MaxBackups})"); + Log.Info($"Loaded save\n{GetSaveSummary()}"); - PauseServer(); + PauseServer(); - return true; - } + return true; + } - public void Stop(bool shouldSave = true) + public void Stop(bool shouldSave = true) + { + if (!IsRunning) { - if (!IsRunning) - { - return; - } + return; + } + IsRunning = false; + try + { serverCancelSource.Cancel(); - Log.Info("Nitrox Server Stopping..."); - DisablePeriodicSaving(); + } + catch + { + // ignored + } - if (shouldSave) - { - Save(); - } + Log.Info("Nitrox Server Stopping..."); + DisablePeriodicSaving(); - server.Stop(); - Log.Info("Nitrox Server Stopped"); + if (shouldSave) + { + Save(); } - private async Task LogHowToConnectAsync() + server.Stop(); + Log.Info("Nitrox Server Stopped"); + } + + public void BackUp() + { + if (!IsRunning) { - Task localIp = Task.Run(NetHelper.GetLanIp); - Task wanIp = NetHelper.GetWanIpAsync(); - Task hamachiIp = Task.Run(NetHelper.GetHamachiIp); + return; + } - List options = new(); - options.Add("127.0.0.1 - You (Local)"); - if (await wanIp != null) - { - options.Add("{ip:l} - Friends on another internet network (Port Forwarding)"); - } - if (await hamachiIp != null) - { - options.Add($"{hamachiIp.Result} - Friends using Hamachi (VPN)"); - } - // LAN IP could be null if all Ethernet/Wi-Fi interfaces are disabled. - if (await localIp != null) + Save(); + + worldPersistence.BackUp(Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), Name)); + } + + private async Task LogHowToConnectAsync() + { + Task localIp = Task.Run(NetHelper.GetLanIp); + Task wanIp = NetHelper.GetWanIpAsync(); + Task hamachiIp = Task.Run(NetHelper.GetHamachiIp); + + List options = ["127.0.0.1 - You (Local)"]; + if (await wanIp != null) + { + options.Add("{ip:l} - Friends on another internet network (Port Forwarding)"); + } + if (await hamachiIp != null) + { + options.Add($"{hamachiIp.Result} - Friends using Hamachi (VPN)"); + } + // LAN IP could be null if all Ethernet/Wi-Fi interfaces are disabled. + if (await localIp != null) + { + options.Add($"{localIp.Result} - Friends on same internet network (LAN)"); + } + + Log.InfoSensitive($"Use IP to connect:{Environment.NewLine}\t{string.Join($"{Environment.NewLine}\t", options)}", wanIp.Result); + } + + public void StopAndWait(bool shouldSave = true) + { + Stop(shouldSave); + Log.Info("Press enter to continue"); + Console.Read(); + } + + public void EnablePeriodicSaving() + { + saveTimer.Start(); + } + + public void DisablePeriodicSaving() + { + saveTimer.Stop(); + } + + public void PauseServer() + { + DisablePeriodicSaving(); + world.TimeKeeper.StopCounting(); + Log.Info("Server has paused, waiting for players to connect"); + } + + public void ResumeServer() + { + if (!serverConfig.DisableAutoSave) + { + EnablePeriodicSaving(); + } + world.TimeKeeper.StartCounting(); + Log.Info("Server has resumed"); + } + + private static List GetSaves() + { + try + { + Directory.CreateDirectory(KeyValueStore.Instance.GetSavesFolderDir()); + + List saves = []; + foreach (string saveDir in Directory.EnumerateDirectories(KeyValueStore.Instance.GetSavesFolderDir())) { - options.Add($"{localIp.Result} - Friends on same internet network (LAN)"); + try + { + ServerListing entryFromDir = ServerListing.Validate(saveDir); + if (entryFromDir != null) + { + saves.Add(entryFromDir); + } + } + catch (Exception) + { + // ignored + } } - Log.InfoSensitive($"Use IP to connect:{Environment.NewLine}\t{string.Join($"{Environment.NewLine}\t", options)}", wanIp.Result); + return [.. saves.OrderByDescending(entry => entry.LastAccessedTime)]; } - - public void StopAndWait(bool shouldSave = true) + catch (Exception ex) { - Stop(shouldSave); - Log.Info("Press enter to continue"); - Console.Read(); + Log.Error(ex, "Error while getting saves"); } + return []; + } - public void EnablePeriodicSaving() + /// + /// Parses the save name from the given command line arguments or defaults to the standard save name. + /// + // TODO : Remove this method once server hosting/loading happens as a service (see '.NET Generic Host' on msdn) + public static string GetSaveName(string[] args) + { + if (args.Length == 1 && IsValidSaveName(args[0])) { - saveTimer.Start(); + return args[0].Trim(); } + for (int i = 0; i < args.Length; i++) + { + if (i + 1 < args.Length && args[i] is "--save" or "--name" && IsValidSaveName(args[i + 1])) + { + return args[i + 1].Trim(); + } + } + return "My World"; + } - public void DisablePeriodicSaving() + private static bool IsValidSaveName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + if (name.StartsWith("--")) + { + return false; + } + if (name.EndsWith(".")) + { + return false; + } + if (name.IndexOfAny(Path.GetInvalidFileNameChars().ToArray()) > -1) { - saveTimer.Stop(); + return false; } + return true; + } +} - public void PauseServer() +internal class ServerListing +{ + public string SaveDir { get; set; } + public Version SaveVersion { get; set; } + public DateTime LastAccessedTime { get; set; } + + internal static ServerListing Validate(string saveDir) + { + ServerListing serverListing = new(); + if (!File.Exists(Path.Combine(saveDir, "server.cfg")) || !File.Exists(Path.Combine(saveDir, "Version.json"))) { - DisablePeriodicSaving(); - world.TimeKeeper.StopCounting(); - Log.Info("Server has paused, waiting for players to connect"); + return null; } - public void ResumeServer() + SubnauticaServerConfig config = SubnauticaServerConfig.Load(saveDir); + string fileEnding = "json"; + if (config.SerializerMode == ServerSerializerMode.PROTOBUF) + { fileEnding = "nitrox"; } + + Version version; + using (FileStream stream = new(Path.Combine(saveDir, $"Version.{fileEnding}"), FileMode.Open, FileAccess.Read, FileShare.Read)) { - if (!serverConfig.DisableAutoSave) - { - EnablePeriodicSaving(); - } - world.TimeKeeper.StartCounting(); - Log.Info("Server has resumed"); + version = new ServerJsonSerializer().Deserialize(stream)?.Version ?? NitroxEnvironment.Version; } + + serverListing.SaveDir = saveDir; + serverListing.SaveVersion = version; + serverListing.LastAccessedTime = File.GetLastWriteTime(File.Exists(Path.Combine(saveDir, $"WorldData.{fileEnding}")) + ? + // This file is affected by server saving + Path.Combine(saveDir, $"WorldData.{fileEnding}") + : + // If the above file doesn't exist (server was never ran), use the Version file instead + Path.Combine(saveDir, $"Version.{fileEnding}")); + + return serverListing; } } diff --git a/NitroxServer/ServerAutoFacRegistrar.cs b/NitroxServer/ServerAutoFacRegistrar.cs index 9e632bdfd7..878ae7c057 100644 --- a/NitroxServer/ServerAutoFacRegistrar.cs +++ b/NitroxServer/ServerAutoFacRegistrar.cs @@ -1,4 +1,5 @@ global using NitroxModel.Logger; +using System; using System.Reflection; using Autofac; using NitroxModel.Core; @@ -26,7 +27,8 @@ public virtual void RegisterDependencies(ContainerBuilder containerBuilder) private static void RegisterCoreDependencies(ContainerBuilder containerBuilder) { - containerBuilder.Register(c => Server.ServerStartHandler()).SingleInstance(); + // TODO: Remove this once .NET Generic Host is implemented + containerBuilder.Register(c => Server.CreateOrLoadConfig()).SingleInstance(); containerBuilder.RegisterType().SingleInstance(); containerBuilder.RegisterType().InstancePerLifetimeScope(); containerBuilder.RegisterType().InstancePerLifetimeScope(); @@ -41,7 +43,8 @@ private void RegisterWorld(ContainerBuilder containerBuilder) { containerBuilder.RegisterType().SingleInstance(); - containerBuilder.Register(c => c.Resolve().Load()).SingleInstance(); + // TODO: Remove this once .NET Generic Host is implemented + containerBuilder.Register(c => c.Resolve().Load(Server.GetSaveName(Environment.GetCommandLineArgs()))).SingleInstance(); containerBuilder.Register(c => c.Resolve().BuildingManager).SingleInstance(); containerBuilder.Register(c => c.Resolve().TimeKeeper).SingleInstance(); containerBuilder.Register(c => c.Resolve().PlayerManager).SingleInstance();