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

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

View File

@@ -0,0 +1,355 @@
# Rider
.idea/
# Visual Studio Code
.vscode/
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/

View File

@@ -0,0 +1,173 @@
using System;
using System.IO;
using System.Security;
using Microsoft.Build.Framework;
namespace GodotTools.BuildLogger
{
public class GodotBuildLogger : ILogger
{
public string? Parameters { get; set; }
public LoggerVerbosity Verbosity { get; set; }
private StreamWriter _logStreamWriter = StreamWriter.Null;
private StreamWriter _issuesStreamWriter = StreamWriter.Null;
private int _indent;
public void Initialize(IEventSource eventSource)
{
if (null == Parameters)
throw new LoggerException("Log directory parameter not specified.");
string[] parameters = Parameters.Split(new[] { ';' });
string logDir = parameters[0];
if (string.IsNullOrEmpty(logDir))
throw new LoggerException("Log directory parameter is empty.");
if (parameters.Length > 1)
throw new LoggerException("Too many parameters passed.");
string logFile = Path.Combine(logDir, "msbuild_log.txt");
string issuesFile = Path.Combine(logDir, "msbuild_issues.csv");
try
{
if (!Directory.Exists(logDir))
Directory.CreateDirectory(logDir);
_logStreamWriter = new StreamWriter(logFile);
_issuesStreamWriter = new StreamWriter(issuesFile);
}
catch (Exception ex)
{
if (ex is UnauthorizedAccessException
|| ex is ArgumentNullException
|| ex is PathTooLongException
|| ex is DirectoryNotFoundException
|| ex is NotSupportedException
|| ex is ArgumentException
|| ex is SecurityException
|| ex is IOException)
{
throw new LoggerException("Failed to create log file: " + ex.Message);
}
// Unexpected failure
throw;
}
eventSource.ProjectStarted += eventSource_ProjectStarted;
eventSource.ProjectFinished += eventSource_ProjectFinished;
eventSource.MessageRaised += eventSource_MessageRaised;
eventSource.WarningRaised += eventSource_WarningRaised;
eventSource.ErrorRaised += eventSource_ErrorRaised;
}
private void eventSource_ProjectStarted(object sender, ProjectStartedEventArgs e)
{
WriteLine(e.Message);
_indent++;
}
private void eventSource_ProjectFinished(object sender, ProjectFinishedEventArgs e)
{
_indent--;
WriteLine(e.Message);
}
private void eventSource_ErrorRaised(object sender, BuildErrorEventArgs e)
{
string line = $"{e.File}({e.LineNumber},{e.ColumnNumber}): error {e.Code}: {e.Message}";
if (!string.IsNullOrEmpty(e.ProjectFile))
line += $" [{e.ProjectFile}]";
WriteLine(line);
string errorLine = $@"error,{e.File?.CsvEscape() ?? string.Empty},{e.LineNumber},{e.ColumnNumber}," +
$"{e.Code?.CsvEscape() ?? string.Empty},{e.Message.CsvEscape()}," +
$"{e.ProjectFile?.CsvEscape() ?? string.Empty}";
_issuesStreamWriter.WriteLine(errorLine);
}
private void eventSource_WarningRaised(object sender, BuildWarningEventArgs e)
{
string line = $"{e.File}({e.LineNumber},{e.ColumnNumber}): warning {e.Code}: {e.Message}";
if (!string.IsNullOrEmpty(e.ProjectFile))
line += $" [{e.ProjectFile}]";
WriteLine(line);
string warningLine = $@"warning,{e.File?.CsvEscape() ?? string.Empty},{e.LineNumber},{e.ColumnNumber}," +
$"{e.Code?.CsvEscape() ?? string.Empty},{e.Message.CsvEscape()}," +
$"{e.ProjectFile?.CsvEscape() ?? string.Empty}";
_issuesStreamWriter.WriteLine(warningLine);
}
private void eventSource_MessageRaised(object sender, BuildMessageEventArgs e)
{
// BuildMessageEventArgs adds Importance to BuildEventArgs
// Let's take account of the verbosity setting we've been passed in deciding whether to log the message
if (e.Importance == MessageImportance.High && IsVerbosityAtLeast(LoggerVerbosity.Minimal)
|| e.Importance == MessageImportance.Normal && IsVerbosityAtLeast(LoggerVerbosity.Normal)
|| e.Importance == MessageImportance.Low && IsVerbosityAtLeast(LoggerVerbosity.Detailed))
{
WriteLineWithSenderAndMessage(string.Empty, e);
}
}
/// <summary>
/// Write a line to the log, adding the SenderName and Message
/// (these parameters are on all MSBuild event argument objects)
/// </summary>
private void WriteLineWithSenderAndMessage(string line, BuildEventArgs e)
{
if (0 == string.Compare(e.SenderName, "MSBuild", StringComparison.OrdinalIgnoreCase))
{
// Well, if the sender name is MSBuild, let's leave it out for prettiness
WriteLine(line + e.Message);
}
else
{
WriteLine(e.SenderName + ": " + line + e.Message);
}
}
private void WriteLine(string line)
{
for (int i = _indent; i > 0; i--)
{
_logStreamWriter.Write("\t");
}
_logStreamWriter.WriteLine(line);
}
public void Shutdown()
{
_logStreamWriter.Close();
_issuesStreamWriter.Close();
}
private bool IsVerbosityAtLeast(LoggerVerbosity checkVerbosity)
{
return Verbosity >= checkVerbosity;
}
}
internal static class StringExtensions
{
public static string CsvEscape(this string value, char delimiter = ',')
{
bool hasSpecialChar = value.IndexOfAny(new[] { '\"', '\n', '\r', delimiter }) != -1;
if (hasSpecialChar)
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
return value;
}
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ProjectGuid>{6CE9A984-37B1-4F8A-8FE9-609F05F071B3}</ProjectGuid>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>10</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build.Framework" Version="15.1.548" ExcludeAssets="runtime" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,30 @@
using System.IO;
namespace GodotTools.Core
{
public static class FileUtils
{
public static void SaveBackupCopy(string filePath)
{
string backupPathBase = filePath + ".old";
string backupPath = backupPathBase;
const int maxAttempts = 5;
int attempt = 1;
while (File.Exists(backupPath) && attempt <= maxAttempts)
{
backupPath = backupPathBase + "." + (attempt);
attempt++;
}
if (attempt > maxAttempts + 1)
{
// Overwrite the oldest one
backupPath = backupPathBase;
}
File.Copy(filePath, backupPath, overwrite: true);
}
}
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ProjectGuid>{639E48BD-44E5-4091-8EDD-22D36DC0768D}</ProjectGuid>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,38 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace GodotTools.Core
{
public static class ProcessExtensions
{
public static async Task WaitForExitAsync(this Process process, CancellationToken cancellationToken = default)
{
var tcs = new TaskCompletionSource<bool>();
void ProcessExited(object? sender, EventArgs e)
{
tcs.TrySetResult(true);
}
process.EnableRaisingEvents = true;
process.Exited += ProcessExited;
try
{
if (process.HasExited)
return;
using (cancellationToken.Register(() => tcs.TrySetCanceled()))
{
await tcs.Task;
}
}
finally
{
process.Exited -= ProcessExited;
}
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
namespace GodotTools.Core
{
public static class StringExtensions
{
private static readonly string _driveRoot = Path.GetPathRoot(Environment.CurrentDirectory)!;
public static string RelativeToPath(this string path, string dir)
{
// Make sure the directory ends with a path separator
dir = Path.Combine(dir, " ").TrimEnd();
if (Path.DirectorySeparatorChar == '\\')
dir = dir.Replace("/", "\\", StringComparison.Ordinal) + "\\";
var fullPath = new Uri(Path.GetFullPath(path), UriKind.Absolute);
var relRoot = new Uri(Path.GetFullPath(dir), UriKind.Absolute);
// MakeRelativeUri converts spaces to %20, hence why we need UnescapeDataString
return Uri.UnescapeDataString(relRoot.MakeRelativeUri(fullPath).ToString());
}
public static string NormalizePath(this string path)
{
bool rooted = path.IsAbsolutePath();
path = path.Replace('\\', '/');
path = path[path.Length - 1] == '/' ? path.Substring(0, path.Length - 1) : path;
string[] parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
path = string.Join(Path.DirectorySeparatorChar.ToString(), parts).Trim();
if (!rooted)
return path;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string maybeDrive = parts[0];
if (maybeDrive.Length == 2 && maybeDrive[1] == ':')
return path; // Already has drive letter
}
return Path.DirectorySeparatorChar + path;
}
public static bool IsAbsolutePath(this string path)
{
return path.StartsWith("/", StringComparison.Ordinal) ||
path.StartsWith("\\", StringComparison.Ordinal) ||
path.StartsWith(_driveRoot, StringComparison.Ordinal);
}
}
}

View File

@@ -0,0 +1,58 @@
using System.Globalization;
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(CultureInfo.InvariantCulture));
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(CultureInfo.InvariantCulture));
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} =======");
}
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ProjectGuid>{B06C2951-C8E3-4F28-80B2-717CF327EB19}</ProjectGuid>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>10</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GodotTools.IdeMessaging\GodotTools.IdeMessaging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,217 @@
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);
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ProjectGuid>{EAFFF236-FA96-4A4D-BD23-0E51EF988277}</ProjectGuid>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0-windows</TargetFramework>
<LangVersion>10</LangVersion>
<Nullable>enable</Nullable>
<SelfContained>False</SelfContained>
<RollForward>LatestMajor</RollForward>
</PropertyGroup>
<PropertyGroup Condition="Exists('$(SolutionDir)/../../../../bin/GodotSharp/Api/Debug/GodotSharp.dll') And ('$(GodotPlatform)' == 'windows' Or ('$(GodotPlatform)' == '' And '$(OS)' == 'Windows_NT'))">
<OutputPath>$(SolutionDir)/../../../../bin/GodotSharp/Tools</OutputPath>
<AppendTargetFrameworkToOutputPath>False</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>False</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EnvDTE" Version="17.8.37221" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,295 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text.RegularExpressions;
using EnvDTE;
namespace GodotTools.OpenVisualStudio
{
internal static class Program
{
[DllImport("ole32.dll")]
private static extern int GetRunningObjectTable(int reserved, out IRunningObjectTable pprot);
[DllImport("ole32.dll")]
private static extern void CreateBindCtx(int reserved, out IBindCtx ppbc);
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
private static void ShowHelp()
{
Console.WriteLine("Opens the file(s) in a Visual Studio instance that is editing the specified solution.");
Console.WriteLine("If an existing instance for the solution is not found, a new one is created.");
Console.WriteLine();
Console.WriteLine("Usage:");
Console.WriteLine(@" GodotTools.OpenVisualStudio.exe solution [file[;line[;col]]...]");
Console.WriteLine();
Console.WriteLine("Lines and columns begin at one. Zero or lower will result in an error.");
Console.WriteLine("If a line is specified but a column is not, the line is selected in the text editor.");
}
// STAThread needed, otherwise CoRegisterMessageFilter may return CO_E_NOT_SUPPORTED.
[STAThread]
private static int Main(string[] args)
{
if (args.Length == 0 || args[0] == "--help" || args[0] == "-h")
{
ShowHelp();
return 0;
}
string solutionFile = NormalizePath(args[0]);
var dte = FindInstanceEditingSolution(solutionFile);
if (dte == null)
{
// Open a new instance
dte = TryVisualStudioLaunch("VisualStudio.DTE.17.0");
if (dte == null)
{
// Launch of VS 2022 failed, fallback to 2019
dte = TryVisualStudioLaunch("VisualStudio.DTE.16.0");
if (dte == null)
{
Console.Error.WriteLine("Visual Studio not found");
return 1;
}
}
dte.UserControl = true;
try
{
dte.Solution.Open(solutionFile);
}
catch (ArgumentException)
{
Console.Error.WriteLine("Solution.Open: Invalid path or file not found");
return 1;
}
dte.MainWindow.Visible = true;
}
MessageFilter.Register();
try
{
// Open files
for (int i = 1; i < args.Length; i++)
{
// Both the line number and the column begin at one
string[] fileArgumentParts = args[i].Split(';');
string filePath = NormalizePath(fileArgumentParts[0]);
try
{
dte.ItemOperations.OpenFile(filePath);
}
catch (ArgumentException)
{
Console.Error.WriteLine("ItemOperations.OpenFile: Invalid path or file not found");
return 1;
}
if (fileArgumentParts.Length > 1)
{
if (int.TryParse(fileArgumentParts[1], out int line))
{
var textSelection = (TextSelection)dte.ActiveDocument.Selection;
if (fileArgumentParts.Length > 2)
{
if (int.TryParse(fileArgumentParts[2], out int column))
{
textSelection.MoveToLineAndOffset(line, column);
}
else
{
Console.Error.WriteLine("The column part of the argument must be a valid integer");
return 1;
}
}
else
{
textSelection.GotoLine(line, Select: true);
}
}
else
{
Console.Error.WriteLine("The line part of the argument must be a valid integer");
return 1;
}
}
}
}
finally
{
var mainWindow = dte.MainWindow;
mainWindow.Activate();
SetForegroundWindow(mainWindow.HWnd);
MessageFilter.Revoke();
}
return 0;
}
private static DTE? TryVisualStudioLaunch(string version)
{
try
{
var visualStudioDteType = Type.GetTypeFromProgID(version, throwOnError: true);
var dte = (DTE?)Activator.CreateInstance(visualStudioDteType!);
return dte;
}
catch (COMException)
{
return null;
}
}
private static DTE? FindInstanceEditingSolution(string solutionPath)
{
if (GetRunningObjectTable(0, out IRunningObjectTable pprot) != 0)
return null;
try
{
pprot.EnumRunning(out IEnumMoniker ppenumMoniker);
ppenumMoniker.Reset();
var moniker = new IMoniker[1];
while (ppenumMoniker.Next(1, moniker, IntPtr.Zero) == 0)
{
string ppszDisplayName;
CreateBindCtx(0, out IBindCtx ppbc);
try
{
moniker[0].GetDisplayName(ppbc, null, out ppszDisplayName);
}
finally
{
Marshal.ReleaseComObject(ppbc);
}
if (ppszDisplayName == null)
continue;
// The digits after the colon are the process ID
if (!Regex.IsMatch(ppszDisplayName, "!VisualStudio.DTE.1[6-7].0:[0-9]"))
continue;
if (pprot.GetObject(moniker[0], out object ppunkObject) == 0)
{
if (ppunkObject is DTE dte && dte.Solution.FullName.Length > 0)
{
if (NormalizePath(dte.Solution.FullName) == solutionPath)
return dte;
}
}
}
}
finally
{
Marshal.ReleaseComObject(pprot);
}
return null;
}
private static string NormalizePath(string path)
{
return new Uri(Path.GetFullPath(path)).LocalPath
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.ToUpperInvariant();
}
#region MessageFilter. See: http: //msdn.microsoft.com/en-us/library/ms228772.aspx
private class MessageFilter : IOleMessageFilter
{
// Class containing the IOleMessageFilter
// thread error-handling functions
private static IOleMessageFilter? _oldFilter;
// Start the filter
public static void Register()
{
IOleMessageFilter newFilter = new MessageFilter();
int ret = CoRegisterMessageFilter(newFilter, out _oldFilter);
if (ret != 0)
Console.Error.WriteLine($"CoRegisterMessageFilter failed with error code: {ret}");
}
// Done with the filter, close it
public static void Revoke()
{
int ret = CoRegisterMessageFilter(_oldFilter, out _);
if (ret != 0)
Console.Error.WriteLine($"CoRegisterMessageFilter failed with error code: {ret}");
}
//
// IOleMessageFilter functions
// Handle incoming thread requests
int IOleMessageFilter.HandleInComingCall(int dwCallType, IntPtr hTaskCaller, int dwTickCount, IntPtr lpInterfaceInfo)
{
// Return the flag SERVERCALL_ISHANDLED
return 0;
}
// Thread call was rejected, so try again.
int IOleMessageFilter.RetryRejectedCall(IntPtr hTaskCallee, int dwTickCount, int dwRejectType)
{
// flag = SERVERCALL_RETRYLATER
if (dwRejectType == 2)
{
// Retry the thread call immediately if return >= 0 & < 100
return 99;
}
// Too busy; cancel call
return -1;
}
int IOleMessageFilter.MessagePending(IntPtr hTaskCallee, int dwTickCount, int dwPendingType)
{
// Return the flag PENDINGMSG_WAITDEFPROCESS
return 2;
}
// Implement the IOleMessageFilter interface
[DllImport("ole32.dll")]
private static extern int CoRegisterMessageFilter(IOleMessageFilter? newFilter, out IOleMessageFilter? oldFilter);
}
[ComImport(), Guid("00000016-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IOleMessageFilter
{
[PreserveSig]
int HandleInComingCall(int dwCallType, IntPtr hTaskCaller, int dwTickCount, IntPtr lpInterfaceInfo);
[PreserveSig]
int RetryRejectedCall(IntPtr hTaskCallee, int dwTickCount, int dwRejectType);
[PreserveSig]
int MessagePending(IntPtr hTaskCallee, int dwTickCount, int dwPendingType);
}
#endregion
}
}

View File

@@ -0,0 +1,169 @@
using GodotTools.Core;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace GodotTools.ProjectEditor
{
public class DotNetSolution
{
private const string _solutionTemplate =
@"Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012
{0}
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
{1}
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2}
EndGlobalSection
EndGlobal
";
private const string _projectDeclaration =
@"Project(""{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}"") = ""{0}"", ""{1}"", ""{{{2}}}""
EndProject";
private const string _solutionPlatformsConfig =
@" {0}|Any CPU = {0}|Any CPU";
private const string _projectPlatformsConfig =
@" {{{0}}}.{1}|Any CPU.ActiveCfg = {1}|Any CPU
{{{0}}}.{1}|Any CPU.Build.0 = {1}|Any CPU";
private readonly Dictionary<string, ProjectInfo> _projects = new Dictionary<string, ProjectInfo>();
public string Name { get; }
public string DirectoryPath { get; }
public class ProjectInfo
{
public string Guid { get; }
public string PathRelativeToSolution { get; }
public List<string> Configs { get; }
public ProjectInfo(string guid, string pathRelativeToSolution, List<string> configs)
{
Guid = guid;
PathRelativeToSolution = pathRelativeToSolution;
Configs = configs;
}
}
public void AddNewProject(string name, ProjectInfo projectInfo)
{
_projects[name] = projectInfo;
}
public bool HasProject(string name)
{
return _projects.ContainsKey(name);
}
public ProjectInfo GetProjectInfo(string name)
{
return _projects[name];
}
public bool RemoveProject(string name)
{
return _projects.Remove(name);
}
public void Save()
{
if (!Directory.Exists(DirectoryPath))
throw new FileNotFoundException("The solution directory does not exist.");
string projectsDecl = string.Empty;
string slnPlatformsCfg = string.Empty;
string projPlatformsCfg = string.Empty;
bool isFirstProject = true;
foreach (var pair in _projects)
{
string name = pair.Key;
ProjectInfo projectInfo = pair.Value;
if (!isFirstProject)
projectsDecl += "\n";
projectsDecl += string.Format(CultureInfo.InvariantCulture, _projectDeclaration,
name, projectInfo.PathRelativeToSolution.Replace("/", "\\", StringComparison.Ordinal), projectInfo.Guid);
for (int i = 0; i < projectInfo.Configs.Count; i++)
{
string config = projectInfo.Configs[i];
if (i != 0 || !isFirstProject)
{
slnPlatformsCfg += "\n";
projPlatformsCfg += "\n";
}
slnPlatformsCfg += string.Format(CultureInfo.InvariantCulture, _solutionPlatformsConfig, config);
projPlatformsCfg += string.Format(CultureInfo.InvariantCulture, _projectPlatformsConfig, projectInfo.Guid, config);
}
isFirstProject = false;
}
string solutionPath = Path.Combine(DirectoryPath, Name + ".sln");
string content = string.Format(CultureInfo.InvariantCulture, _solutionTemplate, projectsDecl, slnPlatformsCfg, projPlatformsCfg);
File.WriteAllText(solutionPath, content, Encoding.UTF8); // UTF-8 with BOM
}
public DotNetSolution(string name, string directoryPath)
{
Name = name;
DirectoryPath = directoryPath.IsAbsolutePath() ? directoryPath : Path.GetFullPath(directoryPath);
}
public static void MigrateFromOldConfigNames(string slnPath)
{
if (!File.Exists(slnPath))
return;
string input = File.ReadAllText(slnPath);
if (!Regex.IsMatch(input, Regex.Escape("Tools|Any CPU")))
return;
// This method renames old configurations in solutions to the new ones.
//
// This is the order configs appear in the solution and what we want to rename them to:
// Debug|Any CPU = Debug|Any CPU -> ExportDebug|Any CPU = ExportDebug|Any CPU
// Tools|Any CPU = Tools|Any CPU -> Debug|Any CPU = Debug|Any CPU
//
// But we want to move Tools (now Debug) to the top, so it's easier to rename like this:
// Debug|Any CPU = Debug|Any CPU -> Debug|Any CPU = Debug|Any CPU
// Release|Any CPU = Release|Any CPU -> ExportDebug|Any CPU = ExportDebug|Any CPU
// Tools|Any CPU = Tools|Any CPU -> ExportRelease|Any CPU = ExportRelease|Any CPU
var dict = new Dictionary<string, string>
{
{"Debug|Any CPU", "Debug|Any CPU"},
{"Release|Any CPU", "ExportDebug|Any CPU"},
{"Tools|Any CPU", "ExportRelease|Any CPU"}
};
var regex = new Regex(string.Join("|", dict.Keys.Select(Regex.Escape)));
string result = regex.Replace(input, m => dict[m.Value]);
if (result != input)
{
// Save a copy of the solution before replacing it
FileUtils.SaveBackupCopy(slnPath);
File.WriteAllText(slnPath, result);
}
}
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ProjectGuid>{A8CDAD94-C6D4-4B19-A7E7-76C53CC92984}</ProjectGuid>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build" Version="15.1.548" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.Build.Locator" Version="1.2.6" />
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0" PrivateAssets="All" />
<PackageReference Include="NuGet.Frameworks" Version="6.12.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GodotTools.Core\GodotTools.Core.csproj" />
<ProjectReference Include="..\GodotTools.Shared\GodotTools.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace GodotTools.ProjectEditor
{
public static class IdentifierUtils
{
public static string SanitizeQualifiedIdentifier(string qualifiedIdentifier, bool allowEmptyIdentifiers)
{
if (string.IsNullOrEmpty(qualifiedIdentifier))
throw new ArgumentException($"{nameof(qualifiedIdentifier)} cannot be empty", nameof(qualifiedIdentifier));
string[] identifiers = qualifiedIdentifier.Split('.');
for (int i = 0; i < identifiers.Length; i++)
{
identifiers[i] = SanitizeIdentifier(identifiers[i], allowEmpty: allowEmptyIdentifiers);
}
return string.Join(".", identifiers);
}
/// <summary>
/// Skips invalid identifier characters including decimal digit numbers at the start of the identifier.
/// </summary>
private static void SkipInvalidCharacters(string source, int startIndex, StringBuilder outputBuilder)
{
for (int i = startIndex; i < source.Length; i++)
{
char @char = source[i];
switch (char.GetUnicodeCategory(@char))
{
case UnicodeCategory.UppercaseLetter:
case UnicodeCategory.LowercaseLetter:
case UnicodeCategory.TitlecaseLetter:
case UnicodeCategory.ModifierLetter:
case UnicodeCategory.LetterNumber:
case UnicodeCategory.OtherLetter:
outputBuilder.Append(@char);
break;
case UnicodeCategory.NonSpacingMark:
case UnicodeCategory.SpacingCombiningMark:
case UnicodeCategory.ConnectorPunctuation:
case UnicodeCategory.DecimalDigitNumber:
// Identifiers may start with underscore
if (outputBuilder.Length > startIndex || @char == '_')
outputBuilder.Append(@char);
break;
}
}
}
public static string SanitizeIdentifier(string identifier, bool allowEmpty)
{
if (string.IsNullOrEmpty(identifier))
{
if (allowEmpty)
return "Empty"; // Default value for empty identifiers
throw new ArgumentException($"{nameof(identifier)} cannot be empty if {nameof(allowEmpty)} is false", nameof(identifier));
}
if (identifier.Length > 511)
identifier = identifier.Substring(0, 511);
var identifierBuilder = new StringBuilder();
int startIndex = 0;
if (identifier[0] == '@')
{
identifierBuilder.Append('@');
startIndex += 1;
}
SkipInvalidCharacters(identifier, startIndex, identifierBuilder);
if (identifierBuilder.Length == startIndex)
{
// All characters were invalid so now it's empty. Fill it with something.
identifierBuilder.Append("Empty");
}
identifier = identifierBuilder.ToString();
if (identifier[0] != '@' && IsKeyword(identifier, anyDoubleUnderscore: true))
identifier = '@' + identifier;
return identifier;
}
private static bool IsKeyword(string value, bool anyDoubleUnderscore)
{
// Identifiers that start with double underscore are meant to be used for reserved keywords.
// Only existing keywords are enforced, but it may be useful to forbid any identifier
// that begins with double underscore to prevent issues with future C# versions.
if (anyDoubleUnderscore)
{
if (value.Length > 2 && value[0] == '_' && value[1] == '_' && value[2] != '_')
return true;
}
else
{
if (_doubleUnderscoreKeywords.Contains(value))
return true;
}
return _keywords.Contains(value);
}
private static readonly HashSet<string> _doubleUnderscoreKeywords = new HashSet<string>
{
"__arglist",
"__makeref",
"__reftype",
"__refvalue",
};
private static readonly HashSet<string> _keywords = new HashSet<string>
{
"as",
"do",
"if",
"in",
"is",
"for",
"int",
"new",
"out",
"ref",
"try",
"base",
"bool",
"byte",
"case",
"char",
"else",
"enum",
"goto",
"lock",
"long",
"null",
"this",
"true",
"uint",
"void",
"break",
"catch",
"class",
"const",
"event",
"false",
"fixed",
"float",
"sbyte",
"short",
"throw",
"ulong",
"using",
"where",
"while",
"yield",
"double",
"extern",
"object",
"params",
"public",
"return",
"sealed",
"sizeof",
"static",
"string",
"struct",
"switch",
"typeof",
"unsafe",
"ushort",
"checked",
"decimal",
"default",
"finally",
"foreach",
"partial",
"private",
"virtual",
"abstract",
"continue",
"delegate",
"explicit",
"implicit",
"internal",
"operator",
"override",
"readonly",
"volatile",
"interface",
"namespace",
"protected",
"unchecked",
"stackalloc",
};
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Globalization;
using System.IO;
using System.Text;
using Microsoft.Build.Construction;
using Microsoft.Build.Evaluation;
using GodotTools.Shared;
namespace GodotTools.ProjectEditor
{
public static class ProjectGenerator
{
public static string GodotSdkAttrValue => $"Godot.NET.Sdk/{GeneratedGodotNupkgsVersions.GodotNETSdk}";
public static string GodotMinimumRequiredTfm => "net8.0";
public static ProjectRootElement GenGameProject(string name)
{
if (name.Length == 0)
throw new ArgumentException("Project name is empty.", nameof(name));
var root = ProjectRootElement.Create(NewProjectFileOptions.None);
root.Sdk = GodotSdkAttrValue;
var mainGroup = root.AddPropertyGroup();
mainGroup.AddProperty("TargetFramework", GodotMinimumRequiredTfm);
// Non-gradle builds require .NET 9 to match the jar libraries included in the export template.
var net9 = mainGroup.AddProperty("TargetFramework", "net9.0");
net9.Condition = " '$(GodotTargetPlatform)' == 'android' ";
mainGroup.AddProperty("EnableDynamicLoading", "true");
string sanitizedName = IdentifierUtils.SanitizeQualifiedIdentifier(name, allowEmptyIdentifiers: true);
// If the name is not a valid namespace, manually set RootNamespace to a sanitized one.
if (sanitizedName != name)
mainGroup.AddProperty("RootNamespace", sanitizedName);
return root;
}
public static string GenAndSaveGameProject(string dir, string name)
{
if (name.Length == 0)
throw new ArgumentException("Project name is empty.", nameof(name));
string path = Path.Combine(dir, name + ".csproj");
var root = GenGameProject(name);
// Save (without BOM)
root.Save(path, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
return Guid.NewGuid().ToString().ToUpperInvariant();
}
}
}

View File

@@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Build.Construction;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Locator;
using NuGet.Frameworks;
namespace GodotTools.ProjectEditor
{
public sealed class MSBuildProject
{
internal ProjectRootElement Root { get; set; }
public bool HasUnsavedChanges { get; set; }
public void Save() => Root.Save();
public MSBuildProject(ProjectRootElement root)
{
Root = root;
}
}
public static partial class ProjectUtils
{
[GeneratedRegex(@"\s*'\$\(GodotTargetPlatform\)'\s*==\s*'(?<platform>[A-z]+)'\s*", RegexOptions.IgnoreCase)]
private static partial Regex GodotTargetPlatformConditionRegex();
private static readonly string[] _platformNames =
{
"windows",
"linuxbsd",
"macos",
"android",
"ios",
"web",
};
public static void MSBuildLocatorRegisterLatest(out Version version, out string path)
{
var instance = MSBuildLocator.QueryVisualStudioInstances()
.OrderByDescending(x => x.Version)
.First();
MSBuildLocator.RegisterInstance(instance);
version = instance.Version;
path = instance.MSBuildPath;
}
public static void MSBuildLocatorRegisterMSBuildPath(string msbuildPath)
=> MSBuildLocator.RegisterMSBuildPath(msbuildPath);
public static MSBuildProject? Open(string path)
{
var root = ProjectRootElement.Open(path, ProjectCollection.GlobalProjectCollection, preserveFormatting: true);
return root != null ? new MSBuildProject(root) : null;
}
public static void UpgradeProjectIfNeeded(MSBuildProject project, string projectName)
{
// NOTE: The order in which changes are made to the project is important.
// Migrate to MSBuild project Sdks style if using the old style.
MigrateToProjectSdksStyle(project, projectName);
EnsureGodotSdkIsUpToDate(project);
EnsureTargetFrameworkMatchesMinimumRequirement(project);
}
private static void MigrateToProjectSdksStyle(MSBuildProject project, string projectName)
{
var origRoot = project.Root;
if (!string.IsNullOrEmpty(origRoot.Sdk))
return;
project.Root = ProjectGenerator.GenGameProject(projectName);
project.Root.FullPath = origRoot.FullPath;
project.HasUnsavedChanges = true;
}
public static void EnsureGodotSdkIsUpToDate(MSBuildProject project)
{
var root = project.Root;
string godotSdkAttrValue = ProjectGenerator.GodotSdkAttrValue;
if (!string.IsNullOrEmpty(root.Sdk) &&
root.Sdk.Trim().Equals(godotSdkAttrValue, StringComparison.OrdinalIgnoreCase))
return;
root.Sdk = godotSdkAttrValue;
project.HasUnsavedChanges = true;
}
private static void EnsureTargetFrameworkMatchesMinimumRequirement(MSBuildProject project)
{
var root = project.Root;
string minTfmValue = ProjectGenerator.GodotMinimumRequiredTfm;
var minTfmVersion = NuGetFramework.Parse(minTfmValue).Version;
ProjectPropertyGroupElement? mainPropertyGroup = null;
ProjectPropertyElement? mainTargetFrameworkProperty = null;
var propertiesToChange = new List<ProjectPropertyElement>();
foreach (var propertyGroup in root.PropertyGroups)
{
bool groupHasCondition = !string.IsNullOrEmpty(propertyGroup.Condition);
// Check if the property group should be excluded from checking for 'TargetFramework' properties.
if (groupHasCondition && !ConditionMatchesGodotPlatform(propertyGroup.Condition))
{
continue;
}
// Store a reference to the first property group without conditions,
// in case we need to add a new 'TargetFramework' property later.
if (mainPropertyGroup == null && !groupHasCondition)
{
mainPropertyGroup = propertyGroup;
}
foreach (var property in propertyGroup.Properties)
{
// We are looking for 'TargetFramework' properties.
if (property.Name != "TargetFramework")
{
continue;
}
bool propertyHasCondition = !string.IsNullOrEmpty(property.Condition);
// Check if the property should be excluded.
if (propertyHasCondition && !ConditionMatchesGodotPlatform(property.Condition))
{
continue;
}
if (!groupHasCondition && !propertyHasCondition)
{
// Store a reference to the 'TargetFramework' that has no conditions
// because it applies to all platforms.
if (mainTargetFrameworkProperty == null)
{
mainTargetFrameworkProperty = property;
}
continue;
}
// If the 'TargetFramework' property is conditional, it may no longer be needed
// when the main one is upgraded to the new minimum version.
var tfmVersion = NuGetFramework.Parse(property.Value).Version;
if (tfmVersion <= minTfmVersion)
{
propertiesToChange.Add(property);
}
}
}
if (mainTargetFrameworkProperty == null)
{
// We haven't found a 'TargetFramework' property without conditions,
// we'll just add one in the first property group without conditions.
if (mainPropertyGroup == null)
{
// We also don't have a property group without conditions,
// so we'll add a new one to the project.
mainPropertyGroup = root.AddPropertyGroup();
}
mainTargetFrameworkProperty = mainPropertyGroup.AddProperty("TargetFramework", minTfmValue);
project.HasUnsavedChanges = true;
}
else
{
var tfmVersion = NuGetFramework.Parse(mainTargetFrameworkProperty.Value).Version;
if (tfmVersion < minTfmVersion)
{
mainTargetFrameworkProperty.Value = minTfmValue;
project.HasUnsavedChanges = true;
}
}
var mainTfmVersion = NuGetFramework.Parse(mainTargetFrameworkProperty.Value).Version;
foreach (var property in propertiesToChange)
{
// If the main 'TargetFramework' property targets a version newer than
// the minimum required by Godot, we don't want to remove the conditional
// 'TargetFramework' properties, only upgrade them to the new minimum.
// Otherwise, it can be removed.
if (mainTfmVersion > minTfmVersion)
{
var propertyTfmVersion = NuGetFramework.Parse(property.Value).Version;
if (propertyTfmVersion == minTfmVersion)
{
// The 'TargetFramework' property already matches the minimum version.
continue;
}
property.Value = minTfmValue;
}
else
{
property.Parent.RemoveChild(property);
}
project.HasUnsavedChanges = true;
}
static bool ConditionMatchesGodotPlatform(string condition)
{
// Check if the condition is checking the 'GodotTargetPlatform' for one of the
// Godot platforms with built-in support in the Godot.NET.Sdk.
var match = GodotTargetPlatformConditionRegex().Match(condition);
if (match.Success)
{
string platform = match.Groups["platform"].Value;
return _platformNames.Contains(platform, StringComparer.OrdinalIgnoreCase);
}
return false;
}
}
}
}

View File

@@ -0,0 +1,39 @@
<Project>
<!-- Generate C# file with the version of all the nupkgs bundled with Godot -->
<Target Name="SetPropertiesForGenerateGodotNupkgsVersions">
<PropertyGroup>
<GeneratedGodotNupkgsVersionsFile>$(IntermediateOutputPath)GodotNupkgsVersions.g.cs</GeneratedGodotNupkgsVersionsFile>
</PropertyGroup>
</Target>
<Target Name="GenerateGodotNupkgsVersionsFile"
DependsOnTargets="_GenerateGodotNupkgsVersionsFile"
BeforeTargets="PrepareForBuild;CompileDesignTime;BeforeCompile;CoreCompile">
<ItemGroup>
<Compile Include="$(GeneratedGodotNupkgsVersionsFile)" />
<FileWrites Include="$(GeneratedGodotNupkgsVersionsFile)" />
</ItemGroup>
</Target>
<Target Name="_GenerateGodotNupkgsVersionsFile"
DependsOnTargets="SetPropertiesForGenerateGodotNupkgsVersions"
Inputs="$(MSBuildProjectFile);$(MSBuildThisFileDirectory);$(MSBuildProjectFile)\..\..\..\SdkPackageVersions.props"
Outputs="$(GeneratedGodotNupkgsVersionsFile)">
<PropertyGroup>
<GenerateGodotNupkgsVersionsCode><![CDATA[
namespace $(RootNamespace)
{
public class GeneratedGodotNupkgsVersions
{
public const string GodotNETSdk = "$(PackageVersion_Godot_NET_Sdk)"%3b
public const string GodotSourceGenerators = "$(PackageVersion_Godot_SourceGenerators)"%3b
public const string GodotSharp = "$(PackageVersion_GodotSharp)"%3b
}
}
]]></GenerateGodotNupkgsVersionsCode>
</PropertyGroup>
<WriteLinesToFile Lines="$(GenerateGodotNupkgsVersionsCode)"
File="$(GeneratedGodotNupkgsVersionsFile)"
Overwrite="True" WriteOnlyWhenDifferent="True" />
</Target>
</Project>

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
<!-- Specify compile items manually to avoid including dangling generated items. -->
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<Import Project="GenerateGodotNupkgsVersions.targets" />
</Project>

View File

@@ -0,0 +1,73 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34728.123
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GodotTools.ProjectEditor", "GodotTools.ProjectEditor\GodotTools.ProjectEditor.csproj", "{A8CDAD94-C6D4-4B19-A7E7-76C53CC92984}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GodotTools", "GodotTools\GodotTools.csproj", "{27B00618-A6F2-4828-B922-05CAEB08C286}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GodotTools.Core", "GodotTools.Core\GodotTools.Core.csproj", "{639E48BD-44E5-4091-8EDD-22D36DC0768D}"
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.IdeMessaging", "GodotTools.IdeMessaging\GodotTools.IdeMessaging.csproj", "{92600954-25F0-4291-8E11-1FEE9FC4BE20}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GodotTools.OpenVisualStudio", "GodotTools.OpenVisualStudio\GodotTools.OpenVisualStudio.csproj", "{EAFFF236-FA96-4A4D-BD23-0E51EF988277}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GodotTools.Shared", "GodotTools.Shared\GodotTools.Shared.csproj", "{2758FFAF-8237-4CF2-B569-66BF8B3587BB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Godot.SourceGenerators", "..\Godot.NET.Sdk\Godot.SourceGenerators\Godot.SourceGenerators.csproj", "{D8C421B2-8911-41EB-B983-F675C7141EB7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Godot.SourceGenerators.Internal", "..\..\glue\GodotSharp\Godot.SourceGenerators.Internal\Godot.SourceGenerators.Internal.csproj", "{55666071-BEC1-4A52-8A98-9A4A7A947DBF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A8CDAD94-C6D4-4B19-A7E7-76C53CC92984}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A8CDAD94-C6D4-4B19-A7E7-76C53CC92984}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A8CDAD94-C6D4-4B19-A7E7-76C53CC92984}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A8CDAD94-C6D4-4B19-A7E7-76C53CC92984}.Release|Any CPU.Build.0 = Release|Any CPU
{27B00618-A6F2-4828-B922-05CAEB08C286}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{27B00618-A6F2-4828-B922-05CAEB08C286}.Debug|Any CPU.Build.0 = Debug|Any CPU
{27B00618-A6F2-4828-B922-05CAEB08C286}.Release|Any CPU.ActiveCfg = Release|Any CPU
{27B00618-A6F2-4828-B922-05CAEB08C286}.Release|Any CPU.Build.0 = Release|Any CPU
{639E48BD-44E5-4091-8EDD-22D36DC0768D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{639E48BD-44E5-4091-8EDD-22D36DC0768D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{639E48BD-44E5-4091-8EDD-22D36DC0768D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{639E48BD-44E5-4091-8EDD-22D36DC0768D}.Release|Any CPU.Build.0 = Release|Any CPU
{6CE9A984-37B1-4F8A-8FE9-609F05F071B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6CE9A984-37B1-4F8A-8FE9-609F05F071B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6CE9A984-37B1-4F8A-8FE9-609F05F071B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6CE9A984-37B1-4F8A-8FE9-609F05F071B3}.Release|Any CPU.Build.0 = Release|Any CPU
{92600954-25F0-4291-8E11-1FEE9FC4BE20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{92600954-25F0-4291-8E11-1FEE9FC4BE20}.Debug|Any CPU.Build.0 = Debug|Any CPU
{92600954-25F0-4291-8E11-1FEE9FC4BE20}.Release|Any CPU.ActiveCfg = Release|Any CPU
{92600954-25F0-4291-8E11-1FEE9FC4BE20}.Release|Any CPU.Build.0 = Release|Any CPU
{EAFFF236-FA96-4A4D-BD23-0E51EF988277}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EAFFF236-FA96-4A4D-BD23-0E51EF988277}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EAFFF236-FA96-4A4D-BD23-0E51EF988277}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EAFFF236-FA96-4A4D-BD23-0E51EF988277}.Release|Any CPU.Build.0 = Release|Any CPU
{2758FFAF-8237-4CF2-B569-66BF8B3587BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2758FFAF-8237-4CF2-B569-66BF8B3587BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2758FFAF-8237-4CF2-B569-66BF8B3587BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2758FFAF-8237-4CF2-B569-66BF8B3587BB}.Release|Any CPU.Build.0 = Release|Any CPU
{D8C421B2-8911-41EB-B983-F675C7141EB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D8C421B2-8911-41EB-B983-F675C7141EB7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8C421B2-8911-41EB-B983-F675C7141EB7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8C421B2-8911-41EB-B983-F675C7141EB7}.Release|Any CPU.Build.0 = Release|Any CPU
{55666071-BEC1-4A52-8A98-9A4A7A947DBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{55666071-BEC1-4A52-8A98-9A4A7A947DBF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{55666071-BEC1-4A52-8A98-9A4A7A947DBF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{55666071-BEC1-4A52-8A98-9A4A7A947DBF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {521EC35A-F7F0-46A9-92CE-680D2F5B02B8}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,21 @@
namespace GodotTools.Build
{
public class BuildDiagnostic
{
public enum DiagnosticType
{
Hidden,
Info,
Warning,
Error,
}
public DiagnosticType Type { get; set; }
public string? File { get; set; }
public int Line { get; set; }
public int Column { get; set; }
public string? Code { get; set; }
public string Message { get; set; } = "";
public string? ProjectFile { get; set; }
}
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Godot;
using Godot.Collections;
using GodotTools.Internals;
using Path = System.IO.Path;
namespace GodotTools.Build
{
[Serializable]
public sealed partial class BuildInfo : RefCounted // TODO Remove RefCounted once we have proper serialization
{
public string Solution { get; private set; }
public string Project { get; private set; }
public string Configuration { get; private set; }
public string? RuntimeIdentifier { get; private set; }
public string? PublishOutputDir { get; private set; }
public bool Restore { get; private set; }
public bool Rebuild { get; private set; }
public bool OnlyClean { get; private set; }
// TODO Use List once we have proper serialization
public Godot.Collections.Array CustomProperties { get; private set; } = new();
public string LogsDirPath => GodotSharpDirs.LogsDirPathFor(Solution, Configuration);
public override bool Equals([NotNullWhen(true)] object? obj)
{
return obj is BuildInfo other &&
other.Solution == Solution &&
other.Project == Project &&
other.Configuration == Configuration && other.RuntimeIdentifier == RuntimeIdentifier &&
other.PublishOutputDir == PublishOutputDir && other.Restore == Restore &&
other.Rebuild == Rebuild && other.OnlyClean == OnlyClean &&
other.CustomProperties == CustomProperties &&
other.LogsDirPath == LogsDirPath;
}
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(Solution);
hash.Add(Project);
hash.Add(Configuration);
hash.Add(RuntimeIdentifier);
hash.Add(PublishOutputDir);
hash.Add(Restore);
hash.Add(Rebuild);
hash.Add(OnlyClean);
hash.Add(CustomProperties);
hash.Add(LogsDirPath);
return hash.ToHashCode();
}
// Needed for instantiation from Godot, after reloading assemblies
private BuildInfo()
{
Solution = string.Empty;
Project = string.Empty;
Configuration = string.Empty;
}
public BuildInfo(string solution, string project, string configuration, bool restore, bool rebuild, bool onlyClean)
{
Solution = solution;
Project = project;
Configuration = configuration;
Restore = restore;
Rebuild = rebuild;
OnlyClean = onlyClean;
}
public BuildInfo(string solution, string project, string configuration, string runtimeIdentifier,
string publishOutputDir, bool restore, bool rebuild, bool onlyClean)
{
Solution = solution;
Project = project;
Configuration = configuration;
RuntimeIdentifier = runtimeIdentifier;
PublishOutputDir = publishOutputDir;
Restore = restore;
Rebuild = rebuild;
OnlyClean = onlyClean;
}
}
}

View File

@@ -0,0 +1,390 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading.Tasks;
using Godot;
using GodotTools.Internals;
using File = GodotTools.Utils.File;
namespace GodotTools.Build
{
public static class BuildManager
{
private static BuildInfo? _buildInProgress;
public const string MsBuildIssuesFileName = "msbuild_issues.csv";
private const string MsBuildLogFileName = "msbuild_log.txt";
public delegate void BuildLaunchFailedEventHandler(BuildInfo buildInfo, string reason);
public static event BuildLaunchFailedEventHandler? BuildLaunchFailed;
public static event Action<BuildInfo>? BuildStarted;
public static event Action<BuildResult>? BuildFinished;
public static event Action<string?>? StdOutputReceived;
public static event Action<string?>? StdErrorReceived;
public static DateTime LastValidBuildDateTime { get; private set; }
static BuildManager()
{
UpdateLastValidBuildDateTime();
}
public static void UpdateLastValidBuildDateTime()
{
var dllName = $"{GodotSharpDirs.ProjectAssemblyName}.dll";
var path = Path.Combine(GodotSharpDirs.ProjectBaseOutputPath, "Debug", dllName);
LastValidBuildDateTime = File.GetLastWriteTime(path);
}
private static void RemoveOldIssuesFile(BuildInfo buildInfo)
{
string issuesFile = GetIssuesFilePath(buildInfo);
if (!File.Exists(issuesFile))
return;
File.Delete(issuesFile);
}
private static void ShowBuildErrorDialog(string message)
{
var plugin = GodotSharpEditor.Instance;
plugin.ShowErrorDialog(message, "Build error");
plugin.MakeBottomPanelItemVisible(plugin.MSBuildPanel);
}
private static string GetLogFilePath(BuildInfo buildInfo)
{
return Path.Combine(buildInfo.LogsDirPath, MsBuildLogFileName);
}
private static string GetIssuesFilePath(BuildInfo buildInfo)
{
return Path.Combine(buildInfo.LogsDirPath, MsBuildIssuesFileName);
}
private static void PrintVerbose(string text)
{
if (OS.IsStdOutVerbose())
GD.Print(text);
}
private static bool Build(BuildInfo buildInfo)
{
if (_buildInProgress != null)
throw new InvalidOperationException("A build is already in progress.");
_buildInProgress = buildInfo;
try
{
BuildStarted?.Invoke(buildInfo);
// Required in order to update the build tasks list.
Internal.GodotMainIteration();
try
{
RemoveOldIssuesFile(buildInfo);
}
catch (IOException e)
{
BuildLaunchFailed?.Invoke(buildInfo, $"Cannot remove issues file: {GetIssuesFilePath(buildInfo)}");
Console.Error.WriteLine(e);
}
try
{
int exitCode = BuildSystem.Build(buildInfo, StdOutputReceived, StdErrorReceived);
if (exitCode != 0)
PrintVerbose($"MSBuild exited with code: {exitCode}. Log file: {GetLogFilePath(buildInfo)}");
BuildFinished?.Invoke(exitCode == 0 ? BuildResult.Success : BuildResult.Error);
return exitCode == 0;
}
catch (Exception e)
{
BuildLaunchFailed?.Invoke(buildInfo,
$"The build method threw an exception.\n{e.GetType().FullName}: {e.Message}");
Console.Error.WriteLine(e);
return false;
}
}
finally
{
_buildInProgress = null;
}
}
public static async Task<bool> BuildAsync(BuildInfo buildInfo)
{
if (_buildInProgress != null)
throw new InvalidOperationException("A build is already in progress.");
_buildInProgress = buildInfo;
try
{
BuildStarted?.Invoke(buildInfo);
try
{
RemoveOldIssuesFile(buildInfo);
}
catch (IOException e)
{
BuildLaunchFailed?.Invoke(buildInfo, $"Cannot remove issues file: {GetIssuesFilePath(buildInfo)}");
Console.Error.WriteLine(e);
}
try
{
int exitCode = await BuildSystem.BuildAsync(buildInfo, StdOutputReceived, StdErrorReceived);
if (exitCode != 0)
PrintVerbose($"MSBuild exited with code: {exitCode}. Log file: {GetLogFilePath(buildInfo)}");
BuildFinished?.Invoke(exitCode == 0 ? BuildResult.Success : BuildResult.Error);
return exitCode == 0;
}
catch (Exception e)
{
BuildLaunchFailed?.Invoke(buildInfo,
$"The build method threw an exception.\n{e.GetType().FullName}: {e.Message}");
Console.Error.WriteLine(e);
return false;
}
}
finally
{
_buildInProgress = null;
}
}
private static bool Publish(BuildInfo buildInfo)
{
if (_buildInProgress != null)
throw new InvalidOperationException("A build is already in progress.");
_buildInProgress = buildInfo;
try
{
BuildStarted?.Invoke(buildInfo);
// Required in order to update the build tasks list.
Internal.GodotMainIteration();
try
{
RemoveOldIssuesFile(buildInfo);
}
catch (IOException e)
{
BuildLaunchFailed?.Invoke(buildInfo, $"Cannot remove issues file: {GetIssuesFilePath(buildInfo)}");
Console.Error.WriteLine(e);
}
try
{
int exitCode = BuildSystem.Publish(buildInfo, StdOutputReceived, StdErrorReceived);
if (exitCode != 0)
PrintVerbose(
$"dotnet publish exited with code: {exitCode}. Log file: {GetLogFilePath(buildInfo)}");
BuildFinished?.Invoke(exitCode == 0 ? BuildResult.Success : BuildResult.Error);
return exitCode == 0;
}
catch (Exception e)
{
BuildLaunchFailed?.Invoke(buildInfo,
$"The publish method threw an exception.\n{e.GetType().FullName}: {e.Message}");
Console.Error.WriteLine(e);
return false;
}
}
finally
{
_buildInProgress = null;
}
}
private static bool BuildProjectBlocking(BuildInfo buildInfo)
{
if (!File.Exists(buildInfo.Project))
return true; // No project to build.
bool success;
using (var pr = new EditorProgress("dotnet_build_project", "Building .NET project...", 1))
{
pr.Step("Building project", 0);
success = Build(buildInfo);
}
if (!success)
{
ShowBuildErrorDialog("Failed to build project. Check MSBuild panel for details.");
}
return success;
}
private static bool CleanProjectBlocking(BuildInfo buildInfo)
{
if (!File.Exists(buildInfo.Project))
return true; // No project to clean.
bool success;
using (var pr = new EditorProgress("dotnet_clean_project", "Cleaning .NET project...", 1))
{
pr.Step("Cleaning project", 0);
success = Build(buildInfo);
}
if (!success)
{
ShowBuildErrorDialog("Failed to clean project");
}
return success;
}
private static bool PublishProjectBlocking(BuildInfo buildInfo)
{
bool success;
using (var pr = new EditorProgress("dotnet_publish_project", "Publishing .NET project...", 1))
{
pr.Step("Running dotnet publish", 0);
success = Publish(buildInfo);
}
return success;
}
private static BuildInfo CreateBuildInfo(
string configuration,
string? platform = null,
bool rebuild = false,
bool onlyClean = false
)
{
var buildInfo = new BuildInfo(GodotSharpDirs.ProjectSlnPath, GodotSharpDirs.ProjectCsProjPath, configuration,
restore: true, rebuild, onlyClean);
// If a platform was not specified, try determining the current one. If that fails, let MSBuild auto-detect it.
if (platform != null || Utils.OS.PlatformNameMap.TryGetValue(OS.GetName(), out platform))
buildInfo.CustomProperties.Add($"GodotTargetPlatform={platform}");
if (Internal.GodotIsRealTDouble())
buildInfo.CustomProperties.Add("GodotFloat64=true");
return buildInfo;
}
private static BuildInfo CreatePublishBuildInfo(
string configuration,
string platform,
string runtimeIdentifier,
string publishOutputDir,
bool includeDebugSymbols = true
)
{
var buildInfo = new BuildInfo(GodotSharpDirs.ProjectSlnPath, GodotSharpDirs.ProjectCsProjPath, configuration,
runtimeIdentifier, publishOutputDir, restore: true, rebuild: false, onlyClean: false);
if (!includeDebugSymbols)
{
buildInfo.CustomProperties.Add("DebugType=None");
buildInfo.CustomProperties.Add("DebugSymbols=false");
}
buildInfo.CustomProperties.Add($"GodotTargetPlatform={platform}");
if (Internal.GodotIsRealTDouble())
buildInfo.CustomProperties.Add("GodotFloat64=true");
return buildInfo;
}
public static bool BuildProjectBlocking(
string configuration,
string? platform = null,
bool rebuild = false
) => BuildProjectBlocking(CreateBuildInfo(configuration, platform, rebuild));
public static bool CleanProjectBlocking(
string configuration,
string? platform = null
) => CleanProjectBlocking(CreateBuildInfo(configuration, platform, rebuild: false, onlyClean: true));
public static bool PublishProjectBlocking(
string configuration,
string platform,
string runtimeIdentifier,
string publishOutputDir,
bool includeDebugSymbols = true
) => PublishProjectBlocking(CreatePublishBuildInfo(configuration,
platform, runtimeIdentifier, publishOutputDir, includeDebugSymbols));
public static bool GenerateXCFrameworkBlocking(
List<string> outputPaths,
string xcFrameworkPath)
{
using var pr = new EditorProgress("generate_xcframework", "Generating XCFramework...", 1);
pr.Step("Running xcodebuild -create-xcframework", 0);
if (!GenerateXCFramework(outputPaths, xcFrameworkPath))
{
ShowBuildErrorDialog("Failed to generate XCFramework");
return false;
}
return true;
}
private static bool GenerateXCFramework(List<string> outputPaths, string xcFrameworkPath)
{
// Required in order to update the build tasks list.
Internal.GodotMainIteration();
try
{
int exitCode = BuildSystem.GenerateXCFramework(outputPaths, xcFrameworkPath, StdOutputReceived, StdErrorReceived);
if (exitCode != 0)
PrintVerbose(
$"xcodebuild create-xcframework exited with code: {exitCode}.");
return exitCode == 0;
}
catch (Exception e)
{
Console.Error.WriteLine(e);
return false;
}
}
public static bool EditorBuildCallback()
{
if (!File.Exists(GodotSharpDirs.ProjectCsProjPath))
return true; // No project to build.
if (GodotSharpEditor.Instance.SkipBuildBeforePlaying)
return true; // Requested play from an external editor/IDE which already built the project.
return BuildProjectBlocking("Debug");
}
public static void Initialize()
{
}
}
}

View File

@@ -0,0 +1,148 @@
using Godot;
using static GodotTools.Internals.Globals;
namespace GodotTools.Build
{
public partial class BuildOutputView : HBoxContainer
{
#nullable disable
private RichTextLabel _log;
private Button _clearButton;
private Button _copyButton;
#nullable enable
public void Append(string text)
{
_log.AddText(text);
}
public void Clear()
{
_log.Clear();
}
private void CopyRequested()
{
string text = _log.GetSelectedText();
if (string.IsNullOrEmpty(text))
text = _log.GetParsedText();
if (!string.IsNullOrEmpty(text))
DisplayServer.ClipboardSet(text);
}
public override void _Ready()
{
Name = "Output".TTR();
var vbLeft = new VBoxContainer
{
CustomMinimumSize = new Vector2(0, 180 * EditorScale),
SizeFlagsVertical = SizeFlags.ExpandFill,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
AddChild(vbLeft);
// Log - Rich Text Label.
_log = new RichTextLabel
{
BbcodeEnabled = true,
ScrollFollowing = true,
SelectionEnabled = true,
ContextMenuEnabled = true,
FocusMode = FocusModeEnum.Click,
SizeFlagsVertical = SizeFlags.ExpandFill,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
DeselectOnFocusLossEnabled = false,
};
vbLeft.AddChild(_log);
var vbRight = new VBoxContainer();
AddChild(vbRight);
// Tools grid
var hbTools = new HBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
vbRight.AddChild(hbTools);
// Clear.
_clearButton = new Button
{
ThemeTypeVariation = "FlatButton",
FocusMode = FocusModeEnum.None,
Shortcut = EditorDefShortcut("editor/clear_output", "Clear Output".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | (Key)KeyModifierMask.MaskShift | Key.K),
};
_clearButton.Pressed += Clear;
hbTools.AddChild(_clearButton);
// Copy.
_copyButton = new Button
{
ThemeTypeVariation = "FlatButton",
FocusMode = FocusModeEnum.None,
Shortcut = EditorDefShortcut("editor/copy_output", "Copy Selection".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | Key.C),
ShortcutContext = this,
};
_copyButton.Pressed += CopyRequested;
hbTools.AddChild(_copyButton);
UpdateTheme();
}
public override void _Notification(int what)
{
base._Notification(what);
if (what == NotificationThemeChanged)
{
UpdateTheme();
}
}
private void UpdateTheme()
{
// Nodes will be null until _Ready is called.
if (_log == null)
return;
var normalFont = GetThemeFont("output_source", "EditorFonts");
if (normalFont != null)
_log.AddThemeFontOverride("normal_font", normalFont);
var boldFont = GetThemeFont("output_source_bold", "EditorFonts");
if (boldFont != null)
_log.AddThemeFontOverride("bold_font", boldFont);
var italicsFont = GetThemeFont("output_source_italic", "EditorFonts");
if (italicsFont != null)
_log.AddThemeFontOverride("italics_font", italicsFont);
var boldItalicsFont = GetThemeFont("output_source_bold_italic", "EditorFonts");
if (boldItalicsFont != null)
_log.AddThemeFontOverride("bold_italics_font", boldItalicsFont);
var monoFont = GetThemeFont("output_source_mono", "EditorFonts");
if (monoFont != null)
_log.AddThemeFontOverride("mono_font", monoFont);
// Disable padding for highlighted background/foreground to prevent highlights from overlapping on close lines.
// This also better matches terminal output, which does not use any form of padding.
_log.AddThemeConstantOverride("text_highlight_h_padding", 0);
_log.AddThemeConstantOverride("text_highlight_v_padding", 0);
int font_size = GetThemeFontSize("output_source_size", "EditorFonts");
_log.AddThemeFontSizeOverride("normal_font_size", font_size);
_log.AddThemeFontSizeOverride("bold_font_size", font_size);
_log.AddThemeFontSizeOverride("italics_font_size", font_size);
_log.AddThemeFontSizeOverride("mono_font_size", font_size);
_clearButton.Icon = GetThemeIcon("Clear", "EditorIcons");
_copyButton.Icon = GetThemeIcon("ActionCopy", "EditorIcons");
}
}
}

View File

@@ -0,0 +1,39 @@
using Godot;
using System.Globalization;
namespace GodotTools.Build
{
public class BuildProblemsFilter
{
public BuildDiagnostic.DiagnosticType Type { get; }
public Button ToggleButton { get; }
private int _problemsCount;
public int ProblemsCount
{
get => _problemsCount;
set
{
_problemsCount = value;
ToggleButton.Text = _problemsCount.ToString(CultureInfo.InvariantCulture);
}
}
public bool IsActive => ToggleButton.ButtonPressed;
public BuildProblemsFilter(BuildDiagnostic.DiagnosticType type)
{
Type = type;
ToggleButton = new Button
{
ToggleMode = true,
ButtonPressed = true,
Text = "0",
FocusMode = Control.FocusModeEnum.None,
ThemeTypeVariation = "EditorLogFilterButton",
};
}
}
}

View File

@@ -0,0 +1,695 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Godot;
using GodotTools.Internals;
using static GodotTools.Internals.Globals;
using FileAccess = Godot.FileAccess;
namespace GodotTools.Build
{
public partial class BuildProblemsView : HBoxContainer
{
#nullable disable
private Button _clearButton;
private Button _copyButton;
private Button _toggleLayoutButton;
private Button _showSearchButton;
private LineEdit _searchBox;
#nullable enable
private readonly Dictionary<BuildDiagnostic.DiagnosticType, BuildProblemsFilter> _filtersByType = new();
#nullable disable
private Tree _problemsTree;
private PopupMenu _problemsContextMenu;
#nullable enable
public enum ProblemsLayout { List, Tree }
private ProblemsLayout _layout = ProblemsLayout.Tree;
private readonly List<BuildDiagnostic> _diagnostics = new();
public int TotalDiagnosticCount => _diagnostics.Count;
private readonly Dictionary<BuildDiagnostic.DiagnosticType, int> _problemCountByType = new();
public int WarningCount =>
GetProblemCountForType(BuildDiagnostic.DiagnosticType.Warning);
public int ErrorCount =>
GetProblemCountForType(BuildDiagnostic.DiagnosticType.Error);
private int GetProblemCountForType(BuildDiagnostic.DiagnosticType type)
{
if (!_problemCountByType.TryGetValue(type, out int count))
{
count = _diagnostics.Count(d => d.Type == type);
_problemCountByType[type] = count;
}
return count;
}
private static IEnumerable<BuildDiagnostic> ReadDiagnosticsFromFile(string csvFile)
{
using var file = FileAccess.Open(csvFile, FileAccess.ModeFlags.Read);
if (file == null)
yield break;
while (!file.EofReached())
{
string[] csvColumns = file.GetCsvLine();
if (csvColumns.Length == 1 && string.IsNullOrEmpty(csvColumns[0]))
yield break;
if (csvColumns.Length != 7)
{
GD.PushError($"Expected 7 columns, got {csvColumns.Length}");
continue;
}
var diagnostic = new BuildDiagnostic
{
Type = csvColumns[0] switch
{
"warning" => BuildDiagnostic.DiagnosticType.Warning,
"error" or _ => BuildDiagnostic.DiagnosticType.Error,
},
File = csvColumns[1],
Line = int.Parse(csvColumns[2], CultureInfo.InvariantCulture),
Column = int.Parse(csvColumns[3], CultureInfo.InvariantCulture),
Code = csvColumns[4],
Message = csvColumns[5],
ProjectFile = csvColumns[6],
};
// If there's no ProjectFile but the File is a csproj, then use that.
if (string.IsNullOrEmpty(diagnostic.ProjectFile) &&
!string.IsNullOrEmpty(diagnostic.File) &&
diagnostic.File.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
{
diagnostic.ProjectFile = diagnostic.File;
}
yield return diagnostic;
}
}
public void SetDiagnosticsFromFile(string csvFile)
{
var diagnostics = ReadDiagnosticsFromFile(csvFile);
SetDiagnostics(diagnostics);
}
public void SetDiagnostics(IEnumerable<BuildDiagnostic> diagnostics)
{
_diagnostics.Clear();
_problemCountByType.Clear();
_diagnostics.AddRange(diagnostics);
UpdateProblemsView();
}
public void Clear()
{
_problemsTree.Clear();
_diagnostics.Clear();
_problemCountByType.Clear();
UpdateProblemsView();
}
private void CopySelectedProblems()
{
var selectedItem = _problemsTree.GetNextSelected(null);
if (selectedItem == null)
return;
var selectedIdxs = new List<int>();
while (selectedItem != null)
{
int selectedIdx = (int)selectedItem.GetMetadata(0);
selectedIdxs.Add(selectedIdx);
selectedItem = _problemsTree.GetNextSelected(selectedItem);
}
if (selectedIdxs.Count == 0)
return;
var selectedDiagnostics = selectedIdxs.Select(i => _diagnostics[i]);
var sb = new StringBuilder();
foreach (var diagnostic in selectedDiagnostics)
{
if (!string.IsNullOrEmpty(diagnostic.Code))
sb.Append(CultureInfo.InvariantCulture, $"{diagnostic.Code}: ");
sb.AppendLine(CultureInfo.InvariantCulture, $"{diagnostic.Message} {diagnostic.File}({diagnostic.Line},{diagnostic.Column})");
}
string text = sb.ToString();
if (!string.IsNullOrEmpty(text))
DisplayServer.ClipboardSet(text);
}
private void ToggleLayout(bool pressed)
{
_layout = pressed ? ProblemsLayout.List : ProblemsLayout.Tree;
var editorSettings = EditorInterface.Singleton.GetEditorSettings();
editorSettings.SetSetting(GodotSharpEditor.Settings.ProblemsLayout, Variant.From(_layout));
_toggleLayoutButton.Icon = GetToggleLayoutIcon();
_toggleLayoutButton.TooltipText = GetToggleLayoutTooltipText();
UpdateProblemsView();
}
private bool GetToggleLayoutPressedState()
{
// If pressed: List layout.
// If not pressed: Tree layout.
return _layout == ProblemsLayout.List;
}
private Texture2D? GetToggleLayoutIcon()
{
return _layout switch
{
ProblemsLayout.List => GetThemeIcon("FileList", "EditorIcons"),
ProblemsLayout.Tree or _ => GetThemeIcon("FileTree", "EditorIcons"),
};
}
private string GetToggleLayoutTooltipText()
{
return _layout switch
{
ProblemsLayout.List => "View as a Tree".TTR(),
ProblemsLayout.Tree or _ => "View as a List".TTR(),
};
}
private void ToggleSearchBoxVisibility(bool pressed)
{
_searchBox.Visible = pressed;
if (pressed)
{
_searchBox.GrabFocus();
}
}
private void SearchTextChanged(string text)
{
UpdateProblemsView();
}
private void ToggleFilter(bool pressed)
{
UpdateProblemsView();
}
private void GoToSelectedProblem()
{
var selectedItem = _problemsTree.GetSelected();
if (selectedItem == null)
throw new InvalidOperationException("Item tree has no selected items.");
// Get correct diagnostic index from problems tree.
int diagnosticIndex = (int)selectedItem.GetMetadata(0);
if (diagnosticIndex < 0 || diagnosticIndex >= _diagnostics.Count)
throw new InvalidOperationException("Diagnostic index out of range.");
var diagnostic = _diagnostics[diagnosticIndex];
if (string.IsNullOrEmpty(diagnostic.ProjectFile) && string.IsNullOrEmpty(diagnostic.File))
return;
string? projectDir = !string.IsNullOrEmpty(diagnostic.ProjectFile) ?
diagnostic.ProjectFile.GetBaseDir() :
GodotSharpEditor.Instance.MSBuildPanel.LastBuildInfo?.Solution.GetBaseDir();
if (string.IsNullOrEmpty(projectDir))
return;
string? file = !string.IsNullOrEmpty(diagnostic.File) ?
Path.Combine(projectDir.SimplifyGodotPath(), diagnostic.File.SimplifyGodotPath()) :
null;
if (!File.Exists(file))
return;
file = ProjectSettings.LocalizePath(file);
if (file.StartsWith("res://", StringComparison.Ordinal))
{
var script = (Script)ResourceLoader.Load(file, typeHint: Internal.CSharpLanguageType);
// Godot's ScriptEditor.Edit is 0-based but the diagnostic lines are 1-based.
if (script != null && Internal.ScriptEditorEdit(script, diagnostic.Line - 1, diagnostic.Column - 1))
Internal.EditorNodeShowScriptScreen();
}
}
private void ShowProblemContextMenu(Vector2 position, long mouseButtonIndex)
{
if (mouseButtonIndex != (long)MouseButton.Right)
return;
_problemsContextMenu.Clear();
_problemsContextMenu.Size = new Vector2I(1, 1);
var selectedItem = _problemsTree.GetSelected();
if (selectedItem != null)
{
// Add menu entries for the selected item.
_problemsContextMenu.AddIconItem(GetThemeIcon("ActionCopy", "EditorIcons"),
label: "Copy Error".TTR(), (int)ProblemContextMenuOption.Copy);
}
if (_problemsContextMenu.ItemCount > 0)
{
_problemsContextMenu.Position = (Vector2I)(GetScreenPosition() + position);
_problemsContextMenu.Popup();
}
}
private enum ProblemContextMenuOption
{
Copy,
}
private void ProblemContextOptionPressed(long id)
{
switch ((ProblemContextMenuOption)id)
{
case ProblemContextMenuOption.Copy:
CopySelectedProblems();
break;
default:
throw new ArgumentOutOfRangeException(nameof(id), id, "Invalid problem context menu option.");
}
}
private bool ShouldDisplayDiagnostic(BuildDiagnostic diagnostic)
{
if (!_filtersByType[diagnostic.Type].IsActive)
return false;
string searchText = _searchBox.Text;
if (string.IsNullOrEmpty(searchText))
return true;
if (diagnostic.Message.Contains(searchText, StringComparison.OrdinalIgnoreCase))
return true;
if (diagnostic.File?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false)
return true;
return false;
}
private Color? GetProblemItemColor(BuildDiagnostic diagnostic)
{
return diagnostic.Type switch
{
BuildDiagnostic.DiagnosticType.Warning => GetThemeColor("warning_color", "Editor"),
BuildDiagnostic.DiagnosticType.Error => GetThemeColor("error_color", "Editor"),
_ => null,
};
}
public void UpdateProblemsView()
{
switch (_layout)
{
case ProblemsLayout.List:
UpdateProblemsList();
break;
case ProblemsLayout.Tree:
default:
UpdateProblemsTree();
break;
}
foreach (var (type, filter) in _filtersByType)
{
int count = _diagnostics.Count(d => d.Type == type);
filter.ProblemsCount = count;
}
if (_diagnostics.Count == 0)
Name = "Problems".TTR();
else
Name = $"{"Problems".TTR()} ({_diagnostics.Count})";
}
private void UpdateProblemsList()
{
_problemsTree.Clear();
var root = _problemsTree.CreateItem();
for (int i = 0; i < _diagnostics.Count; i++)
{
var diagnostic = _diagnostics[i];
if (!ShouldDisplayDiagnostic(diagnostic))
continue;
var item = CreateProblemItem(diagnostic, includeFileInText: true);
var problemItem = _problemsTree.CreateItem(root);
problemItem.SetIcon(0, item.Icon);
problemItem.SetText(0, item.Text);
problemItem.SetTooltipText(0, item.TooltipText);
problemItem.SetMetadata(0, i);
var color = GetProblemItemColor(diagnostic);
if (color.HasValue)
problemItem.SetCustomColor(0, color.Value);
}
}
private void UpdateProblemsTree()
{
_problemsTree.Clear();
var root = _problemsTree.CreateItem();
var groupedDiagnostics = _diagnostics.Select((d, i) => (Diagnostic: d, Index: i))
.Where(x => ShouldDisplayDiagnostic(x.Diagnostic))
.GroupBy(x => x.Diagnostic.ProjectFile)
.Select(g => (ProjectFile: g.Key, Diagnostics: g.GroupBy(x => x.Diagnostic.File)
.Select(x => (File: x.Key, Diagnostics: x.ToArray()))))
.ToArray();
if (groupedDiagnostics.Length == 0)
return;
foreach (var (projectFile, projectDiagnostics) in groupedDiagnostics)
{
TreeItem projectItem;
if (groupedDiagnostics.Length == 1)
{
// Don't create a project item if there's only one project.
projectItem = root;
}
else
{
string projectFilePath = !string.IsNullOrEmpty(projectFile)
? projectFile
: "Unknown project".TTR();
projectItem = _problemsTree.CreateItem(root);
projectItem.SetText(0, projectFilePath);
projectItem.SetSelectable(0, false);
}
foreach (var (file, fileDiagnostics) in projectDiagnostics)
{
if (fileDiagnostics.Length == 0)
continue;
string? projectDir = Path.GetDirectoryName(projectFile);
string relativeFilePath = !string.IsNullOrEmpty(file) && !string.IsNullOrEmpty(projectDir)
? Path.GetRelativePath(projectDir, file)
: "Unknown file".TTR();
string fileItemText = string.Format(CultureInfo.InvariantCulture, "{0} ({1} issues)".TTR(), relativeFilePath, fileDiagnostics.Length);
var fileItem = _problemsTree.CreateItem(projectItem);
fileItem.SetText(0, fileItemText);
fileItem.SetSelectable(0, false);
foreach (var (diagnostic, index) in fileDiagnostics)
{
var item = CreateProblemItem(diagnostic);
var problemItem = _problemsTree.CreateItem(fileItem);
problemItem.SetIcon(0, item.Icon);
problemItem.SetText(0, item.Text);
problemItem.SetTooltipText(0, item.TooltipText);
problemItem.SetMetadata(0, index);
var color = GetProblemItemColor(diagnostic);
if (color.HasValue)
problemItem.SetCustomColor(0, color.Value);
}
}
}
}
private class ProblemItem
{
public string? Text { get; set; }
public string? TooltipText { get; set; }
public Texture2D? Icon { get; set; }
}
private ProblemItem CreateProblemItem(BuildDiagnostic diagnostic, bool includeFileInText = false)
{
var text = new StringBuilder();
var tooltip = new StringBuilder();
ReadOnlySpan<char> shortMessage = diagnostic.Message.AsSpan();
int lineBreakIdx = shortMessage.IndexOf('\n');
if (lineBreakIdx != -1)
shortMessage = shortMessage[..lineBreakIdx];
text.Append(shortMessage);
tooltip.Append(CultureInfo.InvariantCulture, $"Message: {diagnostic.Message}");
if (!string.IsNullOrEmpty(diagnostic.Code))
tooltip.Append(CultureInfo.InvariantCulture, $"\nCode: {diagnostic.Code}");
string type = diagnostic.Type switch
{
BuildDiagnostic.DiagnosticType.Hidden => "hidden",
BuildDiagnostic.DiagnosticType.Info => "info",
BuildDiagnostic.DiagnosticType.Warning => "warning",
BuildDiagnostic.DiagnosticType.Error => "error",
_ => "unknown",
};
tooltip.Append(CultureInfo.InvariantCulture, $"\nType: {type}");
if (!string.IsNullOrEmpty(diagnostic.File))
{
text.Append(' ');
if (includeFileInText)
{
text.Append(diagnostic.File);
}
text.Append(CultureInfo.InvariantCulture, $"({diagnostic.Line},{diagnostic.Column})");
tooltip.Append(CultureInfo.InvariantCulture, $"\nFile: {diagnostic.File}");
tooltip.Append(CultureInfo.InvariantCulture, $"\nLine: {diagnostic.Line}");
tooltip.Append(CultureInfo.InvariantCulture, $"\nColumn: {diagnostic.Column}");
}
if (!string.IsNullOrEmpty(diagnostic.ProjectFile))
tooltip.Append(CultureInfo.InvariantCulture, $"\nProject: {diagnostic.ProjectFile}");
return new ProblemItem()
{
Text = text.ToString(),
TooltipText = tooltip.ToString(),
Icon = diagnostic.Type switch
{
BuildDiagnostic.DiagnosticType.Warning => GetThemeIcon("Warning", "EditorIcons"),
BuildDiagnostic.DiagnosticType.Error => GetThemeIcon("Error", "EditorIcons"),
_ => null,
},
};
}
public override void _Ready()
{
var editorSettings = EditorInterface.Singleton.GetEditorSettings();
_layout = editorSettings.GetSetting(GodotSharpEditor.Settings.ProblemsLayout).As<ProblemsLayout>();
Name = "Problems".TTR();
var vbLeft = new VBoxContainer
{
CustomMinimumSize = new Vector2(0, 180 * EditorScale),
SizeFlagsVertical = SizeFlags.ExpandFill,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
AddChild(vbLeft);
// Problem Tree.
_problemsTree = new Tree
{
SizeFlagsVertical = SizeFlags.ExpandFill,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
AllowRmbSelect = true,
HideRoot = true,
};
_problemsTree.ItemActivated += GoToSelectedProblem;
_problemsTree.ItemMouseSelected += ShowProblemContextMenu;
vbLeft.AddChild(_problemsTree);
// Problem context menu.
_problemsContextMenu = new PopupMenu();
_problemsContextMenu.IdPressed += ProblemContextOptionPressed;
_problemsTree.AddChild(_problemsContextMenu);
// Search box.
_searchBox = new LineEdit
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
PlaceholderText = "Filter Problems".TTR(),
ClearButtonEnabled = true,
};
_searchBox.TextChanged += SearchTextChanged;
vbLeft.AddChild(_searchBox);
var vbRight = new VBoxContainer();
AddChild(vbRight);
// Tools grid.
var hbTools = new HBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
vbRight.AddChild(hbTools);
// Clear.
_clearButton = new Button
{
ThemeTypeVariation = "FlatButton",
FocusMode = FocusModeEnum.None,
Shortcut = EditorDefShortcut("editor/clear_output", "Clear Output".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | (Key)KeyModifierMask.MaskShift | Key.K),
ShortcutContext = this,
};
_clearButton.Pressed += Clear;
hbTools.AddChild(_clearButton);
// Copy.
_copyButton = new Button
{
ThemeTypeVariation = "FlatButton",
FocusMode = FocusModeEnum.None,
Shortcut = EditorDefShortcut("editor/copy_output", "Copy Selection".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | Key.C),
ShortcutContext = this,
};
_copyButton.Pressed += CopySelectedProblems;
hbTools.AddChild(_copyButton);
// A second hbox to make a 2x2 grid of buttons.
var hbTools2 = new HBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
};
vbRight.AddChild(hbTools2);
// Toggle List/Tree.
_toggleLayoutButton = new Button
{
Flat = true,
FocusMode = FocusModeEnum.None,
TooltipText = GetToggleLayoutTooltipText(),
ToggleMode = true,
ButtonPressed = GetToggleLayoutPressedState(),
};
// Don't tint the icon even when in "pressed" state.
_toggleLayoutButton.AddThemeColorOverride("icon_pressed_color", Colors.White);
_toggleLayoutButton.Toggled += ToggleLayout;
hbTools2.AddChild(_toggleLayoutButton);
// Show Search.
_showSearchButton = new Button
{
ThemeTypeVariation = "FlatButton",
FocusMode = FocusModeEnum.None,
ToggleMode = true,
ButtonPressed = true,
Shortcut = EditorDefShortcut("editor/open_search", "Focus Search/Filter Bar".TTR(), (Key)KeyModifierMask.MaskCmdOrCtrl | Key.F),
ShortcutContext = this,
};
_showSearchButton.Toggled += ToggleSearchBoxVisibility;
hbTools2.AddChild(_showSearchButton);
// Diagnostic Type Filters.
vbRight.AddChild(new HSeparator());
var infoFilter = new BuildProblemsFilter(BuildDiagnostic.DiagnosticType.Info);
infoFilter.ToggleButton.TooltipText = "Toggle visibility of info diagnostics.".TTR();
infoFilter.ToggleButton.Toggled += ToggleFilter;
vbRight.AddChild(infoFilter.ToggleButton);
_filtersByType[BuildDiagnostic.DiagnosticType.Info] = infoFilter;
var errorFilter = new BuildProblemsFilter(BuildDiagnostic.DiagnosticType.Error);
errorFilter.ToggleButton.TooltipText = "Toggle visibility of errors.".TTR();
errorFilter.ToggleButton.Toggled += ToggleFilter;
vbRight.AddChild(errorFilter.ToggleButton);
_filtersByType[BuildDiagnostic.DiagnosticType.Error] = errorFilter;
var warningFilter = new BuildProblemsFilter(BuildDiagnostic.DiagnosticType.Warning);
warningFilter.ToggleButton.TooltipText = "Toggle visibility of warnings.".TTR();
warningFilter.ToggleButton.Toggled += ToggleFilter;
vbRight.AddChild(warningFilter.ToggleButton);
_filtersByType[BuildDiagnostic.DiagnosticType.Warning] = warningFilter;
UpdateTheme();
UpdateProblemsView();
}
public override void _Notification(int what)
{
base._Notification(what);
switch ((long)what)
{
case EditorSettings.NotificationEditorSettingsChanged:
var editorSettings = EditorInterface.Singleton.GetEditorSettings();
_layout = editorSettings.GetSetting(GodotSharpEditor.Settings.ProblemsLayout).As<ProblemsLayout>();
_toggleLayoutButton.ButtonPressed = GetToggleLayoutPressedState();
UpdateProblemsView();
break;
case NotificationThemeChanged:
UpdateTheme();
break;
}
}
private void UpdateTheme()
{
// Nodes will be null until _Ready is called.
if (_clearButton == null)
return;
foreach (var (type, filter) in _filtersByType)
{
filter.ToggleButton.Icon = type switch
{
BuildDiagnostic.DiagnosticType.Info => GetThemeIcon("Popup", "EditorIcons"),
BuildDiagnostic.DiagnosticType.Warning => GetThemeIcon("StatusWarning", "EditorIcons"),
BuildDiagnostic.DiagnosticType.Error => GetThemeIcon("StatusError", "EditorIcons"),
_ => null,
};
}
_clearButton.Icon = GetThemeIcon("Clear", "EditorIcons");
_copyButton.Icon = GetThemeIcon("ActionCopy", "EditorIcons");
_toggleLayoutButton.Icon = GetToggleLayoutIcon();
_showSearchButton.Icon = GetThemeIcon("Search", "EditorIcons");
_searchBox.RightIcon = GetThemeIcon("Search", "EditorIcons");
}
}
}

View File

@@ -0,0 +1,8 @@
namespace GodotTools.Build
{
public enum BuildResult
{
Error,
Success
}
}

View File

@@ -0,0 +1,375 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Godot;
using GodotTools.BuildLogger;
using GodotTools.Internals;
using GodotTools.Utils;
using Directory = GodotTools.Utils.Directory;
namespace GodotTools.Build
{
public static class BuildSystem
{
private static Process LaunchBuild(BuildInfo buildInfo, Action<string?>? stdOutHandler,
Action<string?>? stdErrHandler)
{
string? dotnetPath = DotNetFinder.FindDotNetExe();
if (dotnetPath == null)
throw new FileNotFoundException("Cannot find the dotnet executable.");
var editorSettings = EditorInterface.Singleton.GetEditorSettings();
var startInfo = new ProcessStartInfo(dotnetPath);
BuildArguments(buildInfo, startInfo.ArgumentList, editorSettings);
string launchMessage = startInfo.GetCommandLineDisplay(new StringBuilder("Running: ")).ToString();
stdOutHandler?.Invoke(launchMessage);
if (Godot.OS.IsStdOutVerbose())
Console.WriteLine(launchMessage);
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
startInfo.CreateNoWindow = true;
startInfo.EnvironmentVariables["DOTNET_CLI_UI_LANGUAGE"]
= ((string)editorSettings.GetSetting("interface/editor/editor_language")).Replace('_', '-');
if (OperatingSystem.IsWindows())
{
startInfo.StandardOutputEncoding = Encoding.UTF8;
startInfo.StandardErrorEncoding = Encoding.UTF8;
}
// Needed when running from Developer Command Prompt for VS
RemovePlatformVariable(startInfo.EnvironmentVariables);
var process = new Process { StartInfo = startInfo };
if (stdOutHandler != null)
process.OutputDataReceived += (_, e) => stdOutHandler.Invoke(e.Data);
if (stdErrHandler != null)
process.ErrorDataReceived += (_, e) => stdErrHandler.Invoke(e.Data);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
return process;
}
public static int Build(BuildInfo buildInfo, Action<string?>? stdOutHandler, Action<string?>? stdErrHandler)
{
using (var process = LaunchBuild(buildInfo, stdOutHandler, stdErrHandler))
{
process.WaitForExit();
return process.ExitCode;
}
}
public static async Task<int> BuildAsync(BuildInfo buildInfo, Action<string?>? stdOutHandler,
Action<string?>? stdErrHandler)
{
using (var process = LaunchBuild(buildInfo, stdOutHandler, stdErrHandler))
{
await process.WaitForExitAsync();
return process.ExitCode;
}
}
private static Process LaunchPublish(BuildInfo buildInfo, Action<string?>? stdOutHandler,
Action<string?>? stdErrHandler)
{
string? dotnetPath = DotNetFinder.FindDotNetExe();
if (dotnetPath == null)
throw new FileNotFoundException("Cannot find the dotnet executable.");
var editorSettings = EditorInterface.Singleton.GetEditorSettings();
var startInfo = new ProcessStartInfo(dotnetPath);
BuildPublishArguments(buildInfo, startInfo.ArgumentList, editorSettings);
string launchMessage = startInfo.GetCommandLineDisplay(new StringBuilder("Running: ")).ToString();
stdOutHandler?.Invoke(launchMessage);
if (Godot.OS.IsStdOutVerbose())
Console.WriteLine(launchMessage);
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
startInfo.EnvironmentVariables["DOTNET_CLI_UI_LANGUAGE"]
= ((string)editorSettings.GetSetting("interface/editor/editor_language")).Replace('_', '-');
if (OperatingSystem.IsWindows())
{
startInfo.StandardOutputEncoding = Encoding.UTF8;
startInfo.StandardErrorEncoding = Encoding.UTF8;
}
// Needed when running from Developer Command Prompt for VS
RemovePlatformVariable(startInfo.EnvironmentVariables);
var process = new Process { StartInfo = startInfo };
if (stdOutHandler != null)
process.OutputDataReceived += (_, e) => stdOutHandler.Invoke(e.Data);
if (stdErrHandler != null)
process.ErrorDataReceived += (_, e) => stdErrHandler.Invoke(e.Data);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
return process;
}
public static int Publish(BuildInfo buildInfo, Action<string?>? stdOutHandler, Action<string?>? stdErrHandler)
{
using (var process = LaunchPublish(buildInfo, stdOutHandler, stdErrHandler))
{
process.WaitForExit();
return process.ExitCode;
}
}
private static void BuildArguments(BuildInfo buildInfo, Collection<string> arguments,
EditorSettings editorSettings)
{
// `dotnet clean` / `dotnet build` commands
arguments.Add(buildInfo.OnlyClean ? "clean" : "build");
// C# Project
arguments.Add(buildInfo.Project);
// `dotnet clean` doesn't recognize these options
if (!buildInfo.OnlyClean)
{
// Restore
// `dotnet build` restores by default, unless requested not to
if (!buildInfo.Restore)
arguments.Add("--no-restore");
// Incremental or rebuild
if (buildInfo.Rebuild)
arguments.Add("--no-incremental");
}
// Configuration
arguments.Add("-c");
arguments.Add(buildInfo.Configuration);
// Verbosity
AddVerbosityArguments(buildInfo, arguments, editorSettings);
// Logger
AddLoggerArgument(buildInfo, arguments);
// Binary log
AddBinaryLogArgument(buildInfo, arguments, editorSettings);
// Custom properties
foreach (var customProperty in buildInfo.CustomProperties)
{
arguments.Add("-p:" + (string)customProperty);
}
}
private static void BuildPublishArguments(BuildInfo buildInfo, Collection<string> arguments,
EditorSettings editorSettings)
{
arguments.Add("publish"); // `dotnet publish` command
// C# Project
arguments.Add(buildInfo.Project);
// Restore
// `dotnet publish` restores by default, unless requested not to
if (!buildInfo.Restore)
arguments.Add("--no-restore");
// Incremental or rebuild
// TODO: Not supported in `dotnet publish` (https://github.com/dotnet/sdk/issues/11099)
// if (buildInfo.Rebuild)
// arguments.Add("--no-incremental");
// Configuration
arguments.Add("-c");
arguments.Add(buildInfo.Configuration);
// Runtime Identifier
arguments.Add("-r");
arguments.Add(buildInfo.RuntimeIdentifier!);
// Self-published
arguments.Add("--self-contained");
arguments.Add("true");
// Verbosity
AddVerbosityArguments(buildInfo, arguments, editorSettings);
// Logger
AddLoggerArgument(buildInfo, arguments);
// Binary log
AddBinaryLogArgument(buildInfo, arguments, editorSettings);
// Custom properties
foreach (var customProperty in buildInfo.CustomProperties)
{
arguments.Add("-p:" + (string)customProperty);
}
// Publish output directory
if (buildInfo.PublishOutputDir != null)
{
arguments.Add("-o");
arguments.Add(buildInfo.PublishOutputDir);
}
}
private static void AddVerbosityArguments(BuildInfo buildInfo, Collection<string> arguments,
EditorSettings editorSettings)
{
var verbosityLevel =
editorSettings.GetSetting(GodotSharpEditor.Settings.VerbosityLevel).As<VerbosityLevelId>();
arguments.Add("-v");
arguments.Add(verbosityLevel switch
{
VerbosityLevelId.Quiet => "quiet",
VerbosityLevelId.Minimal => "minimal",
VerbosityLevelId.Detailed => "detailed",
VerbosityLevelId.Diagnostic => "diagnostic",
_ => "normal",
});
if ((bool)editorSettings.GetSetting(GodotSharpEditor.Settings.NoConsoleLogging))
arguments.Add("-noconlog");
}
private static void AddLoggerArgument(BuildInfo buildInfo, Collection<string> arguments)
{
string buildLoggerPath = Path.Combine(Internals.GodotSharpDirs.DataEditorToolsDir,
"GodotTools.BuildLogger.dll");
arguments.Add(
$"-l:{typeof(GodotBuildLogger).FullName},{buildLoggerPath};{buildInfo.LogsDirPath}");
}
private static void AddBinaryLogArgument(BuildInfo buildInfo, Collection<string> arguments,
EditorSettings editorSettings)
{
if (!(bool)editorSettings.GetSetting(GodotSharpEditor.Settings.CreateBinaryLog))
return;
arguments.Add($"-bl:{Path.Combine(buildInfo.LogsDirPath, "msbuild.binlog")}");
arguments.Add("-ds:False"); // Honestly never understood why -bl also switches -ds on.
}
private static void RemovePlatformVariable(StringDictionary environmentVariables)
{
// EnvironmentVariables is case sensitive? Seriously?
var platformEnvironmentVariables = new List<string>();
foreach (string env in environmentVariables.Keys)
{
if (env.ToUpperInvariant() == "PLATFORM")
platformEnvironmentVariables.Add(env);
}
foreach (string env in platformEnvironmentVariables)
environmentVariables.Remove(env);
}
private static Process DoGenerateXCFramework(List<string> outputPaths, string xcFrameworkPath,
Action<string?>? stdOutHandler, Action<string?>? stdErrHandler)
{
if (Directory.Exists(xcFrameworkPath))
{
Directory.Delete(xcFrameworkPath, true);
}
var startInfo = new ProcessStartInfo("xcrun");
BuildXCFrameworkArguments(outputPaths, xcFrameworkPath, startInfo.ArgumentList);
string launchMessage = startInfo.GetCommandLineDisplay(new StringBuilder("Packaging: ")).ToString();
stdOutHandler?.Invoke(launchMessage);
if (Godot.OS.IsStdOutVerbose())
Console.WriteLine(launchMessage);
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
if (OperatingSystem.IsWindows())
{
startInfo.StandardOutputEncoding = Encoding.UTF8;
startInfo.StandardErrorEncoding = Encoding.UTF8;
}
// Needed when running from Developer Command Prompt for VS.
RemovePlatformVariable(startInfo.EnvironmentVariables);
var process = new Process { StartInfo = startInfo };
if (stdOutHandler != null)
process.OutputDataReceived += (_, e) => stdOutHandler.Invoke(e.Data);
if (stdErrHandler != null)
process.ErrorDataReceived += (_, e) => stdErrHandler.Invoke(e.Data);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
return process;
}
public static int GenerateXCFramework(List<string> outputPaths, string xcFrameworkPath, Action<string?>? stdOutHandler, Action<string?>? stdErrHandler)
{
using (var process = DoGenerateXCFramework(outputPaths, xcFrameworkPath, stdOutHandler, stdErrHandler))
{
process.WaitForExit();
return process.ExitCode;
}
}
private static void BuildXCFrameworkArguments(List<string> outputPaths,
string xcFrameworkPath, Collection<string> arguments)
{
var baseDylib = $"{GodotSharpDirs.ProjectAssemblyName}.dylib";
var baseSym = $"{GodotSharpDirs.ProjectAssemblyName}.framework.dSYM";
arguments.Add("xcodebuild");
arguments.Add("-create-xcframework");
foreach (var outputPath in outputPaths)
{
arguments.Add("-library");
arguments.Add(Path.Combine(outputPath, baseDylib));
arguments.Add("-debug-symbols");
arguments.Add(Path.Combine(outputPath, baseSym));
}
arguments.Add("-output");
arguments.Add(xcFrameworkPath);
}
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using OS = GodotTools.Utils.OS;
namespace GodotTools.Build
{
public static class DotNetFinder
{
public static string? FindDotNetExe()
{
// In the future, this method may do more than just search in PATH. We could look in
// known locations or use Godot's linked nethost to search from the hostfxr location.
if (OS.IsMacOS)
{
if (RuntimeInformation.OSArchitecture == Architecture.X64)
{
string dotnet_x64 = "/usr/local/share/dotnet/x64/dotnet"; // Look for x64 version, when running under Rosetta 2.
if (File.Exists(dotnet_x64))
{
return dotnet_x64;
}
}
string dotnet = "/usr/local/share/dotnet/dotnet"; // Look for native version.
if (File.Exists(dotnet))
{
return dotnet;
}
}
return OS.PathWhich("dotnet");
}
public static bool TryFindDotNetSdk(
Version expectedVersion,
[NotNullWhen(true)] out Version? version,
[NotNullWhen(true)] out string? path
)
{
version = null;
path = null;
string? dotNetExe = FindDotNetExe();
if (string.IsNullOrEmpty(dotNetExe))
return false;
using Process process = new Process();
process.StartInfo = new ProcessStartInfo(dotNetExe, "--list-sdks")
{
UseShellExecute = false,
RedirectStandardOutput = true
};
if (OperatingSystem.IsWindows())
{
process.StartInfo.StandardOutputEncoding = Encoding.UTF8;
}
process.StartInfo.EnvironmentVariables["DOTNET_CLI_UI_LANGUAGE"] = "en-US";
var lines = new List<string>();
process.OutputDataReceived += (_, e) =>
{
if (!string.IsNullOrWhiteSpace(e.Data))
lines.Add(e.Data);
};
try
{
process.Start();
}
catch
{
return false;
}
process.BeginOutputReadLine();
process.WaitForExit();
Version? latestVersionMatch = null;
string? matchPath = null;
foreach (var line in lines)
{
string[] sdkLineParts = line.Trim()
.Split(' ', 2, StringSplitOptions.TrimEntries);
if (sdkLineParts.Length < 2)
continue;
if (!Version.TryParse(sdkLineParts[0], out var lineVersion))
continue;
// We're looking for the exact same major version
if (lineVersion.Major != expectedVersion.Major)
continue;
if (latestVersionMatch != null && lineVersion < latestVersionMatch)
continue;
latestVersionMatch = lineVersion;
matchPath = sdkLineParts[1].TrimStart('[').TrimEnd(']');
}
if (latestVersionMatch == null)
return false;
version = latestVersionMatch;
path = Path.Combine(matchPath!, version.ToString());
return true;
}
}
}

View File

@@ -0,0 +1,308 @@
using System;
using System.IO;
using Godot;
using GodotTools.Internals;
using static GodotTools.Internals.Globals;
using File = GodotTools.Utils.File;
namespace GodotTools.Build
{
public partial class MSBuildPanel : MarginContainer, ISerializationListener
{
[Signal]
public delegate void BuildStateChangedEventHandler();
#nullable disable
private MenuButton _buildMenuButton;
private Button _openLogsFolderButton;
private BuildProblemsView _problemsView;
private BuildOutputView _outputView;
#nullable enable
public BuildInfo? LastBuildInfo { get; private set; }
public bool IsBuildingOngoing { get; private set; }
public BuildResult? BuildResult { get; private set; }
private readonly object _pendingBuildLogTextLock = new object();
private string _pendingBuildLogText = string.Empty;
public Texture2D? GetBuildStateIcon()
{
if (IsBuildingOngoing)
return GetThemeIcon("Stop", "EditorIcons");
if (_problemsView.WarningCount > 0 && _problemsView.ErrorCount > 0)
return GetThemeIcon("ErrorWarning", "EditorIcons");
if (_problemsView.WarningCount > 0)
return GetThemeIcon("Warning", "EditorIcons");
if (_problemsView.ErrorCount > 0)
return GetThemeIcon("Error", "EditorIcons");
return null;
}
private enum BuildMenuOptions
{
BuildProject,
RebuildProject,
CleanProject,
}
private void BuildMenuOptionPressed(long id)
{
switch ((BuildMenuOptions)id)
{
case BuildMenuOptions.BuildProject:
BuildProject();
break;
case BuildMenuOptions.RebuildProject:
RebuildProject();
break;
case BuildMenuOptions.CleanProject:
CleanProject();
break;
default:
throw new ArgumentOutOfRangeException(nameof(id), id, "Invalid build menu option");
}
}
public void BuildProject()
{
if (!File.Exists(GodotSharpDirs.ProjectCsProjPath))
return; // No project to build.
if (!BuildManager.BuildProjectBlocking("Debug"))
return; // Build failed.
// Notify running game for hot-reload.
Internal.EditorDebuggerNodeReloadScripts();
// Hot-reload in the editor.
GodotSharpEditor.Instance.GetNode<HotReloadAssemblyWatcher>("HotReloadAssemblyWatcher").RestartTimer();
if (Internal.IsAssembliesReloadingNeeded())
{
BuildManager.UpdateLastValidBuildDateTime();
Internal.ReloadAssemblies(softReload: false);
}
}
private void RebuildProject()
{
if (!File.Exists(GodotSharpDirs.ProjectCsProjPath))
return; // No project to build.
if (!BuildManager.BuildProjectBlocking("Debug", rebuild: true))
return; // Build failed.
// Notify running game for hot-reload.
Internal.EditorDebuggerNodeReloadScripts();
// Hot-reload in the editor.
GodotSharpEditor.Instance.GetNode<HotReloadAssemblyWatcher>("HotReloadAssemblyWatcher").RestartTimer();
if (Internal.IsAssembliesReloadingNeeded())
{
BuildManager.UpdateLastValidBuildDateTime();
Internal.ReloadAssemblies(softReload: false);
}
}
private void CleanProject()
{
if (!File.Exists(GodotSharpDirs.ProjectCsProjPath))
return; // No project to build.
_ = BuildManager.CleanProjectBlocking("Debug");
}
private void OpenLogsFolder() => OS.ShellOpen(
$"file://{GodotSharpDirs.LogsDirPathFor("Debug")}"
);
private void BuildLaunchFailed(BuildInfo buildInfo, string cause)
{
IsBuildingOngoing = false;
BuildResult = Build.BuildResult.Error;
_problemsView.Clear();
_outputView.Clear();
var diagnostic = new BuildDiagnostic
{
Type = BuildDiagnostic.DiagnosticType.Error,
Message = cause,
};
_problemsView.SetDiagnostics(new[] { diagnostic });
EmitSignal(SignalName.BuildStateChanged);
}
private void BuildStarted(BuildInfo buildInfo)
{
LastBuildInfo = buildInfo;
IsBuildingOngoing = true;
BuildResult = null;
_problemsView.Clear();
_outputView.Clear();
_problemsView.UpdateProblemsView();
EmitSignal(SignalName.BuildStateChanged);
}
private void BuildFinished(BuildResult result)
{
IsBuildingOngoing = false;
BuildResult = result;
string csvFile = Path.Combine(LastBuildInfo!.LogsDirPath, BuildManager.MsBuildIssuesFileName);
_problemsView.SetDiagnosticsFromFile(csvFile);
_problemsView.UpdateProblemsView();
EmitSignal(SignalName.BuildStateChanged);
}
private void UpdateBuildLogText()
{
lock (_pendingBuildLogTextLock)
{
_outputView.Append(_pendingBuildLogText);
_pendingBuildLogText = string.Empty;
}
}
private void StdOutputReceived(string? text)
{
lock (_pendingBuildLogTextLock)
{
if (_pendingBuildLogText.Length == 0)
CallDeferred(nameof(UpdateBuildLogText));
_pendingBuildLogText += text + "\n";
}
}
private void StdErrorReceived(string? text)
{
lock (_pendingBuildLogTextLock)
{
if (_pendingBuildLogText.Length == 0)
CallDeferred(nameof(UpdateBuildLogText));
_pendingBuildLogText += text + "\n";
}
}
public override void _Ready()
{
base._Ready();
var bottomPanelStylebox = EditorInterface.Singleton.GetBaseControl().GetThemeStylebox("BottomPanel", "EditorStyles");
AddThemeConstantOverride("margin_top", -(int)bottomPanelStylebox.ContentMarginTop);
AddThemeConstantOverride("margin_left", -(int)bottomPanelStylebox.ContentMarginLeft);
AddThemeConstantOverride("margin_right", -(int)bottomPanelStylebox.ContentMarginRight);
var tabs = new TabContainer();
AddChild(tabs);
var tabActions = new HBoxContainer
{
SizeFlagsVertical = SizeFlags.ExpandFill,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
Alignment = BoxContainer.AlignmentMode.End,
};
tabActions.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
tabs.GetTabBar().AddChild(tabActions);
_buildMenuButton = new MenuButton
{
TooltipText = "Build".TTR(),
Flat = true,
};
tabActions.AddChild(_buildMenuButton);
var buildMenu = _buildMenuButton.GetPopup();
buildMenu.AddItem("Build Project".TTR(), (int)BuildMenuOptions.BuildProject);
buildMenu.AddItem("Rebuild Project".TTR(), (int)BuildMenuOptions.RebuildProject);
buildMenu.AddItem("Clean Project".TTR(), (int)BuildMenuOptions.CleanProject);
buildMenu.IdPressed += BuildMenuOptionPressed;
_openLogsFolderButton = new Button
{
TooltipText = "Show Logs in File Manager".TTR(),
Flat = true,
};
_openLogsFolderButton.Pressed += OpenLogsFolder;
tabActions.AddChild(_openLogsFolderButton);
_problemsView = new BuildProblemsView();
tabs.AddChild(_problemsView);
_outputView = new BuildOutputView();
tabs.AddChild(_outputView);
UpdateTheme();
AddBuildEventListeners();
}
public override void _Notification(int what)
{
base._Notification(what);
if (what == NotificationThemeChanged)
{
UpdateTheme();
}
}
private void UpdateTheme()
{
// Nodes will be null until _Ready is called.
if (_buildMenuButton == null)
return;
_buildMenuButton.Icon = GetThemeIcon("BuildCSharp", "EditorIcons");
_openLogsFolderButton.Icon = GetThemeIcon("Filesystem", "EditorIcons");
}
private void AddBuildEventListeners()
{
BuildManager.BuildLaunchFailed += BuildLaunchFailed;
BuildManager.BuildStarted += BuildStarted;
BuildManager.BuildFinished += BuildFinished;
// StdOutput/Error can be received from different threads, so we need to use CallDeferred.
BuildManager.StdOutputReceived += StdOutputReceived;
BuildManager.StdErrorReceived += StdErrorReceived;
}
public void OnBeforeSerialize()
{
// In case it didn't update yet. We don't want to have to serialize any pending output.
UpdateBuildLogText();
// NOTE:
// Currently, GodotTools is loaded in its own load context. This load context is not reloaded, but the script still are.
// Until that changes, we need workarounds like this one because events keep strong references to disposed objects.
BuildManager.BuildLaunchFailed -= BuildLaunchFailed;
BuildManager.BuildStarted -= BuildStarted;
BuildManager.BuildFinished -= BuildFinished;
// StdOutput/Error can be received from different threads, so we need to use CallDeferred
BuildManager.StdOutputReceived -= StdOutputReceived;
BuildManager.StdErrorReceived -= StdErrorReceived;
}
public void OnAfterDeserialize()
{
AddBuildEventListeners(); // Re-add them.
}
}
}

View File

@@ -0,0 +1,22 @@
using Godot;
using System;
using GodotTools.ProjectEditor;
namespace GodotTools
{
public static class CsProjOperations
{
public static string GenerateGameProject(string dir, string name)
{
try
{
return ProjectGenerator.GenAndSaveGameProject(dir, name);
}
catch (Exception e)
{
GD.PushError(e.ToString());
return string.Empty;
}
}
}
}

View File

@@ -0,0 +1,563 @@
using Godot;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using GodotTools.Build;
using GodotTools.Internals;
using Directory = GodotTools.Utils.Directory;
using File = GodotTools.Utils.File;
using OS = GodotTools.Utils.OS;
using Path = System.IO.Path;
using System.Globalization;
namespace GodotTools.Export
{
public partial class ExportPlugin : EditorExportPlugin
{
public override string _GetName() => "C#";
private List<string> _tempFolders = new List<string>();
private static bool ProjectContainsDotNet()
{
return File.Exists(GodotSharpDirs.ProjectSlnPath);
}
public override string[] _GetExportFeatures(EditorExportPlatform platform, bool debug)
{
if (!ProjectContainsDotNet())
return Array.Empty<string>();
return new string[] { "dotnet" };
}
public override Godot.Collections.Array<Godot.Collections.Dictionary> _GetExportOptions(EditorExportPlatform platform)
{
var exportOptionList = new Godot.Collections.Array<Godot.Collections.Dictionary>();
if (platform.GetOsName().Equals(OS.Platforms.Android, StringComparison.OrdinalIgnoreCase))
{
exportOptionList.Add
(
new Godot.Collections.Dictionary()
{
{
"option", new Godot.Collections.Dictionary()
{
{ "name", "dotnet/android_use_linux_bionic" },
{ "type", (int)Variant.Type.Bool }
}
},
{ "default_value", false }
}
);
}
exportOptionList.Add
(
new Godot.Collections.Dictionary()
{
{
"option", new Godot.Collections.Dictionary()
{
{ "name", "dotnet/include_scripts_content" },
{ "type", (int)Variant.Type.Bool }
}
},
{ "default_value", false }
}
);
exportOptionList.Add
(
new Godot.Collections.Dictionary()
{
{
"option", new Godot.Collections.Dictionary()
{
{ "name", "dotnet/include_debug_symbols" },
{ "type", (int)Variant.Type.Bool }
}
},
{ "default_value", true }
}
);
exportOptionList.Add
(
new Godot.Collections.Dictionary()
{
{
"option", new Godot.Collections.Dictionary()
{
{ "name", "dotnet/embed_build_outputs" },
{ "type", (int)Variant.Type.Bool }
}
},
{ "default_value", false }
}
);
return exportOptionList;
}
private void AddExceptionMessage(EditorExportPlatform platform, Exception exception)
{
string? exceptionMessage = exception.Message;
if (string.IsNullOrEmpty(exceptionMessage))
{
exceptionMessage = $"Exception thrown: {exception.GetType().Name}";
}
platform.AddMessage(EditorExportPlatform.ExportMessageType.Error, "Export .NET Project", exceptionMessage);
// We also print exceptions as we receive them to stderr.
Console.Error.WriteLine(exception);
}
// With this method we can override how a file is exported in the PCK
public override void _ExportFile(string path, string type, string[] features)
{
base._ExportFile(path, type, features);
if (type != Internal.CSharpLanguageType)
return;
if (Path.GetExtension(path) != Internal.CSharpLanguageExtension)
throw new ArgumentException(
$"Resource of type {Internal.CSharpLanguageType} has an invalid file extension: {path}",
nameof(path));
if (!ProjectContainsDotNet())
{
GetExportPlatform().AddMessage(EditorExportPlatform.ExportMessageType.Error, "Export .NET Project", $"This project contains C# files but no solution file was found at the following path: {GodotSharpDirs.ProjectSlnPath}\n" +
"A solution file is required for projects with C# files. Please ensure that the solution file exists in the specified location and try again.");
throw new InvalidOperationException($"{path} is a C# file but no solution file exists.");
}
// TODO: What if the source file is not part of the game's C# project?
bool includeScriptsContent = (bool)GetOption("dotnet/include_scripts_content");
if (!includeScriptsContent)
{
// We don't want to include the source code on exported games.
// Sadly, Godot prints errors when adding an empty file (nothing goes wrong, it's just noise).
// Because of this, we add a file which contains a line break.
AddFile(path, System.Text.Encoding.UTF8.GetBytes("\n"), remap: false);
// Tell the Godot exporter that we already took care of the file.
Skip();
}
}
public override void _ExportBegin(string[] features, bool isDebug, string path, uint flags)
{
base._ExportBegin(features, isDebug, path, flags);
try
{
_ExportBeginImpl(features, isDebug, path, flags);
}
catch (Exception e)
{
AddExceptionMessage(GetExportPlatform(), e);
}
}
private void _ExportBeginImpl(string[] features, bool isDebug, string path, long flags)
{
_ = flags; // Unused.
if (!ProjectContainsDotNet())
return;
string osName = GetExportPlatform().GetOsName();
if (!TryDeterminePlatformFromOSName(osName, out string? platform))
throw new NotSupportedException("Target platform not supported.");
if (!new[] { OS.Platforms.Windows, OS.Platforms.LinuxBSD, OS.Platforms.MacOS, OS.Platforms.Android, OS.Platforms.iOS }
.Contains(platform))
{
throw new NotImplementedException("Target platform not yet implemented.");
}
bool useAndroidLinuxBionic = (bool)GetOption("dotnet/android_use_linux_bionic");
PublishConfig publishConfig = new()
{
BuildConfig = isDebug ? "ExportDebug" : "ExportRelease",
IncludeDebugSymbols = (bool)GetOption("dotnet/include_debug_symbols"),
RidOS = DetermineRuntimeIdentifierOS(platform, useAndroidLinuxBionic),
Archs = [],
UseTempDir = platform != OS.Platforms.iOS, // xcode project links directly to files in the publish dir, so use one that sticks around.
BundleOutputs = true,
};
if (features.Contains("x86_64"))
{
publishConfig.Archs.Add("x86_64");
}
if (features.Contains("x86_32"))
{
publishConfig.Archs.Add("x86_32");
}
if (features.Contains("arm64"))
{
publishConfig.Archs.Add("arm64");
}
if (features.Contains("arm32"))
{
publishConfig.Archs.Add("arm32");
}
if (features.Contains("universal"))
{
if (platform == OS.Platforms.MacOS)
{
publishConfig.Archs.Add("x86_64");
publishConfig.Archs.Add("arm64");
}
}
var targets = new List<PublishConfig> { publishConfig };
if (platform == OS.Platforms.iOS)
{
targets.Add(new PublishConfig
{
BuildConfig = publishConfig.BuildConfig,
Archs = ["arm64", "x86_64"],
BundleOutputs = false,
IncludeDebugSymbols = publishConfig.IncludeDebugSymbols,
RidOS = OS.DotNetOS.iOSSimulator,
UseTempDir = false,
});
}
List<string> outputPaths = new();
bool embedBuildResults = ((bool)GetOption("dotnet/embed_build_outputs") || platform == OS.Platforms.Android) && platform != OS.Platforms.MacOS;
var exportedJars = new HashSet<string>();
foreach (PublishConfig config in targets)
{
string ridOS = config.RidOS;
string buildConfig = config.BuildConfig;
bool includeDebugSymbols = config.IncludeDebugSymbols;
foreach (string arch in config.Archs)
{
string ridArch = DetermineRuntimeIdentifierArch(arch);
string runtimeIdentifier = $"{ridOS}-{ridArch}";
string projectDataDirName = $"data_{GodotSharpDirs.CSharpProjectName}_{platform}_{arch}";
if (platform == OS.Platforms.MacOS)
{
projectDataDirName = Path.Combine("Contents", "Resources", projectDataDirName);
}
// Create temporary publish output directory.
string publishOutputDir;
if (config.UseTempDir)
{
publishOutputDir = Path.Combine(Path.GetTempPath(), "godot-publish-dotnet",
$"{System.Environment.ProcessId}-{buildConfig}-{runtimeIdentifier}");
_tempFolders.Add(publishOutputDir);
}
else
{
publishOutputDir = Path.Combine(GodotSharpDirs.ProjectBaseOutputPath, "godot-publish-dotnet",
$"{buildConfig}-{runtimeIdentifier}");
}
outputPaths.Add(publishOutputDir);
if (!Directory.Exists(publishOutputDir))
Directory.CreateDirectory(publishOutputDir);
// Execute dotnet publish.
if (!BuildManager.PublishProjectBlocking(buildConfig, platform,
runtimeIdentifier, publishOutputDir, includeDebugSymbols))
{
throw new InvalidOperationException("Failed to build project. Check MSBuild panel for details.");
}
string soExt = ridOS switch
{
OS.DotNetOS.Win or OS.DotNetOS.Win10 => "dll",
OS.DotNetOS.OSX or OS.DotNetOS.iOS or OS.DotNetOS.iOSSimulator => "dylib",
_ => "so"
};
string assemblyPath = Path.Combine(publishOutputDir, $"{GodotSharpDirs.ProjectAssemblyName}.dll");
string nativeAotPath = Path.Combine(publishOutputDir,
$"{GodotSharpDirs.ProjectAssemblyName}.{soExt}");
if (!File.Exists(assemblyPath) && !File.Exists(nativeAotPath))
{
throw new NotSupportedException(
$"Publish succeeded but project assembly not found at '{assemblyPath}' or '{nativeAotPath}'.");
}
// For ios simulator builds, skip packaging the build outputs.
if (!config.BundleOutputs)
continue;
var manifest = new StringBuilder();
// Add to the exported project shared object list or packed resources.
RecursePublishContents(publishOutputDir,
filterDir: dir =>
{
if (platform == OS.Platforms.iOS)
{
// Exclude dsym folders.
return !dir.EndsWith(".dsym", StringComparison.OrdinalIgnoreCase);
}
return true;
},
filterFile: file =>
{
if (platform == OS.Platforms.iOS)
{
// Exclude the dylib artifact, since it's included separately as an xcframework.
return Path.GetFileName(file) != $"{GodotSharpDirs.ProjectAssemblyName}.dylib";
}
return true;
},
recurseDir: dir =>
{
if (platform == OS.Platforms.iOS)
{
// Don't recurse into dsym folders.
return !dir.EndsWith(".dsym", StringComparison.OrdinalIgnoreCase);
}
return true;
},
addEntry: (path, isFile) =>
{
// We get called back for both directories and files, but we only package files for now.
if (isFile)
{
if (embedBuildResults)
{
if (platform == OS.Platforms.Android)
{
string fileName = Path.GetFileName(path);
if (IsSharedObject(fileName))
{
if (fileName.EndsWith(".so") && !fileName.StartsWith("lib"))
{
// Add 'lib' prefix required for all native libraries in Android.
string newPath = string.Concat(path.AsSpan(0, path.Length - fileName.Length), "lib", fileName);
Godot.DirAccess.RenameAbsolute(path, newPath);
path = newPath;
}
AddSharedObject(path, tags: new string[] { arch },
Path.Join(projectDataDirName,
Path.GetRelativePath(publishOutputDir,
Path.GetDirectoryName(path)!)));
return;
}
bool IsSharedObject(string fileName)
{
if (fileName.EndsWith(".jar"))
{
// Don't export the same jar twice. Otherwise we will have conflicts.
// This can happen when exporting for multiple architectures. Dotnet
// stores the jars in .godot/mono/temp/bin/Export[Debug|Release] per
// target architecture. Jars are cpu agnostic so only 1 is needed.
var jarName = Path.GetFileName(fileName);
return exportedJars.Add(jarName);
}
if (fileName.EndsWith(".so") || fileName.EndsWith(".a") || fileName.EndsWith(".dex"))
{
return true;
}
return false;
}
}
string filePath = SanitizeSlashes(Path.GetRelativePath(publishOutputDir, path));
byte[] fileData = File.ReadAllBytes(path);
string hash = Convert.ToBase64String(SHA512.HashData(fileData));
manifest.Append(CultureInfo.InvariantCulture, $"{filePath}\t{hash}\n");
AddFile($"res://.godot/mono/publish/{arch}/{filePath}", fileData, false);
}
else
{
if (platform == OS.Platforms.iOS && path.EndsWith(".dat", StringComparison.OrdinalIgnoreCase))
{
AddAppleEmbeddedPlatformBundleFile(path);
}
else
{
AddSharedObject(path, tags: null,
Path.Join(projectDataDirName,
Path.GetRelativePath(publishOutputDir,
Path.GetDirectoryName(path)!)));
}
}
}
});
if (embedBuildResults)
{
byte[] fileData = Encoding.Default.GetBytes(manifest.ToString());
AddFile($"res://.godot/mono/publish/{arch}/.dotnet-publish-manifest", fileData, false);
}
}
}
if (platform == OS.Platforms.iOS)
{
if (outputPaths.Count > 2)
{
// lipo the simulator binaries together
string outputPath = Path.Combine(outputPaths[1], $"{GodotSharpDirs.ProjectAssemblyName}.dylib");
string[] files = outputPaths
.Skip(1)
.Select(path => Path.Combine(path, $"{GodotSharpDirs.ProjectAssemblyName}.dylib"))
.ToArray();
if (!Internal.LipOCreateFile(outputPath, files))
{
throw new InvalidOperationException($"Failed to 'lipo' simulator binaries.");
}
outputPaths.RemoveRange(2, outputPaths.Count - 2);
}
string xcFrameworkPath = Path.Combine(GodotSharpDirs.ProjectBaseOutputPath, publishConfig.BuildConfig, $"{GodotSharpDirs.ProjectAssemblyName}_aot.xcframework");
if (!BuildManager.GenerateXCFrameworkBlocking(outputPaths, xcFrameworkPath))
{
throw new InvalidOperationException("Failed to generate xcframework.");
}
AddAppleEmbeddedPlatformEmbeddedFramework(xcFrameworkPath);
}
}
private static void RecursePublishContents(string path, Func<string, bool> filterDir,
Func<string, bool> filterFile, Func<string, bool> recurseDir,
Action<string, bool> addEntry)
{
foreach (string file in Directory.GetFiles(path, "*", SearchOption.TopDirectoryOnly))
{
if (filterFile(file))
{
addEntry(file, true);
}
}
foreach (string dir in Directory.GetDirectories(path, "*", SearchOption.TopDirectoryOnly))
{
if (filterDir(dir))
{
addEntry(dir, false);
if (recurseDir(dir))
{
RecursePublishContents(dir, filterDir, filterFile, recurseDir, addEntry);
}
}
}
}
private string SanitizeSlashes(string path)
{
if (Path.DirectorySeparatorChar == '\\')
return path.Replace('\\', '/');
return path;
}
private string DetermineRuntimeIdentifierOS(string platform, bool useAndroidLinuxBionic)
{
if (platform == OS.Platforms.Android && useAndroidLinuxBionic)
{
return OS.DotNetOS.LinuxBionic;
}
return OS.DotNetOSPlatformMap[platform];
}
private string DetermineRuntimeIdentifierArch(string arch)
{
return arch switch
{
"x86" => "x86",
"x86_32" => "x86",
"x64" => "x64",
"x86_64" => "x64",
"armeabi-v7a" => "arm",
"arm64-v8a" => "arm64",
"arm32" => "arm",
"arm64" => "arm64",
_ => throw new ArgumentOutOfRangeException(nameof(arch), arch, "Unexpected architecture")
};
}
public override void _ExportEnd()
{
base._ExportEnd();
string aotTempDir = Path.Combine(Path.GetTempPath(), $"godot-aot-{System.Environment.ProcessId}");
if (Directory.Exists(aotTempDir))
Directory.Delete(aotTempDir, recursive: true);
foreach (string folder in _tempFolders)
{
Directory.Delete(folder, recursive: true);
}
_tempFolders.Clear();
}
/// <summary>
/// Tries to determine the platform from the export preset's platform OS name.
/// </summary>
/// <param name="osName">Name of the export operating system.</param>
/// <param name="platform">Platform name for the recognized supported platform.</param>
/// <returns>
/// <see langword="true"/> when the platform OS name is recognized as a supported platform,
/// <see langword="false"/> otherwise.
/// </returns>
private static bool TryDeterminePlatformFromOSName(string osName, [NotNullWhen(true)] out string? platform)
{
if (OS.PlatformFeatureMap.TryGetValue(osName, out platform))
{
return true;
}
platform = null;
return false;
}
private struct PublishConfig
{
public bool UseTempDir;
public bool BundleOutputs;
public string RidOS;
public HashSet<string> Archs;
public string BuildConfig;
public bool IncludeDebugSymbols;
}
}
}

View File

@@ -0,0 +1,14 @@
namespace GodotTools
{
public enum ExternalEditorId : long
{
None,
VisualStudio, // TODO (Windows-only)
VisualStudioForMac, // Mac-only
MonoDevelop,
VsCode,
Rider,
CustomEditor,
Fleet,
}
}

View File

@@ -0,0 +1,760 @@
using Godot;
using GodotTools.Core;
using GodotTools.Export;
using GodotTools.Utils;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using GodotTools.Build;
using GodotTools.Ides;
using GodotTools.Ides.Rider;
using GodotTools.Inspector;
using GodotTools.Internals;
using GodotTools.ProjectEditor;
using JetBrains.Annotations;
using static GodotTools.Internals.Globals;
using Environment = System.Environment;
using File = GodotTools.Utils.File;
using OS = GodotTools.Utils.OS;
using Path = System.IO.Path;
namespace GodotTools
{
public partial class GodotSharpEditor : EditorPlugin, ISerializationListener
{
public static class Settings
{
public const string ExternalEditor = "dotnet/editor/external_editor";
public const string CustomExecPath = "dotnet/editor/custom_exec_path";
public const string CustomExecPathArgs = "dotnet/editor/custom_exec_path_args";
public const string VerbosityLevel = "dotnet/build/verbosity_level";
public const string NoConsoleLogging = "dotnet/build/no_console_logging";
public const string CreateBinaryLog = "dotnet/build/create_binary_log";
public const string ProblemsLayout = "dotnet/build/problems_layout";
}
#nullable disable
private EditorSettings _editorSettings;
private PopupMenu _menuPopup;
private AcceptDialog _errorDialog;
private ConfirmationDialog _confirmCreateSlnDialog;
private Button _bottomPanelBtn;
private Button _toolBarBuildButton;
// TODO Use WeakReference once we have proper serialization.
private WeakRef _exportPluginWeak;
private WeakRef _inspectorPluginWeak;
public GodotIdeManager GodotIdeManager { get; private set; }
public MSBuildPanel MSBuildPanel { get; private set; }
#nullable enable
public bool SkipBuildBeforePlaying { get; set; } = false;
[UsedImplicitly]
private bool CreateProjectSolutionIfNeeded()
{
if (!File.Exists(GodotSharpDirs.ProjectSlnPath) || !File.Exists(GodotSharpDirs.ProjectCsProjPath))
{
return CreateProjectSolution();
}
return true;
}
private bool CreateProjectSolution()
{
string? errorMessage = null;
using (var pr = new EditorProgress("create_csharp_solution", "Generating solution...".TTR(), 2))
{
pr.Step("Generating C# project...".TTR());
string csprojDir = Path.GetDirectoryName(GodotSharpDirs.ProjectCsProjPath)!;
string slnDir = Path.GetDirectoryName(GodotSharpDirs.ProjectSlnPath)!;
string name = GodotSharpDirs.ProjectAssemblyName;
string guid = CsProjOperations.GenerateGameProject(csprojDir, name);
if (guid.Length > 0)
{
var solution = new DotNetSolution(name, slnDir);
var projectInfo = new DotNetSolution.ProjectInfo(guid,
Path.GetRelativePath(slnDir, GodotSharpDirs.ProjectCsProjPath),
new List<string> { "Debug", "ExportDebug", "ExportRelease" });
solution.AddNewProject(name, projectInfo);
try
{
solution.Save();
}
catch (IOException e)
{
errorMessage = "Failed to save solution. Exception message: ".TTR() + e.Message;
}
}
else
{
errorMessage = "Failed to create C# project.".TTR();
}
}
if (!string.IsNullOrEmpty(errorMessage))
{
ShowErrorDialog(errorMessage);
return false;
}
_ShowDotnetFeatures();
return true;
}
private void _ShowDotnetFeatures()
{
_bottomPanelBtn.Show();
_toolBarBuildButton.Show();
}
private void _MenuOptionPressed(long id)
{
switch ((MenuOptions)id)
{
case MenuOptions.CreateSln:
{
if (File.Exists(GodotSharpDirs.ProjectSlnPath) || File.Exists(GodotSharpDirs.ProjectCsProjPath))
{
ShowConfirmCreateSlnDialog();
}
else
{
CreateProjectSolution();
}
break;
}
default:
throw new ArgumentOutOfRangeException(nameof(id), id, "Invalid menu option");
}
}
private void BuildProjectPressed()
{
if (!File.Exists(GodotSharpDirs.ProjectCsProjPath))
{
if (!CreateProjectSolution())
return; // Failed to create project.
}
Instance.MSBuildPanel.BuildProject();
}
private enum MenuOptions
{
CreateSln,
}
public void ShowErrorDialog(string message, string title = "Error")
{
_errorDialog.Title = title;
_errorDialog.DialogText = message;
EditorInterface.Singleton.PopupDialogCentered(_errorDialog);
}
public void ShowConfirmCreateSlnDialog()
{
_confirmCreateSlnDialog.Title = "Create C# solution".TTR();
_confirmCreateSlnDialog.DialogText = "C# solution already exists. This will override the existing C# project file, any manual changes will be lost.".TTR();
EditorInterface.Singleton.PopupDialogCentered(_confirmCreateSlnDialog);
}
private static string _vsCodePath = string.Empty;
private static readonly string[] VsCodeNames =
{
"code", "code-oss", "vscode", "vscode-oss", "visual-studio-code", "visual-studio-code-oss", "codium"
};
[UsedImplicitly]
public Error OpenInExternalEditor(Script script, int line, int col)
{
var editorId = _editorSettings.GetSetting(Settings.ExternalEditor).As<ExternalEditorId>();
switch (editorId)
{
case ExternalEditorId.None:
// Not an error. Tells the caller to fallback to the global external editor settings or the built-in editor.
return Error.Unavailable;
case ExternalEditorId.CustomEditor:
{
string file = ProjectSettings.GlobalizePath(script.ResourcePath);
string project = ProjectSettings.GlobalizePath("res://");
// Since ProjectSettings.GlobalizePath replaces only "res:/", leaving a trailing slash, it is removed here.
project = project[..^1];
var execCommand = _editorSettings.GetSetting(Settings.CustomExecPath).As<string>();
var execArgs = _editorSettings.GetSetting(Settings.CustomExecPathArgs).As<string>();
var args = new List<string>();
var from = 0;
var numChars = 0;
var insideQuotes = false;
var hasFileFlag = false;
execArgs = execArgs.ReplaceN("{line}", line.ToString(CultureInfo.InvariantCulture));
execArgs = execArgs.ReplaceN("{col}", col.ToString(CultureInfo.InvariantCulture));
execArgs = execArgs.StripEdges(true, true);
execArgs = execArgs.Replace("\\\\", "\\", StringComparison.Ordinal);
for (int i = 0; i < execArgs.Length; ++i)
{
if ((execArgs[i] == '"' && (i == 0 || execArgs[i - 1] != '\\')) && i != execArgs.Length - 1)
{
if (!insideQuotes)
{
from++;
}
insideQuotes = !insideQuotes;
}
else if ((execArgs[i] == ' ' && !insideQuotes) || i == execArgs.Length - 1)
{
if (i == execArgs.Length - 1 && !insideQuotes)
{
numChars++;
}
var arg = execArgs.Substr(from, numChars);
if (arg.Contains("{file}", StringComparison.OrdinalIgnoreCase))
{
hasFileFlag = true;
}
arg = arg.ReplaceN("{project}", project);
arg = arg.ReplaceN("{file}", file);
args.Add(arg);
from = i + 1;
numChars = 0;
}
else
{
numChars++;
}
}
if (!hasFileFlag)
{
args.Add(file);
}
OS.RunProcess(execCommand, args);
break;
}
case ExternalEditorId.VisualStudio:
{
string scriptPath = ProjectSettings.GlobalizePath(script.ResourcePath);
var args = new List<string>
{
Path.Combine(GodotSharpDirs.DataEditorToolsDir, "GodotTools.OpenVisualStudio.dll"),
GodotSharpDirs.ProjectSlnPath,
line >= 0 ? $"{scriptPath};{line + 1};{col + 1}" : scriptPath
};
string command = DotNetFinder.FindDotNetExe() ?? "dotnet";
try
{
if (Godot.OS.IsStdOutVerbose())
Console.WriteLine(
$"Running: \"{command}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}");
OS.RunProcess(command, args);
}
catch (Exception e)
{
GD.PushError(
$"Error when trying to run code editor: VisualStudio. Exception message: '{e.Message}'");
}
break;
}
case ExternalEditorId.VisualStudioForMac:
goto case ExternalEditorId.MonoDevelop;
case ExternalEditorId.Rider:
case ExternalEditorId.Fleet:
{
string scriptPath = ProjectSettings.GlobalizePath(script.ResourcePath);
RiderPathManager.OpenFile(editorId, GodotSharpDirs.ProjectSlnPath, scriptPath, line + 1, col);
return Error.Ok;
}
case ExternalEditorId.MonoDevelop:
{
string scriptPath = ProjectSettings.GlobalizePath(script.ResourcePath);
GodotIdeManager.LaunchIdeAsync().ContinueWith(launchTask =>
{
var editorPick = launchTask.Result;
if (line >= 0)
editorPick?.SendOpenFile(scriptPath, line + 1, col);
else
editorPick?.SendOpenFile(scriptPath);
});
break;
}
case ExternalEditorId.VsCode:
{
if (string.IsNullOrEmpty(_vsCodePath) || !File.Exists(_vsCodePath))
{
// Try to search it again if it wasn't found last time or if it was removed from its location
_vsCodePath = VsCodeNames.SelectFirstNotNull(OS.PathWhich, orElse: string.Empty);
}
var args = new List<string>();
bool macOSAppBundleInstalled = false;
if (OS.IsMacOS)
{
// The package path is '/Applications/Visual Studio Code.app'
const string vscodeBundleId = "com.microsoft.VSCode";
macOSAppBundleInstalled = Internal.IsMacOSAppBundleInstalled(vscodeBundleId);
if (macOSAppBundleInstalled)
{
args.Add("-b");
args.Add(vscodeBundleId);
// The reusing of existing windows made by the 'open' command might not choose a window that is
// editing our folder. It's better to ask for a new window and let VSCode do the window management.
args.Add("-n");
// The open process must wait until the application finishes (which is instant in VSCode's case)
args.Add("--wait-apps");
args.Add("--args");
}
// Try VSCodium as a fallback if Visual Studio Code can't be found.
if (!macOSAppBundleInstalled)
{
const string VscodiumBundleId = "com.vscodium.codium";
macOSAppBundleInstalled = Internal.IsMacOSAppBundleInstalled(VscodiumBundleId);
if (macOSAppBundleInstalled)
{
args.Add("-b");
args.Add(VscodiumBundleId);
// The reusing of existing windows made by the 'open' command might not choose a window that is
// editing our folder. It's better to ask for a new window and let VSCode do the window management.
args.Add("-n");
// The open process must wait until the application finishes (which is instant in VSCode's case)
args.Add("--wait-apps");
args.Add("--args");
}
}
}
args.Add(Path.GetDirectoryName(GodotSharpDirs.ProjectSlnPath)!);
string scriptPath = ProjectSettings.GlobalizePath(script.ResourcePath);
if (line >= 0)
{
args.Add("-g");
args.Add($"{scriptPath}:{line + 1}:{col + 1}");
}
else
{
args.Add(scriptPath);
}
string command;
if (OS.IsMacOS)
{
if (!macOSAppBundleInstalled && string.IsNullOrEmpty(_vsCodePath))
{
GD.PushError("Cannot find code editor: Visual Studio Code or VSCodium");
return Error.FileNotFound;
}
command = macOSAppBundleInstalled ? "/usr/bin/open" : _vsCodePath;
}
else
{
if (string.IsNullOrEmpty(_vsCodePath))
{
GD.PushError("Cannot find code editor: Visual Studio Code or VSCodium");
return Error.FileNotFound;
}
command = _vsCodePath;
}
try
{
OS.RunProcess(command, args);
}
catch (Exception e)
{
GD.PushError($"Error when trying to run code editor: Visual Studio Code or VSCodium. Exception message: '{e.Message}'");
}
break;
}
default:
throw new ArgumentOutOfRangeException();
}
return Error.Ok;
}
[UsedImplicitly]
public bool OverridesExternalEditor()
{
return _editorSettings.GetSetting(Settings.ExternalEditor).As<ExternalEditorId>() != ExternalEditorId.None;
}
public override bool _Build()
{
return BuildManager.EditorBuildCallback();
}
private void ApplyNecessaryChangesToSolution()
{
try
{
// Migrate solution from old configuration names to: Debug, ExportDebug and ExportRelease
DotNetSolution.MigrateFromOldConfigNames(GodotSharpDirs.ProjectSlnPath);
var msbuildProject = ProjectUtils.Open(GodotSharpDirs.ProjectCsProjPath)
?? throw new InvalidOperationException("Cannot open C# project.");
ProjectUtils.UpgradeProjectIfNeeded(msbuildProject, GodotSharpDirs.ProjectAssemblyName);
if (msbuildProject.HasUnsavedChanges)
{
// Save a copy of the project before replacing it
FileUtils.SaveBackupCopy(GodotSharpDirs.ProjectCsProjPath);
msbuildProject.Save();
}
}
catch (Exception e)
{
GD.PushError(e.ToString());
}
}
private void BuildStateChanged()
{
if (_bottomPanelBtn != null)
_bottomPanelBtn.Icon = MSBuildPanel.GetBuildStateIcon();
}
public override void _EnablePlugin()
{
base._EnablePlugin();
ProjectSettings.SettingsChanged += GodotSharpDirs.DetermineProjectLocation;
if (Instance != null)
throw new InvalidOperationException();
Instance = this;
var dotNetSdkSearchVersion = Environment.Version;
// First we try to find the .NET Sdk ourselves to make sure we get the
// correct version first, otherwise pick the latest.
if (DotNetFinder.TryFindDotNetSdk(dotNetSdkSearchVersion, out var sdkVersion, out string? sdkPath))
{
if (Godot.OS.IsStdOutVerbose())
Console.WriteLine($"Found .NET Sdk version '{sdkVersion}': {sdkPath}");
ProjectUtils.MSBuildLocatorRegisterMSBuildPath(sdkPath);
}
else
{
try
{
ProjectUtils.MSBuildLocatorRegisterLatest(out sdkVersion, out sdkPath);
if (Godot.OS.IsStdOutVerbose())
Console.WriteLine($"Found .NET Sdk version '{sdkVersion}': {sdkPath}");
}
catch (InvalidOperationException e)
{
if (Godot.OS.IsStdOutVerbose())
GD.PrintErr(e.ToString());
GD.PushError($".NET Sdk not found. The required version is '{dotNetSdkSearchVersion}'.");
}
}
var editorBaseControl = EditorInterface.Singleton.GetBaseControl();
_editorSettings = EditorInterface.Singleton.GetEditorSettings();
_errorDialog = new AcceptDialog();
_errorDialog.SetUnparentWhenInvisible(true);
_confirmCreateSlnDialog = new ConfirmationDialog();
_confirmCreateSlnDialog.SetUnparentWhenInvisible(true);
_confirmCreateSlnDialog.Confirmed += () => CreateProjectSolution();
MSBuildPanel = new MSBuildPanel();
MSBuildPanel.BuildStateChanged += BuildStateChanged;
_bottomPanelBtn = AddControlToBottomPanel(MSBuildPanel, "MSBuild".TTR());
AddChild(new HotReloadAssemblyWatcher { Name = "HotReloadAssemblyWatcher" });
_menuPopup = new PopupMenu
{
Name = "CSharpTools",
};
_menuPopup.Hide();
AddToolSubmenuItem("C#", _menuPopup);
_toolBarBuildButton = new Button
{
Flat = false,
Icon = EditorInterface.Singleton.GetEditorTheme().GetIcon("BuildCSharp", "EditorIcons"),
FocusMode = Control.FocusModeEnum.None,
Shortcut = EditorDefShortcut("mono/build_solution", "Build Project".TTR(), (Key)KeyModifierMask.MaskAlt | Key.B),
ShortcutInTooltip = true,
ThemeTypeVariation = "RunBarButton",
};
EditorShortcutOverride("mono/build_solution", "macos", (Key)KeyModifierMask.MaskMeta | (Key)KeyModifierMask.MaskCtrl | Key.B);
_toolBarBuildButton.Pressed += BuildProjectPressed;
Internal.EditorPlugin_AddControlToEditorRunBar(_toolBarBuildButton);
// Move Build button so it appears to the left of the Play button.
_toolBarBuildButton.GetParent().MoveChild(_toolBarBuildButton, 0);
if (File.Exists(GodotSharpDirs.ProjectCsProjPath))
{
ApplyNecessaryChangesToSolution();
}
else
{
_bottomPanelBtn.Hide();
_toolBarBuildButton.Hide();
}
_menuPopup.AddItem("Create C# solution".TTR(), (int)MenuOptions.CreateSln);
_menuPopup.IdPressed += _MenuOptionPressed;
// External editor settings
EditorDef(Settings.ExternalEditor, Variant.From(ExternalEditorId.None));
EditorDef(Settings.CustomExecPath, "");
EditorDef(Settings.CustomExecPathArgs, "");
EditorDef(Settings.VerbosityLevel, Variant.From(VerbosityLevelId.Normal));
EditorDef(Settings.NoConsoleLogging, false);
EditorDef(Settings.CreateBinaryLog, false);
EditorDef(Settings.ProblemsLayout, Variant.From(BuildProblemsView.ProblemsLayout.Tree));
string settingsHintStr = "Disabled";
if (OS.IsWindows)
{
settingsHintStr += $",Visual Studio:{(int)ExternalEditorId.VisualStudio}" +
$",MonoDevelop:{(int)ExternalEditorId.MonoDevelop}" +
$",Visual Studio Code and VSCodium:{(int)ExternalEditorId.VsCode}" +
$",JetBrains Rider:{(int)ExternalEditorId.Rider}" +
$",JetBrains Fleet:{(int)ExternalEditorId.Fleet}" +
$",Custom:{(int)ExternalEditorId.CustomEditor}";
}
else if (OS.IsMacOS)
{
settingsHintStr += $",Visual Studio:{(int)ExternalEditorId.VisualStudioForMac}" +
$",MonoDevelop:{(int)ExternalEditorId.MonoDevelop}" +
$",Visual Studio Code and VSCodium:{(int)ExternalEditorId.VsCode}" +
$",JetBrains Rider:{(int)ExternalEditorId.Rider}" +
$",JetBrains Fleet:{(int)ExternalEditorId.Fleet}" +
$",Custom:{(int)ExternalEditorId.CustomEditor}";
}
else if (OS.IsUnixLike)
{
settingsHintStr += $",MonoDevelop:{(int)ExternalEditorId.MonoDevelop}" +
$",Visual Studio Code and VSCodium:{(int)ExternalEditorId.VsCode}" +
$",JetBrains Rider:{(int)ExternalEditorId.Rider}" +
$",JetBrains Fleet:{(int)ExternalEditorId.Fleet}" +
$",Custom:{(int)ExternalEditorId.CustomEditor}";
}
_editorSettings.AddPropertyInfo(new Godot.Collections.Dictionary
{
["type"] = (int)Variant.Type.Int,
["name"] = Settings.ExternalEditor,
["hint"] = (int)PropertyHint.Enum,
["hint_string"] = settingsHintStr
});
_editorSettings.AddPropertyInfo(new Godot.Collections.Dictionary
{
["type"] = (int)Variant.Type.String,
["name"] = Settings.CustomExecPath,
["hint"] = (int)PropertyHint.GlobalFile,
});
_editorSettings.AddPropertyInfo(new Godot.Collections.Dictionary
{
["type"] = (int)Variant.Type.String,
["name"] = Settings.CustomExecPathArgs,
});
_editorSettings.SetInitialValue(Settings.CustomExecPathArgs, "{file}", false);
var verbosityLevels = Enum.GetValues<VerbosityLevelId>().Select(level => $"{Enum.GetName(level)}:{(int)level}");
_editorSettings.AddPropertyInfo(new Godot.Collections.Dictionary
{
["type"] = (int)Variant.Type.Int,
["name"] = Settings.VerbosityLevel,
["hint"] = (int)PropertyHint.Enum,
["hint_string"] = string.Join(",", verbosityLevels),
});
_editorSettings.AddPropertyInfo(new Godot.Collections.Dictionary
{
["type"] = (int)Variant.Type.Int,
["name"] = Settings.ProblemsLayout,
["hint"] = (int)PropertyHint.Enum,
["hint_string"] = "View as List,View as Tree",
});
OnSettingsChanged();
_editorSettings.SettingsChanged += OnSettingsChanged;
// Export plugin
var exportPlugin = new ExportPlugin();
AddExportPlugin(exportPlugin);
_exportPluginWeak = WeakRef(exportPlugin);
// Inspector plugin
var inspectorPlugin = new InspectorPlugin();
AddInspectorPlugin(inspectorPlugin);
_inspectorPluginWeak = WeakRef(inspectorPlugin);
BuildManager.Initialize();
GodotIdeManager = new GodotIdeManager();
AddChild(GodotIdeManager);
}
public override void _DisablePlugin()
{
base._DisablePlugin();
_editorSettings.SettingsChanged -= OnSettingsChanged;
// Custom signals aren't automatically disconnected currently.
MSBuildPanel.BuildStateChanged -= BuildStateChanged;
}
public override void _ExitTree()
{
_errorDialog?.QueueFree();
_confirmCreateSlnDialog?.QueueFree();
}
private void OnSettingsChanged()
{
var changedSettings = _editorSettings.GetChangedSettings();
if (changedSettings.Contains(Settings.VerbosityLevel))
{
// We want to force NoConsoleLogging to true when the VerbosityLevel is at Detailed or above.
// At that point, there's so much info logged that it doesn't make sense to display it in
// the tiny editor window, and it'd make the editor hang or crash anyway.
var verbosityLevel = _editorSettings.GetSetting(Settings.VerbosityLevel).As<VerbosityLevelId>();
var hideConsoleLog = (bool)_editorSettings.GetSetting(Settings.NoConsoleLogging);
if (verbosityLevel >= VerbosityLevelId.Detailed && !hideConsoleLog)
_editorSettings.SetSetting(Settings.NoConsoleLogging, Variant.From(true));
}
if (changedSettings.Contains(Settings.ExternalEditor) && !changedSettings.Contains(RiderPathManager.EditorPathSettingName))
{
var editor = _editorSettings.GetSetting(Settings.ExternalEditor).As<ExternalEditorId>();
if (editor != ExternalEditorId.Fleet && editor != ExternalEditorId.Rider)
{
return;
}
RiderPathManager.InitializeIfNeeded(editor);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
if (IsInstanceValid(_exportPluginWeak))
{
// We need to dispose our export plugin before the editor destroys EditorSettings.
// Otherwise, if the GC disposes it at a later time, EditorExportPlatformAndroid
// will be freed after EditorSettings already was, and its device polling thread
// will try to access the EditorSettings singleton, resulting in null dereferencing.
(_exportPluginWeak.GetRef().AsGodotObject() as ExportPlugin)?.Dispose();
_exportPluginWeak.Dispose();
}
if (IsInstanceValid(_inspectorPluginWeak))
{
(_inspectorPluginWeak.GetRef().AsGodotObject() as InspectorPlugin)?.Dispose();
_inspectorPluginWeak.Dispose();
}
GodotIdeManager?.Dispose();
}
base.Dispose(disposing);
}
public void OnBeforeSerialize()
{
}
public void OnAfterDeserialize()
{
Instance = this;
}
// Singleton
#nullable disable
public static GodotSharpEditor Instance { get; private set; }
#nullable enable
[UsedImplicitly]
private static IntPtr InternalCreateInstance(IntPtr unmanagedCallbacks, int unmanagedCallbacksSize)
{
Internal.Initialize(unmanagedCallbacks, unmanagedCallbacksSize);
var populateConstructorMethod =
AppDomain.CurrentDomain
.GetAssemblies()
.First(x => x.GetName().Name == "GodotSharpEditor")
.GetType("Godot.EditorConstructors")?
.GetMethod("AddEditorConstructors",
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
if (populateConstructorMethod == null)
{
throw new MissingMethodException("Godot.EditorConstructors",
"AddEditorConstructors");
}
populateConstructorMethod.Invoke(null, null);
return new GodotSharpEditor().NativeInstance;
}
}
}

View File

@@ -0,0 +1,62 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ProjectGuid>{27B00618-A6F2-4828-B922-05CAEB08C286}</ProjectGuid>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
<EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable>
<!-- The Godot editor uses the Debug Godot API assemblies -->
<GodotApiConfiguration>Debug</GodotApiConfiguration>
<GodotSourceRootPath>$(SolutionDir)/../../../../</GodotSourceRootPath>
<GodotOutputDataDir>$(GodotSourceRootPath)/bin/GodotSharp</GodotOutputDataDir>
<GodotApiAssembliesDir>$(GodotOutputDataDir)/Api/$(GodotApiConfiguration)</GodotApiAssembliesDir>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<!-- Needed for our source generators to work despite this not being a Godot game project -->
<PropertyGroup>
<IsGodotToolsProject>true</IsGodotToolsProject>
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Include="IsGodotToolsProject" />
</ItemGroup>
<PropertyGroup Condition=" Exists('$(GodotApiAssembliesDir)/GodotSharp.dll') ">
<!-- The project is part of the Godot source tree -->
<!-- Use the Godot source tree output folder instead of '$(ProjectDir)/bin' -->
<OutputPath>$(GodotOutputDataDir)/Tools</OutputPath>
<!-- Must not append '$(TargetFramework)' to the output path in this case -->
<AppendTargetFrameworkToOutputPath>False</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2019.1.3.0" ExcludeAssets="runtime" PrivateAssets="all" />
<PackageReference Include="JetBrains.Rider.PathLocator" Version="1.0.12" />
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0" PrivateAssets="All" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<Reference Include="GodotSharp">
<HintPath>$(GodotApiAssembliesDir)/GodotSharp.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="GodotSharpEditor">
<HintPath>$(GodotApiAssembliesDir)/GodotSharpEditor.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Godot.NET.Sdk\Godot.SourceGenerators\Godot.SourceGenerators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\..\glue\GodotSharp\Godot.SourceGenerators.Internal\Godot.SourceGenerators.Internal.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GodotTools.BuildLogger\GodotTools.BuildLogger.csproj" />
<ProjectReference Include="..\GodotTools.IdeMessaging\GodotTools.IdeMessaging.csproj" />
<ProjectReference Include="..\GodotTools.ProjectEditor\GodotTools.ProjectEditor.csproj" />
<ProjectReference Include="..\GodotTools.Core\GodotTools.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,58 @@
using Godot;
using GodotTools.Build;
using GodotTools.Internals;
using JetBrains.Annotations;
namespace GodotTools
{
public partial class HotReloadAssemblyWatcher : Node
{
#nullable disable
private Timer _watchTimer;
#nullable enable
public override void _Notification(int what)
{
if (what == Node.NotificationWMWindowFocusIn)
{
RestartTimer();
if (Internal.IsAssembliesReloadingNeeded())
{
BuildManager.UpdateLastValidBuildDateTime();
Internal.ReloadAssemblies(softReload: false);
}
}
}
private void TimerTimeout()
{
if (Internal.IsAssembliesReloadingNeeded())
{
BuildManager.UpdateLastValidBuildDateTime();
Internal.ReloadAssemblies(softReload: false);
}
}
[UsedImplicitly]
public void RestartTimer()
{
_watchTimer.Stop();
_watchTimer.Start();
}
public override void _Ready()
{
base._Ready();
_watchTimer = new Timer
{
OneShot = false,
WaitTime = 0.5f
};
_watchTimer.Timeout += TimerTimeout;
AddChild(_watchTimer);
_watchTimer.Start();
}
}
}

View File

@@ -0,0 +1,236 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Godot;
using GodotTools.IdeMessaging;
using GodotTools.IdeMessaging.Requests;
using GodotTools.Internals;
namespace GodotTools.Ides
{
public sealed partial class GodotIdeManager : Node, ISerializationListener
{
private MessagingServer? _messagingServer;
private MonoDevelop.Instance? _monoDevelInstance;
private MonoDevelop.Instance? _vsForMacInstance;
private MessagingServer GetRunningOrNewServer()
{
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()
{
_ = GetRunningOrNewServer();
}
public void OnBeforeSerialize()
{
}
public void OnAfterDeserialize()
{
_ = GetRunningOrNewServer();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_messagingServer?.Dispose();
}
}
private string GetExternalEditorIdentity(ExternalEditorId editorId)
{
// Manually convert to string to avoid breaking compatibility in case we rename the enum fields.
switch (editorId)
{
case ExternalEditorId.None:
return string.Empty;
case ExternalEditorId.VisualStudio:
return "VisualStudio";
case ExternalEditorId.VsCode:
return "VisualStudioCode";
case ExternalEditorId.Rider:
return "Rider";
case ExternalEditorId.Fleet:
return "Fleet";
case ExternalEditorId.VisualStudioForMac:
return "VisualStudioForMac";
case ExternalEditorId.MonoDevelop:
return "MonoDevelop";
case ExternalEditorId.CustomEditor:
return "CustomEditor";
default:
throw new NotImplementedException();
}
}
public async Task<EditorPick?> LaunchIdeAsync(int millisecondsTimeout = 10000)
{
var editorSettings = EditorInterface.Singleton.GetEditorSettings();
var editorId = editorSettings.GetSetting(GodotSharpEditor.Settings.ExternalEditor).As<ExternalEditorId>();
string editorIdentity = GetExternalEditorIdentity(editorId);
var runningServer = GetRunningOrNewServer();
if (runningServer.IsAnyConnected(editorIdentity))
return new EditorPick(editorIdentity);
LaunchIde(editorId, editorIdentity);
var timeoutTask = Task.Delay(millisecondsTimeout);
var completedTask = await Task.WhenAny(timeoutTask, runningServer.AwaitClientConnected(editorIdentity));
if (completedTask != timeoutTask)
return new EditorPick(editorIdentity);
return null;
}
private void LaunchIde(ExternalEditorId editorId, string editorIdentity)
{
switch (editorId)
{
case ExternalEditorId.None:
case ExternalEditorId.VisualStudio:
case ExternalEditorId.VsCode:
case ExternalEditorId.Rider:
case ExternalEditorId.Fleet:
case ExternalEditorId.CustomEditor:
throw new NotSupportedException();
case ExternalEditorId.VisualStudioForMac:
goto case ExternalEditorId.MonoDevelop;
case ExternalEditorId.MonoDevelop:
{
MonoDevelop.Instance GetMonoDevelopInstance(string solutionPath)
{
if (Utils.OS.IsMacOS && editorId == ExternalEditorId.VisualStudioForMac)
{
_vsForMacInstance = (_vsForMacInstance?.IsDisposed ?? true ? null : _vsForMacInstance) ??
new MonoDevelop.Instance(solutionPath, MonoDevelop.EditorId.VisualStudioForMac);
return _vsForMacInstance;
}
_monoDevelInstance = (_monoDevelInstance?.IsDisposed ?? true ? null : _monoDevelInstance) ??
new MonoDevelop.Instance(solutionPath, MonoDevelop.EditorId.MonoDevelop);
return _monoDevelInstance;
}
try
{
var instance = GetMonoDevelopInstance(GodotSharpDirs.ProjectSlnPath);
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 = editorId == ExternalEditorId.VisualStudioForMac ? "Visual Studio" : "MonoDevelop";
GD.PushError($"Cannot find code editor: {editorName}");
}
break;
}
default:
throw new ArgumentOutOfRangeException(nameof(editorId));
}
}
public readonly struct EditorPick
{
private readonly string _identity;
public EditorPick(string identity)
{
_identity = identity;
}
public bool IsAnyConnected() =>
GodotSharpEditor.Instance.GodotIdeManager.GetRunningOrNewServer().IsAnyConnected(_identity);
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)
{
if (OS.IsStdOutVerbose())
Console.WriteLine(message);
}
public void LogInfo(string message)
{
if (OS.IsStdOutVerbose())
Console.WriteLine(message);
}
public void LogWarning(string message)
{
GD.PushWarning(message);
}
public void LogError(string message)
{
GD.PushError(message);
}
public void LogError(string message, Exception e)
{
GD.PushError(message + "\n" + e);
}
}
}
}

View File

@@ -0,0 +1,399 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
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 GodotTools.Utils;
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;
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 ?? 0;
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, [NotNullWhen(true)] 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!);
},
[StopPlayRequest.Id] = async (peer, content) =>
{
var request = JsonConvert.DeserializeObject<StopPlayRequest>(content.Body);
return await HandleStopPlay(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(() =>
{
// TODO: Add BuildBeforePlaying flag to PlayRequest
// Run the game
Internal.EditorRunPlay();
});
return Task.FromResult<Response>(new PlayResponse());
}
private static Task<Response> HandleDebugPlay(DebugPlayRequest request)
{
DispatchToMainThread(() =>
{
// Tell the build callback whether the editor already built the solution or not
GodotSharpEditor.Instance.SkipBuildBeforePlaying = !(request.BuildBeforePlaying ?? true);
// Pass the debugger agent settings to the player via an environment variables
// TODO: It would be better if this was an argument in EditorRunPlay instead
Environment.SetEnvironmentVariable("GODOT_MONO_DEBUGGER_AGENT",
"--debugger-agent=transport=dt_socket" +
$",address={request.DebuggerHost}:{request.DebuggerPort}" +
",server=n");
// Run the game
Internal.EditorRunPlay();
// Restore normal settings
Environment.SetEnvironmentVariable("GODOT_MONO_DEBUGGER_AGENT", "");
GodotSharpEditor.Instance.SkipBuildBeforePlaying = false;
});
return Task.FromResult<Response>(new DebugPlayResponse());
}
private static Task<Response> HandleStopPlay(StopPlayRequest request)
{
DispatchToMainThread(Internal.EditorRunStop);
return Task.FromResult<Response>(new StopPlayResponse());
}
private static Task<Response> HandleReloadScripts()
{
DispatchToMainThread(Internal.ScriptEditorDebugger_ReloadScripts);
return Task.FromResult<Response>(new ReloadScriptsResponse());
}
private static async Task<Response> HandleCodeCompletionRequest(CodeCompletionRequest request)
{
// This is needed if the "resource path" part of the path is case insensitive.
// However, it doesn't fix resource loading if the rest of the path is also case insensitive.
string? scriptFileLocalized = FsPathUtils.LocalizePathWithCaseChecked(request.ScriptFile);
// The node API can only be called from the main thread.
await Godot.Engine.GetMainLoop().ToSignal(Godot.Engine.GetMainLoop(), "process_frame");
var response = new CodeCompletionResponse { Kind = request.Kind, ScriptFile = request.ScriptFile };
response.Suggestions = Internal.CodeCompletionRequest(response.Kind,
scriptFileLocalized ?? request.ScriptFile);
return response;
}
}
}
}

View File

@@ -0,0 +1,8 @@
namespace GodotTools.Ides.MonoDevelop
{
public enum EditorId
{
MonoDevelop = 0,
VisualStudioForMac = 1
}
}

View File

@@ -0,0 +1,143 @@
using System;
using System.IO;
using System.Collections.Generic;
using System.Diagnostics;
using GodotTools.Internals;
using GodotTools.Utils;
namespace GodotTools.Ides.MonoDevelop
{
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()
{
bool newWindow = _process == null || _process.HasExited;
var args = new List<string>();
string? command;
if (OS.IsMacOS)
{
string bundleId = BundleIds[_editorId];
if (Internal.IsMacOSAppBundleInstalled(bundleId))
{
command = "open";
args.Add("-b");
args.Add(bundleId);
// The 'open' process must wait until the application finishes
if (newWindow)
args.Add("--wait-apps");
args.Add("--args");
}
else
{
command = OS.PathWhich(ExecutableNames[_editorId]);
}
}
else
{
command = OS.PathWhich(ExecutableNames[_editorId]);
}
args.Add("--ipc-tcp");
if (newWindow)
args.Add("\"" + Path.GetFullPath(_solutionFile) + "\"");
if (command == null)
throw new FileNotFoundException();
LaunchTime = DateTime.Now;
if (newWindow)
{
_process = Process.Start(new ProcessStartInfo
{
FileName = command,
Arguments = string.Join(" ", args),
UseShellExecute = true
});
}
else
{
Process.Start(new ProcessStartInfo
{
FileName = command,
Arguments = string.Join(" ", args),
UseShellExecute = true
})?.Dispose();
}
}
public Instance(string solutionFile, EditorId editorId)
{
if (editorId == EditorId.VisualStudioForMac && !OS.IsMacOS)
throw new InvalidOperationException($"{nameof(EditorId.VisualStudioForMac)} not supported on this platform");
_solutionFile = solutionFile;
_editorId = editorId;
}
public void Dispose()
{
IsDisposed = true;
_process?.Dispose();
}
private static readonly IReadOnlyDictionary<EditorId, string> ExecutableNames;
private static readonly IReadOnlyDictionary<EditorId, string> BundleIds;
static Instance()
{
if (OS.IsMacOS)
{
ExecutableNames = new Dictionary<EditorId, string>
{
// Rely on PATH
{EditorId.MonoDevelop, "monodevelop"},
{EditorId.VisualStudioForMac, "VisualStudio"}
};
BundleIds = new Dictionary<EditorId, string>
{
// TODO EditorId.MonoDevelop
{EditorId.VisualStudioForMac, "com.microsoft.visual-studio"}
};
}
else if (OS.IsWindows)
{
ExecutableNames = new Dictionary<EditorId, string>
{
// XamarinStudio is no longer a thing, and the latest version is quite old
// MonoDevelop is available from source only on Windows. The recommendation
// is to use Visual Studio instead. Since there are no official builds, we
// will rely on custom MonoDevelop builds being added to PATH.
{EditorId.MonoDevelop, "MonoDevelop.exe"}
};
}
else if (OS.IsUnixLike)
{
ExecutableNames = new Dictionary<EditorId, string>
{
// Rely on PATH
{EditorId.MonoDevelop, "monodevelop"}
};
}
ExecutableNames ??= new Dictionary<EditorId, string>();
BundleIds ??= new Dictionary<EditorId, string>();
}
}
}

View File

@@ -0,0 +1,56 @@
using System;
using Godot;
using JetBrains.Rider.PathLocator;
using Newtonsoft.Json;
using OS = GodotTools.Utils.OS;
namespace GodotTools.Ides.Rider;
public class RiderLocatorEnvironment : IRiderLocatorEnvironment
{
public JetBrains.Rider.PathLocator.OS CurrentOS
{
get
{
if (OS.IsWindows)
return JetBrains.Rider.PathLocator.OS.Windows;
if (OS.IsMacOS) return JetBrains.Rider.PathLocator.OS.MacOSX;
if (OS.IsUnixLike) return JetBrains.Rider.PathLocator.OS.Linux;
return JetBrains.Rider.PathLocator.OS.Other;
}
}
public T? FromJson<T>(string json)
{
return JsonConvert.DeserializeObject<T>(json);
}
public void Info(string message, Exception? e = null)
{
if (e == null)
GD.Print(message);
else
GD.Print(message, e);
}
public void Warn(string message, Exception? e = null)
{
if (e == null)
GD.PushWarning(message);
else
GD.PushWarning(message, e);
}
public void Error(string message, Exception? e = null)
{
if (e == null)
GD.PushError(message);
else
GD.PushError(message, e);
}
public void Verbose(string message, Exception? e = null)
{
// do nothing, since IDK how to write only to the log, without spamming the output
}
}

View File

@@ -0,0 +1,120 @@
using System;
using System.IO;
using System.Linq;
using Godot;
using GodotTools.Internals;
using JetBrains.Rider.PathLocator;
namespace GodotTools.Ides.Rider
{
public static class RiderPathManager
{
internal const string EditorPathSettingName = "dotnet/editor/editor_path_optional";
private static readonly RiderPathLocator RiderPathLocator;
private static readonly RiderFileOpener RiderFileOpener;
static RiderPathManager()
{
var riderLocatorEnvironment = new RiderLocatorEnvironment();
RiderPathLocator = new RiderPathLocator(riderLocatorEnvironment);
RiderFileOpener = new RiderFileOpener(riderLocatorEnvironment);
}
private static string? GetRiderPathFromSettings()
{
var editorSettings = EditorInterface.Singleton.GetEditorSettings();
if (editorSettings.HasSetting(EditorPathSettingName))
{
return (string)editorSettings.GetSetting(EditorPathSettingName);
}
return null;
}
public static void InitializeIfNeeded(ExternalEditorId editor)
{
var editorSettings = EditorInterface.Singleton.GetEditorSettings();
if (!editorSettings.HasSetting(EditorPathSettingName))
{
Globals.EditorDef(EditorPathSettingName, "");
editorSettings.AddPropertyInfo(new Godot.Collections.Dictionary
{
["type"] = (int)Variant.Type.String,
["name"] = EditorPathSettingName,
["hint"] = (int)PropertyHint.File,
["hint_string"] = ""
});
}
var editorPath = (string)editorSettings.GetSetting(EditorPathSettingName);
if (File.Exists(editorPath) && IsMatch(editor, editorPath))
{
Globals.EditorDef(EditorPathSettingName, editorPath);
return;
}
var paths = RiderPathLocator.GetAllRiderPaths().Where(info => IsMatch(editor, info.Path)).ToArray();
if (paths.Length == 0)
{
return;
}
string newPath = paths.Last().Path;
Globals.EditorDef(EditorPathSettingName, newPath);
editorSettings.SetSetting(EditorPathSettingName, newPath);
}
private static bool IsMatch(ExternalEditorId editorId, string path)
{
if (path.IndexOfAny(Path.GetInvalidPathChars()) != -1)
{
return false;
}
var fileInfo = new FileInfo(path);
var name = editorId == ExternalEditorId.Fleet ? "fleet" : "rider";
return fileInfo.Name.StartsWith(name, StringComparison.OrdinalIgnoreCase);
}
private static string? CheckAndUpdatePath(ExternalEditorId editorId, string? idePath)
{
if (File.Exists(idePath))
{
return idePath;
}
var allInfos = RiderPathLocator.GetAllRiderPaths();
if (allInfos.Length == 0)
{
return null;
}
// RiderPathLocator includes Rider and Fleet locations.
var matchingIde = allInfos.LastOrDefault(info => IsMatch(editorId, info.Path));
var newPath = matchingIde.Path;
if (string.IsNullOrEmpty(newPath))
{
return null;
}
var editorSettings = EditorInterface.Singleton.GetEditorSettings();
editorSettings.SetSetting(EditorPathSettingName, newPath);
Globals.EditorDef(EditorPathSettingName, newPath);
return newPath;
}
public static void OpenFile(ExternalEditorId editorId, string slnPath, string scriptPath, int line, int column)
{
var pathFromSettings = GetRiderPathFromSettings();
var path = CheckAndUpdatePath(editorId, pathFromSettings);
if (string.IsNullOrEmpty(path))
{
GD.PushError($"Error when trying to run code editor: JetBrains Rider or Fleet. Could not find path to the editor.");
return;
}
RiderFileOpener.OpenFile(path, slnPath, scriptPath, line, column);
}
}
}

View File

@@ -0,0 +1,37 @@
using Godot;
using GodotTools.Internals;
namespace GodotTools.Inspector
{
public partial class InspectorOutOfSyncWarning : HBoxContainer
{
public override void _Ready()
{
SetAnchorsPreset(LayoutPreset.TopWide);
var iconTexture = GetThemeIcon("StatusWarning", "EditorIcons");
var icon = new TextureRect()
{
Texture = iconTexture,
ExpandMode = TextureRect.ExpandModeEnum.FitWidthProportional,
CustomMinimumSize = iconTexture.GetSize(),
};
icon.SizeFlagsVertical = SizeFlags.ShrinkCenter;
var label = new Label()
{
Text = "This inspector might be out of date. Please build the C# project.".TTR(),
AutowrapMode = TextServer.AutowrapMode.WordSmart,
CustomMinimumSize = new Vector2(100f, 0f),
};
label.AddThemeColorOverride("font_color", GetThemeColor("warning_color", "Editor"));
label.SizeFlagsHorizontal = SizeFlags.Fill | SizeFlags.Expand;
AddChild(icon);
AddChild(label);
}
}
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using Godot;
using GodotTools.Build;
using GodotTools.Utils;
namespace GodotTools.Inspector
{
public partial class InspectorPlugin : EditorInspectorPlugin
{
public override bool _CanHandle(GodotObject godotObject)
{
if (godotObject == null)
{
return false;
}
foreach (var script in EnumerateScripts(godotObject))
{
if (script is CSharpScript)
{
return true;
}
}
return false;
}
public override void _ParseBegin(GodotObject godotObject)
{
foreach (var script in EnumerateScripts(godotObject))
{
if (script is not CSharpScript)
continue;
string scriptPath = script.ResourcePath;
if (string.IsNullOrEmpty(scriptPath))
{
// Generic types used empty paths in older versions of Godot
// so we assume your project is out of sync.
AddCustomControl(new InspectorOutOfSyncWarning());
break;
}
if (scriptPath.StartsWith("csharp://"))
{
// This is a virtual path used by generic types, extract the real path.
var scriptPathSpan = scriptPath.AsSpan("csharp://".Length);
scriptPathSpan = scriptPathSpan[..scriptPathSpan.IndexOf(':')];
scriptPath = $"res://{scriptPathSpan}";
}
if (File.GetLastWriteTime(scriptPath) > BuildManager.LastValidBuildDateTime)
{
AddCustomControl(new InspectorOutOfSyncWarning());
break;
}
}
}
private static IEnumerable<Script> EnumerateScripts(GodotObject godotObject)
{
var script = godotObject.GetScript().As<Script>();
while (script != null)
{
yield return script;
script = script.GetBaseScript();
}
}
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Runtime.CompilerServices;
using Godot;
using Godot.NativeInterop;
namespace GodotTools.Internals
{
public class EditorProgress : IDisposable
{
public string Task { get; }
public EditorProgress(string task, string label, int amount, bool canCancel = false)
{
Task = task;
using godot_string taskIn = Marshaling.ConvertStringToNative(task);
using godot_string labelIn = Marshaling.ConvertStringToNative(label);
Internal.godot_icall_EditorProgress_Create(taskIn, labelIn, amount, canCancel);
}
~EditorProgress()
{
// Should never rely on the GC to dispose EditorProgress.
// It should be disposed immediately when the task finishes.
GD.PushError("EditorProgress disposed by the Garbage Collector");
Dispose();
}
public void Dispose()
{
using godot_string taskIn = Marshaling.ConvertStringToNative(Task);
Internal.godot_icall_EditorProgress_Dispose(taskIn);
GC.SuppressFinalize(this);
}
public void Step(string state, int step = -1, bool forceRefresh = true)
{
using godot_string taskIn = Marshaling.ConvertStringToNative(Task);
using godot_string stateIn = Marshaling.ConvertStringToNative(state);
Internal.godot_icall_EditorProgress_Step(taskIn, stateIn, step, forceRefresh);
}
public bool TryStep(string state, int step = -1, bool forceRefresh = true)
{
using godot_string taskIn = Marshaling.ConvertStringToNative(Task);
using godot_string stateIn = Marshaling.ConvertStringToNative(state);
return Internal.godot_icall_EditorProgress_Step(taskIn, stateIn, step, forceRefresh);
}
}
}

View File

@@ -0,0 +1,63 @@
using Godot;
using Godot.NativeInterop;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
namespace GodotTools.Internals
{
public static class Globals
{
public static float EditorScale => Internal.godot_icall_Globals_EditorScale();
// ReSharper disable once UnusedMethodReturnValue.Global
public static Variant GlobalDef(string setting, Variant defaultValue, bool restartIfChanged = false)
{
using godot_string settingIn = Marshaling.ConvertStringToNative(setting);
using godot_variant defaultValueIn = defaultValue.CopyNativeVariant();
Internal.godot_icall_Globals_GlobalDef(settingIn, defaultValueIn, restartIfChanged,
out godot_variant result);
return Variant.CreateTakingOwnershipOfDisposableValue(result);
}
// ReSharper disable once UnusedMethodReturnValue.Global
public static Variant EditorDef(string setting, Variant defaultValue, bool restartIfChanged = false)
{
using godot_string settingIn = Marshaling.ConvertStringToNative(setting);
using godot_variant defaultValueIn = defaultValue.CopyNativeVariant();
Internal.godot_icall_Globals_EditorDef(settingIn, defaultValueIn, restartIfChanged,
out godot_variant result);
return Variant.CreateTakingOwnershipOfDisposableValue(result);
}
public static Shortcut EditorDefShortcut(string setting, string name, Key keycode = Key.None, bool physical = false)
{
using godot_string settingIn = Marshaling.ConvertStringToNative(setting);
using godot_string nameIn = Marshaling.ConvertStringToNative(name);
Internal.godot_icall_Globals_EditorDefShortcut(settingIn, nameIn, keycode, physical.ToGodotBool(), out godot_variant result);
return (Shortcut)Variant.CreateTakingOwnershipOfDisposableValue(result);
}
public static Shortcut EditorGetShortcut(string setting)
{
using godot_string settingIn = Marshaling.ConvertStringToNative(setting);
Internal.godot_icall_Globals_EditorGetShortcut(settingIn, out godot_variant result);
return (Shortcut)Variant.CreateTakingOwnershipOfDisposableValue(result);
}
public static void EditorShortcutOverride(string setting, string feature, Key keycode = Key.None, bool physical = false)
{
using godot_string settingIn = Marshaling.ConvertStringToNative(setting);
using godot_string featureIn = Marshaling.ConvertStringToNative(feature);
Internal.godot_icall_Globals_EditorShortcutOverride(settingIn, featureIn, keycode, physical.ToGodotBool());
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static string TTR(this string text)
{
using godot_string textIn = Marshaling.ConvertStringToNative(text);
Internal.godot_icall_Globals_TTR(textIn, out godot_string dest);
using (dest)
return Marshaling.ConvertStringToManaged(dest);
}
}
}

View File

@@ -0,0 +1,139 @@
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Godot;
using Godot.NativeInterop;
using GodotTools.Core;
using static GodotTools.Internals.Globals;
namespace GodotTools.Internals
{
public static class GodotSharpDirs
{
public static string ResMetadataDir
{
get
{
Internal.godot_icall_GodotSharpDirs_ResMetadataDir(out godot_string dest);
using (dest)
return Marshaling.ConvertStringToManaged(dest);
}
}
public static string MonoUserDir
{
get
{
Internal.godot_icall_GodotSharpDirs_MonoUserDir(out godot_string dest);
using (dest)
return Marshaling.ConvertStringToManaged(dest);
}
}
public static string BuildLogsDirs
{
get
{
Internal.godot_icall_GodotSharpDirs_BuildLogsDirs(out godot_string dest);
using (dest)
return Marshaling.ConvertStringToManaged(dest);
}
}
public static string DataEditorToolsDir
{
get
{
Internal.godot_icall_GodotSharpDirs_DataEditorToolsDir(out godot_string dest);
using (dest)
return Marshaling.ConvertStringToManaged(dest);
}
}
public static string CSharpProjectName
{
get
{
Internal.godot_icall_GodotSharpDirs_CSharpProjectName(out godot_string dest);
using (dest)
return Marshaling.ConvertStringToManaged(dest);
}
}
[MemberNotNull("_projectAssemblyName", "_projectSlnPath", "_projectCsProjPath")]
public static void DetermineProjectLocation()
{
_projectAssemblyName = (string?)ProjectSettings.GetSetting("dotnet/project/assembly_name");
if (string.IsNullOrEmpty(_projectAssemblyName))
{
_projectAssemblyName = CSharpProjectName;
ProjectSettings.SetSetting("dotnet/project/assembly_name", _projectAssemblyName);
}
string? slnParentDir = (string?)ProjectSettings.GetSetting("dotnet/project/solution_directory");
if (string.IsNullOrEmpty(slnParentDir))
slnParentDir = "res://";
else if (!slnParentDir.StartsWith("res://", System.StringComparison.Ordinal))
slnParentDir = "res://" + slnParentDir;
// The csproj should be in the same folder as project.godot.
string csprojParentDir = "res://";
_projectSlnPath = Path.Combine(ProjectSettings.GlobalizePath(slnParentDir),
string.Concat(_projectAssemblyName, ".sln"));
_projectCsProjPath = Path.Combine(ProjectSettings.GlobalizePath(csprojParentDir),
string.Concat(_projectAssemblyName, ".csproj"));
}
private static string? _projectAssemblyName;
private static string? _projectSlnPath;
private static string? _projectCsProjPath;
public static string ProjectAssemblyName
{
get
{
if (_projectAssemblyName == null)
DetermineProjectLocation();
return _projectAssemblyName;
}
}
public static string ProjectSlnPath
{
get
{
if (_projectSlnPath == null)
DetermineProjectLocation();
return _projectSlnPath;
}
}
public static string ProjectCsProjPath
{
get
{
if (_projectCsProjPath == null)
DetermineProjectLocation();
return _projectCsProjPath;
}
}
public static string ProjectBaseOutputPath
{
get
{
if (_projectCsProjPath == null)
DetermineProjectLocation();
return Path.Combine(Path.GetDirectoryName(_projectCsProjPath)!, ".godot", "mono", "temp", "bin");
}
}
public static string LogsDirPathFor(string solution, string configuration)
=> Path.Combine(BuildLogsDirs, $"{solution.Md5Text()}_{configuration}");
public static string LogsDirPathFor(string configuration)
=> LogsDirPathFor(ProjectSlnPath, configuration);
}
}

View File

@@ -0,0 +1,186 @@
#pragma warning disable IDE1006 // Naming rule violation
// ReSharper disable InconsistentNaming
using System;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Godot;
using Godot.NativeInterop;
using Godot.SourceGenerators.Internal;
using GodotTools.IdeMessaging.Requests;
namespace GodotTools.Internals
{
[GenerateUnmanagedCallbacks(typeof(InternalUnmanagedCallbacks))]
internal static partial class Internal
{
public const string CSharpLanguageType = "CSharpScript";
public const string CSharpLanguageExtension = ".cs";
public static string FullExportTemplatesDir
{
get
{
godot_icall_Internal_FullExportTemplatesDir(out godot_string dest);
using (dest)
return Marshaling.ConvertStringToManaged(dest);
}
}
public static string SimplifyGodotPath(this string path) => Godot.StringExtensions.SimplifyPath(path);
public static bool IsMacOSAppBundleInstalled(string bundleId)
{
using godot_string bundleIdIn = Marshaling.ConvertStringToNative(bundleId);
return godot_icall_Internal_IsMacOSAppBundleInstalled(bundleIdIn);
}
public static bool LipOCreateFile(string outputPath, string[] files)
{
using godot_string outputPathIn = Marshaling.ConvertStringToNative(outputPath);
using godot_packed_string_array filesIn = Marshaling.ConvertSystemArrayToNativePackedStringArray(files);
return godot_icall_Internal_LipOCreateFile(outputPathIn, filesIn);
}
public static bool GodotIs32Bits() => godot_icall_Internal_GodotIs32Bits();
public static bool GodotIsRealTDouble() => godot_icall_Internal_GodotIsRealTDouble();
public static void GodotMainIteration() => godot_icall_Internal_GodotMainIteration();
public static bool IsAssembliesReloadingNeeded() => godot_icall_Internal_IsAssembliesReloadingNeeded();
public static void ReloadAssemblies(bool softReload) => godot_icall_Internal_ReloadAssemblies(softReload);
public static void EditorDebuggerNodeReloadScripts() => godot_icall_Internal_EditorDebuggerNodeReloadScripts();
public static bool ScriptEditorEdit(Resource resource, int line, int col, bool grabFocus = true) =>
godot_icall_Internal_ScriptEditorEdit(resource.NativeInstance, line, col, grabFocus);
public static void EditorNodeShowScriptScreen() => godot_icall_Internal_EditorNodeShowScriptScreen();
public static void EditorRunPlay() => godot_icall_Internal_EditorRunPlay();
public static void EditorRunStop() => godot_icall_Internal_EditorRunStop();
public static void EditorPlugin_AddControlToEditorRunBar(Control control) =>
godot_icall_Internal_EditorPlugin_AddControlToEditorRunBar(control.NativeInstance);
public static void ScriptEditorDebugger_ReloadScripts() =>
godot_icall_Internal_ScriptEditorDebugger_ReloadScripts();
public static string[] CodeCompletionRequest(CodeCompletionRequest.CompletionKind kind,
string scriptFile)
{
using godot_string scriptFileIn = Marshaling.ConvertStringToNative(scriptFile);
godot_icall_Internal_CodeCompletionRequest((int)kind, scriptFileIn, out godot_packed_string_array res);
using (res)
return Marshaling.ConvertNativePackedStringArrayToSystemArray(res);
}
#region Internal
private static bool initialized = false;
// ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Global
internal static unsafe void Initialize(IntPtr unmanagedCallbacks, int unmanagedCallbacksSize)
{
if (initialized)
throw new InvalidOperationException("Already initialized.");
initialized = true;
if (unmanagedCallbacksSize != sizeof(InternalUnmanagedCallbacks))
throw new ArgumentException("Unmanaged callbacks size mismatch.", nameof(unmanagedCallbacksSize));
_unmanagedCallbacks = Unsafe.AsRef<InternalUnmanagedCallbacks>((void*)unmanagedCallbacks);
}
private partial struct InternalUnmanagedCallbacks
{
}
/*
* IMPORTANT:
* The order of the methods defined in NativeFuncs must match the order
* in the array defined at the bottom of 'editor/editor_internal_calls.cpp'.
*/
public static partial void godot_icall_GodotSharpDirs_ResMetadataDir(out godot_string r_dest);
public static partial void godot_icall_GodotSharpDirs_MonoUserDir(out godot_string r_dest);
public static partial void godot_icall_GodotSharpDirs_BuildLogsDirs(out godot_string r_dest);
public static partial void godot_icall_GodotSharpDirs_DataEditorToolsDir(out godot_string r_dest);
public static partial void godot_icall_GodotSharpDirs_CSharpProjectName(out godot_string r_dest);
public static partial void godot_icall_EditorProgress_Create(in godot_string task, in godot_string label,
int amount, bool canCancel);
public static partial void godot_icall_EditorProgress_Dispose(in godot_string task);
public static partial bool godot_icall_EditorProgress_Step(in godot_string task, in godot_string state,
int step,
bool forceRefresh);
private static partial void godot_icall_Internal_FullExportTemplatesDir(out godot_string dest);
private static partial bool godot_icall_Internal_IsMacOSAppBundleInstalled(in godot_string bundleId);
private static partial bool godot_icall_Internal_LipOCreateFile(in godot_string outputPath, in godot_packed_string_array files);
private static partial bool godot_icall_Internal_GodotIs32Bits();
private static partial bool godot_icall_Internal_GodotIsRealTDouble();
private static partial void godot_icall_Internal_GodotMainIteration();
private static partial bool godot_icall_Internal_IsAssembliesReloadingNeeded();
private static partial void godot_icall_Internal_ReloadAssemblies(bool softReload);
private static partial void godot_icall_Internal_EditorDebuggerNodeReloadScripts();
private static partial bool godot_icall_Internal_ScriptEditorEdit(IntPtr resource, int line, int col,
bool grabFocus);
private static partial void godot_icall_Internal_EditorNodeShowScriptScreen();
private static partial void godot_icall_Internal_EditorRunPlay();
private static partial void godot_icall_Internal_EditorRunStop();
private static partial void godot_icall_Internal_EditorPlugin_AddControlToEditorRunBar(IntPtr p_control);
private static partial void godot_icall_Internal_ScriptEditorDebugger_ReloadScripts();
private static partial void godot_icall_Internal_CodeCompletionRequest(int kind, in godot_string scriptFile,
out godot_packed_string_array res);
public static partial float godot_icall_Globals_EditorScale();
public static partial void godot_icall_Globals_GlobalDef(in godot_string setting, in godot_variant defaultValue,
bool restartIfChanged, out godot_variant result);
public static partial void godot_icall_Globals_EditorDef(in godot_string setting, in godot_variant defaultValue,
bool restartIfChanged, out godot_variant result);
public static partial void
godot_icall_Globals_EditorDefShortcut(in godot_string setting, in godot_string name, Key keycode, godot_bool physical, out godot_variant result);
public static partial void
godot_icall_Globals_EditorGetShortcut(in godot_string setting, out godot_variant result);
public static partial void
godot_icall_Globals_EditorShortcutOverride(in godot_string setting, in godot_string feature, Key keycode, godot_bool physical);
public static partial void godot_icall_Globals_TTR(in godot_string text, out godot_string dest);
public static partial void godot_icall_Utils_OS_GetPlatformName(out godot_string dest);
public static partial bool godot_icall_Utils_OS_UnixFileHasExecutableAccess(in godot_string filePath);
#endregion
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
namespace GodotTools.Utils
{
public static class CollectionExtensions
{
[return: NotNullIfNotNull("orElse")]
public static T? SelectFirstNotNull<T>(this IEnumerable<T> enumerable, Func<T, T?> predicate, T? orElse = null)
where T : class
{
foreach (T elem in enumerable)
{
T? result = predicate(elem);
if (result != null)
return result;
}
return orElse;
}
public static IEnumerable<string> EnumerateLines(this TextReader textReader)
{
string? line;
while ((line = textReader.ReadLine()) != null)
yield return line;
}
}
}

View File

@@ -0,0 +1,40 @@
using System.IO;
using Godot;
namespace GodotTools.Utils
{
public static class Directory
{
private static string GlobalizePath(this string path)
{
return ProjectSettings.GlobalizePath(path);
}
public static bool Exists(string path)
{
return System.IO.Directory.Exists(path.GlobalizePath());
}
/// Create directory recursively
public static DirectoryInfo CreateDirectory(string path)
{
return System.IO.Directory.CreateDirectory(path.GlobalizePath());
}
public static void Delete(string path, bool recursive)
{
System.IO.Directory.Delete(path.GlobalizePath(), recursive);
}
public static string[] GetDirectories(string path, string searchPattern, SearchOption searchOption)
{
return System.IO.Directory.GetDirectories(path.GlobalizePath(), searchPattern, searchOption);
}
public static string[] GetFiles(string path, string searchPattern, SearchOption searchOption)
{
return System.IO.Directory.GetFiles(path.GlobalizePath(), searchPattern, searchOption);
}
}
}

View File

@@ -0,0 +1,43 @@
using System;
using Godot;
namespace GodotTools.Utils
{
public static class File
{
private static string GlobalizePath(this string path)
{
return ProjectSettings.GlobalizePath(path);
}
public static void WriteAllText(string path, string contents)
{
System.IO.File.WriteAllText(path.GlobalizePath(), contents);
}
public static bool Exists(string path)
{
return System.IO.File.Exists(path.GlobalizePath());
}
public static DateTime GetLastWriteTime(string path)
{
return System.IO.File.GetLastWriteTime(path.GlobalizePath());
}
public static void Delete(string path)
{
System.IO.File.Delete(path.GlobalizePath());
}
public static void Copy(string sourceFileName, string destFileName)
{
System.IO.File.Copy(sourceFileName.GlobalizePath(), destFileName.GlobalizePath(), overwrite: true);
}
public static byte[] ReadAllBytes(string path)
{
return System.IO.File.ReadAllBytes(path.GlobalizePath());
}
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Godot;
using GodotTools.Core;
namespace GodotTools.Utils
{
public static class FsPathUtils
{
private static readonly string ResourcePath = ProjectSettings.GlobalizePath("res://");
private static bool PathStartsWithAlreadyNorm(this string childPath, string parentPath)
{
// This won't work for Linux/macOS case insensitive file systems, but it's enough for our current problems
bool caseSensitive = !OS.IsWindows;
string parentPathNorm = parentPath.NormalizePath() + Path.DirectorySeparatorChar;
string childPathNorm = childPath.NormalizePath() + Path.DirectorySeparatorChar;
return childPathNorm.StartsWith(parentPathNorm,
caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);
}
public static bool PathStartsWith(this string childPath, string parentPath)
{
string childPathNorm = childPath.NormalizePath() + Path.DirectorySeparatorChar;
string parentPathNorm = parentPath.NormalizePath() + Path.DirectorySeparatorChar;
return childPathNorm.PathStartsWithAlreadyNorm(parentPathNorm);
}
public static string? LocalizePathWithCaseChecked(string path)
{
string pathNorm = path.NormalizePath() + Path.DirectorySeparatorChar;
string resourcePathNorm = ResourcePath.NormalizePath() + Path.DirectorySeparatorChar;
if (!pathNorm.PathStartsWithAlreadyNorm(resourcePathNorm))
return null;
string result = "res://" + pathNorm.Substring(resourcePathNorm.Length);
// Remove the last separator we added
return result.Substring(0, result.Length - 1);
}
}
}

View File

@@ -0,0 +1,335 @@
using Godot.NativeInterop;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Text;
using GodotTools.Internals;
namespace GodotTools.Utils
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class OS
{
/// <summary>
/// Display names for the OS platforms.
/// </summary>
private static class Names
{
public const string Windows = "Windows";
public const string MacOS = "macOS";
public const string Linux = "Linux";
public const string FreeBSD = "FreeBSD";
public const string NetBSD = "NetBSD";
public const string BSD = "BSD";
public const string Android = "Android";
public const string iOS = "iOS";
public const string Web = "Web";
}
/// <summary>
/// Godot platform identifiers.
/// </summary>
public static class Platforms
{
public const string Windows = "windows";
public const string MacOS = "macos";
public const string LinuxBSD = "linuxbsd";
public const string Android = "android";
public const string iOS = "ios";
public const string Web = "web";
}
/// <summary>
/// OS name part of the .NET runtime identifier (RID).
/// See https://docs.microsoft.com/en-us/dotnet/core/rid-catalog.
/// </summary>
public static class DotNetOS
{
public const string Win = "win";
public const string OSX = "osx";
public const string Linux = "linux";
public const string Win10 = "win10";
public const string Android = "android";
public const string LinuxBionic = "linux-bionic";
public const string iOS = "ios";
public const string iOSSimulator = "iossimulator";
public const string Browser = "browser";
}
public static readonly Dictionary<string, string> PlatformFeatureMap = new Dictionary<string, string>(
// Export `features` may be in lower case
StringComparer.InvariantCultureIgnoreCase
)
{
["Windows"] = Platforms.Windows,
["macOS"] = Platforms.MacOS,
["Linux"] = Platforms.LinuxBSD,
["Android"] = Platforms.Android,
["iOS"] = Platforms.iOS,
["Web"] = Platforms.Web
};
public static readonly Dictionary<string, string> PlatformNameMap = new Dictionary<string, string>
{
[Names.Windows] = Platforms.Windows,
[Names.MacOS] = Platforms.MacOS,
[Names.Linux] = Platforms.LinuxBSD,
[Names.FreeBSD] = Platforms.LinuxBSD,
[Names.NetBSD] = Platforms.LinuxBSD,
[Names.BSD] = Platforms.LinuxBSD,
[Names.Android] = Platforms.Android,
[Names.iOS] = Platforms.iOS,
[Names.Web] = Platforms.Web
};
public static readonly Dictionary<string, string> DotNetOSPlatformMap = new Dictionary<string, string>
{
[Platforms.Windows] = DotNetOS.Win,
[Platforms.MacOS] = DotNetOS.OSX,
// TODO:
// Does .NET 6 support BSD variants? If it does, it may need the name `unix`
// instead of `linux` in the runtime identifier. This would be a problem as
// Godot has a single export profile for both, named LinuxBSD.
[Platforms.LinuxBSD] = DotNetOS.Linux,
[Platforms.Android] = DotNetOS.Android,
[Platforms.iOS] = DotNetOS.iOS,
[Platforms.Web] = DotNetOS.Browser
};
private static bool IsOS(string name)
{
Internal.godot_icall_Utils_OS_GetPlatformName(out godot_string dest);
using (dest)
{
string platformName = Marshaling.ConvertStringToManaged(dest);
return name.Equals(platformName, StringComparison.OrdinalIgnoreCase);
}
}
private static bool IsAnyOS(IEnumerable<string> names)
{
Internal.godot_icall_Utils_OS_GetPlatformName(out godot_string dest);
using (dest)
{
string platformName = Marshaling.ConvertStringToManaged(dest);
return names.Any(p => p.Equals(platformName, StringComparison.OrdinalIgnoreCase));
}
}
private static readonly IEnumerable<string> LinuxBSDPlatforms =
new[] { Names.Linux, Names.FreeBSD, Names.NetBSD, Names.BSD };
private static readonly IEnumerable<string> UnixLikePlatforms =
new[] { Names.MacOS, Names.Android, Names.iOS }
.Concat(LinuxBSDPlatforms).ToArray();
private static readonly Lazy<bool> _isWindows = new(() => IsOS(Names.Windows));
private static readonly Lazy<bool> _isMacOS = new(() => IsOS(Names.MacOS));
private static readonly Lazy<bool> _isLinuxBSD = new(() => IsAnyOS(LinuxBSDPlatforms));
private static readonly Lazy<bool> _isAndroid = new(() => IsOS(Names.Android));
private static readonly Lazy<bool> _isiOS = new(() => IsOS(Names.iOS));
private static readonly Lazy<bool> _isWeb = new(() => IsOS(Names.Web));
private static readonly Lazy<bool> _isUnixLike = new(() => IsAnyOS(UnixLikePlatforms));
[SupportedOSPlatformGuard("windows")] public static bool IsWindows => _isWindows.Value;
[SupportedOSPlatformGuard("osx")] public static bool IsMacOS => _isMacOS.Value;
[SupportedOSPlatformGuard("linux")] public static bool IsLinuxBSD => _isLinuxBSD.Value;
[SupportedOSPlatformGuard("android")] public static bool IsAndroid => _isAndroid.Value;
[SupportedOSPlatformGuard("ios")] public static bool IsiOS => _isiOS.Value;
[SupportedOSPlatformGuard("browser")] public static bool IsWeb => _isWeb.Value;
public static bool IsUnixLike => _isUnixLike.Value;
public static char PathSep => IsWindows ? ';' : ':';
public static string? PathWhich(string name)
{
if (IsWindows)
return PathWhichWindows(name);
return PathWhichUnix(name);
}
private static string? PathWhichWindows(string name)
{
string[] windowsExts =
Environment.GetEnvironmentVariable("PATHEXT")?.Split(PathSep) ?? Array.Empty<string>();
string[]? pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(PathSep);
char[] invalidPathChars = Path.GetInvalidPathChars();
var searchDirs = new List<string>();
if (pathDirs != null)
{
foreach (var pathDir in pathDirs)
{
if (pathDir.IndexOfAny(invalidPathChars) != -1)
continue;
searchDirs.Add(pathDir);
}
}
string nameExt = Path.GetExtension(name);
bool hasPathExt = !string.IsNullOrEmpty(nameExt) &&
windowsExts.Contains(nameExt, StringComparer.OrdinalIgnoreCase);
searchDirs.Add(System.IO.Directory.GetCurrentDirectory()); // last in the list
if (hasPathExt)
return searchDirs.Select(dir => Path.Combine(dir, name)).FirstOrDefault(File.Exists);
return (from dir in searchDirs
select Path.Combine(dir, name)
into path
from ext in windowsExts
select path + ext).FirstOrDefault(File.Exists);
}
private static string? PathWhichUnix(string name)
{
string[]? pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(PathSep);
char[] invalidPathChars = Path.GetInvalidPathChars();
var searchDirs = new List<string>();
if (pathDirs != null)
{
foreach (var pathDir in pathDirs)
{
if (pathDir.IndexOfAny(invalidPathChars) != -1)
continue;
searchDirs.Add(pathDir);
}
}
searchDirs.Add(System.IO.Directory.GetCurrentDirectory()); // last in the list
return searchDirs.Select(dir => Path.Combine(dir, name))
.FirstOrDefault(path =>
{
using godot_string pathIn = Marshaling.ConvertStringToNative(path);
return File.Exists(path) && Internal.godot_icall_Utils_OS_UnixFileHasExecutableAccess(pathIn);
});
}
public static void RunProcess(string command, IEnumerable<string> arguments)
{
var startInfo = new ProcessStartInfo(command)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
foreach (string arg in arguments)
startInfo.ArgumentList.Add(arg);
using Process? process = Process.Start(startInfo);
if (process == null)
throw new InvalidOperationException("No process was started.");
process.BeginOutputReadLine();
process.BeginErrorReadLine();
if (IsWindows && process.Id > 0)
User32Dll.AllowSetForegroundWindow(process.Id); // Allows application to focus itself
}
public static int ExecuteCommand(string command, IEnumerable<string> arguments)
{
var startInfo = new ProcessStartInfo(command)
{
// Print the output
RedirectStandardOutput = false,
RedirectStandardError = false,
UseShellExecute = false
};
foreach (string arg in arguments)
startInfo.ArgumentList.Add(arg);
Console.WriteLine(startInfo.GetCommandLineDisplay(new StringBuilder("Executing: ")).ToString());
using var process = new Process { StartInfo = startInfo };
process.Start();
process.WaitForExit();
return process.ExitCode;
}
private static void AppendProcessFileNameForDisplay(this StringBuilder builder, string fileName)
{
if (builder.Length > 0)
builder.Append(' ');
if (fileName.Contains(' ', StringComparison.Ordinal))
{
builder.Append('"');
builder.Append(fileName);
builder.Append('"');
}
else
{
builder.Append(fileName);
}
}
private static void AppendProcessArgumentsForDisplay(this StringBuilder builder,
Collection<string> argumentList)
{
// This is intended just for reading. It doesn't need to be a valid command line.
// E.g.: We don't handle escaping of quotes.
foreach (string argument in argumentList)
{
if (builder.Length > 0)
builder.Append(' ');
if (argument.Contains(' ', StringComparison.Ordinal))
{
builder.Append('"');
builder.Append(argument);
builder.Append('"');
}
else
{
builder.Append(argument);
}
}
}
public static StringBuilder GetCommandLineDisplay(
this ProcessStartInfo startInfo,
StringBuilder? optionalBuilder = null
)
{
var builder = optionalBuilder ?? new StringBuilder();
builder.AppendProcessFileNameForDisplay(startInfo.FileName);
if (startInfo.ArgumentList.Count == 0)
{
builder.Append(' ');
builder.Append(startInfo.Arguments);
}
else
{
builder.AppendProcessArgumentsForDisplay(startInfo.ArgumentList);
}
return builder;
}
}
}

View File

@@ -0,0 +1,10 @@
using System.Runtime.InteropServices;
namespace GodotTools.Utils
{
public static class User32Dll
{
[DllImport("user32.dll")]
public static extern bool AllowSetForegroundWindow(int dwProcessId);
}
}

View File

@@ -0,0 +1,11 @@
namespace GodotTools
{
public enum VerbosityLevelId : long
{
Quiet,
Minimal,
Normal,
Detailed,
Diagnostic,
}
}