Detect window open and close

Helpful script writing tricks and HowTo's
Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Detect window open and close

22 Aug 2023, 06:38

Introduction
Detecting whether a particular window has opened or closed is a recurring problem which has multiple possible solutions. This tutorial outlines some, but probably not all of them. My personal preferred method is either SetWinEventHook (see also the WinEvent library) or ShellHook (which is documented by Microsoft as deprecated, so it might not be the best).
All the examples detect the Notepad and/or Calculator window open/close, so if for some reason the title of Notepad or Calculator differs in your computer, make sure to change the code accordingly.

Table of contents
1. WinWait
2. SetTimer
3. SetWinEventHook
4. ShellHook
5. UIAutomation event
6. Advanced: SetWindowsHookEx


1. WinWait
This method uses WinWait to wait for a window to open and WinWaitClose for a window to close. It is the simplest one of all of these, but the downside is that the script can't continue on with other tasks (except hotkeys, timers and callbacks). In addition, if there are multiple windows that are to be monitored, then the code might get a bit complex.

Code: Select all

#Requires AutoHotkey v2

Loop { ; Loop indefinitely
    winId := WinWait("ahk_exe notepad.exe")
    ; winId := WinWaitActive("ahk_exe notepad.exe") ; Requires Notepad to exist AND be the active window
    ; In this case, winId can't be 0 because WinWait didn't have a timeout, so we don't need to check for it and can directly use WinGetTitle(winId).
    ; If a timeout was specified then check for 0 with "if winId { ... }" or "if winId := WinWait("ahk_exe notepad.exe") { ... }", otherwise WinGetTitle might throw an error.
    ToolTip(WinGetTitle(winId) " window created") ; Instead of winId we could also use Last Known Window by using WinGetTitle()
    SetTimer(ToolTip, -3000) ; Remove tooltip in 3 seconds
    WinWaitClose("ahk_exe notepad.exe")
    ToolTip("Notepad window closed")
    SetTimer(ToolTip, -3000) ; Remove tooltip in 3 seconds
}
Multiple windows can be waited for using ahk_group. With this method we can't easily determine which window in the group actually closed, only that one has. For a possible way around that see the SetTimer example for multiple windows.

Code: Select all

#Requires AutoHotkey v2

SetTitleMatchMode(3)
GroupAdd("WindowOpenClose", "ahk_exe notepad.exe")
GroupAdd("WindowOpenClose", "Calculator")

Loop { ; Loop indefinitely
    winId := WinWait("ahk_group WindowOpenClose")
    ; if WinWait had a timeout, then also check "if winId { ... }", otherwise WinGetTitle will throw an error
    ToolTip(WinGetTitle(winId) " window created")
    SetTimer(ToolTip, -3000) ; Remove tooltip in 3 seconds
    WinWaitClose("ahk_group WindowOpenClose")
    ToolTip("Calculator or Notepad window closed")
    SetTimer(ToolTip, -3000) ; Remove tooltip in 3 seconds
}
2. SetTimer
This method sets up a recurring timer that checks whether the target window has been created or closed. The downside is that this is not event-driven, which means that most of the time AHK is wasting time checking the status of the window.
To monitor just one window or process, we can use something like the following:

Code: Select all

#Requires AutoHotkey v2

SetTimer(WinOpenClose) ; Calls the function WinOpenClose every 250 milliseconds
; SetTimer(WinOpenClose, 0) ; This can be used to turn off the timer
Persistent() ; We have no hotkeys, so Persistent is required to keep the script going

WinOpenClose() {
    static targetWindow := "ahk_exe notepad.exe", lastExist := !!WinExist(targetWindow)
    if lastExist = !!WinExist(targetWindow) ; Checks whether Notepad exists and it didn't last time, or vice-versa
        return
    if (lastExist := !lastExist) {
        ToolTip("Notepad opened")
    } else {
        ToolTip("Notepad closed")
    }
    SetTimer(ToolTip, -3000)
}
To monitor multiple windows, then we need to keep track of what windows exist and update it accordingly. This is even more resource-intensive, since we need to go over the list of all open windows every time the timer is called and cross-check which ones have since been created/destroyed:

Code: Select all

#Requires AutoHotkey v2

SetTimer(WinOpenClose) ; Calls the function WinOpenClose every 250 milliseconds
; SetTimer(WinOpenClose, 0) ; This can be used to turn off the timer
Persistent() ; We have no hotkeys, so Persistent is required to keep the script going

WinOpenClose() {
    static lastOpenWindows := ListOpenWindows()
    currentOpenWindows := ListOpenWindows()
    for hwnd in SortedArrayDiff([currentOpenWindows*], [lastOpenWindows*]) {
        if !lastOpenWindows.Has(hwnd) {
            info := currentOpenWindows[hwnd]
            if (info.processName = "notepad.exe" || info.title = "Calculator") {
                ToolTip(info.title " window created")
                SetTimer(ToolTip, -3000)
            }
        } else {
            info := lastOpenWindows[hwnd]
            if (info.processName = "notepad.exe" || info.title = "Calculator") {
                ToolTip(info.title " window closed")
                SetTimer(ToolTip, -3000)
            }
        }
    }
    lastOpenWindows := currentOpenWindows

    ListOpenWindows() { ; Returns Map where key=window handle and value={title, class, processName}
        openWindows := Map()
        for hwnd in WinGetList()
            try openWindows[hwnd] := {title: WinGetTitle(hwnd), class:WinGetClass(hwnd), processName: WinGetProcessName(hwnd)}
        return openWindows
    }
    SortedArrayDiff(arr1, arr2) { ; https://www.geeksforgeeks.org/symmetric-difference-two-sorted-array/ also accounting for array length difference
        i := 1, j := 1, n := arr1.Length, m := arr2.Length, diff := []
        while (i <= n && j <= m) {
            if arr1[i] < arr2[j] {
                diff.Push(arr1[i]), i++
            } else if arr2[j] < arr1[i] {
                diff.Push(arr2[j]), j++
            } else {
                i++, j++
            }
        }
        while i <= n
            diff.Push(arr1[i]), i++
        while j <= m
            diff.Push(arr2[j]), j++
        return diff
    }
}
3. SetWinEventHook
This method uses I guess the preferred method of detecting window open/close events: SetWinEventHook. This hook will cause Microsoft to notify us whenever a window is created or destroyed, which means our program doesn't have to waste resources by constantly checking for updates. In addition to window created/destroyed events it can also be used to detect window move events (EVENT_OBJECT_LOCATIONCHANGE), window activation, minimize/maximize and much more. See the Event Constants for a full list of possible events. In addition, an Acc object can be created with AccessibleObjectFromEvent from information sent by this hook, but this is a whole other topic.

