1

I have a PowerShell script to send a notification to an user when a new fax is added to a folder. Here's the script:

$searchPath = "\\server_name\Public\folder_x\folder_y\FAX"

$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = $searchPath
$watcher.IncludeSubdirectories = $false
$watcher.EnableRaisingEvents = $true

$created = Register-ObjectEvent $watcher "Created" -Action {
  [System.Windows.MessageBox]::Show('New fax in folder \\server_name\Public\folder_x\folder_y\FAX')
}

This script is running when I open it in PowerShell ISE and press Play but it's not working any other way.

I tried making it run from command line after changing my execution policy to Unrestricted, but it's not working. I typed powershell "C:\Users\my_username\Documents\FAX_Warning.ps1" I get no error, it's just not doing anything.

I also tried it in the task manager which is what I ultimately want to use. I made a task to run PowerShell with the path as an argument at every login of the user, but it is not working. Again, it's just not doing anything.

Any idea why it's working in the PowerShell ISE but not in anything else?

Simon F.-Smith
  • 781
  • 3
  • 8

2 Answers2

1

Your problem is because System.Windows.Forms.MessageBox.Show() runs in the session of the caller.
the fact of it working on ISE is because you are running in the session that contains the window handle.
The easiest way of doing this is using P/Invoke, and the WTSSendMessage Win32 Terminal Services API.
This API allows you to send messages to all or selected sessions on a local or remote computer.
There are a LOT of ways to accomplish this, and you can find examples here.
On our method, we list all sessions on the current computer using WTSEnumerateSessions, filter the active, or selected ones and send the message.

First we add the signature for these functions:

Add-Type -Namespace 'Utilities' -Name 'WtsApi' -MemberDefinition @'
[DllImport("wtsapi32.dll", SetLastError = true)]
public static extern int WTSEnumerateSessions(
    IntPtr hServer,
    int Reserved,
    int Version,
    ref IntPtr ppSessionInfo,
    ref int pCount
);
[DllImport("wtsapi32.dll", SetLastError = true)]
public static extern bool WTSSendMessage(
    IntPtr hServer,
    int SessionId,
    String pTitle,
    int TitleLength,
    String pMessage,
    int MessageLength,
    int Style,
    int Timeout,
    out int pResponse,
    bool bWait
);
'@

This method uses the least amount of C# possible, so we're not actually defining the WTS_SESSION_INFO struct. We're taking advantage that the layout of these structs are sequential and that each member it's 8 bytes long.

# Enumerating sessions.
$pSessionInfo = [System.IntPtr]::Zero
$count = 0
[void][Utilities.WtsApi]::WTSEnumerateSessions(0, 0, 1, [ref]$pSessionInfo, [ref]$count)

# Converting the data pointed by $pSessionInfo into a byte array.
$bufferSize = $count * 24
$buffer = [byte[]]::new($bufferSize)
[System.Runtime.InteropServices.Marshal]::Copy($pSessionInfo, $buffer, 0, $bufferSize)

# For each session info structure returned, we send the message.
$Message = 'Test message'
$Caption = 'Test title'
$offset = 0
for ($i = 0; $i -lt $count; $i++) {
    
    # Getting the current struture from the byte array
    $nativeSessionId = [System.BitConverter]::ToInt32($buffer[$offset..($offset + 8)], 0)
    $response = 0

    # Sending the message
    [void][Utilities.WtsApi]::WTSSendMessage(0, $nativeSessionId, $Caption, $Caption.Length, $Message, $Message.Length, $Style, $Timeout, [ref]$response, [bool]$Wait)
    
    # Incrementing the offset for the next operation
    $offset += 24
}

We tidy it up, put into a function inside the script block for your event action:

function New-WtsMessage {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Caption,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Message,

        [Parameter()]
        [long]$Style = 0x00001040L,

        [Parameter()]
        [int]$Timeout = 0,

        [Parameter()]
        [switch]$Wait
    )

    try {
        Add-Type -Namespace 'Utilities' -Name 'WtsApi' -MemberDefinition @'
        [DllImport("wtsapi32.dll", SetLastError = true)]
        public static extern int WTSEnumerateSessions(
            IntPtr hServer,
            int Reserved,
            int Version,
            ref IntPtr ppSessionInfo,
            ref int pCount
        );
        [DllImport("wtsapi32.dll", SetLastError = true)]
        public static extern bool WTSSendMessage(
            IntPtr hServer,
            int SessionId,
            String pTitle,
            int TitleLength,
            String pMessage,
            int MessageLength,
            int Style,
            int Timeout,
            out int pResponse,
            bool bWait
        );
'@
    }
    catch { }

    $pSessionInfo = [System.IntPtr]::Zero
    $count = 0
    [void][Utilities.WtsApi]::WTSEnumerateSessions(0, 0, 1, [ref]$pSessionInfo, [ref]$count)
    
    $bufferSize = $count * 24
    $buffer = [byte[]]::new($bufferSize)
    [System.Runtime.InteropServices.Marshal]::Copy($pSessionInfo, $buffer, 0, $bufferSize)
    
    $offset = 0
    for ($i = 0; $i -lt $count; $i++) {
        $nativeSessionId = [System.BitConverter]::ToInt32($buffer[$offset..($offset + 8)], 0)
        $response = 0
        
        # Session 0 is the system session.
        if ($nativeSessionId -ne 0) {
            [void][Utilities.WtsApi]::WTSSendMessage(0, $nativeSessionId, $Caption, $Caption.Length, $Message, $Message.Length, $Style, $Timeout, [ref]$response, [bool]$Wait)
            [PSCustomObject]@{ SessionId = $nativeSessionId; Response = [WtsMessageResponse]$response }
        }
        $offset += 24
    }
}

New-WtsMessage -Caption 'Warning!' -Message 'New fax in folder \\server_name\Public\folder_x\folder_y\FAX'

Also, it's a good idea putting a check inside the action to make sure it's being triggered, like:

Add-Content -Path C:\Path\To\Folder\FaxFolderFileWatch.log -Value "Hit! $([datetime]::Now)"

Hope it helps.
Happy scripting!

