cristianbuse / VBA-StateLossCallback

A class that allows a callback when state is lost
MIT License
11 stars 2 forks source link

[Question] Embedding An Instance of `StateLossCallback` In `Application.VBE.MainWindow` #6

Closed Finatra closed 2 months ago

Finatra commented 2 months ago

Hello, this is just a general question.

I've been looking for a good way to natively handle (specifically) the closure/destruction of the VBA IDE main window.

I thought about embedding a reference to an instance of StateLossCallback into a CommandBarControl or ToolWindow and then adding that object to Application.VBE.CommandBars or Application.VBE.Windows. This is more difficult to do than I expected; and I have not yet found a way to do it.

My question is, if I did somehow manage to do this, would StateLossCallback work as intended?

Many thanks for all your great work.

cristianbuse commented 2 months ago

Hi @Finatra ,

closure/destruction of the VBA IDE main window

Closing the VBA window can be detected easily by using Win API calls - not sure about Mac though. However, if the window is actually destroyed as in DestroyWindow then that is a completely different thing as code debugging stops working entirely and trying to break into code in debug mode will cause a crash - in other words if the VBE window is ever destroyed then the application needs to be restarted anyway.

I am guessing you would rather "catch" the event of closing the VBE window. In that case, yes, this will still work.

The StateLossCallback class has a major issue - it does not work if state is lost when code is already running e.g. Sub Test(): End: End Sub. Only works for state loss when code is not running e.g. Stop button.

My plan is to try to find a workaround in the next few weeks so that the class is reliable. There are other issues raised by other users, where this class causes the application to crash when the application is closed (it used to work fine).

Finatra commented 2 months ago

Thanks for the reply!

I will keep looking for a good way to attach it to the VBE window.

Out of curiosity, I initially tried using WinAPI calls (by subclassing the VBE window) however it wasn't practical because it made interacting with the VBE unstable.

Every window message triggered a callback to my VBA code which slowed down the environment as it was constantly toggling the "[running]" state.

I could not find an external way to filter the window messages to reduce the number of callbacks.

I wanted to avoid the trivial solution of just periodically checking the window state.

cristianbuse commented 2 months ago

Try this:

Option Explicit

Private myHook As Long

'https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwineventhook
Private Declare PtrSafe Function SetWinEventHook _
    Lib "user32.dll" (ByVal eventMin As Long _
                    , ByVal eventMax As Long _
                    , ByVal hmodWinEventProc As LongPtr _
                    , ByVal pfnWinEventProc As LongPtr _
                    , ByVal idProcess As Long _
                    , ByVal idThread As Long _
                    , ByVal dwFlags As Long) As Long
Private Declare PtrSafe Function UnhookWinEvent Lib "user32.dll" (ByVal hWinEventHook As Long)
Private Declare PtrSafe Function GetCurrentThreadId Lib "kernel32" () As Long
Private Declare PtrSafe Function GetCurrentProcessId Lib "kernel32" () As Long

Public Sub HookWinEvent()
    'https://learn.microsoft.com/en-us/windows/win32/winauto/event-constants
    Const EVENT_SYSTEM_FOREGROUND As Long = &H3
    Const WINEVENT_OUTOFCONTEXT As Long = 0
    #If Win64 Then
        Const NULL_PTR As LongLong = 0^
    #Else
        Const NULL_PTR As Long = 0&
    #End If
    '
    If myHook <> 0 Then UnhookWinEvent myHook
    myHook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND _
                           , EVENT_SYSTEM_FOREGROUND _
                           , NULL_PTR _
                           , AddressOf WinEventProc _
                           , GetCurrentProcessId() _
                           , GetCurrentThreadId() _
                           , WINEVENT_OUTOFCONTEXT)
End Sub

Public Sub UnhookWEvent()
    If myHook <> 0 Then UnhookWinEvent myHook
    myHook = 0
End Sub

'https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-wineventproc
Public Sub WinEventProc(ByVal hWinEventHook As LongPtr _
                      , ByVal wEvent As Long _
                      , ByVal hWnd As LongPtr _
                      , ByVal idObject As Long _
                      , ByVal idChild As Long _
                      , ByVal idEventThread As Long _
                      , ByVal dwmsEventTime As Long)
    Debug.Print "A window was opened/closed"
    If hWnd = Application.VBE.MainWindow.hWnd Then
        Debug.Print "VBE was opened/closed"
    End If
End Sub

Anyway, there are a few issues with the above:

Finatra commented 2 months ago

Thanks! I'll give it a try!

cristianbuse commented 2 months ago

Hi @Finatra

Did you have any success with the SetWinEventHook code? I was just thinking that I might use it to capture when the application is closing which should help with preventing some of the crashes.

Finatra commented 2 months ago

Still looking into it.

I did fix the Unhook though:

