#9 Change server panel updating to directly ping servers

Open
TooManySugar wants to merge 1 commits from TooManySugar/feature/change-server-panel-updating-to-directly-ping-servers into Veloe/master

+ 0 - 18
VeloeMinecraftLauncher/Models/Entity/McStatus/McStatus.cs

@@ -1,18 +0,0 @@
-using System.Collections.Generic;
-
-namespace VeloeMinecraftLauncher.Entity.McStatus;
-
-public class McStatus
-{
-    public string? MessageOfTheDay { get; set; }
-    public string? Gametype { get; set; }
-    public string? GameId { get; set; }
-    public string? Version { get; set; }
-    public string? Plugins { get; set; }
-    public string? Map { get; set; }
-    public string? NumPlayers { get; set; }
-    public string? MaxPlayers { get; set; }
-    public string? HostPort { get; set; }
-    public string? HostIp { get; set; }
-    public List<string>? Players { get; set; }
-}

+ 110 - 0
VeloeMinecraftLauncher/Utils/McProtocol/Client.cs

@@ -0,0 +1,110 @@
+using DnsClient;
+using DnsClient.Protocol;
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Net;
+using System.Net.Sockets;
+
+namespace VeloeMinecraftLauncher.Utils.McProtocol;
+
+public class Client
+{
+    private const string _minecraftSrvRecordPrefix = "_minecraft._tcp.";
+
+    public class PingResult
+    {
+        public PingResult(StatusResponse? statusResponse) => StatusResponse = statusResponse;
+
+        public StatusResponse? StatusResponse { get; set; }
+    }
+
+    public static async Task<PingResult> Ping(string address)
+    {
+        if (!IPEndPoint.TryParse(address, out var serverEndpoint))
+        {
+            IPAddress ip = null;
+            ushort port = 25565;
+
+            var lookupClient = new LookupClient();
+
+            var parts = address.Split(new char[]{':'}, 2);
+            if (parts.Length == 2)
+            {
+                address = parts[0];
+                port = ushort.Parse(parts[1]);
+            }
+            else
+            {
+                var result = lookupClient.Query(_minecraftSrvRecordPrefix + address, QueryType.SRV);
+                var srvRecord = result.Answers.OfType<SrvRecord>().FirstOrDefault();
+                if (srvRecord is not null)
+                {
+                    port = srvRecord.Port;
+                    var additionalRecord = result.Additionals
+                        .FirstOrDefault(p => p.DomainName.Equals(srvRecord.Target));
+                    if (additionalRecord is ARecord aRecord)
+                        ip = aRecord.Address;
+                }
+            }
+
+            if (ip is null)
+            {
+                var result = lookupClient.Query(address, QueryType.A);
+                var aRecord = result.Answers.ARecords().FirstOrDefault();
+                ip = aRecord?.Address;
+            }
+
+            serverEndpoint = new IPEndPoint(ip, (int)port);
+        }
+
+        if (serverEndpoint.AddressFamily != AddressFamily.InterNetwork)
+        {
+            throw new ArgumentException($"Non IPv4 address families not supported, address: {serverEndpoint}");
+        }
+
+        var serverAddress = serverEndpoint.Address.ToString();
+        var serverPort = (ushort)serverEndpoint.Port;
+
+        using TcpClient client = new();
+        await client.ConnectAsync(serverEndpoint);
+        await using NetworkStream stream = client.GetStream();
+
+        var handshakePacketPayload = (new TVarInt(0).GetBytes()) // ID
+            .Concat(new TVarInt(-1).GetBytes())                  // Data.ProtocolVersion
+            .Concat(new TString(serverAddress).GetBytes())       // Data.ServerAddress
+            .Concat(new TUInt16(serverPort).GetBytes())          // Data.ServerPort
+            .Concat(new TVarInt(1).GetBytes())                   // Data.NextState
+            .ToArray();
+
+        var handshakePacketBytes = (new TVarInt(handshakePacketPayload.Length).GetBytes()) // Size
+            .Concat(handshakePacketPayload)                                                // ID + Data
+            .ToArray();
+
+        await stream.WriteAsync(handshakePacketBytes);
+
+        var statusRequestPacketBytes = (new TVarInt(1).GetBytes()) // Size
+            .Concat(new TVarInt(0).GetBytes())                     // ID
+            .ToArray();
+
+        await stream.WriteAsync(statusRequestPacketBytes);
+
+        // TODO:
+        // https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping#Status_Response
+        // Note that Notchian servers will for unknown reasons wait to receive the following
+        // Ping Request packet for 30 seconds before timing out and sending Response.
+
+        TVarInt.Parse(stream);                                            // Size
+        TVarInt.Parse(stream);                                            // ID
+        var statusResponsePacketDataJsonResponse = TString.Parse(stream); // Data.JsonResponse
+
+        var statusResponse = JsonSerializer.Deserialize<StatusResponse>(
+            statusResponsePacketDataJsonResponse.Value,
+            new JsonSerializerOptions { }
+        );
+
+        return new PingResult(statusResponse);
+    }
+}

