From MSI to WiX, Part 19 – The Art of Custom Action, Part 1


The main page for the series is here.


 


Code for this topic is attached.


 



Introduction


Today we will start exploring custom actions, how to write them, what makes custom action good custom action and everything else related to custom actions.


Let’s start with very simple sample.  We have an application which creates a new file during run-time and this file is not part of originally installed files.  On uninstall we need to delete this file as well.  The name of the file is well known.


Here is the source for our test console application:


using System;


using System.Collections.Generic;


using System.Text;


using System.IO;


 


namespace Test


{


    class Program


    {


        static void Main(string[] args)


        {


            using (StreamWriter fs = File.CreateText(“Test.txt”))


            {


                fs.Write(“Hello”);


            }


 


            Console.WriteLine(“Bye”);


        }


    }


}


During run time our program creates Test.txt file which we want to remove on our test application uninstall.  Because new file name is well known, all we need to do is to add RemoveFile element:


<?xml version=1.0 encoding=UTF-8?>


<Wix xmlns=http://schemas.microsoft.com/wix/2003/01/wi>


  <Product Id={2F59639E-4626-44f8-AAF0-EE375B766221}


           Name=Test Application


           Language=1033


           Version=0.0.0.0


           Manufacturer=Your Company>


    <Package Id=????????-????-????-????-????????????


             Description=Shows how to delete run-time file with known file name.


             Comments=This will appear in the file summary stream.


             InstallerVersion=200


             Compressed=yes />


 


    <Media Id=1 Cabinet=Product.cab EmbedCab=yes />


 


    <Directory Id=TARGETDIR Name=SourceDir>


      <Directory Id=ProgramFilesFolder>


        <Directory Id=INSTALLLOCATION Name=Test1 LongName=Test for removal of run time file>


 


          <Component Id=ProductComponent


                     Guid={8F4CC43A-9290-4c93-9B97-B9FC1C3579CC}>


            <File Id=Test.exe DiskId=1 Name=Test.exe


                  Source=..\Test\bin\Debug\Test.exe KeyPath=yes />


 


            <RemoveFile Id=Test.txt On=uninstall Name=Test.txt />


          </Component>


 


        </Directory>


      </Directory>


    </Directory>


 


    <Feature Id=ProductFeature Title=Feature Title Level=1>


      <ComponentRef Id=ProductComponent />


    </Feature>


  </Product>


</Wix>


To test our install, install application, go to installation folder and run Test.exe.  Application will create Test.txt file.  Uninstall application and make sure that installation folder is gone. 


There is no need for custom action in here.  Things are more complicated if our program creates file or files with unknown at installation creation package time.


Here is an updated version of Test application.  It creates a file with arbitrary name:


using System;


using System.Collections.Generic;


using System.Text;


using System.IO;


 


namespace Test


{


    class Program


    {


        static void Main(string[] args)


        {


            using (StreamWriter fs = File.CreateText(DateTime.Now.Ticks.ToString()+ “.log”))


            {


                fs.Write(“Hello”);


            }


 


            Console.WriteLine(“Bye”);


        }


    }


}



Because we don’t know the name of the new file we need custom action which will find this new file and delete it.  This custom action must be deferred and, for simplicity sake, we will be using VBScript custom action type 38 (see introduction to custom actions for more details).  Because our custom action is deferred, we will pass all required parameters through CustomActionData property (see here for details).


<?xml version=1.0 encoding=UTF-8?>