As a simplified version there is the option of using the WinEvent library, which is a wrapper for SetWinEventHook.

The following example detects Notepad and Calculator window open and close events with HandleWinEvent function:

Code: Select all

#Requires AutoHotkey v2

; Map out all open windows so we can keep track of their names when they're closed.
; After the window close event the windows no longer have their titles, so we can't do it afterwards. 
global gOpenWindows := Map()
for hwnd in WinGetList()
    try gOpenWindows[hwnd] := {title: WinGetTitle(hwnd), class:WinGetClass(hwnd), processName: WinGetProcessName(hwnd)}

global EVENT_OBJECT_CREATE := 0x8000, EVENT_OBJECT_DESTROY := 0x8001, OBJID_WINDOW := 0, INDEXID_CONTAINER := 0
; Set up our hook. Putting it in a variable is necessary to keep the hook alive, since once it gets
; rewritten (for example with hook := "") the hook is automatically destroyed.
hook := WinEventHook(HandleWinEvent, EVENT_OBJECT_CREATE, EVENT_OBJECT_DESTROY)
; We have no hotkeys, so Persistent is required to keep the script going.
Persistent()

/**
 * Our event handler which needs to accept 7 arguments. To ignore some of them use the * character,
 * for example HandleWinEvent(hWinEventHook, event, hwnd, idObject, idChild, *)
 * @param hWinEventHook Handle to an event hook function. This isn't useful for our purposes 
 * @param event Specifies the event that occurred. This value is one of the event constants (https://learn.microsoft.com/en-us/windows/win32/winauto/event-constants).
 * @param hwnd Handle to the window that generates the event, or NULL if no window is associated with the event.
 * @param idObject Identifies the object associated with the event.
 * @param idChild Identifies whether the event was triggered by an object or a child element of the object.
 * @param idEventThread Id of the thread that triggered this event.
 * @param dwmsEventTime Specifies the time, in milliseconds, that the event was generated.
 */
HandleWinEvent(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime) {
    Critical -1
    idObject := idObject << 32 >> 32, idChild := idChild << 32 >> 32, event &= 0xFFFFFFFF, idEventThread &= 0xFFFFFFFF, dwmsEventTime &= 0xFFFFFFFF ; convert to INT/UINT

    global gOpenWindows
    if (idObject = OBJID_WINDOW && idChild = INDEXID_CONTAINER) { ; Filters out only windows
        ; GetAncestor checks that we are dealing with a top-level window, not a control. This doesn't work
        ; for EVENT_OBJECT_DESTROY events. 
        if (event = EVENT_OBJECT_CREATE && DllCall("IsTopLevelWindow", "Ptr", hwnd)) {
            try {
                ; Update gOpenWindows accordingly
                gOpenWindows[hwnd] := {title:WinGetTitle(hwnd), class:WinGetClass(hwnd), processName: WinGetProcessName(hwnd)}
                if gOpenWindows[hwnd].processName = "notepad.exe"
                    ToolTip "Notepad window created"
                else if gOpenWindows[hwnd].title = "Calculator"
                    ToolTip "Calculator window created"
            }
        } else if (event = EVENT_OBJECT_DESTROY) {
            if gOpenWindows.Has(hwnd) {
                if gOpenWindows[hwnd].processName = "notepad.exe"
                    ToolTip "Notepad window destroyed"
                else if gOpenWindows[hwnd].title = "Calculator"
                    ToolTip "Calculator window destroyed"
                ; Delete info about windows that have been destroyed to avoid unnecessary memory usage
                gOpenWindows.Delete(hwnd)
            }
        }
        SetTimer(ToolTip, -3000) ; Remove created ToolTip in 3 seconds
    }
}

class WinEventHook {
    /**
     * Sets a new WinEventHook and returns on object describing the hook. 
     * When the object is released, the hook is also released. Alternatively use WinEventHook.Stop()
     * to stop the hook.
     * @param callback The function that will be called, which needs to accept 7 arguments:
     *    hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime
     * @param eventMin Optional: Specifies the event constant for the lowest event value in the range of events that are handled by the hook function.
     *  Default is the lowest possible event value.
     *  See more about event constants: https://learn.microsoft.com/en-us/windows/win32/winauto/event-constants
     *  Msaa Events List: Https://Msdn.Microsoft.Com/En-Us/Library/Windows/Desktop/Dd318066(V=Vs.85).Aspx
     *  System-Level And Object-Level Events: Https://Msdn.Microsoft.Com/En-Us/Library/Windows/Desktop/Dd373657(V=Vs.85).Aspx
     *  Console Accessibility: Https://Msdn.Microsoft.Com/En-Us/Library/Ms971319.Aspx
     * @param eventMax Optional: Specifies the event constant for the highest event value in the range of events that are handled by the hook function.
     *  If eventMin is omitted then the default is the highest possible event value.
     *  If eventMin is specified then the default is eventMin.
     * @param winTitle Optional: WinTitle of a certain window to hook to. Default is system-wide hook.
     * @param PID Optional: process ID of the process for which threads to hook to. Default is system-wide hook.
     * @param skipOwnProcess Optional: whether to skip windows (eg Tooltips) from the running script. 
     *  Default is not to skip.
     * @returns {WinEventHook} 
     */
    __New(callback, eventMin?, eventMax?, winTitle := 0, PID := 0, skipOwnProcess := false) {
        if !HasMethod(callback)
            throw ValueError("The callback argument must be a function", -1)
        if !IsSet(eventMin)
            eventMin := 0x00000001, eventMax := IsSet(eventMax) ? eventMax : 0x7fffffff
        else if !IsSet(eventMax)
            eventMax := eventMin
        this.callback := callback, this.winTitle := winTitle, this.flags := skipOwnProcess ? 2 : 0, this.eventMin := eventMin, this.eventMax := eventMax, this.threadId := 0
        if winTitle != 0 {
            if !(this.winTitle := WinExist(winTitle))
                throw TargetError("Window not found", -1)
            this.threadId := DllCall("GetWindowThreadProcessId", "Int", this.winTitle, "UInt*", &PID)
        }
        this.pCallback := CallbackCreate(callback, "C", 7)
        , this.hHook := DllCall("SetWinEventHook", "UInt", eventMin, "UInt", eventMax, "Ptr", 0, "Ptr", this.pCallback, "UInt", this.PID := PID, "UInt", this.threadId, "UInt", this.flags)
    }
    Stop() => this.__Delete()
    __Delete() {
        if (this.pCallback)
            DllCall("UnhookWinEvent", "Ptr", this.hHook), CallbackFree(this.pCallback), this.hHook := 0, this.pCallback := 0
    }
}
Note that some windows' title doesn't update immediately after creation, but instead with a slight delay (the window gets detected by AHK too fast!). This can cause problem detecting such windows, because we usually use the title as a filter criteria. The way around this is to update the gOpenWindows variable with a slight delay (for example 20-100ms), or instead of EVENT_OBJECT_CREATE use EVENT_OBJECT_SHOW which is activated once a window is actually displayed (and thus has a title). See an example in a post further down in this thread.

