1

I'm trying to detect window title changes of AIMP music player using the SetWinEventHook and it works, the problem is that it also detects Tooltip popups when I hover over buttons with mouse (stop, play, minimize, etc).

I would like to exclude these when setting the SetWinEventHook or filter it out in the WinEventProc event.
Any ideas?

using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Forms;

class NameChangeTracker
{
    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;

    // Need to ensure delegate is not collected while we're using it,
    // storing it in a class field is simplest way to do this.
    static WinEventDelegate procDelegate = new WinEventDelegate(WinEventProc);

    public static void Main()
    {
        // Listen for name change changes across all processes/threads on current desktop...
        IntPtr hhook = SetWinEventHook(EVENT_OBJECT_NAMECHANGE, EVENT_OBJECT_NAMECHANGE, IntPtr.Zero,
                procDelegate, (uint)Process.GetProcessesByName("AIMP").FirstOrDefault().Id, 0, WINEVENT_OUTOFCONTEXT);

        MessageBox.Show("Tracking name changes on HWNDs, close message box to exit.");
        UnhookWinEvent(hhook);
    }

    static void WinEventProc(IntPtr hWinEventHook, uint eventType,
        IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime)
    {
        // filter out non-HWND namechanges... (eg. items within a listbox)
        if (idObject != 0 || idChild != 0) return; 
        
        if (Process.GetProcessesByName("AIMP").FirstOrDefault().MainWindowHandle.ToInt32() == hwnd.ToInt32())
        {
            Console.WriteLine("Current song: " + Process.GetProcessesByName("AIMP").FirstOrDefault().MainWindowTitle);
        }
    }
}

outputs:

Current song: Michael Jackson - Speed Demon
Current song: Minimize
Jimi
  • 29,621
  • 8
  • 43
  • 61
