initial commit, 4.5 stable
Some checks failed
🔗 GHA / 📊 Static checks (push) Has been cancelled
🔗 GHA / 🤖 Android (push) Has been cancelled
🔗 GHA / 🍏 iOS (push) Has been cancelled
🔗 GHA / 🐧 Linux (push) Has been cancelled
🔗 GHA / 🍎 macOS (push) Has been cancelled
🔗 GHA / 🏁 Windows (push) Has been cancelled
🔗 GHA / 🌐 Web (push) Has been cancelled

This commit is contained in:
2025-09-16 20:46:46 -04:00
commit 9d30169a8d
13378 changed files with 7050105 additions and 0 deletions

View File

@@ -0,0 +1,363 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using Newtonsoft.Json;
using System.Threading;
using System.Threading.Tasks;
using GodotTools.IdeMessaging.Requests;
using GodotTools.IdeMessaging.Utils;
namespace GodotTools.IdeMessaging
{
// ReSharper disable once UnusedType.Global
public sealed class Client : IDisposable
{
private readonly ILogger logger;
private readonly string identity;
private string MetaFilePath { get; }
private DateTime? metaFileModifiedTime;
private GodotIdeMetadata godotIdeMetadata;
private readonly FileSystemWatcher fsWatcher;
public string GodotEditorExecutablePath => godotIdeMetadata.EditorExecutablePath;
private readonly IMessageHandler messageHandler;
private Peer? peer;
private readonly SemaphoreSlim connectionSem = new SemaphoreSlim(1);
private readonly Queue<NotifyAwaiter<bool>> clientConnectedAwaiters = new Queue<NotifyAwaiter<bool>>();
private readonly Queue<NotifyAwaiter<bool>> clientDisconnectedAwaiters = new Queue<NotifyAwaiter<bool>>();
// ReSharper disable once UnusedMember.Global
public async Task<bool> AwaitConnected()
{
var awaiter = new NotifyAwaiter<bool>();
clientConnectedAwaiters.Enqueue(awaiter);
return await awaiter;
}
// ReSharper disable once UnusedMember.Global
public async Task<bool> AwaitDisconnected()
{
var awaiter = new NotifyAwaiter<bool>();
clientDisconnectedAwaiters.Enqueue(awaiter);
return await awaiter;
}
// ReSharper disable once MemberCanBePrivate.Global
public bool IsDisposed { get; private set; }
// ReSharper disable once MemberCanBePrivate.Global
[MemberNotNullWhen(true, "peer")]
public bool IsConnected => peer != null && !peer.IsDisposed && peer.IsTcpClientConnected;
// ReSharper disable once EventNeverSubscribedTo.Global
public event Action Connected
{
add
{
if (peer != null && !peer.IsDisposed)
peer.Connected += value;
}
remove
{
if (peer != null && !peer.IsDisposed)
peer.Connected -= value;
}
}
// ReSharper disable once EventNeverSubscribedTo.Global
public event Action Disconnected
{
add
{
if (peer != null && !peer.IsDisposed)
peer.Disconnected += value;
}
remove
{
if (peer != null && !peer.IsDisposed)
peer.Disconnected -= value;
}
}
~Client()
{
Dispose(disposing: false);
}
public async void Dispose()
{
if (IsDisposed)
return;
using (await connectionSem.UseAsync())
{
if (IsDisposed) // lock may not be fair
return;
IsDisposed = true;
}
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
private void Dispose(bool disposing)
{
if (disposing)
{
peer?.Dispose();
fsWatcher.Dispose();
}
}
public Client(string identity, string godotProjectDir, IMessageHandler messageHandler, ILogger logger)
{
this.identity = identity;
this.messageHandler = messageHandler;
this.logger = logger;
string projectMetadataDir = Path.Combine(godotProjectDir, ".godot", "mono", "metadata");
// FileSystemWatcher requires an existing directory
if (!Directory.Exists(projectMetadataDir))
{
// Check if the non hidden version exists
string nonHiddenProjectMetadataDir = Path.Combine(godotProjectDir, "godot", "mono", "metadata");
if (Directory.Exists(nonHiddenProjectMetadataDir))
{
projectMetadataDir = nonHiddenProjectMetadataDir;
}
else
{
Directory.CreateDirectory(projectMetadataDir);
}
}
MetaFilePath = Path.Combine(projectMetadataDir, GodotIdeMetadata.DefaultFileName);
fsWatcher = new FileSystemWatcher(projectMetadataDir, GodotIdeMetadata.DefaultFileName);
}
private async void OnMetaFileChanged(object sender, FileSystemEventArgs e)
{
if (IsDisposed)
return;
using (await connectionSem.UseAsync())
{
if (IsDisposed)
return;
if (!File.Exists(MetaFilePath))
return;
var lastWriteTime = File.GetLastWriteTime(MetaFilePath);
if (lastWriteTime == metaFileModifiedTime)
return;
metaFileModifiedTime = lastWriteTime;
var metadata = ReadMetadataFile();
if (metadata != null && metadata != godotIdeMetadata)
{
godotIdeMetadata = metadata.Value;
_ = Task.Run(ConnectToServer);
}
}
}
private async void OnMetaFileDeleted(object sender, FileSystemEventArgs e)
{
if (IsDisposed)
return;
if (IsConnected)
{
using (await connectionSem.UseAsync())
peer?.Dispose();
}
// The file may have been re-created
using (await connectionSem.UseAsync())
{
if (IsDisposed)
return;
if (IsConnected || !File.Exists(MetaFilePath))
return;
var lastWriteTime = File.GetLastWriteTime(MetaFilePath);
if (lastWriteTime == metaFileModifiedTime)
return;
metaFileModifiedTime = lastWriteTime;
var metadata = ReadMetadataFile();
if (metadata != null)
{
godotIdeMetadata = metadata.Value;
_ = Task.Run(ConnectToServer);
}
}
}
private GodotIdeMetadata? ReadMetadataFile()
{
using (var fileStream = new FileStream(MetaFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var reader = new StreamReader(fileStream))
{
string? portStr = reader.ReadLine();
if (portStr == null)
return null;
string? editorExecutablePath = reader.ReadLine();
if (editorExecutablePath == null)
return null;
if (!int.TryParse(portStr, out int port))
return null;
return new GodotIdeMetadata(port, editorExecutablePath);
}
}
private async Task AcceptClient(TcpClient tcpClient)
{
logger.LogDebug("Accept client...");
using (peer = new Peer(tcpClient, new ClientHandshake(), messageHandler, logger))
{
// ReSharper disable AccessToDisposedClosure
peer.Connected += () =>
{
logger.LogInfo("Connection open with Ide Client");
while (clientConnectedAwaiters.Count > 0)
clientConnectedAwaiters.Dequeue().SetResult(true);
};
peer.Disconnected += () =>
{
while (clientDisconnectedAwaiters.Count > 0)
clientDisconnectedAwaiters.Dequeue().SetResult(true);
};
// ReSharper restore AccessToDisposedClosure
try
{
if (!await peer.DoHandshake(identity))
{
logger.LogError("Handshake failed");
return;
}
}
catch (Exception e)
{
logger.LogError("Handshake failed with unhandled exception: ", e);
return;
}
await peer.Process();
logger.LogInfo("Connection closed with Ide Client");
}
}
private async Task ConnectToServer()
{
var tcpClient = new TcpClient();
try
{
logger.LogInfo("Connecting to Godot Ide Server");
await tcpClient.ConnectAsync(IPAddress.Loopback, godotIdeMetadata.Port);
logger.LogInfo("Connection open with Godot Ide Server");
await AcceptClient(tcpClient);
}
catch (SocketException e)
{
if (e.SocketErrorCode == SocketError.ConnectionRefused)
logger.LogError("The connection to the Godot Ide Server was refused");
else
throw;
}
}
// ReSharper disable once UnusedMember.Global
public async void Start()
{
fsWatcher.Created += OnMetaFileChanged;
fsWatcher.Changed += OnMetaFileChanged;
fsWatcher.Deleted += OnMetaFileDeleted;
fsWatcher.EnableRaisingEvents = true;
using (await connectionSem.UseAsync())
{
if (IsDisposed)
return;
if (IsConnected)
return;
if (!File.Exists(MetaFilePath))
{
logger.LogInfo("There is no Godot Ide Server running");
return;
}
var metadata = ReadMetadataFile();
if (metadata != null)
{
godotIdeMetadata = metadata.Value;
_ = Task.Run(ConnectToServer);
}
else
{
logger.LogError("Failed to read Godot Ide metadata file");
}
}
}
public async Task<TResponse?> SendRequest<TResponse>(Request request)
where TResponse : Response, new()
{
if (!IsConnected)
{
logger.LogError("Cannot write request. Not connected to the Godot Ide Server.");
return null;
}
string body = JsonConvert.SerializeObject(request);
return await peer.SendRequest<TResponse>(request.Id, body);
}
public async Task<TResponse?> SendRequest<TResponse>(string id, string body)
where TResponse : Response, new()
{
if (!IsConnected)
{
logger.LogError("Cannot write request. Not connected to the Godot Ide Server.");
return null;
}
return await peer.SendRequest<TResponse>(id, body);
}
}
}

View File

@@ -0,0 +1,45 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
namespace GodotTools.IdeMessaging
{
public class ClientHandshake : IHandshake
{
private static readonly string ClientHandshakeBase = $"{Peer.ClientHandshakeName},Version={Peer.ProtocolVersionMajor}.{Peer.ProtocolVersionMinor}.{Peer.ProtocolVersionRevision}";
private static readonly string ServerHandshakePattern = $@"{Regex.Escape(Peer.ServerHandshakeName)},Version=([0-9]+)\.([0-9]+)\.([0-9]+),([_a-zA-Z][_a-zA-Z0-9]{{0,63}})";
public string GetHandshakeLine(string identity) => $"{ClientHandshakeBase},{identity}";
public bool IsValidPeerHandshake(string handshake, [NotNullWhen(true)] out string? identity, ILogger logger)
{
identity = null;
var match = Regex.Match(handshake, ServerHandshakePattern);
if (!match.Success)
return false;
if (!uint.TryParse(match.Groups[1].Value, out uint serverMajor) || Peer.ProtocolVersionMajor != serverMajor)
{
logger.LogDebug("Incompatible major version: " + match.Groups[1].Value);
return false;
}
if (!uint.TryParse(match.Groups[2].Value, out uint serverMinor) || Peer.ProtocolVersionMinor < serverMinor)
{
logger.LogDebug("Incompatible minor version: " + match.Groups[2].Value);
return false;
}
if (!uint.TryParse(match.Groups[3].Value, out uint _)) // Revision
{
logger.LogDebug("Incompatible revision build: " + match.Groups[3].Value);
return false;
}
identity = match.Groups[4].Value;
return true;
}
}
}

View File

@@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using GodotTools.IdeMessaging.Requests;
using Newtonsoft.Json;
namespace GodotTools.IdeMessaging
{
// ReSharper disable once UnusedType.Global
public abstract class ClientMessageHandler : IMessageHandler
{
private readonly Dictionary<string, Peer.RequestHandler> requestHandlers;
protected ClientMessageHandler()
{
requestHandlers = InitializeRequestHandlers();
}
public async Task<MessageContent> HandleRequest(Peer peer, string id, MessageContent content, ILogger logger)
{
if (!requestHandlers.TryGetValue(id, out var handler))
{
logger.LogError($"Received unknown request: {id}");
return new MessageContent(MessageStatus.RequestNotSupported, "null");
}
try
{
var response = await handler(peer, content);
return new MessageContent(response.Status, JsonConvert.SerializeObject(response));
}
catch (JsonException)
{
logger.LogError($"Received request with invalid body: {id}");
return new MessageContent(MessageStatus.InvalidRequestBody, "null");
}
}
private Dictionary<string, Peer.RequestHandler> InitializeRequestHandlers()
{
return new Dictionary<string, Peer.RequestHandler>
{
[OpenFileRequest.Id] = async (peer, content) =>
{
var request = JsonConvert.DeserializeObject<OpenFileRequest>(content.Body);
return await HandleOpenFile(request!);
}
};
}
protected abstract Task<Response> HandleOpenFile(OpenFileRequest request);
}
}

View File

@@ -0,0 +1,142 @@
// ReSharper disable once CheckNamespace
namespace System.Diagnostics.CodeAnalysis
{
/// <summary>Specifies that null is allowed as an input even if the corresponding type disallows it.</summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)]
internal sealed class AllowNullAttribute : Attribute
{ }
/// <summary>Specifies that null is disallowed as an input even if the corresponding type allows it.</summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)]
internal sealed class DisallowNullAttribute : Attribute
{ }
/// <summary>Specifies that an output may be null even if the corresponding type disallows it.</summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)]
internal sealed class MaybeNullAttribute : Attribute
{ }
/// <summary>Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns.</summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)]
internal sealed class NotNullAttribute : Attribute
{ }
/// <summary>Specifies that when a method returns <see cref="ReturnValue"/>, the parameter may be null even if the corresponding type disallows it.</summary>
[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
internal sealed class MaybeNullWhenAttribute : Attribute
{
/// <summary>Initializes the attribute with the specified return value condition.</summary>
/// <param name="returnValue">
/// The return value condition. If the method returns this value, the associated parameter may be null.
/// </param>
public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
/// <summary>Gets the return value condition.</summary>
public bool ReturnValue { get; }
}
/// <summary>Specifies that when a method returns <see cref="ReturnValue"/>, the parameter will not be null even if the corresponding type allows it.</summary>
[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
internal sealed class NotNullWhenAttribute : Attribute
{
/// <summary>Initializes the attribute with the specified return value condition.</summary>
/// <param name="returnValue">
/// The return value condition. If the method returns this value, the associated parameter will not be null.
/// </param>
public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
/// <summary>Gets the return value condition.</summary>
public bool ReturnValue { get; }
}
/// <summary>Specifies that the output will be non-null if the named parameter is non-null.</summary>
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)]
internal sealed class NotNullIfNotNullAttribute : Attribute
{
/// <summary>Initializes the attribute with the associated parameter name.</summary>
/// <param name="parameterName">
/// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null.
/// </param>
public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName;
/// <summary>Gets the associated parameter name.</summary>
public string ParameterName { get; }
}
/// <summary>Applied to a method that will never return under any circumstance.</summary>
[AttributeUsage(AttributeTargets.Method, Inherited = false)]
internal sealed class DoesNotReturnAttribute : Attribute
{ }
/// <summary>Specifies that the method will not return if the associated Boolean parameter is passed the specified value.</summary>
[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
internal sealed class DoesNotReturnIfAttribute : Attribute
{
/// <summary>Initializes the attribute with the specified parameter value.</summary>
/// <param name="parameterValue">
/// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to
/// the associated parameter matches this value.
/// </param>
public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue;
/// <summary>Gets the condition parameter value.</summary>
public bool ParameterValue { get; }
}
/// <summary>Specifies that the method or property will ensure that the listed field and property members have not-null values.</summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)]
internal sealed class MemberNotNullAttribute : Attribute
{
/// <summary>Initializes the attribute with a field or property member.</summary>
/// <param name="member">
/// The field or property member that is promised to be not-null.
/// </param>
public MemberNotNullAttribute(string member) => Members = new[] { member };
/// <summary>Initializes the attribute with the list of field and property members.</summary>
/// <param name="members">
/// The list of field and property members that are promised to be not-null.
/// </param>
public MemberNotNullAttribute(params string[] members) => Members = members;
/// <summary>Gets field or property member names.</summary>
public string[] Members { get; }
}
/// <summary>Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition.</summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)]
internal sealed class MemberNotNullWhenAttribute : Attribute
{
/// <summary>Initializes the attribute with the specified return value condition and a field or property member.</summary>
/// <param name="returnValue">
/// The return value condition. If the method returns this value, the associated parameter will not be null.
/// </param>
/// <param name="member">
/// The field or property member that is promised to be not-null.
/// </param>
public MemberNotNullWhenAttribute(bool returnValue, string member)
{
ReturnValue = returnValue;
Members = new[] { member };
}
/// <summary>Initializes the attribute with the specified return value condition and list of field and property members.</summary>
/// <param name="returnValue">
/// The return value condition. If the method returns this value, the associated parameter will not be null.
/// </param>
/// <param name="members">
/// The list of field and property members that are promised to be not-null.
/// </param>
public MemberNotNullWhenAttribute(bool returnValue, params string[] members)
{
ReturnValue = returnValue;
Members = members;
}
/// <summary>Gets the return value condition.</summary>
public bool ReturnValue { get; }
/// <summary>Gets field or property member names.</summary>
public string[] Members { get; }
}
}

View File

@@ -0,0 +1,46 @@
using System.Diagnostics.CodeAnalysis;
namespace GodotTools.IdeMessaging
{
public readonly struct GodotIdeMetadata
{
public int Port { get; }
public string EditorExecutablePath { get; }
public const string DefaultFileName = "ide_messaging_meta.txt";
public GodotIdeMetadata(int port, string editorExecutablePath)
{
Port = port;
EditorExecutablePath = editorExecutablePath;
}
public static bool operator ==(GodotIdeMetadata a, GodotIdeMetadata b)
{
return a.Port == b.Port && a.EditorExecutablePath == b.EditorExecutablePath;
}
public static bool operator !=(GodotIdeMetadata a, GodotIdeMetadata b)
{
return !(a == b);
}
public override bool Equals([NotNullWhen(true)] object? obj)
{
return obj is GodotIdeMetadata metadata && metadata == this;
}
public bool Equals(GodotIdeMetadata other)
{
return Port == other.Port && EditorExecutablePath == other.EditorExecutablePath;
}
public override int GetHashCode()
{
unchecked
{
return (Port * 397) ^ (EditorExecutablePath != null ? EditorExecutablePath.GetHashCode() : 0);
}
}
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ProjectGuid>{92600954-25F0-4291-8E11-1FEE9FC4BE20}</ProjectGuid>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>9</LangVersion>
<Nullable>enable</Nullable>
<PackageId>GodotTools.IdeMessaging</PackageId>
<Version>1.1.2</Version>
<AssemblyVersion>$(Version)</AssemblyVersion>
<Authors>Godot Engine contributors</Authors>
<Company />
<PackageTags>godot</PackageTags>
<RepositoryUrl>https://github.com/godotengine/godot/tree/master/modules/mono/editor/GodotTools/GodotTools.IdeMessaging</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Copyright>Copyright (c) Godot Engine contributors</Copyright>
<Description>
This library enables communication with the Godot Engine editor (the version with .NET support).
It's intended for use in IDEs/editors plugins for a better experience working with Godot C# projects.
A client using this library is only compatible with servers of the same major version and of a lower or equal minor version.
</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
using System.Diagnostics.CodeAnalysis;
namespace GodotTools.IdeMessaging
{
public interface IHandshake
{
string GetHandshakeLine(string identity);
bool IsValidPeerHandshake(string handshake, [NotNullWhen(true)] out string? identity, ILogger logger);
}
}

View File

@@ -0,0 +1,13 @@
using System;
namespace GodotTools.IdeMessaging
{
public interface ILogger
{
void LogDebug(string message);
void LogInfo(string message);
void LogWarning(string message);
void LogError(string message);
void LogError(string message, Exception e);
}
}

View File

@@ -0,0 +1,9 @@
using System.Threading.Tasks;
namespace GodotTools.IdeMessaging
{
public interface IMessageHandler
{
Task<MessageContent> HandleRequest(Peer peer, string id, MessageContent content, ILogger logger);
}
}

View File

@@ -0,0 +1,52 @@
namespace GodotTools.IdeMessaging
{
public class Message
{
public MessageKind Kind { get; }
public string Id { get; }
public MessageContent Content { get; }
public Message(MessageKind kind, string id, MessageContent content)
{
Kind = kind;
Id = id;
Content = content;
}
public override string ToString()
{
return $"{Kind} | {Id}";
}
}
public enum MessageKind
{
Request,
Response
}
public enum MessageStatus
{
Ok,
RequestNotSupported,
InvalidRequestBody
}
public readonly struct MessageContent
{
public MessageStatus Status { get; }
public string Body { get; }
public MessageContent(string body)
{
Status = MessageStatus.Ok;
Body = body;
}
public MessageContent(MessageStatus status, string body)
{
Status = status;
Body = body;
}
}
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Text;
namespace GodotTools.IdeMessaging
{
public class MessageDecoder
{
private class DecodedMessage
{
public MessageKind? Kind;
public string? Id;
public MessageStatus? Status;
public readonly StringBuilder Body = new StringBuilder();
public uint? PendingBodyLines;
public void Clear()
{
Kind = null;
Id = null;
Status = null;
Body.Clear();
PendingBodyLines = null;
}
public Message ToMessage()
{
if (!Kind.HasValue || Id == null || !Status.HasValue ||
!PendingBodyLines.HasValue || PendingBodyLines.Value > 0)
throw new InvalidOperationException();
return new Message(Kind.Value, Id, new MessageContent(Status.Value, Body.ToString()));
}
}
public enum State
{
Decoding,
Decoded,
Errored
}
private readonly DecodedMessage decodingMessage = new DecodedMessage();
public State Decode(string messageLine, out Message? decodedMessage)
{
decodedMessage = null;
if (!decodingMessage.Kind.HasValue)
{
if (!Enum.TryParse(messageLine, ignoreCase: true, out MessageKind kind))
{
decodingMessage.Clear();
return State.Errored;
}
decodingMessage.Kind = kind;
}
else if (decodingMessage.Id == null)
{
decodingMessage.Id = messageLine;
}
else if (decodingMessage.Status == null)
{
if (!Enum.TryParse(messageLine, ignoreCase: true, out MessageStatus status))
{
decodingMessage.Clear();
return State.Errored;
}
decodingMessage.Status = status;
}
else if (decodingMessage.PendingBodyLines == null)
{
if (!uint.TryParse(messageLine, out uint pendingBodyLines))
{
decodingMessage.Clear();
return State.Errored;
}
decodingMessage.PendingBodyLines = pendingBodyLines;
}
else
{
if (decodingMessage.PendingBodyLines > 0)
{
decodingMessage.Body.AppendLine(messageLine);
decodingMessage.PendingBodyLines -= 1;
}
else
{
decodedMessage = decodingMessage.ToMessage();
decodingMessage.Clear();
return State.Decoded;
}
}
return State.Decoding;
}
}
}

View File

@@ -0,0 +1,298 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GodotTools.IdeMessaging.Requests;
using GodotTools.IdeMessaging.Utils;
namespace GodotTools.IdeMessaging
{
public sealed class Peer : IDisposable
{
/// <summary>
/// Major version.
/// There is no forward nor backward compatibility between different major versions.
/// Connection is refused if client and server have different major versions.
/// </summary>
public static readonly int ProtocolVersionMajor = Assembly.GetAssembly(typeof(Peer)).GetName().Version.Major;
/// <summary>
/// Minor version, which clients must be backward compatible with.
/// Connection is refused if the client's minor version is lower than the server's.
/// </summary>
public static readonly int ProtocolVersionMinor = Assembly.GetAssembly(typeof(Peer)).GetName().Version.Minor;
/// <summary>
/// Revision, which doesn't affect compatibility.
/// </summary>
public static readonly int ProtocolVersionRevision = Assembly.GetAssembly(typeof(Peer)).GetName().Version.Revision;
public const string ClientHandshakeName = "GodotIdeClient";
public const string ServerHandshakeName = "GodotIdeServer";
private const int ClientWriteTimeout = 8000;
public delegate Task<Response> RequestHandler(Peer peer, MessageContent content);
private readonly TcpClient tcpClient;
private readonly TextReader clientReader;
private readonly TextWriter clientWriter;
private readonly SemaphoreSlim writeSem = new SemaphoreSlim(1);
private string? remoteIdentity;
public string RemoteIdentity => remoteIdentity ??= string.Empty;
public event Action? Connected;
public event Action? Disconnected;
private ILogger Logger { get; }
public bool IsDisposed { get; private set; }
public bool IsTcpClientConnected => tcpClient.Client != null && tcpClient.Client.Connected;
private bool IsConnected { get; set; }
private readonly IHandshake handshake;
private readonly IMessageHandler messageHandler;
private readonly Dictionary<string, Queue<ResponseAwaiter>> requestAwaiterQueues = new Dictionary<string, Queue<ResponseAwaiter>>();
private readonly SemaphoreSlim requestsSem = new SemaphoreSlim(1);
public Peer(TcpClient tcpClient, IHandshake handshake, IMessageHandler messageHandler, ILogger logger)
{
this.tcpClient = tcpClient;
this.handshake = handshake;
this.messageHandler = messageHandler;
Logger = logger;
NetworkStream clientStream = tcpClient.GetStream();
clientStream.WriteTimeout = ClientWriteTimeout;
clientReader = new StreamReader(clientStream, Encoding.UTF8);
clientWriter = new StreamWriter(clientStream, Encoding.UTF8) { NewLine = "\n" };
}
public async Task Process()
{
try
{
var decoder = new MessageDecoder();
string? messageLine;
while ((messageLine = await ReadLine()) != null)
{
var state = decoder.Decode(messageLine, out var msg);
if (state == MessageDecoder.State.Decoding)
continue; // Not finished decoding yet
if (state == MessageDecoder.State.Errored)
{
Logger.LogError($"Received message line with invalid format: {messageLine}");
continue;
}
Logger.LogDebug($"Received message: {msg}");
try
{
if (msg!.Kind == MessageKind.Request)
{
var responseContent = await messageHandler.HandleRequest(this, msg.Id, msg.Content, Logger);
await WriteMessage(new Message(MessageKind.Response, msg.Id, responseContent));
}
else if (msg.Kind == MessageKind.Response)
{
ResponseAwaiter responseAwaiter;
using (await requestsSem.UseAsync())
{
if (!requestAwaiterQueues.TryGetValue(msg.Id, out var queue) || queue.Count <= 0)
{
Logger.LogError($"Received unexpected response: {msg.Id}");
return;
}
responseAwaiter = queue.Dequeue();
}
responseAwaiter.SetResult(msg.Content);
}
else
{
throw new IndexOutOfRangeException($"Invalid message kind {msg.Kind}");
}
}
catch (Exception e)
{
Logger.LogError($"Message handler for '{msg}' failed with exception", e);
}
}
}
catch (Exception e)
{
if (!IsDisposed || !(e is SocketException || e.InnerException is SocketException))
{
Logger.LogError("Unhandled exception in the peer loop", e);
}
}
}
public async Task<bool> DoHandshake(string identity)
{
if (!await WriteLine(handshake.GetHandshakeLine(identity)))
{
Logger.LogError("Could not write handshake");
return false;
}
var readHandshakeTask = ReadLine();
if (await Task.WhenAny(readHandshakeTask, Task.Delay(8000)) != readHandshakeTask)
{
Logger.LogError("Timeout waiting for the client handshake");
return false;
}
string? peerHandshake = await readHandshakeTask;
if (peerHandshake == null || !handshake.IsValidPeerHandshake(peerHandshake, out remoteIdentity, Logger))
{
Logger.LogError("Received invalid handshake: " + peerHandshake);
return false;
}
IsConnected = true;
Connected?.Invoke();
Logger.LogInfo("Peer connection started");
return true;
}
private async Task<string?> ReadLine()
{
try
{
return await clientReader.ReadLineAsync();
}
catch (Exception e)
{
if (IsDisposed)
{
var se = e as SocketException ?? e.InnerException as SocketException;
if (se != null && se.SocketErrorCode == SocketError.Interrupted)
return null;
}
throw;
}
}
private Task<bool> WriteMessage(Message message)
{
Logger.LogDebug($"Sending message: {message}");
int bodyLineCount = message.Content.Body.Count(c => c == '\n');
bodyLineCount += 1; // Extra line break at the end
var builder = new StringBuilder();
builder.AppendLine(message.Kind.ToString());
builder.AppendLine(message.Id);
builder.AppendLine(message.Content.Status.ToString());
builder.AppendLine(bodyLineCount.ToString());
builder.AppendLine(message.Content.Body);
return WriteLine(builder.ToString());
}
public async Task<TResponse?> SendRequest<TResponse>(string id, string body)
where TResponse : Response, new()
{
ResponseAwaiter responseAwaiter;
using (await requestsSem.UseAsync())
{
bool written = await WriteMessage(new Message(MessageKind.Request, id, new MessageContent(body)));
if (!written)
return null;
if (!requestAwaiterQueues.TryGetValue(id, out var queue))
{
queue = new Queue<ResponseAwaiter>();
requestAwaiterQueues.Add(id, queue);
}
responseAwaiter = new ResponseAwaiter<TResponse>();
queue.Enqueue(responseAwaiter);
}
return (TResponse)await responseAwaiter;
}
private async Task<bool> WriteLine(string text)
{
if (IsDisposed || !IsTcpClientConnected)
return false;
using (await writeSem.UseAsync())
{
try
{
await clientWriter.WriteLineAsync(text);
await clientWriter.FlushAsync();
}
catch (Exception e)
{
if (!IsDisposed)
{
var se = e as SocketException ?? e.InnerException as SocketException;
if (se != null && se.SocketErrorCode == SocketError.Shutdown)
Logger.LogInfo("Client disconnected ungracefully");
else
Logger.LogError("Exception thrown when trying to write to client", e);
Dispose();
}
}
}
return true;
}
// ReSharper disable once UnusedMember.Global
public void ShutdownSocketSend()
{
tcpClient.Client.Shutdown(SocketShutdown.Send);
}
public void Dispose()
{
if (IsDisposed)
return;
IsDisposed = true;
if (IsTcpClientConnected)
{
if (IsConnected)
Disconnected?.Invoke();
}
clientReader.Dispose();
clientWriter.Dispose();
((IDisposable)tcpClient).Dispose();
}
}
}

View File

@@ -0,0 +1,130 @@
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
// ReSharper disable UnusedAutoPropertyAccessor.Global
using System;
using Newtonsoft.Json;
namespace GodotTools.IdeMessaging.Requests
{
public abstract class Request
{
[JsonIgnore] public string Id { get; }
protected Request(string id)
{
Id = id;
}
}
public abstract class Response
{
[JsonIgnore] public MessageStatus Status { get; set; } = MessageStatus.Ok;
}
public sealed class CodeCompletionRequest : Request
{
public enum CompletionKind
{
InputActions = 0,
NodePaths,
ResourcePaths,
ScenePaths,
ShaderParams,
Signals,
ThemeColors,
ThemeConstants,
ThemeFonts,
ThemeStyles
}
public CompletionKind Kind { get; set; }
public string ScriptFile { get; set; } = string.Empty;
public new const string Id = "CodeCompletion";
public CodeCompletionRequest() : base(Id)
{
}
}
public sealed class CodeCompletionResponse : Response
{
public CodeCompletionRequest.CompletionKind Kind;
public string ScriptFile { get; set; } = string.Empty;
public string[] Suggestions { get; set; } = Array.Empty<string>();
}
public sealed class PlayRequest : Request
{
public new const string Id = "Play";
public PlayRequest() : base(Id)
{
}
}
public sealed class PlayResponse : Response
{
}
public sealed class StopPlayRequest : Request
{
public new const string Id = "StopPlay";
public StopPlayRequest() : base(Id)
{
}
}
public sealed class StopPlayResponse : Response
{
}
public sealed class DebugPlayRequest : Request
{
public string DebuggerHost { get; set; } = string.Empty;
public int DebuggerPort { get; set; }
public bool? BuildBeforePlaying { get; set; }
public new const string Id = "DebugPlay";
public DebugPlayRequest() : base(Id)
{
}
}
public sealed class DebugPlayResponse : Response
{
}
public sealed class OpenFileRequest : Request
{
public string File { get; set; } = string.Empty;
public int? Line { get; set; }
public int? Column { get; set; }
public new const string Id = "OpenFile";
public OpenFileRequest() : base(Id)
{
}
}
public sealed class OpenFileResponse : Response
{
}
public sealed class ReloadScriptsRequest : Request
{
public new const string Id = "ReloadScripts";
public ReloadScriptsRequest() : base(Id)
{
}
}
public sealed class ReloadScriptsResponse : Response
{
}
}

View File

@@ -0,0 +1,23 @@
using GodotTools.IdeMessaging.Requests;
using GodotTools.IdeMessaging.Utils;
using Newtonsoft.Json;
namespace GodotTools.IdeMessaging
{
public abstract class ResponseAwaiter : NotifyAwaiter<Response>
{
public abstract void SetResult(MessageContent content);
}
public class ResponseAwaiter<T> : ResponseAwaiter
where T : Response, new()
{
public override void SetResult(MessageContent content)
{
if (content.Status == MessageStatus.Ok)
SetResult(JsonConvert.DeserializeObject<T>(content.Body)!);
else
SetResult(new T { Status = content.Status });
}
}
}

View File

@@ -0,0 +1,67 @@
// ReSharper disable ParameterHidesMember
// ReSharper disable UnusedMember.Global
using System;
using System.Runtime.CompilerServices;
namespace GodotTools.IdeMessaging.Utils
{
public class NotifyAwaiter<T> : INotifyCompletion
{
private Action? continuation;
private Exception? exception;
private T? result;
public bool IsCompleted { get; private set; }
public T GetResult()
{
if (exception != null)
throw exception;
return result!;
}
public void OnCompleted(Action continuation)
{
if (this.continuation != null)
throw new InvalidOperationException("This awaiter already has a continuation.");
this.continuation = continuation;
}
public void SetResult(T result)
{
if (IsCompleted)
throw new InvalidOperationException("This awaiter is already completed.");
IsCompleted = true;
this.result = result;
continuation?.Invoke();
}
public void SetException(Exception exception)
{
if (IsCompleted)
throw new InvalidOperationException("This awaiter is already completed.");
IsCompleted = true;
this.exception = exception;
continuation?.Invoke();
}
public NotifyAwaiter<T> Reset()
{
continuation = null;
exception = null;
result = default(T);
IsCompleted = false;
return this;
}
public NotifyAwaiter<T> GetAwaiter()
{
return this;
}
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
namespace GodotTools.IdeMessaging.Utils
{
public static class SemaphoreExtensions
{
public static ConfiguredTaskAwaitable<IDisposable> UseAsync(this SemaphoreSlim semaphoreSlim, CancellationToken cancellationToken = default(CancellationToken))
{
var wrapper = new SemaphoreSlimWaitReleaseWrapper(semaphoreSlim, out Task waitAsyncTask, cancellationToken);
return waitAsyncTask.ContinueWith<IDisposable>(t => wrapper, cancellationToken).ConfigureAwait(false);
}
private readonly struct SemaphoreSlimWaitReleaseWrapper : IDisposable
{
private readonly SemaphoreSlim semaphoreSlim;
public SemaphoreSlimWaitReleaseWrapper(SemaphoreSlim semaphoreSlim, out Task waitAsyncTask, CancellationToken cancellationToken = default(CancellationToken))
{
this.semaphoreSlim = semaphoreSlim;
waitAsyncTask = this.semaphoreSlim.WaitAsync(cancellationToken);
}
public void Dispose()
{
semaphoreSlim.Release();
}
}
}
}