0

My task is to update a program on users' computers. I will do this by running a script on behalf of the system account via the task scheduler.

But before starting the procedure of deleting and installing a new version, I want to get confirmation from the user that this can be done right now (the program will be closed).

I thought to send them a dialog box via Windows Forms MessageBox, but I can't open the form in another user's session.

How can this be done?

Tried System.Windows.Forms.MessageBox. Sent notification via msg.exe - can't process user response result

For example, open a dialog box in a script:

[System.Windows.Forms.MessageBox]::Show($FrmMain,"Install now?", "Title", 4, 32)

I will run the script through the task scheduler on behalf of SYSTEM. But I need the form to open in the user session that is currently open and not in the SYSTEM session.

5
  • What user? All interactive users as there might be multiple logged on? What with non interactive users also on the system? Or is it about a specific user you can name? Commented Mar 31 at 15:05
  • Any domain user who is currently working on a laptop. And who can be seen in the Task manager - Users Commented Mar 31 at 15:28
  • If there is an issue with users being logged on, then this something that is scheduled and all users are notified in advance through company communications. These types of updates are often done outside of normal working hours to minimize impact. Commented Mar 31 at 15:32
  • So lets say you have 5 users currently using a single laptop (and your application) you want to inform all of them and then find somehow consensus if they are all ok with updating? You presumably need an explicit IPC endpoint in your app that the service running as SERVICE can talk to or a different app you write that runs within a user session and you can talk to via some IPC mechanism. Commented Mar 31 at 15:42
  • We have only one user working on each laptop. You only need to get "OK" from him. Commented Mar 31 at 17:44

1 Answer 1

0

If you define custom function Show-MessageToInteractiveUser in your script (source code below), you can call it even from scripts running in the hidden, noninteractive services session - such as scheduled tasks running as SYSTEM - to show a message box in the active interactive user's session (window station), and process the user's response:

# Display a message box on the desktop of whatever user happens to be the
# active interactive one.
$response = 
  Show-MessageToInteractiveUser 'Install now?' 'Title' -Buttons YesNo -Icon Question

if ($response -eq 'Yes') { # The response reflects the name of the button pressed.
  # ... proceed'
}

Note that there's also a -Timeout parameter that accepts a number of seconds after which to automatically close the message box if the user hasn't responded yet; if so, check for 'TimedOut' to detect this condition.
If there is no interactive user session (window session present), 'NoInteractiveUser' is returned.


Show-MessageToInteractiveUser source code:

