Last active
April 12, 2025 20:06
-
-
Save mklement0/f3c44bea260f95306383ba5ff5e24824 to your computer and use it in GitHub Desktop.
Windows-only PowerShell function that shows a message box on the desktop of the interactive user, for use from non-interactive contexts such as scheduled tasks.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<# | |
Prerequisites: Windows PowerShell 5.1 or PowerShell 7 | |
License: MIT | |
Author: Michael Klement <[email protected]> | |
DOWNLOAD and DEFINITION OF THE FUNCTION: | |
irm https://gist.github.com/mklement0/f3c44bea260f95306383ba5ff5e24824/raw/Show-MessageToInteractiveUser.ps1 | iex | |
The above directly defines the function below in your session and offers guidance for making it available in future | |
sessions too. | |
DOWNLOAD ONLY: | |
irm https://gist.github.com/mklement0/f3c44bea260f95306383ba5ff5e24824/raw > ./Show-MessageToInteractiveUser.ps1 | |
The above downloads to the specified file, which you then need to dot-source to make the function available | |
in the current session: | |
. ./Show-MessageToInteractiveUser.ps1 | |
To learn what the function does: | |
* see the next comment block | |
* or, once downloaded and defined, invoke the function with -? or pass its name to Get-Help. | |
To define an ALIAS for the function, (also) add something like the following to your $PROFILE: | |
Set-Alias shmi Show-MessageToInteractiveUser | |
#> | |
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 | |
# -------------------------------- | |
# GENERIC INSTALLATION HELPER CODE | |
# -------------------------------- | |
# Provides guidance for making the function persistently available when | |
# this script is either directly invoked from the originating Gist or | |
# dot-sourced after download. | |
# IMPORTANT: | |
# * DO NOT USE `exit` in the code below, because it would exit | |
# the calling shell when Invoke-Expression is used to directly | |
# execute this script's content from GitHub. | |
# * Because the typical invocation is DOT-SOURCED (via Invoke-Expression), | |
# do not define variables or alter the session state via Set-StrictMode, ... | |
# *except in child scopes*, via & { ... } | |
if ($MyInvocation.Line -eq '') { | |
# Most likely, this code is being executed via Invoke-Expression directly | |
# from gist.github.com | |
# To simulate for testing with a local script, use the following: | |
# Note: Be sure to use a path and to use "/" as the separator. | |
# iex (Get-Content -Raw ./script.ps1) | |
# Derive the function name from the invocation command, via the enclosing | |
# script name presumed to be contained in the URL. | |
# NOTE: Unfortunately, when invoked via Invoke-Expression, $MyInvocation.MyCommand.ScriptBlock | |
# with the actual script content is NOT available, so we cannot extract | |
# the function name this way. | |
& { | |
param($invocationCmdLine) | |
# Try to extract the function name from the URL. | |
$funcName = $invocationCmdLine -replace '^.+/(.+?)(?:\.ps1).*$', '$1' | |
if ($funcName -eq $invocationCmdLine) { | |
# Function name could not be extracted, just provide a generic message. | |
# Note: Hypothetically, we could try to extract the Gist ID from the URL | |
# and use the REST API to determine the first filename. | |
Write-Verbose -Verbose "Function is now defined in this session." | |
} | |
else { | |
# Indicate that the function is now defined and also show how to | |
# add it to the $PROFILE or convert it to a script file. | |
Write-Verbose -Verbose @" | |
Function `"$funcName`" is now defined in this session. | |
* If you want to add this function to your `$PROFILE, run the following: | |
"``nfunction $funcName {``n`${function:$funcName}``n}" | Add-Content `$PROFILE | |
* If you want to convert this function into a script file that you can invoke | |
directly, run: | |
"`${function:$funcName}" | Set-Content $funcName.ps1 -Encoding $('utf8' + ('', 'bom')[[bool] (Get-Variable -ErrorAction Ignore IsCoreCLR -ValueOnly)]) | |
"@ | |
} | |
} $MyInvocation.MyCommand.Definition # Pass the original invocation command line to the script block. | |
} | |
else { | |
# Invocation presumably as a local file after manual download, | |
# either dot-sourced (as it should be) or mistakenly directly. | |
& { | |
param($originalInvocation) | |
# Parse this file to reliably extract the name of the embedded function, | |
# irrespective of the name of the script file. | |
$ast = $originalInvocation.MyCommand.ScriptBlock.Ast | |
$funcName = $ast.Find( { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false).Name | |
if ($originalInvocation.InvocationName -eq '.') { | |
# Being dot-sourced as a file. | |
# Provide a hint that the function is now loaded and provide | |
# guidance for how to add it to the $PROFILE. | |
Write-Verbose -Verbose @" | |
Function `"$funcName`" is now defined in this session. | |
If you want to add this function to your `$PROFILE, run the following: | |
"``nfunction $funcName {``n`${function:$funcName}``n}" | Add-Content `$PROFILE | |
"@ | |
} | |
else { | |
# Mistakenly directly invoked. | |
# Issue a warning that the function definition didn't effect and | |
# provide guidance for reinvocation and adding to the $PROFILE. | |
Write-Warning @" | |
This script contains a definition for function "$funcName", but this definition | |
only takes effect if you dot-source this script. | |
To define this function for the current session, run: | |
. "$($originalInvocation.MyCommand.Path)" | |
"@ | |
} | |
} $MyInvocation # Pass the original invocation info to the helper script block. | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment