Reputation: 885
This is driving me nuts. If one runs the below script, cancels the $host.ui.promptforchoice
, and checks $Choice
, they get:
PS C:\> $Choice.gettype()
IsPublic IsSerial Name BaseType
-------- -------- ---- --------
True True Int32 System.ValueType
PS C:\> write-host """$Choice"""
"-1"
PS C:\> $Choice -eq -1
True
What on earth am I missing in my do...while loop that has the script exiting (it isn't the return; I commented out the switch statement) without looping!
$s = ""
do
{
$Choice = $host.ui.promptforchoice($ScriptFileName, "$($s)User this script is running under is not an admin acount.`r`n`r`nRunning this script will cause multiple password entries throughout.`r`n`r`nRecommend logging out and logging back in as an admin, then re-running. Proceed anyway?", @("&Yes", "&No"), 1)
switch($Choice)
{
0 {write-host "hello";<#Log ...Proceeding with script#>}
1 {write-host "`r`n`r`nQuitting script. Please login under an admin account and rerun`r`n" -foregroundcolor red -backgroundcolor yellow; return}
}
$s = "Selection Cancelled. Please make a selection. No to quit the script`r`n`r`n"
}
while($Choice -eq -1)
Upvotes: 0
Views: 103
Reputation: 439812
Preface:
The fact that, in later comments, you're referencing a close button (x
), i.e. a GUI element, implies that you're running your code in the Windows PowerShell ISE, where $host.ui.PromptForChoice()
(perhaps surprisingly) presents a GUI dialog rather than a console-based prompt - the latter is what you get in a regular console window / a WT (Windows Terminal) tab (which I'll collectively refer to as a console from now on), and there is no UI element in the console-based prompt that would allow you to cancel it; the only way to cancel is to abort the script as a whole, by pressing Ctrl+C.
Counterintuitively, clicking the close button in the ISE acts like pressing Ctrl+C
in the console, so the problem you need to solve is how to intercept Ctrl+C
, as addressed below.
Arguably, the dialog shouldn't even have a close button (or at least it should be disabled).
That said, the ISE is best avoided:
It is best avoided for running (as opposed to developing) scripts, because its behavior differs from that of consoles in several important ways (see the link in the next bullet point).
More importantly, the ISE is no longer actively developed and there are reasons not to use it (bottom section), notably not being able to run PowerShell (Core) 7. The actively developed, cross-platform editor that offers the best PowerShell development experience is Visual Studio Code with its PowerShell extension.
If you avoid the ISE, you may not need to solve your problem at all, given that the console-based prompt lacks a UI element for canceling that users may be tempted to use (and, should they use Ctrl+C they likely expect the script to be aborted as a whole.
The short of it is: You can not intercept and react to the user pressing Ctrl+C while a $host.UI.PromptForChoice()
is shown from a script; however, you can perform cleanup actions, including emitting a message before the invariable termination of the script.
Santiago's helpful answer shows you a lower-level alternative to $host.UI.PromptForChoice()
that does allow you to react to Ctrl+C, although it requires you to re-create the method's functionality yourself, and necessitates changing the submission logic from typing an option letter followed by Enter to instantly submitting on typing the option letter.
(The GUI solution offered there, via System.Windows.Forms.MessageBox
, isn't a full-fledged alternative, as it doesn't support an open-ended number of choices.)
When aborting a $host.UI.PromptForChoice()
prompt via Ctrl+C, the result of that call is only meaningfully set to -1
at the interactive prompt.
By contrast, doing the same from a script unconditionally terminates that script.
You can not prevent this termination in the case of $host.UI.PromptForChoice()
(see also the bottom section), although you can perform - limited - cleanup actions via the finally
block of a try { ... } catch { ... } finally { ... }
statement.
Inside the finally
block, with which the script's execution invariably ends if Ctrl+C was pressed, the method-call result too is -1
, which can be used to distinguish between regular execution (in which case the result is either 0
or 1
in your case) and aborting via Ctrl+C.
Thus, in your case, the simplest approach is therefore not to try to intercept and react to Ctrl+C (which you cannot, in this case), but to emit a message from the finally
block, using Write-Host
.[1]
# A script block that defines the actions to perform when the
# user aborts the script with Ctrl+C or chooses "No".
$abortActions = {
Write-Host "`r`n`r`nQuitting script. Please log in under an admin account and rerun`r`n" -ForegroundColor red -BackgroundColor yellow
exit 1 # signal non-success
}
try {
$choice = $host.UI.PromptForChoice($ScriptFileName, "User this script is running under is not an admin account.`r`n`r`nRunning this script will cause multiple password entries throughout.`r`n`r`nRecommend logging out and logging back in as an admin, then re-running. Proceed anyway?", @('&Yes', '&No'), 1)
} finally {
if ($choice -eq -1) { # Implies that Ctrl+C was pressed.
& $abortActions
}
}
switch ($choice) {
0 { Write-Host 'hello'; <#Log ...Proceeding with script#> }
1 { & $abortActions }
}
By default, Ctrl+C quietly terminates a PowerShell script / a compiled .NET application:
Note that if Ctrl+C is pressed in PowerShell while a long-running .NET method is being executed, the method runs to completion, and only then is the script terminated.
Pressing Ctrl+C while an interactively submitted command is running only terminates that command, not the entire interactive session.
As discussed and demonstrated above, a finally
block with - limited - cleanup operations can be run in a PowerShell script, but the termination itself cannot be prevented.
.NET does offer a general mechanism to intercept and optionally ignore Ctrl+C, via the Console.CancelKeyPress
event.
Unfortunately, this event isn't directly usable from PowerShell via a script block acting as the event handler, because the "event handler for this event is executed on a thread pool thread", which doesn't work, because these threads do not have a PowerShell runspace associated with them that (which is necessary to execute a script block).
You can, however, use ad hoc-compiled C# code to implement the event handler, as shown in the bottom section.
[Console]::TreatControlCAsInput
= $true
is a - limited - alternative to the CancelKeyPress
event:
It treats Ctrl+C like a regular keypress (key chord) and thereby does not result in termination.
If it is in effect while the user is being prompted with [Console]::ReadKey()
,[2] a System.ConsoleKeyInfo
describing the keypress is returned.
Except in the latter case, where you can examine the System.ConsoleKeyInfo
instance returned to detect whether Ctrl+C was pressed, the net effect is that Ctrl+C is ignored.
Unfortunately, PowerShell seemingly (temporarily) disables this feature in a few situations:
While Read-Host
is waiting for input.
As discussed, while $host.UI.PromptForChoice()
is waiting for input.
Console.CancelKeyPress
event handler in PowerShell with ad hoc-compiled C# code:Add-Type
allows you to compile C# code on demand, which in this case can be used to implement a Console.CancelKeyPress
event handler that can be called from an unspecified thread-pool thread.
The custom event handler below prevents termination of the script and allows it to keep running.
A challenge is how to communicate the fact that Ctrl+C was pressed to the PowerShell script; in the simple implementation below, an environment variable is used.
Caveats and limitations:
The code below works as intended on Windows, but inexplicably not on Unix-like platforms, as of PowerShell 7.5.0: see GitHub issue #24902
The code runs only in PowerShell (Core) 7.[3]
The code:
does not work with $host.UI.PromptForChoice()
and Read-Host
, because they temporarily restore the default Ctrl+C behavior.
does work with [Console]::ReadKey()
and [Console]::ReadLine()
, though note that the latter call is inexplicably aborted, i.e. execution resumes with the next statement.
does work when Ctrl+C is pressed while neither [Console]::ReadKey()
nor [Console]::ReadLine()
are being executed (due to the event callback happening on a separate thread).
# Compile C# code that implements a Console.CancelKeyPress event handler.
Add-Type @'
using System;
public class CancelKeyPressHelper {
public static void myHandler(object sender, ConsoleCancelEventArgs args)
{
args.Cancel = true;
Environment.SetEnvironmentVariable("CtrlCPressed", "1");
}
}
'@
# Register the compiled event hander.
[Console]::add_CancelKeyPress([CancelKeyPressHelper]::myHandler)
$env:CtrlCPressed = $null # Undefine the environment variable that the event handler will set.
Write-Host 'Press Q to quit or Ctrl+C to test the CancelKeyPress event handler...'
while ($true) {
Write-Host -NoNewline .
Start-Sleep -Milliseconds 100
if ($env:CtrlCPressed) {
$env:CtrlCPressed = $null
Write-Verbose -Verbose 'CTRL+C WAS PRESSED.'
}
if ([Console]::KeyAvailable) {
if ([Console]::ReadKey($true).KeyChar -eq 'q') { break }
}
}
[1] Note that success output, whether explicitly with Write-Output
or implicitly, can not be produced from a finally
block invoked in response to Ctrl+C, and neither can error output.
[2] In principle, the same applies to $host.UI.RawUI.ReadKey()
, which even has an AllowCtrlC
option for ad hoc use. However, at least as of PowerShell 7.5.0 / PSReadLine 2.3.6, this is virtually useless in practice:
On Unix-like platforms, AllowCtrlC
is inexplicably ignored on Unix-like platforms, though manually setting [Console]::TreatControlCAsInput = $true
first does work.
On Windows, $host.UI.RawUI.ReadKey('AllowCtrlC, IncludeKeyUp')
works only if the PSReadLine module - which is loaded by default and a vital part of the command-line editing experience - is explicitly unloaded first.
[3] I'm unclear on exactly why. Passing the event-hander method as an event delegate fails during event registration, because Windows PowerShell doesn't recognize the method as a suitable delegate.
Upvotes: 1
Reputation: 60838
If you want to be able to catch CTRL+C as an option you can set TreatControlCAsInput
to true
however, using $HOST.UI.PromptForChoice
is no longer an option if going this route. You can use ReadKey
in this case, here a simple example of how your code should look:
[console]::TreatControlCAsInput = $true
while ($true) {
Write-Host 'Options: [Y / N]'
switch ([System.Console]::ReadKey($true)) {
{ $_.KeyChar -eq 'Y' } { Write-Host 'Y, do stuff...' }
{ $_.KeyChar -eq 'N' } { Write-Host 'Quitting script...'; return }
{ $_.Modifiers -eq 4 -and $_.Key -eq 67 } { Write-Host 'Ctrl+C... do something' }
default { Write-Host 'Invalid choice' }
}
}
Another option that wouldn't allow the user to CTRL+C during the prompt, would be to use a MessageBox
from Windows Forms. You can also combine this approach with TreatControlCAsInput
set to true
if you also want them to avoid being able to terminate the script on one of the switch options code paths.
Add-Type -AssemblyName System.Windows.Forms
while ($true) {
$result = [System.Windows.Forms.MessageBox]::Show(
'Do stuff?',
'Doing stuff',
[System.Windows.Forms.MessageBoxButtons]::YesNo, # Probably just 'OK' is better here
[System.Windows.Forms.MessageBoxIcon]::Question)
switch ($result) {
([System.Windows.Forms.DialogResult]::Yes) { Write-Host 'Do stuff' }
([System.Windows.Forms.DialogResult]::No) { Write-Host 'Quitting...'; return }
}
}
Upvotes: 1