Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / HTML

.NET Core UI using Webviews

5.00/5 (4 votes)
30 Nov 2019CPOL11 min read 26.7K  
Write cross platform desktop applications with .Net Core by using the OS native webview.

Introduction

With .NET Core, Microsoft released a powerful tool for cross platform .NET development but it is lacking in the UI department. In general, for cross platform UIs, many choose Electron (like the very successful Visual Studio Code) which is based on Node.js as a runtime and an embedded Chromium as webview. Embedding the webview may ensure that everything looks (mostly) the same on all platforms but takes up quite a bit of space. Modern operating systems like Windows or macOS have a webview already built in and on Linux, it can easily be added with a package manager (if not installed by default anyway). All of that led me to creating a library for .NET Core that provides a UI using the OS native webview. It's called SpiderEye and this article shows how to use it and some background on how it's implemented.

Background

This section talks about how the library does what it does. As mentioned in the introduction, the library uses the webview that each operating system has built in. To use the webview, I also had to implement some window management using the native APIs. Naturally, this involves a lot of P/Invoke calls and pointers. In the public API, all of that is hidden away of course.

Generally, there is a static Application class for each platform (e.g., LinuxApplication) that does whatever initialization is necessary for that platform and injects a factory class into the common Application class that handles the app for all platforms. Then there is the Window class that is common for all platforms and uses the previously injected factory class to internally talk to the native counterparts. The webviews themselves aren't exposed directly but needed functionality is provided by the Window class. As far as the API consumer is concerned, the window IS the webview. To enable communication with the webview, the Window class provides a bridge object. With it, you can call a handler on the webview side (which has to be registered first with JavaScript) or you can register an object that handles calls coming from the webview. That object is just a simple class with methods and can be compared to what a Controller is in MVC.

The content (HTML, CSS, etc.) to load in the webview is served directly through the API that the webview provides and no extra server is needed. The only exception to that is the Windows Forms WebBrowser control that has no API for that and uses a simple localhost server hosted directly in the app. The content itself is usually embedded in the assembly but can be loaded from somewhere else.

Windows

Windows was the easiest platform to implement since I could use Windows Forms and the WebBrowser control directly without having to call any native APIs. The WebBrowser control is based on IE though and on Windows 10 the more modern, Edge based, webview is available. Luckily, there also is a .NET API available and only requires one native call to check the exact Windows 10 version because it's only available from build 17134 onwards. The new Edge with a Chromium webview is not fully released at the time of writing but will be included once it is. During creation of the window, it is decided which webview is used depending on the operating system and user configuration.

Linux

On Linux, the window handling is implemented with GTK and the webview with webkit2gtk. I chose GTK because it is very widespread and very often already installed even on non-GTK desktop environments. It is also very easy to use. For example, this is the (slightly edited) code for creating the window and attaching the webview:

C#
// create the window
Handle = Gtk.Window.Create(GtkWindowType.Toplevel);

// create and add a scrolling container
IntPtr scroller = Gtk.Window.CreateScrolled(IntPtr.Zero, IntPtr.Zero);
Gtk.Widget.ContainerAdd(Handle, scroller);
// add the webview (created earlier) to the scrolling container
Gtk.Widget.ContainerAdd(scroller, webview.Handle);

Gtk.Widget and Gtk.Window are static classes containing the P/Invoke functions like Create.

macOS

macOS uses Cocoa and WKWebView and was the most difficult platform to work with. Mainly because most of the time you can't just call a native function, but have to use the Objective-C runtime and, in extension, use Objective-C syntax. Most of the time you just use objc_getClass to get a pointer to a type, sel_registerName to get a pointer to a method or property and objc_msgSend with various overloads to actually execute the method or property. This is how the window gets created and initialized:

C#
// create a window
Handle = AppKit.Call("NSWindow", "alloc");

// get the style for the window, it's just an enum with various flags
var style = GetStyleMask(config.CanResize);

