From fe779bff36dc139c8615a4cd78133bfff4d4bc27 Mon Sep 17 00:00:00 2001 From: Rob Reynolds Date: Fri, 1 Jan 2016 10:18:24 -0600 Subject: [PATCH] (GH-8) Implement Custom PowerShell Host Implement a custom PowerShell host. This allows for the following: * Read-Host timeouts when running with confirm prompts so that the host doesn't block forever. It also allows the default to automatically be selected. * ReadKey timeouts when running with confirm prompts. * Write-* values all get logged with the choco log all in the proper location instead of needing to parse the values to determine which log they should go to. * Write-Progress shows up inline. --- src/chocolatey/chocolatey.csproj | 5 +- .../infrastructure/powershell/PoshHost.cs | 115 +++++++ .../powershell/PoshHostRawUserInterface.cs | 115 +++++++ .../powershell/PoshHostUserInterface.cs | 310 ++++++++++++++++++ 4 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 src/chocolatey/infrastructure/powershell/PoshHost.cs create mode 100644 src/chocolatey/infrastructure/powershell/PoshHostRawUserInterface.cs create mode 100644 src/chocolatey/infrastructure/powershell/PoshHostUserInterface.cs diff --git a/src/chocolatey/chocolatey.csproj b/src/chocolatey/chocolatey.csproj index 304efc15cb..59ce4fa0a4 100644 --- a/src/chocolatey/chocolatey.csproj +++ b/src/chocolatey/chocolatey.csproj @@ -122,6 +122,9 @@ + + + @@ -295,4 +298,4 @@ --> - + \ No newline at end of file diff --git a/src/chocolatey/infrastructure/powershell/PoshHost.cs b/src/chocolatey/infrastructure/powershell/PoshHost.cs new file mode 100644 index 0000000000..302e1f5cdc --- /dev/null +++ b/src/chocolatey/infrastructure/powershell/PoshHost.cs @@ -0,0 +1,115 @@ +// Copyright © 2011 - Present RealDimensions Software, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace chocolatey.infrastructure.powershell +{ + using System; + using System.Globalization; + using System.Management.Automation.Host; + using app; + using app.configuration; + + public class PoshHost : PSHost + { + private readonly ChocolateyConfiguration _configuration; + private readonly Guid _hostId = Guid.NewGuid(); + private readonly PoshHostUserInterface _psUI; + + private bool _isClosing; + + // http://blogs.msdn.com/b/kebab/archive/2014/04/28/executing-powershell-scripts-from-c.aspx + // https://msdn.microsoft.com/en-us/library/ee706594(v=vs.85).aspx + // http://powershellstation.com/2009/11/10/writing-your-own-powershell-hosting-app-part-4/ + + // others + // http://stackoverflow.com/questions/16329448/hosting-powershell-powershell-vs-runspace-vs-runspacepool-vs-pipeline + // + + public int ExitCode { get; set; } + public Exception HostException { get; set; } + public bool StandardErrorWritten { get { return _psUI.StandardErrorWritten; } } + + public PoshHost(ChocolateyConfiguration configuration) + { + ExitCode = -1; + _configuration = configuration; + _psUI = new PoshHostUserInterface(configuration); + } + + public override void SetShouldExit(int exitCode) + { + if (!_isClosing) + { + _isClosing = true; + } + + ExitCode = exitCode; + } + + public override CultureInfo CurrentCulture + { + get { return CultureInfo.InvariantCulture; } + } + + public override CultureInfo CurrentUICulture + { + get { return CultureInfo.InvariantCulture; } + } + + public override Guid InstanceId + { + get { return _hostId; } + } + + public override string Name + { + get { return ApplicationParameters.Name + "_PSHost"; } + } + + public override PSHostUserInterface UI + { + get { return _psUI; } + } + + public override Version Version + { + get { return new Version(_configuration.Information.ChocolateyVersion); } + } + + #region Not Implemented / Empty + + public override void NotifyBeginApplication() + { + // no state to hold + } + + public override void NotifyEndApplication() + { + // no state to restore + } + + public override void EnterNestedPrompt() + { + throw new NotImplementedException("Nested prompt not implemented"); + } + + public override void ExitNestedPrompt() + { + throw new NotImplementedException("Nested prompt not implemented"); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/chocolatey/infrastructure/powershell/PoshHostRawUserInterface.cs b/src/chocolatey/infrastructure/powershell/PoshHostRawUserInterface.cs new file mode 100644 index 0000000000..d9a1445d27 --- /dev/null +++ b/src/chocolatey/infrastructure/powershell/PoshHostRawUserInterface.cs @@ -0,0 +1,115 @@ +// Copyright © 2011 - Present RealDimensions Software, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace chocolatey.infrastructure.powershell +{ + using System; + using System.Management.Automation.Host; + + public class PoshHostRawUserInterface : PSHostRawUserInterface + { + public override ConsoleColor BackgroundColor + { + get { return Console.BackgroundColor; } + set { Console.BackgroundColor = value; } + } + + public override Size BufferSize + { + get { return new Size(Console.BufferWidth, Console.BufferHeight); } + set { Console.SetBufferSize(value.Width, value.Height); } + } + + public override Coordinates CursorPosition { get; set; } + + public override int CursorSize + { + get { return Console.CursorSize; } + set { Console.CursorSize = value; } + } + + public override ConsoleColor ForegroundColor + { + get { return Console.ForegroundColor; } + set { Console.ForegroundColor = value; } + } + + public override bool KeyAvailable + { + get { return Console.KeyAvailable; } + } + + public override Size MaxPhysicalWindowSize + { + get { return new Size(Console.LargestWindowWidth, Console.LargestWindowHeight); } + } + + public override Size MaxWindowSize + { + get { return new Size(Console.LargestWindowWidth, Console.LargestWindowHeight); } + } + + public override Coordinates WindowPosition + { + get { return new Coordinates(Console.WindowLeft, Console.WindowTop); } + set { Console.SetWindowPosition(value.X, value.Y); } + } + + public override Size WindowSize + { + get { return new Size(Console.WindowWidth, Console.WindowHeight); } + set { Console.SetWindowSize(value.Width, value.Height); } + } + + public override string WindowTitle + { + get { return Console.Title; } + set { Console.Title = value; } + } + + #region Not Implemented / Empty + + public override void FlushInputBuffer() + { + } + + public override KeyInfo ReadKey(ReadKeyOptions options) + { + throw new NotImplementedException(); + } + + public override void SetBufferContents(Coordinates origin, BufferCell[,] contents) + { + throw new NotImplementedException(); + } + + public override void SetBufferContents(Rectangle rectangle, BufferCell fill) + { + throw new NotImplementedException(); + } + + public override BufferCell[,] GetBufferContents(Rectangle rectangle) + { + throw new NotImplementedException(); + } + + public override void ScrollBufferContents(Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill) + { + throw new NotImplementedException(); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/chocolatey/infrastructure/powershell/PoshHostUserInterface.cs b/src/chocolatey/infrastructure/powershell/PoshHostUserInterface.cs new file mode 100644 index 0000000000..38ce8c2d6e --- /dev/null +++ b/src/chocolatey/infrastructure/powershell/PoshHostUserInterface.cs @@ -0,0 +1,310 @@ +// Copyright © 2011 - Present RealDimensions Software, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace chocolatey.infrastructure.powershell +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Globalization; + using System.Management.Automation; + using System.Management.Automation.Host; + using System.Security; + using System.Text; + using app.configuration; + using logging; + using Console = adapters.Console; + + public class PoshHostUserInterface : PSHostUserInterface + { + private readonly ChocolateyConfiguration _configuration; + protected readonly Console Console = new Console(); + private readonly PoshHostRawUserInterface _rawUi = new PoshHostRawUserInterface(); + private const int TIMEOUT_IN_SECONDS = 30; + + public bool StandardErrorWritten { get; set; } + + public PoshHostUserInterface(ChocolateyConfiguration configuration) + { + _configuration = configuration; + } + + /// + /// Depending on whether we allow prompting or not, we will set Console.ReadLine(). + /// If the user has set confirm all prompts (-y), we still want to give them a + /// chance to make a selection, but it should ultimately time out and move on + /// so it doesn't break unattended operations. + /// + /// + public override string ReadLine() + { + if (!_configuration.PromptForConfirmation) + { + this.Log().Warn(ChocolateyLoggers.Important, @" Confirmation (`-y`) is set. + Respond within {0} seconds or the default selection will be chosen.".format_with(TIMEOUT_IN_SECONDS)); + + return Console.ReadLine(TIMEOUT_IN_SECONDS * 1000); + } + + return Console.ReadLine(); + } + + public override void Write(string value) + { + this.Log().Info(value); + //Console.Write(value); + } + + public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) + { + var originalForegroundColor = System.Console.ForegroundColor; + var originalBackgroundColor = System.Console.BackgroundColor; + System.Console.ForegroundColor = foregroundColor; + System.Console.BackgroundColor = backgroundColor; + + this.Log().Info(value); + + //Console.Write(value); + + System.Console.ForegroundColor = originalForegroundColor; + System.Console.BackgroundColor = originalBackgroundColor; + } + + public override void WriteLine() + { + base.WriteLine(); + } + + public override void WriteLine(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) + { + var originalForegroundColor = System.Console.ForegroundColor; + var originalBackgroundColor = System.Console.BackgroundColor; + System.Console.ForegroundColor = foregroundColor; + System.Console.BackgroundColor = backgroundColor; + + this.Log().Info(value); + + //Console.Write(value); + + System.Console.ForegroundColor = originalForegroundColor; + System.Console.BackgroundColor = originalBackgroundColor; + } + + public override void WriteLine(string value) + { + this.Log().Info(value); + } + + public override void WriteErrorLine(string value) + { + StandardErrorWritten = true; + this.Log().Error(value); + } + + public override void WriteDebugLine(string message) + { + this.Log().Debug(message); + } + + public override void WriteProgress(long sourceId, ProgressRecord record) + { + if (record.PercentComplete == -1) return; + + if (record.PercentComplete == 100) this.Log().Debug(() => "Progress: 100%{0}".format_with(" ".PadRight(20))); + + // http://stackoverflow.com/a/888569/18475 + Console.Write("\rProgress: {0}%{1}".format_with(record.PercentComplete, " ".PadRight(20))); + } + + public override void WriteVerboseLine(string message) + { + this.Log().Info(ChocolateyLoggers.Verbose, "VERBOSE: " + message); + } + + public override void WriteWarningLine(string message) + { + this.Log().Warn("WARNING: " + message); + } + + public override Dictionary Prompt(string caption, string message, Collection descriptions) + { + this.Log().Info(ChocolateyLoggers.Important, caption); + var results = new Dictionary(); + foreach (FieldDescription field in descriptions) + { + if (string.IsNullOrWhiteSpace(field.Label)) this.Log().Warn(field.Name); + else + { + string[] label = get_hotkey_and_label(field.Label); + this.Log().Warn(label[1]); + } + + string selection = ReadLine(); + if (selection == null) return null; + + results[field.Name] = PSObject.AsPSObject(selection); + } + + return results; + } + + /// + /// Parse a string containing a hotkey character. + /// Take a string of the form + /// Yes to &all + /// and returns a two-dimensional array split out as + /// "A", "Yes to all". + /// + /// The string to process + /// + /// A two dimensional array containing the parsed components. + /// + private static string[] get_hotkey_and_label(string input) + { + var result = new[] { String.Empty, String.Empty }; + string[] fragments = input.Split('&'); + if (fragments.Length == 2) + { + if (fragments[1].Length > 0) result[0] = fragments[1][0].to_string().ToUpper(CultureInfo.CurrentCulture); + + result[1] = (fragments[0] + fragments[1]).Trim(); + } + else result[1] = input; + + return result; + } + + public override int PromptForChoice(string caption, string message, Collection choices, int defaultChoice) + { + this.Log().Warn(caption); + + string[,] promptData = build_hotkeys_and_plain_labels(choices); + + // Format the overall choice prompt string to display. + var choicePrompt = new StringBuilder(); + for (int element = 0; element < choices.Count; element++) + { + choicePrompt.Append(String.Format( + CultureInfo.CurrentCulture, + "|{0}> {1} ", + promptData[0, element], + promptData[1, element])); + } + + choicePrompt.Append(String.Format( + CultureInfo.CurrentCulture, + "[Default is ({0}]", + promptData[0, defaultChoice])); + + while (true) + { + this.Log().Warn(choicePrompt.ToString()); + string selection = ReadLine().trim_safe().ToUpper(CultureInfo.CurrentCulture); + + if (selection.Length == 0) return defaultChoice; + + for (int i = 0; i < choices.Count; i++) + { + if (promptData[0, i] == selection) return i; + } + + this.Log().Warn(ChocolateyLoggers.Important, "Invalid choice: " + selection); + } + } + + private static string[,] build_hotkeys_and_plain_labels(Collection choices) + { + var choiceSelections = new string[2, choices.Count]; + + for (int i = 0; i < choices.Count; ++i) + { + string[] hotkeyAndLabel = get_hotkey_and_label(choices[i].Label); + choiceSelections[0, i] = hotkeyAndLabel[0]; + choiceSelections[1, i] = hotkeyAndLabel[1]; + } + + return choiceSelections; + } + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) + { + return PromptForCredential(caption, message, userName, targetName, PSCredentialTypes.Default, PSCredentialUIOptions.Default); + } + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) + { + this.Log().Warn(caption); + if (string.IsNullOrWhiteSpace(userName)) + { + this.Log().Warn("Please provide username:"); + string selection = ReadLine().trim_safe().ToUpper(CultureInfo.CurrentCulture); + + if (selection.Length == 0) selection = targetName; + + if (!string.IsNullOrWhiteSpace(selection)) userName = selection; + } + + var password = string.Empty; + this.Log().Warn("Please provide password:"); + var possibleNonInteractive = !_configuration.PromptForConfirmation; + ConsoleKeyInfo info = possibleNonInteractive ? Console.ReadKey(TIMEOUT_IN_SECONDS * 1000) : Console.ReadKey(true); + while (info.Key != ConsoleKey.Enter) + { + if (info.Key != ConsoleKey.Backspace) + { + Console.Write("*"); + password += info.KeyChar; + info = possibleNonInteractive ? Console.ReadKey(TIMEOUT_IN_SECONDS * 1000) : Console.ReadKey(true); + } + else if (info.Key == ConsoleKey.Backspace) + { + if (!string.IsNullOrEmpty(password)) + { + password = password.Substring(0, password.Length - 1); + // get the location of the cursor + int pos = System.Console.CursorLeft; + // move the cursor to the left by one character + System.Console.SetCursorPosition(pos - 1, System.Console.CursorTop); + // replace it with space + Console.Write(" "); + // move the cursor to the left by one character again + System.Console.SetCursorPosition(pos - 1, System.Console.CursorTop); + } + info = possibleNonInteractive ? Console.ReadKey(TIMEOUT_IN_SECONDS * 1000) : Console.ReadKey(true); + } + } + for (int i = 0; i < password.Length; i++) Console.Write("*"); + System.Console.WriteLine(""); + + if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(password)) + { + this.Log().Warn(ChocolateyLoggers.Important, "A userName or password was not entered. This may result in future failures."); + } + + return new PSCredential(userName, password.to_secure_string()); + } + + public override PSHostRawUserInterface RawUI { get { return _rawUi; } } + + #region Not Implemented / Empty + + public override SecureString ReadLineAsSecureString() + { + throw new NotImplementedException("Reading secure strings is not implemented."); + } + + #endregion + } +}