This commit is contained in:
zznty
2022-07-21 21:57:27 +07:00
commit bc4546410e
75 changed files with 2709 additions and 0 deletions

454
.gitignore vendored Normal file
View File

@@ -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

13
.idea/.idea.TorchRemote/.idea/.gitignore generated vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AvaloniaProject">
<option name="projectPerEditor">
<map>
<entry key="TorchRemote/App.axaml" value="TorchRemote/TorchRemote.csproj" />
<entry key="TorchRemote/Views/MainWindow.axaml" value="TorchRemote/TorchRemote.csproj" />
<entry key="TorchRemote/Views/RemoteServerView.axaml" value="TorchRemote/TorchRemote.csproj" />
<entry key="TorchRemote/Views/Server/ChatView.axaml" value="TorchRemote/TorchRemote.csproj" />
<entry key="TorchRemote/Views/Server/DashboardView.axaml" value="TorchRemote/TorchRemote.csproj" />
<entry key="TorchRemote/Views/Server/PlayersView.axaml" value="TorchRemote/TorchRemote.csproj" />
<entry key="TorchRemote/Views/Server/ServerConfigView.axaml" value="TorchRemote/TorchRemote.csproj" />
<entry key="TorchRemote/Views/Server/SettingsView.axaml" value="TorchRemote/TorchRemote.csproj" />
</map>
</option>
</component>
</project>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

6
.idea/.idea.TorchRemote/.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="com.jetbrains.rider.android.RiderAndroidMiscFileCreationComponent">
<option name="ENSURE_MISC_FILE_EXISTS" value="true" />
</component>
</project>

6
.idea/.idea.TorchRemote/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,3 @@
namespace TorchRemote.Models.Requests;
public record ChatCommandRequest(string Command);

View File

@@ -0,0 +1,4 @@
using TorchRemote.Models.Shared;
namespace TorchRemote.Models.Requests;
public record ChatMessageRequest(string Author, string Message, ChatChannel Channel, long? TargetId = null);

View File

@@ -0,0 +1,3 @@
namespace TorchRemote.Models.Requests;
public record StopServerRequest(bool Save = true);

View File

@@ -0,0 +1,3 @@
namespace TorchRemote.Models.Responses;
public record ChatCommandResponse(Guid Id, string Author, string Message) : ChatResponseBase;

View File

@@ -0,0 +1,4 @@
using TorchRemote.Models.Shared;
namespace TorchRemote.Models.Responses;
public record ChatMessageResponse(string AuthorName, ulong? Author, ChatChannel Channel, string Message) : ChatResponseBase;

View File

@@ -0,0 +1,6 @@
using System.Text.Json.Serialization;
namespace TorchRemote.Models.Responses;
[JsonDerivedType(typeof(ChatCommandResponse), "command")]
[JsonDerivedType(typeof(ChatMessageResponse), "message")]
public record ChatResponseBase();

View File

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

View File

@@ -0,0 +1,15 @@
namespace TorchRemote.Models.Responses;
public record ServerStatusResponse(double SimSpeed, int MemberCount, TimeSpan Uptime, ServerStatus Status);
public enum ServerStatus
{
/// <summary>The server is not running.</summary>
Stopped,
/// <summary>The server is starting/loading the session.</summary>
Starting,
/// <summary>The server is running.</summary>
Running,
/// <summary>The server encountered an error.</summary>
Error,
}

View File

@@ -0,0 +1,15 @@
namespace TorchRemote.Models.Responses;
public record SettingInfoResponse(string Name, ICollection<SettingPropertyInfo> 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");
}

View File

@@ -0,0 +1,3 @@
namespace TorchRemote.Models.Responses;
public record WorldResponse(string Name, long SizeKb);

View File

@@ -0,0 +1,9 @@
namespace TorchRemote.Models.Shared;
public enum ChatChannel
{
Global,
GlobalScripted,
Faction,
Private
}

View File

@@ -0,0 +1,3 @@
namespace TorchRemote.Models.Shared;
public record IpAddress(string Ip, int Port);

View File