// initialize the window
ObjC.SendMessage(
    Handle,
    ObjC.RegisterName("initWithContentRect:styleMask:backing:defer:"),
    new CGRect(0, 0, config.Size.Width, config.Size.Height),
    style,
    new UIntPtr(2),
    false);

As with Linux before, AppKit and ObjC are static classes containing the P/Invoke calls and AppKit.Call is just a helper method that combines all three of the aforementioned functions and looks like this:

C#
public static IntPtr Call(string id, string sel)
{
    return ObjC.SendMessage(GetClass(id), ObjC.RegisterName(sel));
}

Using the Library

A SpiderEye app usually consists of four projects. A core library that contains common logic and the web stuff (like HTML, CSS, JS, etc.) and one project for each platform (Windows, Linux, macOS).

A Simple Example

The simplest example would have a shared startup code in the core library like so:

C#
namespace SpiderEye.Example.Simple.Core
{
    public abstract class ProgramBase
    {
        protected static void Run()
        {
            // this creates a new window with default values
            using (var window = new Window())
            {
                // this provides webview content from files embedded in the assembly
                Application.ContentProvider = new EmbeddedContentProvider("App");

                // runs the application and opens the window with the given page loaded
                Application.Run(window, "index.html");
            }
        }
    }
}

The EmbeddedContentProvider class simply loads files that are requested by the webview (e.g., index.html) from the files that are embedded in the library. For this example, we have a folder named "App" that contains those client side files. To embed the files with the path name intact, you have to add this to the csproj file:

XML
<ItemGroup>
  <!-- The App folder is where all our html, css, js, etc. files are -->
  <EmbeddedResource Include="App\**">
    <!-- this retains the original filename of the embedded files 
         (required to located them later) -->
    <LogicalName>%(RelativeDir)%(Filename)%(Extension)</LogicalName>
  </EmbeddedResource>
</ItemGroup>

And the platform specific projects only need a startup class that initializes the platform and calls the common startup logic, e.g., for Windows:

C#
using System;
using SpiderEye.Windows;
using SpiderEye.Example.Simple.Core;

namespace SpiderEye.Example.Simple
{
    class Program : ProgramBase
    {
        [STAThread]
        public static void Main(string[] args)
        {
            // initializes Windows specific things to run the app
            WindowsApplication.Init();
            // run the app by calling the common startup logic from the core library
            Run();
        }
    }
}

The complete example project and some more complex ones can be found on Github.

The API

The public API of SpiderEye is relatively simple and revolves mostly around the Application and Window class.

The Application Class

This class is the start point for every application and works very similar to the Windows Forms Application class.

Properties
C#
static bool ExitWithLastWindow { get; set; }

With ExitWithLastWindow you can set if the whole application closes once the last window closes. Usually, this is set to true. If you have a background app (usually in combination with StatusIcon) or if you are on macOS, you may want to set it to false.

C#
static IContentProvider ContentProvider { get; set; }

With the content provider, you can set an object that loads the files for the webview from somewhere. Usually, you'll use EmbeddedContentProvider like in the example above but it's easy to implement a custom one if you need it.

C#
static IUriWatcher UriWatcher { get; set; }

The UriWatcher is very useful for development. All it does is check the URI that is about to be loaded in the webview and, if required, replaces it with a different one. A use case for that is if you use something like the Angular dev server, you'll want to direct any requests to the dev server while developing but not when released.

C#
static OperatingSystem OS { get; }

The OS property simply returns on which operating system you are currently running.

Methods
C#
static void Run()
static void Run(Window window)
static void Run(Window window, string startUrl)

Calling Run starts the main loop and blocks until the application exits (e.g., when all windows are closed or Exit is called). There are overloads to pass in a window which gets displayed once Run is called and you can specify a start URL that gets loaded immediately.

C#
static void Exit()

With Exit, you can close the whole application and the Run call will return.

C#
static void Invoke(Action action)
static T Invoke<T>(Func<T> action)