4. ShellHook
This method is also an event-driven one which registers our script to receive messages that could be useful for shell applications such as window created, destroyed, activated. Unfortunately it's also documented by Microsoft that this is not intended for general use and may be altered or unavailable in subsequent versions of Windows, so SetWinEventHook might be a better option.

Code: Select all

#Requires AutoHotkey v2

; Map out all open windows so we can keep track of their names when they're closed.
; After the window close event the windows no longer have their titles, so we can't do it afterwards. 
global gOpenWindows := Map()
for hwnd in WinGetList()
    try gOpenWindows[hwnd] := {title: WinGetTitle(hwnd), class:WinGetClass(hwnd), processName: WinGetProcessName(hwnd)}

DllCall("RegisterShellHookWindow", "UInt", A_ScriptHwnd)
OnMessage(DllCall("RegisterWindowMessage", "Str", "SHELLHOOK"), ShellProc)

; The following DllCall can also be used to stop the hook at any point, otherwise this will call it on script exit
OnExit((*) => DllCall("DeregisterShellHookWindow", "UInt", A_ScriptHwnd))
Persistent()

ShellProc(wParam, lParam, *) {
    global gOpenWindows
    if (wParam = 1) { ; HSHELL_WINDOWCREATED
        gOpenWindows[lParam] := {title: WinGetTitle(lParam), class:WinGetClass(lParam), processName: WinGetProcessName(lParam)}
        if gOpenWindows[lParam].processName = "notepad.exe" || gOpenWindows[lParam].title = "Calculator" {
            ToolTip(gOpenWindows[lParam].title " window opened")
            SetTimer(ToolTip, -3000)
        }
    } else if (wParam = 2) && gOpenWindows.Has(lParam) { ; HSHELL_WINDOWDESTROYED
        if gOpenWindows[lParam].processName = "notepad.exe" || gOpenWindows[lParam].title = "Calculator" {
            ToolTip(gOpenWindows[lParam].title " window closed")
            SetTimer(ToolTip, -3000)
        }
        gOpenWindows.Delete(lParam)
    }
}
5. UIAutomation event
The latest of Microsofts' accessibility interfaces - UIAutomation - can also be used to hook window-based events. The following example uses the UIA.ahk library to implement this, which will need to be in the same folder as the example script. Note that Window_WindowClosed event isn't reliably triggered by all windows, so it might be necessary to keep track of open windows in a similar manner as in the previous methods.

Code: Select all

#Requires AutoHotkey v2
#include UIA.ahk

; Caching is necessary to ensure that we won't be requesting information about windows that don't exist any more (eg after close), or when a window was created and closed while our handler function was running
cacheRequest := UIA.CreateCacheRequest(["Name", "Type", "NativeWindowHandle"])
; I'm using an event handler group, but if only one event is needed then a regular event handler could be used as well
groupHandler := UIA.CreateEventHandlerGroup()
handler := UIA.CreateAutomationEventHandler(AutomationEventHandler)
groupHandler.AddAutomationEventHandler(handler, UIA.Event.Window_WindowOpened, UIA.TreeScope.Subtree, cacheRequest)
groupHandler.AddAutomationEventHandler(handler, UIA.Event.Window_WindowClosed, UIA.TreeScope.Subtree, cacheRequest)
; Root element = Desktop element, which means that using UIA.TreeScope.Subtree, all windows on the desktop will be monitored
UIA.AddEventHandlerGroup(groupHandler, UIA.GetRootElement())
Persistent()

AutomationEventHandler(sender, eventId) {
    if eventId = UIA.Event.Window_WindowOpened {
        ; hwnd := DllCall("GetAncestor", "UInt", sender.CachedNativeWindowHandle, "UInt", 2) ; If the window handle (winId) is needed
        if InStr(sender.CachedName, "Notepad") {
            ToolTip("Notepad opened")
        } else if InStr(sender.CachedName, "Calculator") {
            ToolTip("Calculator opened")
        }
        SetTimer(ToolTip, -3000)
    } else if eventId = UIA.Event.Window_WindowClosed {
        if InStr(sender.CachedName, "Notepad") {
            ToolTip("Notepad closed")
        } else if InStr(sender.CachedName, "Calculator") {
            ToolTip("Calculator closed")
        }
        SetTimer(ToolTip, -3000)
    }
}
6. Advanced: SetWindowsHookEx
This method is similar to SetWinEventHook, but differs in two important ways:
1) It can intercept messages, for example it can stop or change window open/close/minimize and all sorts of other messages. Because of this it is also quite dangerous to use, because if the event handler is written badly then it might freeze up the whole system, which can be fixed by logging out or a reboot. An example of a sloppy mistake might be using a MsgBox inside a global window close event hook without unhooking first, because then the MsgBox can't be closed (AHK can process only one event at a time, and closing a MsgBox would be a window-close event), nor can any other window be closed.
2) It requires a dll file to be used, which is injected into all processes that process the event. Due to how Windows works, a 64-bit dll can be used by 64-bit AHK to inject into 64-bit processes, or 32-bit dll by 32-bit AHK into 32-bit processes. Also, this dll can't be injected into a process running at a more elevated level than the script: for example if the target window/process was opened as administrator, then the script needs to be ran as administrator as well.

