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
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:
355
modules/mono/editor/GodotTools/.gitignore
vendored
Normal file
355
modules/mono/editor/GodotTools/.gitignore
vendored
Normal 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/
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
30
modules/mono/editor/GodotTools/GodotTools.Core/FileUtils.cs
Normal file
30
modules/mono/editor/GodotTools/GodotTools.Core/FileUtils.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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} =======");
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
363
modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Client.cs
Normal file
363
modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Client.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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; }
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
298
modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Peer.cs
Normal file
298
modules/mono/editor/GodotTools/GodotTools.IdeMessaging/Peer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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>
|
73
modules/mono/editor/GodotTools/GodotTools.sln
Normal file
73
modules/mono/editor/GodotTools/GodotTools.sln
Normal 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
|
@@ -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; }
|
||||
}
|
||||
}
|
86
modules/mono/editor/GodotTools/GodotTools/Build/BuildInfo.cs
Normal file
86
modules/mono/editor/GodotTools/GodotTools/Build/BuildInfo.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
390
modules/mono/editor/GodotTools/GodotTools/Build/BuildManager.cs
Normal file
390
modules/mono/editor/GodotTools/GodotTools/Build/BuildManager.cs
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
namespace GodotTools.Build
|
||||
{
|
||||
public enum BuildResult
|
||||
{
|
||||
Error,
|
||||
Success
|
||||
}
|
||||
}
|
375
modules/mono/editor/GodotTools/GodotTools/Build/BuildSystem.cs
Normal file
375
modules/mono/editor/GodotTools/GodotTools/Build/BuildSystem.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
121
modules/mono/editor/GodotTools/GodotTools/Build/DotNetFinder.cs
Normal file
121
modules/mono/editor/GodotTools/GodotTools/Build/DotNetFinder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
308
modules/mono/editor/GodotTools/GodotTools/Build/MSBuildPanel.cs
Normal file
308
modules/mono/editor/GodotTools/GodotTools/Build/MSBuildPanel.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
563
modules/mono/editor/GodotTools/GodotTools/Export/ExportPlugin.cs
Normal file
563
modules/mono/editor/GodotTools/GodotTools/Export/ExportPlugin.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
namespace GodotTools
|
||||
{
|
||||
public enum ExternalEditorId : long
|
||||
{
|
||||
None,
|
||||
VisualStudio, // TODO (Windows-only)
|
||||
VisualStudioForMac, // Mac-only
|
||||
MonoDevelop,
|
||||
VsCode,
|
||||
Rider,
|
||||
CustomEditor,
|
||||
Fleet,
|
||||
}
|
||||
}
|
760
modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs
Normal file
760
modules/mono/editor/GodotTools/GodotTools/GodotSharpEditor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
62
modules/mono/editor/GodotTools/GodotTools/GodotTools.csproj
Normal file
62
modules/mono/editor/GodotTools/GodotTools/GodotTools.csproj
Normal 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>
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
namespace GodotTools.Ides.MonoDevelop
|
||||
{
|
||||
public enum EditorId
|
||||
{
|
||||
MonoDevelop = 0,
|
||||
VisualStudioForMac = 1
|
||||
}
|
||||
}
|
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
186
modules/mono/editor/GodotTools/GodotTools/Internals/Internal.cs
Normal file
186
modules/mono/editor/GodotTools/GodotTools/Internals/Internal.cs
Normal 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
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
40
modules/mono/editor/GodotTools/GodotTools/Utils/Directory.cs
Normal file
40
modules/mono/editor/GodotTools/GodotTools/Utils/Directory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
43
modules/mono/editor/GodotTools/GodotTools/Utils/File.cs
Normal file
43
modules/mono/editor/GodotTools/GodotTools/Utils/File.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
335
modules/mono/editor/GodotTools/GodotTools/Utils/OS.cs
Normal file
335
modules/mono/editor/GodotTools/GodotTools/Utils/OS.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
10
modules/mono/editor/GodotTools/GodotTools/Utils/User32Dll.cs
Normal file
10
modules/mono/editor/GodotTools/GodotTools/Utils/User32Dll.cs
Normal 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);
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
namespace GodotTools
|
||||
{
|
||||
public enum VerbosityLevelId : long
|
||||
{
|
||||
Quiet,
|
||||
Minimal,
|
||||
Normal,
|
||||
Detailed,
|
||||
Diagnostic,
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user