I wrote about using the open-source tool Electron.NET to implement cross-platform desktop applications for Windows, macOS, and Linux platforms in a previous blog post. By providing a wrapper around a standard Electron application with an embedded ASP.NET Core website, Electron.NET allows C# developers to target multiple platforms without coding in JavaScript.

This blog post illustrates how you can apply the same techniques to create Blazor desktop applications. Topics covered include:

  • Modifying the default Blazor Server application to use Electron.NET
  • Using Visual Studio Code to debug Blazor pages
  • Implementing native UI elements such as message boxes
  • Adding third-party Blazor controls
  • Building deployment media for other platforms

Try ComponentOne Studio

Download the latest version of ComponentOne Studio Enterprise

Download Now!

Background

Electron is a framework that supports the development of desktop applications using web technologies such as the Chromium rendering engine and the Node.js runtime. Supported operating systems include Windows, macOS, and Linux. It leverages familiar standards such as HTML, CSS, and JavaScript.

Electron.NET allows .NET developers to invoke native Electron APIs using C#. It consists of two components:

  • A NuGet package that adds Electron APIs to an ASP.NET Core or Blazor project.
  • A .NET Core command-line extension that builds and launches applications for Windows, macOS, and Linux platforms.

Electron.NET requires the prior installation of the following software:

Let's get started by building the canonical Blazor application from the command line.

Create a Blazor Server Application

For this exercise, I'm using Visual Studio Code running on a Mac. First, open a terminal window and run the following commands to create a new project called BlazorApp.

mkdir BlazorApp  
cd BlazorApp  
dotnet new blazorserver  
code .  

When prompted by Visual Studio Code, say Yes to load the required assets for the project. Press F5 to build and run the application, then open a browser on localhost:5001 to view the default Blazor page. Close the page, return to VS Code, and stop debugging.

Electronize It!

Now let's turn our boilerplate Blazor project into a desktop Electron application. This involves adding a NuGet package to the project file, inserting some initialization code, and installing a command-line tool to perform builds. First, open the file BlazorApp.csproj and insert a package reference for the Electron.NET API–hosted on nuget.org:

<ItemGroup>  
  <PackageReference Include="ElectronNET.API" Version="11.5.1" />  
</ItemGroup>  

Save the file, then restore packages when prompted to do so by VS Code. This restore gives you immediate access to Intellisense for subsequent modifications to the code.

Next, edit Program.cs and insert a using statement for the newly added package:

using ElectronNET.API; 

Locate the static method CreateHostBuilder and insert the following two lines before the call to UseStartup:

webBuilder.UseElectron(args);  
webBuilder.UseEnvironment("Development");  

The first line is necessary. The second is convenient during development, as it allows detailed error messages to be displayed.

Edit Startup.cs and insert the following using statement:

using ElectronNET.API;  

Locate the Configure method and add the following lines to the end of its body:

if (HybridSupport.IsElectronActive)  
{  
    CreateWindow();  
} 

Finally, add the following method to the Startup class to create the main Electron window:

private async void CreateWindow()  
{  
    var window = await Electron.WindowManager.CreateWindowAsync();  
    window.OnClosed += () => {  
        Electron.App.Quit();  
    };  
}  

Since our application consists of a single window, we handle the OnClosed event to terminate the application if the user closes the window (instead of choosing Quit or Exit from the main menu).

Install the Command Line Tool

In addition to the runtime package that you previously referenced in the project file, Electron.NET provides a command line tool to perform build and deployment tasks. In VS Code, create a new terminal window and type:

dotnet tool install ElectronNET.CLI -g  

This one-time step will install a .NET Core global tool that implements a command named electronize. To see a list of tools/commands installed on your system, type the following:

dotnet tool list -g

Run the Electronized Application

After installing the command-line tool, type these lines in the VS Code terminal window:

electronize init  
electronize start 

