diff --git a/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/GUIConsole.ConPTY.csproj b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/GUIConsole.ConPTY.csproj new file mode 100644 index 0000000000..dbdcea46b6 --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/GUIConsole.ConPTY.csproj @@ -0,0 +1,7 @@ + + + + netstandard2.0 + + + diff --git a/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Native/ConsoleApi.cs b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Native/ConsoleApi.cs new file mode 100644 index 0000000000..8c0c153a44 --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Native/ConsoleApi.cs @@ -0,0 +1,23 @@ +using System.Runtime.InteropServices; + +namespace GUIConsole.ConPTY.Native +{ + /// + /// PInvoke signatures for Win32's Console API. + /// + static class ConsoleApi + { + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern bool SetConsoleCtrlHandler(ConsoleEventDelegate callback, bool add); + internal delegate bool ConsoleEventDelegate(CtrlTypes ctrlType); + + internal enum CtrlTypes : uint + { + CTRL_C_EVENT = 0, + CTRL_BREAK_EVENT, + CTRL_CLOSE_EVENT, + CTRL_LOGOFF_EVENT = 5, + CTRL_SHUTDOWN_EVENT + } + } +} diff --git a/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Native/ProcessApi.cs b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Native/ProcessApi.cs new file mode 100644 index 0000000000..c0e0d016d6 --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Native/ProcessApi.cs @@ -0,0 +1,86 @@ +using System; +using System.Runtime.InteropServices; + +namespace GUIConsole.ConPTY.Native +{ + /// + /// PInvoke signatures for Win32's Process API. + /// + static class ProcessApi + { + internal const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct STARTUPINFOEX + { + public STARTUPINFO StartupInfo; + public IntPtr lpAttributeList; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct STARTUPINFO + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct SECURITY_ATTRIBUTES + { + public int nLength; + public IntPtr lpSecurityDescriptor; + public int bInheritHandle; + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool InitializeProcThreadAttributeList( + IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref IntPtr lpSize); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool UpdateProcThreadAttribute( + IntPtr lpAttributeList, uint dwFlags, IntPtr attribute, IntPtr lpValue, + IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize); + + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CreateProcess( + string lpApplicationName, string lpCommandLine, ref SECURITY_ATTRIBUTES lpProcessAttributes, + ref SECURITY_ATTRIBUTES lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, + IntPtr lpEnvironment, string lpCurrentDirectory, [In] ref STARTUPINFOEX lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool DeleteProcThreadAttributeList(IntPtr lpAttributeList); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern bool CloseHandle(IntPtr hObject); + } +} diff --git a/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Native/PseudoConsoleApi.cs b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Native/PseudoConsoleApi.cs new file mode 100644 index 0000000000..f0c08f5990 --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Native/PseudoConsoleApi.cs @@ -0,0 +1,30 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.Runtime.InteropServices; + +namespace GUIConsole.ConPTY.Native +{ + /// + /// PInvoke signatures for Win32's PseudoConsole API. + /// + static class PseudoConsoleApi + { + internal const uint PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016; + + [StructLayout(LayoutKind.Sequential)] + internal struct COORD + { + public short X; + public short Y; + } + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern int CreatePseudoConsole(COORD size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern int ClosePseudoConsole(IntPtr hPC); + + [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] + internal static extern bool CreatePipe(out SafeFileHandle hReadPipe, out SafeFileHandle hWritePipe, IntPtr lpPipeAttributes, int nSize); + } +} diff --git a/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Processes/Process.cs b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Processes/Process.cs new file mode 100644 index 0000000000..14bfb03135 --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Processes/Process.cs @@ -0,0 +1,70 @@ +using System; +using System.Runtime.InteropServices; +using static GUIConsole.ConPTY.Native.ProcessApi; + +namespace GUIConsole.ConPTY.Processes +{ + /// + /// Represents an instance of a process. + /// + internal sealed class Process : IDisposable + { + public Process(STARTUPINFOEX startupInfo, PROCESS_INFORMATION processInfo) + { + StartupInfo = startupInfo; + ProcessInfo = processInfo; + } + + public STARTUPINFOEX StartupInfo { get; } + public PROCESS_INFORMATION ProcessInfo { get; } + + #region IDisposable Support + + private bool disposedValue = false; // To detect redundant calls + + void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + // dispose managed state (managed objects). + } + + // dispose unmanaged state + + // 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); + } + + disposedValue = true; + } + } + + ~Process() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + #endregion + } +} diff --git a/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Processes/ProcessFactory.cs b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Processes/ProcessFactory.cs new file mode 100644 index 0000000000..06e99e9d9c --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Processes/ProcessFactory.cs @@ -0,0 +1,99 @@ +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using static GUIConsole.ConPTY.Native.ProcessApi; + +namespace GUIConsole.ConPTY.Processes +{ + /// + /// 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 ProcessFactory + { + /// + /// Start and configure a process. The return value represents the process and should be disposed. + /// + internal static Process Start(string command, IntPtr attributes, IntPtr hPC) + { + var startupInfo = ConfigureProcessThread(hPC, attributes); + var processInfo = RunProcess(ref startupInfo, command); + return new Process(startupInfo, processInfo); + } + + private static STARTUPINFOEX ConfigureProcessThread(IntPtr hPC, IntPtr attributes) + { + // this method implements the behavior described in https://docs.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session#preparing-for-creation-of-the-child-process + + 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 Win32Exception(Marshal.GetLastWin32Error(), "Could not calculate the number of bytes for the attribute list."); + } + + 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 Win32Exception(Marshal.GetLastWin32Error(), "Could not set up attribute list."); + } + + 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 Win32Exception(Marshal.GetLastWin32Error(), "Could not set pseudoconsole thread attribute."); + } + + 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 Win32Exception(Marshal.GetLastWin32Error(), "Could not create process."); + } + + return pInfo; + } + } +} diff --git a/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/PseudoConsole.cs b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/PseudoConsole.cs new file mode 100644 index 0000000000..eaabb07f02 --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/PseudoConsole.cs @@ -0,0 +1,40 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.ComponentModel; +using static GUIConsole.ConPTY.Native.PseudoConsoleApi; + +namespace GUIConsole.ConPTY +{ + /// + /// 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 Win32Exception(createResult, "Could not create pseudo console."); + } + return new PseudoConsole(hPC); + } + + public void Dispose() + { + ClosePseudoConsole(Handle); + } + } +} diff --git a/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/PseudoConsolePipe.cs b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/PseudoConsolePipe.cs new file mode 100644 index 0000000000..5716e24c48 --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/PseudoConsolePipe.cs @@ -0,0 +1,48 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using static GUIConsole.ConPTY.Native.PseudoConsoleApi; + +namespace GUIConsole.ConPTY +{ + /// + /// 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 readonly SafeFileHandle ReadSide; + public readonly SafeFileHandle WriteSide; + + public PseudoConsolePipe() + { + if (!CreatePipe(out ReadSide, out WriteSide, IntPtr.Zero, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "failed to create pipe"); + } + } + + #region IDisposable + + void Dispose(bool disposing) + { + if (disposing) + { + ReadSide?.Dispose(); + WriteSide?.Dispose(); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + #endregion + } +} diff --git a/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Terminal.cs b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Terminal.cs new file mode 100644 index 0000000000..bedc7b117b --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.ConPTY/Terminal.cs @@ -0,0 +1,113 @@ +using GUIConsole.ConPTY.Processes; +using Microsoft.Win32.SafeHandles; +using System; +using System.IO; +using System.Threading; +using static GUIConsole.ConPTY.Native.ConsoleApi; + +namespace GUIConsole.ConPTY +{ + /// + /// Class for managing communication with the underlying console, and communicating with its pseudoconsole. + /// + public sealed class Terminal + { + private const string ExitCommand = "exit\r"; + private const string CtrlC_Command = "\x3"; + private SafeFileHandle _consoleInputPipeWriteHandle; + private StreamWriter _consoleInputWriter; + + /// + /// A stream of VT-100-enabled output from the console. + /// + public FileStream ConsoleOutStream { get; private set; } + + /// + /// Fired once the console has been hooked up and is ready to receive input. + /// + public event EventHandler OutputReady; + + public Terminal() + { + + } + + /// + /// 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 + /// The height (in characters) to start the pseudoconsole with. Defaults to 80. + /// The width (in characters) to start the pseudoconsole with. Defaults to 30. + public void Start(string command, int consoleWidth = 80, int consoleHeight = 30) + { + using (var inputPipe = new PseudoConsolePipe()) + using (var outputPipe = new PseudoConsolePipe()) + using (var pseudoConsole = PseudoConsole.Create(inputPipe.ReadSide, outputPipe.WriteSide, consoleWidth, consoleHeight)) + using (var process = ProcessFactory.Start(command, PseudoConsole.PseudoConsoleThreadAttribute, pseudoConsole.Handle)) + { + // copy all pseudoconsole output to a FileStream and expose it to the rest of the app + ConsoleOutStream = new FileStream(outputPipe.ReadSide, FileAccess.Read); + OutputReady.Invoke(this, EventArgs.Empty); + + // Store input pipe handle, and a writer for later reuse + _consoleInputPipeWriteHandle = inputPipe.WriteSide; + _consoleInputWriter = new StreamWriter(new FileStream(_consoleInputPipeWriteHandle, FileAccess.Write)) + { + AutoFlush = true + }; + + // free resources in case the console is ungracefully closed (e.g. by the 'x' in the window titlebar) + OnClose(() => DisposeResources(process, pseudoConsole, outputPipe, inputPipe, _consoleInputWriter)); + + WaitForExit(process).WaitOne(Timeout.Infinite); + } + } + + /// + /// Sends the given string to the anonymous pipe that writes to the active pseudoconsole. + /// + /// A string of characters to write to the console. Supports VT-100 codes. + public void WriteToPseudoConsole(string input) + { + if (_consoleInputWriter == null) + { + throw new InvalidOperationException("There is no writer attached to a pseudoconsole. Have you called Start on this instance yet?"); + } + _consoleInputWriter.Write(input); + } + + /// + /// Get an AutoResetEvent that signals when the process exits + /// + private static AutoResetEvent WaitForExit(Process process) => + new AutoResetEvent(false) + { + SafeWaitHandle = new SafeWaitHandle(process.ProcessInfo.hProcess, ownsHandle: false) + }; + + /// + /// 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(); + } + } + } +} diff --git a/samples/ConPTY/GUIConsole/GUIConsole.WPF/App.config b/samples/ConPTY/GUIConsole/GUIConsole.WPF/App.config new file mode 100644 index 0000000000..bae5d6d814 --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.WPF/App.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/samples/ConPTY/GUIConsole/GUIConsole.WPF/App.xaml b/samples/ConPTY/GUIConsole/GUIConsole.WPF/App.xaml new file mode 100644 index 0000000000..e95139446b --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.WPF/App.xaml @@ -0,0 +1,8 @@ + + + + + diff --git a/samples/ConPTY/GUIConsole/GUIConsole.WPF/App.xaml.cs b/samples/ConPTY/GUIConsole/GUIConsole.WPF/App.xaml.cs new file mode 100644 index 0000000000..be938d6d4e --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.WPF/App.xaml.cs @@ -0,0 +1,15 @@ +using System.Windows; + +namespace GUIConsole.Wpf +{ + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application + { + public App() + { + + } + } +} diff --git a/samples/ConPTY/GUIConsole/GUIConsole.WPF/GUIConsole.WPF.csproj b/samples/ConPTY/GUIConsole/GUIConsole.WPF/GUIConsole.WPF.csproj new file mode 100644 index 0000000000..f561ba6fb3 --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.WPF/GUIConsole.WPF.csproj @@ -0,0 +1,105 @@ + + + + + Debug + AnyCPU + {FD2109FE-F78A-4E31-8317-11D1B66B69AF} + WinExe + GUIConsole.Wpf + GUIConsole.Wpf + v4.6.1 + 512 + {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + 4 + true + true + + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + 4.0 + + + + + + + + MSBuild:Compile + Designer + + + MSBuild:Compile + Designer + + + App.xaml + Code + + + MainWindow.xaml + Code + + + + + Code + + + True + True + Resources.resx + + + True + Settings.settings + True + + + ResXFileCodeGenerator + Resources.Designer.cs + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + + + + + + {96634c74-0c52-4381-9477-97e1d58fe5b5} + GUIConsole.ConPTY + + + + \ No newline at end of file diff --git a/samples/ConPTY/GUIConsole/GUIConsole.WPF/MainWindow.xaml b/samples/ConPTY/GUIConsole/GUIConsole.WPF/MainWindow.xaml new file mode 100644 index 0000000000..766abcfca8 --- /dev/null +++ b/samples/ConPTY/GUIConsole/GUIConsole.WPF/MainWindow.xaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + GUIConsole + + +