Building Cross-Platform Desktop Apps with Electron.NET
Tags: webdev, devops, dotnet, csharp
Published: 2023-11-03 15:38:56

---
canonical_url: https://developer.mescius.com/blogs/building-cross-platform-desktop-apps-with-electron-dot-net
description: Develop and deploy an application with Electron.NET. See more from ComponentOne today.
---

This article provides a walkthrough for developing and deploying an application with Electron.NET.

Topics covered include:

*   Modifying the default ASP. NET Core application to use Electron.NET
*   Using Visual Studio Code to debug ASP. NET Razor pages
*   Implementing native UI elements such as message boxes, system file dialogs, and custom application menus
*   Adding third-party MVC controls
*   Building deployment media for other platforms

### What is Electron?

[Electron](http://electronjs.org/) 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. Chances are you have used at least one of these applications, which were all developed using Electron:

*   Visual Studio Code
*   Slack
*   Discord
*   Skype
*   GitHub Desktop
*   Twitch

Electron leverages familiar standards such as HTML, CSS, and JavaScript. But what if you are a .NET developer accustomed to working in C#? That's where Electron.NET comes in.

### What is Electron.NET?

[Electron.NET](https://github.com/ElectronNET/Electron.NET) is a wrapper around a "normal" Electron application with an embedded ASP. NET Core application. It is an open-source project that allows .NET developers to invoke native Electron APIs using C#. Electron.NET consists of two components:

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

Electron.NET requires prior installation of the following software:

*   [Node.js](https://nodejs.org/)
*   [.NET Core 3.1](https://dotnet.microsoft.com/download/dotnet-core/3.1 "https://dotnet.microsoft.com/download/dotnet-core/3.1") (or higher)

One of our developers, John Juback, relied on Electron.NET to develop the [C1DataEngine Workbench](https://developer.mescius.com/componentone/docs/services/online-dataengine/visual-etl-data-workbench.html), a cross-platform tool that supports the creation and visualization of data analysis workspaces managed by the [ComponentOne](https://developer.mescius.com/componentone) DataEngine library for .NET Standard. He initially planned to make this a standard Electron application that communicated with the library via shell commands that called out to a .NET Core global tool. However, once he discovered [Electron.NET](http://electron.net/ "http://Electron.NET"), he was able to eliminate the shell commands and call the library directly.

![Electron.NET](//cdn.mescius.io/umb/media/svvabdxe/image1_edited.jpg)

### Create an ASP. NET Core Web Application

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

```
    mkdir Processes  
    cd Processes  
    dotnet new webapp  
    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, opening a browser on the default ASP. NET Core welcome page, hosted on localhost:5001\. Close the page, return to VS Code, and stop debugging.

#### Electronize It!

Now, let's turn our boilerplate ASP. NET Core project into an Electron application. This step 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 _Processes.csproj_ and insert a package reference for the Electron.NET API hosted on [nuget.org](https://www.nuget.org):

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

Save the file, then restore packages when prompted to do so by VS Code. This action 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 statements:

```
    using ElectronNET.API;  
    using ElectronNET.API.Entities;  
    using System.Runtime.InteropServices; 
```

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. The second line is used to launch the Electron application (don't use F5, as this will only open the ASP. NET Core application in the browser). Note that the content now appears in an application window, not a browser.

![Electron.NET](//cdn.mescius.io/umb/media/rqcl5lw5/image2_edited.jpg)

Also, note the default Electron application menu. On Macs, this menu is not part of the window itself but anchored to the top of the screen.

![Electron.NET](//cdn.mescius.io/umb/media/cpcbkt1e/image3-3.png)

At the time of this writing, there was a script error in the Bootstrap module installed when you created the project.

To see it, open the View menu and click Toggle Developer Tools.

```
    Uncaught TypeError: Cannot read property 'fn' of undefined  
        at util.js:55  
        at bootstrap.bundle.min.js:6  
        at bootstrap.bundle.min.js:6 
```

Fortunately, there is an easy fix. First, open the Electron menu and click Quit Electron to close Developer Tools and the application window. In VS Code, open _Pages/Shared/_Layout.cshtml_ and insert the following line before the Bootstrap script tag:

```
    <script>window.$ = window.jquery = module.exports;</script>  
```

Save this change, then type _electronize start_ in the terminal window to rerun the application. Open Developer Tools and note that the error message is gone. Keep the application window open for the next step.

### Debug ASP. NET Code

Since we launched our application with an external command instead of F5, we need to attach a debugger to the running ASP. NET process. With the application window open, go to VS Code, open _Pages/Index.cshtml.cs_, and set a breakpoint on the **OnGet** handler.

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. Type the name of the application (Processes) into the list and select the one remaining item. (If by some chance there are multiple processes still displayed, pick the one with the largest value of _electronWebPort_.)

![Electron.NET](//cdn.mescius.io/umb/media/sgmhsqny/image4_edited.jpg)

In the application window, click Reload on the View menu, and the breakpoint will be hit. Continue execution, close the application window, and note that the debugger is automatically disconnected.

### Customize the Home Page

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

First, open _Pages/Index.cshtml.cs_ and add the following using statement for process APIs:

```
    using System.Diagnostics;  
```

Next, add the following property declaration to the **IndexModel** class:

```
    public List<Process> Processes { get; set; } 
```

Now add the following lines to the (initially empty) body of the **OnGet** handler:

```
    var items = Process.GetProcesses().Where(p => !String.IsNullOrEmpty(p.ProcessName));
    Processes = items.ToList();
```

Now, let's modify the corresponding Razor page markup to display the list of processes.

Open _Pages/Index.cshtml_ and replace the entire original `<div>` tag with the following lines:

```
    <div>  
         <table id="myTable" class="table table-sm table-striped">  
            <thead class="thead-dark">  
                <tr class="d-flex">  
                    <th scope="col" class="col-sm-2">Id</th>  
                    <th scope="col" class="col-sm-6">Process Name</th>  
                    <th scope="col" class="col-sm-4 text-right">Physical Memory</th>  
                </tr>  
            </thead>  
            <tbody>  
                @foreach (var item in Model.Processes)  
                {  
                    <tr class="d-flex">  
                        <td class="col-sm-2" scope="row">@item.Id</td>  
                        <td class="col-sm-6">@try{@item.MainModule.ModuleName}catch{@item.ProcessName}</td>  
                        <td class="col-sm-4 text-right">@item.WorkingSet64</td>  
                    </tr>  
                }  
            </tbody>  
        </table>  
    </div> 
```

This modification 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 inline _try/catch_ block. On some platforms, the process name may be truncated, so the module name is favored, with the process name serving as a fallback value.

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, the modified home page should look something like this:

![Electron.NET](//cdn.mescius.io/umb/media/2x5b431r/image5_edited.jpg)

### Add the Detail View

In a typical ASP. NET Core application, items in a list contain a link to a detail page where users can view the item in greater detail or modify it. Let's create a simple view for an individual process. First, add a new file to the _Pages_ folder named _View.cshtml_ and insert the following markup:

```
    @page  
    @model ViewModel  
    @{  
        ViewData["Title"] = "Process view";  
    }  
    <div>  
        <dl class="row">  
            @foreach (var property in @Model.PropertyList.Select(name => typeof(System.Diagnostics.Process).GetProperty(name)))  
            {  
            <dt class="col-sm-4">  
                @property.Name  
            </dt>  
            <dd class="col-sm-8">  
                @property.GetValue(Model.Process)  
            </dd>  
            }  
        </dl>  
    </div>  
    <div>  
        <hr />  
        <form method="post">  
            <input type="submit" value="Kill Process" class="btn btn-danger" />  
            <a class="btn btn-light" asp-page="./Index">Back to List</a>  
        </form>  
    </div>  
```

Next, add a corresponding code-behind file named _View.cshtml.cs_ and insert the following code:

```
    using System.Diagnostics;  
    using System.Threading.Tasks;  
    using Microsoft.AspNetCore.Mvc;  
    using Microsoft.AspNetCore.Mvc.RazorPages;  
    using Microsoft.Extensions.Logging;  
    using ElectronNET.API;  
    using ElectronNET.API.Entities;

    namespace Processes.Pages  
    {  
        public class ViewModel : PageModel  
        {  
            private readonly ILogger<ViewModel> _logger;

            public ViewModel(ILogger<ViewModel> logger)  
            {  
                _logger = logger;

                PropertyList = new string[] {  
                    "Id",  
                    "ProcessName",  
                    "PriorityClass",  
                    "WorkingSet64"  
                };  
            }  

            public Process Process { get; set; }

            public string[] PropertyList { get; set; }

            public void OnGet(int? id)  
            {  
                if (id.HasValue)  
                {  
                    Process = Process.GetProcessById(id.Value);  
                }  

                if (Process == null)  
                {  
                    NotFound();  
                }  
            }  
        }  
    } 
```

The string array **PropertyList** defines a list of **Process** object properties to be displayed in the detail view. Rather than hard-coding 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 home page, edit _Pages/Index.cshtml_ and replace the expression:

```
    @item.Id  
```

with this anchor tag:

```
    <a asp-page="./View" asp-route-id="@item.Id">@item.Id</a>  
```

Run the application as before and note that the _Id_ column contains hyperlinks that navigate to a page similar to this one:

![Electron.NET](//cdn.mescius.io/umb/media/u1obs1vl/image6_edited.jpg)

You may have noticed that the markup contains a submit button for an HTTP POST request. Let's finish the implementation of the detail page by adding the following method to the **ViewModel** class:

```
    public async Task<IActionResult> OnPostAsync(int? id)  
    {  
        if (id.HasValue)  
        {  
            Process = Process.GetProcessById(id.Value);  
        }  

        if (Process == null)  
        {  
            return NotFound();  
        }

        Process.Kill();  
        return RedirectToPage("Index");  
    }  
```

While this will undoubtedly do the job, it would be better to give the user a chance to think it over and cancel the operation. Let's replace the last two lines with the following code, which uses the **ShowMessageBoxAsync** API of Electron.NET to display a platform-specific confirmation dialog box to the user:

```
    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)  
    {  
        Process.Kill();  
        return RedirectToPage("Index");  
    }

    return Page();  
```

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

![Electron.NET](//cdn.mescius.io/umb/media/onrmjd2p/image7.png?rmode=max&width=673&height=333)

### Customize the Application Menu

Electron.NET provides a default application menu, as illustrated previously. This menu is the same default menu provided by the Electron framework itself. Unfortunately, there is no way to tweak the default menu (a limitation of Electron).

If you want to add a new command or remove a submenu that you don't need, you have no choice but to specify the entire menu structure from scratch. This task is further complicated by the differences between macOS and other platforms. On macOS, applications have their own menu to the left of the standard File/Edit/View menus.

```
    private void CreateMenu()  
    {  
        bool isMac = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);  
        MenuItem[] menu = null;

        MenuItem[] appMenu = new MenuItem[]  
        {  
            new MenuItem { Role = MenuRole.about },  
            new MenuItem { Type = MenuType.separator },  
            new MenuItem { Role = MenuRole.services },  
            new MenuItem { Type = MenuType.separator },  
            new MenuItem { Role = MenuRole.hide },  
            new MenuItem { Role = MenuRole.hideothers },  
            new MenuItem { Role = MenuRole.unhide },  
            new MenuItem { Type = MenuType.separator },  
            new MenuItem { Role = MenuRole.quit }  
        };

        MenuItem[] fileMenu = new MenuItem[]  
        {  
            new MenuItem { Role = isMac ? MenuRole.close : MenuRole.quit }  
        };

        MenuItem[] viewMenu = new MenuItem[]  
        {  
            new MenuItem { Role = MenuRole.reload },  
            new MenuItem { Role = MenuRole.forcereload },  
            new MenuItem { Role = MenuRole.toggledevtools },  
            new MenuItem { Type = MenuType.separator },  
            new MenuItem { Role = MenuRole.resetzoom },  
            new MenuItem { Role = MenuRole.zoomin },  
            new MenuItem { Role = MenuRole.zoomout },  
            new MenuItem { Type = MenuType.separator },  
            new MenuItem { Role = MenuRole.togglefullscreen }  
        };  

        if (isMac)  
        {  
            menu = new MenuItem[]  
            {  
                new MenuItem { Label = "Electron", Type = MenuType.submenu, Submenu = appMenu },  
                new MenuItem { Label = "File", Type = MenuType.submenu, Submenu = fileMenu },  
                new MenuItem { Label = "View", Type = MenuType.submenu, Submenu = viewMenu }  
            };  
        }  
        else  
        {  
            menu = new MenuItem[]  
            {  
                new MenuItem { Label = "File", Type = MenuType.submenu, Submenu = fileMenu },  
                new MenuItem { Label = "View", Type = MenuType.submenu, Submenu = viewMenu }  
            };  
        }

        Electron.Menu.SetApplicationMenu(menu);  
    }

    private async void CreateWindow()  
    {  
        CreateMenu(); // add this line  
        var window = await Electron.WindowManager.CreateWindowAsync();  
        window.OnClosed += () => {  
            Electron.App.Quit();  
        };  
    } 
```

Note the use of predefined menu roles to specify the appropriate actions and shortcut keys for the target platform. Also, because of the way Electron.NET serializes the argument passed to **SetApplicationMenu**, you need to build the entire menu structure using an array initializer. You can't append MenuItem instances to an empty array and then pass that to the menu API.

Now, when you run the application on a Mac, the menu bar will have three submenus named Electron, File, and View.

Electron.NET also supports native system file dialogs. Let's modify the File menu definition to add a Save As command that prompts the user for a filename and outputs the current process list to that file in comma-delimited format.

```
    MenuItem[] fileMenu = new MenuItem[]  
    {  
        new MenuItem { Label = "Save As...", Type = MenuType.normal, Click = async () => {  
            var mainWindow = Electron.WindowManager.BrowserWindows.First();  
            var options = new SaveDialogOptions() {  
                Filters = new FileFilter[] { new FileFilter{ Name = "CSV Files", Extensions = new string[] { "csv" } }  
            }};  
            string result = await Electron.Dialog.ShowSaveDialogAsync(mainWindow, options);  
            if (!string.IsNullOrEmpty(result))  
            {  
                string url = $"http://localhost:{BridgeSettings.WebPort}/SaveAs?path={result}";  
                mainWindow.LoadURL(url);  
            }  
        }},  
        new MenuItem { Type = MenuType.separator },  
        new MenuItem { Role = isMac ? MenuRole.close : MenuRole.quit }  
    };  
```

Instead of specifying one of the predefined menu roles, we set the menu type to _normal_ and provide an asynchronous **Click** handler. The **ShowSaveDialogAsync** API opens a native file dialog using the specified options, in this case, a filter for files with the .csv extension.

If the user does not cancel the dialog, the API returns the full path to the selected file. This path is used as an argument to the _SaveAs_ Razor page, which contains an **OnGetAsync** handler that outputs comma-delimited text:

```
    public async Task<IActionResult> OnGetAsync(string path)  
    {  
        System.IO.StringWriter writer = new System.IO.StringWriter();  
        writer.WriteLine("Id,Process Name,Physical Memory");  

        var items = Process.GetProcesses().Where(p => !String.IsNullOrEmpty(p.ProcessName)).ToList();  
        items.ForEach(p => {  
            writer.Write(p.Id);  
            writer.Write(",");  
            writer.Write(p.MainModule.ModuleName);  
            writer.Write(",");  
            writer.WriteLine(p.WorkingSet64);  
         });

        await System.IO.File.WriteAllTextAsync(path, writer.ToString());  
        return RedirectToPage("Index");  
    } 
```

![Electron.NET](//cdn.mescius.io/umb/media/gsbfjzbd/image8_edited.jpg)

### Add Third-Party Controls

As with any regular ASP. NET Core website, you can add third-party controls to an Electron.NET application. Let's replace the default privacy page with a ComponentOne FlexChart control that plots the top ten processes in descending order of physical memory used.

First, add the following package reference to the .csproj file:

```
    <PackageReference Include="C1.AspNetCore.Mvc" Version="3.0.20202.*" />  
```

Edit _Pages/_ViewImports.cshtml_ and append the corresponding tag helper:

```
    @addTagHelper *, C1.AspNetCore.Mvc  
```

Edit _Pages/Shared/_Layout.cshtml_ and insert the following lines before the closing `</head>` tag:

```
    <c1-styles />  
    <c1-scripts>  
        <c1-basic-scripts />  
    </c1-scripts>  
```

Then, in the same file, replace the first two occurrences of _Privacy_ with _Chart_.

Edit _Startup.cs_ and replace the **UseEndpoints** call with the following:

```
    app.UseEndpoints(endpoints =>  
    {  
        endpoints.MapControllerRoute(  
            name: "default",  
            pattern: "{controller=Home}/{action=Index}/{id?}");

        endpoints.MapRazorPages();  
    });  
```

The call to **MapControllerRoute** is needed for the MVC controls to load their resources properly.

Lastly, create a file named _Chart.cshtml_ in the _Pages_ folder and add the following markup:

```
    @page  
    @model ChartModel  
    @{  
        ViewData["Title"] = "Chart view";  
    }  
    <div>  
    <c1-flex-chart binding-x="Name" chart-type="Bar" legend-position="None">  
        <c1-items-source source-collection="@Model.Processes"></c1-items-source>  
        <c1-flex-chart-series binding="Memory" name="Physical Memory" />  
        <c1-flex-chart-axis c1-property="AxisX" position="None" />  
        <c1-flex-chart-axis c1-property="AxisY" reversed="true" />  
    </c1-flex-chart>  
    </div>  
```

Then, add the corresponding code-behind file, _Chart.cshtml.cs_:

```
    using System;  
    using System.Collections.Generic;  
    using System.Linq;  
    using System.Threading.Tasks;  
    using Microsoft.AspNetCore.Mvc;  
    using Microsoft.AspNetCore.Mvc.RazorPages;  
    using Microsoft.Extensions.Logging;  
    using System.Diagnostics;

    namespace Processes.Pages  
    {  
        public class ChartModel : PageModel  
        {  
            public List<object> Processes { get; set; }  
            private readonly ILogger<ChartModel> _logger;

            public ChartModel(ILogger<ChartModel> logger)  
            {  
                _logger = logger;  
            }

            public void OnGet()  
            {  
                var items = Process.GetProcesses()  
                    .Where(p => !String.IsNullOrEmpty(p.ProcessName))  
                    .OrderByDescending(p => p.WorkingSet64)  
                    .Take(10);

                Processes = items.Select(p => new {  
                    Id = p.Id,  
                    Name = p.ProcessName,  
                    Memory = p.WorkingSet64  
                }).ToList<object>();  
            }  
        }  
    }
```

Save all of your changes. Note that the build process will create a 30-day trial license for ComponentOne Studio Enterprise. Click the Chart link in the menu bar, and you should see a bar chart similar to the following:

![Electron.NET](//cdn.mescius.io/umb/media/ovbhcmdn/image9_edited.jpg)

### 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_, _osx_. Output goes to the _bin/Desktop_ folder, for example:

*   Processes Setup 1.0.0.exe (Windows)
*   Processes-1.0.0.AppImage (Linux)
*   Processes-1.0.0.dmg (OSX)

Note that the Windows executable is a setup program, not the application itself. Also, OSX targets can only be built 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 creating the installation media.

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

![Electron.NET](//cdn.mescius.io/umb/media/nzgpjxhl/image10_edited.jpg)

### Conclusion and Sample Code

Electron.NET is an open-source tool that adds value to ASP. NET Core 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 MVC controls in ComponentOne Studio Enterprise.

The source code for the completed project described in this article is available on [GitHub](https://github.com/jjuback/NDCOslo2020).