@@ -0,0 +1,9 @@
namespace TorchRemote.Models.Shared;
public record ServerSettings(
string ServerName,
string MapName,
string ServerDescription,
short MemberLimit,
IpAddress ListenEndPoint
);

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>10</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="7.0.0-preview.6.22324.4" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("TorchRemote.Plugin")]
// ReSharper disable once CheckNamespace
namespace System.Runtime.CompilerServices;
internal class IsExternalInit { }

View File

@@ -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/";
}

View File

@@ -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<IChatManagerServer>() is { } manager)
manager.DisplayMessageOnSelf(request.Author, request.Message);
}
[Route(HttpVerbs.Post, $"{RootPath}/command")]
public async Task<Guid> 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<Match>().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<string> 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));
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<PropertyChanged />
</Weavers>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="PropertyChanged" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:attribute name="InjectOnPropertyNameChanged" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="TriggerDependentProperties" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if the Dependent properties feature is enabled.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="EnableIsChangedProperty" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if the IsChanged property feature is enabled.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="EventInvokerNames" type="xs:string">
<xs:annotation>
<xs:documentation>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.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="CheckForEquality" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="CheckForEqualityUsingBaseEquals" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if equality checks should use the Equals method resolved from the base class.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UseStaticEqualsFromBase" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if equality checks should use the static Equals method resolved from the base class.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="SuppressWarnings" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to turn off build warnings from this weaver.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="SuppressOnPropertyNameChangedWarning" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to turn off build warnings about mismatched On_PropertyName_Changed methods.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>

View File

@@ -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<ServerController>()
.WithController<SettingsController>()
.WithController<WorldsController>()
.WithController<ChatController>())
.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);
}
}

View File

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

View File

@@ -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<XmlIgnoreAttribute>() &&
!b.HasAttribute<JsonIgnoreAttribute>() &&
(!includeOnlyDisplay ||
b.HasAttribute<DisplayAttribute>()))
.Select(property => new SettingProperty(property.Name,
GetTypeId(property.PropertyType, property.GetValue(value), includeOnlyDisplay),
property.PropertyType, property.GetMethod, property.SetMethod,
property.GetCustomAttribute<DisplayAttribute>() 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<Guid, Setting> Settings { get; } = new ConcurrentDictionary<Guid, Setting>();
}
public record Setting(string Name, Type Type, IEnumerable<SettingProperty> 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);

View File

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

View File

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

View File

@@ -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> _config = null!;
public override void Init(ITorchBase torch)
{
base.Init(torch);
_config = Persistent<Config>.Load(Path.Combine(StoragePath, "TorchRemote.cfg"));
Torch.Managers.AddManager(new ApiServerManager(Torch, _config.Data));
Torch.Managers.AddManager(new SettingManager(Torch));
Torch.Managers.GetManager<ITorchSessionManager>()
.AddFactory(s => new ChatMonitorManager(s.Torch));
}
public UserControl GetControl() => new PropertyGrid
{
Margin = new(3),
DataContext = _config.Data
};
}

View File

@@ -0,0 +1,99 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<LangVersion>10</LangVersion>
<UseWpf>true</UseWpf>
<TorchDir>$(SolutionDir)TorchBinaries\</TorchDir>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<Reference Include="NLog">
<HintPath>$(TorchDir)NLog.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Sandbox.Common, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(TorchDir)DedicatedServer64\Sandbox.Common.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Sandbox.Game, Version=0.1.1.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(TorchDir)DedicatedServer64\Sandbox.Game.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Sandbox.Graphics, Version=0.1.1.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(TorchDir)DedicatedServer64\Sandbox.Graphics.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="System.Memory">
<HintPath>$(TorchDir)DedicatedServer64\System.Memory.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="System.Net.Http" />
<Reference Include="Torch">
<HintPath>$(TorchDir)Torch.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Torch.API">
<HintPath>$(TorchDir)Torch.API.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Torch.Server">
<HintPath>$(TorchDir)Torch.Server.exe</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="VRage, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(TorchDir)DedicatedServer64\VRage.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="VRage.Game, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(TorchDir)DedicatedServer64\VRage.Game.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="VRage.Input, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(TorchDir)DedicatedServer64\VRage.Input.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="VRage.Library, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(TorchDir)DedicatedServer64\VRage.Library.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="VRage.Math, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(TorchDir)DedicatedServer64\VRage.Math.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="VRage.Network, Version=1.0.53.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(TorchDir)DedicatedServer64\VRage.Network.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="EmbedIO" Version="3.4.3" />
<PackageReference Include="EmbedIO.BearerToken" Version="3.4.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" ExcludeAssets="all" PrivateAssets="all" />
<PackageReference Include="PropertyChanged.Fody" Version="4.0.0" PrivateAssets="all" />
<PackageReference Include="System.Text.Json" Version="7.0.0-preview.6.22324.4" />
</ItemGroup>
<ItemGroup>
<None Update="manifest.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TorchRemote.Models\TorchRemote.Models.csproj" />
</ItemGroup>
<Target Name="CopyToTorch" AfterTargets="Build">
<ZipDirectory DestinationFile="$(TorchDir)Plugins\$(AssemblyName).zip" SourceDirectory="$(TargetDir)" Overwrite="true" />
</Target>
</Project>

