How To: Generate publish.htm with MSBuild

One of the features Visual Studio provides when publishing is the generation of a web page that serves as an installation entry point of a ClickOnce application. It describes some information about the application, application requirements, and even includes a jscript that will bypass the bootstrapper if the required version of the .NET Framework is already installed. The web page is generated as part of the publishing phase (as opposed to the manifest generation phase) when the publish action is taken publishing within Visual Studio. Manifest generation is msbuild-based, while the publish phase is not.

There are several circumstances when you want thte web page created as part of the manifest generation process instead of the publish process. For example, when manifests are generated as part of an automated build process. Perhaps you want to use your own template for the web page, but still want some aspects of the page to be dynamically set. Or, matbe you want to translate the template into a different language, be it one available from a Visual Studio language pack, or one not supplied by Microsoft. Whatever your reason, this blog post will show you one way to get that done.

Background: How publish.htm is generated

Let's begin by looking at the template used to generate the web page. It is embedded as a resource in Microsoft.ViualStudio.Publish.dll, available in the GAC. I have attached it at the end of the post, but it is an XSL style sheet. Here's a little snippet:

 <xsl:template  match="PageData"> 
 
  <HTML> 
 
    <HEAD> 
      <TITLE> 
        <xsl:value-of  select="ProductName"/> 
      </TITLE> 
      <META  HTTP-EQUIV="Content-Type"  CONTENT="text/html; charset=utf-8"/>

The stylesheet reads info out of an XML fragment, of which PageData is the root element. A scan of the stylesheet shows that the data used to transform the stylesheet may look something like this:

 <PageData  xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"  xmlns:xsd="https://www.w3.org/2001/XMLSchema"> 
  <ApplicationVersion>1.0.0.0</ApplicationVersion> 
  <InstallUrl>ConsoleApplication.application</InstallUrl> 
  <ProductName>ConsoleApplication</ProductName> 
  <PublisherName>Mike Wade</PublisherName> 
  <SupportUrl>https://blogs.msdn.com/mwade</SupportUrl> 
  <Prerequisites> 
    <Prerequisite>.NET Framework 3.5</Prerequisite> 
    <Prerequisite>Windows Installer 3.1</Prerequisite> 
  </Prerequisites> 
  <RuntimeVersion>2.0.0</RuntimeVersion> 
  <BootstrapperText>The following prerequisites are required:</BootstrapperText> 
  <ButtonLabel>Install</ButtonLabel> 
  <BypassText>If these components are already installed, you can {launch} the application now. Otherwise, click the button below to install the prerequisites and run the application.</BypassText> 
  <HelpText>ClickOnce and .NET Framework Resources</HelpText> 
  <HelpUrl>https://msdn.microsoft.com/clickonce</HelpUrl> 
  <NameLabel>Name:</NameLabel> 
  <PublisherLabel>Publisher:</PublisherLabel> 
  <SupportText>Mike Wade Customer Support</SupportText> 
  <VersionLabel>Version:</VersionLabel> 
</PageData>

The xml input contains 3 basic types of information:

  1. Application specific information (publisher, product, install, etc.)
  2. Application prerequisite information (bootsrapper packages installed, a .NET Framework runtime used by the jscript)
  3. Static strings read from Microsoft.VisualStudio.Publish.dll

A build task to create publish.htm

Let's create a build task that will create the publish web page.

 namespace CreatePublishWebPage 
{ 
    public class CreatePublishWebPage : Task 
    { 
    } 
}

This task will be performing the XML transform. Most of the work that the task does will be to populate the page data to get it ready for serialization and then used in the transform. The page data is declared like this:

 public struct PageData 
{ 
    public string ApplicationVersion; 
    public string InstallUrl; 
    public string ProductName; 
    public string PublisherName; 
    public string SupportUrl; 
 
    [XmlArray()] 
    [XmlArrayItem("Prerequisite")] 
    public string[] Prerequisites; 
    public string RuntimeVersion; 
 
    public string BootstrapperText; 
    public string ButtonLabel; 
    public string BypassText; 
    public string HelpText; 
    public string HelpUrl; 
    public string NameLabel; 
    public string PublisherLabel; 
    public string SupportText; 
    public string VersionLabel; 
}

The first 5 items are all part of the deploy manifest. It would be possible to populate the information with the project properties used to build the manifest, or the information could be pulled directly from the manifest. Let's choose the latter. One slight difference is that the publisher and product are automatically populated when the manifest is generated, even if the properties are blank.

The prerequisites will be taken from the bootrapper build information, both the bootstraper package items as well as the bootstrapper enabled property. The RuntimeVersion data is only used when the jscript is written into the web page. There are 2 potential sources for this version: either by inspecting the list of bootstrapper packages, or by inspecting the application manifest's prerequisites. VisualStudio uses the former, but this task will use the latter: its a more accurate reflection of the true requirements of the application.

Finally, the other information will be part of the build tasks resources, which I also pulled out of Microsoft.VisualStudio.Publish.dll

With this in mind, it is easy to stub out the task, like this:

 [Required] 
