diff options
author | Ignacio Etcheverry <ignalfonsore@gmail.com> | 2020-05-09 20:45:43 +0200 |
---|---|---|
committer | Ignacio Etcheverry <ignalfonsore@gmail.com> | 2020-05-09 21:49:40 +0200 |
commit | 3ce09246d10a267bf00ae8bf37068ec458c69287 (patch) | |
tree | ca5fdaa109198e3a6dcd9cae55c71b9d8664e9a8 | |
parent | f3bcd5f8dd36c4ce384f00a03650f71dce5c4de6 (diff) |
C#: Rewrite GodotTools messaging protocol
42 files changed, 2239 insertions, 1047 deletions
diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/ConsoleLogger.cs b/modules/mono/editor/GodotTools/GodotTools.IdeConnection/ConsoleLogger.cs deleted file mode 100644 index 7a2ff2ca56..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/ConsoleLogger.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; - -namespace GodotTools.IdeConnection -{ - public class ConsoleLogger : ILogger - { - public void LogDebug(string message) - { - Console.WriteLine("DEBUG: " + message); - } - - public void LogInfo(string message) - { - Console.WriteLine("INFO: " + message); - } - - public void LogWarning(string message) - { - Console.WriteLine("WARN: " + message); - } - - public void LogError(string message) - { - Console.WriteLine("ERROR: " + message); - } - - public void LogError(string message, Exception e) - { - Console.WriteLine("EXCEPTION: " + message); - Console.WriteLine(e); - } - } -} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeBase.cs b/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeBase.cs deleted file mode 100644 index be89638241..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeBase.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using Path = System.IO.Path; - -namespace GodotTools.IdeConnection -{ - public class GodotIdeBase : IDisposable - { - private ILogger logger; - - public ILogger Logger - { - get => logger ?? (logger = new ConsoleLogger()); - set => logger = value; - } - - private readonly string projectMetadataDir; - - protected const string MetaFileName = "ide_server_meta.txt"; - protected string MetaFilePath => Path.Combine(projectMetadataDir, MetaFileName); - - private GodotIdeConnection connection; - protected readonly object ConnectionLock = new object(); - - public bool IsDisposed { get; private set; } = false; - - public bool IsConnected => connection != null && !connection.IsDisposed && connection.IsConnected; - - public event Action Connected - { - add - { - if (connection != null && !connection.IsDisposed) - connection.Connected += value; - } - remove - { - if (connection != null && !connection.IsDisposed) - connection.Connected -= value; - } - } - - protected GodotIdeConnection Connection - { - get => connection; - set - { - connection?.Dispose(); - connection = value; - } - } - - protected GodotIdeBase(string projectMetadataDir) - { - this.projectMetadataDir = projectMetadataDir; - } - - protected void DisposeConnection() - { - lock (ConnectionLock) - { - connection?.Dispose(); - } - } - - ~GodotIdeBase() - { - Dispose(disposing: false); - } - - public void Dispose() - { - if (IsDisposed) - return; - - lock (ConnectionLock) - { - if (IsDisposed) // lock may not be fair - return; - IsDisposed = true; - } - - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - connection?.Dispose(); - } - } - } -} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeClient.cs b/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeClient.cs deleted file mode 100644 index 2bf3b83c75..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeClient.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Sockets; -using System.Threading; - -namespace GodotTools.IdeConnection -{ - public abstract class GodotIdeClient : GodotIdeBase - { - protected GodotIdeMetadata GodotIdeMetadata; - - private readonly FileSystemWatcher fsWatcher; - - protected GodotIdeClient(string projectMetadataDir) : base(projectMetadataDir) - { - messageHandlers = InitializeMessageHandlers(); - - // FileSystemWatcher requires an existing directory - if (!File.Exists(projectMetadataDir)) - Directory.CreateDirectory(projectMetadataDir); - - fsWatcher = new FileSystemWatcher(projectMetadataDir, MetaFileName); - } - - private void OnMetaFileChanged(object sender, FileSystemEventArgs e) - { - if (IsDisposed) - return; - - lock (ConnectionLock) - { - if (IsDisposed) - return; - - if (!File.Exists(MetaFilePath)) - return; - - var metadata = ReadMetadataFile(); - - if (metadata != null && metadata != GodotIdeMetadata) - { - GodotIdeMetadata = metadata.Value; - ConnectToServer(); - } - } - } - - private void OnMetaFileDeleted(object sender, FileSystemEventArgs e) - { - if (IsDisposed) - return; - - if (IsConnected) - DisposeConnection(); - - // The file may have been re-created - - lock (ConnectionLock) - { - if (IsDisposed) - return; - - if (IsConnected || !File.Exists(MetaFilePath)) - return; - - var metadata = ReadMetadataFile(); - - if (metadata != null) - { - GodotIdeMetadata = metadata.Value; - ConnectToServer(); - } - } - } - - private GodotIdeMetadata? ReadMetadataFile() - { - using (var reader = File.OpenText(MetaFilePath)) - { - 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 void ConnectToServer() - { - var tcpClient = new TcpClient(); - - Connection = new GodotIdeConnectionClient(tcpClient, HandleMessage); - Connection.Logger = Logger; - - try - { - Logger.LogInfo("Connecting to Godot Ide Server"); - - tcpClient.Connect(IPAddress.Loopback, GodotIdeMetadata.Port); - - Logger.LogInfo("Connection open with Godot Ide Server"); - - var clientThread = new Thread(Connection.Start) - { - IsBackground = true, - Name = "Godot Ide Connection Client" - }; - clientThread.Start(); - } - catch (SocketException e) - { - if (e.SocketErrorCode == SocketError.ConnectionRefused) - Logger.LogError("The connection to the Godot Ide Server was refused"); - else - throw; - } - } - - public void Start() - { - Logger.LogInfo("Starting Godot Ide Client"); - - fsWatcher.Changed += OnMetaFileChanged; - fsWatcher.Deleted += OnMetaFileDeleted; - fsWatcher.EnableRaisingEvents = true; - - lock (ConnectionLock) - { - if (IsDisposed) - return; - - if (!File.Exists(MetaFilePath)) - { - Logger.LogInfo("There is no Godot Ide Server running"); - return; - } - - var metadata = ReadMetadataFile(); - - if (metadata != null) - { - GodotIdeMetadata = metadata.Value; - ConnectToServer(); - } - else - { - Logger.LogError("Failed to read Godot Ide metadata file"); - } - } - } - - public bool WriteMessage(Message message) - { - return Connection.WriteMessage(message); - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (disposing) - { - fsWatcher?.Dispose(); - } - } - - protected virtual bool HandleMessage(Message message) - { - if (messageHandlers.TryGetValue(message.Id, out var action)) - { - action(message.Arguments); - return true; - } - - return false; - } - - private readonly Dictionary<string, Action<string[]>> messageHandlers; - - private Dictionary<string, Action<string[]>> InitializeMessageHandlers() - { - return new Dictionary<string, Action<string[]>> - { - ["OpenFile"] = args => - { - switch (args.Length) - { - case 1: - OpenFile(file: args[0]); - return; - case 2: - OpenFile(file: args[0], line: int.Parse(args[1])); - return; - case 3: - OpenFile(file: args[0], line: int.Parse(args[1]), column: int.Parse(args[2])); - return; - default: - throw new ArgumentException(); - } - } - }; - } - - protected abstract void OpenFile(string file); - protected abstract void OpenFile(string file, int line); - protected abstract void OpenFile(string file, int line, int column); - } -} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeConnection.cs b/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeConnection.cs deleted file mode 100644 index 6441be8d6e..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeConnection.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Net.Sockets; -using System.Text; - -namespace GodotTools.IdeConnection -{ - public abstract class GodotIdeConnection : IDisposable - { - protected const string Version = "1.0"; - - protected static readonly string ClientHandshake = $"Godot Ide Client Version {Version}"; - protected static readonly string ServerHandshake = $"Godot Ide Server Version {Version}"; - - private const int ClientWriteTimeout = 8000; - private readonly TcpClient tcpClient; - - private TextReader clientReader; - private TextWriter clientWriter; - - private readonly object writeLock = new object(); - - private readonly Func<Message, bool> messageHandler; - - public event Action Connected; - - private ILogger logger; - - public ILogger Logger - { - get => logger ?? (logger = new ConsoleLogger()); - set => logger = value; - } - - public bool IsDisposed { get; private set; } = false; - - public bool IsConnected => tcpClient.Client != null && tcpClient.Client.Connected; - - protected GodotIdeConnection(TcpClient tcpClient, Func<Message, bool> messageHandler) - { - this.tcpClient = tcpClient; - this.messageHandler = messageHandler; - } - - public void Start() - { - try - { - if (!StartConnection()) - return; - - string messageLine; - while ((messageLine = ReadLine()) != null) - { - if (!MessageParser.TryParse(messageLine, out Message msg)) - { - Logger.LogError($"Received message with invalid format: {messageLine}"); - continue; - } - - Logger.LogDebug($"Received message: {msg}"); - - if (msg.Id == "close") - { - Logger.LogInfo("Closing connection"); - return; - } - - try - { - try - { - Debug.Assert(messageHandler != null); - - if (!messageHandler(msg)) - Logger.LogError($"Received unknown message: {msg}"); - } - catch (Exception e) - { - Logger.LogError($"Message handler for '{msg}' failed with exception", e); - } - } - catch (Exception e) - { - Logger.LogError($"Exception thrown from message handler. Message: {msg}", e); - } - } - } - catch (Exception e) - { - Logger.LogError($"Unhandled exception in the Godot Ide Connection thread", e); - } - finally - { - Dispose(); - } - } - - private bool StartConnection() - { - NetworkStream clientStream = tcpClient.GetStream(); - - clientReader = new StreamReader(clientStream, Encoding.UTF8); - - lock (writeLock) - clientWriter = new StreamWriter(clientStream, Encoding.UTF8); - - clientStream.WriteTimeout = ClientWriteTimeout; - - if (!WriteHandshake()) - { - Logger.LogError("Could not write handshake"); - return false; - } - - if (!IsValidResponseHandshake(ReadLine())) - { - Logger.LogError("Received invalid handshake"); - return false; - } - - Connected?.Invoke(); - - Logger.LogInfo("Godot Ide connection started"); - - return true; - } - - private string ReadLine() - { - try - { - return clientReader?.ReadLine(); - } - catch (Exception e) - { - if (IsDisposed) - { - var se = e as SocketException ?? e.InnerException as SocketException; - if (se != null && se.SocketErrorCode == SocketError.Interrupted) - return null; - } - - throw; - } - } - - public bool WriteMessage(Message message) - { - Logger.LogDebug($"Sending message {message}"); - - var messageComposer = new MessageComposer(); - - messageComposer.AddArgument(message.Id); - foreach (string argument in message.Arguments) - messageComposer.AddArgument(argument); - - return WriteLine(messageComposer.ToString()); - } - - protected bool WriteLine(string text) - { - if (clientWriter == null || IsDisposed || !IsConnected) - return false; - - lock (writeLock) - { - try - { - clientWriter.WriteLine(text); - clientWriter.Flush(); - } - 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; - } - - protected abstract bool WriteHandshake(); - protected abstract bool IsValidResponseHandshake(string handshakeLine); - - public void Dispose() - { - if (IsDisposed) - return; - - IsDisposed = true; - - clientReader?.Dispose(); - clientWriter?.Dispose(); - ((IDisposable)tcpClient)?.Dispose(); - } - } -} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeConnectionClient.cs b/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeConnectionClient.cs deleted file mode 100644 index 1b11a14358..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeConnectionClient.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Net.Sockets; -using System.Threading.Tasks; - -namespace GodotTools.IdeConnection -{ - public class GodotIdeConnectionClient : GodotIdeConnection - { - public GodotIdeConnectionClient(TcpClient tcpClient, Func<Message, bool> messageHandler) - : base(tcpClient, messageHandler) - { - } - - protected override bool WriteHandshake() - { - return WriteLine(ClientHandshake); - } - - protected override bool IsValidResponseHandshake(string handshakeLine) - { - return handshakeLine == ServerHandshake; - } - } -} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeConnectionServer.cs b/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeConnectionServer.cs deleted file mode 100644 index aa98dc7ca3..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeConnectionServer.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Net.Sockets; -using System.Threading.Tasks; - -namespace GodotTools.IdeConnection -{ - public class GodotIdeConnectionServer : GodotIdeConnection - { - public GodotIdeConnectionServer(TcpClient tcpClient, Func<Message, bool> messageHandler) - : base(tcpClient, messageHandler) - { - } - - protected override bool WriteHandshake() - { - return WriteLine(ServerHandshake); - } - - protected override bool IsValidResponseHandshake(string handshakeLine) - { - return handshakeLine == ClientHandshake; - } - } -} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotTools.IdeConnection.csproj b/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotTools.IdeConnection.csproj deleted file mode 100644 index c3ce3654ed..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotTools.IdeConnection.csproj +++ /dev/null @@ -1,11 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <ProjectGuid>{92600954-25F0-4291-8E11-1FEE9FC4BE20}</ProjectGuid> - <OutputType>Library</OutputType> - <TargetFramework>net472</TargetFramework> - <LangVersion>7.2</LangVersion> - </PropertyGroup> - <ItemGroup> - <Folder Include="Properties" /> - </ItemGroup> -</Project> diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/Message.cs b/modules/mono/editor/GodotTools/GodotTools.IdeConnection/Message.cs deleted file mode 100644 index f24d324ae3..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/Message.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Linq; - -namespace GodotTools.IdeConnection -{ - public struct Message - { - public string Id { get; set; } - public string[] Arguments { get; set; } - - public Message(string id, params string[] arguments) - { - Id = id; - Arguments = arguments; - } - - public override string ToString() - { - return $"(Id: '{Id}', Arguments: '{string.Join(",", Arguments)}')"; - } - } -} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/MessageComposer.cs b/modules/mono/editor/GodotTools/GodotTools.IdeConnection/MessageComposer.cs deleted file mode 100644 index 30ffe7a06e..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/MessageComposer.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Linq; -using System.Text; - -namespace GodotTools.IdeConnection -{ - public class MessageComposer - { - private readonly StringBuilder stringBuilder = new StringBuilder(); - - private static readonly char[] CharsToEscape = { '\\', '"' }; - - public void AddArgument(string argument) - { - AddArgument(argument, quoted: argument.Contains(",")); - } - - public void AddArgument(string argument, bool quoted) - { - if (stringBuilder.Length > 0) - stringBuilder.Append(','); - - if (quoted) - { - stringBuilder.Append('"'); - - foreach (char @char in argument) - { - if (CharsToEscape.Contains(@char)) - stringBuilder.Append('\\'); - stringBuilder.Append(@char); - } - - stringBuilder.Append('"'); - } - else - { - stringBuilder.Append(argument); - } - } - - public override string ToString() - { - return stringBuilder.ToString(); - } - } -} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/MessageParser.cs b/modules/mono/editor/GodotTools/GodotTools.IdeConnection/MessageParser.cs deleted file mode 100644 index 4365d69989..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/MessageParser.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace GodotTools.IdeConnection -{ - public static class MessageParser - { - public static bool TryParse(string messageLine, out Message message) - { - var arguments = new List<string>(); - var stringBuilder = new StringBuilder(); - - bool expectingArgument = true; - - for (int i = 0; i < messageLine.Length; i++) - { - char @char = messageLine[i]; - - if (@char == ',') - { - if (expectingArgument) - arguments.Add(string.Empty); - - expectingArgument = true; - continue; - } - - bool quoted = false; - - if (messageLine[i] == '"') - { - quoted = true; - i++; - } - - while (i < messageLine.Length) - { - @char = messageLine[i]; - - if (quoted && @char == '"') - { - i++; - break; - } - - if (@char == '\\') - { - i++; - if (i < messageLine.Length) - break; - - stringBuilder.Append(messageLine[i]); - } - else if (!quoted && @char == ',') - { - break; // We don't increment the counter to allow the colon to be parsed after this - } - else - { - stringBuilder.Append(@char); - } - - i++; - } - - arguments.Add(stringBuilder.ToString()); - stringBuilder.Clear(); - - expectingArgument = false; - } - - if (arguments.Count == 0) - { - message = new Message(); - return false; - } - - message = new Message - { - Id = arguments[0], - Arguments = arguments.Skip(1).ToArray() - }; - - return true; - } - } -} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging.CLI/ForwarderMessageHandler.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging.CLI/ForwarderMessageHandler.cs new file mode 100644 index 0000000000..3cb6a6687e --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging.CLI/ForwarderMessageHandler.cs @@ -0,0 +1,57 @@ +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GodotTools.IdeMessaging.Utils; + +namespace GodotTools.IdeMessaging.CLI +{ + public class ForwarderMessageHandler : IMessageHandler + { + private readonly StreamWriter outputWriter; + private readonly SemaphoreSlim outputWriteSem = new SemaphoreSlim(1); + + public ForwarderMessageHandler(StreamWriter outputWriter) + { + this.outputWriter = outputWriter; + } + + public async Task<MessageContent> HandleRequest(Peer peer, string id, MessageContent content, ILogger logger) + { + await WriteRequestToOutput(id, content); + return new MessageContent(MessageStatus.RequestNotSupported, "null"); + } + + private async Task WriteRequestToOutput(string id, MessageContent content) + { + using (await outputWriteSem.UseAsync()) + { + await outputWriter.WriteLineAsync("======= Request ======="); + await outputWriter.WriteLineAsync(id); + await outputWriter.WriteLineAsync(content.Body.Count(c => c == '\n').ToString()); + await outputWriter.WriteLineAsync(content.Body); + await outputWriter.WriteLineAsync("======================="); + await outputWriter.FlushAsync(); + } + } + + public async Task WriteResponseToOutput(string id, MessageContent content) + { + using (await outputWriteSem.UseAsync()) + { + await outputWriter.WriteLineAsync("======= Response ======="); + await outputWriter.WriteLineAsync(id); + await outputWriter.WriteLineAsync(content.Body.Count(c => c == '\n').ToString()); + await outputWriter.WriteLineAsync(content.Body); + await outputWriter.WriteLineAsync("========================"); + await outputWriter.FlushAsync(); + } + } + + public async Task WriteLineToOutput(string eventName) + { + using (await outputWriteSem.UseAsync()) + await outputWriter.WriteLineAsync($"======= {eventName} ======="); + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging.CLI/GodotTools.IdeMessaging.CLI.csproj b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging.CLI/GodotTools.IdeMessaging.CLI.csproj new file mode 100644 index 0000000000..ae78da27bc --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging.CLI/GodotTools.IdeMessaging.CLI.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <ProjectGuid>{B06C2951-C8E3-4F28-80B2-717CF327EB19}</ProjectGuid> + <OutputType>Exe</OutputType> + <TargetFramework>net472</TargetFramework> + <LangVersion>7.2</LangVersion> + </PropertyGroup> + <ItemGroup> + <Reference Include="System" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\GodotTools.IdeMessaging\GodotTools.IdeMessaging.csproj" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> + </ItemGroup> +</Project> diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging.CLI/Program.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging.CLI/Program.cs new file mode 100644 index 0000000000..99a55c471b --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging.CLI/Program.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using GodotTools.IdeMessaging.Requests; +using Newtonsoft.Json; + +namespace GodotTools.IdeMessaging.CLI +{ + internal static class Program + { + private static readonly ILogger Logger = new CustomLogger(); + + public static int Main(string[] args) + { + try + { + var mainTask = StartAsync(args, Console.OpenStandardInput(), Console.OpenStandardOutput()); + mainTask.Wait(); + return mainTask.Result; + } + catch (Exception ex) + { + Logger.LogError("Unhandled exception: ", ex); + return 1; + } + } + + private static async Task<int> StartAsync(string[] args, Stream inputStream, Stream outputStream) + { + var inputReader = new StreamReader(inputStream, Encoding.UTF8); + var outputWriter = new StreamWriter(outputStream, Encoding.UTF8); + + try + { + if (args.Length == 0) + { + Logger.LogError("Expected at least 1 argument"); + return 1; + } + + string godotProjectDir = args[0]; + + if (!Directory.Exists(godotProjectDir)) + { + Logger.LogError($"The specified Godot project directory does not exist: {godotProjectDir}"); + return 1; + } + + var forwarder = new ForwarderMessageHandler(outputWriter); + + using (var fwdClient = new Client("VisualStudioCode", godotProjectDir, forwarder, Logger)) + { + fwdClient.Start(); + + // ReSharper disable AccessToDisposedClosure + fwdClient.Connected += async () => await forwarder.WriteLineToOutput("Event=Connected"); + fwdClient.Disconnected += async () => await forwarder.WriteLineToOutput("Event=Disconnected"); + // ReSharper restore AccessToDisposedClosure + + // TODO: Await connected with timeout + + while (!fwdClient.IsDisposed) + { + string firstLine = await inputReader.ReadLineAsync(); + + if (firstLine == null || firstLine == "QUIT") + goto ExitMainLoop; + + string messageId = firstLine; + + string messageArgcLine = await inputReader.ReadLineAsync(); + + if (messageArgcLine == null) + { + Logger.LogInfo("EOF when expecting argument count"); + goto ExitMainLoop; + } + + if (!int.TryParse(messageArgcLine, out int messageArgc)) + { + Logger.LogError("Received invalid line for argument count: " + firstLine); + continue; + } + + var body = new StringBuilder(); + + for (int i = 0; i < messageArgc; i++) + { + string bodyLine = await inputReader.ReadLineAsync(); + + if (bodyLine == null) + { + Logger.LogInfo($"EOF when expecting body line #{i + 1}"); + goto ExitMainLoop; + } + + body.AppendLine(bodyLine); + } + + var response = await SendRequest(fwdClient, messageId, new MessageContent(MessageStatus.Ok, body.ToString())); + + if (response == null) + { + Logger.LogError($"Failed to write message to the server: {messageId}"); + } + else + { + var content = new MessageContent(response.Status, JsonConvert.SerializeObject(response)); + await forwarder.WriteResponseToOutput(messageId, content); + } + } + + ExitMainLoop: + + await forwarder.WriteLineToOutput("Event=Quit"); + } + + return 0; + } + catch (Exception e) + { + Logger.LogError("Unhandled exception", e); + return 1; + } + } + + private static async Task<Response> SendRequest(Client client, string id, MessageContent content) + { + var handlers = new Dictionary<string, Func<Task<Response>>> + { + [PlayRequest.Id] = async () => + { + var request = JsonConvert.DeserializeObject<PlayRequest>(content.Body); + return await client.SendRequest<PlayResponse>(request); + }, + [DebugPlayRequest.Id] = async () => + { + var request = JsonConvert.DeserializeObject<DebugPlayRequest>(content.Body); + return await client.SendRequest<DebugPlayResponse>(request); + }, + [ReloadScriptsRequest.Id] = async () => + { + var request = JsonConvert.DeserializeObject<ReloadScriptsRequest>(content.Body); + return await client.SendRequest<ReloadScriptsResponse>(request); + }, + [CodeCompletionRequest.Id] = async () => + { + var request = JsonConvert.DeserializeObject<CodeCompletionRequest>(content.Body); + return await client.SendRequest<CodeCompletionResponse>(request); + } + }; + + if (handlers.TryGetValue(id, out var handler)) + return await handler(); + + Console.WriteLine("INVALID REQUEST"); + return null; + } + + private class CustomLogger : ILogger + { + private static string ThisAppPath => Assembly.GetExecutingAssembly().Location; + private static string ThisAppPathWithoutExtension => Path.ChangeExtension(ThisAppPath, null); + + private static readonly string LogPath = $"{ThisAppPathWithoutExtension}.log"; + + private static StreamWriter NewWriter() => new StreamWriter(LogPath, append: true, encoding: Encoding.UTF8); + + private static void Log(StreamWriter writer, string message) + { + writer.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff}: {message}"); + } + + public void LogDebug(string message) + { + using (var writer = NewWriter()) + { + Log(writer, "DEBUG: " + message); + } + } + + public void LogInfo(string message) + { + using (var writer = NewWriter()) + { + Log(writer, "INFO: " + message); + } + } + + public void LogWarning(string message) + { + using (var writer = NewWriter()) + { + Log(writer, "WARN: " + message); + } + } + + public void LogError(string message) + { + using (var writer = NewWriter()) + { + Log(writer, "ERROR: " + message); + } + } + + public void LogError(string message, Exception e) + { + using (var writer = NewWriter()) + { + Log(writer, "EXCEPTION: " + message + '\n' + e); + } + } + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Client.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Client.cs new file mode 100644 index 0000000000..d069651dd3 --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Client.cs @@ -0,0 +1,332 @@ +using System; +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 GodotIdeMetadata godotIdeMetadata; + private readonly FileSystemWatcher fsWatcher; + + 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 + 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, ".mono", "metadata"); + + MetaFilePath = Path.Combine(projectMetadataDir, GodotIdeMetadata.DefaultFileName); + + // FileSystemWatcher requires an existing directory + if (!File.Exists(projectMetadataDir)) + Directory.CreateDirectory(projectMetadataDir); + + 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 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 metadata = ReadMetadataFile(); + + if (metadata != null) + { + godotIdeMetadata = metadata.Value; + _ = Task.Run(ConnectToServer); + } + } + } + + private GodotIdeMetadata? ReadMetadataFile() + { + using (var reader = File.OpenText(MetaFilePath)) + { + 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.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); + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ClientHandshake.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ClientHandshake.cs new file mode 100644 index 0000000000..43041be7be --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ClientHandshake.cs @@ -0,0 +1,44 @@ +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, 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; + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ClientMessageHandler.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ClientMessageHandler.cs new file mode 100644 index 0000000000..64bcfd824c --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ClientMessageHandler.cs @@ -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); + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeMetadata.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/GodotIdeMetadata.cs index d16daba0e2..686202e81e 100644 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/GodotIdeMetadata.cs +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/GodotIdeMetadata.cs @@ -1,10 +1,12 @@ -namespace GodotTools.IdeConnection +namespace GodotTools.IdeMessaging { - public struct GodotIdeMetadata + 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; diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/GodotTools.IdeMessaging.csproj b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/GodotTools.IdeMessaging.csproj new file mode 100644 index 0000000000..67815959a6 --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/GodotTools.IdeMessaging.csproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <ProjectGuid>{92600954-25F0-4291-8E11-1FEE9FC4BE20}</ProjectGuid> + <TargetFramework>netstandard2.0</TargetFramework> + <LangVersion>7.2</LangVersion> + <PackageId>GodotTools.IdeMessaging</PackageId> + <Version>1.1.0</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> + <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="12.0.3" /> + </ItemGroup> +</Project> diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/IHandshake.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/IHandshake.cs new file mode 100644 index 0000000000..6387145a28 --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/IHandshake.cs @@ -0,0 +1,8 @@ +namespace GodotTools.IdeMessaging +{ + public interface IHandshake + { + string GetHandshakeLine(string identity); + bool IsValidPeerHandshake(string handshake, out string identity, ILogger logger); + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/ILogger.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ILogger.cs index 614bb30271..d2855f93a1 100644 --- a/modules/mono/editor/GodotTools/GodotTools.IdeConnection/ILogger.cs +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ILogger.cs @@ -1,6 +1,6 @@ using System; -namespace GodotTools.IdeConnection +namespace GodotTools.IdeMessaging { public interface ILogger { diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/IMessageHandler.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/IMessageHandler.cs new file mode 100644 index 0000000000..9622fcc96d --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/IMessageHandler.cs @@ -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); + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Message.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Message.cs new file mode 100644 index 0000000000..6903ec197b --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Message.cs @@ -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; + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/MessageDecoder.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/MessageDecoder.cs new file mode 100644 index 0000000000..a00575a2a1 --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/MessageDecoder.cs @@ -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; + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Peer.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Peer.cs new file mode 100644 index 0000000000..a4e86d6177 --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Peer.cs @@ -0,0 +1,302 @@ +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 = string.Empty; + public string RemoteIdentity => remoteIdentity; + + 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 + { + 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) + { + Logger.LogError($"Exception thrown from message handler. Message: {msg}", e); + } + } + } + catch (Exception e) + { + 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 (handshake == 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 (clientWriter == null || 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(); + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Requests/Requests.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Requests/Requests.cs new file mode 100644 index 0000000000..1dd4f852e5 --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Requests/Requests.cs @@ -0,0 +1,116 @@ +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable UnusedMember.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global + +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; } + + public new const string Id = "CodeCompletion"; + + public CodeCompletionRequest() : base(Id) + { + } + } + + public sealed class CodeCompletionResponse : Response + { + public CodeCompletionRequest.CompletionKind Kind; + public string ScriptFile { get; set; } + public string[] Suggestions { get; set; } + } + + public sealed class PlayRequest : Request + { + public new const string Id = "Play"; + + public PlayRequest() : base(Id) + { + } + } + + public sealed class PlayResponse : Response + { + } + + public sealed class DebugPlayRequest : Request + { + public string DebuggerHost { get; set; } + 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; } + 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 + { + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ResponseAwaiter.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ResponseAwaiter.cs new file mode 100644 index 0000000000..548e7f06ee --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/ResponseAwaiter.cs @@ -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}); + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools/Utils/NotifyAwaiter.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Utils/NotifyAwaiter.cs index 700b786752..d84a63c83c 100644 --- a/modules/mono/editor/GodotTools/GodotTools/Utils/NotifyAwaiter.cs +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Utils/NotifyAwaiter.cs @@ -1,9 +1,9 @@ using System; using System.Runtime.CompilerServices; -namespace GodotTools.Utils +namespace GodotTools.IdeMessaging.Utils { - public sealed class NotifyAwaiter<T> : INotifyCompletion + public class NotifyAwaiter<T> : INotifyCompletion { private Action continuation; private Exception exception; diff --git a/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Utils/SemaphoreExtensions.cs b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Utils/SemaphoreExtensions.cs new file mode 100644 index 0000000000..9d593fbf8a --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Utils/SemaphoreExtensions.cs @@ -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 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(); + } + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools.sln b/modules/mono/editor/GodotTools/GodotTools.sln index a3438ea5f3..f6147eb5bb 100644 --- a/modules/mono/editor/GodotTools/GodotTools.sln +++ b/modules/mono/editor/GodotTools/GodotTools.sln @@ -9,7 +9,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GodotTools.Core", "GodotToo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GodotTools.BuildLogger", "GodotTools.BuildLogger\GodotTools.BuildLogger.csproj", "{6CE9A984-37B1-4F8A-8FE9-609F05F071B3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GodotTools.IdeConnection", "GodotTools.IdeConnection\GodotTools.IdeConnection.csproj", "{92600954-25F0-4291-8E11-1FEE9FC4BE20}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GodotTools.IdeMessaging", "GodotTools.IdeMessaging\GodotTools.IdeMessaging.csproj", "{92600954-25F0-4291-8E11-1FEE9FC4BE20}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/modules/mono/editor/GodotTools/GodotTools/BuildManager.cs b/modules/mono/editor/GodotTools/GodotTools/BuildManager.cs index cd646e6201..2d1b873808 100644 --- a/modules/mono/editor/GodotTools/GodotTools/BuildManager.cs +++ b/modules/mono/editor/GodotTools/GodotTools/BuildManager.cs @@ -219,7 +219,7 @@ namespace GodotTools if (File.Exists(editorScriptsMetadataPath)) File.Copy(editorScriptsMetadataPath, playerScriptsMetadataPath); - var currentPlayRequest = GodotSharpEditor.Instance.GodotIdeManager.GodotIdeServer.CurrentPlayRequest; + var currentPlayRequest = GodotSharpEditor.Instance.CurrentPlaySettings; if (currentPlayRequest != null) { @@ -233,7 +233,8 @@ namespace GodotTools ",server=n"); } - return true; // Requested play from an external editor/IDE which already built the project + if (!currentPlayRequest.Value.BuildBeforePlaying) + return true; // Requested play from an external editor/IDE which already built the project } var godotDefines = new[] diff --git a/modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs b/modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs index 812d575f3b..ec3cae00d5 100644 --- a/modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs +++ b/modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs @@ -37,6 +37,8 @@ namespace GodotTools public BottomPanel BottomPanel { get; private set; } + public PlaySettings? CurrentPlaySettings { get; set; } + public static string ProjectAssemblyName { get @@ -228,12 +230,12 @@ namespace GodotTools [UsedImplicitly] public Error OpenInExternalEditor(Script script, int line, int col) { - var editor = (ExternalEditorId)editorSettings.GetSetting("mono/editor/external_editor"); + var editorId = (ExternalEditorId)editorSettings.GetSetting("mono/editor/external_editor"); - switch (editor) + switch (editorId) { case ExternalEditorId.None: - // Tells the caller to fallback to the global external editor settings or the built-in editor + // Not an error. Tells the caller to fallback to the global external editor settings or the built-in editor. return Error.Unavailable; case ExternalEditorId.VisualStudio: throw new NotSupportedException(); @@ -249,10 +251,14 @@ namespace GodotTools { string scriptPath = ProjectSettings.GlobalizePath(script.ResourcePath); - if (line >= 0) - GodotIdeManager.SendOpenFile(scriptPath, line + 1, col); - else - GodotIdeManager.SendOpenFile(scriptPath); + GodotIdeManager.LaunchIdeAsync().ContinueWith(launchTask => + { + var editorPick = launchTask.Result; + if (line >= 0) + editorPick?.SendOpenFile(scriptPath, line + 1, col); + else + editorPick?.SendOpenFile(scriptPath); + }); break; } @@ -299,7 +305,7 @@ namespace GodotTools if (line >= 0) { args.Add("-g"); - args.Add($"{scriptPath}:{line + 1}:{col}"); + args.Add($"{scriptPath}:{line}:{col}"); } else { diff --git a/modules/mono/editor/GodotTools/GodotTools/GodotTools.csproj b/modules/mono/editor/GodotTools/GodotTools/GodotTools.csproj index 16e3e948be..6857663a4c 100644 --- a/modules/mono/editor/GodotTools/GodotTools/GodotTools.csproj +++ b/modules/mono/editor/GodotTools/GodotTools/GodotTools.csproj @@ -29,7 +29,7 @@ </ItemGroup> <ItemGroup> <ProjectReference Include="..\GodotTools.BuildLogger\GodotTools.BuildLogger.csproj" /> - <ProjectReference Include="..\GodotTools.IdeConnection\GodotTools.IdeConnection.csproj" /> + <ProjectReference Include="..\GodotTools.IdeMessaging\GodotTools.IdeMessaging.csproj" /> <ProjectReference Include="..\GodotTools.ProjectEditor\GodotTools.ProjectEditor.csproj" /> <ProjectReference Include="..\GodotTools.Core\GodotTools.Core.csproj" /> </ItemGroup> diff --git a/modules/mono/editor/GodotTools/GodotTools/Ides/GodotIdeManager.cs b/modules/mono/editor/GodotTools/GodotTools/Ides/GodotIdeManager.cs index 54f0ffab96..e4932ca217 100644 --- a/modules/mono/editor/GodotTools/GodotTools/Ides/GodotIdeManager.cs +++ b/modules/mono/editor/GodotTools/GodotTools/Ides/GodotIdeManager.cs @@ -1,73 +1,104 @@ using System; using System.IO; +using System.Threading.Tasks; using Godot; -using GodotTools.IdeConnection; +using GodotTools.IdeMessaging; +using GodotTools.IdeMessaging.Requests; using GodotTools.Internals; namespace GodotTools.Ides { - public class GodotIdeManager : Node, ISerializationListener + public sealed class GodotIdeManager : Node, ISerializationListener { - public GodotIdeServer GodotIdeServer { get; private set; } + private MessagingServer MessagingServer { get; set; } private MonoDevelop.Instance monoDevelInstance; private MonoDevelop.Instance vsForMacInstance; - private GodotIdeServer GetRunningServer() + private MessagingServer GetRunningOrNewServer() { - if (GodotIdeServer != null && !GodotIdeServer.IsDisposed) - return GodotIdeServer; - StartServer(); - return GodotIdeServer; + if (MessagingServer != null && !MessagingServer.IsDisposed) + return MessagingServer; + + MessagingServer?.Dispose(); + MessagingServer = new MessagingServer(OS.GetExecutablePath(), ProjectSettings.GlobalizePath(GodotSharpDirs.ResMetadataDir), new GodotLogger()); + + _ = MessagingServer.Listen(); + + return MessagingServer; } public override void _Ready() { - StartServer(); + _ = GetRunningOrNewServer(); } public void OnBeforeSerialize() { - GodotIdeServer?.Dispose(); } public void OnAfterDeserialize() { - StartServer(); + _ = GetRunningOrNewServer(); } - private ILogger logger; + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); - protected ILogger Logger + if (disposing) + { + MessagingServer?.Dispose(); + } + } + + private string GetExternalEditorIdentity(ExternalEditorId editorId) { - get => logger ?? (logger = new GodotLogger()); + // Manually convert to string to avoid breaking compatibility in case we rename the enum fields. + switch (editorId) + { + case ExternalEditorId.None: + return null; + case ExternalEditorId.VisualStudio: + return "VisualStudio"; + case ExternalEditorId.VsCode: + return "VisualStudioCode"; + case ExternalEditorId.Rider: + return "Rider"; + case ExternalEditorId.VisualStudioForMac: + return "VisualStudioForMac"; + case ExternalEditorId.MonoDevelop: + return "MonoDevelop"; + default: + throw new NotImplementedException(); + } } - private void StartServer() + public async Task<EditorPick?> LaunchIdeAsync(int millisecondsTimeout = 10000) { - GodotIdeServer?.Dispose(); - GodotIdeServer = new GodotIdeServer(LaunchIde, - OS.GetExecutablePath(), - ProjectSettings.GlobalizePath(GodotSharpDirs.ResMetadataDir)); + var editorId = (ExternalEditorId)GodotSharpEditor.Instance.GetEditorInterface() + .GetEditorSettings().GetSetting("mono/editor/external_editor"); + string editorIdentity = GetExternalEditorIdentity(editorId); - GodotIdeServer.Logger = Logger; + var runningServer = GetRunningOrNewServer(); - GodotIdeServer.StartServer(); - } + if (runningServer.IsAnyConnected(editorIdentity)) + return new EditorPick(editorIdentity); - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); + LaunchIde(editorId, editorIdentity); + + var timeoutTask = Task.Delay(millisecondsTimeout); + var completedTask = await Task.WhenAny(timeoutTask, runningServer.AwaitClientConnected(editorIdentity)); + + if (completedTask != timeoutTask) + return new EditorPick(editorIdentity); - GodotIdeServer?.Dispose(); + return null; } - private void LaunchIde() + private void LaunchIde(ExternalEditorId editorId, string editorIdentity) { - var editor = (ExternalEditorId)GodotSharpEditor.Instance.GetEditorInterface() - .GetEditorSettings().GetSetting("mono/editor/external_editor"); - - switch (editor) + switch (editorId) { case ExternalEditorId.None: case ExternalEditorId.VisualStudio: @@ -80,14 +111,14 @@ namespace GodotTools.Ides { MonoDevelop.Instance GetMonoDevelopInstance(string solutionPath) { - if (Utils.OS.IsOSX && editor == ExternalEditorId.VisualStudioForMac) + if (Utils.OS.IsOSX && editorId == ExternalEditorId.VisualStudioForMac) { - vsForMacInstance = vsForMacInstance ?? + vsForMacInstance = (vsForMacInstance?.IsDisposed ?? true ? null : vsForMacInstance) ?? new MonoDevelop.Instance(solutionPath, MonoDevelop.EditorId.VisualStudioForMac); return vsForMacInstance; } - monoDevelInstance = monoDevelInstance ?? + monoDevelInstance = (monoDevelInstance?.IsDisposed ?? true ? null : monoDevelInstance) ?? new MonoDevelop.Instance(solutionPath, MonoDevelop.EditorId.MonoDevelop); return monoDevelInstance; } @@ -96,12 +127,25 @@ namespace GodotTools.Ides { var instance = GetMonoDevelopInstance(GodotSharpDirs.ProjectSlnPath); - if (!instance.IsRunning) + if (instance.IsRunning && !GetRunningOrNewServer().IsAnyConnected(editorIdentity)) + { + // After launch we wait up to 30 seconds for the IDE to connect to our messaging server. + var waitAfterLaunch = TimeSpan.FromSeconds(30); + var timeSinceLaunch = DateTime.Now - instance.LaunchTime; + if (timeSinceLaunch > waitAfterLaunch) + { + instance.Dispose(); + instance.Execute(); + } + } + else if (!instance.IsRunning) + { instance.Execute(); + } } catch (FileNotFoundException) { - string editorName = editor == ExternalEditorId.VisualStudioForMac ? "Visual Studio" : "MonoDevelop"; + string editorName = editorId == ExternalEditorId.VisualStudioForMac ? "Visual Studio" : "MonoDevelop"; GD.PushError($"Cannot find code editor: {editorName}"); } @@ -113,26 +157,45 @@ namespace GodotTools.Ides } } - private void WriteMessage(string id, params string[] arguments) + public readonly struct EditorPick { - GetRunningServer().WriteMessage(new Message(id, arguments)); - } + private readonly string identity; - public void SendOpenFile(string file) - { - WriteMessage("OpenFile", file); - } + public EditorPick(string identity) + { + this.identity = identity; + } - public void SendOpenFile(string file, int line) - { - WriteMessage("OpenFile", file, line.ToString()); - } + public bool IsAnyConnected() => + GodotSharpEditor.Instance.GodotIdeManager.GetRunningOrNewServer().IsAnyConnected(identity); - public void SendOpenFile(string file, int line, int column) - { - WriteMessage("OpenFile", file, line.ToString(), column.ToString()); + private void SendRequest<TResponse>(Request request) + where TResponse : Response, new() + { + // Logs an error if no client is connected with the specified identity + GodotSharpEditor.Instance.GodotIdeManager + .GetRunningOrNewServer() + .BroadcastRequest<TResponse>(identity, request); + } + + public void SendOpenFile(string file) + { + SendRequest<OpenFileResponse>(new OpenFileRequest {File = file}); + } + + public void SendOpenFile(string file, int line) + { + SendRequest<OpenFileResponse>(new OpenFileRequest {File = file, Line = line}); + } + + public void SendOpenFile(string file, int line, int column) + { + SendRequest<OpenFileResponse>(new OpenFileRequest {File = file, Line = line, Column = column}); + } } + public EditorPick PickEditor(ExternalEditorId editorId) => new EditorPick(GetExternalEditorIdentity(editorId)); + private class GodotLogger : ILogger { public void LogDebug(string message) diff --git a/modules/mono/editor/GodotTools/GodotTools/Ides/GodotIdeServer.cs b/modules/mono/editor/GodotTools/GodotTools/Ides/GodotIdeServer.cs deleted file mode 100644 index 72676a8b24..0000000000 --- a/modules/mono/editor/GodotTools/GodotTools/Ides/GodotIdeServer.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using GodotTools.IdeConnection; -using GodotTools.Internals; -using GodotTools.Utils; -using Directory = System.IO.Directory; -using File = System.IO.File; -using Thread = System.Threading.Thread; - -namespace GodotTools.Ides -{ - public class GodotIdeServer : GodotIdeBase - { - private readonly TcpListener listener; - private readonly FileStream metaFile; - private readonly Action launchIdeAction; - private readonly NotifyAwaiter<bool> clientConnectedAwaiter = new NotifyAwaiter<bool>(); - - private async Task<bool> AwaitClientConnected() - { - return await clientConnectedAwaiter.Reset(); - } - - public GodotIdeServer(Action launchIdeAction, string editorExecutablePath, string projectMetadataDir) - : base(projectMetadataDir) - { - messageHandlers = InitializeMessageHandlers(); - - this.launchIdeAction = launchIdeAction; - - // Make sure the directory exists - Directory.CreateDirectory(projectMetadataDir); - - // The Godot editor's file system thread can keep the file open for writing, so we are forced to allow write sharing... - const FileShare metaFileShare = FileShare.ReadWrite; - - metaFile = File.Open(MetaFilePath, FileMode.Create, FileAccess.Write, metaFileShare); - - listener = new TcpListener(new IPEndPoint(IPAddress.Loopback, port: 0)); - listener.Start(); - - int port = ((IPEndPoint)listener.Server.LocalEndPoint).Port; - using (var metaFileWriter = new StreamWriter(metaFile, Encoding.UTF8)) - { - metaFileWriter.WriteLine(port); - metaFileWriter.WriteLine(editorExecutablePath); - } - - StartServer(); - } - - public void StartServer() - { - var serverThread = new Thread(RunServerThread) { Name = "Godot Ide Connection Server" }; - serverThread.Start(); - } - - private void RunServerThread() - { - SynchronizationContext.SetSynchronizationContext(Godot.Dispatcher.SynchronizationContext); - - try - { - while (!IsDisposed) - { - TcpClient tcpClient = listener.AcceptTcpClient(); - - Logger.LogInfo("Connection open with Ide Client"); - - lock (ConnectionLock) - { - Connection = new GodotIdeConnectionServer(tcpClient, HandleMessage); - Connection.Logger = Logger; - } - - Connected += () => clientConnectedAwaiter.SetResult(true); - - Connection.Start(); - } - } - catch (Exception e) - { - if (!IsDisposed && !(e is SocketException se && se.SocketErrorCode == SocketError.Interrupted)) - throw; - } - } - - public async void WriteMessage(Message message) - { - async Task LaunchIde() - { - if (IsConnected) - return; - - launchIdeAction(); - await Task.WhenAny(Task.Delay(10000), AwaitClientConnected()); - } - - await LaunchIde(); - - if (!IsConnected) - { - Logger.LogError("Cannot write message: Godot Ide Server not connected"); - return; - } - - Connection.WriteMessage(message); - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (disposing) - { - listener?.Stop(); - - metaFile?.Dispose(); - - File.Delete(MetaFilePath); - } - } - - protected virtual bool HandleMessage(Message message) - { - if (messageHandlers.TryGetValue(message.Id, out var action)) - { - action(message.Arguments); - return true; - } - - return false; - } - - private readonly Dictionary<string, Action<string[]>> messageHandlers; - - private Dictionary<string, Action<string[]>> InitializeMessageHandlers() - { - return new Dictionary<string, Action<string[]>> - { - ["Play"] = args => - { - switch (args.Length) - { - case 0: - Play(); - return; - case 2: - Play(debuggerHost: args[0], debuggerPort: int.Parse(args[1])); - return; - default: - throw new ArgumentException(); - } - }, - ["ReloadScripts"] = args => ReloadScripts() - }; - } - - private void DispatchToMainThread(Action action) - { - var d = new SendOrPostCallback(state => action()); - Godot.Dispatcher.SynchronizationContext.Post(d, null); - } - - private void Play() - { - DispatchToMainThread(() => - { - CurrentPlayRequest = new PlayRequest(); - Internal.EditorRunPlay(); - CurrentPlayRequest = null; - }); - } - - private void Play(string debuggerHost, int debuggerPort) - { - DispatchToMainThread(() => - { - CurrentPlayRequest = new PlayRequest(debuggerHost, debuggerPort); - Internal.EditorRunPlay(); - CurrentPlayRequest = null; - }); - } - - private void ReloadScripts() - { - DispatchToMainThread(Internal.ScriptEditorDebugger_ReloadScripts); - } - - public PlayRequest? CurrentPlayRequest { get; private set; } - - public struct PlayRequest - { - public bool HasDebugger { get; } - public string DebuggerHost { get; } - public int DebuggerPort { get; } - - public PlayRequest(string debuggerHost, int debuggerPort) - { - HasDebugger = true; - DebuggerHost = debuggerHost; - DebuggerPort = debuggerPort; - } - } - } -} diff --git a/modules/mono/editor/GodotTools/GodotTools/Ides/MessagingServer.cs b/modules/mono/editor/GodotTools/GodotTools/Ides/MessagingServer.cs new file mode 100644 index 0000000000..32f264d100 --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools/Ides/MessagingServer.cs @@ -0,0 +1,360 @@ +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.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using GodotTools.IdeMessaging; +using GodotTools.IdeMessaging.Requests; +using GodotTools.IdeMessaging.Utils; +using GodotTools.Internals; +using Newtonsoft.Json; +using Directory = System.IO.Directory; +using File = System.IO.File; + +namespace GodotTools.Ides +{ + public sealed class MessagingServer : IDisposable + { + private readonly ILogger logger; + + private readonly FileStream metaFile; + private string MetaFilePath { get; } + + private readonly SemaphoreSlim peersSem = new SemaphoreSlim(1); + + private readonly TcpListener listener; + + private readonly Dictionary<string, Queue<NotifyAwaiter<bool>>> clientConnectedAwaiters = new Dictionary<string, Queue<NotifyAwaiter<bool>>>(); + private readonly Dictionary<string, Queue<NotifyAwaiter<bool>>> clientDisconnectedAwaiters = new Dictionary<string, Queue<NotifyAwaiter<bool>>>(); + + public async Task<bool> AwaitClientConnected(string identity) + { + if (!clientConnectedAwaiters.TryGetValue(identity, out var queue)) + { + queue = new Queue<NotifyAwaiter<bool>>(); + clientConnectedAwaiters.Add(identity, queue); + } + + var awaiter = new NotifyAwaiter<bool>(); + queue.Enqueue(awaiter); + return await awaiter; + } + + public async Task<bool> AwaitClientDisconnected(string identity) + { + if (!clientDisconnectedAwaiters.TryGetValue(identity, out var queue)) + { + queue = new Queue<NotifyAwaiter<bool>>(); + clientDisconnectedAwaiters.Add(identity, queue); + } + + var awaiter = new NotifyAwaiter<bool>(); + queue.Enqueue(awaiter); + return await awaiter; + } + + public bool IsDisposed { get; private set; } + + public bool IsAnyConnected(string identity) => string.IsNullOrEmpty(identity) ? + Peers.Count > 0 : + Peers.Any(c => c.RemoteIdentity == identity); + + private List<Peer> Peers { get; } = new List<Peer>(); + + ~MessagingServer() + { + Dispose(disposing: false); + } + + public async void Dispose() + { + if (IsDisposed) + return; + + using (await peersSem.UseAsync()) + { + if (IsDisposed) // lock may not be fair + return; + IsDisposed = true; + } + + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + foreach (var connection in Peers) + connection.Dispose(); + Peers.Clear(); + listener?.Stop(); + + metaFile?.Dispose(); + + File.Delete(MetaFilePath); + } + } + + public MessagingServer(string editorExecutablePath, string projectMetadataDir, ILogger logger) + { + this.logger = logger; + + MetaFilePath = Path.Combine(projectMetadataDir, GodotIdeMetadata.DefaultFileName); + + // Make sure the directory exists + Directory.CreateDirectory(projectMetadataDir); + + // The Godot editor's file system thread can keep the file open for writing, so we are forced to allow write sharing... + const FileShare metaFileShare = FileShare.ReadWrite; + + metaFile = File.Open(MetaFilePath, FileMode.Create, FileAccess.Write, metaFileShare); + + listener = new TcpListener(new IPEndPoint(IPAddress.Loopback, port: 0)); + listener.Start(); + + int port = ((IPEndPoint)listener.Server.LocalEndPoint).Port; + using (var metaFileWriter = new StreamWriter(metaFile, Encoding.UTF8)) + { + metaFileWriter.WriteLine(port); + metaFileWriter.WriteLine(editorExecutablePath); + } + } + + private async Task AcceptClient(TcpClient tcpClient) + { + logger.LogDebug("Accept client..."); + + using (var peer = new Peer(tcpClient, new ServerHandshake(), new ServerMessageHandler(), logger)) + { + // ReSharper disable AccessToDisposedClosure + peer.Connected += () => + { + logger.LogInfo("Connection open with Ide Client"); + + if (clientConnectedAwaiters.TryGetValue(peer.RemoteIdentity, out var queue)) + { + while (queue.Count > 0) + queue.Dequeue().SetResult(true); + clientConnectedAwaiters.Remove(peer.RemoteIdentity); + } + }; + + peer.Disconnected += () => + { + if (clientDisconnectedAwaiters.TryGetValue(peer.RemoteIdentity, out var queue)) + { + while (queue.Count > 0) + queue.Dequeue().SetResult(true); + clientDisconnectedAwaiters.Remove(peer.RemoteIdentity); + } + }; + // ReSharper restore AccessToDisposedClosure + + try + { + if (!await peer.DoHandshake("server")) + { + logger.LogError("Handshake failed"); + return; + } + } + catch (Exception e) + { + logger.LogError("Handshake failed with unhandled exception: ", e); + return; + } + + using (await peersSem.UseAsync()) + Peers.Add(peer); + + try + { + await peer.Process(); + } + finally + { + using (await peersSem.UseAsync()) + Peers.Remove(peer); + } + } + } + + public async Task Listen() + { + try + { + while (!IsDisposed) + _ = AcceptClient(await listener.AcceptTcpClientAsync()); + } + catch (Exception e) + { + if (!IsDisposed && !(e is SocketException se && se.SocketErrorCode == SocketError.Interrupted)) + throw; + } + } + + public async void BroadcastRequest<TResponse>(string identity, Request request) + where TResponse : Response, new() + { + using (await peersSem.UseAsync()) + { + if (!IsAnyConnected(identity)) + { + logger.LogError("Cannot write request. No client connected to the Godot Ide Server."); + return; + } + + var selectedConnections = string.IsNullOrEmpty(identity) ? + Peers : + Peers.Where(c => c.RemoteIdentity == identity); + + string body = JsonConvert.SerializeObject(request); + + foreach (var connection in selectedConnections) + _ = connection.SendRequest<TResponse>(request.Id, body); + } + } + + private class ServerHandshake : IHandshake + { + private static readonly string ServerHandshakeBase = $"{Peer.ServerHandshakeName},Version={Peer.ProtocolVersionMajor}.{Peer.ProtocolVersionMinor}.{Peer.ProtocolVersionRevision}"; + private static readonly string ClientHandshakePattern = $@"{Regex.Escape(Peer.ClientHandshakeName)},Version=([0-9]+)\.([0-9]+)\.([0-9]+),([_a-zA-Z][_a-zA-Z0-9]{{0,63}})"; + + public string GetHandshakeLine(string identity) => $"{ServerHandshakeBase},{identity}"; + + public bool IsValidPeerHandshake(string handshake, out string identity, ILogger logger) + { + identity = null; + + var match = Regex.Match(handshake, ClientHandshakePattern); + + if (!match.Success) + return false; + + if (!uint.TryParse(match.Groups[1].Value, out uint clientMajor) || Peer.ProtocolVersionMajor != clientMajor) + { + logger.LogDebug("Incompatible major version: " + match.Groups[1].Value); + return false; + } + + // ReSharper disable once ConditionIsAlwaysTrueOrFalse + if (!uint.TryParse(match.Groups[2].Value, out uint clientMinor) || Peer.ProtocolVersionMinor > clientMinor) + { + 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; + } + } + + private class ServerMessageHandler : IMessageHandler + { + private static void DispatchToMainThread(Action action) + { + var d = new SendOrPostCallback(state => action()); + Godot.Dispatcher.SynchronizationContext.Post(d, null); + } + + private readonly Dictionary<string, Peer.RequestHandler> 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 static Dictionary<string, Peer.RequestHandler> InitializeRequestHandlers() + { + return new Dictionary<string, Peer.RequestHandler> + { + [PlayRequest.Id] = async (peer, content) => + { + _ = JsonConvert.DeserializeObject<PlayRequest>(content.Body); + return await HandlePlay(); + }, + [DebugPlayRequest.Id] = async (peer, content) => + { + var request = JsonConvert.DeserializeObject<DebugPlayRequest>(content.Body); + return await HandleDebugPlay(request); + }, + [ReloadScriptsRequest.Id] = async (peer, content) => + { + _ = JsonConvert.DeserializeObject<ReloadScriptsRequest>(content.Body); + return await HandleReloadScripts(); + }, + [CodeCompletionRequest.Id] = async (peer, content) => + { + var request = JsonConvert.DeserializeObject<CodeCompletionRequest>(content.Body); + return await HandleCodeCompletionRequest(request); + } + }; + } + + private static Task<Response> HandlePlay() + { + DispatchToMainThread(() => + { + GodotSharpEditor.Instance.CurrentPlaySettings = new PlaySettings(); + Internal.EditorRunPlay(); + GodotSharpEditor.Instance.CurrentPlaySettings = null; + }); + return Task.FromResult<Response>(new PlayResponse()); + } + + private static Task<Response> HandleDebugPlay(DebugPlayRequest request) + { + DispatchToMainThread(() => + { + GodotSharpEditor.Instance.CurrentPlaySettings = + new PlaySettings(request.DebuggerHost, request.DebuggerPort, request.BuildBeforePlaying ?? true); + Internal.EditorRunPlay(); + GodotSharpEditor.Instance.CurrentPlaySettings = null; + }); + return Task.FromResult<Response>(new DebugPlayResponse()); + } + + private static Task<Response> HandleReloadScripts() + { + DispatchToMainThread(Internal.ScriptEditorDebugger_ReloadScripts); + return Task.FromResult<Response>(new ReloadScriptsResponse()); + } + + private static async Task<Response> HandleCodeCompletionRequest(CodeCompletionRequest request) + { + var response = new CodeCompletionResponse {Kind = request.Kind, ScriptFile = request.ScriptFile}; + response.Suggestions = await Task.Run(() => Internal.CodeCompletionRequest(response.Kind, response.ScriptFile)); + return response; + } + } + } +} diff --git a/modules/mono/editor/GodotTools/GodotTools/Ides/MonoDevelop/Instance.cs b/modules/mono/editor/GodotTools/GodotTools/Ides/MonoDevelop/Instance.cs index 0fdc964039..d6fa2eeba7 100644 --- a/modules/mono/editor/GodotTools/GodotTools/Ides/MonoDevelop/Instance.cs +++ b/modules/mono/editor/GodotTools/GodotTools/Ides/MonoDevelop/Instance.cs @@ -7,14 +7,16 @@ using GodotTools.Utils; namespace GodotTools.Ides.MonoDevelop { - public class Instance + public class Instance : IDisposable { + public DateTime LaunchTime { get; private set; } private readonly string solutionFile; private readonly EditorId editorId; private Process process; public bool IsRunning => process != null && !process.HasExited; + public bool IsDisposed { get; private set; } public void Execute() { @@ -59,6 +61,8 @@ namespace GodotTools.Ides.MonoDevelop if (command == null) throw new FileNotFoundException(); + LaunchTime = DateTime.Now; + if (newWindow) { process = Process.Start(new ProcessStartInfo @@ -88,6 +92,12 @@ namespace GodotTools.Ides.MonoDevelop this.editorId = editorId; } + public void Dispose() + { + IsDisposed = true; + process?.Dispose(); + } + private static readonly IReadOnlyDictionary<EditorId, string> ExecutableNames; private static readonly IReadOnlyDictionary<EditorId, string> BundleIds; diff --git a/modules/mono/editor/GodotTools/GodotTools/Internals/Internal.cs b/modules/mono/editor/GodotTools/GodotTools/Internals/Internal.cs index 026a7db89c..7e5049e4b7 100644 --- a/modules/mono/editor/GodotTools/GodotTools/Internals/Internal.cs +++ b/modules/mono/editor/GodotTools/GodotTools/Internals/Internal.cs @@ -2,6 +2,7 @@ using System; using System.Runtime.CompilerServices; using Godot; using Godot.Collections; +using GodotTools.IdeMessaging.Requests; namespace GodotTools.Internals { @@ -52,6 +53,9 @@ namespace GodotTools.Internals public static void ScriptEditorDebugger_ReloadScripts() => internal_ScriptEditorDebugger_ReloadScripts(); + public static string[] CodeCompletionRequest(CodeCompletionRequest.CompletionKind kind, string scriptFile) => + internal_CodeCompletionRequest((int)kind, scriptFile); + #region Internal [MethodImpl(MethodImplOptions.InternalCall)] @@ -111,6 +115,9 @@ namespace GodotTools.Internals [MethodImpl(MethodImplOptions.InternalCall)] private static extern void internal_ScriptEditorDebugger_ReloadScripts(); + [MethodImpl(MethodImplOptions.InternalCall)] + private static extern string[] internal_CodeCompletionRequest(int kind, string scriptFile); + #endregion } } diff --git a/modules/mono/editor/GodotTools/GodotTools/PlaySettings.cs b/modules/mono/editor/GodotTools/GodotTools/PlaySettings.cs new file mode 100644 index 0000000000..820d0c0b83 --- /dev/null +++ b/modules/mono/editor/GodotTools/GodotTools/PlaySettings.cs @@ -0,0 +1,19 @@ +namespace GodotTools +{ + public struct PlaySettings + { + public bool HasDebugger { get; } + public string DebuggerHost { get; } + public int DebuggerPort { get; } + + public bool BuildBeforePlaying { get; } + + public PlaySettings(string debuggerHost, int debuggerPort, bool buildBeforePlaying) + { + HasDebugger = true; + DebuggerHost = debuggerHost; + DebuggerPort = debuggerPort; + BuildBeforePlaying = buildBeforePlaying; + } + } +} diff --git a/modules/mono/editor/code_completion.cpp b/modules/mono/editor/code_completion.cpp new file mode 100644 index 0000000000..7a5e465e7a --- /dev/null +++ b/modules/mono/editor/code_completion.cpp @@ -0,0 +1,249 @@ +/*************************************************************************/ +/* code_completion.cpp */ +/*************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/*************************************************************************/ +/* Copyright (c) 2007-2020 Juan Linietsky, Ariel Manzur. */ +/* Copyright (c) 2014-2020 Godot Engine contributors (cf. AUTHORS.md). */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/*************************************************************************/ + +#include "code_completion.h" + +#include "core/project_settings.h" +#include "editor/editor_file_system.h" +#include "editor/editor_settings.h" +#include "scene/gui/control.h" +#include "scene/main/node.h" + +namespace gdmono { + +// Almost everything here is taken from functions used by GDScript for code completion, adapted for C#. + +_FORCE_INLINE_ String quoted(const String &p_str) { + return "\"" + p_str + "\""; +} + +void _add_nodes_suggestions(const Node *p_base, const Node *p_node, PackedStringArray &r_suggestions) { + if (p_node != p_base && !p_node->get_owner()) + return; + + String path_relative_to_orig = p_base->get_path_to(p_node); + + r_suggestions.push_back(quoted(path_relative_to_orig)); + + for (int i = 0; i < p_node->get_child_count(); i++) { + _add_nodes_suggestions(p_base, p_node->get_child(i), r_suggestions); + } +} + +Node *_find_node_for_script(Node *p_base, Node *p_current, const Ref<Script> &p_script) { + if (p_current->get_owner() != p_base && p_base != p_current) + return nullptr; + + Ref<Script> c = p_current->get_script(); + + if (c == p_script) + return p_current; + + for (int i = 0; i < p_current->get_child_count(); i++) { + Node *found = _find_node_for_script(p_base, p_current->get_child(i), p_script); + if (found) + return found; + } + + return nullptr; +} + +void _get_directory_contents(EditorFileSystemDirectory *p_dir, PackedStringArray &r_suggestions) { + for (int i = 0; i < p_dir->get_file_count(); i++) { + r_suggestions.push_back(quoted(p_dir->get_file_path(i))); + } + + for (int i = 0; i < p_dir->get_subdir_count(); i++) { + _get_directory_contents(p_dir->get_subdir(i), r_suggestions); + } +} + +Node *_try_find_owner_node_in_tree(const Ref<Script> p_script) { + SceneTree *tree = SceneTree::get_singleton(); + if (!tree) + return nullptr; + Node *base = tree->get_edited_scene_root(); + if (base) { + base = _find_node_for_script(base, base, p_script); + } + return base; +} + +PackedStringArray get_code_completion(CompletionKind p_kind, const String &p_script_file) { + PackedStringArray suggestions; + + switch (p_kind) { + case CompletionKind::INPUT_ACTIONS: { + List<PropertyInfo> project_props; + ProjectSettings::get_singleton()->get_property_list(&project_props); + + for (List<PropertyInfo>::Element *E = project_props.front(); E; E = E->next()) { + const PropertyInfo &prop = E->get(); + + if (!prop.name.begins_with("input/")) + continue; + + String name = prop.name.substr(prop.name.find("/") + 1, prop.name.length()); + suggestions.push_back(quoted(name)); + } + } break; + case CompletionKind::NODE_PATHS: { + { + // AutoLoads + List<PropertyInfo> props; + ProjectSettings::get_singleton()->get_property_list(&props); + + for (List<PropertyInfo>::Element *E = props.front(); E; E = E->next()) { + String s = E->get().name; + if (!s.begins_with("autoload/")) { + continue; + } + String name = s.get_slice("/", 1); + suggestions.push_back(quoted("/root/" + name)); + } + } + + { + // Current edited scene tree + Ref<Script> script = ResourceLoader::load(p_script_file.simplify_path()); + Node *base = _try_find_owner_node_in_tree(script); + if (base) { + _add_nodes_suggestions(base, base, suggestions); + } + } + } break; + case CompletionKind::RESOURCE_PATHS: { + if (bool(EditorSettings::get_singleton()->get("text_editor/completion/complete_file_paths"))) { + _get_directory_contents(EditorFileSystem::get_singleton()->get_filesystem(), suggestions); + } + } break; + case CompletionKind::SCENE_PATHS: { + DirAccessRef dir_access = DirAccess::create(DirAccess::ACCESS_RESOURCES); + List<String> directories; + directories.push_back(dir_access->get_current_dir()); + + while (!directories.empty()) { + dir_access->change_dir(directories.back()->get()); + directories.pop_back(); + + dir_access->list_dir_begin(); + String filename = dir_access->get_next(); + + while (filename != "") { + if (filename == "." || filename == "..") { + filename = dir_access->get_next(); + continue; + } + + if (dir_access->dir_exists(filename)) { + directories.push_back(dir_access->get_current_dir().plus_file(filename)); + } else if (filename.ends_with(".tscn") || filename.ends_with(".scn")) { + suggestions.push_back(quoted(dir_access->get_current_dir().plus_file(filename))); + } + + filename = dir_access->get_next(); + } + } + } break; + case CompletionKind::SHADER_PARAMS: { + print_verbose("Shared params completion for C# not implemented."); + } break; + case CompletionKind::SIGNALS: { + Ref<Script> script = ResourceLoader::load(p_script_file.simplify_path()); + + List<MethodInfo> signals; + script->get_script_signal_list(&signals); + + StringName native = script->get_instance_base_type(); + if (native != StringName()) { + ClassDB::get_signal_list(native, &signals, /* p_no_inheritance: */ false); + } + + for (List<MethodInfo>::Element *E = signals.front(); E; E = E->next()) { + const String &signal = E->get().name; + suggestions.push_back(quoted(signal)); + } + } break; + case CompletionKind::THEME_COLORS: { + Ref<Script> script = ResourceLoader::load(p_script_file.simplify_path()); + Node *base = _try_find_owner_node_in_tree(script); + if (base && Object::cast_to<Control>(base)) { + List<StringName> sn; + Theme::get_default()->get_color_list(base->get_class(), &sn); + + for (List<StringName>::Element *E = sn.front(); E; E = E->next()) { + suggestions.push_back(quoted(E->get())); + } + } + } break; + case CompletionKind::THEME_CONSTANTS: { + Ref<Script> script = ResourceLoader::load(p_script_file.simplify_path()); + Node *base = _try_find_owner_node_in_tree(script); + if (base && Object::cast_to<Control>(base)) { + List<StringName> sn; + Theme::get_default()->get_constant_list(base->get_class(), &sn); + + for (List<StringName>::Element *E = sn.front(); E; E = E->next()) { + suggestions.push_back(quoted(E->get())); + } + } + } break; + case CompletionKind::THEME_FONTS: { + Ref<Script> script = ResourceLoader::load(p_script_file.simplify_path()); + Node *base = _try_find_owner_node_in_tree(script); + if (base && Object::cast_to<Control>(base)) { + List<StringName> sn; + Theme::get_default()->get_font_list(base->get_class(), &sn); + + for (List<StringName>::Element *E = sn.front(); E; E = E->next()) { + suggestions.push_back(quoted(E->get())); + } + } + } break; + case CompletionKind::THEME_STYLES: { + Ref<Script> script = ResourceLoader::load(p_script_file.simplify_path()); + Node *base = _try_find_owner_node_in_tree(script); + if (base && Object::cast_to<Control>(base)) { + List<StringName> sn; + Theme::get_default()->get_stylebox_list(base->get_class(), &sn); + + for (List<StringName>::Element *E = sn.front(); E; E = E->next()) { + suggestions.push_back(quoted(E->get())); + } + } + } break; + default: + ERR_FAIL_V_MSG(suggestions, "Invalid completion kind."); + } + + return suggestions; +} + +} // namespace gdmono diff --git a/modules/mono/editor/code_completion.h b/modules/mono/editor/code_completion.h new file mode 100644 index 0000000000..77673b766f --- /dev/null +++ b/modules/mono/editor/code_completion.h @@ -0,0 +1,56 @@ +/*************************************************************************/ +/* code_completion.h */ +/*************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/*************************************************************************/ +/* Copyright (c) 2007-2020 Juan Linietsky, Ariel Manzur. */ +/* Copyright (c) 2014-2020 Godot Engine contributors (cf. AUTHORS.md). */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/*************************************************************************/ + +#ifndef CODE_COMPLETION_H +#define CODE_COMPLETION_H + +#include "core/ustring.h" +#include "core/variant.h" + +namespace gdmono { + +enum class CompletionKind { + INPUT_ACTIONS = 0, + NODE_PATHS, + RESOURCE_PATHS, + SCENE_PATHS, + SHADER_PARAMS, + SIGNALS, + THEME_COLORS, + THEME_CONSTANTS, + THEME_FONTS, + THEME_STYLES +}; + +PackedStringArray get_code_completion(CompletionKind p_kind, const String &p_script_file); + +} // namespace gdmono + +#endif // CODE_COMPLETION_H diff --git a/modules/mono/editor/editor_internal_calls.cpp b/modules/mono/editor/editor_internal_calls.cpp index c3e7e67ae9..c9117f1312 100644 --- a/modules/mono/editor/editor_internal_calls.cpp +++ b/modules/mono/editor/editor_internal_calls.cpp @@ -48,6 +48,7 @@ #include "../mono_gd/gd_mono_marshal.h" #include "../utils/osx_utils.h" #include "bindings_generator.h" +#include "code_completion.h" #include "godotsharp_export.h" #include "script_class_parser.h" @@ -354,6 +355,12 @@ void godot_icall_Internal_ScriptEditorDebugger_ReloadScripts() { } } +MonoArray *godot_icall_Internal_CodeCompletionRequest(int32_t p_kind, MonoString *p_script_file) { + String script_file = GDMonoMarshal::mono_string_to_godot(p_script_file); + PackedStringArray suggestions = gdmono::get_code_completion((gdmono::CompletionKind)p_kind, script_file); + return GDMonoMarshal::PackedStringArray_to_mono_array(suggestions); +} + float godot_icall_Globals_EditorScale() { return EDSCALE; } @@ -454,6 +461,7 @@ void register_editor_internal_calls() { mono_add_internal_call("GodotTools.Internals.Internal::internal_EditorRunPlay", (void *)godot_icall_Internal_EditorRunPlay); mono_add_internal_call("GodotTools.Internals.Internal::internal_EditorRunStop", (void *)godot_icall_Internal_EditorRunStop); mono_add_internal_call("GodotTools.Internals.Internal::internal_ScriptEditorDebugger_ReloadScripts", (void *)godot_icall_Internal_ScriptEditorDebugger_ReloadScripts); + mono_add_internal_call("GodotTools.Internals.Internal::internal_CodeCompletionRequest", (void *)godot_icall_Internal_CodeCompletionRequest); // Globals mono_add_internal_call("GodotTools.Internals.Globals::internal_EditorScale", (void *)godot_icall_Globals_EditorScale); diff --git a/modules/mono/mono_gd/gd_mono.cpp b/modules/mono/mono_gd/gd_mono.cpp index a6ed2957e6..fbaa81e83f 100644 --- a/modules/mono/mono_gd/gd_mono.cpp +++ b/modules/mono/mono_gd/gd_mono.cpp @@ -133,6 +133,10 @@ void gd_mono_debug_init() { CharString da_args = OS::get_singleton()->get_environment("GODOT_MONO_DEBUGGER_AGENT").utf8(); + if (da_args.length()) { + OS::get_singleton()->set_environment("GODOT_MONO_DEBUGGER_AGENT", String()); + } + #ifdef TOOLS_ENABLED int da_port = GLOBAL_DEF("mono/debugger_agent/port", 23685); bool da_suspend = GLOBAL_DEF("mono/debugger_agent/wait_for_debugger", false); |