View File

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

View File

@@ -0,0 +1,34 @@
using System.Text.Json;
using EmbedIO;
using EmbedIO.WebApi;
namespace TorchRemote.Plugin.Utils;
/// <summary>
/// <para>Specifies that a parameter of a controller method will receive
/// an object obtained by deserializing the request body as JSON.</para>
/// <para>The received object will be <see langword="null"/>
/// only if the deserialized object is <c>null</c>.</para>
/// <para>If the request body is not valid JSON,
/// or if it cannot be deserialized to the type of the parameter,
/// a <c>400 Bad Request</c> response will be sent to the client.</para>
/// <para>This class cannot be inherited.</para>
/// </summary>
/// <seealso cref="Attribute" />
/// <seealso cref="IRequestDataAttribute{TController}" />
[AttributeUsage(AttributeTargets.Parameter)]
public class JsonDataAttribute : Attribute, IRequestDataAttribute<WebApiController>
{
/// <inheritdoc />
public async Task<object?> 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}.");
}
}
}

View File

@@ -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<SettingManager>();
public static InstanceManager InstanceManager => Torch.Managers.GetManager<InstanceManager>();
public static CommandManager? CommandManager => Torch.CurrentSession?.Managers.GetManager<CommandManager>();
public static ChatModule ChatModule = null!;
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0"?>
<PluginManifest xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Name>TorchRemote.Plugin</Name>
<Guid>284017F3-9682-4841-A544-EB04DB8CB9BA</Guid>
<Version>v1.0.0</Version>
</PluginManifest>

View File

@@ -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

28
TorchRemote.sln Normal file
View File

@@ -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

9
TorchRemote/App.axaml Normal file
View File

@@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:TorchRemote"
xmlns:styling="clr-namespace:FluentAvalonia.Styling;assembly=FluentAvalonia"
x:Class="TorchRemote.App">
<Application.Styles>
<styling:FluentAvaloniaTheme PreferSystemTheme="True" />
</Application.Styles>
</Application>

42
TorchRemote/App.axaml.cs Normal file
View File

@@ -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<IScreen>(new MainWindowViewModel());
Locator.CurrentMutable.Register<IViewFor<RemoteServerViewModel>>(() => new RemoteServerView());
Locator.CurrentMutable.Register<IViewFor<DashboardViewModel>>(() => new DashboardView());
Locator.CurrentMutable.Register<IViewFor<ServerConfigViewModel>>(() => new ServerConfigView());
Locator.CurrentMutable.Register<IViewFor<ChatViewModel>>(() => new ChatView());
Locator.CurrentMutable.Register<IViewFor<PlayersViewModel>>(() => new PlayersView());
Locator.CurrentMutable.Register<IViewFor<SettingsViewModel>>(() => new SettingsView());
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = Locator.Current.GetService<IScreen>(),
};
}
base.OnFrameworkInitializationCompleted();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -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<MessageConfirmDismissFlyout, ICommand> ResultCommandProperty =
AvaloniaProperty.RegisterDirect<MessageConfirmDismissFlyout, ICommand>(nameof(ResultCommand), flyout => flyout._command!,
(flyout, command) => flyout._command = command);
public static readonly DirectProperty<MessageConfirmDismissFlyout, string> MessageProperty =
AvaloniaProperty.RegisterDirect<MessageConfirmDismissFlyout, string>(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);
}
}