public string ApplicationManifestFileName { get; set; } 
 
[Required] 
public string DeploymentManifestFileName { get; set; } 
 
[Required] 
public bool BootstrapperEnabled { get; set; } 
 
public ITaskItem[] BootstrapperPackages { get; set; } 
 
public string TemplateFileName { get; set; } 
 
public string OutputFileName { get; set; } 
 
public override bool Execute() 
{ 
    PageData data = new PageData(); 
 
    WriteManifestInfo(ref data); 
    if (BootstrapperEnabled) 
    { 
        WriteBootstrapperInfo(ref data); 
    } 
    WriteStaticText(ref data); 
 
    XmlTransform(data); 
     
    return true; 
}

Now, let's get to th actual meat of the task, starting with WriteManifestInfo. The code will use the ManifestUtilities API to directly access the appropriate properties from DeployManifest. ManifestUtilities is defined in Microsoft.Build.Tasks.v3.5.dll, which unfortunately only exists in the GAC. You will have to copy it out of the GAC in order to add a reference to it. The code to populate the page data is not terribly tricky:

 using Microsoft.Build.Tasks.Deployment.ManifestUtilities;

private DeployManifest deployManifest = null; 
private DeployManifest DeployManifest 
{ 
    get 
    { 
        if (deployManifest == null) 
        { 
            deployManifest = (DeployManifest)ManifestReader.ReadManifest(this.DeploymentManifestFileName, true); 
        } 
        return deployManifest; 
    } 
} 
 
 private void WriteManifestInfo(ref PageData data) 
{ 
    data.ApplicationVersion = DeployManifest.AssemblyIdentity.Version; 
    data.InstallUrl = Path.GetFileName(DeployManifest.SourcePath); 
    data.ProductName = DeployManifest.Product; 
    data.PublisherName = DeployManifest.Publisher; 
    data.SupportUrl = DeployManifest.SupportUrl; 
}

The code uses a simple accessor to access the DeployManifest, and then writes appropriate information to the page data.

Dealing with prerequisites is a little more complicated in. There are 3 important steps in the process:

  1. Add a list of all installed prerequisites into the Prerequisites field
  2. Determine if the only prerequisites are .NET Frameowrk (or Windows Installer 3.1) only
  3. Determine the required framework version based on the presence of specific .NET assemblies in the application manifest

For this last point, the WindowsBase prereq corresponds to the 3.0 framework, while System.Core corresponds to the 3.5 framework. The prereqs are looked by examining the AssemblyReferences of the application manifest. Now for the actual code:

 using Microsoft.Build.Tasks.Deployment.Bootstrapper;

private static readonly string[] DOTNET30AssemblyIdentity = { "WindowsBase", "3.0.0.0", "31bf3856ad364e35", "neutral", "msil" }; 
private static readonly string[] DOTNET35AssemblyIdentity = { "System.Core", "3.5.0.0", "b77a5c561934e089", "neutral", "msil" }; 
private const string METADATA_INSTALL = "Install"; 
private const string PRODUCT_CODE_DOTNET_PREFIX = "Microsoft.Net.Framework."; 
private const string PRODUCT_CODE_WINDOWS_INSTALLER = "Microsoft.Windows.Installer.3.1";

private void WriteBootstrapperInfo(ref PageData data) 
{ 
    BootstrapperBuilder builder = new BootstrapperBuilder(); 
    ProductCollection products = builder.Products; 
     
    bool isDotNetOnly = true; 
    List<string> productNames = new List<string>(); 
    foreach (ITaskItem bootstrapperPackage in this.BootstrapperPackages) 
    { 
        string productCode = bootstrapperPackage.ItemSpec; 
        bool install = false; 
        bool.TryParse(bootstrapperPackage.GetMetadata(METADATA_INSTALL), out install); 
        if (install) 
        { 
            string productName = productCode; 
            Product product = products.Product(productCode); 
            if (product != null) 
            { 
                productName = product.Name; 
            } 
            productNames.Add(productName); 
 
            if (!productCode.StartsWith(PRODUCT_CODE_DOTNET_PREFIX, StringComparison.OrdinalIgnoreCase) && 
                !productCode.Equals(PRODUCT_CODE_WINDOWS_INSTALLER, StringComparison.OrdinalIgnoreCase)) 
            { 
                isDotNetOnly = false; 
            } 
        } 
    } 
 
    data.Prerequisites = productNames.ToArray(); 
    if (data.Prerequisites.Length > 0 && isDotNetOnly) 
    { 
        data.RuntimeVersion = GetRuntimeVersion(); 
    } 
} 
 
private string GetRuntimeVersion() 
{ 
    string version = "2.0.0"; 
    AssemblyReference dotNet30AssemblyReference = ApplicationManifest.AssemblyReferences.Find(CreateAssemblyIdentity(DOTNET30AssemblyIdentity)); 
    if (dotNet30AssemblyReference != null && dotNet30AssemblyReference.IsPrerequisite) 
    { 
        version = "3.0.0"; 
    } 
    AssemblyReference dotNet35AssemblyReference = ApplicationManifest.AssemblyReferences.Find(CreateAssemblyIdentity(DOTNET35AssemblyIdentity)); 
    if (dotNet35AssemblyReference != null && dotNet35AssemblyReference.IsPrerequisite) 
    { 
        version = "3.5.0"; 
    } 
 
    return version; 
} 
 