Note:

  • The function builds on the WTSEnumerateSessions and WTSSendMessage WinAPI functions (WTS refers to Windows Terminal Services, though the functions also work locally); it is a substantially expanded and cleaned-up version of the code in this answer.

  • The assumption is that the local machine is a workstation edition of Windows (e.g. Windows 11), where there can only be (at most) one active interactive window station at a given time, and this window station can either be the local machine itself or that of a user logged-on remotely via RDP (WTS).

    • (I'm unclear on whether on a server edition acting as a WTS server hosting multiple RDP sessions simultaneously they would all be considered active; if so, the message box would show in all of these sessions, and multiple responses would be returned.)
  • Run Show-MessageToInteractiveUser -? for concise help, and Get-Help Show-MessageToInteractiveUser -Detailed for detailed one.

  • The function below is also available as an MIT-licensed Gist, and only the latter will be maintained going forward. Assuming you have looked at the linked code to ensure that it is safe (which I can personally assure you of, but you should always check), you can define it directly as follows (instructions for how to make the function available in future sessions or to convert it to a script will be displayed):

    irm https://gist.github.com/mklement0/f3c44bea260f95306383ba5ff5e24824/raw/Show-MessageToInteractiveUser.ps1 | iex
    
function Show-MessageToInteractiveUser {
<#
.SYNOPSIS
Shows a message box to the local machine's interactive user, if any.

.DESCRIPTION
This Windows-only command synchronously shows a customizable, always-on-top 
message box to the interactive user, i.e to the user logged on to the 
active window station, so as to allow scripts running in the invisible services
session to inform or prompt the interactive user.

Notably, this allows you to call the command from a script that is run via a 
scheduled task configured to run whether or not the chosen user is logged on or
not or configured to run as the SYSTEM account.

NOTE:
  * The assumption is that the local machine is running a workstation edition
    of Windows (as opposed to a server acting an RDP / WTS server), where there
    can be at most ONE interactive user session at a time.
  * If you call this command from an interactive user's window station, the 
    message box will be shown to that user, i.e. it behaves like a regular
    message-box call.

This command outputs:
  * EITHER: The name of the button pressed by the interactive user, e.g. 'Ok',
            using the names passed as part of the -Button argument.
  * OR: One of the following:
    * 'NoInteractiveUser', if there is no interactive user present.
    * 'TimedOut` in case of a timeout (does *not* occur with just an OK button)
    * 'UnknownDueToAsyncCall', if -NoWait was specified.

.PARAMETER Message
The message box' message text (required).

.PARAMETER Caption
The message box' caption (title) text. Defaults to "Notification".

.PARAMETER Buttons
What buttons to present in the message box.
Defaults to just an OK button.
Use tab-completion to see all options.

.PARAMETER DefaultButtonIndex
1-based index of the button to focus, which is the button that will be pressed
when the user presses the space or Enter key.
Default is 1, i.e. the first button.

.PARAMETER Icon
What icon to show in the message box.
Defaults to an information icon.
Use tab-completion to see all options.

.PARAMETER Timeout
Timeout, in seconds, after which the message box will automatically close
if the user hasn't responded by then.
Default is 0, i.e. NO timeout.

.PARAMETER NoWait
If specified, puts up the message box, but doesn't wait for the user to respond.

.EXAMPLE
$result = Show-MessageOnUserDesktop 'Your computer will shut down for an update.' -Buttons OkCancel -Timeout 60 -Icon Warning

Prompts the user for permission to shut down for an update, timing out after 60
seconds. Use $result -in 'NoInteractiveUser', 'Ok', 'TimedOut' to determine 
whether there is no interactive user currently logged on, whether 
the user pressed OK, or whether showing the message timed out.

.NOTES
This command relies on the native WTS (Windows Terminal Services) APIs, 
specifically the WTSEnumerateSessions() and WTSSendMessage() functions.

Credit is due to the following Stack Overflow answer, of which this command is 
a substantially expanded and cleaned-up version:
https://stackoverflow.com/a/75870604/45375
#>

[CmdletBinding()]
param (
  [Parameter(Mandatory, Position = 0)]
  [string] $Message,

  [Parameter(Position = 1)]
  [string] $Caption = 'Notification',

  [ValidateSet('Ok', 'AbortRetryIgnore', 'CancelTryContinue', 'OkCancel', 'RetryCancel', 'YesNo', 'YesNoCancel')]
  [string] $Buttons = 'Ok',

  [ValidateRange(1, 4)]
  [int] $DefaultButtonIndex = 1,

  [ValidateSet('Warning', 'Information', 'Question', 'Error')]
  [string] $Icon = 'Information',

  [int] $Timeout = 0, # in seconds; 0 == NO timeout

  [switch] $NoWait
)

if ($env:OS -ne 'Windows_NT') { throw "This command works on Windows only." }

$winApiHelper = 
Add-Type -PassThru -Namespace NS$PID -Name ($MyInvocation.MyCommand -replace '-', '_' -replace '\..+$') -ErrorAction Stop -MemberDefinition @'
      [DllImport("wtsapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
      public static extern int WTSEnumerateSessions(
          IntPtr hServer,
          UInt64 Reserved,
          UInt64 Version,
          ref IntPtr ppSessionInfo,
          ref UInt64 pCount
      );
      [DllImport("wtsapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
      public static extern bool WTSSendMessage(
          IntPtr hServer,
          UInt64 SessionId,
          String pTitle,
          UInt64 TitleLength,
          String pMessage,
          UInt64 MessageLength,
          UInt64 Style,
          UInt64 Timeout,
          out UInt64 pResponse,
          bool bWait
      );
      [DllImport("wtsapi32.dll", SetLastError = true)]
      public static extern void WTSFreeMemory(
        IntPtr pMemory
      );
'@

function assertOK {
  [CmdletBinding()]
  param([bool] $result)
  if (-not $result) {
    throw [System.ComponentModel.Win32Exception]::new([System.Runtime.InteropServices.Marshal]::GetLastWin32Error()) 
  }
}

[uint32] $style = 0x1000 # 0x00001000L == MB_SYSTEMMODAL
$style += @{
  'Ok'                = 0x0
  'AbortRetryIgnore'  = 0x2
  'CancelTryContinue' = 0x6
  'OkCancel'          = 0x1
  'RetryCancel'       = 0x5
  'YesNo'             = 0x4
  'YesNoCancel'       = 0x3
}[$Buttons]

$style += @{
  'Warning'     = 0x30
  'Information' = 0x40
  'Question'    = 0x20
  'Error'       = 0x10
}[$Icon]

$style += switch ($DefaultButtonIndex) {
  2 { 0x100 }
  3 { 0x200 }
  4 { 0x300 }
}

$pSessionInfo = [System.IntPtr]::Zero; $count = $null
$sessionInfoStructSize = 24 # The size of the WTS_SESSION_INFOW structure in bytes - https://learn.microsoft.com/en-us/windows/win32/api/wtsapi32/ns-wtsapi32-wts_session_infow 
assertOK ($winApiHelper::WTSEnumerateSessions(0, 0, 1, [ref] $pSessionInfo, [ref] $count))
    
# See https://learn.microsoft.com/en-us/windows/win32/api/wtsapi32/ne-wtsapi32-wts_connectstate_class
enum SMTIU_ConnectState {
  Active
  Connected
  ConnectQuery
  Shadow
  Disconnected
  Idle
  Listen
  Reset
  Down
  Init  
}
# See https://learn.microsoft.com/en-us/windows/win32/api/wtsapi32/nf-wtsapi32-wtssendmessagew#parameters
enum SMTIU_Response {
  NoInteractiveUser = -1 
  Error = 0 # Happens if the targeted session is disconnected (such as a local that got suspended due to an RDP session taking over).
  Ok = 1
  Cancel = 2
  Abort = 3
  Retry = 4
  Ignore = 5
  Yes = 6
  No = 7
  TryAgain = 10
  Continue = 11
  UnknownDueToAsyncCall = 32001
  TimedOut = 32000  # Note: Strangely, this doesn't occur with just an OK button - the result is invariably 1 (OK)
}

$activeSessionPresent = $false; $offset = 0
foreach ($i in 0..($count - 1)) {
  $sessionId = [System.BitConverter]::ToUInt64([System.BitConverter]::GetBytes([System.Runtime.InteropServices.Marshal]::ReadInt64($pSessionInfo, $offset)), 0)
  $sessionName = [System.Runtime.InteropServices.Marshal]::PtrToStringUni([System.Runtime.InteropServices.Marshal]::ReadIntPtr($pSessionInfo, $offset + 8))
  $sessionConnectState = [SMTIU_ConnectState] [System.Runtime.InteropServices.Marshal]::ReadInt32($pSessionInfo, $offset + 16)
  # Only target the active session.
  # ?? With multiple RDP sessions on a server, can there be more than one session in this state?
  if ($sessionConnectState -eq 'Active') {
    $activeSessionPresent = $true
    # Show the message box.
    [SMTIU_Response] $response = [SMTIU_Response]::Error 
    assertOK ($winApiHelper::WTSSendMessage(0, $sessionId, $Caption, $Caption.Length * 2, $Message, $Message.Length * 2, $style, $Timeout, [ref] $response, -not $NoWait))
    # Output the response only, but, if verbose output is enabled, also output the session ID and name.
    Write-Verbose (
      [pscustomobject] @{ 
        SessionId   = $sessionId
        SessionName = $sessionName
        Response    = $response 
      }
    )
    $response # output
  }
  $offset += $sessionInfoStructSize 
}
$winApiHelper::WTSFreeMemory($pSessionInfo)

if (-not $activeSessionPresent) {
  [SMTIU_Response]::NoInteractiveUser # output
}

}  # function
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.