Problem Overview
The "standard" Visual Studio way of creating a Windows Service when a product is installed is to create a Custom Action class derived from Installer which uses the ServiceInstaller and ServiceProcessInstaller classes to register the service. There are plenty of samples of this technique around.
The main problem with the "standard" way is that it only creates (registers) the service the first time you run the installer. The next install, say for an update, will crash because the service is already registered. I tried putting logic in the CA class to skip a duplicate install, but it there was a weird crash during install and it backed out.
Secondary problems with a "standard" service installer are related to the status of the service. There is no support to start a service after install or upgrade, or to stop it before uninstall or upgrade. You can write some OnAfterInstall event code to start the service, but any attempts to stop the service will be futile because the code runs at an inappropriate time in the Execute Install Sequence.
Once I realised that the Visual Studio and Framework class support for installing and creating Windows Service is seriously deficient, I had to find if there was a way of creating an MSI installer with full support for managing services. I have seen installers from Microsoft and other vendors that silently install or upgrade Windows Service with no manual intervention. So if other people can do it, then there must be a way I can do the same. The secret lies in two tables inside the installer.
Service Tables
An MSI installer file is a container for a large set of RDB-like tables which provide excruciatingly detailed control over install processing. Two of the tables named ServiceInstall and ServiceControl are specifically designed to control services. By carefully adding a row to each of these tables you can promote an installer to have the magical behaviour where a service is created, stopped, started and removed perfectly during install and uninstall processing.
The question becomes ... what's the easiest way of inserting rows into the service tables? If I could create some code to do this, then I planned to wrap the code in a library and a command line utility so that it can be called from MSBuild or Visual Studio project post-build events.
PowerShell
As an exercise in becoming more proficient in PowerShell coding I attempted to write a script that would insert rows into the service tables. If you run a search for "msi powershell opendatabase" you will many samples where people have used PowerShell to manipulate MSI files and their tables. There is no formal support for interaction with the installer COM object, so you have to use rather verbose and fragile reflection calls on COM objects.
After suffering terribly for many hours I concocted about 80 lines of script that were almost working. I could read the tables, but any attempt to open the database for update failed with cryptic and useless errors. At this point I threw in the towel, as I knew it would take hours of more suffering to perhaps solve the update failure. In any case, I felt like I was abusing PowerShell and using it for something it wasn't ideally designed for, as the script was becoming quite ugly.
On a whim, I opened up LINQPad to write some C# code to replace the script.
Managed Code
The C# dynamic type makes it really easy to work with COM objects. Within 15 minutes I had translated the PowerShell script into some C# code in LINQPad that successfully read and updated MSI installer service tables. The resulting code was a fraction of the original script size and it was much easier to read. To see how easy it is to read installer tables, here's some sample code the displays the contents of the [File] table.
string msifile = $"{testdata}\\MockService-1-0-4.msi"; Type t = Type.GetTypeFromProgID("WindowsInstaller.Installer"); dynamic installer = Activator.CreateInstance(t); dynamic database = installer.OpenDatabase(msifile, 0); dynamic view = database.OpenView("SELECT Component_, FileName, FileSize, Version FROM File"); view.Execute(); dynamic row = view.Fetch(); while (row != null) { string component = row.StringData[1]; string filename = row.StringData[2]; string filesize = row.StringData[3]; string version = row.StringData[4]; Console.WriteLine($"{component} • {filename} • {filesize} • {version}"); row = view.Fetch(); } view.Close(); //database.Commit(); // Only needed if you update something Marshal.FinalReleaseComObject(database); Marshal.FinalReleaseComObject(installer);
The code that scans and updates the MSI service tables has been formalised and placed into a DOS utility command. Source is in this DevOps repository:
https://dev.azure.com/orthogonal/MsiUpdater
There is a method in the file that takes all of the parameters required to register a service in an MSI installer's service tables. It throws if conditions are unexpected. I have wrapped a small console command around the method and I call it in the post-build event of Visual Studio setup projects to silently promote the MSI file into a formal service installer.
Summary
It took me many hours of suffering spread out over many weeks to find the MSI service specific tables, learn how to update them correctly, and to test the resulting installers have the correct behaviour on install, upgrade and uninstall.
I hope this post will help others avoid suffering.
No comments:
Post a Comment