Creating an installer for an addin

In a previous post I created my Tesseract OCR client add-in.  To test that it worked properly, I registered the client add-in using the debug output path for the assembly location.  This allows me to debug the add-in but won't work on any other workstation.  Therefore I need to package the add-in into an installer, which would place the required files in a consistent location I can reference when registering the add-in.  

To create an installer you'll need the WiX toolset.  You can then add a new project to the solution using the Setup Project for WiX v3 project template, as shown below.  Note that can you create multiple installers within a given solution (which I'm doing since I have two different add-ins: client and event).  

 
2017-11-29_13-52-32.png
 

Anytime I add a new project to the solution I revisit the configuration manager.  Since I can envision wanting to debug the add-in without any need to create an installer, I decide to create a new solution configuration named "debug (installers)". 

 
Note the active configuration (debug) does not build installers

Note the active configuration (debug) does not build installers

 

I leave the existing debug configuration alone and then modify the new one.  The debug installer configuration should build all of the projects.  Installing that output allows you to attach a debug session to an installed copy of the add-in.  The release configuration is identical, except each project configuration is set to release.

 
2017-11-29_14-00-48.png
 

The WiX project template results in one file being creating within the project: "Product.wxs".  Before tackling that file, I immediately add a reference to the addin project and the WixUIExtension library.  The UI extension library will allow me to create a custom UI navigation that prompts the user for the installation path.

I then created a file named UI.wxs and used the content shown below.

