Home arrow Blog arrow Doing Microsoft's job for them... again.
Friday, 24 February 2017
Main Menu
Web Links
Blog Links
Musical Links
Nerdery Links
Other Neato Links
Doing Microsoft's job for them... again.
User Rating: / 5
Written by WATYF on Sunday, 28 August 2005 (14582 hits)
Category: .NET Programming

In an earlier piece, I detailed all the trials an tribulations I endured while trying to deal with the problems and shortcomings of the VS.NET installer (i.e. the MSI file generated by a Setup and Deployment project). Well, you can consider this article to be Part II of that miserable saga.

It's bad enough that some of the most basic and common functionality of installers isn't available in the .NET Setup and Deployment project... but what makes it worse is just how hard it can be to implement some of the simplest features into your installation process.... and that's where I come in. I subject myself to hours of unimaginable misery, just so you don't have to... but that's just the kinda guy I am.

But before I tell you all you need to know about hacking up your very own MSI files, let's list off all the things that a VS.NET Setup and Deployment project won't do for you...

This, of course, is only a partial list, since it would take months... maybe even years... to list them all.

1) It won't check to see if a previous version of your program is currently running. Honestly, I don't consider this to be a major oversight on their part... this is more of a "nice to have" functionality. But, the funny thing is... it does check to see if completely unrelated software is running. I had the VS.NET IDE open when I tried to run it once, and it told me the install couldn't proceed until I closed VS.NET. So obviously, they're checkin' to see if some kinds of software are running.... just not the ones that matter.

2) It won't detect the installation directory of a previous install, and display that to the user as the default. This is pretty much a standard feature in every installer I've every used. If I'm doing an upgrade, I don't have to browse to the folder I had already installed that software to... it finds out what it is and plops it in the folder textbox for me.

3) It won't allow you to add shortcuts based on conditions. This is something I mentioned in the previous article. Considering this is pretty much a staple of installer features, it's kind of annoying that you even have to think of a way to get this one to happen.

4) It won't replace previous versions of installed files if you don't set RemovePreviousVersions to True. This one was kind of a pain. I mean... if I send someone a new install package, I expect it to at least overwrite the critical application files with the ones included in the new version.... but no... no such luck. Instead, you have to set RemovePreviousVersions to True... but... then, if you're using some kind of custom action during uninstall, it will run that custom action. Well, since I want my uninstall action to completely wipe all traces of my program from the user's machine, it ends up deleting all their previous settings and files before installing the new version. This is obviously a bad thing.

So... now that we've established a few of the things we'd like to do that VS.NET isn't gonna do for us... let's dive into the methods you can use to do them yourself.