<Wix xmlns=http://schemas.microsoft.com/wix/2003/01/wi>


  <Product Id={2F59639E-4626-44f8-AAF0-EE375B766221}


           Name=Test Application


           Language=1033


           Version=0.0.0.0


           Manufacturer=Your Company>


    <Package Id={58BCF2DD-16A0-4654-B289-F13AADA240CD}


             Description=Shows how to delete run-time file with known file name.


             Comments=This will appear in the file summary stream.


             InstallerVersion=200


             Compressed=yes />


 


    <Media Id=1 Cabinet=Product.cab EmbedCab=yes />


 


    <Directory Id=TARGETDIR Name=SourceDir>


      <Directory Id=ProgramFilesFolder>


        <Directory Id=INSTALLLOCATION Name=Test1 LongName=Test for removal of run time file>


 


          <Component Id=ProductComponent


                     Guid={8F4CC43A-9290-4c93-9B97-B9FC1C3579CC}>


            <File Id=Test.exe DiskId=1 Name=Test.exe


                  Source=..\Test\bin\Debug\Test.exe KeyPath=yes />


          </Component>


 


        </Directory>


      </Directory>


    </Directory>


 


    <CustomAction Id=RemoveTempFile Script=vbscript Execute=deferred>


      <![CDATA[


      On error resume next


      Dim fso


      Set fso = CreateObject(“Scripting.FileSystemObject”)


      fso.DeleteFile(Session.Property(“CustomActionData”) + “*.log“)


      Set fso = Nothing


      ]]>


    </CustomAction>


 


    <CustomAction Id=SetCustomActionData


                  Property=RemoveTempFile Value=[INSTALLLOCATION] />


 


    <InstallExecuteSequence>


      <Custom Action=SetCustomActionData Before=RemoveTempFile>REMOVE=”ALL”</Custom>


      <Custom Action=RemoveTempFile Before=RemoveFiles>REMOVE=”ALL”</Custom>


    </InstallExecuteSequence>


 


    <Feature Id=ProductFeature Title=Feature Title Level=1>


      <ComponentRef Id=ProductComponent />


    </Feature>


  </Product>


</Wix>


Few things to notice in here.  First of all, we need to set CustomActionData property before our RemoveTempFile custom action.  RemoveTempFile custom action must be scheduled before RemoveFiles standard action because this standard action decides on whether installation folder will be removed or not.  If at the end of RemoveFiles standard action folder is not empty, folder won’t be deleted. That’s why we want to delete extra files before RemoveFiles standard action.  Also, both custom actions are conditioned to run on complete uninstall only.


Let’s test our installer.  Install application and run Test.exe a few times.  After every run you will see a new file being created.  Now uninstall the application.  Installation folder should disappear.


So, are we successfully solved the problem?  Let’s create an uninstall failure condition.  In all good installation packages all changes made during uninstall should be rolled back in case of uninstall failure.  Let’s see how we are doing.  The easiest way to create an uninstall failure is to schedule deferred custom action type 54 right before InstallFinalize standard action.  We can’t use custom action type 19 because this custom action does not allow setting execution option (in other words, it is always immediate custom action) and we want to fail an installation after we ran our RemoveTempFile custom action.


Here is our updated WiX source file:


<?xml version=1.0 encoding=UTF-8?>


<Wix xmlns=http://schemas.microsoft.com/wix/2003/01/wi>


  <Product Id={2F59639E-4626-44f8-AAF0-EE375B766221}


           Name=Test Application


           Language=1033


           Version=0.0.0.0


           Manufacturer=Your Company>


    <Package Id={58BCF2DD-16A0-4654-B289-F13AADA240CD}


             Description=Shows how to delete run-time file with known file name.


             Comments=This will appear in the file summary stream.


             InstallerVersion=200


             Compressed=yes />


 


    <Media Id=1 Cabinet=Product.cab EmbedCab=yes />


 


    <Directory Id=TARGETDIR Name=SourceDir>


      <Directory Id=ProgramFilesFolder>


        <Directory Id=INSTALLLOCATION Name=Test1 LongName=Test for removal of run time file>


 


          <Component Id=ProductComponent


                     Guid={8F4CC43A-9290-4c93-9B97-B9FC1C3579CC}>


            <File Id=Test.exe DiskId=1 Name=Test.exe


                  Source=..\Test\bin\Debug\Test.exe KeyPath=yes />


          </Component>


 


        </Directory>


      </Directory>


    </Directory>


 


    <CustomAction Id=RemoveTempFile Script=vbscript Execute=deferred>


      <![CDATA[


      On error resume next


      Dim fso


      Set fso = CreateObject(“Scripting.FileSystemObject”)


      fso.DeleteFile(Session.Property(“CustomActionData”) + “*.log”)


      Set fso = Nothing


      ]]>


    </CustomAction>


 


    <CustomAction Id=SetCustomActionData


                  Property=RemoveTempFile Value=[INSTALLLOCATION] />


 


    <Property Id=FailureProgram>


      <![CDATA[


      Function Main()


        Main = 3


      End Function


      ]]>


    </Property>


 


    <CustomAction Id=FakeFailure


                  VBScriptCall=Main


                  Property=FailureProgram


                  Execute=deferred />


 


    <InstallExecuteSequence>


      <Custom Action=SetCustomActionData Before=RemoveTempFile>REMOVE=”ALL”</Custom>


      <Custom Action=RemoveTempFile Before=RemoveFiles>REMOVE=”ALL”</Custom>


 


      <Custom Action=FakeFailure Before=InstallFinalize>REMOVE=”ALL” AND TESTFAIL</Custom>


    </InstallExecuteSequence>


 


    <Feature Id=ProductFeature Title=Feature Title Level=1>


      <ComponentRef Id=ProductComponent />


    </Feature>


  </Product>