The UnhookWinEvent() declaration was missing a return type. (gave it As Boolean) I changed the hWinEventHook parameter of the WinEventProc() from LongPtr to Long.

Option Explicit

Private myHook As Long

'https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwineventhook
Private Declare PtrSafe Function SetWinEventHook _
    Lib "user32.dll" (ByVal eventMin As Long _
                    , ByVal eventMax As Long _
                    , ByVal hmodWinEventProc As LongPtr _
                    , ByVal pfnWinEventProc As LongPtr _
                    , ByVal idProcess As Long _
                    , ByVal idThread As Long _
                    , ByVal dwFlags As Long) As Long
Private Declare PtrSafe Function UnhookWinEvent Lib "user32.dll" (ByVal hWinEventHook As Long) As Boolean
Private Declare PtrSafe Function GetCurrentThreadId Lib "kernel32" () As Long
Private Declare PtrSafe Function GetCurrentProcessId Lib "kernel32" () As Long

Public Sub HookWinEvent()
    'https://learn.microsoft.com/en-us/windows/win32/winauto/event-constants
    Const EVENT_SYSTEM_FOREGROUND As Long = &H3
    Const WINEVENT_OUTOFCONTEXT As Long = 0
    #If Win64 Then
        Const NULL_PTR As LongLong = 0^
    #Else
        Const NULL_PTR As Long = 0&
    #End If
    '
    If myHook <> 0 Then UnhookWinEvent myHook
    myHook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND _
                           , EVENT_SYSTEM_FOREGROUND _
                           , NULL_PTR _
                           , AddressOf WinEventProc _
                           , GetCurrentProcessId() _
                           , GetCurrentThreadId() _
                           , WINEVENT_OUTOFCONTEXT)
    Debug.Print "SetWinEventHook/myHook: " & myHook
End Sub

Public Sub UnhookWEvent()
    If myHook <> 0 Then Debug.Print "UnhookWinEvent: " & UnhookWinEvent(myHook)
    myHook = 0
End Sub

'https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-wineventproc
Public Sub WinEventProc(ByVal hWinEventHook As Long _
                      , ByVal wEvent As Long _
                      , ByVal hWnd As LongPtr _
                      , ByVal idObject As Long _
                      , ByVal idChild As Long _
                      , ByVal idEventThread As Long _
                      , ByVal dwmsEventTime As Long)
    Debug.Print "hWinEventHook: " & hWinEventHook
    Debug.Print "       myHook: " & myHook

    If hWinEventHook <> 0 Then
        If hWinEventHook <> myHook Then myHook = hWinEventHook
    End If

    'Debug.Print "A window was opened/closed"
    If hWnd = Application.VBE.MainWindow.hWnd Then
        'Debug.Print "VBE was opened/closed"
    End If
End Sub

Still experimenting. Will update soon.

Finatra commented 2 months ago

Ok, after some tinkering I figured out that the hook isn't called for ALL desktop windows. It's only called for Application.VBE.MainWindow.

Only one window can be the FOREGROUND window at a time. So, focusing back and forth between VBE and any other window will trigger the CallBack each time. Focusing between two non-VBE windows does not trigger the CallBack.

I don't think EVENT_SYSTEM_FOREGROUND is the right event for detecting closure. I also tried EVENT_OBJECT_CLOAKED and EVENT_OBJECT_HIDE; neither of which were better.

Regarding Unhooking: The CallBack passes the hook's handle, so if the hook triggers after myHook has lost its state you can safely unhook using the hWinEventHook argument that was passed by the CallBack.

Option Explicit

Private myHook As Long

'https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwineventhook
Private Declare PtrSafe Function _
    SetWinEventHook Lib "user32.dll" ( _
        ByVal eventMin As Long, _
        ByVal eventMax As Long, _
        ByVal hmodWinEventProc As LongPtr, _
        ByVal pfnWinEventProc As LongPtr, _
        ByVal idProcess As Long, _
        ByVal idThread As Long, _
        ByVal dwFlags As Long) _
    As Long ' _
End Declare

Private Declare PtrSafe Function UnhookWinEvent Lib "user32.dll" (ByVal hWinEventHook As Long) As Boolean
Private Declare PtrSafe Function GetCurrentThreadId Lib "kernel32" () As Long
Private Declare PtrSafe Function GetCurrentProcessId Lib "kernel32" () As Long

'https://learn.microsoft.com/en-us/windows/win32/winauto/event-constants
Const EVENT_OBJECT_CLOAKED As Long = &H8017&
Const EVENT_OBJECT_HIDE As Long = &H8003&
Const EVENT_SYSTEM_FOREGROUND As Long = &H3&
Const WINEVENT_OUTOFCONTEXT As Long = &H0&
Const WINEVENT_SKIPOWNTHREAD As Long = &H1&
Const WINEVENT_SKIPOWNPROCESS As Long = &H2&
Const WINEVENT_INCONTEXT As Long = &H4&

