windowspowershellkeyboardshutdown

How do I get the PowerShell Scriptin to give me a YES or NO option when I press CTRL+C instead of closing the process directly?


I am using the following PowerShell Script to be able to shut down the computer after a certain time.

# Determine the path to a file in which information about a pending
# shutdown is persisted by this script.
$lastScheduleFile = Join-Path $env:TEMP ('~{0}_Schedule.txt' -f [IO.Path]::GetFileNameWithoutExtension($PSCommandPath))

[datetime] $shutdownTime = 0
# First, see if this script previously scheduled a shutdown.
try { 
  $shutdownTime = ([datetime] (Get-Content -ErrorAction Ignore $lastScheduleFile)).ToUniversalTime()
}
catch {}
# If the time is in the past, by definition it doesn't reflect the true pending shutdown time, so we ignore it.
if ($shutdownTime -lt [datetime]::UtcNow) { 
  $shutdownTime = 0 
}
else {
  # Warn that the retrieved shutdown time isn't *guaranteed* to be correct.
  Write-Warning @'
The pending shutdown time is assumed to be what *this* script last requested, 
which is not guaranteed to be the true time, nor is it guaranteed that a shutdown is even still pending.
'@
}
$shutdownAlreadyPending = $shutdownTime -ne 0

if (-not $shutdownAlreadyPending) {
  # Prompt the user for when (how many minutes / hours and minutes from now) to shut down.
  while ($true) {
    try {
      $secsFromNow = switch -Regex ((Read-Host 'Enter the timespan after which to shut down, either in minutes (e.g. 30) or hours and minutes (e.g. 1:15)').Trim()) {
        '^[1-9]\d*$' { [int] $_ * 60; break }
        '^\d+:\d+$' { ([timespan] $_).TotalSeconds; break }
        default { throw }
      }
      break # input was valid; proceed below.
    }
    catch {
      Write-Warning 'Invalid timespan entered; please try again.'
    }
  }

  # Calculate the resulting shutdown time.
  $shutdownTime = [datetime]::UtcNow.AddSeconds($secsFromNow)

  # Schedule the shutdown via shutdown.exe
  while ($true) {
    # Note: Due to use of /t with a nonzero value, /f is implied,
    #       i.e. the shutdown will be forced at the implied time.
    shutdown /s /t $secsFromNow 
    if ($LASTEXITCODE -eq 1190) {
      # A shutdown/restart is already scheduled. We cannot know what its delay is.
      Write-Warning "A shutdown is already pending. It will be canceled and rescheduled as requsted."
      shutdown /a # Abort the pending shutdown, so that the new one can be requested as scheduled.
      continue
    }
    break
  }

  if ($LASTEXITCODE) {
    # Unexpected error.
    Write-Error 'Scheduling a shutdown failed unexpectedly.'
    exit $LASTEXITCODE
  }

  # Persist the scheduled shutdown time in a file, so that
  # if this script gets killed, we can resume the countdown on re-execution.
  $shutdownTime.ToString('o') > $lastScheduleFile
}

# Show a countdown display or handle a preexisting shutdown request,
# with support for Ctrl-C in order to cancel.
$ctrlCPressed = $true
try {
  [Console]::CursorVisible = $false
  # Display a countdown to the shutdown.
  do {
    $secsRemaining = ($shutdownTime - [datetime]::UtcNow).TotalSeconds
    $timespanRemaining = $shutdownTime - [datetime]::UtcNow
    Write-Host -NoNewline ("`r" + 'SHUTTING DOWN in {0:hh\:mm\:ss}, at {1}. Press Ctrl-C to CANCEL.' -f $timespanRemaining, $shutdownTime.ToLocalTime())
    Start-Sleep -Seconds 1
  } while ($secsRemaining -gt 0)
  # Getting here means that Ctrl-C was NOT pressed.
  $ctrlCPressed = $false
}
finally {
  # Note: Only Write-Host statements can be used in this block.
  [Console]::CursorVisible = $true
  if ($ctrlCPressed) {
    # Abort the pending shutdown.    
    shutdown /a *>$null
    switch ($LASTEXITCODE) {
      0 { Write-Host "`nShutdown aborted by user request." }
      1116 { Write-Host "`n(Shutdown has already been canceled.)" }
      default { Write-Host "`nUNEXPECTED ERROR trying to cancel the pending shutdown."; exit $_ }
    } 
  }
  # Clean up the file in which the last schedule attempt is persisted. 
  Remove-Item -ErrorAction Ignore $lastScheduleFile
  # Note: We consider this way of exiting successful.
  #       If the shutdown is allowed to take place, this script never returns to a caller.
  #       If it *does* return:
  #        * If it is due to a *failure to even schedule* the shutdown (see above), it will be nonzero.
  #        * 0 therefore implies having successfully aborted (canceled) the shutdown.
  exit 0
}