</Wix>



FakeFailure custom action is running VBScript stored in the FailureProgram property.  This script just returns the value 3 which means that script has failed.  This return code will force Windows Installer engine to fail an uninstall and roll back all changes made up to this point.  We also put a condition on FakeFailure custom action to run on uninstall and only if TESTFAIL property is defined.  The reason for this is that we don’t want our installation package to be stuck in uninstall failure.  Doing that will require use of MsiZap tool to remove our installation from the system.  Instead, we allow uninstall to succeed normally, but if we want to fail an installation we must use command line to do that:


msiexec /uninstall Project.msi TESTFAIL=YES


Let’s install our test application, run Test.exe few times.  Make sure that we have few files created in the same folder where Test.exe is located.  Now try to uninstall program by running above mentioned command in the command window.  Installation will fail.  Go back to installation folder where Test.exe file is located and observe that all files created by Test.exe are gone.


Depending on what those files are that may be OK, but that could be a big problem.  Imagine, that our Test.exe is a personal finance management software (something like Microsoft Money) and it stores last synchronization  with bank’s server data.  By removing those extra files we are forcing our software to resynch the whole transaction history on the next run.  That’s bad.


So, how we can fix the problem.  Remember, that in addition to immediate and deferred custom actions we have also rollback (runs during rollback installation phase) and commit (runs at the end of successful transaction) custom actions.  Changes that we are going to make are:




  • Deferred custom action will move files to be deleted into temporary folder


  • Commit custom action will delete files from the temporary folder


  • Rollback custom action will move files back from temporary folder to installation folder

Because scripts will be larger than custom action type 38 can store we will be using custom action type 6 instead (again, see introduction to custom actions for more details).


Here is the deferred custom action (MoveTempFile.vbs):


Function Main()


 


    On Error Resume Next


 


    Dim properties, tempFile, fso, folder


   


    Const msiDoActionStatusSuccess = 1


 


    properties = Split(Session.Property(“CustomActionData”), “;”, -1, 1)


    tempFile = properties(1) & “{42A7DCCB-868B-4D11-BBBE-5A32B8DF5CC9}\”


 


    Set fso = CreateObject(“Scripting.FileSystemObject”)


    Set folder = fso.CreateFolder(tempFile)


    fso.DeleteFile tempFile & “*.log”, true


    fso.MoveFile properties(0) & “*.log”, tempFile


    Set folder = Nothing


    Set fso = Nothing


   


    Main = msiDoActionStatusSuccess


 


End Function


In CustomActionData we will pass both INSTALLLOCATION and TempFolder properties.  This script will create subfolder in the TempFolder.  Subfolder’s name is the UpgradeCode Guid.  It will move log files from installation folder to temporary folder.


Commit script (CommitTempFile.vbs) will delete log files from temporary folder:


