diff --git a/Editor/Extensions/ProcessExtensions.cs b/Editor/Extensions/ProcessExtensions.cs new file mode 100644 index 0000000..9a1df0b --- /dev/null +++ b/Editor/Extensions/ProcessExtensions.cs @@ -0,0 +1,257 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Threading; +using System; + +namespace Utilities.Extensions.Editor +{ + /// + /// Extension class. + /// + public static class ProcessExtensions + { + /// + /// Runs an external process. + /// + /// + /// The passed arguments. + /// The output of the process. + /// The Application to run through the command line. Default application is "cmd.exe" + /// Add the platform command switch to the arguments? + /// Output string. + /// This process will block the main thread of the editor if command takes too long to run. Use for a background process. + public static bool Run(this Process process, string args, out string output, string application = "", bool usePlatformArgs = true) + { + if (string.IsNullOrEmpty(args)) + { + output = "You cannot pass a null or empty parameter."; + UnityEngine.Debug.LogError(output); + return false; + } + + if (usePlatformArgs) + { + SetupPlatformArgs(ref args, ref application); + } + + process.StartInfo = new ProcessStartInfo + { + Arguments = args, + CreateNoWindow = true, + FileName = application, + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + + try + { + if (!process.Start()) + { + output = "Failed to start process!"; + UnityEngine.Debug.LogError(output); + return false; + } + + var error = process.StandardError.ReadToEnd(); + + if (!string.IsNullOrEmpty(error)) + { + output = error; + return false; + } + + output = process.StandardOutput.ReadToEnd(); + + process.WaitForExit(); + process.Close(); + process.Dispose(); + } + catch (Exception e) + { + output = e.Message; + UnityEngine.Debug.LogException(e); + } + + return true; + } + + /// + /// Starts a process asynchronously. + /// + /// This Process. + /// The Process arguments. + /// Should output debug code to Editor Console? + /// + /// + public static async Task RunAsync(this Process process, string args, bool showDebug, CancellationToken cancellationToken = default) + => await RunAsync(process, args, string.Empty, showDebug, cancellationToken); + + /// + /// Starts a process asynchronously. + /// + /// This Process. + /// The process executable to run. + /// The Process arguments. + /// Should output debug code to Editor Console? + /// + /// Add the command platform switch to the arguments? + /// + public static async Task RunAsync(this Process process, string args, string application = "", bool showDebug = false, CancellationToken cancellationToken = default, bool setPlatformArgs = true) + { + if (setPlatformArgs) + { + SetupPlatformArgs(ref args, ref application); + } + + if (showDebug) + { + UnityEngine.Debug.Log($"\"{application}\" {args}"); + } + + return await RunAsync(process, new ProcessStartInfo + { + Arguments = args, + CreateNoWindow = true, + FileName = application, + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + WindowStyle = ProcessWindowStyle.Hidden + }, showDebug, cancellationToken); + } + + /// + /// Starts a process asynchronously. + /// + /// The provided Process Start Info must not use shell execution, and should redirect the standard output and errors. + /// This Process. + /// The Process start info. + /// Should output debug code to Editor Console? + /// + /// + public static async Task RunAsync(this Process process, ProcessStartInfo startInfo, bool showDebug = false, CancellationToken cancellationToken = default) + { + Debug.Assert(!startInfo.UseShellExecute, "Process Start Info must not use shell execution."); + Debug.Assert(startInfo.RedirectStandardOutput, "Process Start Info must redirect standard output."); + Debug.Assert(startInfo.RedirectStandardError, "Process Start Info must redirect standard errors."); + + process.StartInfo = startInfo; + process.EnableRaisingEvents = true; + + var processResult = new TaskCompletionSource(); + var errorCodeResult = new TaskCompletionSource(); + var errorList = new List(); + var outputCodeResult = new TaskCompletionSource(); + var outputList = new List(); + + process.Exited += OnProcessExited; + process.ErrorDataReceived += OnErrorDataReceived; + process.OutputDataReceived += OnOutputDataReceived; + + async void OnProcessExited(object sender, EventArgs args) + { + processResult.TrySetResult(new ProcessResult(process.StartInfo.Arguments, process.ExitCode, await errorCodeResult.Task, await outputCodeResult.Task)); + process.Close(); + process.Dispose(); + process = null; + } + + void OnErrorDataReceived(object sender, DataReceivedEventArgs args) + { + if (!string.IsNullOrWhiteSpace(args.Data)) + { + errorList.Add(args.Data); + + if (!showDebug) + { + return; + } + + UnityEngine.Debug.LogError(args.Data); + } + else + { + errorCodeResult.TrySetResult(errorList.ToArray()); + } + } + + void OnOutputDataReceived(object sender, DataReceivedEventArgs args) + { + if (!string.IsNullOrWhiteSpace(args.Data)) + { + outputList.Add(args.Data); + + if (!showDebug) + { + return; + } + + UnityEngine.Debug.Log(args.Data); + } + else + { + outputCodeResult.TrySetResult(outputList.ToArray()); + } + } + + if (!process.Start()) + { + if (showDebug) + { + UnityEngine.Debug.LogError("Failed to start process!"); + } + + processResult.TrySetResult(new ProcessResult(process.StartInfo.Arguments, process.ExitCode, new[] { "Failed to start process!" }, null)); + } + else + { + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + CancellationWatcher(); + } + + async void CancellationWatcher() + { + // ReSharper disable once MethodSupportsCancellation + // We utilize the cancellation token in the loop + await Task.Run(() => + { + while (process is { HasExited: false }) + { + if (cancellationToken.IsCancellationRequested) + { + processResult.TrySetResult(new ProcessResult(process.StartInfo.Arguments, 1223, new[] { "The operation was canceled by the user." }, new[] { "The operation was canceled by the user." })); + process.Kill(); + process.Close(); + process.Dispose(); + break; + } + } + }); + } + + return await processResult.Task; + } + + private static void SetupPlatformArgs(ref string args, ref string application) + { + var updateApplication = string.IsNullOrWhiteSpace(application); + + if (updateApplication) + { +#if UNITY_EDITOR_WIN + application = "cmd.exe"; + args = $"/c {args}"; +#else + application = "/bin/bash"; + args = $"-c \"{args}\""; +#endif + } + } + } +} diff --git a/Editor/Extensions/ProcessExtensions.cs.meta b/Editor/Extensions/ProcessExtensions.cs.meta new file mode 100644 index 0000000..92be01b --- /dev/null +++ b/Editor/Extensions/ProcessExtensions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e94708f93385d1d46a0416a27d938e80 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Extensions/ProcessResult.cs b/Editor/Extensions/ProcessResult.cs new file mode 100644 index 0000000..67c0d46 --- /dev/null +++ b/Editor/Extensions/ProcessResult.cs @@ -0,0 +1,76 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Text; +using System; + +namespace Utilities.Extensions.Editor +{ + /// + /// Result from a completed asynchronous process. + /// + public class ProcessResult + { + public string Arguments { get; } + + /// + /// Exit code from completed process. + /// + public int ExitCode { get; } + + /// + /// Errors from completed process. + /// + public string[] Errors { get; } + + /// + /// Output from completed process. + /// + public string[] Output { get; } + + /// + /// Constructor for Process Result. + /// + /// The process into arguments. + /// Exit code from completed process. + /// Errors from completed process. + /// Output from completed process. + public ProcessResult(string arguments, int exitCode, string[] errors, string[] output) + { + Arguments = arguments; + ExitCode = exitCode; + Errors = errors; + Output = output; + } + + /// + /// Checks and throws an exception if it is not zero. + /// + /// + public void ThrowIfNonZeroExitCode() + { + if (ExitCode != 0) + { + var messageBuilder = new StringBuilder($"[{ExitCode}] Failed to run: \"{Arguments}\""); + + if (Output != null) + { + + foreach (var line in Output) + { + messageBuilder.Append($"\n{line}"); + } + } + + if (Errors != null) + { + foreach (var line in Errors) + { + messageBuilder.Append($"\n{line}"); + } + } + + throw new Exception(messageBuilder.ToString()); + } + } + } +} diff --git a/Editor/Extensions/ProcessResult.cs.meta b/Editor/Extensions/ProcessResult.cs.meta new file mode 100644 index 0000000..dad1fa4 --- /dev/null +++ b/Editor/Extensions/ProcessResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4472c029eef8a6c46bad0f2371ee7356 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json index 496661a..f75f480 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Utilities.Extensions", "description": "Common extensions for Unity types (UPM)", "keywords": [], - "version": "1.1.15", + "version": "1.1.16", "unity": "2021.3", "documentationUrl": "https://github.com/RageAgainstThePixel/com.utilities.extensions#documentation", "changelogUrl": "https://github.com/RageAgainstThePixel/com.utilities.extensions/releases",