0 Comments

I did a strange thing a few weeks ago. Something I honestly believed that I would never have to do.

I populated a Single Page Javascript Application with data extracted from a local database via a C# application installed using ClickOnce.

But why?

I maintain a small desktop application that acts as a companion to a service primarily offered through a website. Why do they need a companion application? Well, they participate in the Australian medical industry, and all the big Practice Management Software (PMS) Systems available are desktop applications, so if they want to integrate with anything, they need to have a presence on the local machine.

So that’s where I come in.

The pitch was that they need to offer a new feature (a questionnaire) that interacted with installed PMS, but wanted to implement the feature using web technologies, rather than putting it together using any of the desktop frameworks available (like WPF). The reasoning behind this desire was:

  1. The majority of the existing team had mostly web development experience (I’m pretty much the only one that works on the companion application), so building the whole thing using WPF or something similar wouldn’t be making the best use of available resources, 
  2. They wanted to be able to reuse the implementation in other places, and not just have it available in the companion application.
  3. They wanted to be able to reskin/improve the feature without having to deploy a new companion application

Completely fair and understandable points in favour of the web technology approach.

Turtles All The Way Down

When it comes to running a website inside a C# WPF desktop application, there are a few options available. You can try to use the native WebBrowser control that comes with .NET 3.5 SP1, but that seems to rely on the version of IE installed on the computer and is fraught with all sorts of peril.

You’re much better off going with a dedicated browser control library, of which the Chromium Embedded Framework is probably your best bet. For .NET that means CEFSharp (and for us, that means its WPF browser control, CEFSharp.WPF).

Installing CEFSharp and getting it to work in a development environment is pretty straightforward, just install the Nuget packages that you need and its all probably going to start working.

While functional, its not the greatest experience though.

  • If you’re using the CEFSharp WPF control, don’t try to use the designer. It will crash, slowly and painfully. Its best to just do the entire thing in code behind and leave the browser control out of the XAML entirely.
  • The underlying C++ library (libcef.dll) is monstrous, weighing in at around 48MB. For us, this was about 4 times the size of the entire application, so its a bit of a change.
  • If you’re using ClickOnce to deploy your application, you’re in for a bit of an adventure.

The last point is the most relevant to this post.

Because of the way ClickOnce works, a good chunk of the dependencies that are required by CEFSharp will not be recognised as required files, and thus will not be included in the deployment package when its built. If you weren’t using ClickOnce, everything would be copied correctly into the output folder by build events and MSBuild targets, and you could then package up your application in a sane way, but once you pick a deployment technology, you’re pretty much stuck with it.

You’ll find a lot of guidance on the internet about how to make ClickOnce work with CEFSharp, but I had the most luck with the following:

  1. Add Nuget references to the packages that you require. For me this was just CEFSharp.WPF, specifically version 49.0.0 because my application is still tied to .NET 4.0. This will add references to CEFSharp.Common and cef.redist.x86 (and x64).
  2. Edit the csproj file to remove any imports of msbuild targets added by CEFSharp. This is how CEFSharp would normally copy itself to the output folder, but it doesn’t work properly with ClickOnce. Also remove any props imports, because this is how CEFSharp does library references.
  3. Settle on a processor architecture (i.e. x86 or x64). CEFSharp only works with one or the other, so you might as well remove the one you don’t need.
  4. Manually add references to the .NET DLLs (CefSharp.WPF.dll, CefSharp.Core.dll and CefSharp.dll). You can find these DLLs inside the appropriate folder in your packages directory.
  5. This last step deals entirely with the dependencies required to run CEFSharp and making sure they present in the ClickOnce deployment. Manually edit the csproj file to include the following snippet:

    <ItemGroup>    
        <Content Include="..\packages\cef.redist.x86.3.2623.1396\CEF\*.*">
          <Link>%(Filename)%(Extension)</Link>
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
          <Visible>false</Visible>
        </Content>
        <Content Include="..\packages\cef.redist.x86.3.2623.1396\CEF\x86\*.*">
          <Link>%(Filename)%(Extension)</Link>
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
          <Visible>false</Visible>
        </Content>
        <Content Include="..\packages\CefSharp.Common.49.0.0\CefSharp\x86\CefSharp.BrowserSubprocess.*">
          <Link>%(Filename)%(Extension)</Link>
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
          <Visible>false</Visible>
        </Content>
        <Content Include="lib\*.*">
          <Link>%(Filename)%(Extension)</Link>
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
          <Visible>false</Visible>
        </Content>
        <Content Include="$(SolutionDir)packages\cef.redist.x86.3.2623.1396\CEF\locales\*">
          <Link>locales\%(Filename)%(Extension)</Link>
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
          <Visible>false</Visible>
        </Content>
    </ItemGroup>
    This particularly brutal chunk of XML adds off the dependencies required for CEFSharp to work properly as content files into the project, and then hides them, so you don’t have to see their ugly faces whenever you open your solution.

After taking those steps, I was able to successfully deploy the application with a working browser control, free from the delights of constant crashes.

Injected With A Poison

Of course, opening a browser to a particular webpage is nothing without the second part; giving that webpage access to some arbitrary data from the local context.

CEFSharp is pretty good in this respect to be honest. Before you navigate to the address you want to go to, you just have to call RegisterJsObject, supplying a name for the Javascript variable and a reference to your .NET object. The variable is then made available to the Javascript running on the page.

The proof of concept that I put together used a very simple object (a class with a few string properties), so I haven’t tested the limits of this approach, but I’m pretty sure you can do most of the basic things like arrays and properties that are objects themselves (i.e. Foo.Bar.Thing).

The interesting part is that the variable is made available to every page that is navigated to in the Browser from that point forward, so if there is a link on your page that goes somewhere else, it will get the same context as the previous page did.

In my case, my page was completely trivial, echoing back the injected variable if it existed.

<html>
<body>
<p id="content">
Script not run yet
</p>
<script>
if (typeof(injectedData) == 'undefined')
{
    document.getElementById("content").innerHTML = "no data specified";
}
else 
{
    document.getElementById("content").innerHTML = "name: " + injectedData.name + ", company: " + injectedData.company;
}
</script>
</body>
</html>

Summary

It is more than possible to have good integration between a website (well, one featuring Javascript at least) and an installed C# application, even if you happen to have the misfortune of using ClickOnce to deploy it. Really, all of the congratulations should go to the CEFSharp guys for creating an incredibly useful and flexible browser component using Chromium, even if it does feel monstrous and unholy.

Now, whether or not any of the above is a good idea is a different question. I had to make some annoying compromises to get the whole thing working, especially with ClickOnce, so its not exactly in the best place moving forward (for example, upgrading the version of CEFSharp is going to be pretty painful due to those hard references that were manually added into the csproj file). I made sure to document everything I did in the repository readme, so its not the end of the world, but its definitely going to cause pain in the future to some poor bastard.

This is probably how Dr Frankenstein felt, if he were a real person.

Disgusted, but a little bit proud as well.