Lakyn
  • 11
  • 4
  • Try filtering the `WinEventProc` `idObject` using, for example, `OBJID_TITLEBAR = 0xFFFFFFFE`. I don't really know where these changes occur, the TitleBar is just a guess. At this time, you're only excluding `SWEH_CHILDID_SELF = 0`. For other possible values see here: [Move window when external application's window moves](https://stackoverflow.com/a/48812831/7444103), you can find all the other object IDs listed in an enumerator. – Jimi Jan 01 '19 at 01:56
  • Actually, I'm excluding everything BUT the `SWEH_CHILDID_SELF`. The `idObject` is `0` for both the window title change and the tooltip popup. – Lakyn Jan 01 '19 at 02:58
  • Yes, sure. That *excluding* was meant to be *filtering*. You have to filter `SWEH_CHILDID_SELF`, because you can receive more than one `idObject` in sequence. I was suggesting to *exclude* all that is not what you're expecting. I have no means to test this now. If you (or others) don't come up with something, I'll give it a look. – Jimi Jan 01 '19 at 03:09
  • Another (unsollicited?) suggestion is to use UI Automation instead of Hooks. Those tools would provide much more precise results in this case. – Jimi Jan 01 '19 at 05:38
  • I'll look into the UIA later tonight. Thanks – Lakyn Jan 01 '19 at 16:48
  • That's a good idea. UI Automation has its learning curve, that's for sure, but you'll be happy with it when you'll get the hang of it. If you don't know about it, Visual Studio installs the **UI Automation Inspect Utility**, 32/64bit (usually in `C:\Program Files (x86)\Windows Kits\10\bin\x64\inspect.exe`). This tool lets you browse all the components (and all their properties) that UI Automation can address. It's somewhat like an *upgraded* Spy++. It's very useful. I suggest to add it to the VS IDE `External Tools` for quick access. You'll need it, not just for Automation. – Jimi Jan 01 '19 at 17:02
  • Well, I probably should have mentioned that I'm quite a noob in programming. I'm afraid this UIA stuff is beyond me. Not many example codes for what I need either. From what I understood I need `AddAutomationPropertyChangedEventHandler` for what I want to do but how do I set it up I have no idea. I tried to do `Automation.AddAutomationPropertyChangedEventHandler(AutomationElement.FocusedElement, TreeScope.Element, new AutomationPropertyChangedEventHandler(OnPropertyChanged),AutomationElement.NameProperty);` but it doesn't do anything. I think I'll have to stick with the `SetWinEventHook`. – Lakyn Jan 02 '19 at 01:31
  • Do you want to test an UI Automation handler that detects when an application's Title Bar value changes? – Jimi Jan 02 '19 at 04:07
  • That's right :) – Lakyn Jan 02 '19 at 04:25
  • Well, I tried to keep it simple and as short as possible. I'm not sure I succeeded here. Give it a try. – Jimi Jan 02 '19 at 10:16
  • Also, if you find this alternative method useful, you'll have to change the question's Title: it doesn't match the content anymore. – Jimi Jan 02 '19 at 10:31

1 Answers1

1

Question objective changed, as discussed in the comments, from a solution using Hooks (SetWinEventHook) to a UI Automation one.


Since you've never used UI Automation before, this could be a rodeo, so I'll try to explain the process of adding Automation Event handlers for some type of events that can be useful for this task.

The task at hand:

Your program needs to be notified when the status of a property of an UI Element (in this case, a TitleBar value) of an Application changes.

First of all, you probably want to know whether the target application is already running when your program starts.
We can use Process.GetProcessesByName() to determine if an application process is active.

  • The target application Main Window needs to be associated with a AutomationElement (the Automation object used to identify an UI object - in other words, an element in the UI Automation tree).

Note:

We cannot associate the target Main Window with a specific Automation Element when setting up an event handler that detects an Application main Window creation.
We could, with the AutomationElement.FromHandle([Handle]) method, using the Handle returned by Process.MainWindowHandle. But this Automation Element will be strictly tied to a specific Process instance, thus a specific Process.Id. If the target Application is closed and reopened, its Process.Id would be different and the Event Handler will not recognize it.

  • We need to associate the Event Handler that detects a Window creation with the AutomationElement.RootElement, representing the root element of the current Desktop (any UI element, or Window, in practice), then determine if it's the Main Windows of the target Application, inspecting some relevant property of the Automation Element provided by the Event as the source object (as any standard event). In the sample code, I'm using the Element.Current.ClassName.
  • Since the target application can be closed at some point, we need to be notified when this happen, too.
    Our program might need to make some decisions based on the status of the target application.
    Or simply notify the User and/or update its own UI.
  • The target application can be opened and closed over and over during the life-time of the program. We will need to track these changes over time.
  • When a property value is changed, we can receive a notification using a AutomationPropertyChangedEventHandler. This event is raised when a specific property of a defined Automation Element or Element Type changes (see the event type descriptions that follow).

UI Automation provides Event Handlers and Patterns that can be used to track all the described events.

Detect when application starts:

We need to set an AutomationEventHandler delegate, using Automation.AddAutomationEventHandler, that raises an event when a Window is created.

The AddAutomationEventHandler requires:

  • The type of Automation Event that will be handled
  • The Automation Element that is associated with the event
  • The scope of Event. The scope can be limited to the Automation Element specified or extended to all its ancestors and descendants elements.
  • The method delegate that will be called when the event is raised

The Event type is provided by the WindowPattern.WindowOpenedEvent field.
The Automation Element can be a specific Element or the RootElement (previously described).
The Scope is provided by the TreeScope enumeration: it can be the Element itself (TreeScope.Element) or all the subtree of the specified Element (TreeScope.Subtree). We're using the latter in this case, it's required when referencing the RootElement in this context.
The method delegate is a standard event handler delegate:

AutomationElement TargetElement = AutomationElement.RootElement;
AutomationEventHandler WindowOpenedHandler = null;

Automation.AddAutomationEventHandler(WindowPattern.WindowOpenedEvent, TargetElement,
    TreeScope.Subtree, WindowOpenedHandler = new AutomationEventHandler(OnTargetOpened));

public void OnTargetOpened(object source, AutomationEventArgs e)
{
    AutomationElement element = source as AutomationElement;
}

Detect when application closes:

Same as above, except the eventId is provided by a WindowPattern.WindowClosedEvent field instead.

Note:

Some Elements and Properties should be cached and accessed activating a pre-defined CacheRequest: not all UIA values can be accessed using the Element.Current object; a cached Element is required in some cases.
I'm deliberately skipping this feature to keep this as simple (and short) as possible.
None of the Elements, Patterns and Property values discussed here strictly need caching, anyway.

Detect when a property value changes:

A property change is notified using a AutomationPropertyChangedEventHandler, which requires:

  • The Automation Element with which we want to associate the event handler.
  • A Scope for the event; in this case, the scope is the Element itself (TreeScope.Element): we only want to track one of its properties, no descendants are involved.
  • An AutomationPropertyChangedEventHandler delegate that will handle the event (standard delegate)
  • One or more UI Automation properties we're interested in.

The Automation Element can be determined using the RootElement (Main Window) FindFirst() method: we need to specify that the searched Element is a descendant (TreeScope.Descendants) and the criteria used to match the Element.

The Docs list all the pre-defined Automation Identifiers for this class.

AutomationPropertyChangedEventHandler TargetTitleBarHandler = null;

Condition titleBarCondition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.TitleBar);

TitleBarElement = RootElement.FindFirst(TreeScope.Descendants, titleBarCondition);

Automation.AddAutomationPropertyChangedEventHandler(TitleBarElement, TreeScope.Element,
    TargetTitleBarHandler = new AutomationPropertyChangedEventHandler(OnTargetTitleBarChange),
    AutomationElement.NameProperty);

