2

I'm trying to create a Popularity Contest for Forms in our primary front end. There are many items that are no longer used, but getting details on which are used and which are no longer used is proving to be difficult.

So I came up with the idea of logging a form when it is loaded and then in a year or so I'll run a group by and get an idea of which forms are used, how often, and by who. Now the issue is that I don't want to add a line to every forms InitializeComponent block. Instead I would like to put this in the Program.cs file and some how intercept all Form loads so I can log them.

Is this possible?

Edit

Using @Jimi's comment I was able to come up with the following.

using CrashReporterDotNET;
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Windows.Automation;
using System.Windows.Forms;

namespace Linnabary
{
    static class Program
    {
        /// <summary>
        /// The main entry point for the application.
        /// </summary>
        [STAThread]
        static void Main()
        {
            //This keeps the user from opening multiple copies of the program
            string[] clArgs = Environment.GetCommandLineArgs();
            if (PriorProcess() != null && clArgs.Count() == 1)
            {
                MessageBox.Show("Another instance of the WOTC-FE application is already running.");
                return;
            }

            //Error Reporting Engine Setup
            Application.ThreadException += ApplicationThreadException;
            AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException;


            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);

            //This is the SyncFusion License Key.
            Syncfusion.Licensing.SyncfusionLicenseProvider.RegisterLicense("<Removed>");

            //Popularity Contest
            Automation.AddAutomationEventHandler(WindowPattern.WindowOpenedEvent,
                         AutomationElement.RootElement, TreeScope.Subtree, (UIElm, evt) =>
                          {
                              try
                              {
                                  AutomationElement element = UIElm as AutomationElement;
                                  string AppText = element.Current.Name;
                                  if (element.Current.ProcessId == Process.GetCurrentProcess().Id)
                                  {
                                      Classes.Common.PopularityContest(AppText);
                                  }
                              }
                              catch (Exception)
                              {
                                  //throw;
                              }
                          });


            Application.Run(new Forms.frmMain());
        }

        private static void CurrentDomainOnUnhandledException(object sender, UnhandledExceptionEventArgs unhandledExceptionEventArgs)
        {
            ReportCrash((Exception)unhandledExceptionEventArgs.ExceptionObject);
            Environment.Exit(0);
        }

        private static void ApplicationThreadException(object sender, ThreadExceptionEventArgs e)
        {
            ReportCrash(e.Exception);
        }

        public static void ReportCrash(Exception exception, string developerMessage = "")
        {
            var reportCrash = new ReportCrash("<Removed>")
            {
                CaptureScreen = true,
                DeveloperMessage = Environment.UserName,
                ToEmail = "<Removed>"
            };
            reportCrash.Send(exception);
        }

        public static Process PriorProcess()
        {
            Process curr = Process.GetCurrentProcess();
            Process[] procs = Process.GetProcessesByName(curr.ProcessName);
            foreach (Process p in procs)
            {
                if ((p.Id != curr.Id) && (p.MainModule.FileName == curr.MainModule.FileName))
                {
                    return p;
                }
            }
            return null;
        }
    }
}

However, I wonder if there is a way to get the name of the form instead of it's Text. Since this is accessing ALL windows and is therefor outside of the managed space, I doubt it. Still, it works and I'll post this as an answer tomorrow if no one else does so.

Jimi
  • 29,621
  • 8
  • 43
  • 61
