13 November 2010

WIX configurable search & replace custom action for text files

Recently I had my first encounter with actually writing setups myself using WIX. This is a powerful albeit a bit complex technology that allows you to build MSI setups using XML files. It includes all kinds of tasks to modify files after installation, and thus you can for instance change the settings in configuration files based upon user input. Unfortunately good examples are few and far between, and I hope to save some poor sod a lot of time with this article

Trouble is that the standard tasks only know how to modify XML files. Now this is usually enough, but if you want to change plain text files files then you are basically on your own. WIX supports the idea of custom actions that you can write in C#, So I set out to write such a search & replace custom action that was to be configured by a custom table.

That turned out less straightforward than I thought. Under Windows 7 and Vista, with UAC enabled, part of the installation sequence is run with elevated rights, part is not, and your custom actions are running not with elevated rights unless they are ‘deferred’ but then you don’t have access to the custom table anymore. I spent quite some time figuring out why my configuration files were virtualized by the installer but those in the program directory themselves never got changed. Finally solved that catch-22 by following what appeared to be a beaten track: split the task into two task. The first task runs immediate, reads the table, and dumps its contents in a file, the second runs deferred.

The actual code consist out of two methods and a property. The class declaration with the property looks like this:

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Microsoft.Deployment.WindowsInstaller;

namespace LocalJoost.Wix
{
  public class CustomActions
  {
    /// <summary>
    /// Gets the search and replace data file location. 
 /// This is stored in the user's temp directory
    /// and used by the installer.
    /// </summary>
    /// <value>The search and replace data file.</value>
    private static string SearchAndReplaceDataFile
    {
      get
      {
       return Environment.GetEnvironmentVariable("TEMP") + 
            Path.DirectorySeparatorChar + 
           "SearchAndReplace.xml";
      }
    }
  }
}

This defines a hard coded XML file in the installer user's temp directory. Then, the first method that actually gathers the information and writes it into said XML file:

/// <summary>
/// This method should be declared with Execute="immediate" 
/// and called with Before="InstallFinalize"
/// Use in conjunction with SearchAndReplaceExec
/// </summary>
/// <param name="session">The session.</param>
/// <returns></returns>
[CustomAction]
public static ActionResult SearchAndReplaceInit(Session session)
{
  session.Log("Begin SearchAndReplaceInit");
  File.Delete(SearchAndReplaceDataFile);
  if (session.Database.Tables.Contains("SearchAndReplace"))
  {
     var lstSearchAndReplace = new List<SearchAndReplaceData>();
     using (var propertyView = 
      session.Database.OpenView("SELECT * FROM `SearchAndReplace`"))
     {
       propertyView.Execute();
       foreach (var record in propertyView)
       {
         var token = new SearchAndReplaceData
         {
           File = session.Format(record["File"].ToString()),
           Search = session.Format(record["Search"].ToString()),
           Replace = session.Format(record["Replace"].ToString())
         };
         lstSearchAndReplace.Add(token);
       }
     }
     var serializer = new TypedXmlSerializer<List<SearchAndReplaceData>>();
     serializer.Serialize(SearchAndReplaceDataFile, lstSearchAndReplace);
  }
  else
  {
    session.Log("No SearchAndReplace custom table found");
  }
  session.Log("End SearchAndReplaceInit");
  return ActionResult.Success;
}
and finally the method that reads the XML file and actually executes the search and replace actions
/// <summary>
/// This method should be decleared with Execute="deferred" 
/// and called with Before="InstallFinalize"
/// Use in conjunction with SearchAndReplaceInit
/// </summary>
/// <param name="session">The session.</param>
/// <returns></returns>
[CustomAction]
public static ActionResult SearchAndReplaceExec(Session session)
{
  session.Log("Begin SearchAndReplaceExec");
  if (File.Exists(SearchAndReplaceDataFile))
  {
    var serializer = new TypedXmlSerializer<List<SearchAndReplaceData>>();
    var tokens = serializer.Deserialize(SearchAndReplaceDataFile);
    tokens.ForEach(token =>
    {
      try
      {
        string fileContents;
   
        var file = new FileInfo(token.File);
        {
          if (file.Exists)
          {
            using (var reader = new StreamReader(file.OpenRead()))
            {
              fileContents = reader.ReadToEnd();
              reader.Close();
            }
            fileContents = fileContents.Replace(token.Search, token.Replace);
         
            using (var writer = new StreamWriter(file.OpenWrite()))
            {
              writer.Write(fileContents);
              writer.Flush();
              writer.Close();
            }
          }
        }
      }
      catch (Exception)
      {
        session.Log("Could not process file " + token.File);
      }
    });
    File.Delete(SearchAndReplaceDataFile);
  }
  session.Log("End SearchAndReplaceExec");
  return ActionResult.Success;
}
Attentive readers will have noticed this code actually uses two companion classes: SearchAndReplaceData:
namespace LocalJoost.Wix
{
  public class SearchAndReplaceData
  {
    public string File { get; set; }
    public string Search { get; set; }
    public string Replace { get; set; }
  }
}
and TypedXmlSerializer:
using System.Collections;
using System.IO;
using System.Xml.Serialization;

