Working with WebWindow — To Use Kestrel or to not use Kestrel?

Working with WebWindow — To Use Kestrel or to not use Kestrel?
Photo by Denny Müller / Unsplash

Simpler is better? What is simpler? In the wise words of every teenager — It’s Complicated.

Previously, I decided I wanted to write a better money app, but I wanted to use web technology, since I’m much more comfortable with it and tend to prefer it over Windows Forms or WPF. Plus, the web tends to be more portable.

However, I wanted the security of a local desktop application for my finances. I’m tired of Plaid selling my data just so I can view my accounts in Money in Excel. It’s not like Excel is even a free tool — it’s part of Office 365, which, last I checked, is a paid subscription. So why would my data be at risk? Welcome to today’s world.

Anyway, I didn’t want my program to be more of the same. I want my information locked tight on my client. But, is there any way to bring the web to my desktop? Sounds like a stupid question. Sure, I could load up IIS, start a localhost website, start an SQL Server instance, and keep it running on my local machine. Not very portable though — good luck installing that. Let’s skip IIS & SQL Server, but let’s still try to use a web view of some sort. Well, look no farther than WebWindow — for an experimental example.

WebWindow

WebWindow is a pretty cool library. It exposes a one-line command that would let you run a Blazor app by providing the Startup class and the path to the index page (e.g. index.html). But that’s here my difficulty started.

static void Main(string[] args)
{
    ComponentsDesktop.Run<Startup>("My Blazor App", "wwwroot/index.html");
}

This didn’t help me — I didn’t want to run the index.html version of Blazor (e.g. Blazor WASM/Client-Side). Since I was going to be on a localhost, I didn’t see a reason to opt out of Blazor Server, so I wanted to see if I could run with that. I tried running this over the basic money app I wrote, and complete failure.

I knew I’d run into issues. The blog article told me it was a prototype from the start. Github also shows 51 open issues, and Steve has repeatedly said that this is just an experiment.

At this point, I wanted to see how he programmed it and how it works, so I pulled it apart, and to see if I could make it work, extend it & run with it for my app.

How It Worked

So, we start with ComponentsDesktop. It took a while for me to understand, but the Run command creates a WebWindow object with some options, and then passes that into an IPC. This then attempts to create a service collection, calling the ConventionBasedStartup that was passed into it, and attempts to supply and render requests.

It’s actually pretty amazing. He created a simplified ASP.NET Core pipeline that would use a custom IPC to send/receive messages to/from the JS and .NET. Pulling it apart, I could never get it to work fully, as my code was expecting the ASP.NET Core pipeline to be there. I was left with a choice: Either I re-create the entire ASP.NET Core pipeline, but without a HTTP server, or I use a HTTP server.

To me, the choice seemed clear. Kestrel doesn’t need to be installed separately, and works wonderfully on localhost. If performance becomes an issue, I can look at this decision again to see if we can optimize it, but Kestrel is pretty mature at this point. If I secure the localhost port I’m using with a trusted self-signed certificate and use HTTPS, then there can’t be any local MITM attacks between the communication layers; and I don’t need to worry about typical web security, as we’re hosting the server locally on a client. Also, the code would probably be minimal and more maintainable than trying to rewrite ASP.NET Core to work without a server.

So, the decision: Start an ASP.NET Core site against localhost with a random open port, and load that port in a WebView. Then, I can run anything .NET Core supports using server-side processing without any latency. The only thing I have to be mindful of is that I have two threads — the Windows Form thread, and the Blazor Server thread, and Blazor, for example, can’t directly interact with the Windows GUI. We’ll need to also figure out how to let these two threads communicate to each other, if need be.

Organizing Ourselves

Before I started porting over the code from the WebWindow example, I decided to maintain the project seperation discipline that WebWindow had. I wanted to host a Blazor site, but any assets served over HTTP could be rendered in a WebView, so I separated those out:

  1. Thunder.WebView — The common interfaces and processes for creating a webview
  2. Thunder.WebView.Blazor — The Blazor extensions required to run Blazor in a webview.
  3. Thunder.WebView.Windows — The implementation required to make WebView work on Windows. This shouldn’t reference Blazor.

Right now, I only planned on using Edge’s WebView2, since that’s what WebWindow used for Windows, but I’ll worry about seeing if I could switch out Edge for another browser later. Mabe I could make it pluggable so the user could choose the renderer in a settings menu. But I’m getting ahead of myself. Let’s just get something working. I haven’t even wrote code yet.

Finally Getting Started?

When working with IHostBuilder or any Fluent API, I think about usage first. With writing this, I wanted to be able to take almost any Blazor app, wrap a WebView launcher around it, and see it load in a desktop window.

await Program.CreateDefaultHostBuilder(args)
    .UseWebView<Startup>()
    .Build()
    .RunAsync();

Easy peasy. Now I don’t need to change my site at all. I could just add UseWebView() to the main method that runs the app, and everything else can stay the same.

But what would UseWebView do? Well, It needs to load my startup file, provision a localhost url, start kestrel, and configure a web view.

public static IHostBuilder UseWebView<TStartup>(this IHostBuilder hostBuilder)
    where TStartup : class
{
    //todo replace this with dynamic port look up
    var urls = new[] { "http://localhost:5000", "https://localhost:5001" };

    return hostBuilder
        .ConfigureWebHost(webBuilder =>
        {
            webBuilder
            .UseStartup<TStartup>()
            .UseUrls(urls)
            .UseKestrel();
        })
        .ConfigureServices((hostContext, services) =>
        {
                // todo determine which url to prefer
                var primaryUrl = urls.Last();
                // todo plugin primary URL into WebView2

                // todo create WebView2 here
        });
}