See list of SetWindowsHookEx events here, event submessages are listed under the corresponding Proc functions (eg CBTProc).

Usually the injected dll is written in C++, because C++ is much faster than AHK and wouldn't require slow communication between the dll and AHK. Some hook types require processing hundreds of messages in a short period, so a hook using AHK might slow the system down noticeably. However, the following examples use HookProc.dll, which is written in C++ and filters out only select messages, which should mitigate the slowness somewhat. It's downloadable from HookProc GitHub repo (x64/Release/HookProc.dll). HookProc.dll is only a proof of concept and isn't meant to be used in actual applications, use it at your own risk. Instead I recommend writing your own Proc functions in C++, see example here. If you want to try these examples, put HookProc.dll in the same folder as the script.

Example 1. Blocking Notepad close and open

Code: Select all

#Requires Autohotkey v2.0+
Persistent()
WH_CBT := 5, HCBT_CREATEWND := 3, HCBT_DESTROYWND := 4

; Register a new window message that the application will call on CBTProc event
msg := DllCall("RegisterWindowMessage", "str", "CBTProc", "uint")
OnMessage(msg, CBTProc)

hHook := WindowsHookEx(WH_CBT, msg, [HCBT_CREATEWND, HCBT_DESTROYWND], 0, 0)

; wParam is the target process' handle, and lParam is a struct containing the info:
; struct ProcInfo {
;    int nCode;
;    WPARAM wParam;
;    LPARAM lParam;
;}; size = 24 due to struct packing
CBTProc(hProcess, lParam, msg, hWnd) {
    DetectHiddenWindows(1) ; Necessary because windows that haven't been created aren't visible
    ; Read CBTProc arguments (nCode, wParam, lParam) from the message lParam
	if !TryReadProcessMemory(hProcess, lParam, info := Buffer(24)) {
        OutputDebug("Reading CBTProc arguments failed!`n")
        return -1
    }
	nCode := NumGet(info, "int"), wParam := NumGet(info, A_PtrSize, "ptr"), lParam := NumGet(info, A_PtrSize*2, "ptr")
    ; Filter only for Notepad; keep in mind that controls are considered windows as well
    if WinExist(wParam) && InStr(WinGetProcessName(wParam), "notepad.exe") {
        if (nCode == HCBT_CREATEWND) {
            ; This might be a child window (eg a dialog box), which we don't want to prevent
            ; To determine that, get CBT_CREATEWND->lpcs->hwndParent which should be 0 for a top-level window
            if !TryReadProcessMemory(hProcess, lParam, CBT_CREATEWND := Buffer(A_PtrSize*2,0)) {
                OutputDebug("Reading CBT_CREATEWND failed!`n")
                return -1
            }
            lpcs := NumGet(CBT_CREATEWND, "ptr"), hwndInsertAfter := NumGet(CBT_CREATEWND, A_PtrSize, "ptr")
			if !lpcs
                return -1

			if !TryReadProcessMemory(hProcess, lpcs, CREATESTRUCT := Buffer(6*A_PtrSize + 6*4, 0)) {
                OutputDebug("Reading CREATESTRUCT failed!`n")
                return -1
            }
			hwndParent := NumGet(CREATESTRUCT, A_PtrSize*3, "ptr")
            
            if hwndParent == 0 {
                ; Because AHK is single-threaded, unhook before the MsgBox or no window can be created/destroyed during that time
                global hHook := 0
                MsgBox("Creating Notepad has been blocked!")
                hHook := WindowsHookEx(WH_CBT, msg, [HCBT_CREATEWND, HCBT_DESTROYWND], 0, 0)
                return 1
            }
        } else {
			; Show message only for top-level windows (not controls)
            if wParam = DllCall("GetAncestor", "ptr", wParam, "uint", 2, "ptr") {
                ; Because AHK is single-threaded, unhook before the MsgBox or no window can be created/destroyed during that time
                global hHook := 0
                MsgBox("Closing Notepad has been blocked!")
                hHook := WindowsHookEx(WH_CBT, msg, [HCBT_CREATEWND, HCBT_DESTROYWND], 0, 0)
            }
			return 1
        }
	}
	return -1
}

/**
 * Sets a new WindowsHookEx, which can be used to intercept window messages. It can only be used with 64-bit AHK, to hook 64-bit programs.
 * This has the potential to completely freeze your system and force a reboot, so use it at your
 * own peril!
 * Syntax: WindowsHookEx(idHook, msg, nCodes, HookedWinTitle := "", timeOut := 16, ReceiverWinTitle := A_ScriptHwnd)
 * @param {number} idHook The type of hook procedure to be installed. 
 * Common ones: WH_GETMESSAGE := 3, WH_CALLWNDPROC := 4, WH_CBT := 5
 * @param {number} msg The window message number where new events are directed to.
 * Can be created with `msg := DllCall("RegisterWindowMessage", "str", "YourMessageNameHere", "uint")`
 * @param {array} nCodes An array of codes to be monitored (max of 9). 
 * For most hook types this can be one of nCode values (eg HCBT_MINMAX for WH_CBT), but in the
 * case of WH_CALLWNDPROC this should be an array of monitored window messages (eg WM_PASTE).
 * 
 * nCode 0xFFFFFFFF can be used to match all nCodes, but the use of this is not recommended
 * because of the slowness of AHK and inter-process communication, which might slow down the whole system.
 * @param HookedWinTitle A specific window title or hWnd to hook. Specify 0 for a global hook (all programs).
 * @param {number} timeOut Timeout in milliseconds for events. Set 0 for infinite wait, but this
 * isn't recommended because of the high potential of freezing the system (all other incoming
 * messages would not get processed!).
 * @param ReceiverWinTitle The WinTitle or hWnd of the receiver who will get the event messages.
 * Default is current script. 
 * @returns {Object} New hook object which contains hook information, and when destroyed unhooks the hook.
 */
