Appframe Knowledge Base


1 hits
0

Custom ClickOnce authoring

Article is based on Sys.MSBuild.Example.CustomLoginClickOnce implementation and all the source is available in this project.

I will explain how you can Implement custom ClickOnce build for Sys.WinClient.login. Changes to existing Login implementation:

  • Adding dll reference.
  • Adding content file.

At the same time we want to continue using Sys.WinClient.login and Sys.MSBuild.LoginClickOnce for our actual ClickOnce build. We just want to alter the actual login project. So we need to create another MSBuild project that is going to be used as Entry Point project for our build service. It will construct archive to be sent and will contain MSBuild script to kick off build on the other side. In addition to entry point project we have to define custom build steps in login builder. For our purpose it sufficient to just Insert ClickOnce steps and replace SetEntryProjName action value with your entry project name (in my case it is Sys.MSBuild.Example.CustomLoginClickOnce).

 Image

    Setting custom build steps for your build project

We are done with defining steps for custom build. Now we need to implement our Entry Point project. In Win Projects form we press Create New.., Select appropriate namespace, supply a good name for a project, pick Sys.Template.BuildEntryPoint template and add description.

Create Entry Point Project

    Creating Entry Point project

Now that a project is created, we have to write something in it! First and most important part is settings.xml as it defines what is actually going to be sent to build service. Things we must define:

  1. BuildScript
  2. OutputDir
  3. dependencies

BuildScript name attribute points to the MSBuild file that should be called first. In our example we leave it as it is. We are still going to call build.proj. dependencies define additional content to be sent to build service. We need 4 additional projects for that. Sys.WinClient.login is required by definition. You can not build login without login itself :). Next as we said we want to continue using existing (maintained) ClickOnce build project. We need to include Sys.MSBuild.LoginClickOnce and everything included inside its settings.xml, Sys.MSBuild.Targets will have to come along. Since we needed to alter our login by adding some dll reference and some content file, I chose Sys.Template.EmptyClassLibrary since it actually has dll and some files to be included. OutputDir determines relative path to be archived and sent back (and inserted as version in login builder history tab). Since Sys.MSBuild.LoginClickOnce is going to put all the build artifacts into "Output" folder we will change it accordingly.

settings.xml file content:

<?xml version="1.0" encoding="utf-8"?>
<settings>
  <BuildScript name ="build.proj" />
  <OutputDir name ="Output" />
  <dependencies>
    <DatabaseProject name ="Sys.WinClient.login" type="Application" />
    <DatabaseProject name ="Sys.MSBuild.LoginClickOnce" type="Application" />
    <DatabaseProject name ="Sys.MSBuild.Targets" type="Library" />
    <DatabaseProject name ="Sys.Template.EmptyClassLibrary" type="Library" />
  </dependencies>
</settings>

Now we are reaching essence of what is actually going to be done. We must update build.proj to actually alter Sys.WinClient.login and call original Sys.MSBuild.LoginClickOnce build script.

As we all know all the project files like .vbproj and .csproj (and .fsproj and .wixproj and..) are nothing more than MSBuild scripts. So in order to use custom build we need to know MSBuild to some extent. We need to do 2 things in our build script:

  1. Alter Sys.WinClient.login project build script (MarketMaker.vbproj).
  2. Call the original Sys.MSBuild.LoginClickOnce project build script (build.proj).

But at first we must define some properties inside our MSBuild script. It is generally accepted good practice to define local properties starting with "_" (underscore) although they are still going to be visible to all imported/used scripts. _loginprojpath points to folder where our login project is. _loginbuildfile points to MarketMaker.vbproj path. _outputpath is where our resulting artifacts will be placed.

  <PropertyGroup>
    <_loginprojpath Condition="$(_loginprojpath) == ''" >..\..\WinProjects\Sys.WinClient.login</_loginprojpath>
    <_loginbuildfile Condition="$(_loginbuildfile) == ''">$(_loginprojpath)\MarketMaker.vbproj</_loginbuildfile>
    <_outputpath>$(MSBuildProjectDirectory)\..\..\..\Output\</_outputpath>
  </PropertyGroup>

Calling the original scrip is easy. Just import actual project file and define Target calling targets from Sys.MSBuild.LoginClickOnce. Inside Project node define Target and Import nodes:

  <Target Name ="callclickoncebuild">
    <CallTarget Targets="build" />
  </Target>

  <Import Project="$(MSBuildThisFileDirectory)\..\Sys.MSBuild.LoginClickOnce\build.proj" />

