diff --git a/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs b/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs new file mode 100644 index 0000000000..585c8c9a86 --- /dev/null +++ b/Examples/UICatalog/Scenarios/OpenProcess/OpenChildInAnotherProcess.cs @@ -0,0 +1,287 @@ +#nullable enable + +using System.Diagnostics; +using System.IO.Pipes; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("OpenChildInAnotherProcess", "Open Child In Another Process")] +[ScenarioCategory ("Application")] +public sealed class OpenChildInAnotherProcess : Scenario +{ + public override void Main () + { + // Only work with legacy + Application.Init (); + + // Setup - Create a top-level application window and configure it. + Window appWindow = new () + { + Title = GetQuitKeyAndName (), + BorderStyle = LineStyle.None + }; + + var label = new Label { X = Pos.Center (), Y = 3 }; + + var button = new Button + { + X = Pos.Center (), + Y = 1, + Title = "_Open Child In Another Process" + }; + + button.Accepting += async (_, e) => + { + // When Accepting is handled, set e.Handled to true to prevent further processing. + button.Enabled = false; + e.Handled = true; + label.Text = await OpenNewTerminalWindowAsync ("EditName") ?? string.Empty; + button.Enabled = true; + }; + + appWindow.Add (button, label); + + Application.Run (appWindow); + appWindow.Dispose (); + + Application.Shutdown (); + } + + public static async Task OpenNewTerminalWindowAsync (string action) + { + var pipeName = "RunChildProcess"; + + // Start named pipe server before launching child + var server = new NamedPipeServerStream (pipeName, PipeDirection.In); + + // Launch external console process running UICatalog app again + var p = new Process (); + + if (OperatingSystem.IsWindows ()) + { + p.StartInfo.FileName = Environment.ProcessPath!; + p.StartInfo.Arguments = $"{pipeName} --child --action \"{action}\""; + p.StartInfo.UseShellExecute = true; // Needed so it opens a new terminal window + } + else + { + var executable = $"dotnet {Assembly.GetExecutingAssembly ().Location}"; + var arguments = $"{pipeName} --child --action \"{action}\""; + UnixTerminalHelper.AdjustTerminalProcess (executable, arguments, p); + } + + try + { + p.Start (); + } + catch (Exception ex) + { + // Catch any other unexpected exception + Console.WriteLine ($@"Failed to launch terminal: {ex.Message}"); + + return default (T?); + } + + // Wait for connection from child + await server.WaitForConnectionAsync (); + + using var reader = new StreamReader (server); + string json = await reader.ReadToEndAsync (); + + return JsonSerializer.Deserialize (json)!; + } +} + +public static class UnixTerminalHelper +{ + private static readonly string [] _knownTerminals = + { + // Linux + "gnome-terminal", + "konsole", + "xfce4-terminal", + "xterm", + "lxterminal", + "tilix", + "mate-terminal", + "alacritty", + "terminator", + + // macOS + "Terminal", "iTerm" + }; + + public static void AdjustTerminalProcess (string executable, string arguments, Process p) + { + var command = $"{executable} {arguments}"; + var escaped = $"{command.Replace ("\"", "\\\"")} && exit"; + string script; + string? terminal = DetectTerminalProcess (); + + if (IsRunningOnWsl ()) + { + terminal = "cmd.exe"; + } + else if (terminal is null) + { + throw new InvalidOperationException ( + "No supported terminal emulator found. Install gnome-terminal, xterm, konsole, etc."); + } + + p.StartInfo.FileName = OperatingSystem.IsMacOS () ? "osascript" : terminal; + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardInput = false; + p.StartInfo.RedirectStandardOutput = false; + p.StartInfo.RedirectStandardError = false; + + // Use -- to avoid TTY reuse + switch (terminal) + { + case "cmd.exe": + p.StartInfo.ArgumentList.Add ("/c"); + p.StartInfo.ArgumentList.Add ($"start wsl {command}"); + + break; + case "Terminal": + script = $""" + tell application "Terminal" + activate + do script "{escaped}" + end tell + """; + + p.StartInfo.ArgumentList.Add ("-e"); + p.StartInfo.ArgumentList.Add (script); + + break; + case "iTerm": + script = $""" + + tell application "iTerm" + create window with default profile + tell current session of current window + write text "{escaped}" + end tell + end tell + """; + + p.StartInfo.ArgumentList.Add ("-e"); + p.StartInfo.ArgumentList.Add (script); + + break; + case "gnome-terminal": + case "tilix": + case "mate-terminal": + p.StartInfo.ArgumentList.Add ("--"); + p.StartInfo.ArgumentList.Add ("bash"); + p.StartInfo.ArgumentList.Add ("-c"); + p.StartInfo.ArgumentList.Add (command); + + break; + case "konsole": + p.StartInfo.ArgumentList.Add ("-e"); + p.StartInfo.ArgumentList.Add ($"bash -c \"{command}\""); + + break; + case "xfce4-terminal": + case "lxterminal": + p.StartInfo.ArgumentList.Add ("--command"); + p.StartInfo.ArgumentList.Add ($"bash -c \"{command}\""); + + break; + case "xterm": + p.StartInfo.ArgumentList.Add ("-e"); + p.StartInfo.ArgumentList.Add ($"bash -c \"{command}\""); + + break; + default: + throw new NotSupportedException ($"Terminal detected but unsupported mapping: {terminal}"); + } + } + + public static string? DetectTerminalProcess () + { + int pid = Process.GetCurrentProcess ().Id; + + while (pid > 1) + { + int? ppid = GetParentProcessId (pid); + + if (ppid is null) + { + break; + } + + try + { + var parent = Process.GetProcessById (ppid.Value); + + string? match = _knownTerminals + .FirstOrDefault (t => parent.ProcessName.Contains (t, StringComparison.OrdinalIgnoreCase)); + + if (match is { }) + { + return match; + } + + pid = parent.Id; + } + catch + { + break; + } + } + + return null; // unknown + } + + public static bool IsRunningOnWsl () + { + if (Environment.GetEnvironmentVariable ("WSL_DISTRO_NAME") != null) + { + return true; + } + + if (File.Exists ("/proc/sys/kernel/osrelease") + && File.ReadAllText ("/proc/sys/kernel/osrelease") + .Contains ("microsoft", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private static int? GetParentPidUnix (int pid) + { + try + { + string output = Process.Start ( + new ProcessStartInfo + { + FileName = "ps", + ArgumentList = { "-o", "ppid=", "-p", pid.ToString () }, + RedirectStandardOutput = true + })!.StandardOutput.ReadToEnd (); + + return int.TryParse (output.Trim (), out int ppid) ? ppid : null; + } + catch + { + return null; + } + } + + private static int? GetParentProcessId (int pid) + { + if (!RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) + { + return GetParentPidUnix (pid); + } + + return null; + } +} diff --git a/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs b/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs new file mode 100644 index 0000000000..f2b4325441 --- /dev/null +++ b/Examples/UICatalog/Scenarios/OpenProcess/RunChildProcess.cs @@ -0,0 +1,84 @@ +#nullable enable + +using System.IO.Pipes; +using System.Text.Json; + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("RunChildProcess", "Run Child Process from Open Child In Another Process")] +[ScenarioCategory ("Application")] +public sealed class RunChildProcess : Scenario +{ + /// + public override void Main () + { + // Only work with legacy + Application.Init (); + Application.Run (); + Application.TopRunnable?.Dispose (); + Application.Shutdown (); + } + + public static async Task RunChildAsync (string pipeName, string action) + { + // Run your Terminal.Gui UI + object result = await RunMyDialogAsync (action); + + // Send result back + await using var client = new NamedPipeClientStream (".", pipeName, PipeDirection.Out); + await client.ConnectAsync (); + + string json = JsonSerializer.Serialize (result); + await using var writer = new StreamWriter (client); + await writer.WriteAsync (json); + await writer.FlushAsync (); + } + + public static Task RunMyDialogAsync (string action) + { + TaskCompletionSource tcs = new (); + string? result = null; + + IApplication app = Application.Create (); + + app.Init (); + + var win = new Window () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + Title = $"Child Window: {action}" + }; + + var input = new TextField + { + X = 1, + Y = 1, + Width = 30 + }; + + var ok = new Button + { + X = 1, + Y = 3, + Text = "Ok", + IsDefault = true + }; + ok.Accepting += (_, e) => + { + result = input.Text; + app.RequestStop (); + e.Handled = true; + }; + + win.Add (input, ok); + + app.Run (win); + win.Dispose (); + app.Shutdown (); + + tcs.SetResult (result ?? string.Empty); + + return tcs.Task; + } +} diff --git a/Examples/UICatalog/UICatalog.cs b/Examples/UICatalog/UICatalog.cs index 884e65fd18..225be60dd8 100644 --- a/Examples/UICatalog/UICatalog.cs +++ b/Examples/UICatalog/UICatalog.cs @@ -25,6 +25,7 @@ using Serilog; using Serilog.Core; using Serilog.Events; +using UICatalog.Scenarios; using Command = Terminal.Gui.Input.Command; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -141,9 +142,18 @@ private static int Main (string [] args) .ToArray () ); + Option childOption = new ("--child", "Run in child mode"); + + Option actionOption = new ( + name: "--action", + description: "Optional action for child window", + getDefaultValue: () => string.Empty + ); + var rootCommand = new RootCommand ("A comprehensive sample library and test app for Terminal.Gui") { - scenarioArgument, debugLogLevel, benchmarkFlag, benchmarkTimeout, resultsFile, driverOption, disableConfigManagement + scenarioArgument, debugLogLevel, benchmarkFlag, benchmarkTimeout, resultsFile, driverOption, disableConfigManagement, + childOption, actionOption }; rootCommand.SetHandler ( @@ -157,7 +167,9 @@ private static int Main (string [] args) Benchmark = context.ParseResult.GetValueForOption (benchmarkFlag), BenchmarkTimeout = context.ParseResult.GetValueForOption (benchmarkTimeout), ResultsFile = context.ParseResult.GetValueForOption (resultsFile) ?? string.Empty, - DebugLogLevel = context.ParseResult.GetValueForOption (debugLogLevel) ?? "Warning" + DebugLogLevel = context.ParseResult.GetValueForOption (debugLogLevel) ?? "Warning", + IsChild = context.ParseResult.GetValueForOption (childOption), + Action = context.ParseResult.GetValueForOption (actionOption) ?? string.Empty /* etc. */ }; @@ -380,6 +392,13 @@ private static void UICatalogMain (UICatalogCommandLineOptions options) )!); UICatalogTop.CachedSelectedScenario = (Scenario)Activator.CreateInstance (UICatalogTop.CachedScenarios [item].GetType ())!; + if (options.IsChild) + { + Task.Run (async () => await RunChildProcess.RunChildAsync (UICatalogTop.CachedSelectedScenario.GetName (), options.Action)).Wait (); + + return; + } + BenchmarkResults? results = RunScenario (UICatalogTop.CachedSelectedScenario, options.Benchmark); if (results is { }) diff --git a/Examples/UICatalog/UICatalogCommandLineOptions.cs b/Examples/UICatalog/UICatalogCommandLineOptions.cs index b297c06ee4..417b295f23 100644 --- a/Examples/UICatalog/UICatalogCommandLineOptions.cs +++ b/Examples/UICatalog/UICatalogCommandLineOptions.cs @@ -16,5 +16,9 @@ public struct UICatalogCommandLineOptions public string ResultsFile { get; set; } public string DebugLogLevel { get; set; } + + public bool IsChild { get; set; } + + public string Action { get; set; } /* etc. */ }