#If Win64 Then
    Const NULL_PTR As LongLong = 0^
#Else
    Const NULL_PTR As Long = 0&
#End If

Public Sub HookWinEvent()

    Dim FLAGS As Long
    FLAGS = WINEVENT_OUTOFCONTEXT
    'FLAGS = FLAGS + WINEVENT_SKIPOWNTHREAD
    'FLAGS = FLAGS + WINEVENT_SKIPOWNPROCESS
    'FLAGS = FLAGS + WINEVENT_INCONTEXT

    If myHook <> 0 Then UnhookWinEvent myHook
    myHook = SetWinEventHook( _
                 EVENT_SYSTEM_FOREGROUND, _
                 EVENT_SYSTEM_FOREGROUND, _
                 NULL_PTR, _
                 AddressOf WinEventProc, _
                 GetCurrentProcessId(), _
                 GetCurrentThreadId(), _
                 FLAGS _
             )
    Debug.Print "SetWinEventHook/myHook: " & myHook

End Sub

Public Sub UnhookWEvent()
    Debug.Print "UnhookWinEvent: " & UnhookWinEvent(myHook)
    myHook = 0
End Sub

'https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-wineventproc
Public Sub _
    WinEventProc( _
        ByVal hWinEventHook As Long, _
        ByVal wEvent As Long, _
        ByVal hWnd As LongPtr, _
        ByVal idObject As Long, _
        ByVal idChild As Long, _
        ByVal idEventThread As Long, _
        ByVal dwmsEventTime As Long _
    ) ' _
End Declare

    'Try to unhook safely after state loss
    If hWinEventHook <> myHook Then
        Debug.Print "Unexpected CallBack. Attempting to Unhook..."
        If UnhookWinEvent(hWinEventHook) Then
            Debug.Print "UnhookWinEvent(hWinEventHook:=" & hWinEventHook & ")=True 'Unhook Successful"
        Else
            Debug.Print "UnhookWinEvent(hWinEventHook:=" & hWinEventHook & ")=False 'Unhook Failed"
            Debug.Print "Attempting to Unhook via myHook..."
            If UnhookWinEvent(myHook) Then
                Debug.Print "UnhookWinEvent(myHook:=" & myHook & ")=True 'Unhook Successful"
            Else
                Debug.Print "UnhookWinEvent(myHook:=" & myHook & ")=False 'Unhook Failed"
            End If
        End If
        myHook = 0
        Exit Sub
    End If

    'Output
    If hWnd = Application.VBE.MainWindow.hWnd Then
        Debug.Print "The VBE window was opened/closed"
    Else
        Debug.Print "Another window was opened/closed"
    End If

End Sub
cristianbuse commented 2 months ago

Hi @Finatra ,

The UnhookWinEvent() declaration was missing a return type. (gave it As Boolean)

😄 Yeah, sometimes my attention to detail goes down the drain. Thanks for finding that.

The CallBack passes the hook's handle, so if the hook triggers after myHook has lost its state you can safely unhook using the hWinEventHook argument that was passed by the CallBack.

Cool. I tried it and it works.

So what does this mean for you? Is this API code solving your problem, meaning you have no need for StateLossCallback anymore? Or, you don't like this approach and maybe you need something that works on Mac as well?

Best, Cristian

Finatra commented 2 months ago

It turns out that VBE.MainWindow never truly "closes" while its Host Application is running. Closing the window just hides the window.

That being the case, it seems that even if I managed to embed a StateLossCallback object somewhere in the window, the callback would never trigger since hiding the window does not cause state loss.

I was able to monitor the events triggered by closing the VBE window using accevent.exe. The event I want to hook is UIA_Window_WindowClosedEventId:

Public Const UIA_Window_WindowClosedEventId As Long = 20017

Although, it seems to be a "WinEvent" like EVENT_SYSTEM_FOREGROUND and belongs to one of the allocations listed here: https://learn.microsoft.com/en-us/windows/win32/winauto/allocation-of-winevent-ids It doesn't seem to work with SetWinEventHook.

Anyway, you're welcome to close this issue since this is not directly related to StateLossCallback and is mostly just for my own curiosity.

Thank you for your suggestions and your time! -Ben

cristianbuse commented 2 months ago

It turns out that VBE.MainWindow never truly "closes" while its Host Application is running.

Sorry I did not mention that sooner - I knew but I assumed you also knew. I did some tests a few years ago and found out that if you manually kill the VBE window (I mentioned this in my first comment above) then if the code stops on a breakpoint or on a Stop statement or on an unhandled error then the app will crash.

Thanks for giving me feedback on my suggestion. Will let you know when I get to improve this repo in a few weeks time.

Cheers