初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
using PCL.Core.Utils;
|
||||
using PCL.Core.Utils.OS;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.Link;
|
||||
|
||||
public class BroadcastListener(bool receiveLocalOnly = true) : IDisposable
|
||||
{
|
||||
private UdpClient? _client;
|
||||
private UdpClient? _clientV6;
|
||||
private CancellationTokenSource? _cts;
|
||||
private static readonly IPAddress _MulticastAddress = IPAddress.Parse("224.0.2.60");
|
||||
private static readonly IPAddress _MulticastAddressV6 = IPAddress.Parse("ff75:230::60");
|
||||
private Task? _listenTask;
|
||||
private Task? _listenTaskV6;
|
||||
|
||||
public event Action<BroadcastRecord, IPEndPoint>? OnReceive;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_client is not null || _clientV6 is not null) return;
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
// IPv4
|
||||
_client = new UdpClient();
|
||||
_client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
_client.Client.Bind(new IPEndPoint(IPAddress.Any, 4445));
|
||||
_client.JoinMulticastGroup(_MulticastAddress);
|
||||
|
||||
// IPv6
|
||||
_clientV6 = new UdpClient(AddressFamily.InterNetworkV6);
|
||||
_clientV6.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
_clientV6.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, 4445));
|
||||
_clientV6.JoinMulticastGroup(_MulticastAddressV6);
|
||||
|
||||
_listenTask = _ListenThreadAsync(_client);
|
||||
_listenTaskV6 = _ListenThreadAsync(_clientV6);
|
||||
}
|
||||
|
||||
private async Task _ListenThreadAsync(UdpClient? client)
|
||||
{
|
||||
while (_cts is not null && client is not null && !_cts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await client.ReceiveAsync(_cts.Token);
|
||||
var receivedData = result.Buffer;
|
||||
var senderEndpoint = result.RemoteEndPoint;
|
||||
|
||||
// 转换为 UTF-8 字符串
|
||||
var message = Encoding.UTF8.GetString(receivedData);
|
||||
|
||||
// 解析服务端信息
|
||||
if (!_TryParseServerInfo(message, out var serverInfo) || serverInfo is null) continue;
|
||||
if (receiveLocalOnly && !_isAddressLocal(senderEndpoint.Address)) continue;
|
||||
OnReceive?.Invoke(serverInfo, senderEndpoint);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常取消,不报错
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 忽略解析错误或网络异常,继续监听
|
||||
Console.WriteLine($"Error processing packet: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool _isAddressLocal(IPAddress address)
|
||||
{
|
||||
var ips = NetworkUtils.GetAllLocalAddress();
|
||||
return ips.Contains(address);
|
||||
}
|
||||
|
||||
private static bool _TryParseServerInfo(string rawMessage, out BroadcastRecord? serverInfo)
|
||||
{
|
||||
// 使用正则提取 [MOTD]...[/MOTD] 和 [AD]...[/AD]
|
||||
var motdMatch = RegexPatterns.BroadcastMotd.Match(rawMessage);
|
||||
var adMatch = RegexPatterns.BroadcastAd.Match(rawMessage);
|
||||
|
||||
// 端口是必须的
|
||||
if (!adMatch.Success || !int.TryParse(adMatch.Groups[1].Value.Trim(), out int port))
|
||||
{
|
||||
serverInfo = null;
|
||||
return false; // 端口无效,忽略整个消息
|
||||
}
|
||||
|
||||
serverInfo = new BroadcastRecord(
|
||||
motdMatch.Success ? motdMatch.Groups[1].Value.Trim() : "missing no",
|
||||
new IPEndPoint(IPAddress.Loopback, port),
|
||||
DateTime.Now);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_client is null) return;
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
_client?.Close();
|
||||
_client?.Dispose();
|
||||
_client = null;
|
||||
_clientV6?.Close();
|
||||
_clientV6?.Dispose();
|
||||
_clientV6 = null;
|
||||
|
||||
_listenTask?.Wait(500);
|
||||
_listenTaskV6?.Wait(500);
|
||||
}
|
||||
|
||||
private bool _isDisposed;
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
_isDisposed = true;
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.IO.Net;
|
||||
|
||||
namespace PCL.Core.Link;
|
||||
|
||||
public class BroadcastLocal(string description, int localPort) : IDisposable
|
||||
{
|
||||
private Socket? _broadcastSocket;
|
||||
private CancellationTokenSource? _cts;
|
||||
private bool _isRunning;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_isRunning) return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_isRunning = true;
|
||||
|
||||
// 启动 UDP 广播任务
|
||||
_ = Task.Run(() => _RunUdpBroadcastAsync(_cts.Token), _cts.Token);
|
||||
|
||||
Console.WriteLine($"开始向本地 Minecraft 客户端广播,端口: {localPort}");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!_isRunning) return;
|
||||
|
||||
_cts?.Cancel();
|
||||
_isRunning = false;
|
||||
|
||||
_broadcastSocket?.SafeClose();
|
||||
|
||||
Console.WriteLine("停止向本地 Minecraft 客户端广播");
|
||||
}
|
||||
|
||||
private async Task _RunUdpBroadcastAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_broadcastSocket = new Socket(SocketType.Dgram, ProtocolType.Udp)
|
||||
{
|
||||
DualMode = true
|
||||
};
|
||||
|
||||
var buffer = Encoding.UTF8.GetBytes($"[MOTD]{description}[/MOTD][AD]{localPort}[/AD]");
|
||||
// 向本地地址发送,而不是广播地址
|
||||
var localEndpoint = new IPEndPoint(IPAddress.Loopback, 4445);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _broadcastSocket.SendToAsync(new ArraySegment<byte>(buffer), SocketFlags.None, localEndpoint);
|
||||
await Task.Delay(1500, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"UDP 本地广播错误: {ex.Message}");
|
||||
await Task.Delay(5000, cancellationToken); // 出错后等待 5 秒再重试
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"UDP 本地广播任务发生错误: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_cts?.Dispose();
|
||||
_broadcastSocket.SafeClose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace PCL.Core.Link;
|
||||
|
||||
public record BroadcastRecord(string Desc, IPEndPoint Address, DateTime FoundAt);
|
||||
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.App.Localization;
|
||||
using PCL.Core.IO.Net;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils;
|
||||
using PCL.Core.Utils.Secret;
|
||||
using static PCL.Core.Link.EasyTier.ETInfoProvider;
|
||||
using static PCL.Core.Link.Lobby.LobbyInfoProvider;
|
||||
using static PCL.Core.Link.Natayark.NatayarkProfileManager;
|
||||
|
||||
namespace PCL.Core.Link.EasyTier;
|
||||
// ReSharper disable InconsistentNaming
|
||||
|
||||
public enum ETState
|
||||
{
|
||||
Stopped,
|
||||
Running,
|
||||
Ready
|
||||
}
|
||||
|
||||
public static class ETController
|
||||
{
|
||||
public static Process? ETProcess { get; private set; }
|
||||
public static int ETRpcPort { get; private set; }
|
||||
public static ETState Status { get; internal set; }
|
||||
|
||||
public static int Precheck()
|
||||
{
|
||||
var existedET = Process.GetProcessesByName("easytier-core");
|
||||
foreach (var p in existedET)
|
||||
{
|
||||
LogWrapper.Warn("Link", $"发现已有的 EasyTier 实例,可能影响与启动器所用的实例通信: {p.Id}");
|
||||
}
|
||||
|
||||
// 检查文件
|
||||
LogWrapper.Info("Link", "EasyTier 路径: " + ETPath);
|
||||
if (!(File.Exists(ETPath + "\\easytier-core.exe") && File.Exists(ETPath + "\\easytier-cli.exe") &&
|
||||
File.Exists(ETPath + "\\Packet.dll")))
|
||||
{
|
||||
LogWrapper.Error("Link", "EasyTier 不存在或不完整");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int Launch(bool isHost, string? hostname = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (TargetLobby is null || Precheck() != 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
ETProcess = new Process
|
||||
{
|
||||
EnableRaisingEvents = true,
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = $"{ETPath}\\easytier-core.exe", WorkingDirectory = ETPath,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
}
|
||||
};
|
||||
|
||||
var arguments = new ArgumentsBuilder();
|
||||
|
||||
// 大厅信息
|
||||
var name = TargetLobby.NetworkName;
|
||||
var secret = TargetLobby.NetworkSecret;
|
||||
|
||||
switch (TargetLobby.Type)
|
||||
{
|
||||
case LobbyType.PCLCE:
|
||||
name = ETNetworkNamePrefix + name;
|
||||
secret = ETNetworkSecretPrefix + secret;
|
||||
break;
|
||||
case LobbyType.Terracotta:
|
||||
name = "terracotta-mc-" + name;
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException(Lang.Text("Link.Lobby.UnsupportedType", TargetLobby.Type));
|
||||
}
|
||||
|
||||
arguments.AddFlag("no-tun");
|
||||
arguments.Add("network-name", name);
|
||||
arguments.Add("network-secret", secret);
|
||||
arguments.Add("relay-network-whitelist", name);
|
||||
arguments.Add("private-mode", "true");
|
||||
// 网络参数
|
||||
if (isHost)
|
||||
{
|
||||
LogWrapper.Info("Link", $"本机作为创建者创建大厅,EasyTier 网络名称: {name}");
|
||||
arguments.Add("i", "10.114.51.41");
|
||||
arguments.Add("tcp-whitelist", TargetLobby.Port.ToString());
|
||||
arguments.Add("udp-whitelist", TargetLobby.Port.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
LogWrapper.Info("Link", $"本机作为加入者加入大厅,EasyTier 网络名称: {name}");
|
||||
arguments.AddFlag("d");
|
||||
arguments.Add("tcp-whitelist", "0");
|
||||
arguments.Add("udp-whitelist", "0");
|
||||
|
||||
JoinerLocalPort = NetworkHelper.NewTcpPort();
|
||||
LogWrapper.Info("Link", $"ET 端口转发: 远程 {TargetLobby.Port} -> 本地 {JoinerLocalPort}");
|
||||
arguments.Add("port-forward", $"tcp://127.0.0.1:{JoinerLocalPort}/{TargetLobby.Ip}:{TargetLobby.Port}");
|
||||
arguments.Add("port-forward", $"udp://127.0.0.1:{JoinerLocalPort}/{TargetLobby.Ip}:{TargetLobby.Port}");
|
||||
}
|
||||
|
||||
// 节点设置
|
||||
var relays = ETRelay.RelayList;
|
||||
var customNodes = Config.Link.CustomRelayServer;
|
||||
foreach (var node in customNodes.Split([';'], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (node.Contains("tcp://") || node.Contains("udp://"))
|
||||
{
|
||||
relays.Add(new ETRelay
|
||||
{
|
||||
Url = node,
|
||||
Name = "Custom",
|
||||
Type = ETRelayType.Custom
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
LogWrapper.Warn("Link", $"无效的自定义节点 URL: {node}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var relay in
|
||||
from relay in relays
|
||||
let serverType = Config.Link.ServerType
|
||||
where (relay.Type == ETRelayType.Selfhosted && serverType != 2) ||
|
||||
(relay.Type == ETRelayType.Community && serverType == 1) || relay.Type == ETRelayType.Custom
|
||||
select relay)
|
||||
{
|
||||
arguments.Add("p", relay.Url);
|
||||
}
|
||||
|
||||
// 中继行为设置
|
||||
if (Config.Link.RelayType == LinkRelayBehavior.ForceRelay)
|
||||
{
|
||||
arguments.AddFlag("disable-p2p");
|
||||
}
|
||||
|
||||
// 数据流代理设置
|
||||
arguments.AddFlag("enable-quic-proxy");
|
||||
arguments.AddFlag("enable-kcp-proxy");
|
||||
arguments.AddFlag("use-smoltcp");
|
||||
arguments.Add("encryption-algorithm", "chacha20");
|
||||
arguments.Add("default-protocol", Config.Link.ProtocolPreference.ToString().ToLower());
|
||||
arguments.AddFlagIf(!Config.Link.TryPunchSym, "disable-sym-hole-punching");
|
||||
arguments.AddFlagIf(!Config.Link.EnableIPv6, "disable-ipv6");
|
||||
|
||||
// 用户名与其他参数
|
||||
arguments.AddFlagIf(Config.Link.UseLatencyFirstMode, "latency-first");
|
||||
arguments.Add("compression", "zstd");
|
||||
arguments.AddFlag("multi-thread");
|
||||
arguments.Add("machine-id", Identify.LauncherId);
|
||||
|
||||
// TODO: 等待玩家档案迁移以获取正在使用的档案名称
|
||||
var showName = "default";
|
||||
if (AllowCustomName && !string.IsNullOrWhiteSpace(Config.Link.Username))
|
||||
{
|
||||
showName = Config.Link.Username;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(NaidProfile.Username))
|
||||
{
|
||||
showName = NaidProfile.Username;
|
||||
}
|
||||
|
||||
arguments.Add("hostname",
|
||||
(isHost ? "H|" : "J|") + showName + (!string.IsNullOrWhiteSpace(hostname) ? "|" + hostname : ""));
|
||||
|
||||
// 指定 RPC 端口以避免与其他 ET 实例冲突
|
||||
ETRpcPort = NetworkHelper.NewTcpPort();
|
||||
arguments.Add("rpc-portal", $"127.0.0.1:{ETRpcPort}");
|
||||
|
||||
// 启动
|
||||
ETProcess.StartInfo.Arguments = arguments.GetResult();
|
||||
LogWrapper.Info("Link", "启动 EasyTier");
|
||||
// 操作 UI 显示大厅编号(可能写到 XAML 下面 UI 控制那部分去?)
|
||||
ETProcess.Start();
|
||||
Status = ETState.Running;
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Link", "尝试启动 EasyTier 时遇到问题");
|
||||
Status = ETState.Stopped;
|
||||
ETProcess = null;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Exit()
|
||||
{
|
||||
if (Status == ETState.Stopped || ETProcess is null) return;
|
||||
try
|
||||
{
|
||||
LogWrapper.Info("Link", $"关闭 EasyTier (PID: {ETProcess.Id})");
|
||||
ETProcess.Kill();
|
||||
ETProcess.WaitForExit(200);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
LogWrapper.Warn("Link", "EasyTier 进程不存在,可能已退出");
|
||||
}
|
||||
catch (NullReferenceException)
|
||||
{
|
||||
LogWrapper.Warn("Link", "EasyTier 进程不存在,可能已退出");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Link", "关闭 EasyTier 时遇到问题");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Status = ETState.Stopped;
|
||||
ETProcess = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Link.EasyTier;
|
||||
// ReSharper disable InconsistentNaming, CompareOfFloatsByEqualityOperator
|
||||
|
||||
public enum ETConnectionType
|
||||
{
|
||||
Local,
|
||||
P2P,
|
||||
Relay,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public class ETPlayerInfo
|
||||
{
|
||||
public required bool IsHost { get; init; }
|
||||
/// <summary>
|
||||
/// EasyTier 设置的原始主机名
|
||||
/// </summary>
|
||||
public required string Hostname { get; init; }
|
||||
/// <summary>
|
||||
/// 显示的用户名,依次可能为自定义用户名、NAID 用户名
|
||||
/// </summary>
|
||||
public string? Username { get; init; }
|
||||
public string? McName { get; init; }
|
||||
/// <summary>
|
||||
/// 连接方式
|
||||
/// </summary>
|
||||
public ETConnectionType Cost { get; init; } = ETConnectionType.Unknown;
|
||||
/// <summary>
|
||||
/// 延迟 (ms)
|
||||
/// </summary>
|
||||
public double Ping { get; set; }
|
||||
/// <summary>
|
||||
/// 丢包率 (%)
|
||||
/// </summary>
|
||||
public double Loss { get; init; }
|
||||
public string? NatType { get; init; }
|
||||
/// <summary>
|
||||
/// 节点的 EasyTier 版本
|
||||
/// </summary>
|
||||
public string? ETVersion { get; init; }
|
||||
}
|
||||
|
||||
public static class ETInfoProvider
|
||||
{
|
||||
public const string ETNetworkNamePrefix = "PCLCELobby";
|
||||
public const string ETNetworkSecretPrefix = "PCLCEETLOBBY2025";
|
||||
public const string ETVersion = Scaffolding.EasyTier.EasyTierMetadata.CurrentEasyTierVer;
|
||||
public static readonly string ETPath = Path.Combine(Paths.SharedLocalData, "EasyTier", ETVersion,
|
||||
"easytier-windows-" + (RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x86_64"));
|
||||
|
||||
private static ETConnectionType _GetConnectionType(string cost)
|
||||
{
|
||||
if (IsContains("p2p")) return ETConnectionType.P2P;
|
||||
if (IsContains("relay")) return ETConnectionType.Relay;
|
||||
if (IsContains("local")) return ETConnectionType.Local;
|
||||
return ETConnectionType.Unknown;
|
||||
bool IsContains(string str) => cost.Contains(str, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
private static readonly Process _CliProcess = new() {
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = $"{ETPath}\\easytier-cli.exe",
|
||||
WorkingDirectory = ETPath,
|
||||
Arguments= $"--rpc-portal 127.0.0.1:{ETController.ETRpcPort} -o json peer",
|
||||
ErrorDialog = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 检查 EasyTier 状态,若状态正常则返回 0。
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static async Task<int> CheckETStatusAsync()
|
||||
{
|
||||
var retryCount = 0;
|
||||
var process = ETController.ETProcess;
|
||||
while (process is null && retryCount < 10)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
retryCount++;
|
||||
}
|
||||
if (process is not null)
|
||||
{
|
||||
while (ETController.Status != ETState.Ready)
|
||||
{
|
||||
var info = GetPlayerList().Item1?[0];
|
||||
if (info is null)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
continue;
|
||||
}
|
||||
if (info.Ping != 1000) { ETController.Status = ETState.Ready; }
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取 EasyTier 网络的成员列表和本地信息,若获取失败则均返回 null。
|
||||
/// </summary>
|
||||
/// <returns>Tuple(玩家列表, 本地信息)</returns>
|
||||
public static Tuple<List<ETPlayerInfo>?, ETPlayerInfo?> GetPlayerList()
|
||||
{
|
||||
try
|
||||
{
|
||||
_CliProcess.StartInfo.Arguments = $"--rpc-portal 127.0.0.1:{ETController.ETRpcPort} -o json peer";
|
||||
_CliProcess.Start();
|
||||
_CliProcess.WaitForExit(180);
|
||||
|
||||
var output = _CliProcess.StandardOutput.ReadToEnd() + _CliProcess.StandardError.ReadToEnd();
|
||||
if (!_CliProcess.HasExited)
|
||||
{
|
||||
LogWrapper.Warn("Link", "Cli 获取结果超时(180 ms),程序状态可能异常!");
|
||||
LogWrapper.Warn("Link", "获取到 EasyTier Cli 信息: \r\n" + output);
|
||||
}
|
||||
|
||||
var playerList = new List<ETPlayerInfo>();
|
||||
ETPlayerInfo? localInfo = null;
|
||||
if (JsonCompat.ParseNode(output) is not JsonArray json)
|
||||
return new Tuple<List<ETPlayerInfo>?, ETPlayerInfo?>(null, null);
|
||||
foreach (var p in json)
|
||||
{
|
||||
var info = p.Deserialize<ETPeerInfo>(JsonCompat.SerializerOptions);
|
||||
if (info is null) { continue; }
|
||||
if (info.Hostname.StartsWith("PublicServer")) { continue; } // 服务器
|
||||
var hostnameSplit = info.Hostname.Split('|');
|
||||
var playerInfo = new ETPlayerInfo
|
||||
{
|
||||
IsHost = info.Hostname.StartsWith("H|") || info.Ipv4 == "10.144.144.1",
|
||||
Hostname = info.Hostname,
|
||||
Username = hostnameSplit.Length >= 2 ? hostnameSplit[1] : null,
|
||||
McName = hostnameSplit.Length == 3 ? hostnameSplit[2] : null,
|
||||
Cost = _GetConnectionType(info.Cost),
|
||||
Ping = Math.Round(Convert.ToDouble(info.Ping != "-" ? info.Ping : "0")),
|
||||
Loss = Math.Round(Convert.ToDouble(info.Loss != "-" ? info.Loss.Replace("%", "") : "0")),
|
||||
NatType = info.NatType,
|
||||
ETVersion = info.ETVersion
|
||||
};
|
||||
|
||||
if (playerInfo.IsHost)
|
||||
{
|
||||
playerList.Insert(0, playerInfo); // 主机信息放在列表首位
|
||||
}
|
||||
else
|
||||
{
|
||||
playerList.Add(playerInfo);
|
||||
}
|
||||
if (playerInfo.Cost == ETConnectionType.Local)
|
||||
{
|
||||
localInfo = playerInfo;
|
||||
}
|
||||
}
|
||||
return new Tuple<List<ETPlayerInfo>?, ETPlayerInfo?>(playerList, localInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex,"Link", "获取 EasyTier 网络成员列表失败");
|
||||
return new Tuple<List<ETPlayerInfo>?, ETPlayerInfo?>(null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link.EasyTier;
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
public record ETPeerInfo
|
||||
{
|
||||
[JsonPropertyName("hostname")] public required string Hostname { get; init; }
|
||||
[JsonPropertyName("ipv4")] public required string Ipv4 { get; init; }
|
||||
[JsonPropertyName("cost")] public required string Cost { get; init; }
|
||||
[JsonPropertyName("lat_ms")] public required string Ping { get; init; }
|
||||
[JsonPropertyName("loss_rate")] public required string Loss { get; init; }
|
||||
[JsonPropertyName("nat_type")] public required string NatType { get; init; }
|
||||
[JsonPropertyName("version")] public required string ETVersion { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.Link.EasyTier;
|
||||
// ReSharper disable InconsistentNaming
|
||||
|
||||
public class ETRelay
|
||||
{
|
||||
public static List<ETRelay> RelayList { get; set; } = [];
|
||||
|
||||
public required string Url { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public ETRelayType Type { get; init; }
|
||||
}
|
||||
|
||||
public enum ETRelayType
|
||||
{
|
||||
Community,
|
||||
Selfhosted,
|
||||
Custom
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link;
|
||||
|
||||
public enum LinkAnnounceType
|
||||
{
|
||||
[JsonStringEnumMemberName("notice")] Notice,
|
||||
[JsonStringEnumMemberName("warning")] Warning,
|
||||
[JsonStringEnumMemberName("important")] Important
|
||||
}
|
||||
|
||||
public record LinkAnnounceInfo(
|
||||
[property:JsonPropertyName("type")] LinkAnnounceType Type,
|
||||
[property:JsonPropertyName("content")] string Content
|
||||
);
|
||||
|
||||
public record LinkAnnounce(
|
||||
[property:JsonPropertyName("notices")] LinkAnnounceInfo[] Announces
|
||||
);
|
||||
@@ -0,0 +1,302 @@
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.App.Localization;
|
||||
using PCL.Core.Link.EasyTier;
|
||||
using PCL.Core.Link.Scaffolding;
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Link.Scaffolding.Client.Requests;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using PCL.Core.Utils.OS;
|
||||
using PCL.Core.Utils.Secret;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.IO.Net;
|
||||
using static PCL.Core.Link.Lobby.LobbyInfoProvider;
|
||||
using static PCL.Core.Link.Natayark.NatayarkProfileManager;
|
||||
using LobbyType = PCL.Core.Link.Scaffolding.Client.Models.LobbyType;
|
||||
using PCL.Core.Link.McPing;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
|
||||
namespace PCL.Core.Link.Lobby;
|
||||
|
||||
/// <summary>
|
||||
/// The controller of lobby that used for creating Scaffolding entity.
|
||||
/// </summary>
|
||||
public sealed class LobbyController
|
||||
{
|
||||
/// <summary>
|
||||
/// Demonstrate the current lobby is host or joiner.
|
||||
/// </summary>
|
||||
public bool IsHost = false;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffolding client entity.
|
||||
/// </summary>
|
||||
public ScaffoldingClientEntity? ScfClientEntity;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffolding server entity.
|
||||
/// </summary>
|
||||
public ScaffoldingServerEntity? ScfServerEntity;
|
||||
|
||||
/// <summary>
|
||||
/// Launch a Scaffolding Client.
|
||||
/// </summary>
|
||||
/// <param name="username">Join user name.</param>
|
||||
/// <param name="code">Lobby share code.</param>
|
||||
/// <returns>Created <see cref="ScaffoldingClientEntity"/>.</returns>
|
||||
public async Task<ScaffoldingClientEntity?> LaunchClientAsync(string username, string code, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _SendTelemetryAsync(false).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var scfEntity = await ScaffoldingFactory
|
||||
.CreateClientAsync(username, code, LobbyType.Scaffolding, ct).ConfigureAwait(false);
|
||||
|
||||
ScfClientEntity = scfEntity;
|
||||
|
||||
await scfEntity.Client.ConnectAsync().ConfigureAwait(false);
|
||||
|
||||
var port = await scfEntity.Client.SendRequestAsync(new GetServerPortRequest()).ConfigureAwait(false);
|
||||
|
||||
var hostname = string.Empty;
|
||||
|
||||
while (scfEntity.Client.PlayerList is null)
|
||||
{
|
||||
await Task.Delay(800).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var profile in scfEntity.Client.PlayerList)
|
||||
{
|
||||
if (profile.Kind == PlayerKind.HOST)
|
||||
{
|
||||
hostname = profile.Name;
|
||||
LogWrapper.Debug($"大厅创建者的用户名: {hostname}");
|
||||
}
|
||||
}
|
||||
|
||||
var localPort = await scfEntity.EasyTier
|
||||
.AddPortForwardAsync(scfEntity.HostInfo.Ip, port)
|
||||
.ConfigureAwait(false);
|
||||
var desc = hostname.IsNullOrWhiteSpace()
|
||||
? string.Empty
|
||||
: Lang.Text("Link.Lobby.MotdDesc", hostname);
|
||||
var tcpPortForForward = NetworkHelper.NewTcpPort();
|
||||
|
||||
McForward = new TcpForward(IPAddress.Loopback, tcpPortForForward, IPAddress.Loopback, localPort);
|
||||
McBroadcast = new BroadcastLocal(Lang.Text("Link.Lobby.MotdFormat", desc), tcpPortForForward);
|
||||
McForward.Start();
|
||||
McBroadcast.Start();
|
||||
|
||||
return scfEntity;
|
||||
}
|
||||
catch (ArgumentNullException e)
|
||||
{
|
||||
LogWrapper.Error(e, "大厅创建者的用户名为空");
|
||||
}
|
||||
catch (ArgumentException e)
|
||||
{
|
||||
if (e.Message.Contains("lobby code"))
|
||||
{
|
||||
LogWrapper.Error(e, "大厅编号无效");
|
||||
}
|
||||
else if (e.Message.Contains("hostname"))
|
||||
{
|
||||
LogWrapper.Error(e, "大厅创建者的用户名无效");
|
||||
}
|
||||
else
|
||||
{
|
||||
LogWrapper.Error(e, "在加入大厅时出现意外的无效参数");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogWrapper.Error(e, "在加入大厅时发生意外错误");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launch a Scaffolding Server.
|
||||
/// </summary>
|
||||
/// <param name="username">Host user name.</param>
|
||||
/// <param name="port">Minecraft port.</param>
|
||||
/// <returns>Created <see cref="ScaffoldingServerEntity"/>.</returns>
|
||||
/// <remarks>
|
||||
/// Because of event handling of Scaffolding Server. You SHOULD start the server on your own.
|
||||
/// </remarks>
|
||||
public async Task<ScaffoldingServerEntity?> LaunchServerAsync(string username, int port)
|
||||
{
|
||||
if (!await _SendTelemetryAsync(true).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var scfEntity = ScaffoldingFactory.CreateServer(port, username);
|
||||
ScfServerEntity = scfEntity;
|
||||
IsHost = true;
|
||||
|
||||
LogWrapper.Info("LobbyController", "Successfully to launch Scaffolding Server.");
|
||||
|
||||
return scfEntity;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogWrapper.Error(e, "Occurred error when launching Scafolding Server.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查主机的 MC 实例是否可用。
|
||||
/// </summary>
|
||||
public static async Task<bool> IsHostInstanceAvailableAsync(int port)
|
||||
{
|
||||
using var ping = McPingServiceFactory.CreateService("127.0.0.1", port);
|
||||
var info = await ping.PingAsync().ConfigureAwait(false);
|
||||
|
||||
if (info is not null) return true;
|
||||
|
||||
LogWrapper.Warn("Link", $"本地 MC 局域网实例 ({port}) 疑似已关闭");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 退出大厅。这将同时关闭 EasyTier 和 MC 端口转发,需要自行清理 UI。
|
||||
/// </summary>
|
||||
public async Task<int> CloseAsync()
|
||||
{
|
||||
McForward?.Stop();
|
||||
McBroadcast?.Stop();
|
||||
try
|
||||
{
|
||||
if (ScfClientEntity is not null)
|
||||
{
|
||||
await ScfClientEntity.EasyTier.StopAsync().ConfigureAwait(false);
|
||||
await ScfClientEntity.Client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
else if (ScfServerEntity is not null)
|
||||
{
|
||||
await ScfServerEntity.EasyTier.StopAsync().ConfigureAwait(false);
|
||||
await ScfServerEntity.Server.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ScfClientEntity = null;
|
||||
ScfServerEntity = null;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<bool> _SendTelemetryAsync(bool isHost)
|
||||
{
|
||||
LogWrapper.Info("Link", "开始发送联机数据");
|
||||
var servers = Config.Link.CustomRelayServer;
|
||||
var serverType = Config.Link.ServerType;
|
||||
|
||||
if (Config.Link.ServerType != 2)
|
||||
{
|
||||
servers = (
|
||||
from relay in ETRelay.RelayList
|
||||
where (relay.Type == ETRelayType.Selfhosted && serverType != 2) || (relay.Type == ETRelayType.Community && serverType == 1)
|
||||
select relay
|
||||
).Aggregate(servers, (current, relay) => current + $"{relay.Url};");
|
||||
}
|
||||
|
||||
JsonObject data = new()
|
||||
{
|
||||
["Tag"] = "Link",
|
||||
["Id"] = Identify.LauncherId,
|
||||
["NaidId"] = NaidProfile.Id,
|
||||
["NaidEmail"] = NaidProfile.Email,
|
||||
["NaidLastIp"] = NaidProfile.LastIp,
|
||||
["CustomName"] = Config.Link.Username,
|
||||
["Servers"] = servers,
|
||||
["IsHost"] = isHost
|
||||
};
|
||||
JsonObject sendData = new() { ["data"] = data };
|
||||
|
||||
try
|
||||
{
|
||||
HttpContent httpContent = new StringContent(sendData.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
var key = EnvironmentInterop.GetSecret("TelemetryKey");
|
||||
if (key is null)
|
||||
{
|
||||
if (RequiresLogin)
|
||||
{
|
||||
LogWrapper.Error("Link", "联机数据发送失败,未设置 TelemetryKey");
|
||||
return false;
|
||||
}
|
||||
LogWrapper.Warn("Link", "联机数据发送失败,未设置 TelemetryKey,跳过发送");
|
||||
}
|
||||
else
|
||||
{
|
||||
using var response = await HttpRequest
|
||||
.CreatePost("https://pcl2ce.pysio.online/post")
|
||||
.WithContent(httpContent)
|
||||
.WithBearerToken(key)
|
||||
.SendAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccess)
|
||||
{
|
||||
if (RequiresLogin)
|
||||
{
|
||||
LogWrapper.Error("Link", "联机数据发送失败,响应内容为空");
|
||||
return false;
|
||||
}
|
||||
LogWrapper.Warn("Link", "联机数据发送失败,响应内容为空,跳过发送");
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = await response.AsStringAsync().ConfigureAwait(false);
|
||||
if (result.Contains("数据已成功保存"))
|
||||
{
|
||||
LogWrapper.Info("Link", "联机数据已发送");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (RequiresLogin)
|
||||
{
|
||||
LogWrapper.Error("Link", "联机数据发送失败,响应内容: " + result);
|
||||
return false;
|
||||
}
|
||||
LogWrapper.Warn("Link", "联机数据发送失败,跳过发送,响应内容: " + result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (RequiresLogin)
|
||||
{
|
||||
LogWrapper.Error(ex, "Link",
|
||||
ex.Message.Contains("429") ? "联机数据发送失败,请求过于频繁" : "联机数据发送失败");
|
||||
return false;
|
||||
}
|
||||
LogWrapper.Warn(ex, "Link", "联机数据发送失败,跳过发送");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.IO.Net;
|
||||
using PCL.Core.Link.Natayark;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.Link.Lobby;
|
||||
|
||||
public static class LobbyInfoProvider
|
||||
{
|
||||
public static bool IsLobbyAvailable { get; set; } = false;
|
||||
public static bool AllowCustomName { get; set; } = false;
|
||||
public static bool RequiresLogin { get; set; } = true;
|
||||
public static bool RequiresRealName { get; set; } = true;
|
||||
public static int ProtocolVersion { get; set; } = 6;
|
||||
|
||||
public static BroadcastLocal? McBroadcast { get; internal set; }
|
||||
public static TcpForward? McForward { get; internal set; }
|
||||
|
||||
public class LobbyInfo
|
||||
{
|
||||
public required string OriginalCode { get; init; }
|
||||
public required LobbyType Type { get; init; }
|
||||
public required string NetworkName { get; init; }
|
||||
public required string NetworkSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 远程 IP 地址,需要先解析大厅类型再填充
|
||||
/// </summary>
|
||||
public string? Ip { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标游戏端口
|
||||
/// </summary>
|
||||
public required int Port { get; init; }
|
||||
}
|
||||
|
||||
public enum LobbyType
|
||||
{
|
||||
// ReSharper disable once InconsistentNaming
|
||||
PCLCE,
|
||||
Terracotta
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目标大厅
|
||||
/// </summary>
|
||||
public static LobbyInfo? TargetLobby { get; set; }
|
||||
public static int JoinerLocalPort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 解析大厅编号,并返回 LobbyInfo 对象。若解析失败则返回 null。
|
||||
/// </summary>
|
||||
public static LobbyInfo? ParseCode(string code)
|
||||
{
|
||||
code = code.Trim().ToUpper();
|
||||
if (string.IsNullOrWhiteSpace(code) || code.Length < 9 || !code.IsASCII())
|
||||
{
|
||||
LogWrapper.Error("Link", "无效的大厅编号: " + code);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (code.Split("-".ToCharArray()).Length != 5) // PCL CE 大厅
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = code.FromB32ToB10();
|
||||
return new LobbyInfo
|
||||
{
|
||||
OriginalCode = code,
|
||||
NetworkName = info[..8],
|
||||
NetworkSecret = info[8..10],
|
||||
Port = int.Parse(info[10..]),
|
||||
Type = LobbyType.PCLCE,
|
||||
Ip = "10.114.51.41"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Link", "大厅编号解析失败,可能是无效的 PCL CE 大厅编号: " + code);
|
||||
}
|
||||
}
|
||||
else // 陶瓦
|
||||
{
|
||||
var matches = code.RegexSearch(RegexPatterns.TerracottaId);
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
LogWrapper.Error("Link", "大厅编号解析失败,可能是无效的陶瓦大厅编号: " + code);
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var codeString = match.Replace("I", "1").Replace("O", "0").Replace("-", "");
|
||||
BigInteger value = 0;
|
||||
var checking = 0;
|
||||
const string baseChars = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
for (var i = 0; i <= 23; i++)
|
||||
{
|
||||
var j = baseChars.IndexOf(codeString[i]);
|
||||
value += BigInteger.Parse(j.ToString()) * BigInteger.Pow(34, i);
|
||||
checking = (j + checking) % 34;
|
||||
}
|
||||
|
||||
if (checking != baseChars.IndexOf(codeString[24])) { return null; }
|
||||
var port = (int)(value % 65536);
|
||||
if (port < 100) { return null; }
|
||||
return new LobbyInfo
|
||||
{
|
||||
OriginalCode = code,
|
||||
NetworkName = codeString.Substring(0, 15).ToLower(),
|
||||
NetworkSecret = codeString.Substring(15, 10).ToLower(),
|
||||
Port = port,
|
||||
Type = LobbyType.Terracotta,
|
||||
Ip = "10.144.144.1"
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于联机显示的用户名
|
||||
/// </summary>
|
||||
public static string? GetUsername() => AllowCustomName
|
||||
? Config.Link.Username.ReplaceNullOrEmpty(NatayarkProfileManager.NaidProfile.Username)
|
||||
: NatayarkProfileManager.NaidProfile.Username;
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.App.Localization;
|
||||
using PCL.Core.Link.Natayark;
|
||||
using PCL.Core.Link.Scaffolding;
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Link.Scaffolding.EasyTier;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using PCL.Core.App.IoC;
|
||||
using PCL.Core.UI;
|
||||
using PCL.Core.Link.McPing;
|
||||
|
||||
namespace PCL.Core.Link.Lobby;
|
||||
|
||||
/// <summary>
|
||||
/// Lobby server. For auto-management
|
||||
/// </summary>
|
||||
[LifecycleService(LifecycleState.Loaded)]
|
||||
public class LobbyService() : GeneralService("lobby", "LobbyService")
|
||||
{
|
||||
private static readonly LobbyController _LobbyController = new();
|
||||
private static CancellationTokenSource _lobbyCts = new();
|
||||
|
||||
private static Task? _discoveringTask;
|
||||
private static CancellationTokenSource _discoveringCts = new();
|
||||
|
||||
private static readonly Timer _ServerGameWatcher =
|
||||
new(_CheckGameState, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||
|
||||
private static bool _isGameWatcherRunnable = false;
|
||||
private static int _isLeaving;
|
||||
|
||||
/// <summary>
|
||||
/// Current lobby state.
|
||||
/// </summary>
|
||||
public static LobbyState CurrentState { get; private set; } = LobbyState.Idle;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Founded local Minecraft worlds.
|
||||
/// </summary>
|
||||
public static ObservableCollection<FoundWorld> DiscoveredWorlds { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Current players in current lobby.
|
||||
/// </summary>
|
||||
public static ObservableCollection<PlayerProfile> Players { get; private set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrate whether the current user is the host of the lobby.
|
||||
/// </summary>
|
||||
public static bool IsHost => _LobbyController.IsHost;
|
||||
|
||||
/// <summary>
|
||||
/// Current lobby full code.
|
||||
/// </summary>
|
||||
public static string? CurrentLobbyCode { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current lobby username.
|
||||
/// </summary>
|
||||
public static string? CurrentUserName { get; private set; }
|
||||
|
||||
#region UI Events
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when lobby state changed. (first arg is the old state; second arg is the new state.)
|
||||
/// </summary>
|
||||
public static event Action<LobbyState, LobbyState>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when need to download EasyTier core files.
|
||||
/// </summary>
|
||||
public static event Action? OnNeedDownloadEasyTier;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when user stop the game in server mode.
|
||||
/// </summary>
|
||||
public static event Action? OnUserStopGame;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when client ping happened.
|
||||
/// </summary>
|
||||
public static event Action<long>? OnClientPing;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when server shut down.
|
||||
/// </summary>
|
||||
public static event Action? OnServerShutDown;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when server started successfully.
|
||||
/// </summary>
|
||||
public static event Action? OnServerStarted;
|
||||
|
||||
public static event Action<Exception>? OnServerException;
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Stop()
|
||||
{
|
||||
_lobbyCts.Cancel();
|
||||
_LobbyController.CloseAsync().GetAwaiter().GetResult();
|
||||
_ServerGameWatcher.Dispose();
|
||||
_lobbyCts.Dispose();
|
||||
|
||||
_discoveringCts.Cancel();
|
||||
if (_discoveringTask is not null)
|
||||
{
|
||||
Task.WhenAll(_discoveringTask);
|
||||
_discoveringTask.Dispose();
|
||||
}
|
||||
|
||||
_discoveringCts.Dispose();
|
||||
}
|
||||
|
||||
private static bool _IsEasyTierCoreFileNotExist() =>
|
||||
!File.Exists(Path.Combine(EasyTierMetadata.EasyTierFilePath, "easytier-core.exe")) &&
|
||||
!File.Exists(Path.Combine(EasyTierMetadata.EasyTierFilePath, "Packet.dll")) &&
|
||||
!File.Exists(Path.Combine(EasyTierMetadata.EasyTierFilePath, "easytier-cli.exe"));
|
||||
|
||||
|
||||
public static async Task InitializeAsync()
|
||||
{
|
||||
if (CurrentState is not LobbyState.Idle && CurrentState is not LobbyState.Error)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_SetState(LobbyState.Initializing);
|
||||
try
|
||||
{
|
||||
if (_IsEasyTierCoreFileNotExist())
|
||||
{
|
||||
LogWrapper.Info("LobbyService", "EasyTier not found, starting download.");
|
||||
OnNeedDownloadEasyTier?.Invoke();
|
||||
}
|
||||
else
|
||||
{
|
||||
LogWrapper.Info("LobbyService", "EasyTier files check completed.");
|
||||
}
|
||||
|
||||
// refresh naid token
|
||||
var naidRefreshToken = States.Link.NaidRefreshToken;
|
||||
if (!string.IsNullOrWhiteSpace(naidRefreshToken))
|
||||
{
|
||||
var expTime = States.Link.NaidRefreshExpireTime;
|
||||
if (!string.IsNullOrWhiteSpace(expTime) &&
|
||||
Convert.ToDateTime(expTime).CompareTo(DateTime.Now) < 0)
|
||||
{
|
||||
States.Link.NaidRefreshToken = string.Empty;
|
||||
HintWrapper.Show(Lang.Text("Tools.GameLink.Natayark.TokenExpired"), HintTheme.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
await NatayarkProfileManager.GetNaidDataAsync(naidRefreshToken, true).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
_SetState(LobbyState.Initialized);
|
||||
LogWrapper.Info("LobbyService", "Lobby service initialized successfully.");
|
||||
|
||||
_ = DiscoverWorldAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "LobbyService", "Lobby service initialization failed.");
|
||||
HintWrapper.Show(Lang.Text("Link.Lobby.InitFailed"), HintTheme.Error);
|
||||
_SetState(LobbyState.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discover minecraft shared world.
|
||||
/// </summary>
|
||||
public static async Task DiscoverWorldAsync()
|
||||
{
|
||||
if (_discoveringCts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (CurrentState is not LobbyState.Initialized && CurrentState is not LobbyState.Idle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_SetState(LobbyState.Discovering);
|
||||
await _RunInUiAsync(() => DiscoveredWorlds.Clear()).ConfigureAwait(false);
|
||||
|
||||
_discoveringTask = Task.Run(async () =>
|
||||
{
|
||||
var recordedPorts = new ConcurrentSet<int>();
|
||||
using var listener = new BroadcastListener();
|
||||
|
||||
var handler = new Action<BroadcastRecord, IPEndPoint>((info, _) => Task.Run(async () =>
|
||||
{
|
||||
if (!recordedPorts.TryAdd(info.Address.Port)) return;
|
||||
|
||||
using var pinger = McPingServiceFactory.CreateService(new IPEndPoint(IPAddress.Loopback, info.Address.Port));
|
||||
using var cts = new CancellationTokenSource(2000);
|
||||
|
||||
try
|
||||
{
|
||||
var pingRes = await pinger.PingAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (pingRes is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(pingRes), "Failed to ping minecraft entity.");
|
||||
}
|
||||
|
||||
var worldName = Lang.Text("Link.Lobby.WorldNameFormat",
|
||||
pingRes.Description,
|
||||
pingRes.Version.Name,
|
||||
info.Address.Port);
|
||||
await _RunInUiAsync(() => DiscoveredWorlds.Add(new FoundWorld(worldName, info.Address.Port)))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "LobbyService", $"Pinging port {info.Address.Port} failed.");
|
||||
}
|
||||
}));
|
||||
|
||||
listener.OnReceive += handler;
|
||||
listener.Start();
|
||||
await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
|
||||
listener.OnReceive -= handler;
|
||||
}, _discoveringCts.Token);
|
||||
|
||||
_SetState(LobbyState.Initialized);
|
||||
}
|
||||
|
||||
private static bool _NotHaveNaid() =>
|
||||
LobbyInfoProvider.RequiresLogin &&
|
||||
string.IsNullOrWhiteSpace(NatayarkProfileManager.NaidProfile.AccessToken);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new lobby.
|
||||
/// </summary>
|
||||
/// <param name="port">Minecraft share port.</param>
|
||||
/// <param name="username">Player name.</param>
|
||||
public static async Task<bool> CreateLobbyAsync(int port, string username)
|
||||
{
|
||||
if (_NotHaveNaid())
|
||||
{
|
||||
HintWrapper.Show(Lang.Text("Link.Lobby.LoginRequired"), HintTheme.Error);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _discoveringCts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
_SetState(LobbyState.Creating);
|
||||
try
|
||||
{
|
||||
CurrentUserName = username;
|
||||
|
||||
var serverEntity = await _LobbyController.LaunchServerAsync(username, port).ConfigureAwait(false);
|
||||
if (serverEntity is null)
|
||||
{
|
||||
HintWrapper.Show(Lang.Text("Link.Lobby.CreateRoomFailed"), HintTheme.Error);
|
||||
return false;
|
||||
}
|
||||
|
||||
CurrentLobbyCode = serverEntity.EasyTier.Lobby.FullCode;
|
||||
|
||||
serverEntity.Server.ServerStopped += () => OnServerShutDown?.Invoke();
|
||||
serverEntity.Server.PlayerProfilePing += _ServerOnPlayerPing;
|
||||
serverEntity.Server.ServerStarted += _ServerOnServerStarted;
|
||||
serverEntity.Server.ServerException += _ServerOnServerException;
|
||||
//serverEntity.EasyTier.EasyTierProcessExisted += () =>
|
||||
//{
|
||||
// OnHint?.Invoke("EasyTierCore异常退出", CoreHintType.Critical);
|
||||
// OnServerShutDown?.Invoke();
|
||||
//}; this code will be invoked when EasyTier process successfully exited, not in failed state.
|
||||
|
||||
serverEntity.Server.Start();
|
||||
|
||||
_SetState(LobbyState.Connected);
|
||||
_isGameWatcherRunnable = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "LobbyService", "Failed to create lobby.");
|
||||
HintWrapper.Show(Lang.Text("Link.Lobby.CreateLobbyFailed"), HintTheme.Error);
|
||||
await LeaveLobbyAsync().ConfigureAwait(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void _ServerOnServerException(Exception? ex)
|
||||
{
|
||||
if (ex is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnServerException?.Invoke(ex);
|
||||
}
|
||||
|
||||
private static void _ServerOnServerStarted(IReadOnlyList<PlayerProfile> profiles)
|
||||
{
|
||||
LogWrapper.Debug("LobbyService", "Send server started event.");
|
||||
OnServerStarted?.Invoke();
|
||||
_ServerOnPlayerPing(profiles);
|
||||
}
|
||||
|
||||
private static void _ServerOnPlayerPing(IReadOnlyList<PlayerProfile> players)
|
||||
{
|
||||
_ = _RunInUiAsync(() =>
|
||||
{
|
||||
var currentMachineIds = new HashSet<string>(Players.Select(p => p.MachineId));
|
||||
var newMachineIds = new HashSet<string>(players.Select(p => p.MachineId));
|
||||
|
||||
if (currentMachineIds.SetEquals(newMachineIds))
|
||||
{
|
||||
LogWrapper.Debug("Player list has not changed");
|
||||
return; // nothing was changed
|
||||
}
|
||||
|
||||
LogWrapper.Debug("LobbyService", "Player list membership has changed, updating UI.");
|
||||
|
||||
var sortedNewPlayers = PlayerListHandler.Sort(players);
|
||||
|
||||
var idsToRemove = currentMachineIds.Except(newMachineIds).ToList();
|
||||
if (idsToRemove.Any())
|
||||
{
|
||||
var playersToRemove = Players.Where(p => idsToRemove.Contains(p.MachineId)).ToList();
|
||||
foreach (var player in playersToRemove)
|
||||
{
|
||||
Players.Remove(player);
|
||||
}
|
||||
}
|
||||
|
||||
var idsToAdd = newMachineIds.Except(currentMachineIds).ToList();
|
||||
if (idsToAdd.Any())
|
||||
{
|
||||
var playersToAdd = sortedNewPlayers.Where(p => idsToAdd.Contains(p.MachineId)).ToList();
|
||||
foreach (var player in playersToAdd)
|
||||
{
|
||||
Players.Add(player);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Join an exist lobby.
|
||||
/// </summary>
|
||||
/// <param name="lobbyCode">Lobby share code.</param>
|
||||
/// <param name="username">Current use name.</param>
|
||||
public static async Task<bool> JoinLobbyAsync(string lobbyCode, string username)
|
||||
{
|
||||
await _discoveringCts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
_SetState(LobbyState.Joining);
|
||||
|
||||
LogWrapper.Info("LobbyService", $"Try to join lobby {lobbyCode}");
|
||||
|
||||
try
|
||||
{
|
||||
CurrentUserName = username;
|
||||
CurrentLobbyCode = lobbyCode;
|
||||
|
||||
var clientEntity = await _LobbyController.LaunchClientAsync(username, lobbyCode, _lobbyCts.Token).ConfigureAwait(false);
|
||||
|
||||
if (clientEntity is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
Lang.Text("Link.Lobby.JoinFailed"));
|
||||
}
|
||||
|
||||
clientEntity.Client.Heartbeat += _ClientOnHeartbeat;
|
||||
clientEntity.Client.ServerShuttedDown += _ClientOnServerShutDown;
|
||||
|
||||
_SetState(LobbyState.Connected);
|
||||
}
|
||||
catch (ArgumentException codeEx)
|
||||
{
|
||||
LogWrapper.Error(codeEx, "LobbyService", $"Failed to join lobby {lobbyCode}.");
|
||||
HintWrapper.Show(Lang.Text("Link.Lobby.InvalidCodeFormat"), HintTheme.Error);
|
||||
await LeaveLobbyAsync().ConfigureAwait(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "LobbyService", $"Failed to join lobby {lobbyCode}.");
|
||||
HintWrapper.Show(ex.Message, HintTheme.Error);
|
||||
await LeaveLobbyAsync().ConfigureAwait(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void _ClientOnServerShutDown()
|
||||
{
|
||||
OnServerShutDown?.Invoke();
|
||||
}
|
||||
|
||||
private static void _ClientOnHeartbeat(IReadOnlyList<PlayerProfile> players, long latency)
|
||||
{
|
||||
_ = _RunInUiAsync(() =>
|
||||
{
|
||||
var currentMachineIds = new HashSet<string>(Players.Select(p => p.MachineId));
|
||||
var newMachineIds = new HashSet<string>(players.Select(p => p.MachineId));
|
||||
|
||||
if (currentMachineIds.SetEquals(newMachineIds))
|
||||
{
|
||||
return; // nothing was changed
|
||||
}
|
||||
|
||||
LogWrapper.Debug("LobbyService", "Player list membership has changed, updating UI.");
|
||||
|
||||
var sortedNewPlayers = PlayerListHandler.Sort(players);
|
||||
|
||||
var idsToRemove = currentMachineIds.Except(newMachineIds).ToList();
|
||||
if (idsToRemove.Any())
|
||||
{
|
||||
var playersToRemove = Players.Where(p => idsToRemove.Contains(p.MachineId)).ToList();
|
||||
foreach (var player in playersToRemove)
|
||||
{
|
||||
Players.Remove(player);
|
||||
}
|
||||
}
|
||||
|
||||
var idsToAdd = newMachineIds.Except(currentMachineIds).ToList();
|
||||
if (idsToAdd.Any())
|
||||
{
|
||||
var playersToAdd = sortedNewPlayers.Where(p => idsToAdd.Contains(p.MachineId)).ToList();
|
||||
foreach (var player in playersToAdd)
|
||||
{
|
||||
Players.Add(player);
|
||||
}
|
||||
}
|
||||
|
||||
OnClientPing?.Invoke(latency);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Leave from lobby.
|
||||
/// </summary>
|
||||
public static async Task LeaveLobbyAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _isLeaving, 1) == 1)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_SetState(LobbyState.Leaving);
|
||||
|
||||
await _lobbyCts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
Players.Clear();
|
||||
CurrentLobbyCode = null;
|
||||
CurrentUserName = null;
|
||||
|
||||
if (_LobbyController.ScfClientEntity?.Client is not null)
|
||||
{
|
||||
_LobbyController.ScfClientEntity.Client.Heartbeat -= _ClientOnHeartbeat;
|
||||
}
|
||||
|
||||
if (_LobbyController.ScfServerEntity?.Server is not null)
|
||||
{
|
||||
_LobbyController.ScfServerEntity.Server.PlayerProfilePing -= _ServerOnPlayerPing;
|
||||
_LobbyController.ScfServerEntity.Server.ServerStarted -= _ServerOnServerStarted;
|
||||
}
|
||||
|
||||
await _LobbyController.CloseAsync().ConfigureAwait(false);
|
||||
|
||||
|
||||
_lobbyCts = new CancellationTokenSource();
|
||||
_SetState(LobbyState.Initialized);
|
||||
|
||||
LogWrapper.Info("LobbyService", "Left lobby and cleaned up resources.");
|
||||
|
||||
_isGameWatcherRunnable = false;
|
||||
|
||||
_discoveringCts = new CancellationTokenSource();
|
||||
_ = DiscoverWorldAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "LobbyService", "Failed when leave lobby.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLeaving = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static void _SetState(LobbyState newState)
|
||||
{
|
||||
var oldState = CurrentState;
|
||||
if (oldState == newState)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentState = newState;
|
||||
|
||||
LogWrapper.Info("LobbyService", $"Lobby state changed from {oldState} to {newState}");
|
||||
|
||||
StateChanged?.Invoke(oldState, newState);
|
||||
}
|
||||
|
||||
private static void _CheckGameState(object? state)
|
||||
{
|
||||
if (!_isGameWatcherRunnable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_LobbyController.ScfServerEntity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LobbyController.IsHostInstanceAvailableAsync(_LobbyController.ScfServerEntity.EasyTier.MinecraftPort)
|
||||
.ContinueWith(async (task) =>
|
||||
{
|
||||
var isExist = await task.ConfigureAwait(false);
|
||||
if (!isExist)
|
||||
{
|
||||
_isGameWatcherRunnable = false;
|
||||
OnUserStopGame?.Invoke();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task _RunInUiAsync(Action action)
|
||||
{
|
||||
await Application.Current.Dispatcher.InvokeAsync(action);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Founded minecraft world information.
|
||||
/// </summary>
|
||||
/// <param name="Name">World name.</param>
|
||||
/// <param name="Port">World share port.</param>
|
||||
public record FoundWorld(string Name, int Port);
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace PCL.Core.Link.Lobby;
|
||||
|
||||
/// <summary>
|
||||
/// Lobby server state.
|
||||
/// </summary>
|
||||
public enum LobbyState
|
||||
{
|
||||
/// <summary>
|
||||
/// The lobby is idle and not in use.
|
||||
/// </summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>
|
||||
/// The lobby is being initialized.
|
||||
/// </summary>
|
||||
Initializing,
|
||||
|
||||
/// <summary>
|
||||
/// The lobby has been initialized.
|
||||
/// </summary>
|
||||
Initialized,
|
||||
|
||||
/// <summary>
|
||||
/// The lobby is in the process of discovering available minecraft world.
|
||||
/// </summary>
|
||||
Discovering,
|
||||
|
||||
/// <summary>
|
||||
/// The lobby is in the process of creating a new lobby.
|
||||
/// </summary>
|
||||
Creating,
|
||||
|
||||
/// <summary>
|
||||
/// The lobby is in the process of joining a exist lobby.
|
||||
/// </summary>
|
||||
Joining,
|
||||
|
||||
/// <summary>
|
||||
/// The lobby has been joined a exist lobby.
|
||||
/// </summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>
|
||||
/// The lobby is leaving a exist lobby.
|
||||
/// </summary>
|
||||
Leaving,
|
||||
|
||||
/// <summary>
|
||||
/// Occurred an error in the lobby.
|
||||
/// </summary>
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using PCL.Core.App.Localization;
|
||||
using PCL.Core.Link.EasyTier;
|
||||
|
||||
namespace PCL.Core.Link.Lobby;
|
||||
|
||||
public static class LobbyTextHandler
|
||||
{
|
||||
public static string GetNatTypeName(string type)
|
||||
{
|
||||
return Lang.Text(type switch
|
||||
{
|
||||
_ when type.Contains("Open") || type.Contains("NoP") => "Link.Nat.Type.Open",
|
||||
_ when type.Contains("FullCone") => "Link.Nat.Type.FullCone",
|
||||
_ when type.Contains("PortRestricted") => "Link.Nat.Type.PortRestricted",
|
||||
_ when type.Contains("Restricted") => "Link.Nat.Type.Restricted",
|
||||
_ when type.Contains("SymmetricEasy") => "Link.Nat.Type.SymmetricEasy",
|
||||
_ when type.Contains("Symmetric") => "Link.Nat.Type.Symmetric",
|
||||
_ => "Link.Nat.Type.Unknown"
|
||||
});
|
||||
}
|
||||
|
||||
public static string GetConnectTypeName(ETConnectionType type)
|
||||
{
|
||||
return Lang.Text(type switch
|
||||
{
|
||||
ETConnectionType.Local => "Link.Connection.Local",
|
||||
ETConnectionType.P2P => "Link.Connection.P2P",
|
||||
ETConnectionType.Relay => "Link.Connection.Relay",
|
||||
_ => "Link.Connection.Unknown"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 依据网络质量指数获取大厅连接状况文本。
|
||||
/// </summary>
|
||||
public static (string Keyword, string Desc) GetQualityDesc(int quality)
|
||||
{
|
||||
var keySuffix = quality switch
|
||||
{
|
||||
>= 3 => "Good",
|
||||
>= 2 => "Normal",
|
||||
_ => "Poor"
|
||||
};
|
||||
|
||||
return (
|
||||
Lang.Text($"Link.Quality.{keySuffix}"),
|
||||
Lang.Text($"Link.Quality.{keySuffix}Description")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using PCL.Core.Link.McPing.Model;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.Link.McPing;
|
||||
|
||||
/// <summary>
|
||||
/// Minecraft服务器探测服务接口
|
||||
/// </summary>
|
||||
public interface IMcPingService : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// 异步探测Minecraft服务器信息
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>服务器探测结果,如果探测失败则返回null</returns>
|
||||
Task<McPingResult?> PingAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取服务端点信息
|
||||
/// </summary>
|
||||
IPEndPoint Endpoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取主机地址
|
||||
/// </summary>
|
||||
string Host { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取超时时间(毫秒)
|
||||
/// </summary>
|
||||
int Timeout { get; }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using PCL.Core.Link.McPing.Model;
|
||||
using PCL.Core.Logging;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.Link.McPing;
|
||||
|
||||
/// <summary>
|
||||
/// 旧版Minecraft协议服务器探测服务实现
|
||||
/// 支持1.6及以下版本的服务器信息查询协议
|
||||
/// </summary>
|
||||
public class LegacyMcPingService : IMcPingService
|
||||
{
|
||||
private readonly IPEndPoint _endpoint;
|
||||
private readonly string _host;
|
||||
private const int DefaultTimeout = 10000;
|
||||
private readonly int _timeout;
|
||||
private bool _disposed;
|
||||
|
||||
public IPEndPoint Endpoint => _endpoint;
|
||||
public string Host => _host;
|
||||
public int Timeout => _timeout;
|
||||
|
||||
public LegacyMcPingService(IPEndPoint endpoint, int timeout = DefaultTimeout)
|
||||
{
|
||||
_endpoint = endpoint;
|
||||
_host = _endpoint.Address.ToString();
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
public LegacyMcPingService(string ip, int port = 25565, int timeout = DefaultTimeout)
|
||||
{
|
||||
_endpoint = IPAddress.TryParse(ip, out var ipAddress)
|
||||
? new IPEndPoint(ipAddress, port)
|
||||
: new IPEndPoint(Dns.GetHostAddresses(ip).First(), port);
|
||||
_host = ip;
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行旧版Minecraft协议的服务器探测
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<McPingResult?> PingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: 实现旧版协议的探测逻辑
|
||||
// 这里需要迁移原来McPing类中的PingOldAsync方法逻辑
|
||||
|
||||
using var so = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
using var timeoutCts = new CancellationTokenSource(_timeout);
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
||||
linkedCts.Token.Register(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (so.Connected) so.Close();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
/* Ignore */
|
||||
}
|
||||
});
|
||||
|
||||
await so.ConnectAsync(_endpoint, linkedCts.Token);
|
||||
LogWrapper.Debug("LegacyMcPing", $"Connected to {_endpoint}");
|
||||
await using var stream = new NetworkStream(so, false);
|
||||
|
||||
var queryPack = new byte[] { 0xfe, 0x01 };
|
||||
await stream.WriteAsync(queryPack.AsMemory(0, queryPack.Length), linkedCts.Token);
|
||||
var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms, linkedCts.Token);
|
||||
so.Close();
|
||||
var retData = ms.ToArray();
|
||||
if (retData.Length < 21 || (retData.Length >= 21 && retData[0] != 0xff))
|
||||
{
|
||||
LogWrapper.Info("McPing", $"Unknown response from {_endpoint}, ignore");
|
||||
return null;
|
||||
}
|
||||
|
||||
var retRep = Encoding.UTF8.GetString(retData);
|
||||
try
|
||||
{
|
||||
var retPart = retRep.Split(["\0\0\0"], StringSplitOptions.None);
|
||||
retPart = retPart
|
||||
.Select(s => new string([.. s.Where((_, index) => index % 2 == 0)]))
|
||||
.ToArray();
|
||||
if (retPart.Length < 6)
|
||||
return null;
|
||||
return new McPingResult(new McPingVersionResult(retPart[2], int.Parse(retPart[1])),
|
||||
new McPingPlayerResult(int.Parse(retPart[5]), int.Parse(retPart[4]), []), retPart[3], string.Empty, 0,
|
||||
new McPingModInfoResult(string.Empty, []), null);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogWrapper.Error(e, "McPing", $"Unable to serialize response from {_endpoint}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using System.Buffers.Binary;
|
||||
using PCL.Core.App.Localization;
|
||||
using PCL.Core.Link.McPing.Model;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.Link.McPing;
|
||||
|
||||
/// <summary>
|
||||
/// 现代Minecraft协议服务器探测服务实现
|
||||
/// 支持1.7+版本的服务器信息查询协议
|
||||
/// </summary>
|
||||
public class McPingService : IMcPingService
|
||||
{
|
||||
private readonly IPEndPoint _endpoint;
|
||||
private readonly string _host;
|
||||
private const int DefaultTimeout = 10000;
|
||||
private readonly int _timeout;
|
||||
private bool _disposed;
|
||||
private const string ModuleName = "McPing";
|
||||
|
||||
public IPEndPoint Endpoint => _endpoint;
|
||||
public string Host => _host;
|
||||
public int Timeout => _timeout;
|
||||
|
||||
public McPingService(IPEndPoint endpoint, int timeout = DefaultTimeout)
|
||||
{
|
||||
_endpoint = endpoint;
|
||||
_host = _endpoint.Address.ToString();
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
public McPingService(string ip, int port = 25565, int timeout = DefaultTimeout)
|
||||
{
|
||||
_endpoint = IPAddress.TryParse(ip, out var ipAddress)
|
||||
? new IPEndPoint(ipAddress, port)
|
||||
: new IPEndPoint(Dns.GetHostAddresses(ip).First(), port);
|
||||
_host = ip;
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
public McPingService(string host, IPEndPoint endpoint, int timeout = DefaultTimeout)
|
||||
{
|
||||
_endpoint = endpoint;
|
||||
_host = host;
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行现代Minecraft协议的服务器探测
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<McPingResult?> PingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var so = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
using var timeoutCts = new CancellationTokenSource(_timeout);
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
||||
|
||||
try
|
||||
{
|
||||
LogWrapper.Debug(ModuleName, $"Connecting to {_endpoint}");
|
||||
await so.ConnectAsync(_endpoint.Address, _endpoint.Port, linkedCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
LogWrapper.Error(new TimeoutException(Lang.Text("Tools.ServerQuery.Error.Timeout.Connect")), ModuleName, $"Failed to connect to the {_endpoint}");
|
||||
return null;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogWrapper.Error(e, ModuleName, $"Failed to connect to the {_endpoint}");
|
||||
return null;
|
||||
}
|
||||
|
||||
LogWrapper.Debug(ModuleName, $"Connection established: {_endpoint}");
|
||||
await using var stream = new NetworkStream(so, false);
|
||||
|
||||
var handshakePacket = _BuildHandshakePacket(_host, _endpoint.Port);
|
||||
var statusPacket = _BuildStatusRequestPacket();
|
||||
|
||||
byte[]? statusPayload;
|
||||
long latency = 0;
|
||||
try
|
||||
{
|
||||
await stream.WriteAsync(handshakePacket, linkedCts.Token);
|
||||
LogWrapper.Debug(ModuleName, $"Handshake sent, packet length: {handshakePacket.Length}");
|
||||
|
||||
await stream.WriteAsync(statusPacket, linkedCts.Token);
|
||||
LogWrapper.Debug(ModuleName, $"Status sent, packet length: {statusPacket.Length}");
|
||||
|
||||
var pingTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var pingPacket = _BuildPingRequestPacket(pingTimestamp);
|
||||
|
||||
await stream.WriteAsync(pingPacket, linkedCts.Token);
|
||||
LogWrapper.Debug(ModuleName, $"Ping sent, packet length: {pingPacket.Length}");
|
||||
|
||||
(statusPayload, latency) = await _ReadStatusPayloadAsync(stream, linkedCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
LogWrapper.Error(new TimeoutException(Lang.Text("Tools.ServerQuery.Error.Timeout.ReadWrite")), "McPing", $"Operation timed out on {_endpoint}");
|
||||
return null;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogWrapper.Error(e, ModuleName, $"Failed to communicate with {_endpoint}: {e.Message}");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (so.Connected) so.Shutdown(SocketShutdown.Both);
|
||||
}
|
||||
|
||||
so.Close();
|
||||
|
||||
if (statusPayload is null || statusPayload.Length == 0) throw new InvalidDataException(Lang.Text("Tools.ServerQuery.State.NoInfo"));
|
||||
var retCtx = Encoding.UTF8.GetString(statusPayload);
|
||||
|
||||
var retJson = JsonCompat.ParseNode(retCtx) ?? throw new NullReferenceException(Lang.Text("Tools.ServerQuery.Error.InvalidResponse"));
|
||||
#if DEBUG
|
||||
var resJsonDebug = retJson.DeepClone();
|
||||
if (resJsonDebug is JsonObject jsonObject && jsonObject.ContainsKey("favicon"))
|
||||
{
|
||||
jsonObject["favicon"] = "...";
|
||||
}
|
||||
|
||||
LogWrapper.Debug(ModuleName, resJsonDebug.ToJsonString());
|
||||
#endif
|
||||
// 先处理Description字段,将其转换为字符串形式
|
||||
if (retJson["description"] is JsonObject descObj)
|
||||
{
|
||||
retJson["description"] = _ConvertJNodeToMcString(descObj);
|
||||
}
|
||||
|
||||
var response = JsonSerializer.Deserialize<McPingResult>(retJson, JsonCompat.SerializerOptions);
|
||||
if (response?.Version is null)
|
||||
throw new NullReferenceException(Lang.Text("Tools.ServerQuery.Error.InvalidResponse"));
|
||||
|
||||
response = response with
|
||||
{
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建握手包
|
||||
/// </summary>
|
||||
/// <param name="serverIp">服务器的地址</param>
|
||||
/// <param name="serverPort">服务器的端口</param>
|
||||
/// <returns>返回握手包的字节数组</returns>
|
||||
private byte[] _BuildHandshakePacket(string serverIp, int serverPort)
|
||||
{
|
||||
List<byte> handshake = [];
|
||||
handshake.AddRange(VarIntHelper.Encode(0)); //状态头 表明这是一个握手包
|
||||
handshake.AddRange(VarIntHelper.Encode(772)); //协议头 表明请求客户端的版本
|
||||
var binaryIp = Encoding.UTF8.GetBytes(serverIp);
|
||||
if (binaryIp.Length > 255) throw new Exception(Lang.Text("Tools.ServerQuery.Error.AddressTooLong"));
|
||||
handshake.AddRange(VarIntHelper.Encode((uint)binaryIp.Length)); //服务器地址长度
|
||||
handshake.AddRange(binaryIp); //服务器地址
|
||||
handshake.AddRange(BitConverter.GetBytes((ushort)serverPort).AsEnumerable().Reverse()); //服务器端口
|
||||
handshake.AddRange(VarIntHelper.Encode(1)); //1 表明当前状态为 ping 2 表明当前的状态为连接
|
||||
|
||||
handshake.InsertRange(0, VarIntHelper.Encode((uint)handshake.Count)); //包长度
|
||||
return handshake.ToArray();
|
||||
}
|
||||
|
||||
private byte[] _BuildStatusRequestPacket()
|
||||
{
|
||||
List<byte> statusRequest = [];
|
||||
statusRequest.AddRange(VarIntHelper.Encode(1)); //包长度
|
||||
statusRequest.AddRange(VarIntHelper.Encode(0)); //包 ID
|
||||
return statusRequest.ToArray();
|
||||
}
|
||||
|
||||
private byte[] _BuildPingRequestPacket(long timestamp)
|
||||
{
|
||||
List<byte> pingRequest = [];
|
||||
// Packet ID 使用值为 1 的 VarInt 编码和 8 字节的 long 时间戳
|
||||
pingRequest.AddRange(VarIntHelper.Encode(9));
|
||||
pingRequest.AddRange(VarIntHelper.Encode(1));
|
||||
pingRequest.AddRange(BitConverter.GetBytes(timestamp).AsEnumerable().Reverse());
|
||||
return pingRequest.ToArray();
|
||||
}
|
||||
|
||||
private async Task<(byte[] StatusPayload, long Latency)> _ReadStatusPayloadAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[]? statusPayload = null;
|
||||
long? latency = null;
|
||||
|
||||
try
|
||||
{
|
||||
while (statusPayload is null || latency is null)
|
||||
{
|
||||
var packetLength = checked((int)await VarIntHelper.ReadFromStreamAsync(stream, cancellationToken));
|
||||
LogWrapper.Debug(ModuleName, $"Packet length: {packetLength}");
|
||||
if (packetLength <= 0) throw new InvalidDataException(Lang.Text("Tools.ServerQuery.Error.EmptyPacket"));
|
||||
|
||||
var packetData = await _ReadExactAsync(stream, packetLength, cancellationToken);
|
||||
using var packetStream = new MemoryStream(packetData, writable: false);
|
||||
var packetId = checked((int)await VarIntHelper.ReadFromStreamAsync(packetStream, cancellationToken));
|
||||
LogWrapper.Debug(ModuleName, $"Packet id: {packetId}");
|
||||
|
||||
switch (packetId)
|
||||
{
|
||||
case 0:
|
||||
var jsonLength = checked((int)await VarIntHelper.ReadFromStreamAsync(packetStream, cancellationToken));
|
||||
statusPayload = await _ReadExactAsync(packetStream, jsonLength, cancellationToken);
|
||||
if (packetStream.Position != packetStream.Length)
|
||||
LogWrapper.Warn(ModuleName, $"Status packet contains {packetStream.Length - packetStream.Position} trailing bytes.");
|
||||
break;
|
||||
|
||||
case 1:
|
||||
var pongData = await _ReadExactAsync(packetStream, 8, cancellationToken);
|
||||
if (packetStream.Position != packetStream.Length)
|
||||
LogWrapper.Warn(ModuleName, $"Pong packet contains {packetStream.Length - packetStream.Position} trailing bytes.");
|
||||
latency = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _ReadInt64BigEndian(pongData);
|
||||
break;
|
||||
|
||||
default:
|
||||
LogWrapper.Warn(ModuleName, $"Ignore unexpected packet type: {packetId}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (EndOfStreamException ex)
|
||||
{
|
||||
if (statusPayload is not null && latency is null)
|
||||
throw new EndOfStreamException(Lang.Text("Tools.ServerQuery.Error.StaleConnection"), ex);
|
||||
|
||||
if (statusPayload is null)
|
||||
throw new EndOfStreamException(Lang.Text("Tools.ServerQuery.Error.IncompleteConnection"), ex);
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
return (statusPayload, latency.Value);
|
||||
}
|
||||
|
||||
private static long _ReadInt64BigEndian(byte[] data)
|
||||
{
|
||||
return data.Length != 8
|
||||
? throw new ArgumentException(Lang.Text("Tools.ServerQuery.Error.PongDataLength"), nameof(data))
|
||||
: BinaryPrimitives.ReadInt64BigEndian(data);
|
||||
}
|
||||
|
||||
private static async Task<byte[]> _ReadExactAsync(Stream stream, int length, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[length];
|
||||
await stream.ReadExactlyAsync(buffer, cancellationToken);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static string _ConvertJNodeToMcString(JsonNode? jsonNode)
|
||||
{
|
||||
if (jsonNode is null) return string.Empty;
|
||||
StringBuilder result = new();
|
||||
Stack<JsonNode> stack = new();
|
||||
stack.Push(jsonNode);
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var current = stack.Pop();
|
||||
|
||||
switch (current.GetValueKind())
|
||||
{
|
||||
// 处理对象
|
||||
case JsonValueKind.Object:
|
||||
{
|
||||
var obj = current.AsObject();
|
||||
// LogWrapper.Debug("McPing",$"Treat {obj} as JObject");
|
||||
// 检查并处理 extra 数组
|
||||
if (obj.TryGetPropertyValue("extra", out var extraNode) && extraNode is JsonArray extraArray)
|
||||
// 逆序压栈保证原始顺序
|
||||
for (var i = extraArray.Count - 1; i >= 0; i--)
|
||||
if (extraArray[i] is not null)
|
||||
stack.Push(extraArray[i]!);
|
||||
// 检查并处理 text 属性
|
||||
if (obj.TryGetPropertyValue("text", out _))
|
||||
{
|
||||
var formatCode = _GetTextStyleString(
|
||||
obj["color"]?.ToString() ?? string.Empty,
|
||||
Convert.ToBoolean(obj["bold"]?.ToString() ?? "false"),
|
||||
Convert.ToBoolean(obj["obfuscated"]?.ToString() ?? "false"),
|
||||
Convert.ToBoolean(obj["strikethrough"]?.ToString() ?? "false"),
|
||||
Convert.ToBoolean(obj["underline"]?.ToString() ?? "false"),
|
||||
Convert.ToBoolean(obj["italic"]?.ToString() ?? "false")
|
||||
);
|
||||
result.Append($"{formatCode}{obj["text"] ?? string.Empty}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
// 处理字符串值
|
||||
case JsonValueKind.String:
|
||||
{
|
||||
// LogWrapper.Debug("McPing",$"Treat {value} as JValue");
|
||||
result.Append(current);
|
||||
break;
|
||||
}
|
||||
// 处理数组
|
||||
// 逆序压栈保证原始顺序
|
||||
case JsonValueKind.Array:
|
||||
{
|
||||
var jArr = current.AsArray();
|
||||
// LogWrapper.Debug("McPing",$"Treat {array} as JArray");
|
||||
for (var i = jArr.Count - 1; i >= 0; i--)
|
||||
if (jArr[i] is not null)
|
||||
stack.Push(jArr[i]!);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
LogWrapper.Warn(ModuleName, $"解析到无法处理的 Motd 内容({current.GetValueKind()}):{current}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogWrapper.Debug(ModuleName, $"处理 Motd 内容完成,结果:{result}");
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> _ColorMap = new()
|
||||
{
|
||||
["black"] = "0",
|
||||
["dark_blue"] = "1",
|
||||
["dark_green"] = "2",
|
||||
["dark_aqua"] = "3",
|
||||
["dark_red"] = "4",
|
||||
["dark_purple"] = "5",
|
||||
["gold"] = "6",
|
||||
["gray"] = "7",
|
||||
["dark_gray"] = "8",
|
||||
["blue"] = "9",
|
||||
["green"] = "a",
|
||||
["aqua"] = "b",
|
||||
["red"] = "c",
|
||||
["light_purple"] = "d",
|
||||
["yellow"] = "e",
|
||||
["white"] = "f"
|
||||
};
|
||||
|
||||
private static string _GetTextStyleString(
|
||||
string color,
|
||||
bool bold = false,
|
||||
bool obfuscated = false,
|
||||
bool strikethrough = false,
|
||||
bool underline = false,
|
||||
bool italic = false)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
if (_ColorMap.TryGetValue(color, out var colorCode)) sb.Append($"§{colorCode}");
|
||||
if (bold) sb.Append("§l");
|
||||
if (italic) sb.Append("§o");
|
||||
// if (obfuscated) sb.Append("§k"); // 暂时别用
|
||||
if (underline) sb.Append("§n");
|
||||
if (strikethrough) sb.Append("§m");
|
||||
if (color.StartsWith('#')) sb.Append(color);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Net;
|
||||
|
||||
namespace PCL.Core.Link.McPing;
|
||||
|
||||
/// <summary>
|
||||
/// Minecraft服务器探测服务工厂
|
||||
/// 提供统一的服务创建接口
|
||||
/// </summary>
|
||||
public static class McPingServiceFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建现代协议探测服务
|
||||
/// </summary>
|
||||
/// <param name="endpoint">服务器端点</param>
|
||||
/// <param name="timeout">超时时间(毫秒)</param>
|
||||
/// <returns>IMcPingService实例</returns>
|
||||
public static IMcPingService CreateService(IPEndPoint endpoint, int timeout = 10000)
|
||||
{
|
||||
return new McPingService(endpoint, timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建现代协议探测服务
|
||||
/// </summary>
|
||||
/// <param name="ip">服务器IP地址</param>
|
||||
/// <param name="port">服务器端口</param>
|
||||
/// <param name="timeout">超时时间(毫秒)</param>
|
||||
/// <returns>IMcPingService实例</returns>
|
||||
public static IMcPingService CreateService(string ip, int port = 25565, int timeout = 10000)
|
||||
{
|
||||
return new McPingService(ip, port, timeout);
|
||||
}
|
||||
|
||||
public static IMcPingService CreateService(string host, string? ip, int port = 25565)
|
||||
{
|
||||
return CreateService(host, ip, port, 10000);
|
||||
}
|
||||
|
||||
public static IMcPingService CreateService(string host, string? ip, int port, int timeout)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(ip) && IPAddress.TryParse(ip, out var ipAddress)
|
||||
? new McPingService(host, new IPEndPoint(ipAddress, port), timeout)
|
||||
: new McPingService(host, port, timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建旧版协议探测服务
|
||||
/// </summary>
|
||||
/// <param name="endpoint">服务器端点</param>
|
||||
/// <param name="timeout">超时时间(毫秒)</param>
|
||||
/// <returns>IMcPingService实例</returns>
|
||||
public static IMcPingService CreateLegacyService(IPEndPoint endpoint, int timeout = 10000)
|
||||
{
|
||||
return new LegacyMcPingService(endpoint, timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建旧版协议探测服务
|
||||
/// </summary>
|
||||
/// <param name="ip">服务器IP地址</param>
|
||||
/// <param name="port">服务器端口</param>
|
||||
/// <param name="timeout">超时时间(毫秒)</param>
|
||||
/// <returns>IMcPingService实例</returns>
|
||||
public static IMcPingService CreateLegacyService(string ip, int port = 25565, int timeout = 10000)
|
||||
{
|
||||
return new LegacyMcPingService(ip, port, timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link.McPing.Model;
|
||||
|
||||
public record McPingModInfoModResult(
|
||||
[property: JsonPropertyName("modid")] string Id,
|
||||
[property: JsonPropertyName("version")] string Version);
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link.McPing.Model;
|
||||
|
||||
public record McPingModInfoResult(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("modList")] List<McPingModInfoModResult> ModList);
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link.McPing.Model;
|
||||
|
||||
public record McPingPlayerResult(
|
||||
[property: JsonPropertyName("max")] int Max,
|
||||
[property: JsonPropertyName("online")] int Online,
|
||||
[property: JsonPropertyName("sample")] List<McPingPlayerSampleResult>? Samples);
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link.McPing.Model;
|
||||
|
||||
public record McPingPlayerSampleResult(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("id")] string Id);
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link.McPing.Model;
|
||||
|
||||
public record McPingResult(
|
||||
[property: JsonPropertyName("version")] McPingVersionResult Version,
|
||||
[property: JsonPropertyName("players")] McPingPlayerResult Players,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("favicon")] string? Favicon,
|
||||
[property: JsonPropertyName("latency")] long Latency,
|
||||
[property: JsonPropertyName("modinfo")] McPingModInfoResult? ModInfo,
|
||||
[property: JsonPropertyName("preventsChatReports")] bool? PreventsChatReports);
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link.McPing.Model;
|
||||
|
||||
public record McPingVersionResult(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("protocol")] int Protocol);
|
||||
@@ -0,0 +1,150 @@
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.App.Localization;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.UI;
|
||||
using PCL.Core.Utils.OS;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Link.Natayark;
|
||||
|
||||
public class NaidUser
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? AccessToken { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
/// <summary>
|
||||
/// Natayark ID 状态,1 为正常
|
||||
/// </summary>
|
||||
public int Status { get; set; }
|
||||
public bool IsRealNamed { get; set; }
|
||||
public string? LastIp { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public static class NatayarkProfileManager
|
||||
{
|
||||
private const string LogModule = "Link";
|
||||
|
||||
public static NaidUser NaidProfile { get; private set; } = new();
|
||||
|
||||
private static Task? _getNaidData;
|
||||
|
||||
public static async Task GetNaidDataAsync(string token, bool isRefresh = false, bool isRetry = false, ushort port = 0)
|
||||
{
|
||||
if (_getNaidData is not null && !_getNaidData.IsCompleted)
|
||||
{
|
||||
await _getNaidData;
|
||||
return;
|
||||
}
|
||||
_getNaidData = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取 AccessToken 和 RefreshToken
|
||||
var requestData =
|
||||
$"grant_type={(isRefresh ? "refresh_token" : "authorization_code")}" +
|
||||
$"&client_id={EnvironmentInterop.GetSecret("NAID_CLIENT_ID")}" +
|
||||
$"&client_secret={EnvironmentInterop.GetSecret("NAID_CLIENT_SECRET")}" +
|
||||
$"&{(isRefresh ? "refresh_token" : "code")}={token}" +
|
||||
(isRefresh ? "" : $"&redirect_uri=http://localhost:{port}/callback");
|
||||
|
||||
var httpContent = new StringContent(requestData, Encoding.UTF8, "application/x-www-form-urlencoded");
|
||||
|
||||
using var oauthResponse = await HttpRequest
|
||||
.CreatePost("https://account.naids.com/api/oauth2/token")
|
||||
.WithContent(httpContent)
|
||||
.SendAsync()
|
||||
.ConfigureAwait(false);
|
||||
oauthResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await oauthResponse.AsStringAsync().ConfigureAwait(false)
|
||||
?? throw new Exception(Lang.Text("Link.Natayark.TokenFetchEmpty"));
|
||||
var data = JsonCompat.ParseNode(result);
|
||||
var accessToken = data?["access_token"]?.ToString();
|
||||
var refreshToken = data?["refresh_token"]?.ToString();
|
||||
|
||||
if (data is null || accessToken is null || refreshToken is null)
|
||||
throw new Exception(Lang.Text("Link.Natayark.TokenParseFailed"));
|
||||
|
||||
NaidProfile.AccessToken = accessToken;
|
||||
NaidProfile.RefreshToken = refreshToken;
|
||||
|
||||
var expiresAt = data["refresh_token_expires_at"]!.ToString();
|
||||
|
||||
// 获取用户信息
|
||||
using var userDataResponse = await HttpRequest
|
||||
.Create("https://account.naids.com/api/api/user/data")
|
||||
.WithBearerToken(NaidProfile.AccessToken)
|
||||
.SendAsync()
|
||||
.ConfigureAwait(false);
|
||||
userDataResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var receivedUserData = await userDataResponse.AsStringAsync()
|
||||
?? throw new Exception(Lang.Text("Link.Natayark.UserInfoFetchEmpty"));
|
||||
var userData = (JsonCompat.ParseNode(receivedUserData)?["data"])
|
||||
?? throw new Exception(Lang.Text("Link.Natayark.UserInfoParseFailed"));
|
||||
|
||||
NaidProfile.Id = JsonCompat.ToObject<int>(userData["id"]);
|
||||
NaidProfile.Username = userData["username"]?.ToString() ?? string.Empty;
|
||||
NaidProfile.Email = userData["email"]?.ToString() ?? string.Empty;
|
||||
NaidProfile.Status = JsonCompat.ToObject<int>(userData["status"]);
|
||||
NaidProfile.IsRealNamed = JsonCompat.ToObject<bool>(userData["realname"]);
|
||||
NaidProfile.LastIp = userData["last_ip"]?.ToString() ?? string.Empty;
|
||||
|
||||
// 保存数据
|
||||
States.Link.NaidRefreshToken = NaidProfile.RefreshToken;
|
||||
States.Link.NaidRefreshExpireTime = expiresAt;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (isRetry)
|
||||
{
|
||||
NaidProfile = new NaidUser();
|
||||
States.Link.NaidRefreshToken = string.Empty;
|
||||
WarnLog(Lang.Text("Tools.GameLink.Natayark.ProfileLoadFailed"));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ex.Message.Contains("invalid access token"))
|
||||
{
|
||||
WarnLog(Lang.Text("Tools.GameLink.Natayark.TokenInvalid"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(50)).ConfigureAwait(false); // 搁这让电脑休息半秒吗
|
||||
await GetNaidDataAsync(States.Link.NaidRefreshToken, true, true).ConfigureAwait(false);
|
||||
}
|
||||
else if (ex.Message.Contains("invalid_grant"))
|
||||
{
|
||||
WarnLog(Lang.Text("Tools.GameLink.Natayark.InvalidAuthCode"));
|
||||
}
|
||||
else if (ex is HttpRequestException { StatusCode: System.Net.HttpStatusCode.Unauthorized })
|
||||
{
|
||||
NaidProfile = new NaidUser();
|
||||
States.Link.NaidRefreshToken = string.Empty;
|
||||
WarnLog(Lang.Text("Tools.GameLink.Natayark.AccountExpired"));
|
||||
}
|
||||
else
|
||||
{
|
||||
NaidProfile = new NaidUser();
|
||||
States.Link.NaidRefreshToken = string.Empty;
|
||||
WarnLog(Lang.Text("Tools.GameLink.Natayark.LoginFailed"));
|
||||
}
|
||||
}
|
||||
throw;
|
||||
|
||||
void WarnLog(string msg)
|
||||
{
|
||||
LogWrapper.Warn(ex, LogModule, msg);
|
||||
HintWrapper.Show(msg, HintTheme.Error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _getNaidData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Abstractions;
|
||||
|
||||
public interface IRequest<out TResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the request type string, e.g., "c:ping".
|
||||
/// </summary>
|
||||
string RequestType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes the request body to the provided buffer writer.
|
||||
/// </summary>
|
||||
/// <param name="writer">The buffer to write to.</param>
|
||||
void WriteRequestBody(IBufferWriter<byte> writer);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the response body from the given memory span.
|
||||
/// </summary>
|
||||
/// <param name="responseBody">The raw response body.</param>
|
||||
/// <returns>The parsed response object.</returns>
|
||||
TResponse ParseResponseBody(ReadOnlyMemory<byte> responseBody);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Abstractions;
|
||||
|
||||
public record ScaffoldingResponse(byte Status, ReadOnlyMemory<byte> Body);
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Pipelines;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Link.Scaffolding.Client.Abstractions;
|
||||
using PCL.Core.Link.Scaffolding.Exceptions;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Framing;
|
||||
|
||||
internal static class ProtocolReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Read a response from the pipe.
|
||||
/// </summary>
|
||||
/// <param name="reader">The PipeReader to read from.</param>
|
||||
/// <param name="ct">The CancellationToken.</param>
|
||||
/// <returns>A task representing the asynchronous operation, with a ScaffoldingResponse as the result.</returns>
|
||||
/// <exception cref="ScaffoldingRequestException">Thrown if the request fails due to an invalid status.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the connection is closed unexpectedly.</exception>
|
||||
public static async ValueTask<ScaffoldingResponse> ReadResponseAsync(
|
||||
PipeReader reader,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var result = await reader.ReadAsync(ct).ConfigureAwait(false);
|
||||
var buffer = result.Buffer;
|
||||
|
||||
if (_TryParseResponse(ref buffer, out var response))
|
||||
{
|
||||
reader.AdvanceTo(buffer.Start);
|
||||
return response;
|
||||
}
|
||||
|
||||
reader.AdvanceTo(result.Buffer.Start, result.Buffer.End);
|
||||
|
||||
if (result.IsCompleted)
|
||||
{
|
||||
throw new InvalidOperationException("Connection closed unexpectedly.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse a response from the given buffer.
|
||||
/// </summary>
|
||||
/// <param name="buffer">The buffer containing the response data.</param>
|
||||
/// <param name="response">The parsed ScaffoldingResponse.</param>
|
||||
/// <returns>True if the response was successfully parsed; otherwise, false.</returns>
|
||||
/// <exception cref="ScaffoldingRequestException">Thrown if the request fails due to an invalid status.</exception>
|
||||
private static bool _TryParseResponse(ref ReadOnlySequence<byte> buffer, out ScaffoldingResponse response)
|
||||
{
|
||||
response = null!;
|
||||
if (buffer.Length < 5)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Span<byte> header = stackalloc byte[5];
|
||||
buffer.Slice(0, 5).CopyTo(header);
|
||||
|
||||
var status = header[0];
|
||||
var bodyLength = BinaryPrimitives.ReadUInt32BigEndian(header[1..]);
|
||||
|
||||
var fullPacketLength = 5 + bodyLength;
|
||||
if (buffer.Length < fullPacketLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bodyBuffer = buffer.Slice(5, bodyLength);
|
||||
var body = bodyBuffer.ToArray();
|
||||
|
||||
buffer = buffer.Slice(fullPacketLength);
|
||||
|
||||
response = new ScaffoldingResponse(status, body);
|
||||
|
||||
if (response.Status != 0)
|
||||
{
|
||||
var serverMessage = response.Status == 255 ? Encoding.UTF8.GetString(response.Body.Span) : null;
|
||||
throw new ScaffoldingRequestException(response.Status, serverMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Pipelines;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Link.Scaffolding.Client.Abstractions;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Framing;
|
||||
|
||||
internal static class ProtocolWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Write message to server.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Request body object type.</typeparam>
|
||||
/// <exception cref="InvalidOperationException">Thrown if request type is too long.</exception>
|
||||
/// <exception cref="OperationCanceledException">Thrown if operation is canceled.</exception>
|
||||
public static async ValueTask WriteRequestAsync<T>(
|
||||
PipeWriter writer,
|
||||
IRequest<T> request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var bodyWriter = new ArrayBufferWriter<byte>();
|
||||
request.WriteRequestBody(bodyWriter);
|
||||
var requestBody = bodyWriter.WrittenMemory;
|
||||
|
||||
var requestTypeBytes = Encoding.ASCII.GetBytes(request.RequestType);
|
||||
if (requestTypeBytes.Length > byte.MaxValue)
|
||||
{
|
||||
throw new InvalidOperationException("Request type is too long.");
|
||||
}
|
||||
|
||||
writer.GetSpan(1)[0] = (byte)requestTypeBytes.Length;
|
||||
writer.Advance(1);
|
||||
|
||||
writer.Write(requestTypeBytes);
|
||||
|
||||
var lengthSpan = writer.GetSpan(4);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(lengthSpan, (uint)requestBody.Length);
|
||||
writer.Advance(4);
|
||||
|
||||
if (!requestBody.IsEmpty)
|
||||
{
|
||||
writer.Write(requestBody.Span);
|
||||
}
|
||||
|
||||
var result = await writer.FlushAsync(ct).ConfigureAwait(false);
|
||||
if (result.IsCanceled)
|
||||
{
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Models;
|
||||
|
||||
public record LobbyInfo(string FullCode, string NetworkName, string NetworkSecret);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Models;
|
||||
|
||||
public enum LobbyType
|
||||
{
|
||||
// ReSharper disable once InconsistentNaming
|
||||
PCLCE,
|
||||
Terracotta,
|
||||
Scaffolding
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Models;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum PlayerKind
|
||||
{
|
||||
HOST,
|
||||
GUEST
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Models;
|
||||
|
||||
public record PlayerProfile
|
||||
{
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("machine_id")] public required string MachineId { get; init; }
|
||||
[JsonPropertyName("vendor")] public required string Vendor { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public PlayerKind? Kind { get; init; }
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using PCL.Core.Link.Scaffolding.Client.Abstractions;
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Requests;
|
||||
|
||||
public sealed class GetPlayerProfileListRequest : IRequest<IReadOnlyList<PlayerProfile>>
|
||||
{
|
||||
private static readonly JsonSerializerOptions _JsonOptions = new(JsonCompat.SerializerOptions)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RequestType { get; } = "c:player_profiles_list";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteRequestBody(IBufferWriter<byte> writer)
|
||||
{
|
||||
// empty request body
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<PlayerProfile> ParseResponseBody(ReadOnlyMemory<byte> responseBody)
|
||||
{
|
||||
var profiles = JsonSerializer.Deserialize<IReadOnlyList<PlayerProfile>>(responseBody.Span, _JsonOptions);
|
||||
return profiles ?? ArraySegment<PlayerProfile>.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using PCL.Core.Link.Scaffolding.Client.Abstractions;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Requests;
|
||||
|
||||
public sealed class GetProtocolsRequest(IEnumerable<string> supportProtocols) : IRequest<IReadOnlyList<string>>
|
||||
{
|
||||
private static readonly byte[] _Separator = [(byte)'\0'];
|
||||
private readonly IEnumerable<string> _supportProtocols = supportProtocols;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RequestType { get; } = "c:protocols";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteRequestBody(IBufferWriter<byte> writer)
|
||||
{
|
||||
var protocolStr = string.Join('\0', _supportProtocols);
|
||||
var bytes = Encoding.ASCII.GetBytes(protocolStr);
|
||||
|
||||
writer.Write(bytes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> ParseResponseBody(ReadOnlyMemory<byte> responseBody)
|
||||
{
|
||||
if (responseBody.IsEmpty)
|
||||
{
|
||||
return ArraySegment<string>.Empty;
|
||||
}
|
||||
|
||||
var responseStr = Encoding.ASCII.GetString(responseBody.Span);
|
||||
return responseStr.Split('\0', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using PCL.Core.Link.Scaffolding.Client.Abstractions;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Requests;
|
||||
|
||||
public sealed class GetServerPortRequest : IRequest<ushort>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string RequestType { get; } = "c:server_port";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteRequestBody(IBufferWriter<byte> writer)
|
||||
{
|
||||
// empty
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ushort ParseResponseBody(ReadOnlyMemory<byte> responseBody)
|
||||
{
|
||||
if (responseBody.Length != 2)
|
||||
{
|
||||
throw new InvalidOperationException("Invalid response body for server port.");
|
||||
}
|
||||
|
||||
return BinaryPrimitives.ReadUInt16BigEndian(responseBody.Span);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using PCL.Core.Link.Scaffolding.Client.Abstractions;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Requests;
|
||||
|
||||
public sealed class PingRequest : IRequest<ReadOnlyMemory<byte>>
|
||||
{
|
||||
private readonly ReadOnlyMemory<byte> _payload;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RequestType { get; } = "c:ping";
|
||||
|
||||
public PingRequest(ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
if (payload.Length >= 32)
|
||||
{
|
||||
throw new ArgumentException("Payload must be less than 32 bytes.", nameof(payload));
|
||||
}
|
||||
|
||||
_payload = payload;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteRequestBody(IBufferWriter<byte> writer)
|
||||
{
|
||||
writer.Write(_payload.Span);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ReadOnlyMemory<byte> ParseResponseBody(ReadOnlyMemory<byte> responseBody)
|
||||
{
|
||||
return responseBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using PCL.Core.Link.Scaffolding.Client.Abstractions;
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client.Requests;
|
||||
|
||||
public sealed class PlayerPingRequest(string name, string machineId, string vendor) : IRequest<bool>
|
||||
{
|
||||
private readonly PlayerProfile _profile = new()
|
||||
{
|
||||
Name = name,
|
||||
MachineId = machineId,
|
||||
Vendor = vendor
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions _JsonOptions = new(JsonCompat.SerializerOptions)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RequestType { get; } = "c:player_ping";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteRequestBody(IBufferWriter<byte> writer)
|
||||
{
|
||||
using var jsonWriter = new Utf8JsonWriter(writer);
|
||||
JsonSerializer.Serialize(jsonWriter, _profile, _JsonOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ParseResponseBody(ReadOnlyMemory<byte> responseBody)
|
||||
{
|
||||
// An empty response body indicates success.
|
||||
return responseBody.IsEmpty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using PCL.Core.Link.Scaffolding.Client.Abstractions;
|
||||
using PCL.Core.Link.Scaffolding.Client.Framing;
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Link.Scaffolding.Client.Requests;
|
||||
using PCL.Core.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipelines;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Client;
|
||||
|
||||
internal enum ClientState
|
||||
{
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Handshaking, // 正在进行握手
|
||||
Connected, // 握手成功,准备就绪
|
||||
Disposing
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A client for the Scaffolding data exchange protocol.
|
||||
/// </summary>
|
||||
public sealed class ScaffoldingClient(string host, int scfPort, string playerName, string machineId, string vendor)
|
||||
: IAsyncDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim _srLock = new(1, 1);
|
||||
private TcpClient? _tcpClient;
|
||||
private PipeReader? _pipeReader;
|
||||
private PipeWriter? _pipeWriter;
|
||||
|
||||
// Heart Beat
|
||||
private Task? _heartbeatTask;
|
||||
private readonly PlayerPingRequest _playerPingRequest = new(playerName, machineId, vendor);
|
||||
private CancellationTokenSource? _heartbeatCts;
|
||||
private readonly Stopwatch _heartbeatTimer = new();
|
||||
|
||||
private ClientState _state = ClientState.Disconnected;
|
||||
|
||||
#region Events
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when a heartbeat signal is received, providing the current list of player profiles and the elapsed time
|
||||
/// since the last heartbeat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Subscribers can use this event to monitor player activity or synchronize state at regular
|
||||
/// intervals. The event provides a read-only list of player profiles and an integer representing the elapsed time,
|
||||
/// typically in milliseconds or seconds, depending on the implementation.
|
||||
/// </remarks>
|
||||
public event Action<IReadOnlyList<PlayerProfile>, long>? Heartbeat;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the server has been shut down or is unreachable.
|
||||
/// </summary>
|
||||
public event Action? ServerShuttedDown;
|
||||
|
||||
#endregion
|
||||
|
||||
public IReadOnlyList<PlayerProfile>? PlayerList;
|
||||
|
||||
public bool IsConnected => _state == ClientState.Connected;
|
||||
|
||||
/// <summary>
|
||||
/// Connects to a Scaffolding server.
|
||||
/// </summary>
|
||||
/// <exception cref="Exception">Throws if fialed to connect to server.</exception>
|
||||
public async Task ConnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_state is not ClientState.Disconnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_tcpClient = new TcpClient();
|
||||
try
|
||||
{
|
||||
_state = ClientState.Connecting;
|
||||
|
||||
LogWrapper.Info("Scaffolding", $"Trying to connect to server: {host}:{scfPort}");
|
||||
|
||||
await _tcpClient.ConnectAsync(host, scfPort, ct).ConfigureAwait(false);
|
||||
|
||||
var stream = _tcpClient.GetStream();
|
||||
_pipeReader = PipeReader.Create(stream);
|
||||
_pipeWriter = PipeWriter.Create(stream);
|
||||
|
||||
_state = ClientState.Handshaking;
|
||||
LogWrapper.Info("Scaffolding", "Connecting established. Performing handshake...");
|
||||
|
||||
await SendRequestAsync(_playerPingRequest, ct).ConfigureAwait(false);
|
||||
|
||||
_state = ClientState.Connected;
|
||||
|
||||
_StartHeartbeats();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "ScaffoldingClient", "Failed to connect to server.");
|
||||
|
||||
await DisposeAsync().ConfigureAwait(false);
|
||||
ServerShuttedDown?.Invoke();
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void _StartHeartbeats()
|
||||
{
|
||||
_heartbeatCts = new CancellationTokenSource();
|
||||
_heartbeatTask = _HeartbeatLoopAsync(_heartbeatCts.Token);
|
||||
}
|
||||
|
||||
private async Task _HeartbeatLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false);
|
||||
|
||||
_heartbeatTimer.Start();
|
||||
await SendRequestAsync(_playerPingRequest, ct).ConfigureAwait(false);
|
||||
_heartbeatTimer.Stop();
|
||||
|
||||
var letancy = _heartbeatTimer.ElapsedMilliseconds;
|
||||
_heartbeatTimer.Reset();
|
||||
|
||||
PlayerList = await SendRequestAsync(new GetPlayerProfileListRequest(), ct).ConfigureAwait(false);
|
||||
|
||||
Heartbeat?.Invoke(PlayerList, letancy);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "[ScaffoldingClient]",
|
||||
"Failed when sending heartbeat message. Maybe the server has been shut down.");
|
||||
|
||||
ServerShuttedDown?.Invoke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send TCP request to the Scaffolding server.
|
||||
/// </summary>
|
||||
/// <param name="request">Thr request type.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <typeparam name="TResponse">Response type.</typeparam>
|
||||
/// <exception cref="InvalidOperationException">Throws when server is not ready.</exception>
|
||||
public async Task<TResponse> SendRequestAsync<TResponse>(
|
||||
IRequest<TResponse> request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_state < ClientState.Handshaking)
|
||||
{
|
||||
throw new InvalidOperationException("Client is not connected.");
|
||||
}
|
||||
|
||||
if (_pipeWriter is null || _pipeReader is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client is not connected.");
|
||||
}
|
||||
|
||||
await _srLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await ProtocolWriter.WriteRequestAsync(_pipeWriter, request, ct).ConfigureAwait(false);
|
||||
var response = await ProtocolReader.ReadResponseAsync(_pipeReader, ct).ConfigureAwait(false);
|
||||
|
||||
return request.ParseResponseBody(response.Body);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_srLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_state is ClientState.Disposing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_state = ClientState.Disposing;
|
||||
|
||||
|
||||
await CastAndDispose(_srLock).ConfigureAwait(false);
|
||||
if (_tcpClient is not null) await CastAndDispose(_tcpClient).ConfigureAwait(false);
|
||||
if (_heartbeatCts is not null)
|
||||
{
|
||||
await _heartbeatCts.CancelAsync().ConfigureAwait(false);
|
||||
await CastAndDispose(_heartbeatCts).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_heartbeatTask is not null)
|
||||
{
|
||||
await _heartbeatTask.ConfigureAwait(false);
|
||||
await CastAndDispose(_heartbeatTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
static async ValueTask CastAndDispose(IDisposable resource)
|
||||
{
|
||||
if (resource is IAsyncDisposable resourceAsyncDisposable)
|
||||
await resourceAsyncDisposable.DisposeAsync().ConfigureAwait(false);
|
||||
else
|
||||
resource.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipelines;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.App.Localization;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.EasyTier;
|
||||
|
||||
public class CliNetTest
|
||||
{
|
||||
public enum NatType
|
||||
{
|
||||
Unknown,
|
||||
OpenInternet,
|
||||
NoPat,
|
||||
FullCone,
|
||||
Restricted,
|
||||
PortRestricted,
|
||||
SymmetricEasy,
|
||||
Symmetric,
|
||||
SymmetricFirewall,
|
||||
UdpBlocked
|
||||
}
|
||||
public record NetStatus
|
||||
{
|
||||
public required NatType UdpNatType;
|
||||
public required NatType TcpNatType;
|
||||
public required bool SupportIPv6;
|
||||
}
|
||||
|
||||
public async static Task<NetStatus?> GetNetStatusAsync()
|
||||
{
|
||||
using var cliProcess = new Process();
|
||||
cliProcess.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
|
||||
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
|
||||
Arguments = $"-o json stun",
|
||||
ErrorDialog = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
StandardInputEncoding = Encoding.UTF8
|
||||
};
|
||||
cliProcess.EnableRaisingEvents = true;
|
||||
cliProcess.Start();
|
||||
var reader = PipeReader.Create(cliProcess.StandardOutput.BaseStream);
|
||||
|
||||
StunInfo? stunInfo = null;
|
||||
try
|
||||
{
|
||||
stunInfo = await JsonSerializer.DeserializeAsync<StunInfo>(reader, JsonCompat.SerializerOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, "Link", "Failed to do net test");
|
||||
}
|
||||
if (stunInfo is null) return null;
|
||||
|
||||
var supportIPv6 = false;
|
||||
foreach (var ip in stunInfo.Ips)
|
||||
{
|
||||
if (ip.Contains(":"))
|
||||
{
|
||||
supportIPv6 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new NetStatus { UdpNatType = GetNatTypeViaCode(stunInfo.UdpNatType), TcpNatType = GetNatTypeViaCode(stunInfo.TcpNatType), SupportIPv6 = supportIPv6 };
|
||||
}
|
||||
|
||||
public static NatType GetNatTypeViaCode(int type) => type switch
|
||||
{
|
||||
0 => NatType.OpenInternet,
|
||||
1 => NatType.NoPat,
|
||||
2 => NatType.FullCone,
|
||||
3 => NatType.Restricted,
|
||||
4 => NatType.PortRestricted,
|
||||
5 => NatType.SymmetricEasy,
|
||||
6 => NatType.Symmetric,
|
||||
7 => NatType.SymmetricFirewall,
|
||||
8 => NatType.UdpBlocked,
|
||||
_ => NatType.Unknown
|
||||
};
|
||||
|
||||
public static string GetNatTypeString(NatType type)
|
||||
{
|
||||
return Lang.Text(type switch
|
||||
{
|
||||
NatType.OpenInternet or NatType.NoPat => "Link.Nat.Type.Open",
|
||||
NatType.FullCone => "Link.Nat.Type.FullCone",
|
||||
NatType.PortRestricted => "Link.Nat.Type.PortRestricted",
|
||||
NatType.Restricted => "Link.Nat.Type.Restricted",
|
||||
NatType.SymmetricEasy => "Link.Nat.Type.SymmetricEasy",
|
||||
NatType.Symmetric => "Link.Nat.Type.Symmetric",
|
||||
NatType.SymmetricFirewall => "Link.Nat.Type.SymmetricFirewall",
|
||||
NatType.UdpBlocked => "Link.Nat.Type.UdpBlocked",
|
||||
_ => "Link.Nat.Type.Unknown"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace PCL.Core.Link.Scaffolding.EasyTier;
|
||||
|
||||
public enum ConnectionWay
|
||||
{
|
||||
Local,
|
||||
P2P,
|
||||
Relay,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public record EasyPlayerInfo
|
||||
{
|
||||
public required bool IsHost { get; init; }
|
||||
public required string HostName { get; init; }
|
||||
public required string Ip { get; init; }
|
||||
public string UserName { get; init; } = string.Empty;
|
||||
public string MinecraftName { get; init; } = string.Empty;
|
||||
public ConnectionWay Way { get; init; } = ConnectionWay.Unknown;
|
||||
public double Ping { get; init; }
|
||||
public double Loss { get; init; }
|
||||
public string NatType { get; init; } = string.Empty;
|
||||
public string EasyTierVer { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.Link.EasyTier;
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.IO.Net;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.EasyTier;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates the state of EasyTier entity.
|
||||
/// </summary>
|
||||
public enum EtState
|
||||
{
|
||||
Stopped,
|
||||
Active,
|
||||
Ready
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An EasyTier entity that manages the EasyTier process and its interactions.
|
||||
/// </summary>
|
||||
public class EasyTierEntity
|
||||
{
|
||||
private readonly Process _etProcess;
|
||||
private readonly int _rpcPort;
|
||||
private readonly LobbyInfo _lobby;
|
||||
private readonly int _scfPort;
|
||||
|
||||
public int ForwardPort { get; private set; }
|
||||
public int MinecraftPort { get; init; }
|
||||
public EtState State { get; private set; }
|
||||
public LobbyInfo Lobby => _lobby;
|
||||
|
||||
public event Action? EasyTierProcessExisted;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor of EasyTierEntity
|
||||
/// </summary>
|
||||
/// <param name="lobby">The room information.</param>
|
||||
/// <param name="minecraftPort">Minecraft port.</param>
|
||||
/// <param name="scfPort">The server port.</param>
|
||||
/// <param name="asHost">Indicates whether the entity acts as a host.</param>
|
||||
/// <exception cref="FileNotFoundException">Thrown if EasyTier was broken.</exception>
|
||||
public EasyTierEntity(LobbyInfo lobby, int minecraftPort, int scfPort, bool asHost)
|
||||
{
|
||||
_lobby = lobby;
|
||||
MinecraftPort = minecraftPort;
|
||||
_scfPort = scfPort;
|
||||
State = EtState.Stopped;
|
||||
|
||||
var existEntities = Process.GetProcessesByName("easytier-core");
|
||||
foreach (var entity in existEntities)
|
||||
{
|
||||
LogWrapper.Warn("EasyTier", $"Find exist EasyTier Entity, may affect something: {entity.Id}");
|
||||
}
|
||||
|
||||
LogWrapper.Info("EasyTier", $"EasyTier folder path: {EasyTierMetadata.EasyTierFilePath}");
|
||||
|
||||
if (!(File.Exists($"{EasyTierMetadata.EasyTierFilePath}\\easytier-core.exe") &&
|
||||
File.Exists($"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe") &&
|
||||
File.Exists($"{EasyTierMetadata.EasyTierFilePath}\\Packet.dll")))
|
||||
{
|
||||
LogWrapper.Error("EasyTier", "EasyTier was broken.");
|
||||
|
||||
throw new FileNotFoundException("EasyTier was broken.");
|
||||
}
|
||||
|
||||
State = EtState.Ready;
|
||||
|
||||
_rpcPort = NetworkHelper.NewTcpPort();
|
||||
|
||||
ForwardPort = NetworkHelper.NewTcpPort();
|
||||
|
||||
_etProcess = _BuildProcessAsync(asHost).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launches EasyTier process.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// - 1 means failed to launch EasyTier and can never launch again.<br/>
|
||||
/// - 0 means successful launch.
|
||||
/// </returns>
|
||||
public int Launch()
|
||||
{
|
||||
LogWrapper.Info("EasyTier", "Launch EasyTier Core.");
|
||||
|
||||
try
|
||||
{
|
||||
// LogWrapper.Info("Test", _etProcess.StartInfo.Arguments);
|
||||
_etProcess.Start();
|
||||
State = EtState.Active;
|
||||
|
||||
var cli = _GetCliOutputDebugAsync();
|
||||
|
||||
_etProcess.Exited += (_, _) => EasyTierProcessExisted?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "EasyTier", "Failed to launch EasyTier.");
|
||||
State = EtState.Stopped;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops EasyTier process.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// - 1 means failed to stop EasyTier.<br/>
|
||||
/// - 0 means successful stop.
|
||||
/// </returns>
|
||||
public Task<int> StopAsync()
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_etProcess.HasExited)
|
||||
{
|
||||
_etProcess.Kill(true);
|
||||
_etProcess.WaitForExit(5000);
|
||||
}
|
||||
|
||||
State = EtState.Stopped;
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "EasyTier", "Failed to stop EasyTier.");
|
||||
State = EtState.Stopped;
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Process> _BuildProcessAsync(bool asHost)
|
||||
{
|
||||
var process = new Process
|
||||
{
|
||||
EnableRaisingEvents = true,
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = Path.Combine(EasyTierMetadata.EasyTierFilePath, "easytier-core.exe"),
|
||||
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
}
|
||||
};
|
||||
|
||||
var args = new ArgumentsBuilder();
|
||||
|
||||
args.AddFlag("no-tun")
|
||||
.AddFlag("multi-thread")
|
||||
.AddFlag("enable-kcp-proxy")
|
||||
.AddFlag("enable-quic-proxy")
|
||||
.AddFlagIf(!Config.Link.TryPunchSym, "disable-sys-hole-punching")
|
||||
.AddFlagIf(!Config.Link.EnableIPv6, "disable-ipv6")
|
||||
.AddFlagIf(Config.Link.UseLatencyFirstMode, "latency-first")
|
||||
.Add("encryption-algorithm", "aes-gcm")
|
||||
.Add("compression", "zstd")
|
||||
.Add("default-protocol", Config.Link.ProtocolPreference.ToString().ToLowerInvariant())
|
||||
.Add("network-name", _lobby.NetworkName)
|
||||
.Add("network-secret", _lobby.NetworkSecret)
|
||||
//.Add("relay-network-whitelist", _lobby.NetworkName)
|
||||
.Add("machine-id", Utils.Secret.Identify.LauncherId)
|
||||
.Add("rpc-portal", _rpcPort.ToString())
|
||||
.Add("private-mode", "true")
|
||||
.AddFlag("p2p-only");
|
||||
|
||||
|
||||
if (asHost)
|
||||
{
|
||||
args.AddWithSpace("i", "10.114.51.41")
|
||||
.Add("hostname", $"scaffolding-mc-server-{_scfPort}")
|
||||
.Add("tcp-whitelist", _scfPort.ToString())
|
||||
.Add("udp-whitelist", _scfPort.ToString())
|
||||
.Add("tcp-whitelist", MinecraftPort.ToString())
|
||||
.Add("udp-whitelist", MinecraftPort.ToString())
|
||||
.Add("l", "tcp://0.0.0.0:0")
|
||||
.Add("l", "udp://0.0.0.0:0");
|
||||
}
|
||||
else
|
||||
{
|
||||
args.AddFlag("d")
|
||||
.Add("hostname", Guid.NewGuid().ToString())
|
||||
.Add("tcp-whitelist", "0")
|
||||
.Add("udp-whitelist", "0")
|
||||
.Add("l", "tcp://0.0.0.0:0")
|
||||
.Add("l", "udp://0.0.0.0:0");
|
||||
}
|
||||
|
||||
foreach (var address in ETRelay.RelayList
|
||||
.Select(static x => x.Url)
|
||||
.Concat(_fallbackNodeLinks))
|
||||
{
|
||||
args.Add("p", address);
|
||||
}
|
||||
|
||||
// foreach (var address in await _GetEtRelayListAsync().ConfigureAwait(false))
|
||||
// {
|
||||
// args.Add("p", address);
|
||||
// }
|
||||
|
||||
// if (Config.Link.RelayType == 1)
|
||||
// {
|
||||
// args.AddFlag("disable-p2p");
|
||||
// }
|
||||
|
||||
process.StartInfo.Arguments = args.GetResult();
|
||||
|
||||
LogWrapper.Debug("EasyTier", process.StartInfo.Arguments);
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> _GetEtRelayListAsync()
|
||||
{
|
||||
var relays = ETRelay.RelayList;
|
||||
var customedNodes = Config.Link.CustomRelayServer.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var node in customedNodes)
|
||||
{
|
||||
if (node.Contains("tcp://", StringComparison.OrdinalIgnoreCase) ||
|
||||
node.Contains("udp://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relays.Add(new ETRelay
|
||||
{
|
||||
Url = node,
|
||||
Name = "Custom",
|
||||
Type = ETRelayType.Custom
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
LogWrapper.Warn("EasyTier", $"Invalid custom node URL: {node}.");
|
||||
}
|
||||
}
|
||||
|
||||
var setupRelayList = relays.Select(relay => new { relay, serverType = Config.Link.ServerType })
|
||||
.Where(rl =>
|
||||
(rl.relay.Type == ETRelayType.Selfhosted && rl.serverType != 2) ||
|
||||
(rl.relay.Type == ETRelayType.Community && rl.serverType == 1) ||
|
||||
rl.relay.Type == ETRelayType.Custom)
|
||||
.Select(rl => rl.relay.Url).ToImmutableList();
|
||||
|
||||
var pubNode = await _GetPublicNodeAsync().ConfigureAwait(false);
|
||||
|
||||
var result = setupRelayList
|
||||
.Concat(pubNode)
|
||||
.Take(6)
|
||||
.ToImmutableList();
|
||||
|
||||
LogWrapper.Debug($"Get public node:\n{string.Join("\n\t", result)}");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private readonly string[] _fallbackNodeLinks =
|
||||
[
|
||||
"tcp://public.easytier.top:11010",
|
||||
"tcp://public2.easytier.cn:54321",
|
||||
"https://etnode.zkitefly.eu.org/node1",
|
||||
"https://etnode.zkitefly.eu.org/node2",
|
||||
"https://etnode.zkitefly.eu.org/-node1",
|
||||
"https://etnode.zkitefly.eu.org/-node2"
|
||||
];
|
||||
|
||||
|
||||
private async Task<IReadOnlyList<string>> _GetPublicNodeAsync()
|
||||
{
|
||||
using var rep = await HttpRequest
|
||||
.Create("https://uptime.easytier.cn/api/nodes?page=1&per_page=50&is_active=true")
|
||||
.SendAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
rep.EnsureSuccessStatusCode();
|
||||
|
||||
var dto = await rep
|
||||
.AsJsonAsync<PublicNodeDto>()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
|
||||
var result = dto.Data.Items
|
||||
.Where(it => it is { IsActive: true, IsAllowRelay: true })
|
||||
.Select(it => it.Host)
|
||||
.Union(_fallbackNodeLinks)
|
||||
.ToImmutableList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
#region Information
|
||||
|
||||
/// <summary>
|
||||
/// Checks the status of EasyTier network until it is ready or time-out.
|
||||
/// </summary>
|
||||
/// <returns>Returns 0 when the network is ready, otherwise returns 1 for timeout.</returns>
|
||||
public async Task<(bool, EtPlayerList?)> CheckEasyTierStatusAsync(CancellationToken ct = default)
|
||||
{
|
||||
var retryCount = 0;
|
||||
|
||||
while (_etProcess is null && retryCount < 10)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await Task.Delay(1000, ct).ConfigureAwait(false);
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
if (_etProcess is null)
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
retryCount = 0;
|
||||
while (State is EtState.Active && retryCount < 300)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var info = await _GetPlayersAsync().ConfigureAwait(false);
|
||||
if (info.Host is null)
|
||||
{
|
||||
LogWrapper.Debug("EasyTierEntity", "Retry to get EasyTier Info.");
|
||||
await Task.Delay(1000, ct).ConfigureAwait(false);
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
LogWrapper.Debug("EtEntity", "Successfully to get player info from EasyTier CLI.");
|
||||
|
||||
if (info.Host.Ping < 1000)
|
||||
{
|
||||
State = EtState.Ready;
|
||||
|
||||
return (true, info);
|
||||
}
|
||||
|
||||
await Task.Delay(1000, ct).ConfigureAwait(false);
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
LogWrapper.Debug("EtEntity", "Failed to get player info from EasyTier CLI.");
|
||||
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a port forward to the EasyTier instance.
|
||||
/// </summary>
|
||||
/// <param name="targetIp">Remote IP</param>
|
||||
/// <param name="targetPort">Remote Port</param>
|
||||
/// <returns>Forwarded local port</returns>
|
||||
public async Task<int> AddPortForwardAsync(string targetIp, int targetPort)
|
||||
{
|
||||
var localPort = NetworkHelper.NewTcpPort();
|
||||
using var cliProcess = new Process();
|
||||
cliProcess.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
|
||||
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
|
||||
ErrorDialog = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
StandardInputEncoding = Encoding.UTF8
|
||||
};
|
||||
cliProcess.EnableRaisingEvents = true;
|
||||
try
|
||||
{
|
||||
cliProcess.StartInfo.Arguments =
|
||||
$"--rpc-portal 127.0.0.1:{_rpcPort} port-forward add tcp 127.0.0.1:{localPort} {targetIp}:{targetPort}";
|
||||
cliProcess.Start();
|
||||
await cliProcess.WaitForExitAsync().ConfigureAwait(false);
|
||||
|
||||
cliProcess.StartInfo.Arguments =
|
||||
$"--rpc-portal 127.0.0.1:{_rpcPort} port-forward add udp 127.0.0.1:{localPort} {targetIp}:{targetPort}";
|
||||
cliProcess.Start();
|
||||
await cliProcess.WaitForExitAsync().ConfigureAwait(false);
|
||||
|
||||
cliProcess.StartInfo.Arguments =
|
||||
$"--rpc-portal 127.0.0.1:{_rpcPort} port-forward add tcp [::]:{localPort} {targetIp}:{targetPort}";
|
||||
cliProcess.Start();
|
||||
await cliProcess.WaitForExitAsync().ConfigureAwait(false);
|
||||
|
||||
cliProcess.StartInfo.Arguments =
|
||||
$"--rpc-portal 127.0.0.1:{_rpcPort} port-forward add udp [::]:{localPort} {targetIp}:{targetPort}";
|
||||
cliProcess.Start();
|
||||
await cliProcess.WaitForExitAsync().ConfigureAwait(false);
|
||||
|
||||
LogWrapper.Debug("ET Cli", await cliProcess.StandardOutput.ReadToEndAsync().ConfigureAwait(false) +
|
||||
await cliProcess.StandardError.ReadToEndAsync().ConfigureAwait(false));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogWrapper.Error(e, "ET Cli", "Failed to add port forward.");
|
||||
}
|
||||
return localPort;
|
||||
}
|
||||
|
||||
private async Task _GetCliOutputDebugAsync()
|
||||
{
|
||||
while (State != EtState.Stopped && Config.Link.EnableCliOutput)
|
||||
{
|
||||
using var cliProcess = new Process();
|
||||
cliProcess.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
|
||||
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
|
||||
Arguments = $"--rpc-portal 127.0.0.1:{_rpcPort} peer",
|
||||
ErrorDialog = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
StandardInputEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(5000));
|
||||
|
||||
try
|
||||
{
|
||||
cliProcess.Start();
|
||||
cliProcess.StandardInput.Close();
|
||||
|
||||
var stdOut = await cliProcess.StandardOutput.ReadToEndAsync(cts.Token).ConfigureAwait(false);
|
||||
var stdErr = await cliProcess.StandardError.ReadToEndAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
await cliProcess.WaitForExitAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var output = stdOut + stdErr;
|
||||
|
||||
LogWrapper.Info("EasyTier Cli Debug", "EasyTier Cli 抽样输出: \n" + output);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LogWrapper.Error(e, "EasyTier Cli", "Failed to get EasyTier Cli info");
|
||||
}
|
||||
|
||||
await Task.Delay(30000);
|
||||
}
|
||||
}
|
||||
|
||||
/// <exception cref="ArgumentException">Thrown if host is duplicated.</exception>
|
||||
private async Task<EtPlayerList> _GetPlayersAsync()
|
||||
{
|
||||
using var cliProcess = new Process();
|
||||
cliProcess.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
|
||||
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
|
||||
Arguments = $"--rpc-portal 127.0.0.1:{_rpcPort} -o json peer",
|
||||
ErrorDialog = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
StandardInputEncoding = Encoding.UTF8
|
||||
};
|
||||
cliProcess.EnableRaisingEvents = true;
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(5000));
|
||||
|
||||
try
|
||||
{
|
||||
LogWrapper.Debug("Et Cli", "Trying to get player info.");
|
||||
|
||||
cliProcess.Start();
|
||||
cliProcess.StandardInput.Close();
|
||||
|
||||
var stdOut = await cliProcess.StandardOutput.ReadToEndAsync(cts.Token).ConfigureAwait(false);
|
||||
var stdErr = await cliProcess.StandardError.ReadToEndAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
await cliProcess.WaitForExitAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var output = stdOut + stdErr;
|
||||
//LogWrapper.Debug("ET Cli", output);
|
||||
|
||||
if (JsonCompat.ParseNode(output) is not JsonArray jArray)
|
||||
{
|
||||
return new EtPlayerList(null, null);
|
||||
}
|
||||
|
||||
|
||||
List<EasyPlayerInfo> players = [];
|
||||
EasyPlayerInfo? host = null;
|
||||
foreach (var arr in jArray)
|
||||
{
|
||||
LogWrapper.Debug("Et Cli", "Getting player info.");
|
||||
|
||||
var info = arr.Deserialize<ETPeerInfo>(JsonCompat.SerializerOptions);
|
||||
if (info is null)
|
||||
{
|
||||
LogWrapper.Debug("Et Cli", "Player info is null.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.Hostname.StartsWith("scaffolding-mc-server-", StringComparison.Ordinal))
|
||||
{
|
||||
LogWrapper.Debug("Et Cli", $"Find host player: {info.Hostname}");
|
||||
|
||||
if (host is not null)
|
||||
{
|
||||
LogWrapper.Debug("Et Cli", "Duplicated host player.");
|
||||
throw new ArgumentException("Duplicated host.", nameof(host));
|
||||
}
|
||||
|
||||
host = _ConvertPeerToPlayer(info);
|
||||
continue;
|
||||
}
|
||||
|
||||
LogWrapper.Debug("Et Cli", $"Find player: {info.Hostname}");
|
||||
players.Add(_ConvertPeerToPlayer(info));
|
||||
}
|
||||
|
||||
LogWrapper.Debug("Et Cli", "Return from GetPlayersAsync().");
|
||||
|
||||
var result = host is null ? players : [host, .. players];
|
||||
|
||||
return new EtPlayerList(result, host);
|
||||
}
|
||||
catch (TaskCanceledException tce)
|
||||
{
|
||||
LogWrapper.Error(tce, "EasyTier", "Failed to read CLI output.");
|
||||
return new EtPlayerList(null, null);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "EasyTier", "Failed to get EasyTier player list info.");
|
||||
return new EtPlayerList(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static EasyPlayerInfo _ConvertPeerToPlayer(ETPeerInfo info)
|
||||
{
|
||||
var playerInfo = new EasyPlayerInfo
|
||||
{
|
||||
IsHost = info.Hostname.StartsWith("scaffolding-mc-server", StringComparison.Ordinal),
|
||||
HostName = info.Hostname,
|
||||
Ip = info.Ipv4,
|
||||
Ping = Math.Round(Convert.ToDouble(info.Ping != "-" ? info.Ping : "0")),
|
||||
Loss = Math.Round(Convert.ToDouble(info.Loss != "-" ? info.Loss.Replace("%", "") : "0")),
|
||||
NatType = info.NatType,
|
||||
EasyTierVer = info.ETVersion
|
||||
};
|
||||
|
||||
return playerInfo;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public record EtPlayerList(IReadOnlyList<EasyPlayerInfo>? Players, EasyPlayerInfo? Host);
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using PCL.Core.App;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.EasyTier;
|
||||
|
||||
public static class EasyTierMetadata
|
||||
{
|
||||
public const string CurrentEasyTierVer = "2.6.4";
|
||||
|
||||
public static string EasyTierFilePath => Path.Combine(Paths.SharedLocalData, "EasyTier",
|
||||
CurrentEasyTierVer,
|
||||
$"easytier-windows-{(RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x86_64")}");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.EasyTier;
|
||||
|
||||
internal record PublicNodeDto
|
||||
{
|
||||
[JsonPropertyName("success")] public bool IsSuccess { get; init; }
|
||||
[JsonPropertyName("data")] public required NodeDataDto Data { get; init; }
|
||||
}
|
||||
|
||||
internal record NodeDataDto
|
||||
{
|
||||
[JsonPropertyName("items")] public required IReadOnlyList<NodeItemDto> Items { get; init; }
|
||||
}
|
||||
|
||||
internal record NodeItemDto
|
||||
{
|
||||
[JsonPropertyName("address")] public required string Host { get; init; }
|
||||
[JsonPropertyName("allow_relay")] public bool IsAllowRelay { get; init; }
|
||||
[JsonPropertyName("is_active")] public bool IsActive { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.EasyTier;
|
||||
|
||||
public record StunInfo
|
||||
{
|
||||
[JsonPropertyName("udp_nat_type")] public required int UdpNatType { get; init; }
|
||||
[JsonPropertyName("tcp_nat_type")] public required int TcpNatType { get; init; }
|
||||
[JsonPropertyName("public_ip")] public required string[] Ips { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Exceptions;
|
||||
|
||||
public class FailedToGetPlayerException : Exception
|
||||
{
|
||||
public FailedToGetPlayerException() : base()
|
||||
{
|
||||
}
|
||||
|
||||
public FailedToGetPlayerException(string msg) : base(msg)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a scaffolding request fails with a non-success status code.
|
||||
/// </summary>
|
||||
public class ScaffoldingRequestException(byte statusCode, string? serverMessage = null) : Exception(_BuildMessage(
|
||||
statusCode,
|
||||
serverMessage))
|
||||
{
|
||||
/// <summary>
|
||||
/// The status code returned by the scaffolding server.
|
||||
/// </summary>
|
||||
public byte StatusCode { get; } = statusCode;
|
||||
|
||||
/// <summary>
|
||||
/// The error message or details returned by the server, if any.
|
||||
/// </summary>
|
||||
public string? ServerMessage { get; } = serverMessage;
|
||||
|
||||
private static string _BuildMessage(byte statusCode, string? serverMessage)
|
||||
{
|
||||
var errorType = statusCode switch
|
||||
{
|
||||
>= 32 and < 64 => "Protocol-defined error",
|
||||
255 => "Unknow error",
|
||||
_ => "Generic error"
|
||||
};
|
||||
|
||||
return string.IsNullOrEmpty(serverMessage)
|
||||
? $"Scaffolding request failed with status code {statusCode} ({errorType})."
|
||||
: $"Scaffolding request failed with status code {statusCode} ({errorType}): {serverMessage}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// This code is from terracota project.
|
||||
// Thanks for Burning_TNT's contribution!
|
||||
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding;
|
||||
|
||||
public static class LobbyCodeGenerator
|
||||
{
|
||||
private const string Chars = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
private const string FullCodePrefix = "U/";
|
||||
private const string NetworkNamePrefix = "scaffolding-mc-";
|
||||
private const int BaseVal = 34;
|
||||
|
||||
private const int DataLength = 16; // NNNN NNNN SSSS SSSS (16 chars)
|
||||
private const int HyphenCount = 3;
|
||||
private const int PayloadLength = DataLength + HyphenCount; // 19
|
||||
private const int CodeLength = PayloadLength + 2; // 21 ("U/")
|
||||
|
||||
private static readonly UInt128 _EncodingMaxValue;
|
||||
|
||||
private static readonly Dictionary<char, byte> _CharToValueMap;
|
||||
|
||||
static LobbyCodeGenerator()
|
||||
{
|
||||
_EncodingMaxValue = _CalculatePower(BaseVal, DataLength);
|
||||
|
||||
|
||||
_CharToValueMap = new Dictionary<char, byte>(36);
|
||||
for (byte i = 0; i < Chars.Length; i++)
|
||||
{
|
||||
_CharToValueMap[Chars[i]] = i;
|
||||
}
|
||||
|
||||
_CharToValueMap['I'] = 1;
|
||||
_CharToValueMap['O'] = 0;
|
||||
}
|
||||
|
||||
public static LobbyInfo Generate()
|
||||
{
|
||||
var randomValue = _GetSecureRandomUInt128();
|
||||
var valueInRange = randomValue % _EncodingMaxValue;
|
||||
var remainder = valueInRange % 7;
|
||||
var validValue = randomValue - remainder;
|
||||
|
||||
return _Encode(validValue);
|
||||
}
|
||||
|
||||
public static bool TryParse(string input, [NotNullWhen(true)] out LobbyInfo? roomInfo)
|
||||
{
|
||||
roomInfo = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input) ||
|
||||
!input.StartsWith(FullCodePrefix, StringComparison.Ordinal) ||
|
||||
input.Length != 21)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Span<byte> values = stackalloc byte[DataLength];
|
||||
var valueIndex = 0;
|
||||
var payloadSpan = input.AsSpan(FullCodePrefix.Length);
|
||||
|
||||
for (var i = 0; i < payloadSpan.Length; i++)
|
||||
{
|
||||
var ch = payloadSpan[i];
|
||||
if (ch == '-')
|
||||
{
|
||||
if (i != 4 && i != 9 && i != 14)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueIndex >= DataLength ||
|
||||
!_CharToValueMap.TryGetValue(char.ToUpperInvariant(ch), out var charValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
values[valueIndex++] = charValue;
|
||||
}
|
||||
|
||||
if (valueIndex != DataLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UInt128 value = 0;
|
||||
for (var i = DataLength - 1; i >= 0; i--)
|
||||
{
|
||||
value = value * BaseVal + values[i];
|
||||
}
|
||||
|
||||
if (value % 7 != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var networkNamePayload = payloadSpan[..9];
|
||||
var networkSecretPayload = payloadSpan[10..];
|
||||
|
||||
roomInfo = new LobbyInfo(
|
||||
string.Concat(FullCodePrefix, payloadSpan).ToUpperInvariant(),
|
||||
string.Concat(NetworkNamePrefix, networkNamePayload),
|
||||
networkSecretPayload.ToString());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static LobbyInfo _Encode(UInt128 value)
|
||||
{
|
||||
var codePayload = string.Create(PayloadLength, value, (span, val) =>
|
||||
{
|
||||
Span<char> tempChars = stackalloc char[DataLength];
|
||||
for (var i = 0; i < DataLength; i++)
|
||||
{
|
||||
tempChars[i] = Chars[(int)(val % BaseVal)];
|
||||
val /= BaseVal;
|
||||
}
|
||||
|
||||
tempChars[..4].CopyTo(span[..4]);
|
||||
span[4] = '-';
|
||||
tempChars[4..8].CopyTo(span[5..9]);
|
||||
span[9] = '-';
|
||||
tempChars[8..12].CopyTo(span[10..14]);
|
||||
span[14] = '-';
|
||||
tempChars[12..16].CopyTo(span[15..]);
|
||||
});
|
||||
|
||||
var networkNamePayload = codePayload.AsSpan(0, 9);
|
||||
var networkSecretPayload = codePayload.AsSpan(10);
|
||||
|
||||
return new LobbyInfo(
|
||||
string.Concat(FullCodePrefix, codePayload),
|
||||
string.Concat(NetworkNamePrefix, networkNamePayload),
|
||||
networkSecretPayload.ToString());
|
||||
}
|
||||
|
||||
private static UInt128 _GetSecureRandomUInt128()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
|
||||
var lower = MemoryMarshal.Read<ulong>(bytes);
|
||||
var upper = MemoryMarshal.Read<ulong>(bytes[8..]);
|
||||
|
||||
return new UInt128(lower, upper);
|
||||
}
|
||||
|
||||
private static UInt128 _CalculatePower(uint baseVal, int exp)
|
||||
{
|
||||
UInt128 result = 1;
|
||||
for (var i = 0; i < exp; i++)
|
||||
{
|
||||
result *= baseVal;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding;
|
||||
|
||||
public class PlayerListHandler
|
||||
{
|
||||
public static List<PlayerProfile> Sort(IReadOnlyList<PlayerProfile> list)
|
||||
{
|
||||
var sorted = new List<PlayerProfile>();
|
||||
foreach (var profile in list)
|
||||
{
|
||||
if (profile.Kind == PlayerKind.HOST)
|
||||
{
|
||||
sorted.Insert(0, profile);
|
||||
}
|
||||
else
|
||||
{
|
||||
sorted.Add(profile);
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using PCL.Core.Link.Scaffolding.Client;
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Link.Scaffolding.EasyTier;
|
||||
using PCL.Core.Link.Scaffolding.Exceptions;
|
||||
using PCL.Core.Link.Scaffolding.Server;
|
||||
using PCL.Core.App;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.IO.Net;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding;
|
||||
|
||||
public static class ScaffoldingFactory
|
||||
{
|
||||
// Please update ScaffoldingServerContext.cs at the same time.
|
||||
private static readonly string _LobbyVendor = $"PCL CE {Basics.VersionName}, EasyTier {EasyTierMetadata.CurrentEasyTierVer}";
|
||||
private const string HostIp = "10.114.51.41";
|
||||
|
||||
/// <exception cref="ArgumentException">Invalid lobby code.</exception>
|
||||
/// <exception cref="FailedToGetPlayerException">Thrown if failed to get host player info.</exception>
|
||||
/// <exception cref="InvalidOperationException">Failed to get EasyTier Info.</exception>
|
||||
public static async Task<ScaffoldingClientEntity> CreateClientAsync
|
||||
(string playerName, string lobbyCode, LobbyType from, CancellationToken ct = default)
|
||||
{
|
||||
var machineId = Utils.Secret.Identify.LauncherId;
|
||||
|
||||
if (!LobbyCodeGenerator.TryParse(lobbyCode, out var info))
|
||||
{
|
||||
throw new ArgumentException("Invalid lobby code.", nameof(lobbyCode));
|
||||
}
|
||||
|
||||
var etEntity = _CreateEasyTierEntity(info, 0, 0, false);
|
||||
etEntity.Launch();
|
||||
|
||||
try
|
||||
{
|
||||
var (etStatus, players) = await etEntity.CheckEasyTierStatusAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (!etStatus || players is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to get EasyTier Info.");
|
||||
}
|
||||
|
||||
if (players.Players is null)
|
||||
{
|
||||
throw new FailedToGetPlayerException();
|
||||
}
|
||||
|
||||
var hostInfo = players.Host;
|
||||
|
||||
if (hostInfo is null)
|
||||
{
|
||||
throw new FailedToGetPlayerException("Can not get the host information.");
|
||||
}
|
||||
|
||||
if (!int.TryParse(hostInfo.HostName[22..], out var scfPort))
|
||||
{
|
||||
throw new ArgumentException("Invalid hostname.", nameof(hostInfo));
|
||||
}
|
||||
|
||||
var localPort = await etEntity.AddPortForwardAsync(hostInfo.Ip, scfPort).ConfigureAwait(false);
|
||||
|
||||
var client = new ScaffoldingClient("127.0.0.1", localPort, playerName, machineId, _LobbyVendor);
|
||||
|
||||
return new ScaffoldingClientEntity(client, etEntity, hostInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await etEntity.StopAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create Scaffolding Server.
|
||||
/// </summary>
|
||||
/// <param name="mcPort">Target forward Miencraft shared port.</param>
|
||||
/// <param name="playerName">Game player name.</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException">Fialed to launch EasyTier Core.</exception>
|
||||
public static ScaffoldingServerEntity CreateServer(int mcPort, string playerName)
|
||||
{
|
||||
var context = ScaffoldingServerContext.Create(playerName, mcPort);
|
||||
var scfPort = NetworkHelper.NewTcpPort();
|
||||
|
||||
var etEntity = _CreateEasyTierEntity(context.UserLobbyInfo, mcPort, scfPort, true);
|
||||
var res = etEntity.Launch();
|
||||
if (res != 0)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to launch EasyTier Core.");
|
||||
}
|
||||
|
||||
var server = new ScaffoldingServer(scfPort, context);
|
||||
|
||||
return new ScaffoldingServerEntity(server, etEntity);
|
||||
}
|
||||
|
||||
private static EasyTierEntity _CreateEasyTierEntity(LobbyInfo lobby, int mcPort, int port, bool asHost) =>
|
||||
new(lobby, mcPort, port, asHost);
|
||||
}
|
||||
|
||||
public record ScaffoldingClientEntity(ScaffoldingClient Client, EasyTierEntity EasyTier, EasyPlayerInfo HostInfo);
|
||||
|
||||
public record ScaffoldingServerEntity(ScaffoldingServer Server, EasyTierEntity EasyTier);
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Server.Abstractions;
|
||||
|
||||
public interface IRequestHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the request type this handler is responsible for, e.g., "c:ping".
|
||||
/// </summary>
|
||||
string RequestType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Handle an incoming request and returns a response.
|
||||
/// </summary>
|
||||
/// <param name="requestBody">The raw body of the request.</param>
|
||||
/// <param name="context">The shared server context.</param>
|
||||
/// <param name="sessionId">A unique identifier for the client session.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>A tuple containing the status code and the response body.</returns>
|
||||
Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync
|
||||
(ReadOnlyMemory<byte> requestBody, IServerContext context, string sessionId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Server.Abstractions;
|
||||
|
||||
public interface IServerContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the list of currently connected player profiles.
|
||||
/// </summary>
|
||||
IReadOnlyList<PlayerProfile> PlayerProfiles { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of currently connected player profiles, keyed by a unique session identifier.
|
||||
/// </summary>
|
||||
ConcurrentDictionary<string, TrackedPlayerProfile> TrackedPlayers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs on player profile changed.
|
||||
/// </summary>
|
||||
void OnPlayerProfilesChanged();
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the list of player profiles changes.
|
||||
/// </summary>
|
||||
event Action<IReadOnlyList<PlayerProfile>> PlayerProfilesPing;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prot of the running Minecraft server.
|
||||
/// </summary>
|
||||
int MinecraftServerProt { get; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the room information.
|
||||
/// </summary>
|
||||
LobbyInfo UserLobbyInfo { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Player name(Host).
|
||||
/// </summary>
|
||||
string PlayerName { get; }
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Link.Scaffolding.Server.Abstractions;
|
||||
using PCL.Core.Link.Scaffolding.EasyTier;
|
||||
using PCL.Core.App;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Server.Handlers;
|
||||
|
||||
public class GetPlayerProfileListHandler : IRequestHandler
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string RequestType { get; } = "c:player_profiles_list";
|
||||
|
||||
private static readonly JsonSerializerOptions _JsonOptions = new(JsonCompat.SerializerOptions)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync(ReadOnlyMemory<byte> requestBody,
|
||||
IServerContext context, string sessionId, CancellationToken ct)
|
||||
{
|
||||
var hostProfile = new PlayerProfile
|
||||
{
|
||||
Name = context.PlayerName,
|
||||
MachineId = Utils.Secret.Identify.LauncherId,
|
||||
Vendor = $"PCL CE {Basics.VersionName}, EasyTier {EasyTierMetadata.CurrentEasyTierVer}",
|
||||
Kind = PlayerKind.HOST
|
||||
};
|
||||
|
||||
var allProfiles = context.PlayerProfiles;
|
||||
|
||||
var responseBody = JsonSerializer.SerializeToUtf8Bytes(allProfiles, _JsonOptions);
|
||||
|
||||
return Task.FromResult(((byte)0, new ReadOnlyMemory<byte>(responseBody)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Link.Scaffolding.Server.Abstractions;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Server.Handlers;
|
||||
|
||||
public class GetProtocolsHandler : IRequestHandler
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string RequestType { get; } = "c:protocols";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync(ReadOnlyMemory<byte> requestBody,
|
||||
IServerContext context, string sessionId, CancellationToken ct)
|
||||
{
|
||||
string[] protocols =
|
||||
[
|
||||
"c:ping",
|
||||
"c:protocols",
|
||||
"c:server_port",
|
||||
"c:player_ping",
|
||||
"c:player_profiles_list"
|
||||
];
|
||||
|
||||
var rseponseContent = string.Join('\0', protocols);
|
||||
var responseBody = Encoding.ASCII.GetBytes(rseponseContent);
|
||||
|
||||
return Task.FromResult(((byte)0, new ReadOnlyMemory<byte>(responseBody)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using PCL.Core.Link.Scaffolding.Server.Abstractions;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Server.Handlers;
|
||||
|
||||
public class GetServerPortHandler : IRequestHandler
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string RequestType { get; } = "c:server_port";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync(ReadOnlyMemory<byte> requestBody,
|
||||
IServerContext context, string sessionId, CancellationToken ct)
|
||||
{
|
||||
var port = context.MinecraftServerProt;
|
||||
|
||||
if (port == 0)
|
||||
{
|
||||
return Task.FromResult<(byte, ReadOnlyMemory<byte>)>((32, ReadOnlyMemory<byte>.Empty));
|
||||
}
|
||||
|
||||
|
||||
var portBytes = new byte[2];
|
||||
var portAsUshort = (ushort)port;
|
||||
|
||||
BinaryPrimitives.WriteUInt16BigEndian(portBytes.AsSpan(), portAsUshort);
|
||||
|
||||
return Task.FromResult<(byte, ReadOnlyMemory<byte>)>((0, portBytes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Link.Scaffolding.Server.Abstractions;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Server.Handlers;
|
||||
|
||||
public class PingHandler : IRequestHandler
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string RequestType { get; } = "c:ping";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync(ReadOnlyMemory<byte> requestBody,
|
||||
IServerContext context, string sessionId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult((Status: (byte)0, Body: requestBody));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Link.Scaffolding.Server.Abstractions;
|
||||
using PCL.Core.Logging;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Server.Handlers;
|
||||
|
||||
public class PlayerPingHandler : IRequestHandler
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string RequestType { get; } = "c:player_ping";
|
||||
|
||||
private static readonly JsonSerializerOptions _JsonOptions = new(JsonCompat.SerializerOptions)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(byte Status, ReadOnlyMemory<byte> Body)> HandleAsync(ReadOnlyMemory<byte> requestBody,
|
||||
IServerContext context, string sessionId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = JsonSerializer.Deserialize<PlayerProfile>(requestBody.Span, _JsonOptions);
|
||||
if (profile is null || string.IsNullOrEmpty(profile.MachineId))
|
||||
{
|
||||
LogWrapper.Warn("ScaffoldingServer",
|
||||
$"Received a player_ping from session {sessionId} with a missing or empty machine_id. Ignoring.");
|
||||
return Task.FromResult(((byte)32, ReadOnlyMemory<byte>.Empty));
|
||||
}
|
||||
|
||||
var guestProfile = profile with { Kind = PlayerKind.GUEST };
|
||||
|
||||
var listChanged = false;
|
||||
context.TrackedPlayers.AddOrUpdate(guestProfile.MachineId,
|
||||
_ =>
|
||||
{
|
||||
LogWrapper.Info("ScaffoldingServer",
|
||||
$"New player '{guestProfile.Name}' with machine_id '{guestProfile.MachineId}' connected.");
|
||||
var newPlayer = new TrackedPlayerProfile { Profile = profile, LastSeenUtc = DateTime.UtcNow };
|
||||
listChanged = true;
|
||||
return newPlayer;
|
||||
},
|
||||
(_, existingPlayer) =>
|
||||
{
|
||||
existingPlayer.Profile = guestProfile;
|
||||
existingPlayer.LastSeenUtc = DateTime.UtcNow;
|
||||
|
||||
return existingPlayer;
|
||||
});
|
||||
|
||||
if (listChanged)
|
||||
{
|
||||
context.OnPlayerProfilesChanged();
|
||||
}
|
||||
|
||||
return Task.FromResult(((byte)0, ReadOnlyMemory<byte>.Empty));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
LogWrapper.Warn("ScaffoldingServer",
|
||||
$"Failed to deserialize player_ping JSON from session {sessionId}. Error: {ex.Message}");
|
||||
return Task.FromResult(((byte)32, ReadOnlyMemory<byte>.Empty));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Link.Scaffolding.Server.Abstractions;
|
||||
using PCL.Core.Link.Scaffolding.Server.Handlers;
|
||||
using PCL.Core.Logging;
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Pipelines;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Server;
|
||||
|
||||
/// <summary>
|
||||
/// A server for the Scaffolding data exchange protocol.
|
||||
/// </summary>
|
||||
public sealed class ScaffoldingServer : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly IServerContext _context;
|
||||
private readonly Dictionary<string, IRequestHandler> _handlers;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _listenTask;
|
||||
private Task? _cleanupTask;
|
||||
|
||||
private static readonly TimeSpan _PlayerTimeout = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan _CleanupInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
#region Events
|
||||
|
||||
public event Action<IReadOnlyList<PlayerProfile>>? ServerStarted;
|
||||
public event Action? ServerStopped;
|
||||
public event Action<Exception?>? ServerException;
|
||||
public event Action<IReadOnlyList<PlayerProfile>>? PlayerProfilePing;
|
||||
|
||||
private void _OnContextPlayersPing(IReadOnlyList<PlayerProfile> players)
|
||||
{
|
||||
PlayerProfilePing?.Invoke(players);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public ScaffoldingServer(int port, IServerContext context)
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||
_context = context;
|
||||
|
||||
_context.PlayerProfilesPing += _OnContextPlayersPing;
|
||||
|
||||
_handlers = new()
|
||||
{
|
||||
["c:player_ping"] = new PlayerPingHandler(),
|
||||
["c:server_port"] = new GetServerPortHandler(),
|
||||
["c:player_profiles_list"] = new GetPlayerProfileListHandler(),
|
||||
["c:protocols"] = new GetProtocolsHandler(),
|
||||
["c:ping"] = new PingHandler()
|
||||
};
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
_listener.Start();
|
||||
LogWrapper.Info("ScaffoldingServer",
|
||||
$"Successfully bound to {_listener.LocalEndpoint}. Starting to accept clients.");
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "ScaffoldingServer",
|
||||
$"Failed to start TCP listener on port {((IPEndPoint)_listener.LocalEndpoint).Port}. The port might be in use or blocked.");
|
||||
ServerException?.Invoke(ex);
|
||||
return; // 启动失败,直接返回
|
||||
}
|
||||
|
||||
_listenTask = _ListenForClientsAsync(_cts.Token);
|
||||
_cleanupTask = _MonitorPlayerLivenessAsync(_cts.Token);
|
||||
|
||||
_listenTask.ContinueWith(t =>
|
||||
{
|
||||
LogWrapper.Error(t.Exception, "ScaffoldingServer",
|
||||
"The main listening task failed unexpectedly. The server is no longer accepting new connections.");
|
||||
ServerException?.Invoke(t.Exception);
|
||||
}, TaskContinuationOptions.OnlyOnFaulted);
|
||||
|
||||
_cleanupTask.ContinueWith(
|
||||
t =>
|
||||
{
|
||||
LogWrapper.Error(t.Exception, "ScaffoldingServer", "The player cleanup task failed unexpectedly.");
|
||||
}, TaskContinuationOptions.OnlyOnFaulted);
|
||||
|
||||
LogWrapper.Debug("ScaffoldingServer", "Successfully scheduled server background tasks.");
|
||||
|
||||
ServerStarted?.Invoke(_context.PlayerProfiles);
|
||||
}
|
||||
|
||||
private async Task _MonitorPlayerLivenessAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_CleanupInterval, ct).ConfigureAwait(false);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var timedOutPlayerKeys = new List<string>();
|
||||
|
||||
foreach (var (machineId, trackedPlayer) in _context.TrackedPlayers)
|
||||
{
|
||||
if (trackedPlayer.Profile.Kind is PlayerKind.HOST)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now - trackedPlayer.LastSeenUtc > _PlayerTimeout)
|
||||
{
|
||||
timedOutPlayerKeys.Add(machineId);
|
||||
}
|
||||
}
|
||||
|
||||
if (timedOutPlayerKeys.Count > 0)
|
||||
{
|
||||
var listChanged = false;
|
||||
foreach (var key in timedOutPlayerKeys)
|
||||
{
|
||||
if (_context.TrackedPlayers.TryRemove(key, out var removedPlayer))
|
||||
{
|
||||
listChanged = true;
|
||||
LogWrapper.Info("ScaffoldingServer",
|
||||
$"Player '{removedPlayer.Profile.Name}' timed out and was removed.");
|
||||
}
|
||||
}
|
||||
|
||||
if (listChanged)
|
||||
{
|
||||
_context.OnPlayerProfilesChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "ScaffoldingServer", "An error occurred in the player cleanup task.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _ListenForClientsAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tcpClient = await _listener.AcceptTcpClientAsync(ct).ConfigureAwait(false);
|
||||
LogWrapper.Debug("ScaffoldingServer", $"Client connected: {tcpClient.Client.RemoteEndPoint}");
|
||||
_ = _HandleClientAsync(tcpClient, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
LogWrapper.Debug("ScaffoldingServer", "Listening task cancelled.");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "ScaffoldingServer", "Occurred an exception when server running.");
|
||||
|
||||
try
|
||||
{
|
||||
_listener.Stop();
|
||||
}
|
||||
catch (Exception lisEx)
|
||||
{
|
||||
LogWrapper.Error(lisEx, "ScaffoldingServer", "Occurred an exception when stop listening port.");
|
||||
}
|
||||
|
||||
|
||||
ServerStopped?.Invoke();
|
||||
break;
|
||||
}
|
||||
|
||||
LogWrapper.Debug("ScaffoldingServer", "Listening task finished.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _HandleClientAsync(TcpClient tcpClient, CancellationToken ct)
|
||||
{
|
||||
var sessionId = Guid.NewGuid().ToString();
|
||||
var clientEndPoint = tcpClient.Client.RemoteEndPoint?.ToString() ?? "unknown";
|
||||
LogWrapper.Debug("ScaffoldingServer", $"New connection {sessionId} from {clientEndPoint}.");
|
||||
|
||||
using (tcpClient)
|
||||
{
|
||||
var stream = tcpClient.GetStream();
|
||||
var reader = PipeReader.Create(stream);
|
||||
var writer = PipeWriter.Create(stream);
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
ReadResult readResult;
|
||||
try
|
||||
{
|
||||
readResult = await reader.ReadAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (IOException ex) when (ex.InnerException is SocketException se &&
|
||||
se.SocketErrorCode == SocketError.ConnectionReset)
|
||||
{
|
||||
LogWrapper.Info("ScaffoldingServer",
|
||||
$"Connection {sessionId} from {clientEndPoint} was closed by the client (Connection Reset).");
|
||||
break;
|
||||
}
|
||||
|
||||
var buffer = readResult.Buffer;
|
||||
var consumedPosition = buffer.Start;
|
||||
|
||||
// REFACTOR: 这是核心修改。我们现在循环处理一个缓冲区,直到无法再解析出完整的帧。
|
||||
while (_TryParseFrame(in buffer, out var requestFrame, out var frameEndPosition))
|
||||
{
|
||||
LogWrapper.Debug("ScaffoldingServer", $"[{sessionId}] Received frame: {requestFrame.TypeInfo}");
|
||||
if (_handlers.TryGetValue(requestFrame.TypeInfo, out var handler))
|
||||
{
|
||||
var (status, responseBody) = await handler
|
||||
.HandleAsync(requestFrame.Body, _context, sessionId, ct).ConfigureAwait(false);
|
||||
|
||||
var responseHeader = new byte[5];
|
||||
responseHeader[0] = status;
|
||||
BinaryPrimitives.WriteUInt32BigEndian(responseHeader.AsSpan(1), (uint)responseBody.Length);
|
||||
await writer.WriteAsync(responseHeader, ct).ConfigureAwait(false);
|
||||
if (responseBody.Length > 0)
|
||||
{
|
||||
await writer.WriteAsync(responseBody, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await writer.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogWrapper.Warn("ScaffoldingServer",
|
||||
$"[{sessionId}] No handler for type: {requestFrame.TypeInfo}");
|
||||
}
|
||||
|
||||
// 将缓冲区切片到已处理帧的末尾,为下一次循环做准备
|
||||
consumedPosition = frameEndPosition;
|
||||
buffer = buffer.Slice(consumedPosition);
|
||||
}
|
||||
|
||||
// 告诉 PipeReader 我们已经检查了直到 readResult.Buffer.End 的所有数据,
|
||||
// 并且我们已经处理了直到 consumedPosition 的数据。
|
||||
reader.AdvanceTo(consumedPosition, buffer.End);
|
||||
|
||||
if (readResult.IsCompleted)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
LogWrapper.Warn("ScaffoldingServer",
|
||||
$"Malformed packet from {clientEndPoint} on connection {sessionId}. Closing connection. Reason: {ex.Message}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
LogWrapper.Debug("ScaffoldingServer", $"Connection {sessionId} was canceled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "ScaffoldingServer", $"Unexpected error on connection {sessionId}.");
|
||||
}
|
||||
|
||||
LogWrapper.Debug("ScaffoldingServer", $"Connection {sessionId} from {clientEndPoint} has ended.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool _TryParseFrame
|
||||
(in ReadOnlySequence<byte> buffer, out (string TypeInfo, byte[] Body) frame, out SequencePosition consumed)
|
||||
{
|
||||
frame = default;
|
||||
consumed = buffer.Start;
|
||||
|
||||
const int maxTypeLength = 128;
|
||||
const int maxBodyLength = 65536;
|
||||
|
||||
var reader = new SequenceReader<byte>(buffer);
|
||||
|
||||
// 检查头部是否完整 (1字节类型长度 + 4字节内容长度)
|
||||
if (buffer.Length < 1) return false;
|
||||
if (!reader.TryRead(out var typeLength)) return false;
|
||||
|
||||
if (typeLength is 0 or > maxTypeLength)
|
||||
throw new InvalidDataException($"Invalid frame type length: {typeLength}.");
|
||||
|
||||
if (reader.Remaining < typeLength + 4) return false;
|
||||
|
||||
// 读取类型信息
|
||||
Span<byte> typeInfoSpan = stackalloc byte[typeLength];
|
||||
if (!reader.TryCopyTo(typeInfoSpan)) return false;
|
||||
reader.Advance(typeLength);
|
||||
var typeInfo = Encoding.UTF8.GetString(typeInfoSpan);
|
||||
|
||||
// 读取内容长度
|
||||
if (!reader.TryReadBigEndian(out int bodyLength32)) return false;
|
||||
var bodyLength = (uint)bodyLength32;
|
||||
if (bodyLength > maxBodyLength)
|
||||
throw new InvalidDataException($"Frame body length {bodyLength} exceeds maximum of {maxBodyLength}.");
|
||||
|
||||
// 检查内容是否完整
|
||||
if (reader.Remaining < bodyLength) return false;
|
||||
|
||||
// 提取内容
|
||||
var bodyBuffer = reader.Sequence.Slice(reader.Position, bodyLength);
|
||||
var body = bodyBuffer.ToArray();
|
||||
|
||||
// 构造帧
|
||||
frame = (typeInfo, body);
|
||||
|
||||
reader.Advance(bodyLength);
|
||||
consumed = reader.Position;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
LogWrapper.Debug("ScaffoldingServer", "Come into DisposeAsync().");
|
||||
if (!_cts.IsCancellationRequested)
|
||||
{
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_listenTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _listenTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "ScaffoldingServer",
|
||||
"An exception occurred while awaiting the listen task during disposal.");
|
||||
}
|
||||
}
|
||||
|
||||
if (_cleanupTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _cleanupTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "ScaffoldingServer",
|
||||
"An exception occurred while awaiting the cleanup task during disposal.");
|
||||
}
|
||||
}
|
||||
|
||||
_listener.Stop();
|
||||
|
||||
_cts.Dispose();
|
||||
|
||||
LogWrapper.Debug("ScaffoldingServer", "Server and all background tasks stopped gracefully.");
|
||||
|
||||
ServerStopped?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using PCL.Core.Link.Scaffolding.EasyTier;
|
||||
using PCL.Core.Link.Scaffolding.Server.Abstractions;
|
||||
using PCL.Core.App;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffolding Server running context.
|
||||
/// </summary>
|
||||
public class ScaffoldingServerContext : IServerContext
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TrackedPlayerProfile> _trackedPlayers = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConcurrentDictionary<string, TrackedPlayerProfile> TrackedPlayers
|
||||
{
|
||||
get => _trackedPlayers;
|
||||
private init => _trackedPlayers = value;
|
||||
}
|
||||
|
||||
public IReadOnlyList<PlayerProfile> PlayerProfiles =>
|
||||
_trackedPlayers.Values.Select(player => player.Profile).ToList().AsReadOnly();
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Action<IReadOnlyList<PlayerProfile>>? PlayerProfilesPing;
|
||||
|
||||
public void OnPlayerProfilesChanged()
|
||||
{
|
||||
var currentProfiles = PlayerProfiles;
|
||||
Task.Run(() => PlayerProfilesPing?.Invoke(currentProfiles));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int MinecraftServerProt { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public LobbyInfo UserLobbyInfo { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string PlayerName { get; }
|
||||
|
||||
private ScaffoldingServerContext(
|
||||
ConcurrentDictionary<string, TrackedPlayerProfile> profiles,
|
||||
int mcPort,
|
||||
LobbyInfo info,
|
||||
string playerName)
|
||||
{
|
||||
TrackedPlayers = profiles;
|
||||
MinecraftServerProt = mcPort;
|
||||
UserLobbyInfo = info;
|
||||
PlayerName = playerName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="ScaffoldingServerContext"/>.
|
||||
/// </summary>
|
||||
/// <param name="playerName">Player name.</param>
|
||||
/// <param name="mcPort">Minecraft shared port.</param>
|
||||
public static ScaffoldingServerContext Create(string playerName, int mcPort)
|
||||
{
|
||||
var machineId = Utils.Secret.Identify.LauncherId;
|
||||
var profile = new PlayerProfile
|
||||
{
|
||||
Name = playerName,
|
||||
MachineId = Utils.Secret.Identify.LauncherId,
|
||||
// Please update ScaffoldingFactory.cs at the same time.
|
||||
Vendor = $"PCL CE {Basics.VersionName}, EasyTier {EasyTierMetadata.CurrentEasyTierVer}",
|
||||
Kind = PlayerKind.HOST
|
||||
};
|
||||
|
||||
var tracked = new TrackedPlayerProfile { Profile = profile, LastSeenUtc = DateTime.UtcNow };
|
||||
|
||||
var roomCode = LobbyCodeGenerator.Generate();
|
||||
|
||||
var profiles = new ConcurrentDictionary<string, TrackedPlayerProfile>();
|
||||
profiles.TryAdd(machineId, tracked);
|
||||
|
||||
return new ScaffoldingServerContext(profiles, mcPort, roomCode, playerName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using PCL.Core.Link.Scaffolding.Client.Models;
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Link.Scaffolding.Server;
|
||||
|
||||
public record TrackedPlayerProfile
|
||||
{
|
||||
public required PlayerProfile Profile { get; set; }
|
||||
public required DateTime LastSeenUtc { get; set; }
|
||||
};
|
||||
Reference in New Issue
Block a user