class WindowsHookEx {
	; File name of the HookProc dll, is searched in A_WorkingDir, A_ScriptDir, A_ScriptDir\Lib\, and A_ScriptDir\Resources\
	static DllName := "HookProc.dll"
	; Initializes library at load-time
	static __New() {
		for loc in [A_WorkingDir "\" this.DllName, A_ScriptDir "\" this.DllName, A_ScriptDir "\Lib\" this.DllName, A_ScriptDir "\Resources\" this.DllName] {
			if FileExist(loc) {
				; WindowsHookEx.ClearSharedMemory() ; Might be useful to uncomment while debugging
				this.hLib := DllCall("LoadLibrary", "str", loc, "ptr")
				return
			}
		}
		throw Error("Unable to find " this.DllName " file!", -1)
	}
	__New(idHook, msg, nCodes, HookedWinTitle := "", timeOut := 16, ReceiverWinTitle := A_ScriptHwnd) {
		if !IsInteger(HookedWinTitle) {
			if !(this.hWndTarget := WinExist(HookedWinTitle))
				throw TargetError("HookedWinTitle `"" HookedWinTitle "`" was not found!", -1)
		} else
			this.hWndTarget := HookedWinTitle
		if !(this.hWndReceiver := IsInteger(ReceiverWinTitle) ? ReceiverWinTitle : WinExist(ReceiverWinTitle))
			throw TargetError("Receiver window was not found!", -1)
		if !IsObject(nCodes) && IsInteger(nCodes)
			nCodes := [nCodes]
		this.threadId := DllCall("GetWindowThreadProcessId", "Ptr", this.hWndTarget, "Ptr", 0, "UInt")
		this.idHook := idHook, this.msg := msg, this.nCodes := nCodes, this.nTimeout := timeOut
		local pData := Buffer(nCodes.Length * A_PtrSize)
		for i, nCode in nCodes
			NumPut("ptr", nCode, pData, (i-1)*A_PtrSize)
		this.hHook := DllCall(WindowsHookEx.DllName "\SetHook", "int", idHook, "ptr", msg, "int", this.threadId, "ptr", pData, "int", nCodes.Length, "ptr", this.hWndReceiver, "int", timeOut, "ptr")
	}
	; Unhooks the hook, which is also automatically done when the hook object is destroyed
	static Unhook(hHook) => DllCall(this.DllName "\UnHook", "ptr", IsObject(hHook) ? hHook.hHook : hHook)
	; Clears the shared memory space of the dll which might sometimes get corrupted during debugging
	static ClearSharedMemory() => DllCall(this.DllName "\ClearSharedMemory")
	__Delete() => WindowsHookEx.UnHook(this.hHook)
	; Unhooks all hooks created by this script
	static Close() => DllCall(this.DllName "\Close")
}

TryReadProcessMemory(hProcess, lpBaseAddress, oBuffer, &nBytesRead?) {
	try return DllCall("ReadProcessMemory", "ptr", hProcess, "ptr", lpBaseAddress, "ptr", oBuffer, "int", oBuffer.Size, "int*", IsSet(nBytesRead) ? &nBytesRead:=0 : 0, "int") != 0
	return 0
}
HIWORD(DWORD) => ((DWORD>>16)&0xFFFF)
LOWORD(DWORD) => (DWORD&0xFFFF)
MAKEWORD(LOWORD, HIWORD) => (HIWORD<<16)|(LOWORD&0xFFFF)
Example 2. Intercepting Notepad WM_PASTE event

Code: Select all

#Requires Autohotkey v2.0+
Persistent()
WH_CALLWNDPROC := 4
WM_PASTE := 0x0302

if !WinExist("ahk_exe notepad.exe") {
	Run "notepad.exe"
} else
	WinActivate "ahk_exe notepad.exe"
WinWaitActive "ahk_exe notepad.exe"
hWnd := WinExist()

msg := DllCall("RegisterWindowMessage", "str", "WndProc", "uint")
OnMessage(msg, WndProc)

hHook := WindowsHookEx(WH_CALLWNDPROC, msg, [WM_PASTE], hWnd, 0)

#HotIf WinActive("ahk_exe notepad.exe")
^v::MsgBox("Ctrl+V doesn't send WM_PASTE, try right-clicking and select Paste")

; WH_CALLWNDPROC points the hook to CallWndProc, but that isn't a very useful call so it isn't 
; redirected to AHK. Instead, CallWndProc allows the message through, but redirects the following
; WndProc function call to AHK. 
; WndProc function definition is WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
; which means the OnMessage call lParam will point to a structure containing these values:
; struct NewWndProcInfo {
;    HWND hWnd;
;    UINT uMsg;
;    WPARAM wParam;
;    LPARAM lParam;
;}; (size = 32)
WndProc(hProcess, lParam, msg, hWnd) {
	; Since we are monitoring a single message we could instead use simple "return (MsgBox("Allow paste?", "AHK/Notepad", 0x4) = "Yes") ? -1 : 0"
	; However, this example demonstrates how to read the WndProc arguments from the process that received the event
	if TryReadProcessMemory(hProcess, lParam, info := Buffer(32)) {
		hWnd := NumGet(info, "ptr"), uMsg := NumGet(info, A_PtrSize, "ptr"), wParam := NumGet(info, A_PtrSize*2, "ptr"), lParam := NumGet(info, A_PtrSize*3, "ptr")
		if uMsg = WM_PASTE
			return (MsgBox("Allow paste?", "AHK/Notepad", 0x4) = "Yes") ? -1 : 0
	}
	return -1
}

/**
 * Sets a new WindowsHookEx, which can be used to intercept window messages. It can only be used with 64-bit AHK, to hook 64-bit programs.
 * This has the potential to completely freeze your system and force a reboot, so use it at your
 * own peril!
 * Syntax: WindowsHookEx(idHook, msg, nCodes, HookedWinTitle := "", timeOut := 16, ReceiverWinTitle := A_ScriptHwnd)
 * @param {number} idHook The type of hook procedure to be installed. 
 * Common ones: WH_GETMESSAGE := 3, WH_CALLWNDPROC := 4, WH_CBT := 5
 * @param {number} msg The window message number where new events are directed to.
 * Can be created with `msg := DllCall("RegisterWindowMessage", "str", "YourMessageNameHere", "uint")`
 * @param {array} nCodes An array of codes to be monitored (max of 9). 
 * For most hook types this can be one of nCode values (eg HCBT_MINMAX for WH_CBT), but in the
 * case of WH_CALLWNDPROC this should be an array of monitored window messages (eg WM_PASTE).
 * 
 * nCode 0xFFFFFFFF can be used to match all nCodes, but the use of this is not recommended
 * because of the slowness of AHK and inter-process communication, which might slow down the whole system.
 * @param HookedWinTitle A specific window title or hWnd to hook. Specify 0 for a global hook (all programs).
 * @param {number} timeOut Timeout in milliseconds for events. Set 0 for infinite wait, but this
 * isn't recommended because of the high potential of freezing the system (all other incoming
 * messages would not get processed!).
 * @param ReceiverWinTitle The WinTitle or hWnd of the receiver who will get the event messages.
 * Default is current script. 
 * @returns {Object} New hook object which contains hook information, and when destroyed unhooks the hook.
 */
class WindowsHookEx {
	; File name of the HookProc dll, is searched in A_WorkingDir, A_ScriptDir, A_ScriptDir\Lib\, and A_ScriptDir\Resources\
	static DllName := "HookProc.dll"
	; Initializes library at load-time
	static __New() {
		for loc in [A_WorkingDir "\" this.DllName, A_ScriptDir "\" this.DllName, A_ScriptDir "\Lib\" this.DllName, A_ScriptDir "\Resources\" this.DllName] {
			if FileExist(loc) {
				; WindowsHookEx.ClearSharedMemory() ; Might be useful to uncomment while debugging
				this.hLib := DllCall("LoadLibrary", "str", loc, "ptr")
				return
			}
		}
		throw Error("Unable to find " this.DllName " file!", -1)
	}
	__New(idHook, msg, nCodes, HookedWinTitle := "", timeOut := 16, ReceiverWinTitle := A_ScriptHwnd) {
		if !IsInteger(HookedWinTitle) {
			if !(this.hWndTarget := WinExist(HookedWinTitle))
				throw TargetError("HookedWinTitle `"" HookedWinTitle "`" was not found!", -1)
		} else
			this.hWndTarget := HookedWinTitle
		if !(this.hWndReceiver := IsInteger(ReceiverWinTitle) ? ReceiverWinTitle : WinExist(ReceiverWinTitle))
			throw TargetError("Receiver window was not found!", -1)
		if !IsObject(nCodes) && IsInteger(nCodes)
			nCodes := [nCodes]
		this.threadId := DllCall("GetWindowThreadProcessId", "Ptr", this.hWndTarget, "Ptr", 0, "UInt")
		this.idHook := idHook, this.msg := msg, this.nCodes := nCodes, this.nTimeout := timeOut
		local pData := Buffer(nCodes.Length * A_PtrSize)
		for i, nCode in nCodes
			NumPut("ptr", nCode, pData, (i-1)*A_PtrSize)
		this.hHook := DllCall(WindowsHookEx.DllName "\SetHook", "int", idHook, "ptr", msg, "int", this.threadId, "ptr", pData, "int", nCodes.Length, "ptr", this.hWndReceiver, "int", timeOut, "ptr")
	}
	; Unhooks the hook, which is also automatically done when the hook object is destroyed
	static Unhook(hHook) => DllCall(this.DllName "\UnHook", "ptr", IsObject(hHook) ? hHook.hHook : hHook)
	; Clears the shared memory space of the dll which might sometimes get corrupted during debugging
	static ClearSharedMemory() => DllCall(this.DllName "\ClearSharedMemory")
	__Delete() => WindowsHookEx.UnHook(this.hHook)
	; Unhooks all hooks created by this script
	static Close() => DllCall(this.DllName "\Close")
}

TryReadProcessMemory(hProcess, lpBaseAddress, oBuffer, &nBytesRead?) {
	try return DllCall("ReadProcessMemory", "ptr", hProcess, "ptr", lpBaseAddress, "ptr", oBuffer, "int", oBuffer.Size, "int*", IsSet(nBytesRead) ? &nBytesRead:=0 : 0, "int") != 0
	return 0
}
HIWORD(DWORD) => ((DWORD>>16)&0xFFFF)
LOWORD(DWORD) => (DWORD&0xFFFF)
MAKEWORD(LOWORD, HIWORD) => (HIWORD<<16)|(LOWORD&0xFFFF)
Update history:

Code: Select all

23.08.23. Added explanations to WinWait examples about possibly checking for winId=0 in case a timeout was specified.
04.10.23. Added clarification to SetWinEventHook about sometimes needing a slight delay.
16.10.23. Added SetWindowsHookEx.
03.02.24. Updated SetWinEventHook code.
05.03.24. Added reference to the WinEvent library.
10.03.24. Modified WinEventHook class to contain WinEventHook.Stop(), and changed the default flag to not skip own process.
26.03.24. Removed "Fast" option from CallbackCreate.
Last edited by Descolada on 26 Mar 2024, 11:18, edited 26 times in total.
Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Re: Detect window open and close

22 Aug 2023, 06:38

Reserved for future use.
neogna2
Posts: 591
Joined: 15 Sep 2016, 15:44

Re: Detect window open and close

23 Aug 2023, 05:23

Great post! Useful overview and sample code.
Descolada wrote:
22 Aug 2023, 06:38
WinWait [...] Multiple windows can be waited for using ahk_group. With this method we can't determine which window in the group actually closed, only that one has.
We can return the Hwnd and use that to determine more about the window that closed
WinWait.htm#Return_Value
This function returns the HWND (unique ID) of a matching window if one was found, or 0 if the function timed out.

Code: Select all

GroupAdd("WindowOpenClose", "ahk_exe notepad.exe")
GroupAdd("WindowOpenClose", "Calculator")
if WinHwnd := WinWait("ahk_group WindowOpenClose")
  MsgBox WinHwnd
Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Re: Detect window open and close

23 Aug 2023, 06:40

@neogna2, thanks, I added use of WinHwnd to the examples. Alternatively Last Known Window can also be used, eg

Code: Select all

GroupAdd("WindowOpenClose", "ahk_exe notepad.exe")
GroupAdd("WindowOpenClose", "Calculator")
WinWait("ahk_group WindowOpenClose")
MsgBox WinGetTitle()
But AFAIK this can't be used to easily determine which window closed. For example, open both Notepad and Calculator and try to use GroupAdd in conjunction with WinWaitClose, and close one of the windows. How can you tell which one closed without getting a list of all open Notepad and Calculator windows before WinWaitClose and cross-checking afterwards?
neogna2
Posts: 591
Joined: 15 Sep 2016, 15:44

Re: Detect window open and close

23 Aug 2023, 08:14

Descolada wrote:
23 Aug 2023, 06:40
But AFAIK this can't be used to easily determine which window closed. [...] without getting a list of all open Notepad and Calculator windows before WinWaitClose and cross-checking afterwards?
Yeah you're right, my mistake :oops: We can use WinGetList on the ahk_group at loop start and later push the WinExist returned Hwnd to that array. But that still leaves an edge case when an additional ahk_group elegible window is opened before WinWaitClose triggers.
valuex
Posts: 86
Joined: 01 Nov 2014, 08:17

Re: Detect window open and close

04 Oct 2023, 09:19

It seems that SetWinEventHook can NOT be triggered when open the "Save As" window in Visual Studio Code.
Any idea on how to do this?
Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Re: Detect window open and close

04 Oct 2023, 09:42

@valuex, sometimes the window title doesn't update immediately after creation to the actual title (I don't know why this is for some windows yet not others). It can be worked around by adding the info into gOpenWindows with a slight delay (eg 40ms or 100ms). Since for all the windows that get the title immediately this would add an unnecessary extra delay, then I didn't add it in the example, but I'll add a note about it in there. Example (detecting VSCode "Save As" dialog):