View File

@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ReactiveUI />
</Weavers>

23
TorchRemote/Program.cs Normal file
View File

@@ -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<App>()
.UsePlatformDetect()
.LogToTrace()
.UseReactiveUI();
}
}

View File

@@ -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<ServerStatusResponse> GetServerStatusAsync(CancellationToken token) =>
_client.GetFromJsonAsync<ServerStatusResponse>("server/status", token)!;
public Task<ServerSettings> GetServerSettingsAsync(CancellationToken token) =>
_client.GetFromJsonAsync<ServerSettings>("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<IEnumerable<Guid>> GetWorldsAsync(CancellationToken token) =>
_client.GetFromJsonAsync<IEnumerable<Guid>>("worlds", token)!;
public Task<WorldResponse> GetWorldAsync(Guid id, CancellationToken token) =>
_client.GetFromJsonAsync<WorldResponse>($"worlds/{id}", token)!;
public Task<Guid> GetSelectedWorld(CancellationToken token) =>
_client.GetFromJsonAsync<Guid>("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<Guid> InvokeCommandAsync(ChatCommandRequest request, CancellationToken token)
{
var r = await _client.PostAsJsonAsync("chat/command", request, token);
r.EnsureSuccessStatusCode();
return await r.Content.ReadFromJsonAsync<Guid>(cancellationToken: token);
}
public Task<WebsocketClient> WatchChatAsync() => StartWebsocketConnectionAsync("live/chat");
public Task<WebsocketClient> WatchLogLinesAsync() => StartWebsocketConnectionAsync("live/logs");
private async Task<WebsocketClient> 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;
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
<TrimMode>copyused</TrimMode>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<!--This helps with theme dll-s trimming.
If you will publish your application in self-contained mode with p:PublishTrimmed=true and it will use Fluent theme Default theme will be trimmed from the output and vice versa.
https://github.com/AvaloniaUI/Avalonia/issues/5593 -->
<TrimmableAssembly Include="Avalonia.Themes.Fluent" />
<TrimmableAssembly Include="Avalonia.Themes.Default" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="0.10.16" />
<PackageReference Include="Avalonia.Desktop" Version="0.10.16" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="0.10.16" />
<PackageReference Include="Avalonia.ReactiveUI" Version="0.10.16" />
<PackageReference Include="FluentAvaloniaUI" Version="1.4.1" />
<PackageReference Include="ReactiveUI.Fody" Version="18.3.1" />
<PackageReference Include="System.Text.Json" Version="7.0.0-preview.6.22324.4" />
<PackageReference Include="Websocket.Client" Version="4.4.43" />
<PackageReference Include="XamlNameReferenceGenerator" Version="1.3.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TorchRemote.Models\TorchRemote.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
using System.Collections.ObjectModel;
using ReactiveUI;
namespace TorchRemote.ViewModels;
public class MainWindowViewModel : ViewModelBase, IScreen
{
public ObservableCollection<TabViewModelBase> Tabs { get; set; } = new()
{
new RemoteServerViewModel()
};
public RoutingState Router { get; set; } = new();
}

View File

@@ -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<ServerNavItem> 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<ServerNavItem, IRoutableViewModel>(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; }
}

View File

@@ -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<ChatResponseBase>(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<string>((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<string, Unit> SendMessageCommand { get; set; }
public IObservable<bool> InvalidCommandPopup { get; set; }
}

View File

@@ -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<LogLineResponse>(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<bool>((b, t) => _clientService.StopServerAsync(new(b), t),
this.WhenAnyValue(x => x.Status)
.Select(b => b is ServerStatus.Running));
}
public ReactiveCommand<bool,Unit> StopCommand { get; set; }
public ReactiveCommand<Unit,Unit> 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; }
}

View File

@@ -0,0 +1,10 @@
using TorchRemote.Services;
namespace TorchRemote.ViewModels.Server;
public class PlayersViewModel : ViewModelBase
{
public PlayersViewModel(ApiClientService clientService)
{
}
}

View File

@@ -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<Unit,Unit> 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<World> 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";
}

View File

