godot/modules/mono/editor/GodotTools/GodotTools/Export/ExportPlugin.cs

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)
        {
            return new Godot.Collections.Array<Godot.Collections.Dictionary>()
            {
                new Godot.Collections.Dictionary()
                {
                    {
                        "option", new Godot.Collections.Dictionary()
                        {
                            { "name", "dotnet/include_scripts_content" },
                            { "type", (int)Variant.Type.Bool }
                        }
                    },
                    { "default_value", false }
                },
                new Godot.Collections.Dictionary()
                {
                    {
                        "option", new Godot.Collections.Dictionary()
                        {
                            { "name", "dotnet/include_debug_symbols" },
                            { "type", (int)Variant.Type.Bool }
                        }
                    },
                    { "default_value", true }
                },
                new Godot.Collections.Dictionary()
                {
                    {
                        "option", new Godot.Collections.Dictionary()
                        {
                            { "name", "dotnet/embed_build_outputs" },
                            { "type", (int)Variant.Type.Bool }
                        }
                    },
                    { "default_value", false }
                }
            };
        }

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

            PublishConfig publishConfig = new()
            {
                BuildConfig = isDebug ? "ExportDebug" : "ExportRelease",
                IncludeDebugSymbols = (bool)GetOption("dotnet/include_debug_symbols"),
                RidOS = DetermineRuntimeIdentifierOS(platform),
                Archs = new List<string>(),
                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 = new List<string> { "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;

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

                    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)
                                    {
                                        if (IsSharedObject(Path.GetFileName(path)))
                                        {
                                            AddSharedObject(path, tags: new string[] { arch },
                                                Path.Join(projectDataDirName,
                                                    Path.GetRelativePath(publishOutputDir,
                                                        Path.GetDirectoryName(path)!)));

                                            return;
                                        }

                                        static bool IsSharedObject(string fileName)
                                        {
                                            if (fileName.EndsWith(".so") || fileName.EndsWith(".a")
                                             || fileName.EndsWith(".jar") || 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))
                                    {
                                        AddIosBundleFile(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.");
                }

                AddIosEmbeddedFramework(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)
            => 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 List<string> Archs;
            public string BuildConfig;
            public bool IncludeDebugSymbols;
        }
    }
}