private AssemblyIdentity CreateAssemblyIdentity(string[] info) 
{ 
    return new AssemblyIdentity(info[0], info[1], info[2], info[3], info[4]);  
}

Getting the static text is not terribly exciting. There are only two things of note: the text for the "go" button changes depending on whether the application is installed or is online only, and the support information also incorporates the publisher name.

 private void WriteStaticText(ref PageData data) 
{ 
    data.BootstrapperText = Properties.Resources.BootstrapperText; 
    if (deployManifest.Install) 
    { 
        data.ButtonLabel = Properties.Resources.InstallButtonLabel; 
    } 
    else 
    { 
        data.ButtonLabel = Properties.Resources.RunButtonLabel; 
    } 
    data.BypassText = Properties.Resources.BypassText; 
    data.HelpText = Properties.Resources.HelpText; 
    data.HelpUrl = Properties.Resources.HelpUrl; 
    data.NameLabel = Properties.Resources.NameLabel; 
    data.PublisherLabel = Properties.Resources.PublisherLabel; 
    data.SupportText = string.Format(CultureInfo.CurrentCulture, Properties.Resources.SupportText, DeployManifest.Publisher); 
    data.VersionLabel = Properties.Resources.VersionLabel; 
}

The last item of note is actually performing the transform, based on either a supplied template or using the default one (read from the task assembly's resources). The input is created by serializing the page data.

 private void XmlTransform(PageData data) 
{ 
    if (!string.IsNullOrEmpty(this.OutputFileName)) 
    { 
        // Serialize all of the web page data 
        XmlSerializer serializer = new XmlSerializer(data.GetType()); 
        MemoryStream dataStream = new MemoryStream(); 
        StreamWriter streamWriter = new StreamWriter(dataStream); 
        serializer.Serialize(streamWriter, data); 
        dataStream.Position = 0; 
        XmlReader dataReader = XmlReader.Create(dataStream); 
 
        // Set up the transform 
        XmlDocument stylesheet = new XmlDocument(); 
        if (!string.IsNullOrEmpty(TemplateFileName)) 
        { 
            stylesheet.Load(TemplateFileName); 
        } 
        else 
        { 
            stylesheet.LoadXml(Properties.Resources.PublishPage); 
        } 
        XslCompiledTransform transform = new XslCompiledTransform(); 
        transform.Load(stylesheet); 
 
        // Perform the transform, writing to the output file 
        using (XmlTextWriter outputFile = new XmlTextWriter(this.OutputFileName, Encoding.UTF8)) 
        { 
            outputFile.Formatting = Formatting.Indented; 
            transform.Transform(dataReader, outputFile); 
        } 
    } 
}

Using the CreatePublishWebPage Task

To use the task, you need only to modify your project file, overriding the "AfterPublish" target:

 <UsingTask  TaskName="CreatePublishWebPage"  AssemblyFile="$(MSBuildExtensionsPath)\\CreatePublishWebPage.dll" /> 
<Target  Name="AfterPublish"> 
  <CreatePublishWebPage  ApplicationManifestFileName="$(_DeploymentApplicationDir)$(_DeploymentTargetApplicationManifestFileName)"  
                          
                        DeploymentManifestFileName="$(PublishDir)$(TargetDeployManifestFileName)"  
                          
                        BootstrapperEnabled="$(BootstrapperEnabled)"  
                          
                        BootstrapperPackages="@(BootstrapperPackage)"  
                          
                        OutputFileName="$(PublishDir)$(WebPage)" /> 
</Target>

The above snippet writes the publish.htm to the app.publish folder under your config's output (e.g. bin\debug\app.publish). The relevant paths were pulled from the Microsoft.common.targets file. It may make sense to add a condition or 2 to the target, taking into account whether or not the build is happening from within Visual Studio (BuildingInsideVisualStudio) as well as whether or not the user wants to generate a publish.htm file (CreateWebPageOnPublish). Unfortunately, it appears the file is not picked up as part of the publish phase, so you may have to transport the file to its final resting place yourself.

I have attached the full project as a to the end of this post.


A note about Visual Studio 2008 SP1

There was a little bit of change around the web page, specifically the jscript used to detect the framework. I believe the SP behavior around the .NET Framework versions was similar to what was done here, in that the jscript could actually check for 3.0, 3.5, or 3.5 SP1. It seems that the Visual Studio 2008 RTM could only deal with 2.0 of the Framework. The SP also introduced the client version of the .NET Framework. While the jscript code was updated to take that into account, the PageData is not able to properly detect that the cllient framework bootstrapper package is being used, because of the ProductName used by the client bootststrapper package. Unfortunately, I can't remember for sure, and of course the SP 1 install from MSDN doesn't seem to work on my laptop.

CreatePublishWebPage.zip