Now, I need to define add a web view to the IHost. For our proof-of-concept, I’ll worry about seperating out Blazor and Windows later. First, we want some sort of hosted service that would run a Windows Form. This would be a Windows Form Application containing the WebView2 pointed to the localhost. Since we’re using WebView2, we know our site will be running on Chromium. We know our server code will be running on the localhost. We essentially are just creating a localhost browser, so this should be pretty easy. However, the trick will be loading a Windows Form from a .NET Host.

First, our hosted service. We simply want to run a Windows Form. Easiest way to do that is Application.Run, passing in a context that contains our Form class we want to run. However, this needs to run on a thread with an STA apartment state (I still don’t understand why, even after reading why), and on a separate thread. So our hosted service creates a thread, sets it with the proper apartment state, and we start that thread which calls Application.Run.

public class WindowsFormsApplicationHostedService : IHostedService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly Thread _thread;

    public WindowsFormsApplicationHostedService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
        // The thread hosting the Windows Forms application needs to have an STA apartment state
        _thread = new Thread(UIThreadStart);
        _thread.SetApartmentState(ApartmentState.STA);
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // we wait to start the thread until the app actually starts
        _thread.Start();
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }

    private void UIThreadStart()
    {
        // just get the application context and attempt to run it when the thread starts
        var applicationContext = _serviceProvider.GetRequiredService<ApplicationContext>();
        Application.Run(applicationContext);
    }
}

We use an IServiceProvider to get the ApplicationContext, since we’re now working with multiple threads. This places thread-safety requirement on our DI container, which the default provider does indeed support, as opposed to trying to ensure it here.

Now for this new dependency— ApplicationContext. Luckily, that isn’t something we have to code. We can create one with just giving it a Windows Form object.

new ApplicationContext(new WebViewForm())

Now we need to create the Windows Form — I called it WebViewForm. I’ll be honest, this is the closest to Windows Forms I’ve gotten in a long time, and the guides for WebView2 were incomplete at best. I searched for a library that might provide a control that I could easily use to create a WebView. The internet did not disappoint, again. I found Diga.WebView2: WebView2 Wrapper. It’s a WebView2 control for Windows Form. Using that, I created a simple Form object.

public class WebViewForm : Form
{
    private readonly Diga.WebView2.WinForms.WebView _webView1;

    public WebForm()
    {
        _webView1 = new Diga.WebView2.WinForms.WebView();
        SetupWebView();
        this.AddBrowserControl(_webView1);
    }

    private void AddBrowserControl<T>(T browser)
        where T : Control
    {
        toolStripContainer.ContentPanel.Controls.Add(browser);
    }

    private void SetupWebView()
    {
        // designer code for _webView1
        // truncated
    }
}

I still need a way to set the URL on the WebView, but this will be sufficient for now. Is there anything else I need? Well, yes. One thing more. For hosting a Windows Form app inside a .NET Core Host, I need a way to communicate to the Host that the Windows Form app was closed.

Luckily, .NET Core Host provides the IHostApplicationLifetime interface, which exposes StopApplication. We’ll simply listen to the ApplicationExit event, and if fired, we’ll stop the Host. We’ll create a separate class to handle this.

public class WindowsFormsLifetime : IHostLifetime, IDisposable
{
    private readonly IHostApplicationLifetime _applicationLifetime;

    public WindowsFormsLifetime(IHostApplicationLifetime applicationLifetime)
    {
        _applicationLifetime = applicationLifetime;
    }
    public Task WaitForStartAsync(CancellationToken cancellationToken)
    {
        Application.ApplicationExit += OnExit;
        return Task.CompletedTask;
    }
    public Task StopAsync(CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }

    public void Dispose()
    {
        Application.ApplicationExit -= OnExit;
    }
    private void OnExit(object sender, EventArgs e)
    {
        _applicationLifetime.StopApplication();
        System.Environment.ExitCode = 0;
    }
}

Alright, so now let’s just plug this into our AddWebView function, now that we have a hosted service and all of it’s dependencies.

  ...
  hostBuilder
  .ConfigureWebHost(...)
  .ConfigureServices((hostContext, services) =>
  {
        // todo determine which url to prefer
        // todo plugin primary URL into WebView2
        var primaryUrl = urls.Last();
        
        services.AddSingleton<WebViewForm>();
        services.AddSingleton(c => new ApplicationContext(c.GetRequiredService<WebViewForm>()));
        services.AddSingleton<IHostLifetime, WindowsFormsLifetime>();
        services.AddHostedService<WindowsFormsApplicationHostedService>();
  });
  ...

Now, when we call the Main method, this will call UseWebView, which will start a Windows Form Application inside the .NET Host that contains a WebView2. We still have a few things to tidy up:

  1. Plugin url to WebViewForm so it loads the proper localhost url
  2. Move Windows-specific code to Thunder.WebView.Windows
  3. Ensure Blazor can communicate properly in WebView.
  4. The hosted application can communicate to the UI Host (e.g. Windows Forms) to do things like open/save files or other similar actions.

We’ll look more into each topic in future installments. The Github commit for this is here.