@@ -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!;
}

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
using ReactiveUI;
namespace TorchRemote.ViewModels
{
public class ViewModelBase : ReactiveObject, IRoutableViewModel
{
public string? UrlPathSegment { get; set; }
public IScreen HostScreen { get; set; }
}
}

View File

@@ -0,0 +1,82 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:TorchRemote.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:reactiveUi="http://reactiveui.net"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="TorchRemote.Views.MainWindow"
Icon="/Assets/avalonia-logo.ico"
Title="Torch Remote"
TransparencyLevelHint="AcrylicBlur"
Background="Transparent"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaChromeHints="NoChrome"
ExtendClientAreaTitleBarHeightHint="-1">
<Design.DataContext>
<vm:MainWindowViewModel />
</Design.DataContext>
<Panel>
<ExperimentalAcrylicBorder IsHitTestVisible="False">
<ExperimentalAcrylicBorder.Material>
<ExperimentalAcrylicMaterial TintColor="Black"
TintOpacity="0.4"
MaterialOpacity="0.1"
BackgroundSource="Digger"/>
</ExperimentalAcrylicBorder.Material>
</ExperimentalAcrylicBorder>
<Grid RowDefinitions="Auto,*">
<StackPanel Grid.Row="0" Orientation="Horizontal" IsHitTestVisible="False">
<Image Margin="12,4"
Source="/Assets/avalonia-logo.ico"
Width="18" Height="18"
DockPanel.Dock="Left"
Name="WindowIcon" />
<TextBlock
Text="{Binding $parent[Window].Title}"
FontSize="12"
VerticalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Row="0" HorizontalAlignment="Right" Margin="0,5,0,0" Orientation="Horizontal">
<ui:Button Margin="0,0,5,0"
Classes="AppBarButton"
Command="{Binding $parent[Window].set_WindowState}"
CommandParameter="Minimized">
<ui:SymbolIcon Symbol="Remove" FontSize="18" />
</ui:Button>
<ui:Button Margin="0,0,5,0"
Classes="AppBarButton"
Command="{Binding $parent[Window].Close}">
<ui:SymbolIcon Symbol="Dismiss" FontSize="18" />
</ui:Button>
</StackPanel>
<TabControl Grid.Row="1" Items="{Binding Tabs}">
<TabControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Image Source="../Assets/torchicon.ico"
Margin="12,4"
Width="18"
Height="18" />
<TextBlock Text="{Binding Header}"
FontSize="12"
Margin="0,0,12,0"
VerticalAlignment="Center"/>
</StackPanel>
</DataTemplate>
</TabControl.ItemTemplate>
<TabControl.ContentTemplate>
<DataTemplate DataType="{x:Type vm:TabViewModelBase}">
<reactiveUi:ViewModelViewHost Margin="3" ViewModel="{Binding}" />
</DataTemplate>
</TabControl.ContentTemplate>
</TabControl>
</Grid>
</Panel>
</Window>

View File

@@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace TorchRemote.Views
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
}

View File

@@ -0,0 +1,43 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fuc="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:viewModels="clr-namespace:TorchRemote.ViewModels"
xmlns:reactiveUi="http://reactiveui.net"
xmlns:server="clr-namespace:TorchRemote.ViewModels.Server"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="TorchRemote.Views.RemoteServerView">
<Design.DataContext>
<viewModels:RemoteServerViewModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="fuc|NavigationView /template/ Border#ContentGridBorder">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
</UserControl.Styles>
<fuc:NavigationView MenuItems="{Binding NavItems}"
SelectedItem="{Binding CurrentNavItem, Mode=TwoWay}"
PaneDisplayMode="LeftCompact"
IsSettingsVisible="False">
<fuc:NavigationView.MenuItemTemplate>
<DataTemplate DataType="{x:Type server:ServerNavItem}">
<fuc:NavigationViewItem Content="{Binding Title}"
Icon="{Binding IconElement}"
IsVisible="{Binding IsVisible}"/>
</DataTemplate>
</fuc:NavigationView.MenuItemTemplate>
<Panel>
<Grid Margin="3" RowDefinitions="Auto,*">
<TextBlock Text="{Binding CurrentNavItem.Title}" FontSize="24" FontWeight="Bold" />
<reactiveUi:RoutedViewHost Grid.Row="1" Margin="0,10" Router="{Binding Router}" />
</Grid>
<TextBlock Text="Loading..."
IsVisible="{Binding !Connected}"
IsHitTestVisible="False"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Panel>
</fuc:NavigationView>
</UserControl>

