Ads from Inoreader • Remove

Creating a cross platform desktop app in .NET Core: Distribution for Windows (Part Two)

Windows installer

Introduction

In my previous blog post I talked about what I learnt by leaving the Microsoft bubble, and choices I faced when I considered some cross platform UI Frameworks.

Whichever your choice, you will end up having to solve one issue: How do you distribute your app to your users?
This part was actually 80% of the work, and I will talk about how I tackled it here so you don’t have to suffer as much.

I am distributing the following:

  • setup.exe for windows
  • .dmg for mac
  • .tar.gz for linux nerds
  • .deb for debian users

Self contained dotnet publish

Dotnet has this very useful command called dotnet publish, it basically generate a pre-compiled, self contained version of your app, ready to run on the environment of your choice without the need for your users to have dotnet core runtime installed.

dotnet publish -p:Configuration=Release -p:PublishTrimmed=true -p:SelfContained=true --runtime osx-x64 

I think that Configuration and SelfContained are already properly set by default, but it never hurts to be specific. PublishTrimmed will try to strip unused code after building your code so it has a smaller storage footprint.

This output a binary folder with tons of files. You can then copy this folder on your user’s computer and run the main executable.

Instead of osx-x64, I also used win-x64, linux-x64, and debian-x64 for other platforms.

Everything would work nice but:

  • Most of your user will open the folder and not know which file to run
  • You will need to zip/tar the file, and assume the user know how to open it
  • It is not signed

So how do you solves it? One way is to add -p:PublishSingleFile=true to the publish command. By doing this, all the files in the output folder will be compressed in a single executable file you can run everywhere.

But… there is a catch. This single file is actually under the hood a self extracting zip file! Most users will not bother about it, but this mean that every times you make a new version, it will take more and more space on your user’s drive without the user being aware of it.

The dotnet team will fix that for .NET 5.0, but this is still long down the road.

The other problem is that it “does not feel native”. Desktop users are used to have a native experience, which mean a proper installer, launcher icon, desktop icon, indexation.

If you go down the installer road, you can drop PublishSingleFile because the user will never have to really see the real folder.

Docker makes it easy to build everywhere

A big problem with creating installer is that each platforms requires very specific toolchains.
Because you can’t assume your team will want to install potentially conflicting toolchain, nor you want to assume that your team is coding on Windows, you will find docker very handy.

The nice thing with docker is that the build is repeatable and all the dependencies are specified in a single place. And it will work everywhere!

You can see my directory structure.

C:\SOURCES\BTCPAYSERVER.VAULT\BUILD
│ push-new-tag.ps1
│ README.md
│ RELEASE.md

├───common
│ export-variables.sh

├───debian-x64
│ BTCPayServer.Vault.desktop
│ control
│ Dockerfile

├───linux-x64
│ Dockerfile

├───osx-x64
│ │ Dockerfile
│ │ Info.plist
│ │
│ └───Metadata
│ │ .DS_Store
│ │
│ ├───.background
│ │ Logo_with_text_small.png
│ │
│ └───.fseventsd
│ 00000000009a0cb3
│ 00000000009a0cb4
│ fseventsd-uuid

├───travis
│ build.sh
│ makerelease.sh
│ pgpsign.sh

└───win-x64
Dockerfile
nsis-header.bmp
nsis-wizard.bmp
vault.nsis

In a nutshell, each runtime have its own folder with dependencies needed for building the installer.

Let’s take start by looking at how I packaged windows. (win-x64)

Creating a Windows installer

Creating a windows installer was notoriously hard. The official way to create an msi file with Wix . But for those that used it, everybody will agree: This is bloody hard to maintain, understand and use.

But worse than this: It only works on windows. For an open source project such as mine, this is unacceptable for several reason:

  • This is impossible to dockerize. Sure you can in theory have windows containers, but those do not work on a Linux host so what’s the point?
  • I don’t want my users to install custom heavy toolset just to make an installer.

Instead I decided to go with nsis. Fear not about the retro design of their website. The important part is that everybody on linux who wants to target windows use it, so you have lot’s of ready to run examples to tweak. And this is exactly what I did. (Thanks to the Bitcoin Core and Electrum project)

I copy pasted it from another project, and just adapted it to my own.
Thanks to careful docker caching, management the feedback loop is fast and nsis documentation good enough so you reach the extra mile needed for your project.

Windows installer directory structure

Let’s study the dockerfile.

# Optimize docker cache, do not make it one layer
RUN apt-get update
RUN apt-get install -y --no-install-recommends imagemagick
###

Because you will need imagemagick for other installers, I install it first to optimize docker caching. Imagemagick is a very useful tool to do image conversion and transformation.

RUN apt-get install -y --no-install-recommends nsis unzip wine

I will need nsis to create the installer, but also unzip because I will need to unzip and run additional dependencies for my build.

RUN wget -qO "/tmp/hwi.zip" https://github... && \
unzip "/tmp/hwi.zip" -d "/tmp" && \
echo "f52ec4... /tmp/hwi.exe" | sha256sum -c - && \
wget -qO "/tmp/rcedit.exe" https://ci.appveyor... && \
echo "b8dda... /tmp/rcedit.exe" | sha256sum -c -

Here, I am just downloading dependencies and checking their hash did not changed since last time I downloaded.

hwi.zip is a third party dependencies I need to add to my publish folder for reasons specific to my particular app, you won’t need it.