public void OnTargetTitleBarChange(object source, AutomationPropertyChangedEventArgs e)
{
    if (e.Property == AutomationElement.NameProperty) { }
}

See also: UI Automation Control Types.


Sample Test Code:

I'm using Windows Notepad as the target Application to track. It can be any other application.
Also, I'm using the Application Class Name to identify it. It could be any other know detail that can single it out.

This code requires a Project reference to:

UIAutomationClient
UIAutomationTypes

using System.Windows.Automation;

AutomationEventHandler NotepadHandlerOpen = null;
AutomationEventHandler NotepadHandlerClose = null;
AutomationPropertyChangedEventHandler NotepadTitleBarHandler = null;
AutomationElement NotepadElement = AutomationElement.RootElement;
AutomationElement TitleBarElement = null;

//-----------------------------------------------------------------------------------
// This section of code can be inserted in the app start, Form/Window constructor
// or the event handler of a controls (a Button.Cick maybe)
//-----------------------------------------------------------------------------------

using (Process NotepadProc = Process.GetProcessesByName("notepad").FirstOrDefault())
{
    try
    {
        Automation.AddAutomationEventHandler(WindowPattern.WindowOpenedEvent, NotepadElement,
            TreeScope.Subtree, NotepadHandlerOpen = new AutomationEventHandler(OnNotepadStart));
    }
    finally
    {
        if (NotepadProc != null)
            this.BeginInvoke(NotepadHandlerOpen, 
                AutomationElement.FromHandle(NotepadProc.MainWindowHandle), 
                new AutomationEventArgs(WindowPattern.WindowOpenedEvent));
    }
}

//-----------------------------------------------------------------------------------

public void OnNotepadStart(object source, AutomationEventArgs e)
{
    AutomationElement element = source as AutomationElement;
    if (e.EventId == WindowPattern.WindowOpenedEvent && element.Current.ClassName.Contains("Notepad"))
    {
        NotepadElement = element;
        Console.WriteLine("Notepad is now opened");
        Condition titleBarCondition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.TitleBar);
        TitleBarElement = NotepadElement.FindFirst(TreeScope.Descendants, titleBarCondition);

        Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, NotepadElement,
            TreeScope.Element, NotepadHandlerClose = new AutomationEventHandler(OnNotepadClose));

        Automation.AddAutomationPropertyChangedEventHandler(TitleBarElement, TreeScope.Element,
            NotepadTitleBarHandler = new AutomationPropertyChangedEventHandler(OnNotepadTitleBarChange),
            AutomationElement.NameProperty);
    }
}

public void OnNotepadClose(object source, AutomationEventArgs e)
{
    if (e.EventId == WindowPattern.WindowClosedEvent)
    {
        Console.WriteLine("Notepad is now closed");
        Automation.RemoveAutomationEventHandler(WindowPattern.WindowClosedEvent, NotepadElement, NotepadHandlerClose);
        Automation.RemoveAutomationPropertyChangedEventHandler(TitleBarElement, NotepadTitleBarHandler);
    }
}

public void OnNotepadTitleBarChange(object source, AutomationPropertyChangedEventArgs e)
{
    if (e.Property == AutomationElement.NameProperty)
    {
        Console.WriteLine($"New TitleBar value: {e.NewValue}");
    }
}

When the application (or the Form or Window) closes, remove the Automation Event Handlers still active:

Automation.RemoveAllEventHandlers();
Jimi
  • 29,621
  • 8
  • 43
  • 61
  • Amazing material. It would take me lifetimes to figure this out. Thanks an infinity! So far I got it working with Notepad++ and Firefox, but with AIMP I get an error at `Automation.AddAutomationPropertyChangedEventHandler(TitleBarElement,...` saying `Value cannot be null. Parameter name: element`. I don't really understand why. If I make a breakpoint, the `NotepadElement` at `TitleBarElement = NotepadElement...` contains all the info, just like with Notepad++. – Lakyn Jan 03 '19 at 05:02
  • If I set `ControlType.Window` and `NotepadElement.FindFirst(TreeScope.Element` then there's no error and it at least detects AIMP starting and closing but not the title change (surprisingly, still works with Notepad++ though, both start/close and title change). I bet I'm missing something simple. I'll do more testing tomorrow. – Lakyn Jan 03 '19 at 05:03
  • Well, I don't know that app. There is a chance that it doesn't have a *real* TitleBar. Some apps, to improve their *look*, use some tricks. A typical one is to use [DwmExtendFrameIntoClientArea](https://docs.microsoft.com/en-us/windows/desktop/api/dwmapi/nf-dwmapi-dwmextendframeintoclientarea), to draw customized Windows parts. Use the **Inspect** utility previously mentioned to see what control type that is. Possibly, not a `ControlType.TitleBar`. If you can't figure out that control type (if it's a control), maybe tell me what it is and I'll see to patch the code to support that *thing*. – Jimi Jan 03 '19 at 05:08
  • Inspect.exe says it's `UIA_WindowControlTypeId (0xC370)` which is `ControlType.Window` but that's same with Notepad++. Am I missing something? – Lakyn Jan 03 '19 at 05:25
  • But the `NotepadElement` does contain the title value when I make a breakpoint so it should work? – Lakyn Jan 03 '19 at 05:31
  • Nope. `UIA_WindowControlTypeId (0xC370)` is the Window Border. Notepad++ has a standard TitleBar: `UIA_TitleBarControlTypeId (0xC375)`. You have to click on it to see the actual Automation class type. Maybe, use the **Show Highlight Rectangle** tool of Inspect to highlight the Window part you're selecting. I don't know what you're referring to with *when I make a breakpoint*. Which application is it tracking? – Jimi Jan 03 '19 at 05:33
  • Breakpoint in Visual Studio when I'm debugging the code.[VS Breakpoint](https://learn.microsoft.com/en-us/visualstudio/debugger/using-breakpoints?view=vs-2017) ![link](https://lh6.googleusercontent.com/LqR7b_AHyhbmTYBfpAJMWYS-RhCDh8pokprNwZEFENw_Mhx9ru_lEE9S0Aru8ZY0H-m7UEwkvWJp2BOvCiEd=w1920-h944) – Lakyn Jan 03 '19 at 05:36
  • Yes, well, I know that :) I mean, *when I make a breakpoint* you say *the NotepadElement does contain the title value*. This happens with what application? Notepad++ or your AIMP app? – Jimi Jan 03 '19 at 05:41
  • Sorry, I'm bad at explaining this stuff. Here's a [screenshot](https://lh6.googleusercontent.com/LqR7b_AHyhbmTYBfpAJMWYS-RhCDh8pokprNwZEFENw_Mhx9ru_lEE9S0Aru8ZY0H-m7UEwkvWJp2BOvCiEd=w1920-h944). [AIMP](http://www.aimp.ru) is a music player from which I'm trying to get the title. – Lakyn Jan 03 '19 at 05:46
  • The screenshot is in a private area, I can't see it. The AIMP app is exactly what I described before. It draws its own TitleBar, to make it skinnable. You have to find out what Automation element is showing the text you're after. Use the Inspect utility to determine its type, class name or Automation ID and use this information to track its changes. You'ld have to do +- the same think if you were using SetWinEventHook instead. – Jimi Jan 03 '19 at 05:50
  • OK another try [here](https://drive.google.com/file/d/1FyqeePd4FilW0vnkJzxGc3Y96mVFfWUz/view) :) – Lakyn Jan 03 '19 at 05:55
  • Yes, well, it looks like the Main element is the one that is changed, after all. It could be, from what I see, that what is now `Condition titleBarCondition`, the `ControlType` could be `ControlType.Window` instead of `ControlType.TitleBar` and in the following line, `TitleBarElement = NotepadElement.FindFirst` the `TreeScope` could be `TreeScope.Element` instead of `TreeScope.Descendants`, since it's the Main element that is changing. Maybe I'll download that app and I'll try it out. – Jimi Jan 03 '19 at 06:09
  • Alright I think I finally understand. The Notepad++ has that `(null) title bar` thing inside it and its ControlType is `UIA_TitleBarControlTypeId (0xC375)`. Same for Firefox. The AIMP has `"PlaylistFrame" pane` and its ControlType is `UIA_PaneControlTypeId (0xC371)`. It's really late here and I need sleep, I'll continue tomorrow. Thanks for your time and patience with a noob like me :) – Lakyn Jan 03 '19 at 06:10
  • Nope, still missing something. If I set `ControlType.Pane` and the `ClassName` to the one of the `"PlaylistFrame" pane` it doesn't detect start/close nor the title change. – Lakyn Jan 03 '19 at 21:34
  • Well, I just don't get it. Even if I set `ControlType.Window` then Notepad's title change is still being detected just fine, but not with the AIMP. Both are `UIA_WindowControlTypeId (0xC370)` so why one works and the other one doesn't is just beyond me. I think I'll have to make some kind of loop that will check if `MainWindowTitle` is changed and be happy with that. – Lakyn Jan 04 '19 at 02:26
  • I gave this another try after some time and I think that it's not possible with UIA to detect _NameProperty_ changes of apps that don't have "(null) title bar". It can detect for example _BoundingRectangle_ changes but not the _Name_ changes for some mysterious reason. Strange stuff. But thanks anyway, maybe I'll use this for something else in the future. Cheers – Lakyn Apr 03 '19 at 10:23