Friday, 17 January 2020

Deploying ClickOnce Apps with SDK style projects

ClickOnce is a bit of an old beast these days, but in some cases you may have to keep an old app updated, so you have to dust off the old tools. I ended up in a similar situation, and I thought to see what can I do to ease some of the pain of the process. I had an old ClickOnce app that was written back in the .NET 4.5 time, with some old code, and old project format.

While I know that ClickOnce doesn't support .NET Core apps, would it support the new SDK style projects? Let's try it out.

The first step was to upgrade the old projects to the new SDK style projects - this was a simple change, and can either be done manually or with some external tools. I opted to the manual process as I was already used to it - I created a new solution and projects with the same names and solution structure, then moved all the code across, added any dependencies, and once done, moved it back to the original location. With the upgrade done and the app running locally I started looking for the Publish tab in the project properties, and as expected it was nowhere to be found!

A little research later, and I found a StackOverflow post with a high level overview on how to manually deploy ClickOnce apps using mage.exe and MageUI.exe - two tools that come with the Microsoft SDK.

Some experimenting later, I learned the following:

  • A ClickOnce deployment consists of the application files and two manifest files - the application manifest (MyApp.exe.manifest) and a deployment manifest (MyApp.application)
  • Both manifests can be created using both mage.exe and MageUI.exe
  • The application manifest represents a specific version of the app - it will contain a list of all the files deployed, and their hashes
  • The deployment manifest contains the application's install/update urls, as well as the location of the latest manifest file
  • During the app install, the deployment manifest is downloaded, the information displayed to the user. If the user wishes to proceed, the installed will download the manifest file and all the app files (verifying their signatures and hashes), and install it locally
  • Generating these files by hand is a bit of a pain. If you want to sign your app/deployment with a code signing certificate, it becomes an even more involved process.

Having spent a while trying to generate these files by hand, and getting all kinds of config/signature/hash/path mismatches, I thought to try something else.

Interestingly, while the new SDK style projects don't have a simple way to publish ClickOnce application, they use the same Microsoft.Common.CurrentVersion.targets file during the build, which contains a lot of ClickOnce related tasks. And while I didn't get a full publish process, I can have the build generating the application manifests for me!

There are a couple notes regarding the configuration below:

  • I wanted to have both a debug and release build, generating separate deployments
  • I wanted the build to auto-increment the application numbers, preferably coming from a CI system
  • I wanted to keep each app version stored on the server with a similar structure to the original ClickOnce deployment

This is what I ended up adding to my project:

  <PropertyGroup Condition="'$(BuildNumber)' == ''">
    <BuildNumber>0</BuildNumber>
  </PropertyGroup>

  <PropertyGroup>
    <VersionPrefix>2.0.0.$(BuildNumber)</VersionPrefix>
  </PropertyGroup>

  <!-- Click Once publishing -->
  <PropertyGroup Condition="$(DefineConstants.Contains('CLICKONCE')) And '$(Configuration)'=='Debug'">
    <ClickOnceSuffix>.beta</ClickOnceSuffix>
  </PropertyGroup>

  <PropertyGroup Condition="$(DefineConstants.Contains('CLICKONCE'))">
    <TargetDeployManifestFileName>MyApp$(ClickOnceSuffix).application</TargetDeployManifestFileName>
    <OutDir>bin\$(Configuration)\$(TargetFramework)\Application Files\MyApp.$(VersionPrefix)$(ClickOnceSuffix)\</OutDir>

    <GenerateManifests>true</GenerateManifests>
    <ProductName>My App Name</ProductName>
    <PublisherName>Artiom Chilaru</PublisherName>
    <InstallUrl>https://app.myapp.com/</InstallUrl>
    <ApplicationVersion>$(VersionPrefix)</ApplicationVersion>

    <UpdateEnabled>true</UpdateEnabled>
    <UpdateMode>Foreground</UpdateMode>
    <Install>true</Install>
    <MapFileExtensions>true</MapFileExtensions>
    <TrustUrlParameters>true</TrustUrlParameters>
  </PropertyGroup>

Running a build, and passing a build number, as well as defining the CLICKONCE constant will generate two more files as part of your build - MyApp.application and MyApp.exe.manifest! Great!

There are a couple changes that we want to do before we upload them to the server though.

  • I want to sign both my application files and the manifests with a code signing certificate
  • Since the web server where the app will be hosted will usually prevent us from downloading .exe, .dll or .config files, ClickOnce deployment usually append a .deploy suffix to them
  • We need to put the app and the manifest to a child folder (Application Files\MyApp.$(VersionPrefix)$(ClickOnceSuffix)\) while the MyApp.application deploy manifest will be hosted at the root of https://app.myapp.com/
  • Keep a copy of the signed deployment manifest in the versioned app folder, in case a new release has to be pulled, and we need an older version's deployment manifest file to be promoted for any reason

There are several moving parts here, so I automated it with a simple powershell script:

# Config variables
$certName = "Open Source Developer, Artiom Chilaru"
$certHash = "0E654DA1287370CBB65748C8E33CDDDB10607716"
$assemblyPrefix = "MyApp.*"

# Additional config and tool location
$timestampUrl = "http://timestamp.digicert.com"
$cert = Get-ChildItem "Cert:\CurrentUser\My\$certHash"
$magePath = "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\mage.exe"

# Getting the version folder name for the current deployment
$versionDir = (Get-ChildItem '.\Application Files\').Name

# Signing assembly files
$files = Get-ChildItem ".\Application Files\$versionDir\*" -Include $assemblyPrefix -Exclude *.manifest,*config -File
Set-AuthenticodeSignature $files $cert -TimestampServer $timestampUrl

# Updating the file hashes in the manifest file
& $magePath -Update ".\Application Files\$versionDir\MyApp.exe.manifest" -FromDirectory ".\Application Files\$versionDir\" -Algorithm sha256RSA
# Signing the application manifest
& $magePath -Sign ".\Application Files\$versionDir\MyApp.exe.manifest" -CertHash $certHash -Algorithm sha256RSA -TimestampUri $timestampUrl

# Renaming application files - adding a .deploy suffix
Get-ChildItem ".\Application Files\$versionDir\*" -Exclude *.manifest -File | ForEach-Object { Rename-Item $_.FullName ($_.Name + ".deploy") }

# Getting the deployment manifest file (to support .beta builds)
$deployManifest = (Get-ChildItem *.application).Name
# Updating the deployment manifest with the location and hash of the application manifest
& $magePath -Update $deployManifest -AppManifest "Application Files/$versionDir/MyApp.exe.manifest"
# Signing the deployment manifest
& $magePath -Sign $deployManifest -CertHash $certHash -Algorithm sha256RSA -TimestampUri $timestampUrl
# Copy the deployment manifest to the versioned path
Copy-Item $deployManifest ".\Application Files\$versionDir\$deployManifest"

I had my build server run the build, move the deployment manifest file two levels up (same folder that has the Application Files folder), then run the script below in the same directory.

Once done, the deployment manifest and the whole Application Files can be copied to the webserver, to the web root of https://app.myapp.com/


While not perfect, this gave me a very simple process that can be easily automated to keep deploying ClickOnce apps, while having a cleaner project format. I could even multi-target the application, and have full .NET framework builds be deployed using ClickOnce, and have the same code compiled against .NET Core, and potentially deployed using a different method (MSIX and Squirrel are two things that come to mind)

References