Use Invoke to execute some code on the main thread. This is necessary if you want to access the UI from some other thread. It's also safe to call from the main thread if you aren't sure on which thread you are.

C#
static void AddGlobalHandler(object handler)

With AddGlobalHandler, you can register an object that handles calls from any webview (no matter in which window it is). This is described in more detail further down, here.

The Window Class

With the window class, you can manage a window (obviously), load an URL in the webview and communicate with the webview.

Properties

I won't list all properties here because most of them are very obvious (like Title or Size).

C#
string BackgroundColor { get; set; }

The BackgroundColor property sets the background color of both the window and the webview (if possible). You should set it to the same color as you have set on the web page to avoid any flashing/flickering while the page is loading. Use a six value hex code, e.g. "#FFFFFF"

C#
bool UseBrowserTitle { get; set; }

If UseBrowserTitle is set to true, the window title will reflect whatever is set as title in the currently loaded HTML page.

C#
bool EnableScriptInterface { get; set; }

With EnableScriptInterface, you can set if the webview is allowed to talk to the .NET side.

C#
IWebviewBridge Bridge { get; }

This gets the bridge object with which you can communicate with the webview. It's described in more detail further down, here.

Methods
C#
void Show()
void Close()

Show and Close are pretty self explanatory, they show or close the window.

C#
void SetWindowState(WindowState state)

With SetWindowState, you can change the window state to e.g., minimized or maximized.

C#
void LoadUrl(string url)

With LoadUrl, you can load a page in the webview. If you provide a relative URL (e.g. "/index.html") it will try to load it with Application.ContentProvider, an absolute URL (e.g. "https://www.codeproject.com") will be loaded directly.

The IWebviewBridge Interface

This interface is the bridge between your .NET app and the webview. You can do two things with it, execute something on the webview side or provide an object that handles calls coming from the webview.

Handling Calls Coming From the Webview

Use AddHandler to register an object to handle calls only from one specific window/webview or use AddGlobalHandler to register an object to handle calls from all windows/webviews (even ones created later).

C#
void AddHandler(object handler)
void AddGlobalHandler(object handler)

The object you register is just a simple class with methods, similar to a Controller in MVC.

C#
public class UiBridge
{
    // methods can be async and return a Task or Task<T>
    public async Task RunLongProcedureOnTask()
    {
        await Task.Delay(TimeSpan.FromSeconds(10));
    }

    // they can return complex or simple types.
    // just keep in mind that they are converted to JSON
    public SomeDataModel GetSomeData()
    {
        return new SomeDataModel
        {
            Text = "Hello World",
            Number = 42,
        };
    }

    // they can receive one parameter from the webview.
    // if you need more than one value, use a model like here
    public double Power(PowerModel model)
    {
        return Math.Pow(model.Value, model.Power);
    }

    // any uncaught exceptions are relayed to the webview
    public void ProduceError()
    {
        throw new Exception("Intentional exception from .Net");
    }
}

On the webview side, the public methods of that object are then accessible by a path of "ClassName.camelCaseMethodName". With our class here, for example: "UiBridge.getSomeData"

To actually call something from the client side, it's advisable to install the "spidereye" npm package. It includes the SpiderEye object which provides functions that make it easier to work with the bridge. If that is not an option, you can directly use the injected window._spidereye object. Note that this object may not be available immediately and you should check if it exists first and if not, subscribe to the spidereye-ready event to get notified when it is, e.g., window.addEventListener("spidereye-ready", callback)

In the following examples, I'll use the syntax for the "spidereye" npm package but you can replace "SpiderEye" with "window._spidereye" and it should still work.

So to call our bridge object in JavaScript, do something like this:

JavaScript
const parameters = {
    value: 2,
    power: 6,
};

SpiderEye.invokeApi("UiBridge.power", parameters, result => {
    if (!result.success) {
        console.error(result.error);
    } else {
        console.log(result.value);
    }
});
Execute Something in the Webview

