F# and ClickOnce - The problems I ran into and how to solve these

Recently I had a problem to deploy a F# WPF tool with ClickOnce. Yeah ClickOnce, old technology and it's still in use in some enterprises to deploy applications. The standard ClickOnce tab in the project are missing in the project template from Visual Studio. So I had to do some research and I found out, that with FAKE you can deploy a F# program via ClickOnce.

At first I want to thanks the authors of 2 blog entries...

https://fwaris.wordpress.com/2013/08/14/fake-script-for-clickonce-packaging-of-f-apps/

and

https://github.com/magicmonty/blog/blob/master/_posts/2016-04-15-creating-clickonce-installers-with-fake.md

... for doing all the heavy lifting.

But I won't write a blog article about it, when this was all to do.

I run into 2 Problems:

  1. The FAKE Script doesn't find my MSBuild to build the project
  2. The Installation of the programm ends with an error: "Reference in the manifest does not match the identity of the downloaded assembly your-app.exe."

The Solutions:

1. MSBuild not found

That's an easy one. Use the following line in your FAKE-Script:

setBuildParam "MSBuild" "<your-path-to-vs>\Microsoft Visual Studio/2017/MSBuild/15.0/Bin/MsBuild.exe"

2. The Reference in the manifest does not match ...

So this one was a little harder. The solution is to not add the manifest into the assembly.

Normally in the project setting you have a drop down in the "Application" tab, which says "Create application without a manifest".

See here: https://stackoverflow.com/questions/5337458/error-deploying-clickonce-application-reference-in-the-manifest-does-not-match

20180808_csproj_option_without_manifest

This option doesn't exist in the project settings of a F# project. So I look into a csproj file and find this following settings in the project file is there.

<PropertyGroup>
    <NoWin32Manifest>true</NoWin32Manifest>
</PropertyGroup>

I add this setting to the fsproj file and it doesn't work. Damn.

After some further research I find out, that you can also add a compiler flag to avoid that the manifest is embedded into the .exe file.

--nowin32manifest

So I add this flag in the "Build" tab under "other flags:".

20180808_other_flags_nowin32manifest

Now it work, as it should.

Here is the ClickOnce Deployment Script

I do not use FAKE 5, I use FAKE 4. But maybe I migrate this script to FAKE 5 in the future.

#r @".\packages\FAKE.4.64.13\tools\FakeLib.dll"

open Fake

// project and version info
let projectName = "<your-app-name-without-.exe>"
// use your own version
let version = "1.0.0.0"

// Directories
let buildDir  = @".\build\"
let deployDir = @".\deploy\"
let applicationDirName = (sprintf "%s.%s" projectName version)
let publishDir = deployDir @@ applicationDirName

// here is set the ClickOnce sign certificate
let cert = @".\ClickOnce.pfx"
let certPwFile = @".\ClickOncePwd.txt"

// Set the MSBuild path
setBuildParam "MSBuild" "M:\Microsoft Visual Studio/2017/MSBuild/15.0/Bin/MsBuild.exe"

// Filesets
let appReferences  = !! "<your-project-to-publish-folder>/*.fsproj"


let EmptyMageParams =  { 
    // maybe the mage is in an other folder on your computer
    ToolsPath = @"C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.7.1 Tools"
    Manifest = ""
    ApplicationFile = ""
    CertFile = None

    Name = projectName
    Version = version
    Processor = MSIL
    TrustLevel = None
    
    // Set here your company name
    Publisher = Some "MyAwesomeCompany"
    
    ProviderURL = ""
    SupportURL = None
    FromDirectory = ""

    ProjectFiles = []
    IconPath = ""
    IconFile = ""
    TmpCertFile = ""
    Password = None
    CertHash = None
    IncludeProvider = None
    Install = None
    UseManifest = None
    CodeBase = None }


#r "System.Xml.Linq.dll"
open System.Xml.Linq

let setUpdatePolicy (filename: string) =
    let n nameSpace name = XName.op_Implicit (nameSpace + name)

    let asmv1 = "{urn:schemas-microsoft-com:asm.v1}"
    let asmv2 = "{urn:schemas-microsoft-com:asm.v2}"
    let cov1 = "{urn:schemas-microsoft-com:clickonce.v1}"

    let removeNode name (node: XElement option) =
        match node with
        | None -> None
        | Some n ->
            match n.Element(name) with
            | null -> ()
            | e -> e.Remove()
            node

    let addNode (name: XName) (node: XElement option) =
        match node with
        | Some n -> n.Add(new XElement(name))
        | _ -> ()

    let element name (node: XElement option) : XElement option =
        match node with
        | None -> None
        | Some n ->
            match n.Element(name) with
            | null -> None
            | e -> Some e

    let doc = XDocument.Load(filename)

    Some doc.Root
    |> element (n asmv2 "deployment")
    |> element (n asmv2 "subscription")
    |> element (n asmv2 "update")
    |> removeNode (n asmv2 "expiration")
    |> addNode (n asmv2 "beforeApplicationStartup")

    doc.Save(filename)


Target "Clean" (fun _ ->
    CleanDirs [buildDir; deployDir; publishDir]
)

Target "Build" (fun _ ->
    MSBuildRelease buildDir "Build" appReferences
    |> Log "Release Build-Output: "
)

Target "Prepare Deployment" (fun _ ->
    !! (buildDir @@ "**/*.*")   
    -- "**/*.pdb"
    -- "**/*.xml"
    |> CopyFiles publishDir
)

Target "Create ClickOnce Installer" (fun _ ->
    let appManifest = publishDir @@ (sprintf "%s.exe.manifest" projectName)
    let deployManifest = sprintf "%s.application" projectName
    let appParams = { EmptyMageParams with Manifest = appManifest
                                           ApplicationFile = publishDir @@ (sprintf "%s.exe" projectName)
                                           TrustLevel = Some FullTrust
                                           FromDirectory = publishDir
                                           IconPath = publishDir
                                           IconFile = projectName + ".ico"
                                           IncludeProvider = Some false
                                           UseManifest = Some true
                                           CertFile = Some cert
                                           Password = Some certPwFile}

    

    let deployParams = { EmptyMageParams with Manifest = appManifest
                                              ApplicationFile = deployDir @@ deployManifest
                                              Install = Some true 
                                              CertFile = Some cert
                                              Password = Some certPwFile }
    
    // Create
    MageCreateApp appParams
    // Sign
    MageSignManifest appParams
    // Deploy
    MageDeployApp deployParams
    
    deployDir @@ deployManifest |> setUpdatePolicy
    // Sign
    MageSignDeploy deployParams

    (buildDir) |> CopyFile publishDir
)

Target "Deploy" (fun _ ->
    trace "Deploy the created ClickOnce installer wherever you want"
)

"Clean"
  ==> "Build"
  ==> "Prepare Deployment"
  ==> "Create ClickOnce Installer"
  ==> "Deploy"

RunTargetOrDefault "Deploy"

I hope it helps a little.