ExportPlugin.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. using Godot;
  2. using System;
  3. using System.Collections.Generic;
  4. using System.IO;
  5. using System.Linq;
  6. using System.Security.Cryptography;
  7. using System.Text;
  8. using GodotTools.Build;
  9. using GodotTools.Internals;
  10. using Directory = GodotTools.Utils.Directory;
  11. using File = GodotTools.Utils.File;
  12. using OS = GodotTools.Utils.OS;
  13. using Path = System.IO.Path;
  14. namespace GodotTools.Export
  15. {
  16. public partial class ExportPlugin : EditorExportPlugin
  17. {
  18. public override string _GetName() => "C#";
  19. private List<string> _tempFolders = new List<string>();
  20. private static bool ProjectContainsDotNet()
  21. {
  22. return File.Exists(GodotSharpDirs.ProjectSlnPath);
  23. }
  24. public override string[] _GetExportFeatures(EditorExportPlatform platform, bool debug)
  25. {
  26. if (!ProjectContainsDotNet())
  27. return Array.Empty<string>();
  28. return new string[] { "dotnet" };
  29. }
  30. public override Godot.Collections.Array<Godot.Collections.Dictionary> _GetExportOptions(EditorExportPlatform platform)
  31. {
  32. return new Godot.Collections.Array<Godot.Collections.Dictionary>()
  33. {
  34. new Godot.Collections.Dictionary()
  35. {
  36. {
  37. "option", new Godot.Collections.Dictionary()
  38. {
  39. { "name", "dotnet/include_scripts_content" },
  40. { "type", (int)Variant.Type.Bool }
  41. }
  42. },
  43. { "default_value", false }
  44. },
  45. new Godot.Collections.Dictionary()
  46. {
  47. {
  48. "option", new Godot.Collections.Dictionary()
  49. {
  50. { "name", "dotnet/include_debug_symbols" },
  51. { "type", (int)Variant.Type.Bool }
  52. }
  53. },
  54. { "default_value", true }
  55. },
  56. new Godot.Collections.Dictionary()
  57. {
  58. {
  59. "option", new Godot.Collections.Dictionary()
  60. {
  61. { "name", "dotnet/embed_build_outputs" },
  62. { "type", (int)Variant.Type.Bool }
  63. }
  64. },
  65. { "default_value", false }
  66. }
  67. };
  68. }
  69. private string _maybeLastExportError;
  70. // With this method we can override how a file is exported in the PCK
  71. public override void _ExportFile(string path, string type, string[] features)
  72. {
  73. base._ExportFile(path, type, features);
  74. if (type != Internal.CSharpLanguageType)
  75. return;
  76. if (Path.GetExtension(path) != Internal.CSharpLanguageExtension)
  77. throw new ArgumentException(
  78. $"Resource of type {Internal.CSharpLanguageType} has an invalid file extension: {path}",
  79. nameof(path));
  80. // TODO: What if the source file is not part of the game's C# project?
  81. bool includeScriptsContent = (bool)GetOption("dotnet/include_scripts_content");
  82. if (!includeScriptsContent)
  83. {
  84. // We don't want to include the source code on exported games.
  85. // Sadly, Godot prints errors when adding an empty file (nothing goes wrong, it's just noise).
  86. // Because of this, we add a file which contains a line break.
  87. AddFile(path, System.Text.Encoding.UTF8.GetBytes("\n"), remap: false);
  88. // Tell the Godot exporter that we already took care of the file.
  89. Skip();
  90. }
  91. }
  92. public override void _ExportBegin(string[] features, bool isDebug, string path, uint flags)
  93. {
  94. base._ExportBegin(features, isDebug, path, flags);
  95. try
  96. {
  97. _ExportBeginImpl(features, isDebug, path, flags);
  98. }
  99. catch (Exception e)
  100. {
  101. _maybeLastExportError = e.Message;
  102. // 'maybeLastExportError' cannot be null or empty if there was an error, so we
  103. // must consider the possibility of exceptions being thrown without a message.
  104. if (string.IsNullOrEmpty(_maybeLastExportError))
  105. _maybeLastExportError = $"Exception thrown: {e.GetType().Name}";
  106. GD.PushError($"Failed to export project: {_maybeLastExportError}");
  107. Console.Error.WriteLine(e);
  108. // TODO: Do something on error once _ExportBegin supports failing.
  109. }
  110. }
  111. private void _ExportBeginImpl(string[] features, bool isDebug, string path, long flags)
  112. {
  113. _ = flags; // Unused.
  114. if (!ProjectContainsDotNet())
  115. return;
  116. if (!DeterminePlatformFromFeatures(features, out string platform))
  117. throw new NotSupportedException("Target platform not supported.");
  118. if (!new[] { OS.Platforms.Windows, OS.Platforms.LinuxBSD, OS.Platforms.MacOS, OS.Platforms.Android, OS.Platforms.iOS }
  119. .Contains(platform))
  120. {
  121. throw new NotImplementedException("Target platform not yet implemented.");
  122. }
  123. PublishConfig publishConfig = new()
  124. {
  125. BuildConfig = isDebug ? "ExportDebug" : "ExportRelease",
  126. IncludeDebugSymbols = (bool)GetOption("dotnet/include_debug_symbols"),
  127. RidOS = DetermineRuntimeIdentifierOS(platform),
  128. Archs = new List<string>(),
  129. UseTempDir = platform != OS.Platforms.iOS, // xcode project links directly to files in the publish dir, so use one that sticks around.
  130. BundleOutputs = true,
  131. };
  132. if (features.Contains("x86_64"))
  133. {
  134. publishConfig.Archs.Add("x86_64");
  135. }
  136. if (features.Contains("x86_32"))
  137. {
  138. publishConfig.Archs.Add("x86_32");
  139. }
  140. if (features.Contains("arm64"))
  141. {
  142. publishConfig.Archs.Add("arm64");
  143. }
  144. if (features.Contains("arm32"))
  145. {
  146. publishConfig.Archs.Add("arm32");
  147. }
  148. if (features.Contains("universal"))
  149. {
  150. if (platform == OS.Platforms.MacOS)
  151. {
  152. publishConfig.Archs.Add("x86_64");
  153. publishConfig.Archs.Add("arm64");
  154. }
  155. }
  156. var targets = new List<PublishConfig> { publishConfig };
  157. if (platform == OS.Platforms.iOS)
  158. {
  159. targets.Add(new PublishConfig
  160. {
  161. BuildConfig = publishConfig.BuildConfig,
  162. Archs = new List<string> { "arm64", "x86_64" },
  163. BundleOutputs = false,
  164. IncludeDebugSymbols = publishConfig.IncludeDebugSymbols,
  165. RidOS = OS.DotNetOS.iOSSimulator,
  166. UseTempDir = true,
  167. });
  168. }
  169. List<string> outputPaths = new();
  170. bool embedBuildResults = (bool)GetOption("dotnet/embed_build_outputs") || platform == OS.Platforms.Android;
  171. foreach (PublishConfig config in targets)
  172. {
  173. string ridOS = config.RidOS;
  174. string buildConfig = config.BuildConfig;
  175. bool includeDebugSymbols = config.IncludeDebugSymbols;
  176. foreach (string arch in config.Archs)
  177. {
  178. string ridArch = DetermineRuntimeIdentifierArch(arch);
  179. string runtimeIdentifier = $"{ridOS}-{ridArch}";
  180. string projectDataDirName = $"data_{GodotSharpDirs.CSharpProjectName}_{platform}_{arch}";
  181. if (platform == OS.Platforms.MacOS)
  182. {
  183. projectDataDirName = Path.Combine("Contents", "Resources", projectDataDirName);
  184. }
  185. // Create temporary publish output directory.
  186. string publishOutputDir;
  187. if (config.UseTempDir)
  188. {
  189. publishOutputDir = Path.Combine(Path.GetTempPath(), "godot-publish-dotnet",
  190. $"{System.Environment.ProcessId}-{buildConfig}-{runtimeIdentifier}");
  191. _tempFolders.Add(publishOutputDir);
  192. }
  193. else
  194. {
  195. publishOutputDir = Path.Combine(GodotSharpDirs.ProjectBaseOutputPath, "godot-publish-dotnet",
  196. $"{buildConfig}-{runtimeIdentifier}");
  197. }
  198. outputPaths.Add(publishOutputDir);
  199. if (!Directory.Exists(publishOutputDir))
  200. Directory.CreateDirectory(publishOutputDir);
  201. // Execute dotnet publish.
  202. if (!BuildManager.PublishProjectBlocking(buildConfig, platform,
  203. runtimeIdentifier, publishOutputDir, includeDebugSymbols))
  204. {
  205. throw new InvalidOperationException("Failed to build project.");
  206. }
  207. string soExt = ridOS switch
  208. {
  209. OS.DotNetOS.Win or OS.DotNetOS.Win10 => "dll",
  210. OS.DotNetOS.OSX or OS.DotNetOS.iOS or OS.DotNetOS.iOSSimulator => "dylib",
  211. _ => "so"
  212. };
  213. string assemblyPath = Path.Combine(publishOutputDir, $"{GodotSharpDirs.ProjectAssemblyName}.dll");
  214. string nativeAotPath = Path.Combine(publishOutputDir,
  215. $"{GodotSharpDirs.ProjectAssemblyName}.{soExt}");
  216. if (!File.Exists(assemblyPath) && !File.Exists(nativeAotPath))
  217. {
  218. throw new NotSupportedException(
  219. $"Publish succeeded but project assembly not found at '{assemblyPath}' or '{nativeAotPath}'.");
  220. }
  221. // For ios simulator builds, skip packaging the build outputs.
  222. if (!config.BundleOutputs)
  223. continue;
  224. var manifest = new StringBuilder();
  225. // Add to the exported project shared object list or packed resources.
  226. RecursePublishContents(publishOutputDir,
  227. filterDir: dir =>
  228. {
  229. if (platform == OS.Platforms.iOS)
  230. {
  231. // Exclude dsym folders.
  232. return !dir.EndsWith(".dsym", StringComparison.InvariantCultureIgnoreCase);
  233. }
  234. return true;
  235. },
  236. filterFile: file =>
  237. {
  238. if (platform == OS.Platforms.iOS)
  239. {
  240. // Exclude the dylib artifact, since it's included separately as an xcframework.
  241. return Path.GetFileName(file) != $"{GodotSharpDirs.ProjectAssemblyName}.dylib";
  242. }
  243. return true;
  244. },
  245. recurseDir: dir =>
  246. {
  247. if (platform == OS.Platforms.iOS)
  248. {
  249. // Don't recurse into dsym folders.
  250. return !dir.EndsWith(".dsym", StringComparison.InvariantCultureIgnoreCase);
  251. }
  252. return true;
  253. },
  254. addEntry: (path, isFile) =>
  255. {
  256. // We get called back for both directories and files, but we only package files for now.
  257. if (isFile)
  258. {
  259. if (embedBuildResults)
  260. {
  261. string filePath = SanitizeSlashes(Path.GetRelativePath(publishOutputDir, path));
  262. byte[] fileData = File.ReadAllBytes(path);
  263. string hash = Convert.ToBase64String(SHA512.HashData(fileData));
  264. manifest.Append($"{filePath}\t{hash}\n");
  265. AddFile($"res://.godot/mono/publish/{arch}/{filePath}", fileData, false);
  266. }
  267. else
  268. {
  269. if (platform == OS.Platforms.iOS && path.EndsWith(".dat"))
  270. {
  271. AddIosBundleFile(path);
  272. }
  273. else
  274. {
  275. AddSharedObject(path, tags: null,
  276. Path.Join(projectDataDirName,
  277. Path.GetRelativePath(publishOutputDir,
  278. Path.GetDirectoryName(path))));
  279. }
  280. }
  281. }
  282. });
  283. if (embedBuildResults)
  284. {
  285. byte[] fileData = Encoding.Default.GetBytes(manifest.ToString());
  286. AddFile($"res://.godot/mono/publish/{arch}/.dotnet-publish-manifest", fileData, false);
  287. }
  288. }
  289. }
  290. if (platform == OS.Platforms.iOS)
  291. {
  292. if (outputPaths.Count > 2)
  293. {
  294. // lipo the simulator binaries together
  295. // TODO: Move this to the native lipo implementation we have in the macos export plugin.
  296. var lipoArgs = new List<string>();
  297. lipoArgs.Add("-create");
  298. lipoArgs.AddRange(outputPaths.Skip(1).Select(x => Path.Combine(x, $"{GodotSharpDirs.ProjectAssemblyName}.dylib")));
  299. lipoArgs.Add("-output");
  300. lipoArgs.Add(Path.Combine(outputPaths[1], $"{GodotSharpDirs.ProjectAssemblyName}.dylib"));
  301. int lipoExitCode = OS.ExecuteCommand(XcodeHelper.FindXcodeTool("lipo"), lipoArgs);
  302. if (lipoExitCode != 0)
  303. throw new InvalidOperationException($"Command 'lipo' exited with code: {lipoExitCode}.");
  304. outputPaths.RemoveRange(2, outputPaths.Count - 2);
  305. }
  306. var xcFrameworkPath = Path.Combine(GodotSharpDirs.ProjectBaseOutputPath, publishConfig.BuildConfig,
  307. $"{GodotSharpDirs.ProjectAssemblyName}.xcframework");
  308. if (!BuildManager.GenerateXCFrameworkBlocking(outputPaths,
  309. Path.Combine(GodotSharpDirs.ProjectBaseOutputPath, publishConfig.BuildConfig, xcFrameworkPath)))
  310. {
  311. throw new InvalidOperationException("Failed to generate xcframework.");
  312. }
  313. AddIosEmbeddedFramework(xcFrameworkPath);
  314. }
  315. }
  316. private static void RecursePublishContents(string path, Func<string, bool> filterDir,
  317. Func<string, bool> filterFile, Func<string, bool> recurseDir,
  318. Action<string, bool> addEntry)
  319. {
  320. foreach (string file in Directory.GetFiles(path, "*", SearchOption.TopDirectoryOnly))
  321. {
  322. if (filterFile(file))
  323. {
  324. addEntry(file, true);
  325. }
  326. }
  327. foreach (string dir in Directory.GetDirectories(path, "*", SearchOption.TopDirectoryOnly))
  328. {
  329. if (filterDir(dir))
  330. {
  331. addEntry(dir, false);
  332. }
  333. else if (recurseDir(dir))
  334. {
  335. RecursePublishContents(dir, filterDir, filterFile, recurseDir, addEntry);
  336. }
  337. }
  338. }
  339. private string SanitizeSlashes(string path)
  340. {
  341. if (Path.DirectorySeparatorChar == '\\')
  342. return path.Replace('\\', '/');
  343. return path;
  344. }
  345. private string DetermineRuntimeIdentifierOS(string platform)
  346. => OS.DotNetOSPlatformMap[platform];
  347. private string DetermineRuntimeIdentifierArch(string arch)
  348. {
  349. return arch switch
  350. {
  351. "x86" => "x86",
  352. "x86_32" => "x86",
  353. "x64" => "x64",
  354. "x86_64" => "x64",
  355. "armeabi-v7a" => "arm",
  356. "arm64-v8a" => "arm64",
  357. "arm32" => "arm",
  358. "arm64" => "arm64",
  359. _ => throw new ArgumentOutOfRangeException(nameof(arch), arch, "Unexpected architecture")
  360. };
  361. }
  362. public override void _ExportEnd()
  363. {
  364. base._ExportEnd();
  365. string aotTempDir = Path.Combine(Path.GetTempPath(), $"godot-aot-{System.Environment.ProcessId}");
  366. if (Directory.Exists(aotTempDir))
  367. Directory.Delete(aotTempDir, recursive: true);
  368. foreach (string folder in _tempFolders)
  369. {
  370. Directory.Delete(folder, recursive: true);
  371. }
  372. _tempFolders.Clear();
  373. // TODO: The following is just a workaround until the export plugins can be made to abort with errors
  374. // We check for empty as well, because it's set to empty after hot-reloading
  375. if (!string.IsNullOrEmpty(_maybeLastExportError))
  376. {
  377. string lastExportError = _maybeLastExportError;
  378. _maybeLastExportError = null;
  379. GodotSharpEditor.Instance.ShowErrorDialog(lastExportError, "Failed to export C# project");
  380. }
  381. }
  382. private static bool DeterminePlatformFromFeatures(IEnumerable<string> features, out string platform)
  383. {
  384. foreach (var feature in features)
  385. {
  386. if (OS.PlatformFeatureMap.TryGetValue(feature, out platform))
  387. return true;
  388. }
  389. platform = null;
  390. return false;
  391. }
  392. private struct PublishConfig
  393. {
  394. public bool UseTempDir;
  395. public bool BundleOutputs;
  396. public string RidOS;
  397. public List<string> Archs;
  398. public string BuildConfig;
  399. public bool IncludeDebugSymbols;
  400. }
  401. }
  402. }