The first line is a one-time step that creates a manifest file named electron.manifest.json and adds it to your project. Use the second line to launch the Electron application (don't use F5, as this will only open the Blazor application in the browser). Note that the content now appears in an application window, not a browser.

Blazor application

Note the default Electron application menu. On a Mac, this menu is not part of the window itself, but anchored to the top of the screen.

Electron dot NET application

Debug Blazor Code

Since we launched our application with an external command instead of F5, we need to attach a debugger to the running Blazor process. With the application window open, go to VS Code, open Pages/Counter.razor, and set a breakpoint on line 14. Click Run on the activity bar, select .NET Core Attach from the dropdown control, then click the adjacent icon to reveal a list of processes.

Start typing the application's name (BlazorApp) into the list and select the one remaining item. (If by some chance there are multiple processes still displayed, pick the one with the most significant value of electronWebPort.)

Attaching to a-process for debugging in VS Code

In the application window, visit the Counter page and click the button to trigger the breakpoint. Continue execution, close the application window, and note that the debugger is automatically disconnected.

Customize the Sample Data Page

To illustrate the cross-platform capabilities of Electron.NET, let's replace the default weather data page with a list of active system processes. Later on, we'll build a Linux version and observe the differences on that platform.

First, create a new file in the Data folder named ProcessService.cs and enter the following code:

using System;  
using System.Linq;  
using System.Threading.Tasks;  
using System.Diagnostics;

namespace BlazorApp.Data  
{  
    public class ProcessService  
    {  
        public Task<Process[]> GetProcessesAsync()  
        {  
            return Task.FromResult(Process.GetProcesses().Where(p => !String.IsNullOrEmpty(p.ProcessName)).ToArray());  
        }  
    }  
}  

Next, register this service by opening Startup.cs and adding the following line to the end of the ConfigureServices method:

services.AddSingleton<ProcessService>();  

Create a new file in the Pages folder named Processes.razor and paste the following code:

@page "/processes"

@using BlazorApp.Data  
@using System.Diagnostics  
@inject ProcessService ProcessService

<h1>Processes</h1>

<p>This component displays a list of system processes.</p>

@if (processes == null)  
{  
    <p><em>Loading...</em></p>  
}  
else  
{  
    <table class="table">  
        <thead>  
            <tr>  
                <th>Id</th>  
                <th>Process Name</th>  
                <th>Physical Memory</th>  
            </tr>  
        </thead>  
        <tbody>  
            @foreach (var process in processes)  
            {  
                <tr>  
                    <td>@process.Id</td>  
                    <td>@FormatName(process)</td>  
                    <td>@process.WorkingSet64</td>  
                </tr>  
            }  
        </tbody>  
    </table>  
}

@code {  
    private System.Diagnostics.Process[] processes;

    protected override async Task OnInitializedAsync()  
    {  
        processes = await ProcessService.GetProcessesAsync();  
    }

    private string FormatName(System.Diagnostics.Process process)  
    {  
        string name;  
        const int maxLength = 30;  
        try  
        {  
            name = process.MainModule.ModuleName;  
        }  
        catch  
        {  
            name = process.ProcessName;  
        }  
        if (name.Length > maxLength)  
        {  
            name = name.Substring(0, maxLength) + "...";  
        }  
        return name;  
    }  
} 

This will display a table of named processes with columns for the id number, process name, and the amount of physical memory allocated for the process. Note the use of the FormatName function. On some platforms, the process may be truncated so the module name is favored, with the process name serving as a fallback value. In either case, the result string is truncated to 30 characters.

Lastly, edit the file Shared/NavMenu.razor and replace the final list element with the following:

<li class="nav-item px-3">  
    <NavLink class="nav-link" href="processes">  
        <span class="oi oi-cog" aria-hidden="true"></span> Processes  
    </NavLink>  
</li> 

Electron.NET supports a watch mode where it will monitor your changes and automatically rebuild and relaunch your application. To invoke the watch mode, run the following command:

electronize start /watch  

Now save all of your changes to the project. After the application restarts, click the Processes link on the left, and you will see a display similar to the following:

Blazor application running processes

Add the Detail View

In a typical CRUD application, items in a list contain a link to a detail page where users can view the item in greater detail or modify it if appropriate. Let's create a simple view for an individual process. First, add a new file to the Pages folder named Process.razor and insert the following markup:

@page "/process/{Id:int}" 

@using BlazorApp.Data  
@using ElectronNET.API  
@using ElectronNET.API.Entities  
@inject ProcessService ProcessService  
@inject NavigationManager UriHelper

<h1>Process view</h1>

<p>This component displays details for a single system process.</p>

@if (process == null)  
{  
    <p><em>Loading...</em></p>  
}  
else  
{  
    <div>  
        <dl class="row">  
            @foreach (var property in @PropertyList.Select(name => typeof(System.Diagnostics.Process).GetProperty(name)))  
            {  
            <dt class="col-sm-4">  
                @property.Name  
            </dt>  
            <dd class="col-sm-8">  
                @property.GetValue(process)  
            </dd>  
            }  
        </dl>  
    </div>  
    <div>  
        <hr />  
        <button class="btn btn-danger" @onclick="@(() => Delete())">Kill Process</button>  
        <a class="btn btn-light" href="processes">Back to List</a>  
    </div>  
}

@code {  
    private System.Diagnostics.Process process;  
    private static readonly string[] PropertyList = new[]  
    {  
        "Id", "ProcessName", "PriorityClass", "WorkingSet64"  
    };

    [Parameter]  
    public int Id { get; set; }

    protected override void OnParametersSet()  
    {  
        process = System.Diagnostics.Process.GetProcessById(Id);  
    }

    private async Task Delete()  
    {  
        await Task.Run(() =>  
        {  
            process.Kill();  
            UriHelper.NavigateTo("processes");  
        });  
    }  
} 

The first line specifies the routing for the detail page with an integer parameter that denotes the process id number. The string array PropertyList defines the list of Process object properties displayed in the detail view. Rather than hard code these strings in the page markup, we use reflection to derive property names and values at run time.

To link the detail view to individual items on the Processes page, edit Pages/Processes.razor and replace the expression:

@process.Id 

with this anchor tag:

<a href="process/@process.Id">@process.Id</a> 

Run the application. Note that the _Id_ column now contains hyperlinks that navigate to a page similar to this one:

Blazor application single process

You may have noticed that the onclick handler for the Kill Process button unconditionally calls the Kill method without giving the user a chance to think it over and cancel the operation. Let's rewrite the Delete method to use the ShowMessageBoxAsync API of Electron.NET to display a platform-specific confirmation dialog box to the user:

private async Task Delete()  
{  
    const string msg = "Are you sure you want to kill this process?";  
    MessageBoxOptions options = new MessageBoxOptions(msg);  
    options.Type = MessageBoxType.question;  
    options.Buttons = new string[] {"No", "Yes"};  
    options.DefaultId = 1;  
    options.CancelId = 0;  
    MessageBoxResult result = await Electron.Dialog.ShowMessageBoxAsync(options);

    if (result.Response == 1)  
    {  
        await Task.Run(() =>  
        {  
            process.Kill();  
            UriHelper.NavigateTo("processes");  
        });  
    }  
}

This way, if the user cancels, the detail page remains current. Otherwise, the application redirects to the Processes page after killing the process.

Electron dot NET application

Add Third-Party Controls

As with any Blazor project, you can add third-party controls to an Electron.NET application. Let's replace the Counter page with a ComponentOne calendar control. First, add the following package reference to the .csproj file:

<PackageReference Include="C1.Blazor.Calendar" Version="3.1.20203.*" />

Edit _Pages/Host.cshtml and insert the following stylesheet references before the closing tag:

<link rel="stylesheet" href="/_content/C1.Blazor.Core/styles.css" />  
<link rel="stylesheet" href="/_content/C1.Blazor.Calendar/styles.css" />  

Also, in the same file, insert the following script references before the closing tag:

<script src="/_content/C1.Blazor.Core/scripts.js"></script>  
<script src="/_content/C1.Blazor.Calendar/scripts.js"></script>

Create a new file in the Pages folder named Calendar.razor and paste the following code:

@page "/calendar"

@using C1.Blazor.Calendar

<h1>Calendar</h1>

<C1Calendar></C1Calendar>

Lastly, edit the file Shared/NavMenu.razor and replace the second list element with the following:

<li class="nav-item px-3">  
    <NavLink class="nav-link" href="calendar">  
        <span class="oi oi-calendar" aria-hidden="true"></span> Calendar  
    </NavLink>  
</li>

Save all of your changes. Note that the build process will create a 30-day trial license for ComponentOne Studio Enterprise. Click the Calendar link on the left, and you should see a display similar to the following:

Blazor calendar control

Build for Other Platforms

To build installation media for other platforms, run the following command in a terminal window:

electronize build /target xxx /PublishReadyToRun false 

Where xxx is one of win, linux, or osx. Output goes to the bin/Desktop folder, for example:

  • BlazorApp Setup 1.0.0.exe (windows)
  • BlazorApp-1.0.0.AppImage (linux)
  • BlazorApp-1.0.0.dmg (osx)

Note that the Windows executable is a setup program, not the application itself. Users can only build OSX targets on a Mac, but Windows/Linux targets can be built on any platform. To change the version number, copyright notice, and other attributes, edit electron.manifest.json before building the installation media.

Here's what the application looks like running on Linux:

Blazor application Linux

Conclusion and Sample Code

Electron.NET is an open-source tool that adds value to Blazor by providing C# developers with a vehicle for delivering cross-platform desktop applications for Windows, Linux, and macOS. It is also compatible with third-party components such as the Blazor controls in ComponentOne Studio Enterprise.

The source code for the completed project described in this article is available on GitHub at https://github.com/jjuback/BlazorApp.

Try ComponentOne Studio

Download the latest version of ComponentOne Studio Enterprise

Download Now!