637c57473e
- Move from rather ad-hoc, error-prone resource management to IDisposable, which should give us a bit more enforcement. - Optimistically remove "buggy" from readme because the known bugs are now fixed! The main source of bugs was the incorrect InitializeProcThreadAttributeList usage. - Handle ctrl-c by forwarding it to the PseudoConsole - Handle terminal close when the window close button is used - Use .NET's CopyTo in the CopyPipeToOutput, it's much simpler code and seems more robust than the ReadFile/WriteFile approach - Minor refactor to split native APIs to multiple files
151 lines
5.8 KiB
C#
151 lines
5.8 KiB
C#
using Microsoft.Win32.SafeHandles;
|
|
using System;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using static MiniTerm.Native.ConsoleApi;
|
|
|
|
namespace MiniTerm
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal sealed class Terminal
|
|
{
|
|
private const string ExitCommand = "exit\r";
|
|
private const string CtrlC_Command = "\x3";
|
|
|
|
public Terminal()
|
|
{
|
|
EnableVirtualTerminalSequenceProcessing();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Newer versions of the windows console support interpreting virtual terminal sequences, we just have to opt-in
|
|
/// </summary>
|
|
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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
/// <param name="command">the command to run, e.g. cmd.exe</param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads terminal input and copies it to the PseudoConsole
|
|
/// </summary>
|
|
/// <param name="inputWriteSide">the "write" side of the pseudo console input pipe</param>
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Don't let ctrl-c kill the terminal, it should be sent to the process in the terminal.
|
|
/// </summary>
|
|
private static void ForwardCtrlC(StreamWriter writer)
|
|
{
|
|
Console.CancelKeyPress += (sender, e) =>
|
|
{
|
|
e.Cancel = true;
|
|
writer.Write(CtrlC_Command);
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads PseudoConsole output and copies it to the terminal's standard out.
|
|
/// </summary>
|
|
/// <param name="outputReadSide">the "read" side of the pseudo console output pipe</param>
|
|
private static void CopyPipeToOutput(SafeFileHandle outputReadSide)
|
|
{
|
|
using (var terminalOutput = Console.OpenStandardOutput())
|
|
using (var pseudoConsoleOutput = new FileStream(outputReadSide, FileAccess.Read))
|
|
{
|
|
pseudoConsoleOutput.CopyTo(terminalOutput);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set a callback for when the terminal is closed (e.g. via the "X" window decoration button).
|
|
/// Intended for resource cleanup logic.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|