Windows Writing Data-Driven Custom Actions

Whenever Windows Installer’s built-in actions do not suffice to perform a specific task, a Custom Action needs to be written. Needless to say, Custom Actions, can be a bit tricky – not only can they be laborious to write and cumbersome to debug, they also run the risk of interfering with Windows Installer’s declarative, transactional way of performing installs.

It is not really surprising that Windows Installer therefore more or less discourages the use of Custom Actions unless it is absolutely necessary. Moreover, as a result of its declarative nature, it is understadable that Windows Installer prefers Custom Actions to be data-driven.

What this means in practice is that a Custom Action should not perform a hard-coded task – rather, it should query one or more (custom) tables containing the necessary information (in a declarative manner) about what is to be performed and should act accordingly.

Using WiX, creating custom tables turns out to be pretty easy. Let’s assume we create a Custom Action that, based on some condition, does something with a specific file. An appropriate table could look like this:

    
    <CustomTable Id="MyCustomTable">
      <Column Id="Id" Type="string" PrimaryKey="yes"/>
      <Column Id="Path" Type="string"/>
      <Column Id="Condition" Type="string"/>
    
      <Row>
        <Data Column="Id">Foo</Data>
        <Data Column="Path">[INSTALLLOCATION]foo.txt</Data>
        <Data Column="Condition"><![CDATA[ &FeatureFoo=3 ]]></Data>
      </Row>
      <Row>
        <Data Column="Id">Bar</Data>
        <Data Column="Path">[INSTALLLOCATION]bar.txt</Data>
        <Data Column="Condition"><![CDATA[ &FeatureBar=3 ]]></Data>
      </Row>
    </CustomTable>
    
    

To query this table, we have to open a view and fetch the records one by one:

    PMSIHANDLE Database = MsiGetActiveDatabase( InstallHandle );
    ASSERT( Database != NULL );
    
    PMSIHANDLE View;
    UINT Result = MsiDatabaseOpenView(
      Database,
      L"SELECT `Condition`, `Path`, FROM `MyCustomTable`",
      &View );
    if ( ERROR_SUCCESS != Result )
    {
      ...
    }
    
    Result = MsiViewExecute( View, NULL );
    if ( ERROR_SUCCESS != Result )
    {
      ...
    }
    
    for ( ;; )
    {
      PMSIHANDLE Record;
      Result = MsiViewFetch( View, &Record );
      if ( Result == ERROR_NO_MORE_ITEMS  )
      {
        break;
      }
      else if ( ERROR_SUCCESS != Result )
      {
        ...
      }
    
      //
      // Read condition. 
      //
      // N.B. Do not format -- this is done by 
      // MsiEvaluateCondition itself.
      //
    
      WCHAR Condition[ 256 ];
      DWORD Length = _countof( Condition );
      Result = MsiRecordGetString(
        Record,
        1,
        Condition,
        &Length );
      if ( ERROR_SUCCESS != Result )
      {
        ...
      }
    
      if ( MSICONDITION_TRUE != MsiEvaluateCondition(
        InstallHandle,
        Condition ) )
      {
        //
        // This record can be skipped.
        //
        continue;
      }
    
      //
      // Read remaing fields.
      //
    
      WCHAR Path[ MAX_PATH ];
      Length = _countof( VszPath );
      Result = GetFormattedRecord(
        InstallHandle,
        Record,
        2,
        Path,
        &Length );
      if ( ERROR_SUCCESS != Result )
      {
        ...
      }
    
      
      ...
    }
    
    

With GetFormattedRecord being the following utility routine:

    
    static UINT GetFormattedRecord(
      __in MSIHANDLE InstallHandle,
      __in MSIHANDLE Record,
      __in UINT Field,
      __out PWSTR Value,
      __inout PDWORD Length
      )
    {
      DWORD RecLength = *Length;
      UINT Result = MsiRecordGetString(
        Record,
        Field,
        Value,
        &RecLength );
      if ( ERROR_SUCCESS != Result )
      {
      *Length = RecLength;
        return Result;
      }
    
      PMSIHANDLE FormattingRecord = MsiCreateRecord( 1 );
      
      Result = MsiRecordSetString( FormattingRecord, 0, Value );
      if ( ERROR_SUCCESS != Result )
      {
        return Result;
      }
    
      return MsiFormatRecord(
        InstallHandle,
        FormattingRecord,
        Value,
        Length );
    }
    

Some things are worth noting:

  • I use PMSIHANDLE, which, as you probably already know, is not a typedef for MSIHANDLE* but rather a smart-pointer like class that automatically closes the handle when it goes out of scope.
  • The use of backticks in the query.
  • It must have been a Visual Basic programmer implementing MsiRecordGetString: Field Indexes start with 1, not 0. To make matters worse, reading from index 0 does not fail but returns arbitrary data. Finally, to confuse people further, indexes are 0-based for MsiRecordSetString.
  • If the field contains formatted data, you have to MsiFormatRecord it yourself. For conditions, however, MsiEvaluateCondition handles that for you.

So far, so good. There is, however, one thing to notice: To access the installer database, the custom action must be a nondeferred action:

You cannot access the current installer session or all property data from a deferred execution custom action

The problem with nondeferred actions, however, is that they execute in user context – in contrast to deferred actions, which execute in system context. On pre-Vista platforms, a per-machine installer package can be expected to always be launched by an administrator (otherwise it will fail anyway) – in this case, the differences between user and system context may not be important – both, for example, have r/w access to files in %ProgramFiles%. On Vista and later OS, however, it is common to have a regular user launch an installation which causes an elevation prompt once it reaches the main install phase. In this case, the user context is significantly less privileged than system context.

For a hypothetical custom action that is intended to edit a file installed to %ProgramFiles%, this means that (disregarding rollback considerations and assuming proper scheduling) performing this action from within the nondeferred custom action will work fine on pre-Vista OS. When run on Vista, though, it is likely to fail due to lack of write access to %ProgramFiles%. In practice, this means that all system-changing tasks usually have to be performed by a deferred action.

To sum up: To be data-driven, you have to use nondeferred actions. To be able to perform any serious, system state-changing tasks, however, you have to use deferred actions.

Great.

As it turns out, however, there is a way to escape this catch-22, and it is carefully buried in the Windows Installer documentation:

[...] Actions that update the system, such as the InstallFiles and WriteRegistryValues actions, cannot be run by calling MsiDoAction. The exception to this rule is if MsiDoAction is called from a custom action that is scheduled in the InstallExecuteSequence table between the InstallInitialize and InstallFinalize actions. [...]

[From the Remarks section of MsiDoAction]

In fact, the way I came across this solution was by looking at the source code of the WiX XmlFile action, which I knew manages to both be data-driven (uses a custom table) and alter system state (edits XML files). The way it does this, and the point where the above remark comes into play, is as follows: In the nondeferred action, you do not perform any actions changing system state. Rather, you collect the information from the installer tables and stuff it (yuck) into the CustomActionData property. Then, leveraging MsiDoAction and passing said CustomActionData, you schedule another custom action – this time a deferred one – which parses the CustomActioData (yuck) and, based on this data, finally performs the actual modifications – in system context.

It really could not be easier and more intuitive, right?

Any opinions expressed on this blog are Johannes' own. Refer to the respective vendor’s product documentation for authoritative information.
« Back to home