godot/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Client.cs

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