+ 27 - 0
VeloeMinecraftLauncher/Utils/McProtocol/StatusResponse.cs

@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace VeloeMinecraftLauncher.Utils.McProtocol;
+
+public class StatusResponse
+{
+    [JsonPropertyName("players")]
+    public StatusResponsePlayers? Players { get; set; }
+}
+
+public class StatusResponsePlayers
+{
+    [JsonPropertyName("max")]
+    public int? Max { get; set; }
+
+    [JsonPropertyName("online")]
+    public int? Online { get; set; }
+
+    [JsonPropertyName("sample")]
+    public StatusResponsePlayersSample[]? Sample { get; set; }
+}
+
+public class StatusResponsePlayersSample
+{
+    [JsonPropertyName("name")]
+    public string? Name { get; set; }
+}

+ 32 - 0
VeloeMinecraftLauncher/Utils/McProtocol/TString.cs

@@ -0,0 +1,32 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace VeloeMinecraftLauncher.Utils.McProtocol;
+
+public class TString
+{
+    // TODO: Validate length on setter.
+    public string Value { get; set; }
+
+    public TString(string value) => Value = value;
+
+    public static TString Parse(Stream s)
+    {
+        var size = TVarInt.Parse(s);
+        var buf = new byte[size.Value];
+        s.ReadExactly(buf);
+        var value = Encoding.UTF8.GetString(buf);
+        return new TString(value);
+    }
+
+    public byte[] GetBytes()
+    {
+        var valueBytes = Encoding.UTF8.GetBytes(this.Value);
+        var size = new TVarInt(valueBytes.Length);
+        var sizeBytes = size.GetBytes();
+
+        return sizeBytes.Concat(valueBytes).ToArray();
+    }
+}

+ 23 - 0
VeloeMinecraftLauncher/Utils/McProtocol/TUInt16.cs

@@ -0,0 +1,23 @@
+using System;
+using System.Buffers.Binary;
+
+namespace VeloeMinecraftLauncher.Utils.McProtocol;
+
+public class TUInt16
+{
+    public ushort Value { set; get; }
+
+    public TUInt16(ushort value) => Value = value;
+
+    public byte[] GetBytes()
+    {
+        var v = this.Value;
+
+        if (BitConverter.IsLittleEndian)
+        {
+            v = BinaryPrimitives.ReverseEndianness(v);
+        }
+
+        return BitConverter.GetBytes(v);
+    }
+}

+ 61 - 0
VeloeMinecraftLauncher/Utils/McProtocol/TVarInt.cs