Function Main()


 


    On Error Resume Next


 


    Dim properties, tempFile, fso, folder


   


    Const msiDoActionStatusSuccess = 1


 


    properties = Split(Session.Property(“CustomActionData”), “;”, -1, 1)


    tempFile = properties(1) & “{42A7DCCB-868B-4D11-BBBE-5A32B8DF5CC9}\”


 


    Set fso = CreateObject(“Scripting.FileSystemObject”)


    fso.DeleteFile tempFile & “*.log”, true


    Set fso = Nothing


   


    Main = msiDoActionStatusSuccess


 


End Function



And Rollback script will move files back from temporary folder to installation folder:


Function Main()


 


    On Error Resume Next


 


    Dim properties, tempFile, fso


   


    Const msiDoActionStatusSuccess = 1


 


    properties = Split(Session.Property(“CustomActionData”), “;”, -1, 1)


    tempFile = properties(1) & “{42A7DCCB-868B-4D11-BBBE-5A32B8DF5CC9}\”


 


    Set fso = CreateObject(“Scripting.FileSystemObject”)


    fso.MoveFile tempFile & “*.log”, properties(0)


    Set fso = Nothing


   


    Main = msiDoActionStatusSuccess


 


End Function



Here is the updated WiX source code:


<?xml version=1.0 encoding=UTF-8?>


<Wix xmlns=http://schemas.microsoft.com/wix/2003/01/wi>


  <Product Id={2F59639E-4626-44f8-AAF0-EE375B766221}


           Name=Test Application


           Language=1033


           Version=0.0.0.0


           Manufacturer=Your Company


           UpgradeCode={42A7DCCB-868B-4D11-BBBE-5A32B8DF5CC9}>


    <Package Id={58BCF2DD-16A0-4654-B289-F13AADA240CD}


             Description=Shows how to delete run-time file with known file name.


             Comments=This will appear in the file summary stream.


             InstallerVersion=200


             Compressed=yes />


 


    <Media Id=1 Cabinet=Product.cab EmbedCab=yes />


 


    <Directory Id=TARGETDIR Name=SourceDir>


      <Directory Id=ProgramFilesFolder>


        <Directory Id=INSTALLLOCATION Name=Test1 LongName=Test for removal of run time file>


 


          <Component Id=ProductComponent


                     Guid={8F4CC43A-9290-4c93-9B97-B9FC1C3579CC}>


            <File Id=Test.exe DiskId=1 Name=Test.exe


                  Source=..\Test\bin\Debug\Test.exe KeyPath=yes />


          </Component>


 


        </Directory>


      </Directory>


    </Directory>


 


    <Binary Id=MoveTempFileScript SourceFile=MoveTempFile.vbs />


    <Binary Id=CommitTempFileScript SourceFile=CommitTempFile.vbs />


    <Binary Id=RollbackTempFileScript SourceFile=RollbackTempFile.vbs />


 


    <CustomAction Id=MoveTempFile


                  BinaryKey=MoveTempFileScript


                  VBScriptCall=Main


                  Execute=deferred


                  Return=check />


 


    <CustomAction Id=CommitTempFile


                  BinaryKey=CommitTempFileScript


                  VBScriptCall=Main


                  Execute=commit


                  Return=check />


 


    <CustomAction Id=RollbackTempFile


                  BinaryKey=RollbackTempFileScript


                  VBScriptCall=Main


                  Execute=rollback


                  Return=check />


 


    <CustomAction Id=SetMoveData


                  Property=MoveTempFile Value=[INSTALLLOCATION];[TempFolder] />


 


    <CustomAction Id=SetCommitData


                  Property=CommitTempFile Value=[INSTALLLOCATION];[TempFolder] />


 


    <CustomAction Id=SetRollbackData


                  Property=RollbackTempFile Value=[INSTALLLOCATION];[TempFolder] />


 


    <Property Id=FailureProgram>


      <![CDATA[


      Function Main()


        Main = 3


      End Function


      ]]>


    </Property>


 


    <CustomAction Id=FakeFailure


                  VBScriptCall=Main


                  Property=FailureProgram


                  Execute=deferred />


 


    <InstallExecuteSequence>


      <Custom Action=SetMoveData Before=MoveTempFile>REMOVE=”ALL”</Custom>


      <Custom Action=MoveTempFile Before=RemoveFiles>REMOVE=”ALL”</Custom>


 


      <Custom Action=SetCommitData Before=CommitTempFile>REMOVE=”ALL”</Custom>


      <Custom Action=CommitTempFile After=MoveTempFile>REMOVE=”ALL”</Custom>


 


      <Custom Action=SetRollbackData Before=RollbackTempFile>REMOVE=”ALL”</Custom>


      <Custom Action=RollbackTempFile After=MoveTempFile>REMOVE=”ALL”</Custom>


 


      <Custom Action=FakeFailure Before=InstallFinalize>REMOVE=”ALL” AND TESTFAIL</Custom>


    </InstallExecuteSequence>


 


    <Feature Id=ProductFeature Title=Feature Title Level=1>


      <ComponentRef Id=ProductComponent />


    </Feature>


  </Product>


