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:
Handle = Gtk.Window.Create(GtkWindowType.Toplevel);
IntPtr scroller = Gtk.Window.CreateScrolled(IntPtr.Zero, IntPtr.Zero);
Gtk.Widget.ContainerAdd(Handle, scroller);
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:
Handle = AppKit.Call("NSWindow", "alloc");
var style = GetStyleMask(config.CanResize);
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:
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:
namespace SpiderEye.Example.Simple.Core
{
public abstract class ProgramBase
{
protected static void Run()
{
using (var window = new Window())
{
Application.ContentProvider = new EmbeddedContentProvider("App");
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:
<ItemGroup>
<EmbeddedResource Include="App\**">
<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:
using System;
using SpiderEye.Windows;
using SpiderEye.Example.Simple.Core;
namespace SpiderEye.Example.Simple
{
class Program : ProgramBase
{
[STAThread]
public static void Main(string[] args)
{
WindowsApplication.Init();
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
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
.
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.
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.
static OperatingSystem OS { get; }
The OS
property simply returns on which operating system you are currently running.
Methods
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.
static void Exit()
With Exit
, you can close the whole application and the Run
call will return.
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.
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
).
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"
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.
bool EnableScriptInterface { get; set; }
With EnableScriptInterface
, you can set if the webview is allowed to talk to the .NET side.
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
void Show()
void Close()
Show
and Close
are pretty self explanatory, they show or close the window.
void SetWindowState(WindowState state)
With SetWindowState
, you can change the window state to e.g., minimized or maximized.
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).
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.
public class UiBridge
{
public async Task RunLongProcedureOnTask()
{
await Task.Delay(TimeSpan.FromSeconds(10));
}
public SomeDataModel GetSomeData()
{
return new SomeDataModel
{
Text = "Hello World",
Number = 42,
};
}
public double Power(PowerModel model)
{
return Math.Pow(model.Value, model.Power);
}
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:
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.
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:
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
):
string result = await window.Bridge.InvokeAsync<string>("gimmeSomeData", 42);
In JavaScript, you can also remove that handler again by calling:
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):
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:
static AppIcon FromFile(string iconName, string directory)
static AppIcon FromFile(string iconName, string directory, bool cacheFiles)
or from a resource embedded in an assembly:
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:
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:
MessageBox.Show("Message", "Title", MessageBoxButtons.Ok)
The save file dialog:
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:
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:
Linux (Manjaro, KDE Plasma):
macOS:
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