First off, this all hinges on being able to hack up the MSI file generated by VS.NET. If you can't get that far, then you ain't doin' nuthin. So let's touch on that really quick. There are a number of ways to do this...

  • You can use Orca, which comes with the Windows Installer SDK, to open the MSI file and make changes manually. (Here's a direct link to the Orca.msi download, in case you don't feel like downloading the entire SDK just to get it.) Obviously, you'd have to manually edit the MSI file every time you build your solution again, so this isn't the best option, but I recommend getting Orca anyway, so you can look through the tables and get familiar with the structure of your MSI, and so you can debug the changes you make programmatically (using whatever method the you end up choosing).

  • You can use the Windows Installer API calls directly. This is not the most appealing method, since it requires a lot more handling. But if you're handy with API functions, and you don't mind doing a little dirty work yourself, it doesn't take too many functions to open the database and hit it with a few SQL statements.

  • You can use the WiRunSQL.vbs file (that also comes with the WI SDK) to run SQL statements against the MSI file... This is the most quick-and-dirty option. No objects, no functions... just run the vbs file and pass it an SQL statement (and the msi file) as arguments, and it does all the work for you. Here's an example of running an SQL statement against an MSI file using WiRunSQL.vbs:

    CScript WiRunSQL.vbs MyInstall.msi "UPDATE `Property` SET `Value`='ALL' WHERE `Property`='FolderForm_AllUsers'".

    Just use Shell or Process.Start or a command line (or whatever) to run those commands. A few things to note... in the SQL statements, the fields and tables should all be surrounded by grave marks (`), and they're all case-sensitive. It's not a full-featured SQL subset, either, so you'll run into limitations if you try to do certain things (which I'll get into later).

  • You can use the msi.dll COM object. This should already be on your machine, of course. Just add it as a reference to your project, and call the methods of the Database object to execute Views. I won't get into this one much, because the method I went with is very similar to this, so msi.dll can be used in roughly the same manner.

  • Use a .NET wrapper for the Windows Installer API. This is the method that I went with. Actually, there are a few .NET wrappers out there... on SourceForge... on CodeProject... and on Youseful.com. The latter two are mostly just wrappers for each individual API function, but the SourceForge option (MSI.Interop) gives you a nice object model to work with (very similar to the msi.dll object model). Because of this, I went with MSI.Interop. (Some may wonder why I didn't just use msi.dll, since it's already there, but it's a COM object, and I try to stay 100% .NET with all my projects. In all honstely, I could have used either, and since MSI.Interop is only 56KB, it wasn't exactly a huge effort to download it )

    Here is an example of how to use the MSI.Interop to run SQL statements against your msi file:

    Imports Pahvant.MSI
        Sub Test()
                Dim msi As New Database("C:\My Files\MyInstall.msi", Database.OpenMode.Transact)
                MSIRunSQL("UPDATE `Property` SET `Value`='ALL' WHERE `Property`='FolderForm_AllUsers'", msi)
        End Sub
        Sub MSIRunSQL(ByVal sSQL As String, ByVal msi As Database, Optional ByVal rcd As Record = Nothing)
            Dim vw As Pahvant.MSI.View
            vw = msi.OpenView(sSQL)
            If rcd Is Nothing Then vw.Execute() Else vw.Execute(rcd)
            vw = Nothing
        End Sub

    So... here's what's happening. I'm opening an MSI file (database) and returning the resultant object to my variable (msi). I then pass that object, and an SQL string to a Sub(MSIRunSQL). MSIRunSQL opens a view, using the SQL statement, and executes that view. (the rcd object will be explained later) So now, we can run all the SQL statements we want against the MSI file just by using the msi object and whatever SQL string we like. Note: You could run each statement by just creating the view object and executing it all in one line: msi.OpenView("UPDATE blah WHERE blah='blah').Execute() ...but the problem with that is that you leave objects open that are created by the API functions, and then the msi file is locked for further use (until your app ends). So if you're trying to make changes to the MSI and then do something else to it (like zip it or ftp it somewhere or whatever) then your zip/ftp/whatever code will give you a "file is in use" exception.

    OK... so now we've covered how we're gonna modify the MSI file. Let's get into the stuff we mentioned earlier that we'd like to make our Installer do:

1) Figuring out if our program is currently running: This one was a fun one.... not because it's hard to figure out if a previous version of your app is running (that part was cake)... but because it's dern near impossible to get the check to happen at the right time. What we want is some code (VBScript, .NET exe, whatever) to run before the installation even starts. Well the problem with that is, before the installation runs, your files haven't been installed yet (duh), so you can't exactly just set a custom action to kick-off a file from disk. And this is where actually taking the time to read the MSDN documentation of Custom Actions comes in handy.  There are actually a few ways to run a script during installation without calling a file on disk at all, but I'll just cover two. The first way is by setting your CustomAction's Type to 38, and storing an entire string of VBScript in the Target field. Basically, you type up your VBScript, debug it, then dump that entire string of text into the CustomAction table... here's an example of creating a custom action that will list all the services running on the machine:

MSIRunSQL("INSERT INTO `CustomAction` (`Action`, `Type`, `Source`, `Target`) VALUES('MyScript', '38', '', 'Option Explicit" & vbCrLf _
                & "Dim oWMI, oPrc, cPrc, sList" & vbCrLf & "oWMI = GetObject(""winmgmts:{impersonationLevel=impersonate}!\\.\root\cimv2"")" & vbCrLf _
                & "cPrc = oWMI.ExecQuery(""Select * from Win32_Process"")" & vbCrLf & "For Each oPrc In cPrc" & vbCrLf & "sList = sList & vbCrLf & oPrc.Name" & vbCrLf _
                & "Next" & vbCrLf & "MsgBox sList" & "WScript.Quit'", msi)

This isn't the most elegant way to do it. We have to play games with the quotes and what not (since we're inserting lines with quotes in them into the table. And that actually becomes the biggest downfall of this method... you can't escape apostrophes. Normally you can insert an apostrophe into a table by using two apostrophes ('')... but this particular breed of SQL won't accept that... so since I'm trying to find out if my service is running on the machine, I can't insert a line saying "oPrc.Name='TRunner' ", since the apostrophes will cause a syntax error. There are ways (using the API) to update the string of a record, but I didn't bother getting into that, because I found a more robust way to handle this problem...

Embedding an executable in the MSI as binary data. If you create a Custom Action with a Type of 2, you can store your executable in the Binary table and call it at any point during the Install sequences. This is where that "rcd" object comes into play that we saw earlier. What you do is create a new record, store the binary data in it, then insert that record into the table using the same MSIRunSQL Sub we've been using for everything else. The rcd object is passed to MSIRunSQL, which uses it in the Execute method. You then insert a record into the CustomAction table with a key to the Binary in the Source field (and any arguments in the Target field).

            Dim rcd As New Record(1)
            rcd.SetStream(1, "C:\MyFiles\MyExec.exe")
            MSIRunSQL("INSERT INTO `Binary` (`Name`, `Data`) VALUES ('MyBinary', ?)", msi, rcd)
            MSIRunSQL("INSERT INTO `CustomAction` (`Action`, `Type`, `Source`, `Target`) VALUES('MyExec', '2', 'MyBinary', '/arg1')", msi)

Btw, the code used in the exec to see if the process is running is this:

                If Not Process.GetProcessesByName("MyProcess").Length = 0 Then
                    MsgBox("An instance of the program if currently running. Please exit the program before continuing with the installation.", MsgBoxStyle.Exclamation)
                End If

So that's problem number one solved. On to the next task.

2) Detecting the installation directory of a previous install and dropping it in the installation folder selection field: Believe it or not, this one is pretty easy.... once you know what to do.  There are a number of ways to do this (as with most anything), but we'll focus on one particular way, and you can tailor it to your own needs. What we need to do is run an AppSearch to find any versions of our program, and return the directory. You could search for a registry key (if your program stores it's location to the registry) or search for a particular file (which takes longer, and isn't an optimal way to do it), but we're going to search for the GUID of a file (our primary executable). Here's what we need to do... Put a record in the AppSearch table with a Property (which will store the directory, if the file's GUID is found) and a Signature (which is a key to the table that stores the GUID that we're looking for). We could add our own property to store the directory in, but fortunately, the MSI file from VS.NET already has a property named TARGETDIR which is (oddly enough) uses to determine what directory to install to... it also uses TARGETDIR to determine what to display in the install folder textbox when the user is asked what folder they'd like to install the program to. So, since that property is already there for the taking, we're going to commandeer it for our own purposes. Next, we add a record in the CompLocator table... this is the table used to locate the GUID (if you were trying to locate the program using other means, you'd add a record to the RegLocator, IniLocator, or DrLocator tables). The Signature_ field holds the signature that we put in the AppSearch table, and the ComponentId field is where we put the GUID. We also put a 1 in the Type field, since we're looking for a file. Here's the code needed to do that:

            MSIRunSQL("INSERT INTO `AppSearch` (`Property`, `Signature_`) VALUES('TARGETDIR', 'FindMyProgram')", msi)

            MSIRunSQL("INSERT INTO `CompLocator` (`Signature_`, `ComponentId`, `Type`) VALUES('FindMyProgram', '{18238G214-62HT-829P-B317-3Y7J725TWHW8}', 1)", msi)

Since the AppSearch should already be set to run early on in the InstallUISequence and InstallExecuteSequence, it will look for our GUID and store it's directory in the TARGETDIR property if it finds it (otherwise, it leaves TARGETDIR as whatever it already was). Btw, the GUID for the file can be found in the ComponentId field of the Component table... and to figure out which Component is the file you want to check for, look in the File table (which contains the Component value as a key).

3) Adding shortcuts based on user input: This one's not too tough. In the Setup and Deployment project, add a "Checkboxes" UI to the Install sequence. Set a Label ("Would you like to add a Desktop shortcut", etc) for each of them, and set the property that their result will be returned to. (If you don't need all four checkboxes, set the Visible property to False for the ones you don't need) So this will give our user some options to check, and it will return their input to the properties we created. Then, we just add a Custom Action in the Install folder and select the executable (in your installation package) that you'd like to run. In the arguments field, we pass it the properties that get populated by the checkboxes. So, let's say we added a Checkbox UI, made two checkboxes asking if they'd like to put a shortcut on the Desktop and in the Quick Launch menu. We set the Property of the first one to DTCHK and the second one to QLCHK. Then we added our custom action (which calls our executable) and set the Arguments field to: /[DTCHK] /[QLCHK] (the reason we put the backslash in there is because if they don't check the box, it doesn't pass anything at all (instead of passing "False), so if they didn't check the first checkbox, the value of the second checkbox would actually be in the first argument. So at least this way, it passes the "/" if the user selects nothing).

So now, we make an executable that checks its arguments to see what they selected, and creates the shortcuts accordingly:

    Sub Main(ByVal sArg() As String)
                If sArg(0) = "/1" Then
        'Create Desktop Shortcut
                End If
                If sArg(1) = "/1" Then
        'Create Quick Launch Shortcut
                End If
        Catch ex As Exception
        End Try
    End Sub

I'm not actually going to go into how to create the shortcuts... if you need a reference on that, you can check out this managed .NET wrapper for ShellLink.

4) Replacing previous versions of installation files, without removing ancillary files (such as configuration data): This one requires simply changing when the RemoveExistingProducts action is executed. The default sequence is 1525 (right after InstallInitialize). There are advantages to having it here, and if you don't have a custom action that cleans up your files/folders during uninstall, then I would suggest leaving it here. But since I want a complete cleanup to happen if they're uninstalling, but not to happen if they're just upgrading, then I need to move it. The best place I found to put it is at the very end, after InstallFinalize. The up side is that it won't run the uninstall custom action and delete the user's settings... the downside is that if the removal of the existing products fails, it only rolls back the removal, not the installation. You may be happy with this behavior, though... it really depends on your situation. In any event, this worked out just fine for me. So... to do this, we're just going to update the InstallExecuteSequence table.

MSIRunSQL("UPDATE `InstallExecuteSequence` SET `Sequence`=6700 WHERE `Action`='RemoveExistingProducts'", msi)

And now..... finally... after all that... we have an MSI package that behaves in a reasonable manner...

....no thanks to MS.


< Prev   Next >


You must javascript enabled to use this form

Your article is very useful for me, very good experience of programming. thanks

Posted by Gorden Lin, on 03/01/2006 at 06:51

Very useful. I'm working to add files to a MSI programatically (for output). For example: add a file (test.txt) to the MSI file and when I run it, a copy of the text.txt appears to the TARGETDIR.

I would like to do this using this great "MSI.Interop"

Have you any idea how to do this???

Thanks a lot :)

Posted by FMartins, on 03/27/2006 at 07:06

You can also use MAKEMSI to update the MSI which is much more readable. You can generate the basic script by manually making the changes with orca and using MSIDIFF to compare the new and old versions and dumping the differences as MAKEMSI script.

Posted by Dennis, on 08/29/2006 at 19:16

I also hate the Windows Installer with a passion, and reading your posts on it really gets my blood boiling again. You seem to have had exactly the same experience as me: the most seemingly obvious things are apparently beyond Microsoft's ability to foresee, and we are left with a half-baked solution that we have to hack and prod to get to work in the way we want.

Anyhow I would really like to be able to quit already running instances of a program before the installation process commences, and I was delighted to find your post on the matter. Unfortunately, try as I might I can't seem to get it to work. I have written an application that when executed will do the quit for me, but I can't seem to get it to run using the above method along with MSI.Interop.

By chance have you discovered another way to do this since posting this article?

Please email me. Thanks in advance.

Posted by Logan, whose homepage is here on 07/31/2007 at 18:52

Aha! Seems you forgot to mention that an entry must also be made to the InstallExecuteSequence or InstallUISequence tables with an Action of 'MyExec' and an appropriate sequence number, otherwise the custom action will never be executed.

Please update your article for future readers, and thanks again for the solution! The ONLY such solution I've been able to find.

Posted by Logan, whose homepage is here on 08/01/2007 at 07:44

Page 1 of 1 ( 5 Comments )
©2007 MosCom

TaskRunner Spam-a-lot

It's cool. It's nifty. It does stuff. It's...

  TaskRunner 3.2!!

Click here to get the brand spanking new version of TaskRunner... 3.2!!
Who's Online

Recent Posts
Most Popular
© 2017 Musical Nerdery
Joomla! is Free Software released under the GNU/GPL License.