FranciscoNabas
  • 505
  • 3
  • 9
  • Kudos for a sophisticated solution, but note that the OP's primary problem is a different one, as evidenced by the following statement: "I tried making it run from command line after changing my execution policy to Unrestricted, but it's not working." The primary problem is the lack of keeping the objects alive due to the script ending (it just so happens that in the ISE (only) they _do_ stay alive). – mklement0 Apr 05 '23 at 20:06
  • With that problem solved, there may or may not be a subsequent problem with displaying a message box from a scheduled task: if it is set up to run only when a user is logged on (and therefore runs in that / a user's window station), showing a message box is _not_ a problem. – mklement0 Apr 05 '23 at 20:08
  • 1
    My bad, I misinterpreted the question. – FranciscoNabas Apr 05 '23 at 20:10
0

In a regular console window or in Windows Terminal, unless you explicitly dot-source your script (see below), you must keep your script running for as long as you want to process events.
Otherwise, the relevant objects go out of scope, are garbage-collected, and event processing stops.

Any idea why it's working in the PowerShell ISE?

Unlike when you call a script file (*.ps1) from a regular PowerShell console / from Windows Terminal - where the code runs in a child scope - the PowerShell ISE implicitly dot-sources scripts in the global scope (i.e., runs it directly in the global scope).

Therefore, the relevant objects do not go out of scope, and - even though your script has exited, event processing via the -Action script block - which executes in a dynamic module - continues to work, for the remainder of the ISE session.

As an aside:

  • While this behavior happens to be an advantage in this case, its side effects are generally one of the reasons to avoid the ISE, in addition to its obsolescent status - see the following standard advice:

  • The PowerShell ISE is no longer actively developed and there are reasons not to use it (bottom section), notably not being able to run PowerShell (Core) 6+. The actively developed, cross-platform editor that offers the best PowerShell development experience is Visual Studio Code with its PowerShell extension.


To make your script work in a regular console / in Windows Terminal, you have two options:

  • Either: Do explicitly what the ISE does implicitly: use ., the dot-sourcing operator to invoke your script, e.g. . ./someScript.ps1

    • This only works in interactive (stay-open) sessions, and is therefore not (directly) suitable for calls via the PowerShell CLI (powershell.exe for Windows PowerShell, pwsh for PowerShell (Core) 7+), such as from Task Scheduler.
  • Or, preferably: Keep your script running for as long as events should be processed, which could be indefinitely until the user presses Ctrl-C or closes the window.
    Doing so has two advantages:

    • Your script can be invoked normally.
    • Proper cleanup can be performed.
    • Note: This approach is suitable for use in CLI calls, but note that in interactive sessions the script's invocation will then be synchronous (too) and won't return control to the prompt until the script exits (whether on its own or via Ctrl-C).

Here is a keep-running-indefinitely implementation of your script:

  • Note: Calling [System.Windows.MessageBox]::Show() in response to an event is problematic for two reasons: (a) it blocks further processing until the message box is manually closed and (b) it would never even show when running in a scheduled task that is run whether a user is logged on or not - it would, however, show if the task only runs while a user is logged on, along with initially the console window running the PowerShell script. Thus, a potential solution is to configure the task to run at logon of any user, and make it run in the context of BUILTIN\Users, which invariably runs the task visibly in the context of a user logging in, causing it to keep running for the entire user session and showing message boxes in response to events; you can have the script hide its own console window on startup, though you'll still see it flash briefly).
Add-Type -Assembly PresentationCore, PresentationFramework

$searchPath = "\\server_name\Public\folder_x\folder_y\FAX"

$watcher = 
    [System.IO.FileSystemWatcher] @{
        Path = $searchPath
        IncludeSubdirectories = $false
    }
    
$evtJob = Register-ObjectEvent $watcher Created -Action {
  # Note: Using a message box will block further event processing
  #       until the message box is closed.
  $null = [System.Windows.MessageBox]::Show("New fax in folder \\server_name\Public\folder_x\folder_y\FAX:`n$($event|Out-String)")
}
    
# Start watching.
$watcher.EnableRaisingEvents = $true

Write-Verbose -Verbose 'Waiting for events indefinitely; press Ctrl-C to exit...'
try {
  while ($true) {
    # Must sleep only short intervals, because event processing
    # is blocked during sleep.
    Start-Sleep -Milliseconds 100
  }
} finally {
  # Clean up.
  $watcher.Dispose() # Dispose of the watcher.
  $evtJob | Remove-Job -Force # Remove the event job.
}

An alternative implementation that forgoes use of an -Action Register-ObjectEvent script block in favor of using Wait-Event in the indefinite loop:

Add-type -Assembly PresentationCore, PresentationFramework

$searchPath = "\\server_name\Public\folder_x\folder_y\FAX"

$watcher = 
    [System.IO.FileSystemWatcher] @{
        Path = $searchPath
        IncludeSubdirectories = $false
    }

# Register for the Created event, using self-chosen
# source identifier "fsw"
# NOT using -Action means that events must be retrieved on
# demand, using Get-Event or Wait-Event
Register-ObjectEvent -SourceIdentifier fsw $watcher Created

# Start watching.
$watcher.EnableRaisingEvents = $true

Write-Verbose -Verbose 'Waiting for events indefinitely; press Ctrl-C to exit...'
try {
  # Note: Wait-Event waits indefinitely until the next event arriaves.
  #       If you wanted to perform other tasks while waiting for events, 
  #       use a while ($true) loop and use Wait-Event with a -Timeout argument
  #       in the loop body.  
  while ($evt = Wait-Event -SourceIdentifier fsw) {
    $evt | Remove-Event # Events must be manually removed.
    # Note: Using a message box will block further processing
    #       until the message box is closed.
    $null = [System.Windows.MessageBox]::Show("New fax in folder \\server_name\Public\folder_x\folder_y\FAX:`n$($evt|Out-String)")
  }
} finally {
  # Clean up.  
  $watcher.Dispose() # Dispose of the watcher.
  Unregister-Event -SourceIdentifier fsw # Unregister the event.
}
mklement0
  • 382,024
  • 64
  • 607
  • 775