1

I've looked hard for any examples about .Net events revealing a change of the MainWindowTitle of a process (or a corresponding property).

For detecting changes to file content I can simply use

function Register-FileWatcherEvent {
    param (
        [string]$folder,
        [string]$file,
        [string]$SourceIdentifier = 'File_Changed'
    )

    $watcher = New-Object IO.FileSystemWatcher $folder, $file -Property @{
        IncludeSubdirectories = $false
        EnableRaisingEvents = $true
    }
    $watcher.NotifyFilter = 'LastWrite'

    Register-ObjectEvent $watcher -EventName 'Changed' -SourceIdentifier $SourceIdentifier
}

But what object, properties and notifier should I use for registering an event for change of MainWindowTitle or a corresponding property?

Take 2

I tried messing around with WinEventHooks (and I have no idea what I'm doing here ;)

Some event do get registered but it triggers on mouse over instead of title change...

Add-Type -TypeDefinition @"
    using System;
    using System.Runtime.InteropServices;
    public static class User32 {
        public delegate void WinEventDelegate(
            IntPtr hWinEventHook, 
            uint eventType, 
            IntPtr hwnd, 
            int idObject, 
            int idChild, 
            uint dwEventThread, 
            uint dwmsEventTime);
        [DllImport("user32.dll")]
        public static extern IntPtr SetWinEventHook(
            uint eventMin, 
            uint eventMax, 
            IntPtr hmodWinEventProc, 
            WinEventDelegate lpfnWinEventProc, 
            uint idProcess, 
            uint idThread, uint dwFlags);
        [DllImport("user32.dll")]
        public static extern bool UnhookWinEvent(IntPtr hWinEventHook);
    }
"@

$processId = (Get-Process -Name notepad).Id

$WinEventDelegate = [User32+WinEventDelegate]{
    param (
        $hWinEventHook, 
        $eventType, 
        $hwnd, 
        $idObject, 
        $idChild, 
        $dwEventThread, 
        $dwmsEventTime
    )

    if ($eventType -eq 0x800C) {
        $windowTitle = Get-Process -Id $processId | 
            Select-Object -ExpandProperty MainWindowTitle
        
        Write-Host "Window title changed to: $windowTitle"
    }
}

$hWinEventHook = [User32]::SetWinEventHook(
    0x800C, 
    0x800C, 
    [IntPtr]::Zero, 
    $WinEventDelegate, 
    $processId, 
    0, 
    0
)


$handler = {
    param (
        $sender, 
        $eventArgs
    )
    
    $windowTitle = Get-Process -Id $processId | 
        Select-Object -ExpandProperty MainWindowTitle
    
    Write-Host "Window title changed to: $windowTitle"
}

Register-ObjectEvent -InputObject $null `
    -EventName EventRecord `
    -Action $handler `
    -SourceIdentifier "WindowTitleChanged" `
    -SupportEvent

# To unregister the event, use the following command:
# Unregister-Event -SourceIdentifier "WindowTitleChanged"

0x800C is EVENT_OBJECT_NAMECHANGE, but can be different kind of objects.
MS Learn - Event Constants (Winuser.h)

So I reckon I need to check what was changed at each event trigger?

Dennis
  • 871
  • 9
  • 29

2 Answers2

2

Note:

  • For a truly event-based solution, see this answer, which, however, requires on-demand compilation of C# code that uses P/Invoke WinAPI calls.

In .NET, there is no dedicated event that allows you to respond to changes in a process' .MainWindowTitle property (available on Windows only).

  • The help topic for System.Diagnotics.Process shows the events that are available,Tip of the hat to Santiago. which as of .NET 7 are: OutputDataReceived, ErrorDataReceived (for receiving stdout and stderr output asynchronously) and Exited (for acting on a process' termination).[1]

As a workaround, you can implement a periodic polling approach based on a System.Timers.Timer instance.

  • Note that polling approaches are inherently more CPU-intensive than true events.

  • You can lessen the impact by choosing a longer interval between polling operations, but that could cause you to miss title changes and / or not respond quickly enough.

  • The sample code below uses Register-ObjectEvent with the -Action parameter, which means that the given script block ({ ... }) processes events in a dynamic module, as they arise - assuming that PowerShell is in control of the foreground thread.

    • An alternative approach would be not to use -Action and instead poll for / wait for events with Get-Event / Wait-Event later.
    • See this answer for a juxtaposition of the two approaches.

The following is a self-contained example:

# Create an initially disabled timer that fires every 100 msecs. when enabled.
$timer = [System.Timers.Timer] 100

# Get the process whose title should be monitored.
# (The PowerShell instance itself in this example.s)
$process = Get-Process -Id $PID

# Register for the "Elapsed" event. 
# Note the hashtable passed to -MessageData with relevant data from the caller's
# state, which the -Action scriptblock can access via $Event.MessageData
$eventJob = Register-ObjectEvent -InputObject $timer -EventName Elapsed -Action { 
  ($process = $Event.MessageData.Process).Refresh()
  if (($currTitle = $process.MainWindowTitle) -ne $Event.MessageData.CurrentWindowTitle) {
    Write-Verbose -Verbose (
      'Title of process {0} changed from "{1}" to "{2}".' -f $process.Id, $Event.MessageData.CurrentWindowTitle, $currTitle
    )
    $Event.MessageData.CurrentWindowTitle = $currTitle
  }
} -MessageData @{
  Process = $process
  CurrentWindowTitle = $process.MainWindowTitle
}

# Sample code that sleeps a little and changes the window title, twice.

Write-Verbose -vb 'Starting the timer...'
# Note: The first event will fire only 
#       after the first configured interval has elapsed.
$timer.Start() # alternative: $timer.Enabled = $true
try {

    Write-Verbose -vb 'About to change the title twice...'

    # Simulate foreground activity.
    # Note: Do NOT use Start-Sleep, because event processing is 
    #       BLOCKED during sleep.
    #       Waiting for a non-existent event with a timeout is 
    #       like sleeping, but WITH event processing in the -Action script block.
    Wait-Event -SourceIdentifier NoSuchEvent -Timeout 1

    [Console]::Title = 'foo'
    Wait-Event -SourceIdentifier NoSuchEvent -Timeout 1
    [Console]::Title = 'bar'

    Wait-Event -SourceIdentifier NoSuchEvent -Timeout 1

} finally {
   Write-Verbose -vb 'Stopping the timer and removing the event job...'
   $timer.Dispose()
   Remove-Job $eventJob -Force
}

Sample output:

VERBOSE: Starting the timer...
VERBOSE: About to change the title twice...
VERBOSE: Title of process 3144 changed from "Windows PowerShell" to "foo".
VERBOSE: Title of process 3144 changed from "foo" to "bar".
VERBOSE: Stopping timer and removing the event job...

[1] There's also the inherited Disposed event, but it relates to the .NET instance itself rather than to the process it represents.

mklement0
  • 382,024
  • 64
  • 607
  • 775
1

An alternative answer that is similar to your own P/Invoke-based attempt involving ad hoc-compiled C# code using Add-Type, adapted from this C# answer:

  • The solution involves the SetWinEventHook WinAPI function and its EVENT_OBJECT_NAMECHANGE event

  • You'll pay a once-per-session compilation performance penalty.

  • After that, monitoring will be more responsive and less CPU-intensive than the timer-based solution in the other answer.

# Compile a helper class that uses the SetWinEventHook() WinAPI function
# to listen for name-change events, which includes window-title changes.
# Note that Add-Type -PassThru emits TWO types: the class and the delegate type.
# We don't need to reference the delegate type explicitly on the PowerShell side.
$helperClass, $null = 
Add-Type -PassThru -ReferencedAssemblies System.Console, System.Windows.Forms @'
    using System;
    using System.Windows;
    using System.Windows.Forms;
    using System.Runtime.InteropServices;

    public class NameChangeTracker
    {
        public delegate void WinEventDelegate(
            IntPtr hWinEventHook, uint eventType,
            IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime
        );
        
        [DllImport("user32.dll")]
        static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, IntPtr
          hmodWinEventProc, WinEventDelegate lpfnWinEventProc, uint idProcess,
          uint idThread, uint dwFlags);
        
        [DllImport("user32.dll")]
        static extern bool UnhookWinEvent(IntPtr hWinEventHook);
        
        const uint EVENT_OBJECT_NAMECHANGE = 0x800C;
        const uint WINEVENT_OUTOFCONTEXT = 0;
        const uint WINEVENT_SKIPOWNPROCESS = 2;
            
        public static IntPtr StartMonitoring(WinEventDelegate delegateProc)
        {
            // Listen for name change changes across all processes/threads on current desktop...
            return SetWinEventHook(EVENT_OBJECT_NAMECHANGE, EVENT_OBJECT_NAMECHANGE, IntPtr.Zero,
                    delegateProc, 0, 0, WINEVENT_OUTOFCONTEXT);
        }

        public static void StopMonitoring(IntPtr hHook)
        {
          UnhookWinEvent(hHook);
        }

    }
'@

# Determine the process whose main window title to monitor.
$process = Get-Process Notepad

# Retrieve the process' current main-window title so as to *cache* it.
$null = $process.MainWindowTitle

# Define the event handler (delegate) 
# *and store it in a variable*, so as to prevent it from
# getting garbage-collected prematurely.
$eventProc = {
  param (
    $hWinEventHook, 
    $eventType, 
    $hwnd, 
    $idObject, 
    $idChild, 
    $dwEventThread, 
    $dwmsEventTime
  )
  
  if ($hwnd -eq $process.MainWindowHandle) {
    $oldTitle = $process.MainWindowTitle
    $process.Refresh()
    Write-Host @"
Window title  of process '$($process.Name)' (PID $($process.ID)) changed:
  From: $oldTitle
  To  : $($process.MainWindowTitle)
"@
  } 
}
  
# Start monitoring via the helper class.
$hHook = $helperClass::StartMonitoring($eventProc)

# Start a message loop, which is required for the event monitoring to work.
# Putting up a WinForms message box does that.
$null = [System.Windows.Forms.MessageBox]::Show(
  "Monitoring for window-title changes of process '$($process.Name)' (PID $($process.ID))`nPress OK to stop"
)

# Stop monitoring.
$helperClass::StopMonitoring($hHook)

Note:

  • Because a message loop is required in order to receive events, the above code simply uses a call to [System.Windows.Forms.MessageBox]::Show(), which implicitly provides a message loop while the message box is being displayed.

    • Putting up a GUI message box that you need to close in order to manage the lifetime of the event monitoring may not be your method of choice.
  • A console-based mechanism for indefinite monitoring to be stopped at the user's discretion (unfortunately) requires a solution in which [System.Windows.Forms.Application]::DoEvents() is called in a loop, with intermittent, short sleep intervals to lessen the CPU load, which is the only way to keep PowerShell in control of when to exit; e.g., the following alternative to the [System.Windows.Forms.MessageBox]::Show() call above allows you to use Ctrl-C in the console window to terminate monitoring:

# Start a message loop.
# Note:
#  Call [System.Windows.Forms.Application]::DoEvents() 
#  periodically, returning control to PowerShell in between,
#  so that it gets a chance to handle Ctrl-C.
try {
  while ($true) {
    [System.Windows.Forms.Application]::DoEvents()
     Start-Sleep -Milliseconds 100
  }
}
finally {
  # Stop monitoring.
  $helperClass::StopMonitoring($hHook)
}
mklement0
  • 382,024
  • 64
  • 607
  • 775