@@ -0,0 +1,61 @@
+using System;
+using System.IO;
+using System.Linq;
+
+namespace VeloeMinecraftLauncher.Utils.McProtocol;
+
+public class TVarInt
+{
+    public int Value { get; set; }
+
+    public TVarInt(int value) => Value = value;
+
+    public static TVarInt Parse(Stream s)
+    {
+        uint v = 0;
+        int pos = 0;
+
+        while (true)
+        {
+            var b = s.ReadByte();
+            if (b < 0)
+                throw new EndOfStreamException();
+
+            v |= ((uint)(b & 0b01111111)) << pos;
+
+            if ((b & 0b10000000) == 0)
+                break;
+
+            pos += 7;
+            if (pos > 28)
+            {
+                throw new ArgumentOutOfRangeException("Continuation bit is set on 5th byte");
+            }
+        }
+
+        return new TVarInt(unchecked((int)v));
+    }
+
+    public byte[] GetBytes()
+    {
+        var ret = new byte[5];
+        var v = unchecked((uint)this.Value);
+        int l = 0;
+
+        while (true)
+        {
+            if ((v & 0b_1000_0000) == 0)
+            {
+                ret[l] = Convert.ToByte(v);
+                l++;
+                break;
+            }
+
+            ret[l] =  Convert.ToByte((v & 0b_0111_1111) | 0b_1000_0000);
+            l++;
+            v >>= 7;
+        }
+
+        return ret.Take(l).ToArray();
+    }
+}

+ 1 - 1
VeloeMinecraftLauncher/VeloeMinecraftLauncher.csproj

@@ -54,8 +54,8 @@
 		<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
 		<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.0" />
 		<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
+		<PackageReference Include="DnsClient" Version="1.8.0" />
 		<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
-		<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.4" />
 		<PackageReference Include="ReactiveUI.Validation" Version="4.1.1" />
 		<PackageReference Include="Serilog" Version="4.2.0" />
 		<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />

+ 41 - 53
VeloeMinecraftLauncher/ViewModels/MainWindowViewModel.cs

@@ -9,8 +9,6 @@ using System.Text.Json;
 using System.Threading.Tasks;
 using VeloeMinecraftLauncher.Entity.LauncherProfiles;
 using VeloeMinecraftLauncher.Utils;
-using Microsoft.AspNetCore.SignalR.Client;
-using VeloeMinecraftLauncher.Entity.McStatus;
 using System.Timers;
 using System.Reflection;
 using Serilog;
@@ -26,6 +24,7 @@ using System.Threading;
 using System.ComponentModel;
 using VeloeMinecraftLauncher.Utils.Logger;
 using VeloeMinecraftLauncher.Utils.Downloader;
+using McProtocol = VeloeMinecraftLauncher.Utils.McProtocol;
 using VeloeMinecraftLauncher.Utils.Starter;
 using System.Windows.Input;
 
@@ -115,48 +114,15 @@ public class MainWindowViewModel : ViewModelBase
             }
             try
             {
-
-                _logger.Debug("Connecting to WebSoket");
-                //conection to my servers 
-
+                // Create list of my servers
                 var serverInfo = await Downloader.DownloadAndDeserializeJsonData<Dictionary<string, string[]>>("https://files.veloe.link/launcher/serversInfo.json");
-
                 if (serverInfo is not null)
                 {
-                    _connection = new HubConnectionBuilder()
-                    .WithUrl("https://monitor.veloe.link/hubs/data")
-                    .WithAutomaticReconnect()
-                    .Build();
-
-                    Func<Exception, Task> reconnecting = ex => Task.Run(() =>
-                    {
-                        _logger.Warning("Reconnecting to WebCoket...");
-                    });
-                    Func<string, Task> reconnected = str => Task.Run(() =>
-                    {
-                        _logger.Warning("Reconnected to WebCoket.");
-                        foreach (var server in serverInfo)
-                        {
-                            _connection.InvokeAsync("ConnectToGroup", server.Key);
-                        }
-                    });
-
-                    _connection.Reconnecting += reconnecting;
-                    _connection.Reconnected += reconnected;
-                    
                     await Dispatcher.UIThread.InvokeAsync(()=>
                     {
                         foreach (var server in serverInfo)
                             ServerPanels.Add(CreateServerPanel(server.Key, server.Value[0], server.Value[1]));
                     });
-
-                    await _connection.StartAsync();
-
-                    foreach (var server in serverInfo)
-                    {
-                        await _connection.InvokeAsync("ConnectToGroup", server.Key);
-                    }
-                    _logger.Debug("Connected to WebSoket");
                 }
 
                 _consoleOutputTimer.Elapsed += UpdateConsoleOutput;
@@ -229,9 +195,10 @@ public class MainWindowViewModel : ViewModelBase
         _show = show;
     }
 