Code: Select all

#Requires AutoHotkey v2

; Map out all open windows so we can keep track of their names when they're closed.
; After the window close event the windows no longer have their titles, so we can't do it afterwards. 
global gOpenWindows := Map()
for hwnd in WinGetList()
    try gOpenWindows[hwnd] := {title: WinGetTitle(hwnd), class:WinGetClass(hwnd), processName: WinGetProcessName(hwnd)}

; Set up our hook. Putting it in a variable is necessary to keep the hook alive, since once it gets
; rewritten (for example with hook := "") the hook is automatically destroyed.
hook := SetWinEventHook(HandleWinEvent)
; We have no hotkeys, so Persistent is required to keep the script going.
Persistent() 

/**
 * Our event handler which needs to accept 7 arguments. To ignore some of them use the * character,
 * for example HandleWinEvent(hWinEventHook, event, hwnd, idObject, idChild, *)
 * @param hWinEventHook Handle to an event hook function. This isn't useful for our purposes 
 * @param event Specifies the event that occurred. This value is one of the event constants (https://learn.microsoft.com/en-us/windows/win32/winauto/event-constants).
 * @param hwnd Handle to the window that generates the event, or NULL if no window is associated with the event.
 * @param idObject Identifies the object associated with the event.
 * @param idChild Identifies whether the event was triggered by an object or a child element of the object.
 * @param idEventThread Id of the thread that triggered this event.
 * @param dwmsEventTime Specifies the time, in milliseconds, that the event was generated.
 */