rcedit.exe as you will see, dotnet publish will not properly bundle the icon file for your app with ApplicationIcon (see this issue), so you need to run rcedit.exe. But this is a windows executable! This is why I added wine as a dependency before.

WORKDIR /source
ENV RUNTIME "win-x64"
COPY "Build/common" "Build/common"
ENV EXPORT_VARIABLES "source Build/common/export-variables.sh"
COPY BTCPayServer.Vault/BTCPayServer.Vault.csproj BTCPayServer.Vault/BTCPayServer.Vault.csproj
COPY BTCPayServer.Hwi/BTCPayServer.Hwi.csproj BTCPayServer.Hwi/BTCPayServer.Hwi.csproj
SHELL ["/bin/bash", "-c"]
RUN $EXPORT_VARIABLES && dotnet_restore

Here you can see that I am copying only files necessary to run dotnet restore (defined in export_variables.sh)
I am doing this to optimize docker layer caching. If I don’t change my .csproj files but need to build again the docker image, then docker build will just skip the restore.

export-variables.sh is itself pretty boring, in a nutshell, it just read the .csproj of my project, fetch values from it and put them in environment variables.

COPY BTCPayServer.Hwi BTCPayServer.Hwi
COPY BTCPayServer.Vault BTCPayServer.Vault
COPY BTCPayServerVault.png BTCPayServerVault.png
RUN $EXPORT_VARIABLES && \
mkdir -p "/tmp/BTCPayServerVault.ico.tmp" && \
for size in 256x256 48x48 32x32 16x16; do \
convert -background none -resize "!$size" "BTCPayServerVault.png" "PNG32:/tmp/BTCPayServerVault.ico.tmp/BTCPayServerVault-$size.png"; \
done && \
convert /tmp/BTCPayServerVault.ico.tmp/*.png /tmp/BTCPayServerVault.ico && \
dotnet_publish && mv /tmp/hwi.exe "$PUBLISH_FOLDER/"

This part is unreadable on Medium, so take a look here.

Now I have restored the dependencies of my project, I go ahead and copy what need to be compiled.
You can see that I am generating the .ico (windows icon and taskbar icon) for windows from BTCPayServerVault.png. The png is 1024*1024 pixel, I am using imagemagick with convert to get several standard image size, and bundle those files together in the .ico. (we will need it later)

Then I run dotnet publish.

COPY "Build/${RUNTIME}" "Build/${RUNTIME}"
RUN $EXPORT_VARIABLES && \
wine /tmp/rcedit.exe "$PUBLISH_FOLDER/$EXECUTABLE.exe" \
--set-icon "/tmp/BTCPayServerVault.ico" \
--set-version-string "LegalCopyright" "$LICENSE" \
--set-version-string "CompanyName" "$COMPANY" \
--set-version-string "FileDescription" "$DESCRIPTION" \
--set-version-string "ProductName" "$TITLE" \
--set-file-version "$VERSION" \
--set-product-version "$VERSION" && \

I then proceed to use rcedit.exe, thanks to wine which let me run a windows executable on linux. This utility will edit the metadata of the executable file. This allow me to have proper icons for my executable, as well as proper details of the version of the file and origin.

The icon will appear in the search bar, property windows and in the taskbar, in the Alt+Tab windows.

Because my .ico is bundling different size, the icon never looks weirdly resized.

makensis \
"-DICON=/tmp/BTCPayServerVault.ico" \
"-DICONNAME=BTCPayServerVault.ico" \
"-DPRODUCT_VERSION=$VERSION" \
"-DPRODUCT_NAME=$TITLE" \
"-DPRODUCT_PUBLISHER=$COMPANY" \
"-DPRODUCT_DESCRIPTION=$DESCRIPTION" \
"-DDIST=$DIST" \
"-DEXECUTABLE=$EXECUTABLE" \
"-DPUBLISH_FOLDER=$PUBLISH_FOLDER" \
"-DRESOURCES=${RESOURCES}" \
"$RESOURCES/vault.nsis"

Then I finally invoke nsis. Notice that I am passing to it the environment variables created in export-variables.sh extracted from our csproj.
The vault.nsis file make use of them.

ENTRYPOINT [ "/bin/bash", "-c", "$EXPORT_VARIABLES && cp $DIST/* /opt/dist/" ]

The entrypoint will allow me to easily extract the generated installer out of the docker image, onto my host.

And that’s it! Now anybody with docker can generate the executable reliably.
So here is how you build the docker image. (don’t worry if you see red text about wine, it is fine)

docker build -t vault-win-x64 -f .\Build\win-x64\Dockerfile .

The entrypoint of the docker image will take the files from its internal dist folder and copy it to /opt/dist , so now you just have to run this image with a mapped volume. This will drop the executable in this dist folder of your current directory:

docker run --rm -v "$(pwd)/dist:/opt/dist" vault-win-x64

Conclusion

Building the windows installer on linux allow us to easily and efficiently test it locally with a tight feedback loop, while making sure it will work everywhere (spoiler: later on travis)

We will later see how I handled debian, osx and other linux distribution in the same way.

Note that in order to get rapid feedback when you develop your docker image, it is important that you do not copy files on the docker image that are not involved in the actual build. I customized my .dockerignore file for this.

Last, prune your docker images time to time. Because each new docker build does not clear the previous one, this might free up 20 GB at the end of the day.

docker image prune
stat?event=post.clientViewed&referrerSou