+    private const int _serverPanelUpdateRelaxDurationMs = 10_000;
+
     System.Timers.Timer _consoleOutputTimer = new(250);
 
-    private HubConnection? _connection;
     private string _downloadButton = "Download versions";
     private string _startButton = "Start Minecraft";
     private string _username = string.Empty;
@@ -805,7 +772,7 @@ public class MainWindowViewModel : ViewModelBase
 
     private ServerPanelModel CreateServerPanel(string name, string address, string clientName)
     {
-        ServerPanelModel serverPanelModel = new(name, "Wait for update...", "No players.","-/-", address, clientName);
+        ServerPanelModel serverPanelModel = new(name, $"{name}: Updating...", "No players","-/-", address, clientName);
 
         if (SettingsService.Instance.ServerAutoConnectLinks.ContainsKey(name))
             serverPanelModel.UserClientName = SettingsService.Instance.ServerAutoConnectLinks[name];
@@ -820,22 +787,43 @@ public class MainWindowViewModel : ViewModelBase
         };
         timeoutTimer.Start();
 
-        _connection?.On<string>($"Update{serverPanelModel.Name}", (message) =>
-        {
-            McStatus? status = JsonSerializer.Deserialize<McStatus>(message, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
-
-            if(status is not null)
+        Task.Run(async () => {
+            while (true)
             {
-                serverPanelModel.Status = $"{serverPanelModel.Name}: Online";
-                serverPanelModel.Players = $"{status.NumPlayers}/{status.MaxPlayers}";
-                serverPanelModel.Tip = String.Empty;
-                if (UInt16.Parse(status.NumPlayers ?? "0") > 0)
-                    serverPanelModel.Tip = string.Join('\n',status.Players);
-                else
-                    serverPanelModel.Tip = "No players.";
-            }      
-            timeoutTimer.Stop();
-            timeoutTimer.Start();
+                try
+                {
+                    var result = await McProtocol::Client.Ping(address);
+                    try
+                    {
+                        var statusResponse = result.StatusResponse;
+
+                        serverPanelModel.Status = $"{serverPanelModel.Name}: Online";
+                        serverPanelModel.Players = $"{statusResponse?.Players?.Online}/{statusResponse?.Players?.Max}";
+                        serverPanelModel.Tip = String.Empty;
+
+                        McProtocol::StatusResponsePlayersSample[]? sample = statusResponse?.Players?.Sample;
+                        if (sample is not null && sample.Length > 0)
+                        {
+                            var playerNames = sample.Select(player => player.Name).ToArray();
+                            serverPanelModel.Tip = string.Join('\n', playerNames);
+                        }
+                        else
+                        {
+                            serverPanelModel.Tip = "No players";
+                        }
+
+                        timeoutTimer.Stop();
+                        timeoutTimer.Start();
+                    }
+                    catch (Exception ex)
+                    {
+                        _logger.Error($"Failed to update ServerPanelModel {name} with status response: {ex.Message}{(ex.StackTrace is not null ? "\n" + ex.StackTrace : "")}");
+                    }
+                }
+                catch { }
+
+                await Task.Delay(_serverPanelUpdateRelaxDurationMs);
+            }
         });
 
         return serverPanelModel;