Kayot
  • 582
  • 2
  • 20
  • You might want to take a look at the *Observer* design pattern – Cid May 02 '19 at 15:08
  • 1
    You can add to `Program.cs` an [AutomationEventHandler](https://learn.microsoft.com/en-us/dotnet/api/system.windows.automation.automation.addautomationeventhandler). This event is raised when any Window is about to be shown (any Window). You can the determine if this Window belongs to the current Process (your application) and, if it is, log it. There's a working in C# example here: [Run the current application as Single Instance and show the previous instance](https://stackoverflow.com/a/50555532/7444103) (second code section) which is detecting the opposite, just remove the `!`. – Jimi May 02 '19 at 15:23
  • @Cid I've created a class that uses IObserver but it doesn't fire when opening a form. I've never used IObserver before so I'm probably out of my depth on this one. – Kayot May 02 '19 at 15:27
  • @Jimi That code seems more for keeping a second instance of the program from running. I can't see how I can trigger something on a sub form load. – Kayot May 02 '19 at 15:38
  • Nope. That code **as a whole** is used to prevent a second instance of an application (and something more than that). The second part of the code (which is inside the Form's constructor in that code and needs to be moved to `sub Main` in your case), only detects when a Window is opened and determines if it's not part of the current process. This *logic* can be of course reversed. If you need an example, let me know. – Jimi May 02 '19 at 15:41
  • @Jimi The code I've added to my question has an issue. It runs twice for any form my program loads, but once for starting the program. How do I keep it from running twice on form loads? – Kayot May 02 '19 at 16:46
  • Edit; Only forms loaded from the main screen double entry. Sub form forms don't. – Kayot May 02 '19 at 16:55
  • Maybe you have found a bug in your code. That event is raised once per Window (I don't know what you mean with *main screen*). Do you `.Hide()` and `.Show()` a new Instance? About the Forms' names, you could use the `Application.OpenForms` collection, filtering a Form's handle (`.Where()` clause). Of course you'ld need to Invoke the UI thread, so you need an `IAsyncResult` delegate that returns `true` if `Form.Handle.Equals((IntPtr)element.Current.NativeWindowHandle)`. – Jimi May 02 '19 at 17:04
  • Or, you have put that code in two different places (which, btw, is *context-less* in your edit). You only need that code in the `Main` method of `Program.cs`. – Jimi May 02 '19 at 17:12

4 Answers4

2

I'm posting the code that is required to detect and log Forms activity, for testing or for comparison reasons.
As shown, this code only needs to be inserted in the Program.cs file, inside the Main method.

This procedure logs each new opened Form's Title/Caption and the Form's Name.
Other elements can be added to the log, possibly using a dedicated method.

When a new WindowPattern.WindowOpenedEvent event detects that a new Window is created, the AutomationElement.ProcessId is compared with the Application's ProcessId to determine whether the new Window belongs to the Application.

The Application.OpenForms() collection is then parsed, using the Form.AccessibleObject cast to Control.ControlAccessibleObject to compare the AutomationElelement.NativeWindowHandle with a Form.Handle property, to avoid Invoking the UI Thread to get the handle of a Form (which can generate exceptions or thread locks, since the Forms are just loading at that time).

using System.Diagnostics;
using System.IO;
using System.Security.Permissions;
using System.Windows.Automation;

static class Program
{
    [STAThread]
    [SecurityPermission(SecurityAction.Demand, Flags = SecurityPermissionFlag.ControlAppDomain)]
    static void Main(string[] args)
    {
        Automation.AddAutomationEventHandler(
            WindowPattern.WindowOpenedEvent, AutomationElement.RootElement,
            TreeScope.Subtree, (uiElm, evt) => {
                AutomationElement element = uiElm as AutomationElement;
                if (element == null) return;
                try 
                {
                    if (element.Current.ProcessId == Process.GetCurrentProcess().Id)
                    {
                        IntPtr elmHandle = (IntPtr)element.Current.NativeWindowHandle;
                        Control form = Application.OpenForms.OfType<Control>()
                            .FirstOrDefault(f => (f.AccessibilityObject as Control.ControlAccessibleObject).Handle == elmHandle);

                        string log = $"Name: {form?.Name ?? element.Current.AutomationId} " +
                                     $"Form title: {element.Current.Name}{Environment.NewLine}";
                        File.AppendAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "formLogger.txt"), log);
                    }
                }
                catch (ElementNotAvailableException) { /* May happen when Debugging => ignore or log */ }
            });
    }
}
Jimi
  • 29,621
  • 8
  • 43
  • 61
  • This works way better than what I was doing. I changed this to use the database, but using the Text File is good for people who don't have a database or want to keep it simple. – Kayot May 07 '19 at 14:55
0

Yeah, this should be easy. There are event hooks like OnLoad, OnShow, OnClose() for all forms and most user controls. If you wanted to see, at a more granule level what controls are being used by your users, you can hook up OnClick(), OnMouseOver() and about a hundred other events.

... and you can create your own custom events.

So, hook up the events by selecting the form, then properties (right click or F4 key). In the properties window at the top, you've got a "show events" button that looks like a lightning bolt. Click that and then pick, from the list, the event you want to use for this logging.

enter image description here

Brian
  • 3,653
  • 1
  • 22
  • 33
  • This is how I'm doing it right now. I was wondering if there was a way to do it globally so I wouldn't have to add a line to every form. It's possible to catch errors globally so I thought it would be possible to see forms load globally. – Kayot May 02 '19 at 15:39
  • If all your forms inherited from a parent class that had an OnLoad() event, you might be able to do it that way; that might be a global solution. But it sounds like you're trying to reduce your workload, not add to it. – Brian May 02 '19 at 17:32
0

A not so expensive (maybe) solution can be this:

Create a new class MyBaseForm, which inherits from System.Windows.Forms.Form, and handle its load event in the way you need.

Now the hard part: modify all of the existing forms classes so they inherit from MyBaseForm and not from the default System.Windows.Forms.Form; and be sure you do the same for every future Form you will add to your solution.

Not bullet proof at all, it can be easy to forget to modify the base class for a new form and/or to miss the modification for an existing form class

But you can give it a try

Gian Paolo
  • 4,161
  • 4
  • 16
  • 34
  • I'm using a static class in a common class to do the database work. My primary issue is trying to get it to work without touching every forms code. Basically I'm being lazy ^-^ – Kayot May 02 '19 at 15:41
  • @Kayot it seems you are looking for an event "SomeFormIDontKnowWhich_Loaded". As far as I know, such an event does not exists. Let me know if you find something useful. But actually, what I'm suggesting is a very little modification to all existing forms, a search for ": Form" (i.e. classes inherithing from form) can make your like quite easy – Gian Paolo May 02 '19 at 17:46
0

Applying an IMessageFilter to the application to detect the WM_Create message and then determining if the target handle belonged to a Form would be ideal solution with a minimal performance hit. Unfortunately, that message does not get passed to the filter. As an alternative, I have selected the WM_Paint message to reduce the performance impact. The following filter code creates a dictionary of form type names and a count of Form's with that name ultimate disposal. The Form.Closed Event is not reliable under all closure conditions, but the Disposed event appears reliable.

internal class FormCreationFilter : IMessageFilter
{
    private List<Form> trackedForms = new List<Form>();
    internal Dictionary<string, Int32> formCounter = new Dictionary<string, Int32>(); // FormName, CloseCount

    public bool PreFilterMessage(ref Message m)
    {
        // Ideally we would trap the WM_Create, butthe message is not routed through
        // the message filter mechanism.  It is sent directly to the window.
        // Therefore use WM_Paint as a surrgogate filter to prevent the lookup logic 
        // from running on each message.
        const Int32 WM_Paint = 0xF;
        if (m.Msg == WM_Paint)
        {
            Form f = Control.FromChildHandle(m.HWnd) as Form;
            if (f != null && !(trackedForms.Contains(f)))
            {
                trackedForms.Add(f);
                f.Disposed += IncrementFormDisposed;
            }
        }
        return false;
    }

    private void IncrementFormDisposed(object sender, EventArgs e)
    {
        Form f = sender as Form;
        if (f != null)
        {
            string name = f.GetType().Name;
            if (formCounter.ContainsKey(name))
            {
                formCounter[name] += 1;
            }
            else
            {
                formCounter[name] = 1;
            }
            f.Disposed -= IncrementFormDisposed;
            trackedForms.Remove(f);
        }
    }
}

Create an instance and install the filter similar to the following example. The foreach loop is just shown to demonstrate accessing the count.

    static void Main()
    {
        Application.EnableVisualStyles();
        Application.SetCompatibleTextRenderingDefault(false);

        FormCreationFilter mf = new FormCreationFilter();
        Application.AddMessageFilter(mf);

        Application.Run(new Form1());
        Application.RemoveMessageFilter(mf);

        foreach (KeyValuePair<string, Int32> kvp in mf.formCounter)
        {
            Debug.Print($"{kvp.Key} opened {kvp.Value} times. ");
        }
    }
TnTinMn
  • 11,522
  • 3
  • 18
  • 39