Use InvokeAsync to execute something in the webview. The id parameter is the same value as you use to register the handler in the webview (see example) and the data parameter can be any JSON serializable value you want to pass.

C#
Task InvokeAsync(string id, object data)
Task<T> InvokeAsync<T>(string id, object data)

As before, I am using the "spidereye" npm package for this example, same notes as from the previous section apply.

First, we need to register the handler on the JavaScript side like so:

JavaScript
SpiderEye.addEventHandler("gimmeSomeData", data => {
    console.log(data);
    return "I got some data: " + data;
});

You don't have to return something in the handler, but you can. In .NET, use InvokeAsync if you don't have/need a response and InvokeAsync<T> if you expect a response.

On the .NET side, you can then call that handler, like so (assuming you have a Window instance named window):

C#
string result = await window.Bridge.InvokeAsync<string>("gimmeSomeData", 42);
// result should now be "I got some data: 42"

In JavaScript, you can also remove that handler again by calling:

JavaScript
SpiderEye.removeEventHandler("gimmeSomeData");

The StatusIcon Class

With the StatusIcon class, you can add a status icon with a menu for your app. You may use it like this (irrelevant app configuration is omitted here):

C#
using (var statusIcon = new StatusIcon())
{
    var menu = new Menu();
    var exitItem = menu.MenuItems.AddLabelItem("Exit");
    exitItem.Click += (s, e) => Application.Exit();
    
    statusIcon.Icon = AppIcon.FromFile("icon", ".");
    statusIcon.Title = "My Status Icon App";
    statusIcon.Menu = menu;

    Application.ExitWithLastWindow = false;
    Application.Run();
}

The AppIcon Class

This class represents an icon for your application. It is made in a way that is easy to use no matter which platform you are on. You can create an instance from a file:

C#
static AppIcon FromFile(string iconName, string directory)
static AppIcon FromFile(string iconName, string directory, bool cacheFiles)

or from a resource embedded in an assembly:

C#
static AppIcon FromResource(string iconName, string baseName)
static AppIcon FromResource(string iconName, string baseName, Assembly assembly)

For example, to create an icon from a file:

C#
var icon = AppIcon.FromFile("icon", ".");

It will look for a file with the name "icon" in the directory where the application lies (i.e., beside your executable). As for the file extension, it'll look for "icon.ico" on Windows, "icon.png" on Linux and "icon.icns" on macOS. The ico and icns format can include multiple resolutions in the same file but png doesn't. So on Linux, it may make sense to provide multiple files scaled to the wanted resolutions (for better quality). The AppIcon class will look for file names in formats like:

icon.png
icon32.png
icon-32.png
icon_32.png
icon-32x32.png
icon-32-32.png
icon-32_32.png

Where the numbers state the resolution as Width by Height (single number means the icon is square).

Dialogs

The library also includes classes to show dialogs.

The message box:

C#
MessageBox.Show("Message", "Title", MessageBoxButtons.Ok)

The save file dialog:

C#
var dialog = new SaveFileDialog
{
    Title = "My Save Dialog",
    InitialDirectory = "/Some/Directory",
    FileName = "SaveFile.png",
    OverwritePrompt = true,
};

var result = dialog.Show();
if (result == DialogResult.Ok)
{
    string selectedFile = dialog.FileName;
}

The open file dialog:

C#
var dialog = new OpenFileDialog
{
    Title = "My Open Dialog",
    InitialDirectory = "/Some/Directory",
    FileName = "OpenFile.png",
    Multiselect = false,
};

var result = dialog.Show();
if (result == DialogResult.Ok)
{
    string selectedFile = dialog.FileName;
}

In Action

Windows:

Image 1

Linux (Manjaro, KDE Plasma):

Image 2

macOS:

Image 3

Further Notes

The SpiderEye library is open source (Apache-2.0) and available on Github.

If you encounter any bugs or have a feature request, please file an issue there.

There are also complete examples, installation instructions, project templates (C#, VB.NET, F#) and more.

History

  • 30th November 30, 2019 - Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)