The script works pretty well, except for a few things; but there are a few things i want.

1) If there is an existing shutdown request, the script automatically cancels the request when we press the Ctrl-C key. But I don't want him to cancel it directly. Before canceling I want it to give me an option "Are you sure you want to cancel the existing countdown to shutdown (Y/N)?:".

2) After pressing the Ctrl-C key in the current script, the shutdown request is canceled and it gives the following warning in the terminal:

Shutdown aborted by user request. Terminate batch job (Y/N)?

After selecting and entering Y, the terminal is closed. However, the terminal is also closed after selecting and entering N in the same way.

At this point I need this. If I select the N option, the terminal will not be closed; and ask me to enter a new shutdown request. In other words; should give me the option to set a new shutdown after the current shutdown is cancelled.

If there is someone who has knowledge on this subject, I would like him to know that I am expressing my gratitude with all my sincerity.


Solution

  • As noted in the answer that you took the code in the question from:

    This approach also means that the batch file that you're calling the PowerShell script from won't see the Ctrl-C keypress, and therefore won't show the dreaded Terminate batch job (Y/N)? prompt, which cannot be suppressed (see this answer).

    Here's a simplified proof of concept that shows the core technique; it loops for ca. 10 seconds, during which it detects Ctrl-C keypresses:

    try {
      # Make sure that [Console]::ReadKey() can read Ctrl-C as a regular keypress
      # instead of getting terminated by it.
      [Console]::TreatControlCAsInput = $true
      foreach ($i in 1..100) { # Sample loop that runs for ca. 10 secs.
        Write-Host -NoNewline . # Visualize the passage of time.
        # Check for any keyboard input.
        if ([Console]::KeyAvailable) {
          # Consume the key without displaying it.
          $key = [Console]::ReadKey($true)
          # Check if it represents Ctrl-C
          $ctrlCPressed = $key.Modifiers -eq 'Control' -and $key.Key -eq 'C'
          if ($ctrlCPressed) {
            Write-Verbose -Verbose 'Ctrl-C was pressed'
            # ... take action here.
          }
        }
        # Sleep a little, but not too long, so that 
        # keyboard input checking remains responsive.
        Start-Sleep -Milliseconds 100
      }
    }
    finally {
      # Restore normal Ctrl-C behavior.
      [Console]::TreatControlCAsInput = $false
    }
    

    The full solution in the context of your code:

    # Determine the path to a file in which information about a pending
    # shutdown is persisted by this script.
    $lastScheduleFile = Join-Path $env:TEMP ('~{0}_Schedule.txt' -f [IO.Path]::GetFileNameWithoutExtension($PSCommandPath))
    
    [datetime] $shutdownTime = 0
    # First, see if this script previously scheduled a shutdown.
    try { 
      $shutdownTime = ([datetime] (Get-Content -ErrorAction Ignore $lastScheduleFile)).ToUniversalTime()
    }
    catch {}
    # If the time is in the past, by definition it doesn't reflect the true pending shutdown time, so we ignore it.
    if ($shutdownTime -lt [datetime]::UtcNow) { 
      $shutdownTime = 0 
    }
    else {
      # Warn that the retrieved shutdown time isn't *guaranteed* to be correct.
      Write-Warning @'
    The pending shutdown time is assumed to be what *this* script last requested, 
    which is not guaranteed to be the true time, nor is it guaranteed that a shutdown is even still pending.
    '@
    }
    $shutdownAlreadyPending = $shutdownTime -ne 0
    
    # Loop for potential scheduling and rescheduling
    do {
      
      if (-not $shutdownAlreadyPending -or $reschedule) {
    
        # Prompt the user for when (how many minutes / hours and minutes from now) to shut down.
        # Note: Pressing Ctrl-C at this prompt aborts the entire script instantly.
        while ($true) {
          try {
            $secsFromNow = switch -Regex ((Read-Host 'Enter the timespan after which to shut down, either in minutes (e.g. 30) or hours and minutes (e.g. 1:15)').Trim()) {
              '^[1-9]\d*$' { [int] $_ * 60; break }
              '^\d+:\d+$' { ([timespan] $_).TotalSeconds; break }
              default { throw }
            }
            break # input was valid; proceed below.
          }
          catch {
            Write-Warning 'Invalid timespan entered; please try again.'
          }
        }
        $shutdownTime = [datetime]::UtcNow.AddSeconds($secsFromNow)
          
        # Schedule the shutdown via shutdown.exe
        while ($true) {
          # Note: Due to use of /t with a nonzero value, /f is implied,
          #       i.e. the shutdown will be forced at the implied time.
          shutdown /s /t $secsFromNow 
          if ($LASTEXITCODE -eq 1190) {
            # A shutdown/restart is already scheduled. We cannot know for what time.
            Write-Warning "A shutdown is already pending. It will be canceled and rescheduled as reqeusted."
            shutdown /a # Abort the pending shutdown, so that the new one can be requested as scheduled.
            continue
          }
          break
        }
      
        if ($LASTEXITCODE) {
          # Unexpected error.
          Write-Error 'Scheduling the shutdown failed unexpectedly.'
          exit $LASTEXITCODE
        }
      
        # Persist the scheduled shutdown time in a file, so that
        # if this script gets killed, we can resume the countdown on re-execution.
        $shutdownTime.ToString('o') > $lastScheduleFile
      }
      
      # Show a countdown display, with support for Ctrl-C in order to cancel.
      $ctrlCPressed = $reschedule = $canceled = $false
      $prevSecsRemaining = 0
      try {
        # Make sure that [Console]::ReadKey() can read Ctrl-C as a regular keypress
        # instead of getting terminated by it.
        [Console]::TreatControlCAsInput = $true
        [Console]::CursorVisible = $false
        do {
          
          $timespanRemaining = $shutdownTime - [datetime]::UtcNow
          if ([int] $timespanRemaining.TotalSeconds -ne $prevSecsRemaining) {
            # Update only if the seconds position changed.
            Write-Host -NoNewline ("`r" + 'SHUTTING DOWN in {0:hh\:mm\:ss}, at {1}. Press Ctrl-C to CANCEL.' -f $timespanRemaining, $shutdownTime.ToLocalTime())
            $prevSecsRemaining = [int] $timespanRemaining.TotalSeconds
          }
        
          # Check for Ctrl-C
          if ([Console]::KeyAvailable) {
            # A keypress is available, consume it without displaying it.
            $key = [Console]::ReadKey($true)
            # Check if it represents Ctrl-C.
            $ctrlCPressed = $key.Modifiers -eq 'Control' -and $key.Key -eq 'C'
            if ($ctrlCPressed) {
              # Use choice.exe to prompt for further action.
              choice.exe /m "`nAre you sure you want to cancel the existing countdown to shutdown? Press N to reschedule instead, or R to resume the current countdown." /c ynr
          
              # Evaluate the user's choice, which is reflected in the exit code
              # and therefore in the automatic $LASTEXITCODE variable.
              # 1 indicates the first choice ('y'), 2 the second ('n')
              switch ($LASTEXITCODE) {
                1 {
                  # YES: Cancel the shutdown and exit
                  Write-Host 'Canceling shutdown.'
                  $canceled = $true
                  break 
                }  
                2 {
                  # NO: Cancel the shutdown, but schedule a new one.
                  Write-Host 'Canceling current shutdown. Please schedule a new one now:';
                  $reschedule = $true
                } 
                3 {
                  # RESUME: keep the current schedule and keep counting down.
                }
                Default { 
                  # With three choices, this would imply a value of 0, which would signal having pressed Ctrl-C again.
                  # Due to [Console]::TreatControlCAsInput = $false, however, you won't get here.
                }
              }
    
              if ($reschedule -or $canceled) { break }
    
            }
          }
        
          # Sleep only for a short while, so that checking for keyboard input is responsive.
          Start-Sleep -Milliseconds 100
        
        } while ($timespanRemaining -gt 0)
      }
      finally {
        # Clean up / restore settings.
        [Console]::CursorVisible = $true
        [Console]::TreatControlCAsInput = $false
        # Clean up the file in which the last schedule attempt is persisted. 
        # This is appropriate even if the loop is exited due to having intercepted Ctrl-C
        Remove-Item -ErrorAction Ignore $lastScheduleFile
      }
    
      if ($reschedule -or $canceled) {
    
        # Cancel the pending shutdown 
        shutdown /a *>$null
        switch ($LASTEXITCODE) {
          0 { <# OK #> }
          1116 { <# Shutdown has unexpectedly already been canceled, but that is a benign condition #> }
          default { Write-Host "`nUNEXPECTED ERROR trying to cancel the pending shutdown."; exit $_ }
        }
    
      }
    
    
    } while ($reschedule)
    
    # Exit the script here: either the shutdown has begun, or it has been canceled without replacement.
    exit