diff --git a/GodotEnv/src/features/godot/commands/GodotListCommand.cs b/GodotEnv/src/features/godot/commands/GodotListCommand.cs index 7ffc8e83..0fee22a4 100644 --- a/GodotEnv/src/features/godot/commands/GodotListCommand.cs +++ b/GodotEnv/src/features/godot/commands/GodotListCommand.cs @@ -31,6 +31,7 @@ out var failedInstallations var activeTag = installation.IsActiveVersion ? " *" : ""; log.Print(godotRepo.InstallationVersionName(installation) + activeTag); } + // TODO - Recognize Custom Builds foreach (var unrecognized in unrecognizedDirectories) { log.Warn("Unrecognized subfolder in Godot installation directory (may be a non-conforming version identifier):"); log.Warn($" {unrecognized}"); diff --git a/GodotEnv/src/features/godot/commands/install/GodotRegisterCommand.cs b/GodotEnv/src/features/godot/commands/install/GodotRegisterCommand.cs new file mode 100644 index 00000000..0931e29e --- /dev/null +++ b/GodotEnv/src/features/godot/commands/install/GodotRegisterCommand.cs @@ -0,0 +1,95 @@ +namespace Chickensoft.GodotEnv.Features.Godot.Commands; + +using System.Threading.Tasks; +using Chickensoft.GodotEnv.Common.Models; +using Chickensoft.GodotEnv.Features.Godot.Models; +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using CliWrap; + +// FIX - dependent files not working (e.g. steam_api64.dll) +[Command("godot register", Description = "Register a version of Godot.")] +public class GodotRegisterCommand : + ICommand, ICliCommand, IWindowsElevationEnabled { + [CommandParameter( + 0, + Name = "Name", + Validators = [typeof(GodotVersionValidator)], + Description = "Godot version name: e.g., 4.1.0.CustomBuild, 4.2.0.GodotSteam, etc." + + " Should be a unique Godot version name" + )] + public string VersionName { get; set; } = default!; + + [CommandParameter( + 1, + Name = "Path", + Description = "Godot version path: e.g. /user/godot/version/path/" + )] + public string VersionPath { get; set; } = default!; + + [CommandParameter( + 2, + Name = "Executable Path", + Description = "Godot executable name: e.g. \"godot-steam-4.3/godotsteam.multiplayer.43.editor.windows.64.exe\"" + )] + public string ExecutablePath { get; set; } = default!; + + [CommandOption( + "no-dotnet", 'n', + Description = + "Specify to use the version of Godot that does not support C#/.NET." + )] + public bool NoDotnet { get; set; } + + public IExecutionContext ExecutionContext { get; set; } = default!; + + public bool IsWindowsElevationRequired => true; + + public GodotRegisterCommand(IExecutionContext context) { + ExecutionContext = context; + } + + public async ValueTask ExecuteAsync(IConsole console) { + var godotRepo = ExecutionContext.Godot.GodotRepo; + var platform = ExecutionContext.Godot.Platform; + + var log = ExecutionContext.CreateLog(console); + + // We know this won't throw because the validator okayed it + var version = godotRepo.VersionStringConverter.ParseVersion(VersionName); + var isDotnetVersion = !NoDotnet; + + // TODO - Check if are necessary + var godotInstallationsPath = godotRepo.GodotInstallationsPath; + var godotCachePath = godotRepo.GodotCachePath; + + var existingInstallation = + godotRepo.GetInstallation(version, isDotnetVersion); + + // Log information to show we understood. + platform.Describe(log); + log.Info($"🤖 Godot v{VersionName}"); + log.Info($"🍯 Parsed version: {version}"); + log.Info( + isDotnetVersion ? "😁 Using Godot with .NET" : "😢 Using Godot without .NET" + ); + + // Check for existing installation. + if (existingInstallation is GodotInstallation installation) { + log.Warn( + $"🤔 Godot v{VersionName} is already installed:" + ); + log.Warn(installation); + } + + var newInstallation = + await godotRepo.ExtractGodotCustomBuild(VersionPath, ExecutablePath, version, isDotnetVersion, log); + + await godotRepo.UpdateGodotSymlink(newInstallation, log); + + await godotRepo.UpdateDesktopShortcut(newInstallation, log); + + await godotRepo.AddOrUpdateGodotEnvVariable(log); + } +} diff --git a/GodotEnv/src/features/godot/domain/GodotRepository.cs b/GodotEnv/src/features/godot/domain/GodotRepository.cs index fa160599..36b92f54 100644 --- a/GodotEnv/src/features/godot/domain/GodotRepository.cs +++ b/GodotEnv/src/features/godot/domain/GodotRepository.cs @@ -88,6 +88,19 @@ Task ExtractGodotInstaller( GodotCompressedArchive archive, ILog log ); + /// + /// Move the Godot custom build files into the correct directory. + /// + /// Godot installation path. + /// Godot version. + /// If the Godot build is a Dotnet build. + /// Output log. + /// Path to the subfolder in the Godot installations directory + /// containing the extracted contents. + Task ExtractGodotCustomBuild( + string archivePath, string executableName, GodotVersion version, bool isDotnetVersion, ILog log + ); + /// /// Updates the symlink to point to the specified Godot installation. /// @@ -407,6 +420,49 @@ ILog log ); } + public async Task ExtractGodotCustomBuild( + string archivePath, + string executableName, + GodotVersion version, + bool isDotnetVersion, + ILog log + ) { + var versionString = VersionStringConverter.VersionString(version); + + var destinationDirName = + FileClient.Combine(GodotInstallationsPath, versionString); + + var numFilesExtracted = await ZipClient.ExtractToDirectory( + archivePath, + destinationDirName, + new Progress((percent) => { + var p = Math.Round(percent * 100); + log.InfoInPlace($"🗜 Extracting Godot: {p}%" + " "); + }) + ); + log.Print(""); // New line after progress. + log.ClearCurrentLine(); + log.Print($" Destination: {destinationDirName}"); + log.Success($"✅ Extracted {numFilesExtracted} file(s)."); + log.Print(""); + + var execPath = GetExecutionPath( + installationPath: destinationDirName, + version: version, + isDotnetVersion: isDotnetVersion, + executableName + ); + + return new GodotInstallation( + Name: versionString, + IsActiveVersion: true, // we always switch to the newly installed version. + Version: version, + IsDotnetVersion: isDotnetVersion, + Path: destinationDirName, + ExecutionPath: execPath + ); + } + public async Task UpdateGodotSymlink( GodotInstallation installation, ILog log ) { @@ -568,6 +624,8 @@ out List failedGodotInstallations foreach (var dir in FileClient.GetSubdirectories(GodotInstallationsPath)) { DirectoryToVersion(dir.Name, out var version, out var isDotnetVersion); if (version is null) { + // TODO - Add a godotenv.json to custom builds directories + // so we can identify it's version and dependencies? unrecognizedDirectories.Add(dir.Name); } else { @@ -647,11 +705,11 @@ public async Task Uninstall( } private string GetExecutionPath( - string installationPath, GodotVersion version, bool isDotnetVersion + string installationPath, GodotVersion version, bool isDotnetVersion, string? customExecutablePath = null ) => FileClient.Combine( installationPath, - Platform.GetRelativeExtractedExecutablePath(version, isDotnetVersion) + customExecutablePath ?? Platform.GetRelativeExtractedExecutablePath(version, isDotnetVersion) ); private string GetGodotSharpPath( diff --git a/GodotEnv/src/features/godot/models/CustomBuildVersionStringConverter.cs b/GodotEnv/src/features/godot/models/CustomBuildVersionStringConverter.cs new file mode 100644 index 00000000..a2dd0218 --- /dev/null +++ b/GodotEnv/src/features/godot/models/CustomBuildVersionStringConverter.cs @@ -0,0 +1,64 @@ +namespace Chickensoft.GodotEnv.Features.Godot.Models; + +using System; +using System.Text.RegularExpressions; + +public partial class CustomBuildVersionStringConverter : IVersionStringConverter { + public GodotVersion ParseVersion(string version) { + var match = VersionStringRegex().Match(version); + if (!match.Success) { + throw new ArgumentException( + $"Couldn't match \"{version}\" to known Godot custom build version patterns." + ); + } + // we can safely convert major and minor, since the regex only matches + // digit characters + var major = int.Parse(match.Groups[1].Value); + var minor = int.Parse(match.Groups[2].Value); + // patch string is optional "-\d+", so we can safely convert it after + // the first character if it has length + var patch = 0; + var patchStr = match.Groups[3].Value; + if (patchStr.Length > 0) { + patch = int.Parse(patchStr[1..]); + } + + var label = match.Groups[4].Value; + var labelNum = -1; + if (label != "stable") { + label = match.Groups[5].Value; + var buildVersion = match.Groups[6].Value; + + // Verify if group 6 exists + if (buildVersion != "") { + // If yes, get build version at group 8 + labelNum = int.Parse(match.Groups[8].Value); + } + + } + return new GodotVersion(major, minor, patch, label, labelNum, true); + } + + public string VersionString(GodotVersion version) { + var result = $"{version.Major}.{version.Minor}"; + if (version.Patch != 0) { + result += $".{version.Patch}"; + } + result += $"-{LabelString(version)}"; + return result; + } + + public string LabelString(GodotVersion version) { + var result = version.Label; + if (result != "stable") { + result += version.LabelNumber; + } + return result; + } + + // All published Godot 4+ packages have a label ("-stable" if not prerelease) + // "-stable" labels do not have a number, others do + // Versions with a patch number of 0 do not have a patch number + [GeneratedRegex(@"^(\d+)\.(\d+)(\.[1-9]\d*)?-(stable|([a-zA-Z]+)((\.)?\d+)?)$")] + public static partial Regex VersionStringRegex(); +} diff --git a/GodotEnv/src/features/godot/models/GodotVersion.cs b/GodotEnv/src/features/godot/models/GodotVersion.cs index 2aa95888..9b44d9bf 100644 --- a/GodotEnv/src/features/godot/models/GodotVersion.cs +++ b/GodotEnv/src/features/godot/models/GodotVersion.cs @@ -9,7 +9,7 @@ public partial record GodotVersion { public string Label { get; } public int LabelNumber { get; } - internal GodotVersion(int major, int minor, int patch, string label, int labelNumber) { + internal GodotVersion(int major, int minor, int patch, string label, int labelNumber, bool isCustomBuild = false) { if (major < 0) { throw new ArgumentException($"Major version {major} is invalid"); } @@ -25,7 +25,7 @@ internal GodotVersion(int major, int minor, int patch, string label, int labelNu if (char.IsDigit(label[^1])) { throw new ArgumentException("Label \"{label}\" ambiguously ends with number"); } - if (label != "stable" && labelNumber <= 0) { + if (label != "stable" && !isCustomBuild && labelNumber <= 0) { throw new ArgumentException($"Version label \"{label}\" with numeric identifier {labelNumber} is invalid"); } if (label == "stable" && labelNumber >= 0) { diff --git a/GodotEnv/src/features/godot/models/IOVersionStringConverter.cs b/GodotEnv/src/features/godot/models/IOVersionStringConverter.cs index 00666e64..e93e626d 100644 --- a/GodotEnv/src/features/godot/models/IOVersionStringConverter.cs +++ b/GodotEnv/src/features/godot/models/IOVersionStringConverter.cs @@ -5,10 +5,13 @@ namespace Chickensoft.GodotEnv.Features.Godot.Models; public partial class IOVersionStringConverter : IVersionStringConverter { private readonly ReleaseVersionStringConverter _releaseConverter = new(); private readonly SharpVersionStringConverter _sharpConverter = new(); + private readonly CustomBuildVersionStringConverter _customBuildConverter = new(); public GodotVersion ParseVersion(string version) { var trimmedVersion = version.TrimStart('v'); Exception releaseEx = null!; + Exception sharpEx = null!; + try { return _releaseConverter.ParseVersion(trimmedVersion); } @@ -19,7 +22,13 @@ public GodotVersion ParseVersion(string version) { return _sharpConverter.ParseVersion(trimmedVersion); } catch (Exception ex) { - throw new ArgumentException($"Version string {version} is neither release style ({releaseEx.Message}) nor GodotSharp style ({ex.Message})"); + sharpEx = ex; + } + try { + return _customBuildConverter.ParseVersion(trimmedVersion); + } + catch (Exception ex) { + throw new ArgumentException($"Version string {version} is neither release style ({releaseEx.Message}), GodotSharp style ({sharpEx.Message}) nor custom build style ({ex.Message})"); } }