using System.Diagnostics; using System.Text; using NLog; using StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Helper; namespace StabilityMatrix.Core.Processes; public static class ProcessRunner { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); /// /// Opens the given URL in the default browser. /// /// URL as string public static void OpenUrl(string url) { Logger.Debug($"Opening URL '{url}'"); Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } /// /// Opens the given URL in the default browser. /// /// URI, using AbsoluteUri component public static void OpenUrl(Uri url) { OpenUrl(url.AbsoluteUri); } /// /// Opens the given folder in the system file explorer. /// public static async Task OpenFolderBrowser(string directoryPath) { if (Compat.IsWindows) { using var process = new Process(); process.StartInfo.FileName = "explorer.exe"; process.StartInfo.Arguments = Quote(directoryPath); process.Start(); await process.WaitForExitAsync().ConfigureAwait(false); } else if (Compat.IsLinux) { using var process = new Process(); process.StartInfo.FileName = directoryPath; process.StartInfo.UseShellExecute = true; process.Start(); await process.WaitForExitAsync().ConfigureAwait(false); } else if (Compat.IsMacOS) { using var process = new Process(); process.StartInfo.FileName = "open"; process.StartInfo.Arguments = Quote(directoryPath); process.Start(); await process.WaitForExitAsync().ConfigureAwait(false); } else { throw new PlatformNotSupportedException(); } } /// /// Opens the given file within its folder in the system file explorer. /// public static async Task OpenFileBrowser(string filePath) { if (Compat.IsWindows) { using var process = new Process(); process.StartInfo.FileName = "explorer.exe"; process.StartInfo.Arguments = $"/select, {Quote(filePath)}"; process.Start(); await process.WaitForExitAsync().ConfigureAwait(false); } else if (Compat.IsLinux) { using var process = new Process(); process.StartInfo.FileName = "dbus-send"; process.StartInfo.Arguments = "--print-reply --dest=org.freedesktop.FileManager1 " + "/org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems " + $"array:string:\"file://{filePath}\" string:\"\""; process.StartInfo.UseShellExecute = true; process.Start(); await process.WaitForExitAsync().ConfigureAwait(false); } else if (Compat.IsMacOS) { using var process = new Process(); process.StartInfo.FileName = "explorer"; process.StartInfo.Arguments = $"-R {Quote(filePath)}"; process.Start(); await process.WaitForExitAsync().ConfigureAwait(false); } else { throw new PlatformNotSupportedException(); } } /// /// Starts and tracks a process. /// private static Process StartTrackedProcess(Process process) { process.Start(); // Currently only supported on Windows if (Compat.IsWindows) { // Supress errors here since the process may have already exited try { ProcessTracker.AddProcess(process); } catch (InvalidOperationException) { } } return process; } public static async Task GetProcessOutputAsync( string fileName, string arguments, string? workingDirectory = null, Dictionary? environmentVariables = null ) { Logger.Debug($"Starting process '{fileName}' with arguments '{arguments}'"); var info = new ProcessStartInfo { FileName = fileName, Arguments = arguments, UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true }; if (environmentVariables != null) { foreach (var (key, value) in environmentVariables) { info.EnvironmentVariables[key] = value; } } if (workingDirectory != null) { info.WorkingDirectory = workingDirectory; } using var process = new Process(); process.StartInfo = info; StartTrackedProcess(process); var output = await process.StandardOutput.ReadToEndAsync(); await process.WaitForExitAsync(); return output; } public static Process StartProcess( string fileName, string arguments, string? workingDirectory = null, Action? outputDataReceived = null, IReadOnlyDictionary? environmentVariables = null ) { Logger.Debug($"Starting process '{fileName}' with arguments '{arguments}'"); var info = new ProcessStartInfo { FileName = fileName, Arguments = arguments, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; if (environmentVariables != null) { foreach (var (key, value) in environmentVariables) { info.EnvironmentVariables[key] = value; } } if (workingDirectory != null) { info.WorkingDirectory = workingDirectory; } var process = new Process { StartInfo = info }; StartTrackedProcess(process); if (outputDataReceived == null) return process; process.OutputDataReceived += (sender, args) => outputDataReceived(args.Data); process.ErrorDataReceived += (sender, args) => outputDataReceived(args.Data); process.BeginOutputReadLine(); process.BeginErrorReadLine(); return process; } public static AnsiProcess StartAnsiProcess( string fileName, string arguments, string? workingDirectory = null, Action? outputDataReceived = null, IReadOnlyDictionary? environmentVariables = null ) { Logger.Debug( $"Starting process '{fileName}' with arguments '{arguments}' in working directory '{workingDirectory}'" ); var info = new ProcessStartInfo { FileName = fileName, Arguments = arguments, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; if (environmentVariables != null) { foreach (var (key, value) in environmentVariables) { info.EnvironmentVariables[key] = value; } } if (workingDirectory != null) { info.WorkingDirectory = workingDirectory; } var process = new AnsiProcess(info); StartTrackedProcess(process); if (outputDataReceived != null) { process.BeginAnsiRead(outputDataReceived); } return process; } public static AnsiProcess StartAnsiProcess( string fileName, IEnumerable arguments, string? workingDirectory = null, Action? outputDataReceived = null, Dictionary? environmentVariables = null ) { // Quote arguments containing spaces var args = string.Join(" ", arguments.Where(s => !string.IsNullOrEmpty(s)).Select(Quote)); return StartAnsiProcess( fileName, args, workingDirectory, outputDataReceived, environmentVariables ); } public static async Task RunBashCommand( string command, string workingDirectory = "" ) { // Escape any single quotes in the command var escapedCommand = command.Replace("\"", "\\\""); var arguments = $"-c \"{escapedCommand}\""; Logger.Info($"Running bash command [bash {arguments}]"); var processInfo = new ProcessStartInfo("bash", arguments) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = workingDirectory, }; using var process = new Process(); process.StartInfo = processInfo; var stdout = new StringBuilder(); var stderr = new StringBuilder(); process.OutputDataReceived += (_, args) => stdout.Append(args.Data); process.ErrorDataReceived += (_, args) => stderr.Append(args.Data); StartTrackedProcess(process); process.BeginOutputReadLine(); process.BeginErrorReadLine(); await process.WaitForExitAsync().ConfigureAwait(false); return new ProcessResult { ExitCode = process.ExitCode, StandardOutput = stdout.ToString(), StandardError = stderr.ToString() }; } public static Task RunBashCommand( IEnumerable commands, string workingDirectory = "" ) { // Quote arguments containing spaces var args = string.Join(" ", commands.Select(Quote)); return RunBashCommand(args, workingDirectory); } /// /// Quotes argument with double quotes if it contains spaces, /// and does not already start and end with double quotes. /// public static string Quote(string argument) { var inner = argument.Trim('"'); return inner.Contains(' ') ? $"\"{inner}\"" : argument; } /// /// Check if the process exited with the expected exit code. /// /// Process to check. /// Expected exit code. /// Process stdout. /// Process stderr. /// Thrown if exit code does not match expected value. // ReSharper disable once MemberCanBePrivate.Global public static Task ValidateExitConditionAsync( Process process, int expectedExitCode = 0, string? stdout = null, string? stderr = null ) { var exitCode = process.ExitCode; if (exitCode == expectedExitCode) { return Task.CompletedTask; } var pName = process.StartInfo.FileName; var msg = $"Process {pName} failed with exit-code {exitCode}. stdout: '{stdout}', stderr: '{stderr}'"; Logger.Error(msg); throw new ProcessException(msg); } /// /// Waits for process to exit, then validates exit code. /// /// Process to check. /// Expected exit code. /// Cancellation token. /// Thrown if exit code does not match expected value. public static async Task WaitForExitConditionAsync( Process process, int expectedExitCode = 0, CancellationToken cancelToken = default ) { var stdout = new StringBuilder(); var stderr = new StringBuilder(); process.OutputDataReceived += (_, args) => stdout.Append(args.Data); process.ErrorDataReceived += (_, args) => stderr.Append(args.Data); await process.WaitForExitAsync(cancelToken).ConfigureAwait(false); await ValidateExitConditionAsync( process, expectedExitCode, stdout.ToString(), stderr.ToString() ) .ConfigureAwait(false); } }