Clean Up User Settings

How to use an InstallerClass to locate and remove user settings when uninstalling an application

I typically create a Visual Studio installer project to build install and uninstall scripts for my applications. Although there are more powerful solutions available for creating install scripts, I use the installer project because it is free, simple to use, and meets my needs most of the time. It does a decent job of installing my applications and cleaning up whatever it installed when I uninstall my applications. The problem comes when the application creates registry entries or creates configuration files at run time. The installer is unaware of these items because it did not create them. On application uninstall these registry entries and configuration files are left behind by the uninstaller. While they don't take up a lot of space, it is good etiquette to clean up behind your self and leave no trace behind. I even ran into a situation where a corrupted settings file caused an application to crash on startup. Uninstalling the application did not clean up the settings file. So after uninstalling and reinstalling the application it still crashed. This required removing the corrupt file by hand. Needless to say the user was not pleased. Although the real issue was insufficient error handling when loading the configuration file, performing an uninstall and reinstall should have worked.

Approach

The approach I used to clean up application settings was to use an InstallerClass to run my cleanup code. An InstallerClass is a class you create in the application you are installing. This class inherits from Installer and has several methods you can override. These override-able methods correspond to a number of events that occur during the install and uninstall process of an application. This gives you a way to run some of your own code during the application install and uninstall process.

The methods you can override are:

  • Commit
  • Install
  • OnAfterInstall
  • OnAfterRollback
  • OnAfterUninstall
  • OnBeforeInstall
  • OnBeforeRollback
  • OnBeforeUninstall
  • OnCommitted
  • OnCommitting
  • Rollback
  • Uninstall

Preparation

In order to use an InstallerClass, some up front work is required. Here is how to create the InstallerClass and how to tie the pieces together.

The first step is to add an InstallerClass to the Visual Studio main application project. Right click on the project in Solution Explorer and select Add/New Item from the context menu. In the Search text box enter Installer. Then select InstallerClass from the list, enter a name for your new class, and select Add. A new class with the name you specified will be added to your project.

Now right click on the class you just added in Solution Explorer and select View Code. Then add the UnInstall method to the class. Note that we call our base class UnInstall method. The base class performs the actual uninstall. We want to be able to run our code after the uninstall is run. We will be adding the additional code to this method shortly. For now we need to finish setting up the basic skeleton.

      public override void UnInstall(IDictionary savedState)
      {
          base.UnInstall(savedState);
      }    
    

Next you need create a custom action in the setup project to call the UnInstall() method you just created. To do this, right click on the your Visual Studio setup project in solution explorer and select View/Custom Actions in the context menu. This will open an editing window that will permit you to specify a custom uninstall action. Since we are overriding the Uninstall action, right click on Uninstall and select Custom Action from the context menu. Select Application Folder and press OK. Then select the primary output from your application and select OK again. Now when your application is uninstalled the installer will call our overridden UnInstall method in the main application.

We only want our clean up code to run when the application is being completely uninstalled and not when the application is only being upgraded. We can add a condition to the custom uninstall action to limit when our custom uninstall code is run. The condition we want to add in the properties window is

    REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE

Writing the Clean Up Code

Now that we have the skeleton set up, we can write the clean up code using our overridden UnInstall method to remove any stray configuration files. My application created the settings file under the users AppData\Local folder. Normally you could find this folder using

      Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
    

But in this case Windows Installer is running the UnInstall() method under the System account rather than the application user's account. So the Environment class will return the folder path for the System account folders rather than the folder path for the application user's account. We need to find a way to determine which user account ran Windows Installer so we can clean up their application data. After some research I found a way using some Windows API calls.

First we need to define some API functions we plan to call.These functions will allow us to retrieve the session information we need.

        [DllImport("Wtsapi32.dll")]
        private static extern bool WTSQuerySessionInformation(IntPtr hServer, int sessionId, WtsInfoClass wtsInfoClass, out IntPtr ppBuffer, out int pBytesReturned);

        [DllImport("Wtsapi32.dll")]
        private static extern void WTSFreeMemory(IntPtr pointer);

        [DllImport("kernel32.dll")]
        private static extern uint WTSGetActiveConsoleSessionId();

        private enum WtsInfoClass
        {
            WTSUserName = 5,
            WTSDomainName = 7,
        }

        private const uint INVALID_SESSION_ID = 0xFFFFFFFF;

