commit bc4546410e0aa70c110e008c71f62ad9fafec7cb Author: zznty <94796179+zznty@users.noreply.github.com> Date: Thu Jul 21 21:57:27 2022 +0700 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8afdcb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,454 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# JetBrains Rider +.idea/ +*.sln.iml + +## +## Visual Studio Code +## +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json diff --git a/.idea/.idea.TorchRemote/.idea/.gitignore b/.idea/.idea.TorchRemote/.idea/.gitignore new file mode 100644 index 0000000..5fa4bc4 --- /dev/null +++ b/.idea/.idea.TorchRemote/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/projectSettingsUpdater.xml +/.idea.TorchRemote.iml +/contentModel.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.TorchRemote/.idea/avalonia.xml b/.idea/.idea.TorchRemote/.idea/avalonia.xml new file mode 100644 index 0000000..f9472c9 --- /dev/null +++ b/.idea/.idea.TorchRemote/.idea/avalonia.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/.idea.TorchRemote/.idea/encodings.xml b/.idea/.idea.TorchRemote/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.TorchRemote/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.TorchRemote/.idea/indexLayout.xml b/.idea/.idea.TorchRemote/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.TorchRemote/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.TorchRemote/.idea/misc.xml b/.idea/.idea.TorchRemote/.idea/misc.xml new file mode 100644 index 0000000..1d8c84d --- /dev/null +++ b/.idea/.idea.TorchRemote/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.TorchRemote/.idea/vcs.xml b/.idea/.idea.TorchRemote/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.TorchRemote/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/TorchRemote.Models/Requests/ChatCommandRequest.cs b/TorchRemote.Models/Requests/ChatCommandRequest.cs new file mode 100644 index 0000000..743b333 --- /dev/null +++ b/TorchRemote.Models/Requests/ChatCommandRequest.cs @@ -0,0 +1,3 @@ +namespace TorchRemote.Models.Requests; + +public record ChatCommandRequest(string Command); \ No newline at end of file diff --git a/TorchRemote.Models/Requests/ChatMessageRequest.cs b/TorchRemote.Models/Requests/ChatMessageRequest.cs new file mode 100644 index 0000000..c8c8a3e --- /dev/null +++ b/TorchRemote.Models/Requests/ChatMessageRequest.cs @@ -0,0 +1,4 @@ +using TorchRemote.Models.Shared; +namespace TorchRemote.Models.Requests; + +public record ChatMessageRequest(string Author, string Message, ChatChannel Channel, long? TargetId = null); \ No newline at end of file diff --git a/TorchRemote.Models/Requests/StopServerRequest.cs b/TorchRemote.Models/Requests/StopServerRequest.cs new file mode 100644 index 0000000..31a6f26 --- /dev/null +++ b/TorchRemote.Models/Requests/StopServerRequest.cs @@ -0,0 +1,3 @@ +namespace TorchRemote.Models.Requests; + +public record StopServerRequest(bool Save = true); diff --git a/TorchRemote.Models/Responses/ChatCommandResponse.cs b/TorchRemote.Models/Responses/ChatCommandResponse.cs new file mode 100644 index 0000000..512f1ac --- /dev/null +++ b/TorchRemote.Models/Responses/ChatCommandResponse.cs @@ -0,0 +1,3 @@ +namespace TorchRemote.Models.Responses; + +public record ChatCommandResponse(Guid Id, string Author, string Message) : ChatResponseBase; diff --git a/TorchRemote.Models/Responses/ChatMessageResponse.cs b/TorchRemote.Models/Responses/ChatMessageResponse.cs new file mode 100644 index 0000000..226a569 --- /dev/null +++ b/TorchRemote.Models/Responses/ChatMessageResponse.cs @@ -0,0 +1,4 @@ +using TorchRemote.Models.Shared; +namespace TorchRemote.Models.Responses; + +public record ChatMessageResponse(string AuthorName, ulong? Author, ChatChannel Channel, string Message) : ChatResponseBase; diff --git a/TorchRemote.Models/Responses/ChatResponseBase.cs b/TorchRemote.Models/Responses/ChatResponseBase.cs new file mode 100644 index 0000000..570e127 --- /dev/null +++ b/TorchRemote.Models/Responses/ChatResponseBase.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Serialization; +namespace TorchRemote.Models.Responses; + +[JsonDerivedType(typeof(ChatCommandResponse), "command")] +[JsonDerivedType(typeof(ChatMessageResponse), "message")] +public record ChatResponseBase(); diff --git a/TorchRemote.Models/Responses/LogLineResponse.cs b/TorchRemote.Models/Responses/LogLineResponse.cs new file mode 100644 index 0000000..e7e5505 --- /dev/null +++ b/TorchRemote.Models/Responses/LogLineResponse.cs @@ -0,0 +1,13 @@ +namespace TorchRemote.Models.Responses; + +public record struct LogLineResponse(DateTime Time, LogLineLevel Level, string Logger, string Message); + +public enum LogLineLevel +{ + Trace, + Debug, + Info, + Warning, + Error, + Fatal +} diff --git a/TorchRemote.Models/Responses/ServerStatusResponse.cs b/TorchRemote.Models/Responses/ServerStatusResponse.cs new file mode 100644 index 0000000..080e046 --- /dev/null +++ b/TorchRemote.Models/Responses/ServerStatusResponse.cs @@ -0,0 +1,15 @@ +namespace TorchRemote.Models.Responses; + +public record ServerStatusResponse(double SimSpeed, int MemberCount, TimeSpan Uptime, ServerStatus Status); + +public enum ServerStatus +{ + /// The server is not running. + Stopped, + /// The server is starting/loading the session. + Starting, + /// The server is running. + Running, + /// The server encountered an error. + Error, +} diff --git a/TorchRemote.Models/Responses/SettingInfoResponse.cs b/TorchRemote.Models/Responses/SettingInfoResponse.cs new file mode 100644 index 0000000..870dfe4 --- /dev/null +++ b/TorchRemote.Models/Responses/SettingInfoResponse.cs @@ -0,0 +1,15 @@ +namespace TorchRemote.Models.Responses; + +public record SettingInfoResponse(string Name, ICollection Properties); +public record SettingPropertyInfo(string Name, string? Description, int? Order, Guid Type); + +public struct SettingPropertyTypeEnum +{ + public static readonly Guid Integer = new("95c0d25b-e44d-4505-9549-48ee9c14bce8"); + public static readonly Guid Boolean = new("028ef347-1fc3-486a-b70b-3d3b1dcdb538"); + public static readonly Guid Number = new("009ced71-4a69-4af0-abb9-ec3339fffce0"); + public static readonly Guid String = new("22dbed1b-b976-44b4-98c9-d1b742a93f0c"); + public static readonly Guid DateTime = new("f0978b29-9da9-4289-85c9-41d5b92056e8"); + public static readonly Guid TimeSpan = new("7a2bebf1-78f5-4e4e-8d83-18914dbee55c"); + public static readonly Guid Color = new("99c74632-0fa9-469b-ba05-825ba21a017b"); +} \ No newline at end of file diff --git a/TorchRemote.Models/Responses/WorldResponse.cs b/TorchRemote.Models/Responses/WorldResponse.cs new file mode 100644 index 0000000..8305fd9 --- /dev/null +++ b/TorchRemote.Models/Responses/WorldResponse.cs @@ -0,0 +1,3 @@ +namespace TorchRemote.Models.Responses; + +public record WorldResponse(string Name, long SizeKb); diff --git a/TorchRemote.Models/Shared/ChatChannel.cs b/TorchRemote.Models/Shared/ChatChannel.cs new file mode 100644 index 0000000..ccd3814 --- /dev/null +++ b/TorchRemote.Models/Shared/ChatChannel.cs @@ -0,0 +1,9 @@ +namespace TorchRemote.Models.Shared; + +public enum ChatChannel +{ + Global, + GlobalScripted, + Faction, + Private +} diff --git a/TorchRemote.Models/Shared/IpAddress.cs b/TorchRemote.Models/Shared/IpAddress.cs new file mode 100644 index 0000000..c1ca9be --- /dev/null +++ b/TorchRemote.Models/Shared/IpAddress.cs @@ -0,0 +1,3 @@ +namespace TorchRemote.Models.Shared; + +public record IpAddress(string Ip, int Port); diff --git a/TorchRemote.Models/Shared/ServerSettings.cs b/TorchRemote.Models/Shared/ServerSettings.cs new file mode 100644 index 0000000..482b879 --- /dev/null +++ b/TorchRemote.Models/Shared/ServerSettings.cs @@ -0,0 +1,9 @@ +namespace TorchRemote.Models.Shared; + +public record ServerSettings( + string ServerName, + string MapName, + string ServerDescription, + short MemberLimit, + IpAddress ListenEndPoint +); diff --git a/TorchRemote.Models/TorchRemote.Models.csproj b/TorchRemote.Models/TorchRemote.Models.csproj new file mode 100644 index 0000000..d28ab0c --- /dev/null +++ b/TorchRemote.Models/TorchRemote.Models.csproj @@ -0,0 +1,13 @@ + + + + netstandard2.0 + enable + enable + 10 + + + + + + diff --git a/TorchRemote.Models/Trash/IsExternalInit.cs b/TorchRemote.Models/Trash/IsExternalInit.cs new file mode 100644 index 0000000..1b4fd00 --- /dev/null +++ b/TorchRemote.Models/Trash/IsExternalInit.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("TorchRemote.Plugin")] +// ReSharper disable once CheckNamespace +namespace System.Runtime.CompilerServices; +internal class IsExternalInit { } \ No newline at end of file diff --git a/TorchRemote.Plugin/Config.cs b/TorchRemote.Plugin/Config.cs new file mode 100644 index 0000000..ccad03f --- /dev/null +++ b/TorchRemote.Plugin/Config.cs @@ -0,0 +1,17 @@ +using Torch; +using Torch.Views; + +namespace TorchRemote.Plugin; + +public class Config : ViewModel +{ + [Display(Name = "Web Server Config", Description = "Basic configuration for serving web api.")] + public ListenerConfig Listener { get; set; } = new(); + public string SecurityKey { get; set; } = string.Empty; +} + +public class ListenerConfig : ViewModel +{ + [Display(Name = "Url Prefix", Description = "Root url for all requests. If you want access server from remote replace + with your public ip or domain.")] + public string UrlPrefix { get; set; } = "http://+:80/"; +} diff --git a/TorchRemote.Plugin/Controllers/ChatController.cs b/TorchRemote.Plugin/Controllers/ChatController.cs new file mode 100644 index 0000000..84b90bd --- /dev/null +++ b/TorchRemote.Plugin/Controllers/ChatController.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Reflection; +using System.Text.RegularExpressions; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using EmbedIO.WebSockets; +using Sandbox.Engine.Multiplayer; +using Sandbox.Game.Multiplayer; +using Sandbox.Game.World; +using Torch.API; +using Torch.API.Managers; +using Torch.API.Plugins; +using Torch.Commands; +using Torch.Managers; +using Torch.Utils; +using TorchRemote.Models.Requests; +using TorchRemote.Models.Responses; +using TorchRemote.Models.Shared; +using TorchRemote.Plugin.Modules; +using TorchRemote.Plugin.Utils; +using VRage.Network; +namespace TorchRemote.Plugin.Controllers; + +public class ChatController : WebApiController +{ + private const string RootPath = "/chat"; + + [ReflectedMethodInfo(typeof(MyMultiplayerBase), "OnChatMessageReceived_BroadcastExcept")] + private static readonly MethodInfo BroadcastExceptMethod = null!; + [ReflectedMethodInfo(typeof(MyMultiplayerBase), "OnChatMessageReceived_SingleTarget")] + private static readonly MethodInfo SingleTargetMethod = null!; + + [Route(HttpVerbs.Post, $"{RootPath}/message")] + public void SendMessage([JsonData] ChatMessageRequest request) + { + if (MyMultiplayer.Static is null) + throw new HttpException(HttpStatusCode.ServiceUnavailable); + + var msg = new ChatMsg + { + CustomAuthorName = request.Author, + Text = request.Message, + Channel = (byte)request.Channel, + TargetId = request.TargetId.GetValueOrDefault() + }; + + switch (request.Channel) + { + case ChatChannel.Global: + case ChatChannel.GlobalScripted when request.TargetId is null: + NetworkManager.RaiseStaticEvent(BroadcastExceptMethod, msg); + break; + + case ChatChannel.Private when request.TargetId is not null: + case ChatChannel.GlobalScripted: + var steamId = Sync.Players.TryGetSteamId(request.TargetId.Value); + if (steamId == 0) + throw HttpException.NotFound($"Unable to find player with identity id {request.TargetId.Value}", request.TargetId.Value); + + NetworkManager.RaiseStaticEvent(SingleTargetMethod, msg, new(steamId)); + break; + + case ChatChannel.Faction when request.TargetId is not null: + var faction = MySession.Static.Factions.TryGetFactionById(request.TargetId.Value); + if (faction is null) + throw HttpException.NotFound($"Unable to find faction with id {request.TargetId.Value}", request.TargetId.Value); + + foreach (var playerId in faction.Members.Keys.Where(Sync.Players.IsPlayerOnline)) + { + NetworkManager.RaiseStaticEvent(SingleTargetMethod, msg, new(Sync.Players.TryGetSteamId(playerId))); + } + break; + + default: + throw HttpException.BadRequest("Invalid channel and targetId combination"); + } + + if (Statics.Torch.CurrentSession?.Managers.GetManager() is { } manager) + manager.DisplayMessageOnSelf(request.Author, request.Message); + } + + [Route(HttpVerbs.Post, $"{RootPath}/command")] + public async Task InvokeCommand([JsonData] ChatCommandRequest request) + { + if (Statics.CommandManager is null) + throw new HttpException(HttpStatusCode.ServiceUnavailable); + + if (Statics.CommandManager.Commands.GetCommand(request.Command, out var argText) is not { } command) + throw HttpException.NotFound($"Unable to find command {request.Command}", request.Command); + + var argsList = Regex.Matches(argText, "(\"[^\"]+\"|\\S+)").Cast().Select(x => x.ToString().Replace("\"", "")).ToList(); + + var id = new Guid(); + var context = new WebSocketCommandContext(Statics.Torch, command.Plugin, argText, argsList, Statics.ChatModule, id); + + if (await Statics.Torch.InvokeAsync(() => command.TryInvoke(context))) + return id; + + throw HttpException.BadRequest("Invalid syntax", request.Command); + } +} + +internal class WebSocketCommandContext : CommandContext +{ + private readonly ChatModule _module; + private readonly Guid _id; + public WebSocketCommandContext(ITorchBase torch, ITorchPlugin plugin, string rawArgs, List args, ChatModule module, Guid id) : base(torch, plugin, Sync.MyId, rawArgs, args) + { + _module = module; + _id = id; + } + + public override void Respond(string message, string? sender = null, string? font = null) + { + _module.SendChatResponse(new ChatCommandResponse(_id, sender ?? Torch.Config.ChatName, message)); + } +} \ No newline at end of file diff --git a/TorchRemote.Plugin/Controllers/ServerController.cs b/TorchRemote.Plugin/Controllers/ServerController.cs new file mode 100644 index 0000000..177544b --- /dev/null +++ b/TorchRemote.Plugin/Controllers/ServerController.cs @@ -0,0 +1,76 @@ +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using Sandbox.Game.Multiplayer; +using Sandbox.Game.World; +using Torch.API.Session; +using TorchRemote.Models.Requests; +using TorchRemote.Models.Responses; +using TorchRemote.Models.Shared; +using TorchRemote.Plugin.Utils; + +namespace TorchRemote.Plugin.Controllers; + +public class ServerController : WebApiController +{ + private const string RootPath = "/server"; + + [Route(HttpVerbs.Get, $"{RootPath}/status")] + public ServerStatusResponse GetStatus() + { + return new(Math.Round(Sync.ServerSimulationRatio, 2), + MySession.Static?.Players?.GetOnlinePlayerCount() ?? 0, + Statics.Torch.ElapsedPlayTime, + (ServerStatus)Statics.Torch.State); + } + + [Route(HttpVerbs.Post, $"{RootPath}/start")] + public void Start() + { + if (!Statics.Torch.CanRun) + throw HttpException.BadRequest($"Server can't start in state {Statics.Torch.State}", Statics.Torch.State); + + Statics.Torch.Start(); + } + + [Route(HttpVerbs.Post, $"{RootPath}/stop")] + public async Task Stop(StopServerRequest request) + { + if (!Statics.Torch.IsRunning) + throw HttpException.BadRequest($"Server can't stop in state {Statics.Torch.State}", Statics.Torch.State); + + var saveResult = await Statics.Torch.Save(exclusive: true); + if (saveResult is not GameSaveResult.Success) + throw HttpException.InternalServerError($"Save resulted in {saveResult}", saveResult); + + Statics.Torch.Stop(); + } + + [Route(HttpVerbs.Get, $"{RootPath}/settings")] + public ServerSettings GetSettings() + { + var settings = Statics.Torch.DedicatedInstance.DedicatedConfig; + + return new(settings.ServerName ?? "unamed", + settings.WorldName ?? "unamed", + settings.ServerDescription ?? string.Empty, + settings.SessionSettings.MaxPlayers, + new(settings.IP, settings.Port)); + } + + [Route(HttpVerbs.Post, $"{RootPath}/settings")] + public async Task SetSettings([JsonData] ServerSettings request) + { + var settings = Statics.Torch.DedicatedInstance.DedicatedConfig; + + settings.ServerName = request.ServerName; + settings.WorldName = request.MapName; + settings.ServerDescription = request.ServerDescription; + settings.SessionSettings.MaxPlayers = request.MemberLimit; + settings.IP = request.ListenEndPoint.Ip; + settings.Port = request.ListenEndPoint.Port; + + if (Statics.Torch.IsRunning) + await Statics.Torch.InvokeAsync(request.ApplyDynamically); + } +} diff --git a/TorchRemote.Plugin/Controllers/SettingsController.cs b/TorchRemote.Plugin/Controllers/SettingsController.cs new file mode 100644 index 0000000..9884cb0 --- /dev/null +++ b/TorchRemote.Plugin/Controllers/SettingsController.cs @@ -0,0 +1,24 @@ +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using Swan; +using TorchRemote.Models.Responses; +using TorchRemote.Plugin.Utils; +namespace TorchRemote.Plugin.Controllers; + +public class SettingsController : WebApiController +{ + private const string RootPath = "/settings"; + + [Route(HttpVerbs.Get, $"{RootPath}/{{id}}")] + public SettingInfoResponse Get(Guid id) + { + if (!Statics.SettingManager.Settings.TryGetValue(id, out var setting)) + throw HttpException.NotFound($"Setting with id {id} not found", id); + + return new(setting.Name.Humanize(), setting.Properties.Select(b => + new SettingPropertyInfo(b.DisplayInfo?.Name ?? b.Name.Humanize(), + b.DisplayInfo?.Description, b.DisplayInfo?.Order, b.TypeId)) + .ToArray()); + } +} diff --git a/TorchRemote.Plugin/Controllers/WorldsController.cs b/TorchRemote.Plugin/Controllers/WorldsController.cs new file mode 100644 index 0000000..166a750 --- /dev/null +++ b/TorchRemote.Plugin/Controllers/WorldsController.cs @@ -0,0 +1,62 @@ +using System.Net; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using TorchRemote.Models.Responses; +using TorchRemote.Plugin.Utils; +namespace TorchRemote.Plugin.Controllers; + +public class WorldsController : WebApiController +{ + private const string RootPath = "/worlds"; + + [Route(HttpVerbs.Get, RootPath)] + public IEnumerable Get() + { + var config = Statics.InstanceManager.DedicatedConfig; + + if (config is null) + throw new HttpException(HttpStatusCode.ServiceUnavailable); + + return config.Worlds.Select(b => b.FolderName.ToGuid()); + } + + [Route(HttpVerbs.Get, $"{RootPath}/selected")] + public Guid GetSelected() + { + if (Statics.InstanceManager.DedicatedConfig?.SelectedWorld is not { } world) + throw new HttpException(HttpStatusCode.ServiceUnavailable); + + return world.FolderName.ToGuid(); + } + + [Route(HttpVerbs.Get, $"{RootPath}/{{id}}")] + public WorldResponse GetWorld(Guid id) + { + var config = Statics.InstanceManager.DedicatedConfig; + + if (config is null) + throw new HttpException(HttpStatusCode.ServiceUnavailable); + + if (config.Worlds.FirstOrDefault(b => b.FolderName.ToGuid() == id) is not { } world) + throw HttpException.NotFound($"World not found by given id {id}", id); + + return new(world.FolderName, world.WorldSizeKB); + } + + [Route(HttpVerbs.Post, $"{RootPath}/{{id}}/select")] + public void Select(Guid id) + { + var config = Statics.InstanceManager.DedicatedConfig; + + if (config is null) + throw new HttpException(HttpStatusCode.ServiceUnavailable); + + if (config.Worlds.FirstOrDefault(b => b.FolderName.ToGuid() == id) is not { } world) + throw HttpException.NotFound($"World not found by given id {id}", id); + + config.Model.IgnoreLastSession = true; + config.SelectedWorld = world; + config.Save(); + } +} diff --git a/TorchRemote.Plugin/FodyWeavers.xml b/TorchRemote.Plugin/FodyWeavers.xml new file mode 100644 index 0000000..d5abfed --- /dev/null +++ b/TorchRemote.Plugin/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/TorchRemote.Plugin/FodyWeavers.xsd b/TorchRemote.Plugin/FodyWeavers.xsd new file mode 100644 index 0000000..69dbe48 --- /dev/null +++ b/TorchRemote.Plugin/FodyWeavers.xsd @@ -0,0 +1,74 @@ + + + + + + + + + + + Used to control if the On_PropertyName_Changed feature is enabled. + + + + + Used to control if the Dependent properties feature is enabled. + + + + + Used to control if the IsChanged property feature is enabled. + + + + + Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form. + + + + + Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project. + + + + + Used to control if equality checks should use the Equals method resolved from the base class. + + + + + Used to control if equality checks should use the static Equals method resolved from the base class. + + + + + Used to turn off build warnings from this weaver. + + + + + Used to turn off build warnings about mismatched On_PropertyName_Changed methods. + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/TorchRemote.Plugin/Managers/ApiServerManager.cs b/TorchRemote.Plugin/Managers/ApiServerManager.cs new file mode 100644 index 0000000..52a9731 --- /dev/null +++ b/TorchRemote.Plugin/Managers/ApiServerManager.cs @@ -0,0 +1,95 @@ +using System.Security.Cryptography; +using System.Text.Json; +using EmbedIO; +using EmbedIO.BearerToken; +using EmbedIO.WebApi; +using Microsoft.IdentityModel.Tokens; +using NLog; +using Torch.API; +using Torch.API.Managers; +using Torch.Managers; +using Torch.Server.Managers; +using TorchRemote.Plugin.Controllers; +using TorchRemote.Plugin.Modules; +using TorchRemote.Plugin.Utils; +using VRage.Game.ModAPI; +namespace TorchRemote.Plugin.Managers; + +public class ApiServerManager : Manager +{ + private static readonly ILogger Log = LogManager.GetCurrentClassLogger(); + + private readonly Config _config; + private readonly IWebServer _server; + [Dependency] + private readonly SettingManager _settingManager = null!; + [Dependency] + private readonly InstanceManager _instanceManager = null!; + public ApiServerManager(ITorchBase torchInstance, Config config) : base(torchInstance) + { + _config = config; + + if (string.IsNullOrEmpty(_config.SecurityKey)) + _config.SecurityKey = CreateSecurityKey(); + + var apiModule = new WebApiModule("/api/v1", async (context, data) => + { + try + { + context.Response.ContentType = "application/json"; + using var stream = context.OpenResponseStream(); + await JsonSerializer.SerializeAsync(stream, data, Statics.SerializerOptions); + } + catch (Exception e) + { + Log.Error(e); + throw HttpException.InternalServerError(e.Message, e.Message); + } + }); + + var chatModule = new ChatModule("/api/live/chat", true); + Statics.ChatModule = chatModule; + + _server = new WebServer(o => o + .WithUrlPrefix(_config.Listener.UrlPrefix) + .WithMicrosoftHttpListener()) + .WithLocalSessionManager() + .WithModule(apiModule + .WithController() + .WithController() + .WithController() + .WithController()) + .WithModule(new LogsModule("/api/live/logs", true)) + .WithModule(chatModule) + .WithBearerToken("/api", new SymmetricSecurityKey(Convert.FromBase64String(_config.SecurityKey)), new BasicAuthorizationServerProvider()); + } + + public override void Attach() + { + base.Attach(); + + Starter(); + Log.Info("Listening on {0}", _config.Listener.UrlPrefix); + + //_instanceManager.InstanceLoaded += model => _settingManager.RegisterSetting(model.Model, typeof(IMyConfigDedicated), false); + } + public override void Detach() + { + base.Detach(); + _server.Dispose(); + } + + private async void Starter() + { + await _server.RunAsync(); + } + + private static string CreateSecurityKey() + { + var aes = Aes.Create(); + aes.GenerateIV(); + aes.GenerateKey(); + + return Convert.ToBase64String(aes.Key); + } +} diff --git a/TorchRemote.Plugin/Managers/ChatMonitorManager.cs b/TorchRemote.Plugin/Managers/ChatMonitorManager.cs new file mode 100644 index 0000000..72090fb --- /dev/null +++ b/TorchRemote.Plugin/Managers/ChatMonitorManager.cs @@ -0,0 +1,28 @@ +using Sandbox.Engine.Multiplayer; +using Torch.API; +using Torch.API.Managers; +using Torch.Managers; +using TorchRemote.Models.Responses; +using TorchRemote.Models.Shared; +using TorchRemote.Plugin.Utils; +namespace TorchRemote.Plugin.Managers; + +public class ChatMonitorManager : Manager +{ + [Dependency] + private readonly IChatManagerServer _chatManager = null!; + public ChatMonitorManager(ITorchBase torchInstance) : base(torchInstance) + { + } + + public override void Attach() + { + base.Attach(); + _chatManager.MessageRecieved += ChatManagerOnMessageReceived; + } + private void ChatManagerOnMessageReceived(TorchChatMessage msg, ref bool consumed) + { + Statics.ChatModule.SendChatResponse(new ChatMessageResponse(msg.Author ?? (msg.AuthorSteamId is null ? Torch.Config.ChatName : MyMultiplayer.Static.GetMemberName(msg.AuthorSteamId.Value)), + msg.AuthorSteamId, (ChatChannel)msg.Channel, msg.Message)); + } +} diff --git a/TorchRemote.Plugin/Managers/SettingManager.cs b/TorchRemote.Plugin/Managers/SettingManager.cs new file mode 100644 index 0000000..20c71ef --- /dev/null +++ b/TorchRemote.Plugin/Managers/SettingManager.cs @@ -0,0 +1,77 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Text.Json.Serialization; +using System.Xml.Serialization; +using NLog; +using Torch.API; +using Torch.Managers; +using Torch.Views; +using TorchRemote.Models.Responses; +using TorchRemote.Plugin.Utils; +using VRage; +namespace TorchRemote.Plugin.Managers; + +public class SettingManager : Manager +{ + private static readonly ILogger Log = LogManager.GetCurrentClassLogger(); + + public SettingManager(ITorchBase torchInstance) : base(torchInstance) + { + } + + public Guid RegisterSetting(object value, Type type, bool includeOnlyDisplay = true) + { + var properties = type.IsInterface ? type.GetProperties() : type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + var settingProperties = properties + .Where(b => !b.HasAttribute() && + !b.HasAttribute() && + (!includeOnlyDisplay || + b.HasAttribute())) + .Select(property => new SettingProperty(property.Name, + GetTypeId(property.PropertyType, property.GetValue(value), includeOnlyDisplay), + property.PropertyType, property.GetMethod, property.SetMethod, + property.GetCustomAttribute() is { } attr ? + new(attr.Name, attr.Description, attr.GroupName, attr.Order, attr.ReadOnly, attr.Enabled) : + null)) + .ToArray(); + + var setting = new Setting(type.Name, type, settingProperties, value); + + var id = (type.FullName! + value.GetHashCode()).ToGuid(); + Settings.Add(id, setting); + Log.Debug("Registered type {0} with id {1}", type, id); + return id; + } + + private Guid GetTypeId(Type type, object value, bool includeOnlyDisplay) + { + if (type == typeof(int) || type == typeof(uint)) + return SettingPropertyTypeEnum.Integer; + if (type == typeof(bool)) + return SettingPropertyTypeEnum.Boolean; + if (type == typeof(short) || + type == typeof(ushort) || + type == typeof(byte) || + type == typeof(ulong) || + type == typeof(long) || + type == typeof(float) || + type == typeof(double) || + type == typeof(MyFixedPoint)) + return SettingPropertyTypeEnum.Number; + if (type == typeof(string)) + return SettingPropertyTypeEnum.String; + if (type == typeof(DateTime)) + return SettingPropertyTypeEnum.DateTime; + if (type == typeof(TimeSpan)) + return SettingPropertyTypeEnum.TimeSpan; + if (type == typeof(System.Drawing.Color) || type == typeof(VRageMath.Color)) + return SettingPropertyTypeEnum.Color; + return RegisterSetting(value, type, includeOnlyDisplay); + } + + public IDictionary Settings { get; } = new ConcurrentDictionary(); +} + +public record Setting(string Name, Type Type, IEnumerable Properties, object? Value = null); +public record SettingProperty(string Name, Guid TypeId, Type Type, MethodInfo Getter, MethodInfo? Setter, SettingPropertyDisplayInfo? DisplayInfo); +public record SettingPropertyDisplayInfo(string? Name, string? Description, string? GroupName, int? Order, bool? IsReadOnly, bool? IsEnabled); diff --git a/TorchRemote.Plugin/Modules/ChatModule.cs b/TorchRemote.Plugin/Modules/ChatModule.cs new file mode 100644 index 0000000..8ff9b54 --- /dev/null +++ b/TorchRemote.Plugin/Modules/ChatModule.cs @@ -0,0 +1,28 @@ +using System.Net.WebSockets; +using System.Text.Json; +using EmbedIO.WebSockets; +using TorchRemote.Models.Responses; +using TorchRemote.Plugin.Utils; +namespace TorchRemote.Plugin.Modules; + +public class ChatModule : WebSocketModule +{ + public ChatModule(string urlPath, bool enableConnectionWatchdog) : base(urlPath, enableConnectionWatchdog) + { + } + + public async void SendChatResponse(ChatResponseBase response) + { + if (ActiveContexts.Count == 0) + return; + + var buffer = JsonSerializer.SerializeToUtf8Bytes(response, Statics.SerializerOptions); + await Task.WhenAll(ActiveContexts + .Where(b => b.WebSocket.State is WebSocketState.Open) + .Select(context => context.WebSocket.SendAsync(buffer, true))); + } + + protected override async Task OnMessageReceivedAsync(IWebSocketContext context, byte[] buffer, IWebSocketReceiveResult result) + { + } +} diff --git a/TorchRemote.Plugin/Modules/LogsModule.cs b/TorchRemote.Plugin/Modules/LogsModule.cs new file mode 100644 index 0000000..de30a76 --- /dev/null +++ b/TorchRemote.Plugin/Modules/LogsModule.cs @@ -0,0 +1,85 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using EmbedIO.WebSockets; +using NLog; +using NLog.Targets; +using NLog.Targets.Wrappers; +using Torch.Server; +using TorchRemote.Models.Responses; +using TorchRemote.Plugin.Utils; +namespace TorchRemote.Plugin.Modules; + +public class LogsModule : WebSocketModule +{ + + public LogsModule(string urlPath, bool enableConnectionWatchdog) : base(urlPath, enableConnectionWatchdog) + { + ConfigureLogging(); + } + protected override async Task OnMessageReceivedAsync(IWebSocketContext context, byte[] buffer, IWebSocketReceiveResult result) + { + } + + public async void OnLogMessageReceived(DateTime time, LogLevel level, string logger, string message) + { + if (ActiveContexts.Count == 0) + return; + + var response = new LogLineResponse(time, (LogLineLevel)level.Ordinal, logger, message); + var buffer = JsonSerializer.SerializeToUtf8Bytes(response, Statics.SerializerOptions); + + await Task.WhenAll(ActiveContexts + .Where(b => b.WebSocket.State is WebSocketState.Open) + .Select(context => context.WebSocket.SendAsync(buffer, true))); + } + + private void ConfigureLogging() + { + var cfg = LogManager.Configuration; + var flowDocumentTarget = cfg.FindTargetByName("wpf"); + + if (flowDocumentTarget is null or SplitGroupTarget) + return; + + flowDocumentTarget.Name = "wpf-old"; + + var target = new SplitGroupTarget + { + Name = "wpf", + Targets = + { + flowDocumentTarget, + new StupidTarget(this) + } + }; + + cfg.RemoveTarget("wpf"); + cfg.AddTarget(target); + foreach (var rule in cfg.LoggingRules) + { + if (rule.Targets.Remove(flowDocumentTarget)) + rule.Targets.Add(target); + } + LogManager.Configuration = cfg; + LogManager.GetCurrentClassLogger().Info("Reconfigured logging"); + } + + private class StupidTarget : Target + { + private readonly LogsModule _module; + public StupidTarget(LogsModule module) + { + _module = module; + } + + protected override void Write(LogEventInfo logEvent) + { + var message = logEvent.FormattedMessage; + if (logEvent.Exception is not null) + message += $"\n{logEvent.Exception}"; + + _module.OnLogMessageReceived(logEvent.TimeStamp, logEvent.Level, logEvent.LoggerName, message); + } + } +} diff --git a/TorchRemote.Plugin/Plugin.cs b/TorchRemote.Plugin/Plugin.cs new file mode 100644 index 0000000..b76e23b --- /dev/null +++ b/TorchRemote.Plugin/Plugin.cs @@ -0,0 +1,33 @@ +using System.IO; +using System.Windows.Controls; +using Torch; +using Torch.API; +using Torch.API.Managers; +using Torch.API.Plugins; +using Torch.API.Session; +using Torch.Views; +using TorchRemote.Plugin.Managers; + +namespace TorchRemote.Plugin; + +public class Plugin : TorchPluginBase, IWpfPlugin +{ + private Persistent _config = null!; + + public override void Init(ITorchBase torch) + { + base.Init(torch); + _config = Persistent.Load(Path.Combine(StoragePath, "TorchRemote.cfg")); + + Torch.Managers.AddManager(new ApiServerManager(Torch, _config.Data)); + Torch.Managers.AddManager(new SettingManager(Torch)); + Torch.Managers.GetManager() + .AddFactory(s => new ChatMonitorManager(s.Torch)); + } + + public UserControl GetControl() => new PropertyGrid + { + Margin = new(3), + DataContext = _config.Data + }; +} diff --git a/TorchRemote.Plugin/TorchRemote.Plugin.csproj b/TorchRemote.Plugin/TorchRemote.Plugin.csproj new file mode 100644 index 0000000..b45d623 --- /dev/null +++ b/TorchRemote.Plugin/TorchRemote.Plugin.csproj @@ -0,0 +1,99 @@ + + + + net48 + enable + enable + x64 + 10 + true + $(SolutionDir)TorchBinaries\ + + + + none + + + + + $(TorchDir)NLog.dll + False + + + $(TorchDir)DedicatedServer64\Sandbox.Common.dll + False + + + $(TorchDir)DedicatedServer64\Sandbox.Game.dll + False + + + $(TorchDir)DedicatedServer64\Sandbox.Graphics.dll + False + + + $(TorchDir)DedicatedServer64\System.Memory.dll + False + + + + $(TorchDir)Torch.dll + False + + + $(TorchDir)Torch.API.dll + False + + + $(TorchDir)Torch.Server.exe + False + + + $(TorchDir)DedicatedServer64\VRage.dll + False + + + $(TorchDir)DedicatedServer64\VRage.Game.dll + False + + + $(TorchDir)DedicatedServer64\VRage.Input.dll + False + + + $(TorchDir)DedicatedServer64\VRage.Library.dll + False + + + $(TorchDir)DedicatedServer64\VRage.Math.dll + False + + + $(TorchDir)DedicatedServer64\VRage.Network.dll + False + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + + + + diff --git a/TorchRemote.Plugin/Utils/Extensions.cs b/TorchRemote.Plugin/Utils/Extensions.cs new file mode 100644 index 0000000..253d845 --- /dev/null +++ b/TorchRemote.Plugin/Utils/Extensions.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using System.Text; +using Sandbox.Engine.Multiplayer; +using Sandbox.Engine.Networking; +using Sandbox.Game; +using Sandbox.Game.World; +using TorchRemote.Models.Shared; +namespace TorchRemote.Plugin.Utils; + +public static class Extensions +{ + public static void ApplyDynamically(this ServerSettings settings) + { + MyGameService.GameServer.SetServerName(settings.ServerName); + + MyMultiplayer.Static.HostName = settings.ServerName; + MyMultiplayer.Static.WorldName = settings.MapName; + MySession.Static.Name = settings.MapName; + MyMultiplayer.Static.MemberLimit = settings.MemberLimit; + + MyCachedServerItem.SendSettingsToSteam(); + } + + public static Guid ToGuid(this string s) + { + using var md5 = MD5.Create(); + return new(md5.ComputeHash(Encoding.UTF8.GetBytes(s))); + } +} diff --git a/TorchRemote.Plugin/Utils/JsonDataAttribute.cs b/TorchRemote.Plugin/Utils/JsonDataAttribute.cs new file mode 100644 index 0000000..a1bce52 --- /dev/null +++ b/TorchRemote.Plugin/Utils/JsonDataAttribute.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using EmbedIO; +using EmbedIO.WebApi; +namespace TorchRemote.Plugin.Utils; + +/// +/// Specifies that a parameter of a controller method will receive +/// an object obtained by deserializing the request body as JSON. +/// The received object will be +/// only if the deserialized object is null. +/// If the request body is not valid JSON, +/// or if it cannot be deserialized to the type of the parameter, +/// a 400 Bad Request response will be sent to the client. +/// This class cannot be inherited. +/// +/// +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class JsonDataAttribute : Attribute, IRequestDataAttribute +{ + /// + public async Task GetRequestDataAsync(WebApiController controller, Type type, string parameterName) + { + try + { + using var stream = controller.HttpContext.OpenRequestStream(); + return await JsonSerializer.DeserializeAsync(stream, type, Statics.SerializerOptions); + } + catch (JsonException) + { + throw HttpException.BadRequest($"Expected request body to be deserializable to {type.FullName}."); + } + } +} diff --git a/TorchRemote.Plugin/Utils/Statics.cs b/TorchRemote.Plugin/Utils/Statics.cs new file mode 100644 index 0000000..30a436e --- /dev/null +++ b/TorchRemote.Plugin/Utils/Statics.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using Torch; +using Torch.API; +using Torch.API.Managers; +using Torch.Commands; +using Torch.Server; +using Torch.Server.Managers; +using TorchRemote.Plugin.Managers; +using TorchRemote.Plugin.Modules; +namespace TorchRemote.Plugin.Utils; + +internal static class Statics +{ +#pragma warning disable CS0618 + public static TorchServer Torch => (TorchServer)TorchBase.Instance; +#pragma warning restore CS0618 + + public static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + public static SettingManager SettingManager => Torch.Managers.GetManager(); + public static InstanceManager InstanceManager => Torch.Managers.GetManager(); + public static CommandManager? CommandManager => Torch.CurrentSession?.Managers.GetManager(); + + public static ChatModule ChatModule = null!; +} diff --git a/TorchRemote.Plugin/manifest.xml b/TorchRemote.Plugin/manifest.xml new file mode 100644 index 0000000..ecc37c2 --- /dev/null +++ b/TorchRemote.Plugin/manifest.xml @@ -0,0 +1,6 @@ + + + TorchRemote.Plugin + 284017F3-9682-4841-A544-EB04DB8CB9BA + v1.0.0 + \ No newline at end of file diff --git a/TorchRemote.Plugin/setup.bat b/TorchRemote.Plugin/setup.bat new file mode 100644 index 0000000..ed0572f --- /dev/null +++ b/TorchRemote.Plugin/setup.bat @@ -0,0 +1,20 @@ +:: This script creates a symlink to the game binaries to account for different installation directories on different systems. + +@echo off + +set /p path="Please enter the folder location of your Torch.Server.exe: " +cd %~dp0 +rmdir TorchBinaries > nul 2>&1 +mklink /J ..\TorchBinaries "%path%" +if errorlevel 1 goto Error +echo Done! + +echo You can now open the plugin without issue. +goto EndFinal + +:Error +echo An error occured creating the symlink. +goto EndFinal + +:EndFinal +pause diff --git a/TorchRemote.sln b/TorchRemote.sln new file mode 100644 index 0000000..6e22f8c --- /dev/null +++ b/TorchRemote.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TorchRemote", "TorchRemote\TorchRemote.csproj", "{AFCBEFA1-A827-43C8-B777-A1548FE4BE89}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TorchRemote.Models", "TorchRemote.Models\TorchRemote.Models.csproj", "{FC44F0B6-A2A3-4DA7-A900-4BAC69AEA529}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TorchRemote.Plugin", "TorchRemote.Plugin\TorchRemote.Plugin.csproj", "{054A416F-F106-4E74-98A0-29566F9161C1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AFCBEFA1-A827-43C8-B777-A1548FE4BE89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AFCBEFA1-A827-43C8-B777-A1548FE4BE89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFCBEFA1-A827-43C8-B777-A1548FE4BE89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AFCBEFA1-A827-43C8-B777-A1548FE4BE89}.Release|Any CPU.Build.0 = Release|Any CPU + {FC44F0B6-A2A3-4DA7-A900-4BAC69AEA529}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC44F0B6-A2A3-4DA7-A900-4BAC69AEA529}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC44F0B6-A2A3-4DA7-A900-4BAC69AEA529}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC44F0B6-A2A3-4DA7-A900-4BAC69AEA529}.Release|Any CPU.Build.0 = Release|Any CPU + {054A416F-F106-4E74-98A0-29566F9161C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {054A416F-F106-4E74-98A0-29566F9161C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {054A416F-F106-4E74-98A0-29566F9161C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {054A416F-F106-4E74-98A0-29566F9161C1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/TorchRemote/App.axaml b/TorchRemote/App.axaml new file mode 100644 index 0000000..255fba4 --- /dev/null +++ b/TorchRemote/App.axaml @@ -0,0 +1,9 @@ + + + + + diff --git a/TorchRemote/App.axaml.cs b/TorchRemote/App.axaml.cs new file mode 100644 index 0000000..66057c2 --- /dev/null +++ b/TorchRemote/App.axaml.cs @@ -0,0 +1,42 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using ReactiveUI; +using Splat; +using TorchRemote.ViewModels; +using TorchRemote.ViewModels.Server; +using TorchRemote.Views; +using TorchRemote.Views.Server; + +namespace TorchRemote +{ + public partial class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + Locator.CurrentMutable.RegisterConstant(new MainWindowViewModel()); + + Locator.CurrentMutable.Register>(() => new RemoteServerView()); + Locator.CurrentMutable.Register>(() => new DashboardView()); + Locator.CurrentMutable.Register>(() => new ServerConfigView()); + Locator.CurrentMutable.Register>(() => new ChatView()); + Locator.CurrentMutable.Register>(() => new PlayersView()); + Locator.CurrentMutable.Register>(() => new SettingsView()); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = Locator.Current.GetService(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/TorchRemote/Assets/avalonia-logo.ico b/TorchRemote/Assets/avalonia-logo.ico new file mode 100644 index 0000000..da8d49f Binary files /dev/null and b/TorchRemote/Assets/avalonia-logo.ico differ diff --git a/TorchRemote/Assets/torchicon.ico b/TorchRemote/Assets/torchicon.ico new file mode 100644 index 0000000..4563964 Binary files /dev/null and b/TorchRemote/Assets/torchicon.ico differ diff --git a/TorchRemote/Controls/Flyouts/MessageConfirmDismissFlyout.cs b/TorchRemote/Controls/Flyouts/MessageConfirmDismissFlyout.cs new file mode 100644 index 0000000..d5e07de --- /dev/null +++ b/TorchRemote/Controls/Flyouts/MessageConfirmDismissFlyout.cs @@ -0,0 +1,65 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Controls.Primitives; +namespace TorchRemote.Controls.Flyouts; +#nullable disable +public class MessageConfirmDismissFlyout : PickerFlyoutBase +{ + public static readonly DirectProperty ResultCommandProperty = + AvaloniaProperty.RegisterDirect(nameof(ResultCommand), flyout => flyout._command!, + (flyout, command) => flyout._command = command); + + public static readonly DirectProperty MessageProperty = + AvaloniaProperty.RegisterDirect(nameof(Message), flyout => flyout._message, + (flyout, s) => flyout._message = s); + + public string Message + { + get => _message; + set => SetAndRaise(MessageProperty, ref _message, value); + } + + public ICommand ResultCommand + { + get => _command; + set => SetAndRaise(ResultCommandProperty, ref _command, value); + } + + private ICommand _command; + private string _message; + protected override Control CreatePresenter() + { + var pfp = new PickerFlyoutPresenter + { + Content = new TextBlock + { + Text = _message, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Margin = new(3) + } + }; + + pfp.Confirmed += PfpOnConfirmed; + pfp.Dismissed += PfpOnDismissed; + + return pfp; + } + private void PfpOnDismissed(PickerFlyoutPresenter sender, object args) + { + ResultCommand?.Execute(false); + Hide(); + } + private void PfpOnConfirmed(PickerFlyoutPresenter sender, object args) + { + OnConfirmed(); + Hide(); + } + protected override void OnConfirmed() + { + ResultCommand?.Execute(true); + } +} diff --git a/TorchRemote/FodyWeavers.xml b/TorchRemote/FodyWeavers.xml new file mode 100644 index 0000000..63fc148 --- /dev/null +++ b/TorchRemote/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/TorchRemote/Program.cs b/TorchRemote/Program.cs new file mode 100644 index 0000000..9ee2a43 --- /dev/null +++ b/TorchRemote/Program.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.ReactiveUI; +using System; + +namespace TorchRemote +{ + class Program + { + // Initialization code. 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] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace() + .UseReactiveUI(); + } +} diff --git a/TorchRemote/Services/ApiClientService.cs b/TorchRemote/Services/ApiClientService.cs new file mode 100644 index 0000000..6749460 --- /dev/null +++ b/TorchRemote/Services/ApiClientService.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using TorchRemote.Models.Requests; +using TorchRemote.Models.Responses; +using TorchRemote.Models.Shared; +using Websocket.Client; +namespace TorchRemote.Services; + +public class ApiClientService +{ + public const string Version = "v1"; + public string BearerToken + { + get => _client.DefaultRequestHeaders.Authorization?.Parameter ?? "*****"; + set => _client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse($"Bearer {value}"); + } + private readonly HttpClient _client = new(); + public string BaseUrl + { + get => _client.BaseAddress?.ToString() ?? "http://localhost"; + set => _client.BaseAddress = new($"{value}/api/{Version}/"); + } + + public event EventHandler? Connected; + + public ApiClientService() + { + Task.Run(ConnectionTimer); + } + + private async Task ConnectionTimer() + { + while (true) + { + await Task.Delay(1000); + try + { + await GetServerStatusAsync(CancellationToken.None); + break; + } + catch + { + } + } + + Connected?.Invoke(this, EventArgs.Empty); + } + + public Task GetServerStatusAsync(CancellationToken token) => + _client.GetFromJsonAsync("server/status", token)!; + + public Task GetServerSettingsAsync(CancellationToken token) => + _client.GetFromJsonAsync("server/settings", token)!; + + public Task SetServerSettingsAsync(ServerSettings settings, CancellationToken token) => + _client.PostAsJsonAsync("server/settings", settings, token); + + public Task StartServerAsync(CancellationToken token) => + _client.PostAsync("server/start", null, token); + + public Task StopServerAsync(StopServerRequest request, CancellationToken token) => + _client.PostAsJsonAsync("server/stop", request, token); + + public Task> GetWorldsAsync(CancellationToken token) => + _client.GetFromJsonAsync>("worlds", token)!; + + public Task GetWorldAsync(Guid id, CancellationToken token) => + _client.GetFromJsonAsync($"worlds/{id}", token)!; + + public Task GetSelectedWorld(CancellationToken token) => + _client.GetFromJsonAsync("worlds/selected", token); + + public Task SelectWorldAsync(Guid id, CancellationToken token) => + _client.PostAsync($"worlds/{id}/select", null, token); + + public Task SendChatMessageAsync(ChatMessageRequest request, CancellationToken token) => + _client.PostAsJsonAsync("chat/message", request, token); + + public async Task InvokeCommandAsync(ChatCommandRequest request, CancellationToken token) + { + var r = await _client.PostAsJsonAsync("chat/command", request, token); + r.EnsureSuccessStatusCode(); + return await r.Content.ReadFromJsonAsync(cancellationToken: token); + } + + public Task WatchChatAsync() => StartWebsocketConnectionAsync("live/chat"); + + public Task WatchLogLinesAsync() => StartWebsocketConnectionAsync("live/logs"); + + private async Task StartWebsocketConnectionAsync(string url) + { + var client = new WebsocketClient(new($"{BaseUrl}{url}" + .Replace($"/{Version}", string.Empty) + .Replace("http", "ws")), + () => + { + var socket = new ClientWebSocket(); + socket.Options.SetRequestHeader("Authorization", $"Bearer {BearerToken}"); + return socket; + }) + { + ReconnectTimeout = null, + ErrorReconnectTimeout = TimeSpan.FromSeconds(10) + }; + + await client.Start(); + return client; + } +} diff --git a/TorchRemote/TorchRemote.csproj b/TorchRemote/TorchRemote.csproj new file mode 100644 index 0000000..717c604 --- /dev/null +++ b/TorchRemote/TorchRemote.csproj @@ -0,0 +1,35 @@ + + + WinExe + net6.0 + enable + + copyused + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TorchRemote/ViewModels/MainWindowViewModel.cs b/TorchRemote/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..5dbc438 --- /dev/null +++ b/TorchRemote/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,13 @@ +using System.Collections.ObjectModel; +using ReactiveUI; +namespace TorchRemote.ViewModels; + +public class MainWindowViewModel : ViewModelBase, IScreen +{ + public ObservableCollection Tabs { get; set; } = new() + { + new RemoteServerViewModel() + }; + + public RoutingState Router { get; set; } = new(); +} \ No newline at end of file diff --git a/TorchRemote/ViewModels/RemoteServerViewModel.cs b/TorchRemote/ViewModels/RemoteServerViewModel.cs new file mode 100644 index 0000000..0569b6f --- /dev/null +++ b/TorchRemote/ViewModels/RemoteServerViewModel.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Linq; +using System.Text.Json.Serialization; +using DynamicData.Binding; +using FluentAvalonia.UI.Controls; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; +using TorchRemote.Services; +using TorchRemote.ViewModels.Server; +namespace TorchRemote.ViewModels; + +public class RemoteServerViewModel : TabViewModelBase, IScreen +{ + private readonly ApiClientService _clientService = new(); + + [Reactive] + public override string Header { get; set; } = "Torch Server"; + + public ObservableCollection NavItems { get; set; } + [Reactive] + public ServerNavItem CurrentNavItem { get; set; } + + public RemoteServerViewModel() + { + var settingsViewModel = new SettingsViewModel(_clientService); + NavItems = new() + { + new("Dashboard", Symbol.Home, new DashboardViewModel(_clientService)), + new("Server Config", Symbol.Settings, new ServerConfigViewModel(_clientService)), + new("Chat", Symbol.Message, new ChatViewModel(_clientService)), + new("Players", Symbol.People, new PlayersViewModel(_clientService)), + new("Settings", Symbol.More, settingsViewModel) {IsVisible = true} + }; + CurrentNavItem = NavItems.Last(); + + this.WhenAnyValue(x => x.CurrentNavItem) + .Select(b => b.ViewModel) + .InvokeCommand(Router, x => x.Navigate); + + Observable.FromEventPattern(_clientService, nameof(_clientService.Connected)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(_ => Connected = true); + + this.WhenAnyValue(x => x.Connected) + .Where(b => b) + .Subscribe(_ => + { + foreach (var item in NavItems) + { + item.IsVisible = true; + } + CurrentNavItem = NavItems[0]; + }); + } + public RoutingState Router { get; set; } = new(); + + [JsonIgnore] + [Reactive] + public bool Connected { get; set; } +} diff --git a/TorchRemote/ViewModels/Server/ChatViewModel.cs b/TorchRemote/ViewModels/Server/ChatViewModel.cs new file mode 100644 index 0000000..ee6368d --- /dev/null +++ b/TorchRemote/ViewModels/Server/ChatViewModel.cs @@ -0,0 +1,51 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Reactive; +using System.Reactive.Linq; +using System.Text.Json; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; +using TorchRemote.Models.Responses; +using TorchRemote.Models.Shared; +using TorchRemote.Services; +namespace TorchRemote.ViewModels.Server; + +public class ChatViewModel : ViewModelBase +{ + public ChatViewModel(ApiClientService clientService) + { + Observable.FromEventPattern(clientService, nameof(clientService.Connected)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(_ => + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + Observable.FromAsync(clientService.WatchChatAsync) + .Select(b => b.MessageReceived) + .Concat() + .Select(b => JsonSerializer.Deserialize(b.Text, options)) + .Select(b => b switch + { + ChatMessageResponse msg => $"[{msg.Channel}] {msg.AuthorName}: {msg.Message}", + ChatCommandResponse cmd => $"[Command] {cmd.Author}: {cmd.Message}", + _ => throw new ArgumentOutOfRangeException(nameof(b), b, null) + }) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(s => ChatLines += $"{s}{Environment.NewLine}"); + }); + + SendMessageCommand = ReactiveCommand.CreateFromTask((s, t) => s.StartsWith("!") ? + clientService.InvokeCommandAsync(new(s[(s.IndexOf('!') + 1)..]), t) : + clientService.SendChatMessageAsync(new("Server", s, ChatChannel.GlobalScripted), t)); + + InvalidCommandPopup = SendMessageCommand.ThrownExceptions + .Where(b => b is HttpRequestException {StatusCode: HttpStatusCode.NotFound or HttpStatusCode.BadRequest}) + .Select(_ => true); + } + [Reactive] + public string ChatLines { get; set; } = string.Empty; + + public ReactiveCommand SendMessageCommand { get; set; } + + public IObservable InvalidCommandPopup { get; set; } +} diff --git a/TorchRemote/ViewModels/Server/DashboardViewModel.cs b/TorchRemote/ViewModels/Server/DashboardViewModel.cs new file mode 100644 index 0000000..3805971 --- /dev/null +++ b/TorchRemote/ViewModels/Server/DashboardViewModel.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Text.Json; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; +using TorchRemote.Models.Responses; +using TorchRemote.Services; +namespace TorchRemote.ViewModels.Server; + +public class DashboardViewModel : ViewModelBase +{ + private readonly ApiClientService _clientService; + public DashboardViewModel(ApiClientService clientService) + { + _clientService = clientService; + + Observable.FromEventPattern(_clientService, nameof(_clientService.Connected)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(_ => + { + Observable.Timer(TimeSpan.Zero, TimeSpan.FromSeconds(10)) + .Select(_ => Observable.FromAsync(t => _clientService.GetServerStatusAsync(t))) + .Concat() + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(r => + { + var (simSpeed, online, uptime, status) = r; + SimSpeed = simSpeed; + Status = status; + Uptime = uptime; + MemberCount = online; + }); + + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + Observable.FromAsync(() => _clientService.WatchLogLinesAsync()) + .Select(b => b.MessageReceived) + .Concat() + .Select(b => JsonSerializer.Deserialize(b.Text, options)) + .Select(b => $"{b.Time:hh:mm:ss} [{b.Level}] {(b.Logger.Contains('.') ? b.Logger[(b.Logger.LastIndexOf('.') + 1)..] : b.Logger)}: {b.Message}") + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(s => + { + if (LogLines.Count(b => b == '\n') > 1000) + LogLines = LogLines['\n'..]; + LogLines += $"{s}\n"; + }); + }); + + StartCommand = ReactiveCommand.CreateFromTask(t => _clientService.StartServerAsync(t), + this.WhenAnyValue(x => x.Status) + .Select(b => b is ServerStatus.Stopped)); + + StopCommand = ReactiveCommand.CreateFromTask((b, t) => _clientService.StopServerAsync(new(b), t), + this.WhenAnyValue(x => x.Status) + .Select(b => b is ServerStatus.Running)); + } + public ReactiveCommand StopCommand { get; set; } + public ReactiveCommand StartCommand { get; set; } + + [Reactive] + public double SimSpeed { get; set; } + [Reactive] + public ServerStatus Status { get; set; } + [Reactive] + public TimeSpan Uptime { get; set; } + [Reactive] + public string LogLines { get; set; } = string.Empty; + [Reactive] + public int MemberCount { get; set; } +} diff --git a/TorchRemote/ViewModels/Server/PlayersViewModel.cs b/TorchRemote/ViewModels/Server/PlayersViewModel.cs new file mode 100644 index 0000000..d7281c8 --- /dev/null +++ b/TorchRemote/ViewModels/Server/PlayersViewModel.cs @@ -0,0 +1,10 @@ +using TorchRemote.Services; +namespace TorchRemote.ViewModels.Server; + +public class PlayersViewModel : ViewModelBase +{ + public PlayersViewModel(ApiClientService clientService) + { + + } +} diff --git a/TorchRemote/ViewModels/Server/ServerConfigViewModel.cs b/TorchRemote/ViewModels/Server/ServerConfigViewModel.cs new file mode 100644 index 0000000..6ab91b6 --- /dev/null +++ b/TorchRemote/ViewModels/Server/ServerConfigViewModel.cs @@ -0,0 +1,90 @@ +using System; +using System.Reactive; +using System.Reactive.Linq; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; +using TorchRemote.Services; +namespace TorchRemote.ViewModels.Server; + +public class ServerConfigViewModel : ViewModelBase +{ + public ServerConfigViewModel(ApiClientService clientService) + { + Observable.FromEventPattern(clientService, nameof(clientService.Connected)) + .ObserveOn(RxApp.MainThreadScheduler) + .Select(_ => Observable.FromAsync(clientService.GetServerSettingsAsync)) + .Concat() + .Subscribe(b => + { + Name = b.ServerName; + MapName = b.MapName; + MemberLimit = b.MemberLimit; + Description = b.ServerDescription; + Ip = b.ListenEndPoint.Ip; + Port = b.ListenEndPoint.Port; + }); + + SaveCommand = ReactiveCommand.CreateFromTask(t => + clientService.SetServerSettingsAsync(new( + Name, + MapName, + Description, + MemberLimit, + new(Ip, Port) + ), t)); + + Worlds = Observable.FromEventPattern(clientService, nameof(clientService.Connected)) + .ObserveOn(RxApp.MainThreadScheduler) + .Select(_ => Observable.FromAsync(clientService.GetWorldsAsync)) + .Concat() + .SelectMany(ids => ids) + .Select(id => Observable.FromAsync(t => clientService.GetWorldAsync(id, t)).Select(b => new World(id, b.Name, b.SizeKb))) + .Concat(); + + Observable.FromEventPattern(clientService, nameof(clientService.Connected)) + .ObserveOn(RxApp.MainThreadScheduler) + .Select(_ => Observable.FromAsync(clientService.GetSelectedWorld)) + .Concat() + .Select(id => Observable.FromAsync(t => clientService.GetWorldAsync(id, t)).Select(b => new World(id, b.Name, b.SizeKb))) + .Concat() + .BindTo(this, x => x.SelectedWorld); + + this.ObservableForProperty(x => x.SelectedWorld) + .Select(world => Observable.FromAsync(t => clientService.SelectWorldAsync(world.Value!.Id, t))) + .Concat() + .Subscribe(_ => { }); + } + public ReactiveCommand SaveCommand { get; set; } + + [Reactive] + public string Name { get; set; } = null!; + [Reactive] + public string MapName { get; set; } = null!; + [Reactive] + public string Description { get; set; } = null!; + [Reactive] + public short MemberLimit { get; set; } + [Reactive] + public string Ip { get; set; } = null!; + [Reactive] + public int Port { get; set; } + + public IObservable Worlds { get; set; } + [Reactive] + public World? SelectedWorld { get; set; } +} + +public class World : ReactiveObject +{ + public World(Guid id, string name, long sizeKb) + { + Id = id; + Name = name; + SizeKb = sizeKb; + } + public Guid Id { get; set; } + public string Name { get; set; } + public long SizeKb { get; set; } + + public string SizeString => SizeKb > 1024 ? $"{SizeKb / 1024:N1} MB" : $"{SizeKb:N1} KB"; +} diff --git a/TorchRemote/ViewModels/Server/ServerNavItem.cs b/TorchRemote/ViewModels/Server/ServerNavItem.cs new file mode 100644 index 0000000..311aced --- /dev/null +++ b/TorchRemote/ViewModels/Server/ServerNavItem.cs @@ -0,0 +1,28 @@ +using System.Reactive.Linq; +using System.Text.Json.Serialization; +using FluentAvalonia.UI.Controls; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; +namespace TorchRemote.ViewModels.Server; + +public class ServerNavItem : ReactiveObject +{ + public ServerNavItem(string title, Symbol icon, ViewModelBase viewModel) + { + Title = title; + Icon = icon; + ViewModel = viewModel; + + this.WhenAnyValue(x => x.Icon) + .Select(b => new SymbolIcon {Symbol = b}) + .BindTo(this, x => x.IconElement); + } + public string Title { get; set; } + public Symbol Icon { get; set; } + public ViewModelBase ViewModel { get; set; } + [JsonIgnore] + [Reactive] + public bool IsVisible { get; set; } + [JsonIgnore] + public IconElement IconElement { get; set; } = null!; +} diff --git a/TorchRemote/ViewModels/Server/SettingsViewModel.cs b/TorchRemote/ViewModels/Server/SettingsViewModel.cs new file mode 100644 index 0000000..3e4d02e --- /dev/null +++ b/TorchRemote/ViewModels/Server/SettingsViewModel.cs @@ -0,0 +1,25 @@ +using DynamicData.Binding; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; +using TorchRemote.Services; +namespace TorchRemote.ViewModels.Server; + +public class SettingsViewModel : ViewModelBase +{ + private readonly ApiClientService _clientService; + [Reactive] + public string BearerToken { get; set; } = "WcdYT5qHjSt5Uzjs54xu8vE9Oq4a5MD2edLxywtJHtc="; + [Reactive] + public string RemoteUrl { get; set; } = "http://localhost"; + + public SettingsViewModel(ApiClientService clientService) + { + _clientService = clientService; + + this.WhenValueChanged(x => x.BearerToken) + .BindTo(_clientService, x => x.BearerToken); + + this.WhenValueChanged(x => x.RemoteUrl) + .BindTo(_clientService, x => x.BaseUrl); + } +} diff --git a/TorchRemote/ViewModels/TabViewModelBase.cs b/TorchRemote/ViewModels/TabViewModelBase.cs new file mode 100644 index 0000000..9712620 --- /dev/null +++ b/TorchRemote/ViewModels/TabViewModelBase.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; +namespace TorchRemote.ViewModels; + +[JsonDerivedType(typeof(RemoteServerViewModel))] +public abstract class TabViewModelBase : ViewModelBase +{ + public abstract string Header { get; set; } +} diff --git a/TorchRemote/ViewModels/ViewModelBase.cs b/TorchRemote/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..823c1ec --- /dev/null +++ b/TorchRemote/ViewModels/ViewModelBase.cs @@ -0,0 +1,10 @@ +using ReactiveUI; + +namespace TorchRemote.ViewModels +{ + public class ViewModelBase : ReactiveObject, IRoutableViewModel + { + public string? UrlPathSegment { get; set; } + public IScreen HostScreen { get; set; } + } +} diff --git a/TorchRemote/Views/MainWindow.axaml b/TorchRemote/Views/MainWindow.axaml new file mode 100644 index 0000000..3272cd3 --- /dev/null +++ b/TorchRemote/Views/MainWindow.axaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TorchRemote/Views/MainWindow.axaml.cs b/TorchRemote/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..6a8af19 --- /dev/null +++ b/TorchRemote/Views/MainWindow.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace TorchRemote.Views +{ + public partial class MainWindow : Window + { + public MainWindow() + { + InitializeComponent(); + } + } +} diff --git a/TorchRemote/Views/RemoteServerView.axaml b/TorchRemote/Views/RemoteServerView.axaml new file mode 100644 index 0000000..39ff870 --- /dev/null +++ b/TorchRemote/Views/RemoteServerView.axaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/TorchRemote/Views/RemoteServerView.axaml.cs b/TorchRemote/Views/RemoteServerView.axaml.cs new file mode 100644 index 0000000..f525254 --- /dev/null +++ b/TorchRemote/Views/RemoteServerView.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using TorchRemote.ViewModels; + +namespace TorchRemote.Views; + +public partial class RemoteServerView : ReactiveUserControl +{ + public RemoteServerView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} + diff --git a/TorchRemote/Views/Server/ChatView.axaml b/TorchRemote/Views/Server/ChatView.axaml new file mode 100644 index 0000000..01fcae5 --- /dev/null +++ b/TorchRemote/Views/Server/ChatView.axaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TorchRemote/Views/Server/ChatView.axaml.cs b/TorchRemote/Views/Server/ChatView.axaml.cs new file mode 100644 index 0000000..bfa7e15 --- /dev/null +++ b/TorchRemote/Views/Server/ChatView.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using TorchRemote.ViewModels.Server; + +namespace TorchRemote.Views.Server; + +public partial class ChatView : ReactiveUserControl +{ + public ChatView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} + diff --git a/TorchRemote/Views/Server/DashboardView.axaml b/TorchRemote/Views/Server/DashboardView.axaml new file mode 100644 index 0000000..a6d816a --- /dev/null +++ b/TorchRemote/Views/Server/DashboardView.axaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TorchRemote/Views/Server/DashboardView.axaml.cs b/TorchRemote/Views/Server/DashboardView.axaml.cs new file mode 100644 index 0000000..5b0a451 --- /dev/null +++ b/TorchRemote/Views/Server/DashboardView.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using TorchRemote.ViewModels.Server; + +namespace TorchRemote.Views.Server; + +public partial class DashboardView : ReactiveUserControl +{ + public DashboardView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} + diff --git a/TorchRemote/Views/Server/PlayersView.axaml b/TorchRemote/Views/Server/PlayersView.axaml new file mode 100644 index 0000000..13e7082 --- /dev/null +++ b/TorchRemote/Views/Server/PlayersView.axaml @@ -0,0 +1,8 @@ + + + diff --git a/TorchRemote/Views/Server/PlayersView.axaml.cs b/TorchRemote/Views/Server/PlayersView.axaml.cs new file mode 100644 index 0000000..e368d95 --- /dev/null +++ b/TorchRemote/Views/Server/PlayersView.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using TorchRemote.ViewModels.Server; + +namespace TorchRemote.Views.Server; + +public partial class PlayersView : ReactiveUserControl +{ + public PlayersView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} + diff --git a/TorchRemote/Views/Server/ServerConfigView.axaml b/TorchRemote/Views/Server/ServerConfigView.axaml new file mode 100644 index 0000000..397b34e --- /dev/null +++ b/TorchRemote/Views/Server/ServerConfigView.axaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TorchRemote/Views/Server/ServerConfigView.axaml.cs b/TorchRemote/Views/Server/ServerConfigView.axaml.cs new file mode 100644 index 0000000..1ecfcdd --- /dev/null +++ b/TorchRemote/Views/Server/ServerConfigView.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using TorchRemote.ViewModels.Server; + +namespace TorchRemote.Views.Server; + +public partial class ServerConfigView : ReactiveUserControl +{ + public ServerConfigView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} + diff --git a/TorchRemote/Views/Server/SettingsView.axaml b/TorchRemote/Views/Server/SettingsView.axaml new file mode 100644 index 0000000..c97bd84 --- /dev/null +++ b/TorchRemote/Views/Server/SettingsView.axaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TorchRemote/Views/Server/SettingsView.axaml.cs b/TorchRemote/Views/Server/SettingsView.axaml.cs new file mode 100644 index 0000000..bde8fee --- /dev/null +++ b/TorchRemote/Views/Server/SettingsView.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using TorchRemote.ViewModels.Server; + +namespace TorchRemote.Views.Server; + +public partial class SettingsView : ReactiveUserControl +{ + public SettingsView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} +