HandleWinEvent(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime) {
    static EVENT_OBJECT_CREATE := 0x8000, EVENT_OBJECT_DESTROY := 0x8001, OBJID_WINDOW := 0, INDEXID_CONTAINER := 0
    global gOpenWindows
    if (idObject = OBJID_WINDOW && idChild = INDEXID_CONTAINER) { ; Filters out only windows
        ; GetAncestor checks that we are dealing with a top-level window, not a control. This doesn't work
        ; for EVENT_OBJECT_DESTROY events. 
        if (event = EVENT_OBJECT_CREATE && hwnd = DllCall("GetAncestor", "UInt", hwnd, "Uint", 2)) {
            SetTimer(CheckOpenedWindow.Bind(hwnd), -40)
        } else if (event = EVENT_OBJECT_DESTROY) {
            if gOpenWindows.Has(hwnd) {
                if gOpenWindows[hwnd].processName = "code.exe" && (gOpenWindows[hwnd].Title = "Save As")
                    ToolTip "VSCode Save As destroyed"
                ; Delete info about windows that have been destroyed to avoid unnecessary memory usage
                gOpenWindows.Delete(hwnd)
            }
        }
        SetTimer(ToolTip, -3000) ; Remove created ToolTip in 3 seconds
    }
}

CheckOpenedWindow(hwnd) {
    global gOpenWindows
    try {
        gOpenWindows[hwnd] := {title:WinGetTitle(hwnd), class:WinGetClass(hwnd), processName: WinGetProcessName(hwnd)}
        if gOpenWindows[hwnd].processName = "code.exe" && (gOpenWindows[hwnd].Title = "Save As")
            ToolTip "VSCode Save As created"
    }
}

/**
 * Sets a new WinEventHook and returns on object describing the hook. 
 * When the object is released, the hook is also released.
 * @param callbackFunc The function that will be called, which needs to accept 7 arguments:
 *    hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime
 * @param winTitle Optional: WinTitle of a certain window to hook to. Default is system-wide hook.
 * @param eventMin Optional: Specifies the event constant for the lowest event value in the range of events that are handled by the hook function.
 *  Default is EVENT_OBJECT_CREATE = 0x8000
 *  See more about event constants: https://learn.microsoft.com/en-us/windows/win32/winauto/event-constants
 * @param eventMax Optional: Specifies the event constant for the highest event value in the range of events that are handled by the hook function.
 *  Default is EVENT_OBJECT_DESTROY = 0x8001
 * @param flags Flag values that specify the location of the hook function and of the events to be skipped.
 *  Default is WINEVENT_OUTOFCONTEXT = 0 and WINEVENT_SKIPOWNPROCESS = 2. 
 * @returns {Object} 
 */