Next we need to define some helper methods to make things a bit easier. We need to be able to get the user name for the specified session, retrieve the SID for that user, and then read their profile path from the registry. These three methods accomplish that.

        
        private string GetUsername(int sessionId, bool prependDomain = true)
        {
            var username = "SYSTEM";
            if (WTSQuerySessionInformation(IntPtr.Zero, sessionId, WtsInfoClass.WTSUserName, out IntPtr buffer, out int strLen) && strLen > 1)
            {
                username = Marshal.PtrToStringAnsi(buffer);
                WTSFreeMemory(buffer);
                if (prependDomain)
                {
                    if (WTSQuerySessionInformation(IntPtr.Zero, sessionId, WtsInfoClass.WTSDomainName, out buffer, out strLen) && strLen > 1)
                    {
                        username = Marshal.PtrToStringAnsi(buffer) + "\\" + username;
                        WTSFreeMemory(buffer);
                    }
                }
            }
            return username;
        }

        public string GetUserSID(string userName)
        {
            try
            {
                NTAccount account = new NTAccount(userName);
                SecurityIdentifier sid = (SecurityIdentifier)account.Translate(typeof(SecurityIdentifier));
                return sid.ToString();
            }
            catch
            {
                return null;
            }
        }
        
        //You can either provide User name or SID
        public string GetUserProfilePath(string userName, string userSID = null)
        {
            try
            {
                if (userSID == null)
                {
                    userSID = GetUserSID(userName);
                }

                var keyPath = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\" + userSID;

                var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(keyPath);
                if (key == null)
                {
                    //handle error
                    return null;
                }

                var profilePath = key.GetValue("ProfileImagePath") as string;

                return profilePath;
            }
            catch
            {
                //handle exception
                return null;
            }
        }

Finally, our Uninstall method now looks like this:

        public override void Uninstall(IDictionary savedState)
        {
            base.Uninstall(savedState);

            try
            {
                // proc is only being used so we can place a MessageBox in front of the installer dialog. Otherwise the MessageBox
                //  will appear behind the installer dialog and not be visible to the user. Probably best to not display one if possible.
                // If you have no need to display a MessageBox you can omit the code to get the process.
                //
                // We make two attempts to get the process. That is because if we are debugging in Visual Studio the process will be 
                //  the development environment rather than the windows installer
                //
                string windowTitle = "Your window title";
                var proc = Process.GetProcessesByName("msiexec").FirstOrDefault(p => p.MainWindowTitle == windowTitle);
                if (proc == null) proc = Process.GetProcessesByName("devenv").FirstOrDefault(p => p.MainWindowTitle == windowTitle);

                // Retrieve the session identifier of the console session. The console session is the session that is
                //  currently attached to the physical console.
                var activeSessionId = WTSGetActiveConsoleSessionId();

                // If we got back a valid session identifier
                if (activeSessionId != INVALID_SESSION_ID)
                {
                    // get the user name associated with that session. That should be the user we are looking for
                    //  get the profile path for that user and then build up the path to the configuration settings folder
                    string userName = GetUsername((int)activeSessionId, false);
                    string profilePath = Path.Combine(GetUserProfilePath(userName), "AppData");

                    // remove config files
                    string configFileFolder = "Local\\YourFoldername";
                    string localApplicationDataFolder = Path.Combine(profilePath, configFileFolder);
                    if (Directory.Exists(localApplicationDataFolder))
                    {
                        // TODO:delete any configuration files in this folder
                    }

                    // If local application data folder is empty then delete it as well
                    if (Directory.GetFiles(localApplicationDataFolder).Length == 0)
                    {
                        try
                        {
                            Directory.Delete(localApplicationDataFolder);
                        }
                        catch(Exception)
                        {
                            // TODO: is there anything we can or should do here?
                        }
                    }
                }

            }
            catch (Exception ex)
            {
                MessageBox.Show("Exception in uninstall: " + ex.Message);
            }
        }
    

If you have any registry entries to delete under the user's profile, you can use code similar to GetUserProfilePath() to find the correct path in the registry.

This took me some time to research and debug. I hope it helps to save you some time when developing your installation scripts. A similar technique can be used when installing an application to add configuration files or registry entries as well.

Written by Seth Cohen on 04-Dec-2022

If you want to encourage me to continue developing software you can