UPDATE 2019-12-17: There’s a new version of ElectronCGI, you can find out more about it here.
It was a very exciting moment when .Net Core first came out and it had support for Linux, Mac and Windows.
Since then it has matured to a point where the ASP.NET Core is now one the fastest frameworks you can use to serve requests on the web.
Although all is good on the web side of things, the desktop hasn’t been given the same amount of attention.
Even though .Net Core 3.0 will support WPF and WinForms that still leaves out Linux and Mac.
This blog post is about a cross-platform way of developing desktop applications using .Net Core and Electron called ElectronCGI. It does not rely on running a web server or having the .Net Core code pre-compiled.
To show you how simple using ElectronCGI is here’s how you can configure a NodeJs/Electron UI to run code in a .Net Core console application. In this example a “request” with type “greeting” and a string argument is sent to a .Net application which responds with a string that contains the greeting.
NodeJs/Electron after adding the electron-cgi npm package:
const { ConnectionBuilder } = require('electron-cgi');
const connection = new ConnectionBuilder()
.connectTo('dotnet', 'run', '--project', 'NetCoreProject')
.build();
connection.send('greeting', 'John', greeting => {
console.log(greeting); // will print "Hello John!"
});
In a .Net console application after adding the ElectronCgi.DotNet nuget package:
using ElectronCgi.DotNet;
//...
static void Main(string[] args)
{
var connection = new ConnectionBuilder()
.WithLogging()
.Build();
// expects a request named "greeting" with a string argument and returns a string
connection.On<string, string>("greeting", name =>
{
return "Hello " + name;
});
// wait for incoming requests
connection.Listen();
}
That’s all you need to start. Here’s a video that shows how easy it is to setup and how simple the development workflow is.
Also, here are the github links for the electron-cgi node npm package and the ElectronCgi.DotNet nuget package.
Why
Even though there already are a few ways to create Electron/NodeJs applications that run .Net code, I felt these were too involved and that they could provide a better development experience.
Probably the most popular way of having an UI running on Electron and running .Net code is Electron.NET. The way it works is by having the .Net code run in a full ASP.NET application.
The Electron app displays web pages rendered server-side by ASP.NET. Also, some of Electron’s functionality is “exposed” to the ASP.NET application by using web sockets to send requests/commands that are initiated from the ASP.NET code. For example, it is possible to open a new Electron window from an ASP.NET controller this way.
The biggest drawback I see with this approach is that even though the goal is to create a desktop application we still have to handle a lot of tasks that should be foreign in this scenario. For example, when you create a new ASP.NET Core MVC application it comes with cookie policies, https configuration, HSTS, Routing, etc. In a desktop application scenario that makes no sense and just ends up getting in the way. Ideally, we just want to write the UI using HTML, CSS and a bit of Javascript and be able to invoke .Net code in response to user’s actions.
The other alternative in Electron/NodeJs is Edge.js. It works by enabling .Net code to run in-process in Node. Although technologically this is impressive, it has a few requirements that make using it a little bit hard.
This is how you can do an hello world using Edge.js:
var edge = require('edge');
var helloWorld = edge.func(function () {/*
async (_) => {
return "Hello World";
}
*/});
helloWorld('', function (error, result) {
if (error) throw error;
console.log(result); // will log "Hello world"
});
Yes, the C# code is inside / /. If you are using ES6 you can use template strings, but still, you won’t get intellisense. There are also ways to “bring” a method from a precompiled DLL, however there’s a requirement that the method has a specific signature (Func<object,Task<object>>
).
I should also mention a fully native solution. The one that comes to mind is Qt. Qt has a lot going on for it. It is used by some very well know names (Tesla Model S’ dashboards run Qt for example).
Qt has “bindings” for several languages, including C#/.Net.
The only problem with Qt is that if you are thinking about doing something commercial with it, it becomes very costly. To the tune of more than 5K per developer, per year.
Given all that, I think there’s space for one more way of doing cross-platform desktop applications.
How does it work
ElectronCGI draws inspiration from how the first dynamic web requests were made a reality in the early days of the web.
In the early days the only things that a web server was able to serve were static web pages. To serve dynamic pages the idea of having an external executable take in a representation of the web request and produce a response was put forward.
The way that executable got the web request’s headers was through environment variables and the request’s body was sent through the standard input stream (stdin).
After processing the request the executable would send the resulting html back to the web server through the standard output stream (stdout). This way of doing things was called CGI – Common Gateway Interface.
This mechanism is available in all operating systems and works perfectly well, is super fast and very easy to use. For example, in bash when you write something like ls | more
you are redirecting the stdout of ls
to more
‘s stdin.
That’s exactly what ElectronCGI
takes advantage of. Using NodeJs and .Net as an example (these are the only implementations right now, but there’s no reason why this wouldn’t work on other runtimes/languages), when a connection is created in NodeJs to a .Net console application, ElectronCGI will launch the .Net application and will grab hold of it’s stdin
and stdout
streams. It will also keep the .Net application running until the connection is closed.
Every time a “request” is sent (i.e. connection.send('requestType', args, callbackFn)
) the request is serialised (as JSON) and written to the .Net application’s stdin
stream. After handling the request the .Net Core application sends a response back through stdout
(i.e. connection.On<ArgType, ReturnType>("requestType", handlerFunction)
).
ElectronCGI takes care of all this so that in the end the only thing that you need to do is send requests from NodeJs and provide request handlers for those requests in .Net.
Using stdin/stdout as the communication channel provides very little overhead (I performed a quick test on an i7-7700K and was able to sequentially send 18K requests and receive their responses in one second).
Benefits
Have I mentioned that with ElectronCGI and .Net Core you can create applications that run in Linux, Windows and Mac and use C#?
Also, if you want, since the nuget package for .Net (ElectronCgi.DotNet
) targets .Net Standard 2.0
you can use it with the full .Net Framework (from version 4.6.1). If you do this you’ll only be able to run on Windows though, but it might still be interesting if you are a .Net web developer and want to use HTML and CSS to build your UI while reusing existing .Net code you might have.
Speaking of reusing existing .Net code, even though there’s a requirement that the .Net application you connect to is a console app, there’s no restrictions on what that console application adds as dependencies. That means that you can bring any nuget package or reference other .Net projects you might want to use.
The development experience can also be quite good. If you establish a connection from an Electron app using dotnet run
, for example:
const { ConnectionBuilder } = require('electron-cgi');
const connection = new ConnectionBuilder()
.connectTo('dotnet', 'run', '--project', 'PathToDotNetProject')
.build();
When taking this approach if you make changes in the .Net project the only thing you need to do to see them take effect is to refresh the page (you can leave this enabled and even access the chrome dev tools in Electron). When you refresh, a new connection is created and that causes dotnet run
to be executed which will compile and run the project if there are any pending changes.
So you can imagine a development experience where you can make changes to your UI and/or your .Net code and see them be applied just by doing a “Ctrl+R”. Also, thanks to the good work that the .Net team in terms of speeding up compilation time it really feels seamless.
And when you are done you can always “connect” to the published, self-contained executable for an extra performance boost and so that your application does not depend on the .Net SDK.
Also, if you need to debug the .Net code you can just use the attach functionality in Visual Studio Code (or full Visual Studio) to attach to the running process and add breakpoints.
Early days
There still are a few rough edges with ElectronCGI. Particularly in how errors are handled. Right now you can enable logging in .Net (new ConnectionBuilder().WithLogging("pathToLogFile")
) so that you can see if something went wrong on the .Net side of things.
The exception messages are quite descriptive, for example:
ElectronCgi.DotNet.HandlerFailedException: Request handler for request of type 'division' failed. ---> System.DivideByZeroException: Attempted to divide by zero.
Whenever there’s an unhandled exception in .Net the connection will be “lost”. In NodeJs/Electron the connection’s onDisconnect method is invoked. You can use it for example to restart the connection:
const { ConnectionBuilder } = require('electron-cgi');
let _connection = new ConnectionBuilder().connectTo('dotnet', 'run', '--project', 'DotNetCalculator').build();
_connection.onDisconnect = () => {
alert('Connection lost, restarting...');
_connection = new ConnectionBuilder().connectTo('dotnet', 'run', '--project', 'DotNetCalculator').build();
};
Keep in mind that you can maintain state in .Net since the executable starts when a connection is made and is kept running (listening for requests) until the connection is closed (connection.close()
in NodeJs/Electron) or there’s an exception. When this happens that state is lost.
This behavior might not be ideal for some people. What I feel inclined to do is to have the error surface in NodeJs callback’s first argument for a request, much like is custom in Asynchronous APIs in Node. For example:
connection.send('requestType', args, (err, data) => {
if (err){
//handle error
return;
}
//otherwise handle the data
});
Another thing that might be useful is to add the ability to initiate a request in either end of the connection. Right now we can create a connection from NodeJs to a .Net application and send request from Node to .Net. There’s no reason why we couldn’t add the ability to send requests from .Net to Node after a connection is established.
Another aspect that needs improvement, this one particular to the .Net implementation, is the way requests are currently being handled. Right now, while a request handler is being served the stdin
is not monitored. That means that if you have a request handler that takes a long time to run, subsequent requests originating from Node will be queued in the .Net application until the long running request finishes.
Also, having more options on how to register handlers for requests would be welcomed. Currently there are 4 different ways to register a handler for a request type:
//handler for request of type requestType with no arguments or return value
void On(string requestType, Action handler)
//handler for request of type requestType with argument of type T and no return value
void On<T>(string requestType, Action<T> handler)
//handler for request of type requesType with argument of TIn and return type TOut
void On<TIn, TOut>(string requestType, Func<TIn, TOut> handler)
//async handler for request of type requestType with argument of type T and no return value
void OnAsync<T>(string requestType, Func<T, Task> handler)
//async handler for request of type requestType with argument of type T and return type TOut
void OnAsync<TIn, TOut>(string requestType, Func<TIn, Task<TOut>> handler)
It is possible to use dynamic
for the argument type.
In terms of other things that are missing documentation is certainly one of them. Specifically on how to use ElectronCGI in Electron with React, Angular, Vue and (why not?) Blazor and any other web UI framework I might be missing.