</Wix>



You can test it now to make sure that on uninstall failure all files get restored.


So, are we done yet?  Not quite.  Why?  Because there is much better           <Component Id=ProductComponent


                     Guid={8F4CC43A-9290-4c93-9B97-B9FC1C3579CC}>


            <File Id=Test.exe DiskId=1 Name=Test.exe


                  Source=..\Test\bin\Debug\Test.exe KeyPath=yes />


 


            <RemoveFile Id=RemoveTempFiles On=uninstall Name=*.log />


          </Component>



Well, my goal was to show why do we need rollback and commit custom actions.  Let’s imagine for a moment that we are dealing with unknown at compile time set of file extensions and continue with our discussion.


So, what we are going to do with our immediate custom action is populate this table with the names of the files created by our application during run-time.  After that, Windows Installer will take care of Commit and Rollback actions, so we won’t need these custom actions anymore.  Custom action must be an immediate custom action because during deferred phase session and database handle are no longer accessible.


Here is the source of the custom action:


Function Main()


 


    On Error Resume Next


 


    Dim fso, folder, files


    Dim DirProperty, ComponentId, InstallFolder


    Dim database, view, record


   


    Const msiDoActionStatusSuccess = 1


    Const msiViewModifyInsertTemporary = 7


 


    InstallFolder = Session.Property(“INSTALLLOCATION”)


    ComponentId = “ProductComponent”


    DirProperty = “INSTALLLOCATION”


 


    Set database = Session.Database


    Set view = database.OpenView(“SELECT `FileKey`, `Component_`, `FileName`, `DirProperty`, `InstallMode` FROM `RemoveFile`”)


    view.Execute


 


    Set fso = CreateObject(“Scripting.FileSystemObject”)


    Set folder = fso.GetFolder(InstallFolder)


    Set files = folder.Files


    For Each file in files


        If fso.GetExtensionName(file.name) = “log” Then


            Set record = installer.CreateRecord(5)


            record.StringData(1) = file.name


            record.StringData(2) = ComponentId


            record.StringData(3) = file.name


            record.StringData(4) = DirProperty


            record.IntegerData(5) = 2


           


            view.Modify msiViewModifyInsertTemporary, record


        End If


    Next


   


    view.Close


    Set view = Nothing


    database.Commit


    Set database = Nothing


 


    Set files = Nothing


    Set folder = Nothing


    Set fso = Nothing


   


    Main = msiDoActionStatusSuccess


 


End Function


It opens installer’s database and inserts temporary records into RemoveFile table.


Here is updated WiX source code:


<?xml version=1.0 encoding=UTF-8?>