Since we import Sys.MSBuild.LoginClickOnce\build.proj all the targets defined inside are now available and can be called using CallTarget. That is what we are doing in callclickoncebuild Target definition.

Now we need to somehow alter the MarketMaker.vbproj MSBuild script inside of Sys.WinClient.login. I have decided to go with XslTransformation MSBuild task. You supply original xml file, output destination and transformation file (in bellowed .xslt format). Currently MSBuild supports only version 1.0 of xslt format, but that is enough for our plan. We write output of our transformation to .tmp file since reading and writing to one file at the same time is not good idea at all :). Been there, done that. After transformation we overwrite original file with temp file content and delete the temp file (Copy and Delete MSBuild tasks).

  <Target Name="injectdllref">
    <Message Text="Injecting New dll reference and content file for your $(_loginbuildfile);"
             Importance="high" />
    <XslTransformation XmlInputPaths="$(_loginbuildfile)"
                       XslInputPath="$(MSBuildThisFileDirectory)transformations.xslt"
                       OutputPaths="$(_loginbuildfile).tmp" />

    <Copy SourceFiles="$(_loginbuildfile).tmp" DestinationFiles="$(_loginbuildfile)" />
    <Delete  Files="$(_loginbuildfile).tmp" />
  </Target>

To make it more spicy we sneak in and copy some more content to our output path after all builds are finished. We define new target called postbuild. Inside we copy EntryPointConfig.xml that is generated in login builder and is used by build service. It is just an exercise and a proof that we can add some content to build output archive and nothing more.

  <Target Name ="postbuild" >
    <Copy SourceFiles="..\..\EntryPointConfig.xml" DestinationFiles="$(_outputpath)\EntryPointConfig.xml" />
  </Target>

Now that transformation is called (defined later, read on..) and original Sys.MSBuild.LoginClickOnce\build.proj build script is called (and sneaky file copy is done) we need to set ordering in place and what is called by default when building from this script.

It is clear that first we want to transform and then do the original build. We add attribute DependsOnTargets="injectdllref" to callclickoncebuild target. postbuild on the other hand should be called last and after callclickoncebuild, so we add attributeDependsOnTargets ="callclickoncebuild" to postbuild. We also need to define DefaultTargets for our project. Since calling postbuild will just chain all dependent targets we can supply it. We add attribute DefaultTargets="postbuild" to our project xml node. That is it, we have completed our MSBuild script part.

build.proj content.

<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="postbuild"
         ToolsVersion="4.0">
  <PropertyGroup>
    <_loginprojpath Condition="$(_loginprojpath) == ''" >..\..\WinProjects\Sys.WinClient.login</_loginprojpath>
    <_loginbuildfile Condition="$(_loginbuildfile) == ''">$(_loginprojpath)\MarketMaker.vbproj</_loginbuildfile>
    <_outputpath>$(MSBuildProjectDirectory)\..\..\..\Output\</_outputpath>
  </PropertyGroup>

  <!-- injecting login projects project file with aditional reference and some simple file -->
  <Target Name="injectdllref">
    <Message Text="Injecting New dll reference and content file for your $(_loginbuildfile);"
             Importance="high" />
    <XslTransformation XmlInputPaths="$(_loginbuildfile)"
                       XslInputPath="$(MSBuildThisFileDirectory)transformations.xslt"
                       OutputPaths="$(_loginbuildfile).tmp" />

    <Copy SourceFiles="$(_loginbuildfile).tmp" DestinationFiles="$(_loginbuildfile)" />
    <Delete  Files="$(_loginbuildfile).tmp" />
  </Target>

  <!-- calling the actual click once build -->
  <Target Name ="callclickoncebuild" DependsOnTargets="injectdllref">
    <CallTarget Targets="build" />
  </Target>

  <!--copy some file to output folder after it is built.-->
  <Target Name ="postbuild" DependsOnTargets ="callclickoncebuild" >
    <Copy SourceFiles="..\..\EntryPointConfig.xml" DestinationFiles="$(_outputpath)\EntryPointConfig.xml" />
  </Target>

  <!-- importing click once build script from "Sys.MSBuild.LoginClickOnce" -->
  <Import Project="$(MSBuildThisFileDirectory)\..\Sys.MSBuild.LoginClickOnce\build.proj" />

</Project>