View File

@@ -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<RemoteServerViewModel>
{
public RemoteServerView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View File

@@ -0,0 +1,45 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:server="clr-namespace:TorchRemote.ViewModels.Server"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="TorchRemote.Views.Server.ChatView">
<Design.DataContext>
<server:ChatViewModel/>
</Design.DataContext>
<Grid RowDefinitions="*,Auto">
<ExperimentalAcrylicBorder CornerRadius="6" Margin="5,8">
<ExperimentalAcrylicBorder.Material>
<ExperimentalAcrylicMaterial TintColor="Black"
TintOpacity="0.8"
MaterialOpacity="0.6"
BackgroundSource="Digger"/>
</ExperimentalAcrylicBorder.Material>
<Grid>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
<TextBlock Margin="3,5" Text="{Binding ChatLines}" />
</ScrollViewer>
</Grid>
</ExperimentalAcrylicBorder>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="3">
<TextBox Name="MessageBox" Watermark="Enter chat message or command..." />
<Button Grid.Column="1"
Margin="10,0,0,0"
IsDefault="True"
IsEnabled="{Binding #MessageBox.Text, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
CommandParameter="{Binding #MessageBox.Text}"
Command="{Binding SendMessageCommand}">
<controls:SymbolIcon Symbol="Send" />
</Button>
<Popup Grid.Column="0"
IsLightDismissEnabled="True"
IsOpen="{Binding InvalidCommandPopup^}"
PlacementMode="Top"
PlacementTarget="MessageBox">
<TextPresenter Text="Invalid Command" FontSize="24" FontWeight="Bold" />
</Popup>
</Grid>
</Grid>
</UserControl>

View File

@@ -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<ChatViewModel>
{
public ChatView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View File

@@ -0,0 +1,65 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:server="clr-namespace:TorchRemote.ViewModels.Server"
xmlns:fuc="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:flyouts="clr-namespace:TorchRemote.Controls.Flyouts"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="TorchRemote.Views.Server.DashboardView">
<Design.DataContext>
<server:DashboardViewModel/>
</Design.DataContext>
<Grid RowDefinitions="Auto,*">
<StackPanel Orientation="Horizontal">
<StackPanel Orientation="Horizontal" Margin="5,0">
<TextBlock Text="Status:" FontWeight="Heavy"/>
<TextBlock Text="{Binding Status}" Margin="5,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="5,0">
<TextBlock Text="Sim:" FontWeight="Heavy"/>
<TextBlock Text="{Binding SimSpeed, StringFormat={}{0:0.00}}" Margin="5,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="5,0">
<TextBlock Text="Uptime:" FontWeight="Heavy"/>
<TextBlock Text="{Binding Uptime, StringFormat={}{0:hh\\:mm\\.ss}}" Margin="5,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="5,0">
<TextBlock Text="Online:" FontWeight="Heavy"/>
<TextBlock Text="{Binding MemberCount}" Margin="5,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="5,0">
<fuc:Button Command="{Binding StartCommand}">
<StackPanel Orientation="Horizontal">
<fuc:SymbolIcon Symbol="Up" Margin="5,0"/>
<TextBlock Text="Start" Margin="0,0,5,0" />
</StackPanel>
</fuc:Button>
<fuc:Button Margin="5,0" IsEnabled="{Binding StopCommand.CanExecute^}">
<fuc:Button.Flyout>
<flyouts:MessageConfirmDismissFlyout Message="Save before Stop?"
ResultCommand="{Binding StopCommand}"/>
</fuc:Button.Flyout>
<StackPanel Orientation="Horizontal">
<fuc:SymbolIcon Symbol="Dismiss" Margin="5,0"/>
<TextBlock Text="Stop" Margin="0,0,5,0" />
</StackPanel>
</fuc:Button>
</StackPanel>
</StackPanel>
<ExperimentalAcrylicBorder Grid.Row="1" CornerRadius="6" Margin="5,8">
<ExperimentalAcrylicBorder.Material>
<ExperimentalAcrylicMaterial TintColor="Black"
TintOpacity="0.8"
MaterialOpacity="0.6"
BackgroundSource="Digger"/>
</ExperimentalAcrylicBorder.Material>
<Grid>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
<TextBlock Margin="3,5" Text="{Binding LogLines}" />
</ScrollViewer>
</Grid>
</ExperimentalAcrylicBorder>
</Grid>
</UserControl>

View File

@@ -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<DashboardViewModel>
{
public DashboardView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View File

@@ -0,0 +1,8 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="TorchRemote.Views.Server.PlayersView">
</UserControl>

View File

@@ -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<PlayersViewModel>
{
public PlayersView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View File

@@ -0,0 +1,61 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:server="clr-namespace:TorchRemote.ViewModels.Server"
xmlns:fuc="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="TorchRemote.Views.Server.ServerConfigView">
<Design.DataContext>
<server:ServerConfigViewModel/>
</Design.DataContext>
<StackPanel>
<Grid ColumnDefinitions="1*,1*">
<TextBlock Text="Name"/>
<TextBox Grid.Column="1" Text="{Binding Name}" />
</Grid>
<Grid ColumnDefinitions="1*,1*" Margin="0,10,0,0">
<TextBlock Text="Map Name"/>
<TextBox Grid.Column="1" Text="{Binding MapName}" />
</Grid>
<Grid ColumnDefinitions="1*,1*" Margin="0,10,0,0">
<TextBlock Text="Description"/>
<TextBox Grid.Column="1" AcceptsReturn="True" Text="{Binding Description}" />
</Grid>
<Grid ColumnDefinitions="1*,1*" Margin="0,10,0,0">
<TextBlock Text="Member Limit"/>
<NumericUpDown Grid.Column="1" Minimum="0" Maximum="255" Value="{Binding MemberLimit}" />
</Grid>
<Grid ColumnDefinitions="1*,1*" Margin="0,10,0,0">
<TextBlock Text="IP Address"/>
<Grid Grid.Column="1" ColumnDefinitions="3*,Auto,1*">
<TextBox Text="{Binding Ip}" />
<TextBlock Grid.Column="1" Text=":" FontSize="18" FontWeight="Bold" Margin="3,0" />
<NumericUpDown Grid.Column="2" Value="{Binding Port}" />
</Grid>
</Grid>
<Grid ColumnDefinitions="1*,1*" Margin="0,10,0,0">
<TextBlock Text="Load World"/>
<ComboBox Grid.Column="1" Items="{Binding Worlds^}"
SelectedItem="{Binding SelectedWorld}"
AutoScrollToSelectedItem="True">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Name}"/>
<TextBlock Text="{Binding SizeString}" Margin="8,0,0,0"/>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
<fuc:Button Command="{Binding SaveCommand}"
Classes="accent"
Width="175">
<StackPanel Orientation="Horizontal">
<fuc:SymbolIcon Symbol="Save" Margin="5,0"/>
<TextBlock Text="Save" Margin="0,0,5,0" />
</StackPanel>
</fuc:Button>
</StackPanel>
</UserControl>

View File

@@ -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<ServerConfigViewModel>
{
public ServerConfigView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View File

@@ -0,0 +1,29 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:TorchRemote.ViewModels"
xmlns:fuc="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:server="clr-namespace:TorchRemote.ViewModels.Server"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="TorchRemote.Views.Server.SettingsView">
<Design.DataContext>
<!-- ReSharper disable once Xaml.ConstructorWarning -->
<server:SettingsViewModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="fuc|NavigationView /template/ Border#ContentGridBorder">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
</UserControl.Styles>
<StackPanel>
<Grid ColumnDefinitions="1*,2*"
RowDefinitions="1*,1*">
<TextBlock Text="Url" />
<TextBox Grid.Row="0" Grid.Column="1" Margin="3" Text="{Binding RemoteUrl}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Token" />
<TextBox Grid.Row="1" Grid.Column="1" Margin="3" Text="{Binding BearerToken}" />
</Grid>
</StackPanel>
</UserControl>

View File

@@ -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<SettingsViewModel>
{
public SettingsView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}