<Wix xmlns=http://schemas.microsoft.com/wix/2003/01/wi>


  <Product Id={2F59639E-4626-44f8-AAF0-EE375B766221}


           Name=Test Application


           Language=1033


           Version=0.0.0.0


           Manufacturer=Your Company


           UpgradeCode={42A7DCCB-868B-4D11-BBBE-5A32B8DF5CC9}>


    <Package Id={58BCF2DD-16A0-4654-B289-F13AADA240CD}


             Description=Shows how to delete run-time file with known file name.


             Comments=This will appear in the file summary stream.


             InstallerVersion=200


             Compressed=yes />


 


    <Media Id=1 Cabinet=Product.cab EmbedCab=yes />


 


    <Directory Id=TARGETDIR Name=SourceDir>


      <Directory Id=ProgramFilesFolder>


        <Directory Id=INSTALLLOCATION Name=Test1 LongName=Test for removal of run time file>


 


          <Component Id=ProductComponent


                     Guid={8F4CC43A-9290-4c93-9B97-B9FC1C3579CC}>


            <File Id=Test.exe DiskId=1 Name=Test.exe


                  Source=..\Test\bin\Debug\Test.exe KeyPath=yes />


 


            <!–


            Uncomment next line and comment out MoveTempFile custom action


            to test wildcard file removal option


            <RemoveFile Id=”RemoveTempFiles” On=”uninstall” Name=”*.log” />


            –>


          </Component>


 


        </Directory>


      </Directory>


    </Directory>


 


    <EnsureTable Id=RemoveFile />


 


    <Binary Id=MoveTempFileScript SourceFile=MoveTempFile.vbs />


 


    <CustomAction Id=MoveTempFile


                  BinaryKey=MoveTempFileScript


                  VBScriptCall=Main


                  Execute=immediate


                  Return=check />


 


    <Property Id=FailureProgram>


      <![CDATA[


      Function Main()


        Main = 3


      End Function


      ]]>


    </Property>


 


    <CustomAction Id=FakeFailure


                  VBScriptCall=Main


                  Property=FailureProgram


                  Execute=deferred />


 


    <InstallExecuteSequence>


      <Custom Action=MoveTempFile Before=RemoveFiles>REMOVE=”ALL”</Custom>


 


      <Custom Action=FakeFailure Before=InstallFinalize>REMOVE=”ALL” AND TESTFAIL</Custom>


    </InstallExecuteSequence>


 


    <Feature Id=ProductFeature Title=Feature Title Level=1>


      <ComponentRef Id=ProductComponent />


    </Feature>


  </Product>


</Wix>


One thing to notice here is that I am using new EnsureTable element.  WiX does not create standard tables if they are empty.  Because we don’t have anywhere in our WiX code RemoveFile elements WiX will not create RemoveFile table.  Having EnsureTable element tells to WiX compiler that RemoveFile table must be created even though it is empty.


In Part 2 we will try to make this custom action reusable.


Code is attached.


 

The Art of Custom Actions1.zip

Comments (6)

  1. MaZaY says:

    Спасибо, ждем следующих частей!

  2. Alver says:

    good day Alex!

    Thanks a lot for your work. This is nice set of tutorial.

    Can you answer for my question – i want to call function in custom dll and save return value to custom property. Can I do this using standart set of action type? Or it is possible to do only by set a property value inside dll (using MsiGetProperty and MsiSetProperty)? May be you have new article about this?

  3. Dean says:

    Why go through all this?  Why not just add an empty file placeholder to your deployment tree? Done!

  4. aecsant says:

    Thaaaaaaaaaaaaaaanks a lot.

    i was looking for this, for a long long time.

  5. Bon says:

    Thank you so much for this comprehensive and clear article.

    I was deciding whether to use WIX or NSIS and your examples gave me the confidence on using WIX.

  6. BarryH says:

    I don’t know if this is the correct place to pose this question, but here goes… I have a C++ Custom Action that is called when the Finish button on the ExitDialog is clicked.  I need to know whether or not this was at the end of a Remove (not an install, not an upgrade) operation.  The literature indicates that the REMOVE property is a good thing to check. My problem is that the REMOVE property always comes back blank.  On the other hand, scheduling a test C++ Custom Action in the InstallExecuteSequence (after InstallFinalize) shows that REMOVE=ALL.  Why can’t I see this in a different CustomAction that, in fact, runs later?  I presume that my two CAs are running in different MSIEXEC processes.  But in any event, how would I go about getting the "real" value of REMOVE from a CA that is called as a result of a click on the Finish button?