<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
  <Fragment>
    <UI Id="AddinUI">
      <TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" />
      <TextStyle Id="WixUI_Font_Bigger" FaceName="Tahoma" Size="12" />
      <TextStyle Id="WixUI_Font_Title" FaceName="Tahoma" Size="9" Bold="no" />
      <UIRef Id="WixUI_ErrorProgressText" />
      <Property Id="DefaultUIFont" Value="WixUI_Font_Normal" />
      <Property Id="WixUI_Mode" Value="InstallDir" />
      <DialogRef Id="BrowseDlg" />
      <DialogRef Id="DiskCostDlg" />
      <DialogRef Id="ErrorDlg" />
      <DialogRef Id="FatalError" />
      <DialogRef Id="FilesInUse" />
      <DialogRef Id="MsiRMFilesInUse" />
      <DialogRef Id="PrepareDlg" />
      <DialogRef Id="ProgressDlg" />
      <DialogRef Id="ResumeDlg" />
      <DialogRef Id="UserExit" />
      <Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath" Order="3">1</Publish>
      <Publish Dialog="BrowseDlg" Control="OK" Event="SpawnDialog" Value="InvalidDirDlg" Order="4"><![CDATA[WIXUI_INSTALLDIR_VALID<>"1"]]></Publish>
      <Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999">1</Publish>
      <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg">NOT Installed</Publish>
      <Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish>
      <Publish Dialog="InstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
      <Publish Dialog="InstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath" Order="2">NOT WIXUI_DONTVALIDATEPATH</Publish>
      <Publish Dialog="InstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3"><![CDATA[NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"]]></Publish>
      <Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4">WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"</Publish>
      <Publish Dialog="InstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
      <Publish Dialog="InstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2">1</Publish>
      <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="InstallDirDlg" Order="1">NOT Installed</Publish>
      <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" Order="2">Installed AND NOT PATCH</Publish>
      <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2">Installed AND PATCH</Publish>
      <Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg">1</Publish>
      <Publish Dialog="MaintenanceTypeDlg" Control="RepairButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
      <Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
      <Publish Dialog="MaintenanceTypeDlg" Control="Back" Event="NewDialog" Value="MaintenanceWelcomeDlg">1</Publish>
      <Publish Dialog="CustomizeDlg" Control="Back" Event="NewDialog" Value="CustomizeDlg">1</Publish>
      <Publish Dialog="CustomizeDlg" Control="Next" Event="NewDialog" Value="CustomizeDlg">1</Publish>
      <Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="CustomizeDlg">1</Publish>
      <Property Id="ARPNOMODIFY" Value="1" />
    </UI>
    <UIRef Id="WixUI_Common" />
  </Fragment>
</Wix>

Next I modified the project so that there is a preprocessor variable for the source of files, the output is placed into an alternate location, and heat is used to harvest content.  Below you can see those first two changes.  This was done for all the project configurations.

 
2017-11-29_14-17-05.png
 

In the build events I created a heat command that harvests the files into "Content.wxs"...

 
2017-11-29_14-20-28.png
 

The full text of the command:

 
heat dir "$(SolutionDir)Output\ClientAddin $(ConfigurationName)" -dr INSTALLFOLDER -var var.sourcebin -srd -sreg -gg -cg AddinComponents -out "$(ProjectDir)Content.wxs"
 

Next I added a new file to the solution named "Content.wxs".  This file will be replaced each time the project is built (the heat command above generates the content based on the output from the other project).  The variable parameter matches the preprocessor variable name used in the project properties, effectively ensuring any future changes to the add-in will be included in the installer.

The last step is to update the product file.  Within it I added all the usual manufacturer, product, and media information.  Then I removed everything else (the default fragments provided by the project template).  Instead I reference the UI and the add-in components generated by heat command.

<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
  <Product Id="*" Name="CMRamble Ocr ClientAddin" Language="1033" Version="1.0.0.0" Manufacturer="CMRamble.com" UpgradeCode="05ff6529-a724-4eaf-a199-d920ef03bc20">
    <?define IFOLDER = "INSTALLFOLDER"?>
    <?define InfoURL="https://cmramble.com" ?>
    <Package InstallerVersion="300" Compressed="yes" InstallScope="perUser" />
    <MajorUpgrade DowngradeErrorMessage="A newer version of [ProductName] is already installed." />
    <Media Id="1" Cabinet="ClientAddin.cab" EmbedCab="yes"/>
    <Feature Id="ProductFeature" Title="Installer" Level="1">
      <ComponentGroupRef Id="AddinComponents" />
    </Feature>
    <Property Id="ARPHELPLINK" Value="$(var.InfoURL)" />
    <Property Id="WIXUI_INSTALLDIR" Value="$(var.IFOLDER)"/>
    <UIRef Id="AddinUI" />
  </Product>
  <Fragment>
    <Directory Id="TARGETDIR" Name="SourceDir">
      <Directory Id="ProgramFiles64Folder">
        <Directory Id="CMRambleFolder" Name="CMRamble">
          <Directory Id="OcrFolder" Name="Ocr">
            <Directory Id="INSTALLFOLDER" Name="ClientAddin" />
          </Directory>
        </Directory>
      </Directory>
    </Directory>
  </Fragment>
</Wix>

All done!  I repeated the process for the event processor plugin and then hit build.  The result is an installer for each.

2017-11-29_14-28-25.png

If I launch the client add-in installer I see the UI I defined in the UI.wsx file.  It sequenced the user from the welcome dialog to the installation path dialog.  Clicking next should ask the user where to install it.

2017-11-29_22-07-21.png

It behaves as expected!  The path you supply here is what you will use when later registering the add-in within the client, so it must be consistent across your organization. 

2017-11-29_22-11-03.png

Clicking next shows the ready to install dialog and an install button.  

2017-11-29_22-12-26.png

Once installation has completed, a new folder will exist on the workstation.  It should contain all of the files harvested from the client add-in project.  As the solution grows the installer should automatically keep-up with new references.

Contents of Client Addin installation on workstation

Contents of Client Addin installation on workstation

Within the client the add-in is managed by clicking external links on the administration ribbon.

2017-11-29_22-16-22.png

Then click new generic add-in (.Net)...

2017-11-29_22-17-57.png

Provide a name (this can be anything you want) and select the most appropriate path for your environment.

2017-11-29_22-21-22.png

If you can click OK without receiving an error message, then you have a valid configuration (according to this workstation).  Once the valid configuration has been saved, you must click properties to enable the add-in on specific objects. 

2017-11-29_22-23-04.png

The client add-in is intended for electronic documents so I enabled the document record type.

2017-11-29_22-25-03.png

A quick test of the custom actions proves the installer worked successfully end-to-end.

2017-11-29_22-25-48.png

You can access the latest installers here.

Using Xsd to Dynamically change DataPort during install

This post will lead to code which can manipulate the list of available source format in Content Manager's DataPort.  The code is executed as the last action within an installer project for a new data provider.  It loads an Xml document representing the user's list of sources and then either adds to the list or updates existing entries.  During uninstall it will remove only the newly installed source.

What I needed to accomplish this:

  • Microsoft Visual Studio 2017
  • .Net Framework 4.5.2
  • WiX Toolset v3
  • HPE Content Manager 9.1

The user's list of available DataPort sources is located off the roaming user profile.  Within it exists one node typed "ArrayofDataFormatterDefinition".  That, in turn, contains one or more DataFormatterDefinition children.  

2017-09-24_6-30-50.png

The goals include: add, update, or remove items from this configuration file.


First I launched the Developer Command Prompt for Visual Studio 2017 so that I could mirror someone else's model within my own project.

2017-09-24_4-51-58.png

I navigated into the roaming application data directory for data port preferences.  Executing XSD within that starting directory will make it easier to organize my results.

2017-09-24_4-56-36.png

We can only use XSD on files ending with an ".xml" extension, which the developers have curiously not done.  Since I also don't want to mess up my own copy some how, I might as well go ahead and copy what I've got to a file XSD will accept.  

I did this by executing

copy ImportDataFormatters ImportDataFormatters.xml

Then I execute

xsd ImportDataFormatters.xml

Command Prompt after generating scheme definition

Command Prompt after generating scheme definition

Next I execute

xsd ImportDataFormatters.xsd /c

Command Prompt after generating class definition

Command Prompt after generating class definition

Next I flipped over to Visual Studio and imported the class file.

2017-09-24_5-44-17.png
2017-09-24_6-13-08.png

The file name doesn't match the generated class names.  It doesn't matter either.  What I really want are the properties of the second class defined.  These are the things I want to change for the user. 

2017-09-24_6-15-42.png

In my custom action for this installer I can now serialize and deserialize using the code below.

private static void SaveImportFormattersPreferenceFile(string preferenceFile, XmlSerializer serializer, ArrayOfDataFormatterDefinition importFormatters)
{
    using (TextWriter writer = new StreamWriter(preferenceFile))
    {
        serializer.Serialize(writer, importFormatters);
        writer.Close();
    }
}
 
private static ArrayOfDataFormatterDefinition LoadImportFormattersPreferenceFile(string preferenceFile, XmlSerializer serializer)
{
    ArrayOfDataFormatterDefinition importFormatters;
    using (StreamReader reader = new StreamReader(preferenceFile))
    {
        importFormatters = (ArrayOfDataFormatterDefinition)serializer.Deserialize(reader);
        reader.Close();
    }
 
    return importFormatters;
}

Next I need the logic to find entries in the list or to create a new one.

XmlSerializer serializer = new XmlSerializer(typeof(ArrayOfDataFormatterDefinition));
ArrayOfDataFormatterDefinition importFormatters = LoadImportFormattersPreferenceFile(preferenceFile, serializer);
List<ArrayOfDataFormatterDefinitionDataFormatterDefinition> items = importFormatters.Items.ToList();
var item = importFormatters.Items.FirstOrDefault(x => x.ClassName.Equals("CMRamble.DataPort.Acme"));
if (item == null)
{
    item = new ArrayOfDataFormatterDefinitionDataFormatterDefinition();
    items.Add(item);
}

After I'm done manipulating the item I have in memory, I need to save the changes to disk.

importFormatters.Items = items.ToArray();
SaveImportFormattersPreferenceFile(preferenceFile, serializer, importFormatters);

During my uninstall action I need to basically repeat the process, but this time just remove anything matching my class name.

XmlSerializer serializer = new XmlSerializer(typeof(ArrayOfDataFormatterDefinition));
ArrayOfDataFormatterDefinition importFormatters = LoadImportFormattersPreferenceFile(preferenceFile, serializer);
List<ArrayOfDataFormatterDefinitionDataFormatterDefinition> items = importFormatters.Items.ToList();
importFormatters.Items = items.Where(x => !x.ClassName.Equals("CMRamble.DataPort.Acme")).ToArray();
SaveImportFormattersPreferenceFile(preferenceFile, serializer, importFormatters);

And that's it!  The above code can be attached to any WiX installer action.