diff --git a/samples/ConPTY/MiniTerm/MiniTerm/Process.cs b/samples/ConPTY/MiniTerm/MiniTerm/Process.cs new file mode 100644 index 0000000000..be79b5fb3e --- /dev/null +++ b/samples/ConPTY/MiniTerm/MiniTerm/Process.cs @@ -0,0 +1,133 @@ +using System; +using System.Runtime.InteropServices; +using static MiniTerm.Native.ProcessApi; + +namespace MiniTerm +{ + /// + /// Support for starting and configuring processes. + /// + /// + /// Possible to replace with managed code? The key is being able to provide the PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE attribute + /// + static class Process + { + /// + /// Start and configure a process. The return value should be considered opaque, and eventually disposed. + /// + internal static ProcessResources Start(IntPtr hPC, string command, IntPtr attributes) + { + var startupInfo = ConfigureProcessThread(hPC, attributes); + var processInfo = RunProcess(ref startupInfo, "cmd.exe"); + return new ProcessResources(startupInfo, processInfo); + } + + private static STARTUPINFOEX ConfigureProcessThread(IntPtr hPC, IntPtr attributes) + { + var lpSize = IntPtr.Zero; + var success = InitializeProcThreadAttributeList( + lpAttributeList: IntPtr.Zero, + dwAttributeCount: 1, + dwFlags: 0, + lpSize: ref lpSize + ); + if (success || lpSize == IntPtr.Zero) // we're not expecting `success` here, we just want to get the calculated lpSize + { + throw new InvalidOperationException("Could not calculate the number of bytes for the attribute list. " + Marshal.GetLastWin32Error()); + } + + var startupInfo = new STARTUPINFOEX(); + startupInfo.StartupInfo.cb = Marshal.SizeOf(); + startupInfo.lpAttributeList = Marshal.AllocHGlobal(lpSize); + + success = InitializeProcThreadAttributeList( + lpAttributeList: startupInfo.lpAttributeList, + dwAttributeCount: 1, + dwFlags: 0, + lpSize: ref lpSize + ); + if (!success) + { + throw new InvalidOperationException("Could not set up attribute list. " + Marshal.GetLastWin32Error()); + } + + success = UpdateProcThreadAttribute( + lpAttributeList: startupInfo.lpAttributeList, + dwFlags: 0, + attribute: attributes, + lpValue: hPC, + cbSize: (IntPtr)IntPtr.Size, + lpPreviousValue: IntPtr.Zero, + lpReturnSize: IntPtr.Zero + ); + if (!success) + { + throw new InvalidOperationException("Could not set pseudoconsole thread attribute. " + Marshal.GetLastWin32Error()); + } + + return startupInfo; + } + + private static PROCESS_INFORMATION RunProcess(ref STARTUPINFOEX sInfoEx, string commandLine) + { + int securityAttributeSize = Marshal.SizeOf(); + var pSec = new SECURITY_ATTRIBUTES { nLength = securityAttributeSize }; + var tSec = new SECURITY_ATTRIBUTES { nLength = securityAttributeSize }; + var success = CreateProcess( + lpApplicationName: null, + lpCommandLine: commandLine, + lpProcessAttributes: ref pSec, + lpThreadAttributes: ref tSec, + bInheritHandles: false, + dwCreationFlags: EXTENDED_STARTUPINFO_PRESENT, + lpEnvironment: IntPtr.Zero, + lpCurrentDirectory: null, + lpStartupInfo: ref sInfoEx, + lpProcessInformation: out PROCESS_INFORMATION pInfo + ); + if (!success) + { + throw new InvalidOperationException("Could not create process. " + Marshal.GetLastWin32Error()); + } + + return pInfo; + } + + private static void CleanUp(STARTUPINFOEX startupInfo, PROCESS_INFORMATION processInfo) + { + // Free the attribute list + if (startupInfo.lpAttributeList != IntPtr.Zero) + { + DeleteProcThreadAttributeList(startupInfo.lpAttributeList); + Marshal.FreeHGlobal(startupInfo.lpAttributeList); + } + + // Close process and thread handles + if (processInfo.hProcess != IntPtr.Zero) + { + CloseHandle(processInfo.hProcess); + } + if (processInfo.hThread != IntPtr.Zero) + { + CloseHandle(processInfo.hThread); + } + } + + internal sealed class ProcessResources : IDisposable + { + public ProcessResources(STARTUPINFOEX startupInfo, PROCESS_INFORMATION processInfo) + { + StartupInfo = startupInfo; + ProcessInfo = processInfo; + } + + STARTUPINFOEX StartupInfo { get; } + PROCESS_INFORMATION ProcessInfo { get; } + + public void Dispose() + { + CleanUp(StartupInfo, ProcessInfo); + } + } + } +} diff --git a/samples/ConPTY/MiniTerm/MiniTerm/Program.cs b/samples/ConPTY/MiniTerm/MiniTerm/Program.cs new file mode 100644 index 0000000000..6af24169f3 --- /dev/null +++ b/samples/ConPTY/MiniTerm/MiniTerm/Program.cs @@ -0,0 +1,35 @@ +using System; + +namespace MiniTerm +{ + /// + /// C# version of: + /// https://blogs.msdn.microsoft.com/commandline/2018/08/02/windows-command-line-introducing-the-windows-pseudo-console-conpty/ + /// https://docs.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session + /// + /// System Requirements: + /// As of September 2018, requires Windows 10 with the "Windows Insider Program" installed for Redstone 5. + /// Also requires the Windows Insider Preview SDK: https://www.microsoft.com/en-us/software-download/windowsinsiderpreviewSDK + /// + /// + /// Basic design is: + /// Terminal UI starts the PseudoConsole, and controls it using a pair of PseudoConsolePipes + /// Terminal UI will run the Process (cmd.exe) and associate it with the PseudoConsole. + /// + static class Program + { + static void Main(string[] args) + { + try + { + var terminal = new Terminal(); + terminal.Run("cmd.exe"); + } + catch (InvalidOperationException e) + { + Console.Error.WriteLine(e.Message); + throw; + } + } + } +} diff --git a/samples/ConPTY/MiniTerm/MiniTerm/PseudoConsole.cs b/samples/ConPTY/MiniTerm/MiniTerm/PseudoConsole.cs new file mode 100644 index 0000000000..b0072033d4 --- /dev/null +++ b/samples/ConPTY/MiniTerm/MiniTerm/PseudoConsole.cs @@ -0,0 +1,39 @@ +using Microsoft.Win32.SafeHandles; +using System; +using static MiniTerm.Native.PseudoConsoleApi; + +namespace MiniTerm +{ + /// + /// Utility functions around the new Pseudo Console APIs + /// + internal sealed class PseudoConsole : IDisposable + { + public static readonly IntPtr PseudoConsoleThreadAttribute = (IntPtr)PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE; + + public IntPtr Handle { get; } + + private PseudoConsole(IntPtr handle) + { + this.Handle = handle; + } + + internal static PseudoConsole Create(SafeFileHandle inputReadSide, SafeFileHandle outputWriteSide, int width, int height) + { + var createResult = CreatePseudoConsole( + new COORD { X = (short)width, Y = (short)height }, + inputReadSide, outputWriteSide, + 0, out IntPtr hPC); + if(createResult != 0) + { + throw new InvalidOperationException("Could not create psuedo console. Error Code " + createResult); + } + return new PseudoConsole(hPC); + } + + public void Dispose() + { + ClosePseudoConsole(Handle); + } + } +} diff --git a/samples/ConPTY/MiniTerm/MiniTerm/PseudoConsolePipe.cs b/samples/ConPTY/MiniTerm/MiniTerm/PseudoConsolePipe.cs new file mode 100644 index 0000000000..d2e36d6f0c --- /dev/null +++ b/samples/ConPTY/MiniTerm/MiniTerm/PseudoConsolePipe.cs @@ -0,0 +1,33 @@ +using Microsoft.Win32.SafeHandles; +using System; +using static MiniTerm.Native.PseudoConsoleApi; + +namespace MiniTerm +{ + /// + /// A pipe used to talk to the pseudoconsole, as described in: + /// https://docs.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session + /// + /// + /// We'll have two instances of this class, one for input and one for output. + /// + internal sealed class PseudoConsolePipe : IDisposable + { + public SafeFileHandle ReadSide; + public SafeFileHandle WriteSide; + + public PseudoConsolePipe() + { + if (!CreatePipe(out ReadSide, out WriteSide, IntPtr.Zero, 0)) + { + throw new InvalidOperationException("failed to create pipe"); + } + } + + public void Dispose() + { + ReadSide.Dispose(); + WriteSide.Dispose(); + } + } +} diff --git a/samples/ConPTY/MiniTerm/MiniTerm/Terminal.cs b/samples/ConPTY/MiniTerm/MiniTerm/Terminal.cs new file mode 100644 index 0000000000..546355c542 --- /dev/null +++ b/samples/ConPTY/MiniTerm/MiniTerm/Terminal.cs @@ -0,0 +1,150 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using static MiniTerm.Native.ConsoleApi; + +namespace MiniTerm +{ + /// + /// The UI of the terminal. It's just a normal console window, but we're managing the input/output. + /// In a "real" project this could be some other UI. + /// + internal sealed class Terminal + { + private const string ExitCommand = "exit\r"; + private const string CtrlC_Command = "\x3"; + + public Terminal() + { + EnableVirtualTerminalSequenceProcessing(); + } + + /// + /// Newer versions of the windows console support interpreting virtual terminal sequences, we just have to opt-in + /// + private static void EnableVirtualTerminalSequenceProcessing() + { + var iStdOut = GetStdHandle(STD_OUTPUT_HANDLE); + if (!GetConsoleMode(iStdOut, out uint outConsoleMode)) + { + throw new InvalidOperationException("Could not get console mode"); + } + + outConsoleMode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING | DISABLE_NEWLINE_AUTO_RETURN; + if (!SetConsoleMode(iStdOut, outConsoleMode)) + { + throw new InvalidOperationException("Could not enable virtual terminal processing"); + } + } + + /// + /// Start the psuedoconsole and run the process as shown in + /// https://docs.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session#creating-the-pseudoconsole + /// + /// the command to run, e.g. cmd.exe + public void Run(string command) + { + using (var inputPipe = new PseudoConsolePipe()) + using (var outputPipe = new PseudoConsolePipe()) + using (var pseudoConsole = PseudoConsole.Create(inputPipe.ReadSide, outputPipe.WriteSide, Console.WindowWidth, Console.WindowHeight)) + using (var process = Process.Start(pseudoConsole.Handle, command, PseudoConsole.PseudoConsoleThreadAttribute)) + { + // set up a background task to copy all pseudoconsole output to stdout + Task.Run(() => CopyPipeToOutput(outputPipe.ReadSide)); + + // free resources in case the console is ungracefully closed (e.g. by the 'x' in the window titlebar) + OnClose(() => DisposeResources(process, pseudoConsole, outputPipe, inputPipe)); + + // prompt for stdin input and send the result to the pipe. + // blocks until the user types "exit" + CopyInputToPipe(inputPipe.WriteSide); + } + } + + /// + /// Reads terminal input and copies it to the PseudoConsole + /// + /// the "write" side of the pseudo console input pipe + private static void CopyInputToPipe(SafeFileHandle inputWriteSide) + { + using (var writer = new StreamWriter(new FileStream(inputWriteSide, FileAccess.Write))) + { + ForwardCtrlC(writer); + writer.AutoFlush = true; + writer.WriteLine(@"cd \"); + + StringBuilder buffer = new StringBuilder(); + while (true) + { + // send input character-by-character to the pipe + char key = Console.ReadKey(intercept: true).KeyChar; + writer.Write(key); + + // stop the input loop if 'exit' was sent + // TODO: if we have nested cmd.exe process, this will kill all of them which is wrong. + // could we somehow detect when the top-level cmd.exe process has ended and remove this logic? + buffer.Append(key); + if (key == '\r') + { + if (buffer.ToString() == ExitCommand) + { + break; + } + buffer.Clear(); + } + } + } + } + + /// + /// Don't let ctrl-c kill the terminal, it should be sent to the process in the terminal. + /// + private static void ForwardCtrlC(StreamWriter writer) + { + Console.CancelKeyPress += (sender, e) => + { + e.Cancel = true; + writer.Write(CtrlC_Command); + }; + } + + /// + /// Reads PseudoConsole output and copies it to the terminal's standard out. + /// + /// the "read" side of the pseudo console output pipe + private static void CopyPipeToOutput(SafeFileHandle outputReadSide) + { + using (var terminalOutput = Console.OpenStandardOutput()) + using (var pseudoConsoleOutput = new FileStream(outputReadSide, FileAccess.Read)) + { + pseudoConsoleOutput.CopyTo(terminalOutput); + } + } + + /// + /// Set a callback for when the terminal is closed (e.g. via the "X" window decoration button). + /// Intended for resource cleanup logic. + /// + private static void OnClose(Action handler) + { + SetConsoleCtrlHandler(eventType => + { + if(eventType == CtrlTypes.CTRL_CLOSE_EVENT) + { + handler(); + } + return false; + }, true); + } + + private void DisposeResources(params IDisposable[] disposables) + { + foreach (var disposable in disposables) + { + disposable.Dispose(); + } + } + } +}