Saturday, July 18, 2015

Making parameterized ClickOnce installers work

Alright. I spent entirely too long working on this one purpose yesterday. Judging by the state of internet questions on the topic, others have also spent too long working on this one purpose. Since I did end the night with a functioning system, I thought today I would write how I did it.

Two days ago I live streamed development for a short while, and wanted to let people on the stream test the game with me, and give feedback. I've had click-once based installers for years, but I found myself missing a certain feature. I wanted to be able to authenticate users, and be able to selectively enable clients. I wanted to be also able to give out custom URLs to people to download the game from, in a way that modified their installation so that I know which URL the game was installed from. Each installation URL should still self-update normally, however, and I want to be able to get rid of a given URL and authentication easily at a later date.

So this is a story about how to parameterize a click-once installation.

Attempt 1: Use query parameters on the installation URL to provide unique data to each installation URL.

My first idea was to write a script on the build server that generated a unique URL for the installer using a unique identifier placed in the query-string. So instead of installing from World/World.exe, the link would be World/World.exe?id=yourcustomidentifier.

This attempt ultimately failed, but in finding that out I solved other related problems, so here were the steps and their problems and solutions:

  1. Building the unique links turns out to be easy: 
    1. PowerShell scripts can generate Guids by invoking the .NET class libraries as in $authGuid = [guid]::NewGuid().ToString().
    2. Likewise in MSBuild use the following in a PropertyGroup: $([System.Guid]::NewGuid())
  2. Getting the query parameter to be used by ClickOnce is less easy:
    1. Like many aspects of ClickOnce, it has become obvious that it was not meant to be used by build scripts. Passing the query parameters into the installer requires setting a flag in the application manifest; but there is no means to set this flag using the command-line Manifest Generator (mage). Since we don't want a person involved for every run, UI solutions will not work either. This leaves us with editing the manifest xml ourselves.
      1. Specifically, we need to add trustURLParameters="true" to the  xml tag in the root of the manifest.
      2. In PowerShell I did this hacky monstrosity:
        1. (gc <product>.application) -replace '<deployment ','<deployment trustURLParameters="true"' | Out-File -Encoding "UTF8" <product>.application; exit;
        2. Basically, a string replacement, but take care:
          1. Problem 1: My build script was not powershell, but rather a batch file! Running powershell from the batch requires careful escaping, and looks like powershell -Command "{above command with escaped quotes}"
          2. Problem 2: Why the 'exit'? Because powershell was stopping script execution and that seems to fix it.
          3. Problem 3: Don't forget the -Encoding! My manifest got corrupted and unusable without it. I think it had to do with BOM (Byte order marks).
          4. Problem 4: Be VERY careful to do this operation BEFORE you do any signing of the application file, obviously signing has to come last.
      3. Really it's just amazingly painful using mage.exe.
    2. And now the kicker; This doesn't work!. Why not? Because the links I provide are not to the application, but rather to a bootstrapper Setup.exe that ensure prerequisites are installed, such as the .NET Framework. The bootstrapper does not forward query parameters, which makes sense, it's an EXE, very separate from the URL itself.

Attempt 2: Find some other way to parameterize that is readable during the run of the installed application

  1. So query parameters are out. What else can be use? The next approach I tried was to encode the identifier into the filename itself. So now the download link would look like World/World-identifierhere.exe.
    1. This fails for similar reasons as above; This is the filename of the bootstrapper; not the application manifest. ClickOnce provides access to the launch URL in C#, but this does the application URL, not the bootstrapper EXE URL.
  2. Next attempt: The bootstrapper contains in it the URL of the application; maybe we can add a query-string there and the application will receive it?
    1. So now we have to make a custom Setup.exe for every user that embeds their identifier; this is surprisingly not that difficult! The Bootstrapper is built using MSBuild, and customizing the URL to include a new Guid is not hard
    2. But a new problem arises: When I attach a query to the URL, the bootstrapper no longer works automatically: It forwards the user to a webpage to download the application manifest, rather than just installing it! I don't know why! Google didn't help! 
    3. I thought here that I could just make a custom application manifest for every client, but then realized that to maintain automatic updates I would have to forever-after update EVERY single client's installation for every single patch. NO thank you.

Attempt 3 (Success!): Build the user's ID into the username of the URL of the application manifest, when constructing the Bootstrapper EXE

Every attempt at getting data into the application from the bootstrapper failed, EXCEPT adding a username to the URL, which was passed through and available in the C# ApplicationDeployment.CurrentDeployment.ApplicationURI

Caveat implementor: This URI is only available in the first run, from the bootstrapper! You need to save that identifier somewhere in your application or you'll lose it forever!

So to recap my hopefully final solution:
  1. Build the application manifest and installation the same as usual.
  2. Build a unique Bootstrapper EXE for every client authentication token you want to have.
    1. The bootstrapper's ApplicationUrl needs to include the authentication token inside the username section of the URL.
    2. Rename the bootstrapper's exe to include the authentication token, to provide a unique URL to give to the user to install.
    3. When building the bootstrapper, register your authentication with whatever database you have, to be able to track it later.
  3. In the client application, read from ApplicationDeployment.CurrentDeployment.ApplicationURI.UserInfo, and save the results somewhere permanent.
    1. Use the authentication token saved to authenticate and identify.
  4. Later, just delete the custom Setup.EXE when you want to cut off access, and remove the authentication token from your database.
It may not be the prettiest solution, but it works, for now.

Good luck.

No comments:

Post a Comment