Last thing remaining. We need to define actual project file transformation. Oh boy, oh boy.. xslt transformations. Not So fast. Lets ease our task by using tools! We will use Win Merge to help us.

  1. Download Sys.WinClient.login
  2. Go to containing folder and rename Sys.WinClient.login to Sys.WinClient.login_
  3. Downlaod Sys.WinClient.login (huh ?)
  4. Open either copy or original login project
  5. Add reference and content file (set copy always for content file!). Save. Close.

Now just compare the 2 folders using Win merge (select both folders -> right click -> win merge).

Comparing to see what must be done

We see that changes are only made in project file so we will actually get away with a simple xml transformation. We will define 3 teamplate rules:

  1. Full copy of content.
  2. Add reference node.
  3. Add content file node.

For full copy we match all attributes or nodes and copy them. For Reference node inclusion we match ItemGroup nodes with Project parent node having Reference child Node (match="b:Project/b:ItemGroup[.//b:Reference]"). It is important to supply namespace in match attribute. Other way templates will not match. There for in xsl:stylesheet node we add namespace definition attribute (xmlns:b="http://schemas.microsoft.com/developer/msbuild/2003"). Now we can use b:NodeName to match node of MSBuild namespace. Continuing on template definition we copy all the content and just add reference node after all other template content. Same technique applies to content file inclusion. In this case we match ItemGroup nodes with Project parent node having Content child node (match="b:Project/b:ItemGroup[.//b:Content]"). Template content is almost the same. we just add appropriate MSBuild Content node instead of reference. One thing you might notice that actually long relative path to content file is supplied instead of just file name . It is because when we included content file in login project it also copied the file over. We did not in our build script. So we supply the actual relative path to file (Include="..\..\Libraries\Sys.Template.EmptyClassLibrary\bin\Release\Sys.Template.EmptyClassLibrary.xml").

When adding inline nodes (like we did with Reference and Content MSBuild nodes) we must supply namespace in it (xmlns="http://schemas.microsoft.com/developer/msbuild/2003") because we want our nodes to be of the same namespace as whole document. At the same time we also add attribute exclude-result-prefixes ="b" to xsl:stylesheet because we do not need namespaces to be defined explicitly in those nodes. Here is what we get.

transformations.xslt content.

<xsl:stylesheet version="1.0" 
                xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
                xmlns:b="http://schemas.microsoft.com/developer/msbuild/2003"
                exclude-result-prefixes ="b">

  <!-- "xmlns:b" is required since all the msbuild files are of that namespace.
        But at the same time we do not this namespace in resulting elements,
        "exclude-result-prefixes" serves this purposes. -->

  <!--full copy-->
  <xsl:template match="@*|node()">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()" />
    </xsl:copy>
  </xsl:template>

  <!--Injecting dll reference-->
  <xsl:template match="b:Project/b:ItemGroup[.//b:Reference]">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()"/>
        <Reference Include="Sys.Template.EmptyClassLibrary" 
                   xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
          <HintPath>..\..\Libraries\Sys.Template.EmptyClassLibrary\bin\Release\Sys.Template.EmptyClassLibrary.dll</HintPath>
        </Reference>
    </xsl:copy>
  </xsl:template>

  <!-- Injecting content only file -->
  <xsl:template match="b:Project/b:ItemGroup[.//b:Content]">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()"/>
      <Content Include="..\..\Libraries\Sys.Template.EmptyClassLibrary\bin\Release\Sys.Template.EmptyClassLibrary.xml" 
               xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      </Content>
    </xsl:copy>
  </xsl:template>

</xsl:stylesheet>

Now we can go to login builder and try out our custom build.

It is not mandatory to do exactly as described in this article. If more then trivial reference inclusion is required, you could just branch system login to some other project (taking responsibility of maintaining it, and DO NOT use App.WinClient.Login it is reserved for core launcher click once) and implement custom build using your version of login project all together. It will be easier at first since you will not have to do any kind of transformations but you will have to maintain your login project (harder in the future). When going this path do not forget to change actual login path property in your MSBuild file:

<PropertyGroup>
    <!--path Properties-->
    <LoginProjectPath>..\..\WinProjects\App.WinClient.CustomLogin</LoginProjectPath>
    <!--some other properties ...-->
</PropertyGroup>    

Hope this will help you.

If you feel this article can be improved, do not hesitate and contact me (English is not my native language).

ClickOnce Login · Perma link post comment Posted by: Tomas Dambrauskas (10-sep-2015)