namespace LocalJoost.Wix
{
  public class TypedXmlSerializer<T> 
  {
    public void Serialize(string path, T toSerialize)
    {
      var serializer = new XmlSerializer(typeof(T));
      using (var fileStream = 
      new FileStream(path, FileMode.Create))
      {
        serializer.Serialize(fileStream, toSerialize);
        fileStream.Close();
      }
    }

    public T Deserialize(string path)
    {
      var serializer = new XmlSerializer(typeof(T));
      T persistedObject;
      using (var reader = new StreamReader(path))
      {
        persistedObject = (T)serializer.Deserialize(reader);
        reader.Close();
      }
      return persistedObject;
    }
  }
}
If you got this all up and running, actually using it means taking three steps. First you have to declare them in right into the top Product tag like this:
<CustomAction Id="SearchAndReplaceInit"
    BinaryKey="LJWix"
    DllEntry="SearchAndReplaceInit"
    Execute="immediate"/>

<CustomAction Id="SearchAndReplaceExec"
    BinaryKey="LJWix"
    DllEntry="SearchAndReplaceExec"
    Execute="deferred" Impersonate="no"/>
 
<Binary Id="LJWix" SourceFile="LocalJoost.Wix.CA.dll" />
This assumes that your WIX custom actions projects was called "LocalJoost.Wix" and your resulting dll is called “LocalJoost.Wix.CA.dll”. Here you see the use of "immediate" for the information gathering task and the "deferred" for the actual executing task. The second step is embedding the custom actions into the install execution sequence:
<InstallExecuteSequence>
  <Custom Action="SearchAndReplaceInit" Before="InstallFinalize"/>

  <Custom Action="SearchAndReplaceExec" Before="InstallFinalize"/>
</InstallExecuteSequence>
If you have looked closely at the SearchAndReplaceInit task, you see it's trying to read a custom SearchAndReplace table, so the third and final step is to define and actually fill that table:
<CustomTable Id="SearchAndReplace">
  <Column Id="Id" Type="string" Category="Identifier" PrimaryKey="yes"/>
  <Column Id="File" Type="string"/>
  <Column Id="Search" Type="string"/>
  <Column Id="Replace" Type="string"/>
  <Row>
    <Data Column="Id">id1</Data>
    <Data Column="File" >[INSTALLLOCATION]Somedirectory\Somefile.txt</Data>
    <Data Column="Search">Text to search for</Data>
    <Data Column="Replace">Text to replace this by</Data>
  </Row>
  <Row>
    <Data Column="Id">id2</Data>
    <Data Column="File" >[INSTALLLOCATION]Somedirectory\Someotherfile.txt</Data>
    <Data Column="Search">Some other text to search for</Data>
    <Data Column="Replace">Some other text to replace this by</Data>
  </Row>
</CustomTable>

Custom tables are also declared in the Product tag. The Id tag in the table is there because there seems to be an Id necessary, and the rest in pretty obvious: in the File tag you put the file you want to process, in Search what you want to search for, and in Replace what you want to replace it by. Duh :-) . And that’s pretty much what it there to it.

Now I would love to take credit for this, but the ideas behind it – not to mention significant parts of the code – were supplied by Kevin Darty who kindly assisted me by using his Twitter account when I was struggling with this. In the end I changed a lot of this code, but the samples he sent me saved me a lot of time. And true to the spirit of this blog and my promise to Kevin, I give back to the .NET community what it gave to me.

4 comments:

Scott Kreel said...

TypedXmlSerializer must have a <T> after it in your code above.

Scott Kreel said...

If you replace a larger string with a smaller string the the file new file will repeat the contents at the end of the file (by the size difference between the string to replace the one to replace it with).

Change

using (var writer = new StreamWriter(file.OpenWrite()))

to

using (var writer = new StreamWriter(file.Create()))

Joost van Schaik said...

@Scott: Noted and fixed. Copy & paste error - the < and > where in text, but I forgot to changed them into &lt; and &gt

La Hamburgler (Rob Lingstuyl) said...

I also ran into the replacement of values that were smaller than the original repeating the end of the file over and over. I used

fileContents = File.ReadAllText(token.File);
File.WriteAllText(token.File, fileContents);