SetWinEventHook(callbackFunc, winTitle:=0, eventMin:=0x8000, eventMax:=0x8001, flags:=0x0002) {
    local PID := 0, callbackFuncType, hook := {winTitle:winTitle, flags:flags, eventMin:eventMin, eventMax:eventMax, threadId:0}
    callbackFuncType := Type(callbackFunc)
    if !(callbackFuncType = "Func" || callbackFuncType = "Closure")
        throw ValueError("The callbackFunc argument must be a function", -1)
    if winTitle != 0 {
        if !(hook.winTitle := WinExist(winTitle))
            throw TargetError("Window not found", -1)
        hook.threadId := DllCall("GetWindowThreadProcessId", "Int", hook.winTitle, "UInt*", &PID)
    }
    hook.hHook := DllCall("SetWinEventHook", "UInt", eventMin, "UInt", eventMax, "Ptr",0, "Ptr", hook.callback := CallbackCreate(callbackFunc, "C Fast", 7), "UInt", hook.PID := PID, "UInt", hook.threadId, "UInt", flags)
    hook.DefineProp("__Delete", {call:(this) => (DllCall("UnhookWinEvent", "Ptr", this.hHook), CallbackFree(this.callback))})
    return hook
}
Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Re: Detect window open and close

16 Oct 2023, 02:22

@malcev, thanks, added it. Although it's not very useful for detecting window open/close events (SetWinEventHook does pretty much the same thing with much less work and dangers), intercepting the events might be relevant.
User avatar
andymbody
Posts: 904
Joined: 02 Jul 2017, 23:47

Re: Detect window open and close

01 Mar 2024, 14:49

Thank you!
eugenesv
Posts: 175
Joined: 21 Dec 2015, 10:11

Re: Detect window open and close

12 Mar 2024, 14:19

Descolada wrote:
04 Oct 2023, 09:42
@valuex, sometimes the window title doesn't update immediately after creation to the actual title (I don't know why this is for some windows yet not others). It can be worked around by adding the info into gOpenWindows with a slight delay (eg 40ms or 100ms). Since for all the windows that get the title immediately this would add an unnecessary extra delay
Would it make sense instead of a delay that is added to every window check if title exists, which would have no delay (except for the delay for the check itself), and if not, then loop a few iterations of the same check with small delays so you also wouldn't need to guess whether 40ms or 100ms is correct, just let the loop up to a second trigger on the smallest increment when the title appears
Albireo
Posts: 1756
Joined: 16 Oct 2013, 13:53

Re: Detect window open and close

25 Mar 2024, 10:03

Many good examples (of a rather difficult problem)

One question is .:
When has a program started?
In order to control the program I am currently working on, the window must be visible on the screen.
Now I do it in this way .:

Code: Select all

...
Try Run aProg.file, aProg.path, "Max", &AimsPID
catch as e
{	MsgBox "Error ..." . 
	ExitApp
}

; Wait for the main window...
While !WinExist("AIMS ahk_exe !!!!.exe ahk_class TShellForm")
{	Sleep 100
	If ( Maxtid < ( A_TickCount - StartTime ) / 10000 )
	{	MsgBox "Too long..." .
		ExitApp	
	}
}
...
Would any of the above suggestions work for me instead?
Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Re: Detect window open and close

25 Mar 2024, 10:22

@Albireo, yes, you could use SetWinEventHook with EVENT_OBJECT_SHOW, or an easier method would be using my WinEvent class:

Code: Select all

#requires AutoHotkey v2
#include WinEvent.ahk

WinEvent.Show(AIMS_Showed, "AIMS ahk_exe !!!!.exe ahk_class TShellForm", 1)
Try Run aProg.file, aProg.path, "Max", &AimsPID
catch as e
{	MsgBox "Error ..." . 
	ExitApp
}
SetTimer AIMS_Timeout, -10000 ; timeout in 10 seconds

AIMS_Showed(eventObj, hWnd, *) {
    SetTimer AIMS_Timeout, 0 ; disable the timeout timer
    MsgBox "AIMS appeared"
}

AIMS_Timeout(*) {
    MsgBox "Too long..."
    ExitApp
}
Albireo
Posts: 1756
Joined: 16 Oct 2013, 13:53

Re: Detect window open and close

26 Mar 2024, 07:40

Thank you @Descolada !

It works (in some way.)
But if I add the instruction MsgBox "Next!" (after the structure above)
MsgBox Added in the structure
all my remaining code must end up in the function AIMS_Showed()
In the example above the MsgBox "Next!" is shown before AIMS appeared
Feels like the message AIMS appeared appears at the right moment (maybe works without extra delay)

Any idea how to stop the program until the desired window is active and at the same time avoid putting main code in a function?
Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Re: Detect window open and close

26 Mar 2024, 11:23

@Albireo the purpose of this library is to replace Win... functions with non-blocking ones: while WinWaitActive and such block script execution until the window is in the desired state, WinEvent.Active lets the program do other stuff in the meanwhile and responds only when the window reaches the desired state. If you want to stop the program until the desired window is active and at the same time avoid putting main code in a function then your original code did just that and you could continue using that. If you decide to use WinEvent library then you put all the code related to what should happen when your target window activates inside the AIMS_Showed callback function.
Albireo
Posts: 1756
Joined: 16 Oct 2013, 13:53

Re: Detect window open and close

26 Mar 2024, 11:57

Descolada wrote:
26 Mar 2024, 11:23
@Albireo the purpose of this library is to replace Win... functions with non-blocking ones: while WinWaitActive and such block script execution until the window is in the desired state, WinEvent.Active lets the program do other stuff in the meanwhile and responds only when the window reaches the desired state. If you want to stop the program until the desired window is active and at the same time avoid putting main code in a function then your original code did just that and you could continue using that. If you decide to use WinEvent library then you put all the code related to what should happen when your target window activates inside the AIMS_Showed callback function.
Thank you for the description and explanation!

Return to “Tutorials (v2)”

Who is online

Users browsing this forum: No registered users and 84 guests