godot/modules/mono/editor/GodotTools/GodotTools/Utils/OS.cs

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 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;
        }
    }
}