Friday, January 1, 2021

Electron.NET with server-side Blazor & EF

 In this tutorial I will show you how to develop a simple cross-platform Electron application from server-side Blazor. The application retrieves data from the Northwind database and renders results in a table. 

The solution also allows you to do the following:

  • export data to a CSV file
  • setup the solution as a separate desktop application

Source code : https://github.com/medhatelmasry/ElectronServerBlazorEf

Companion Video: https://youtu.be/WxfnWqwJPlI

What is Electron?

Electron is a framework that supports development of apps using web technologies such as Chromium rendering engine and Node.js runtime. The platform supports Windows, MacOS and Linux. Some very popular applications that run on Electron are Visual Studio Code, Discord, Skype, GitHub Desktop and many others. The official site for Electron is https://www.electronjs.org/.

What is Electron.NET?

Electron.NET is a wrapper around Electron that allows .NET web developers to invoke native Electron APIs using C#. To develop with Electron.NET, you need Node.js & Npm installed on your computer. In addition, you must have .NET Core 3.1 or later. The official site for Electron.NET open source project is https://github.com/electronnet/electron.net/.

Running a docker container with SQL-Server Northwind sample database

I will use a docker image that contains the SQL-Server Northwind database. Credit goes to kcornwall for creating this docker image.

To pull & run the Northwind database in a docker container, run the following command in a terminal window:

docker run -d --name nw -p 1444:1433 kcornwall/sqlnorthwind

The above command does the following:

Docker image: kcornwall/sqlnorthwind
Container Name
(--name):
 nw
Ports (-p): Port 1433 in container is exposed as port 1444 on the host computer
Password: The sa password is Passw0rd2018. This was determined from the Docker Hub page for the image.
-d: Starts the container in detached mode


This is what I experienced after I ran the above command:
docker run
Let us make sure that the container is running. Execute this command to ensure that the container is running OK.
docker ps

The following confirms that the container is indeed running:
docker ps

Setup our application

At the time of writing this article, I was using .NET version 5.0.101 on a Windows 10 computer running version 1909

Let us create an ASP.NET server-side Blazor app named ElectronServerBlazorEf with the following terminal window commands:

mkdir ElectronServerBlazorEf
cd ElectronServerBlazorEf
dotnet new blazorserver

We need two .NET tools. Run the following commands from within a terminal window to install ElectronNET.CLI and the dotnet-ef:

dotnet tool install -g ElectronNET.CLI
dotnet tool install -g dotnet-ef

Continue by adding these packages to your project:

dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package ElectronNET.API

ElectronNET.API is the Electron.NET package.

Finally, let's open our project in VS Code. To do that, simply execute the following command from the same terminal window:
code .

Open Program.cs in the editor and add the following statements to the CreateHostBuilder() method right before webBuilder.UseStartup<Startup>() : 

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

Add the following method to Startup.cs:

public async void ElectronBootstrap() {

  var browserWindow = await Electron.WindowManager.CreateWindowAsync(new BrowserWindowOptions

  {

    Width = 1152,

    Height = 940,

    Show = false

  });


  await browserWindow.WebContents.Session.ClearCacheAsync();


  browserWindow.OnReadyToShow += () => browserWindow.Show();

  browserWindow.SetTitle("Electron.NET with Blazor!");

}


Add the following code to the bottom of the Configure() method in Startup.cs:

if (HybridSupport.IsElectronActive) {

   ElectronBootstrap();

}


That's it. Your ASP.NET application is now electron-ized. To see the fruits of your labor, type the following command in the terminal window:

electronize init
electronize start

electronize init is a one-time command that creates a manifest file named electron.manifest.json and adds it to your project. 

electronize start launches the Electron app. Note that it takes a little longer the first time and the content now appears in an application window, not a browser.


Note that you can still run your application as a web app by simply stopping the Electron app (with File >> Exit from the app's menu system) and running the web app as normal with: dotnet run.

Interacting with the Northwind database

Close the Electron app with File >> Exit.

Let us reverse engineer the database with the following command so that it generates a DbContext class and classes representing the Category Product database entities in a folder named NW:

dotnet-ef dbcontext scaffold "Data Source=localhost,1444;Initial Catalog=Northwind;Persist Security Info=True;User ID=sa;Password=Passw0rd2018" Microsoft.EntityFrameworkCore.SqlServer -c NorthwindContext -o NW --table Products --table Categories

Add the following connection string to the top of appsettings.json just before "Logging":

"ConnectionStrings": {
    "NW": "Data Source=localhost,1444;Initial Catalog=Northwind;Persist Security Info=True;User ID=sa;Password=Passw0rd2018"
},

Open NW/NorthwindContext.cs and delete the OnConfiguring() method so that we do not have confidential connection string information embedded in source code.

Add the following to ConfigureServices() method in Startup.cs:

services.AddDbContext<NorthwindContext>(options => {
  options.UseSqlServer(Configuration.GetConnectionString("NW"));
});

Add a class file named NorthwindService.cs in the Data folder. Replace the class definition with the following code:

public class NorthwindService {
  private readonly NorthwindContext _context;
  public NorthwindService(NorthwindContext context) {
      _context = context;
  }
  public async Task<List<object>> GetCategoriesByProductAsync () {
    var query = _context.Products
      .Include (c => c.Category)
      .GroupBy (p => p.Category.CategoryName)
      .Select (g => new {
          Name = g.Key,
          Count = g.Count ()
      })
      .OrderByDescending(cp => cp.Count);

    return await query.ToListAsync<object> ();  
  }
}

We need to configure the NorthwindService class as scoped so that we can use dependency injection. Add the following statement to the ConfigureServices() method in Startup.cs:

// Scoped creates an instance for each user

services.AddScoped<NorthwindService>();


Make a duplicate copy of the FetchData.razor file in the Pages node and name the new file Report.razorReplace its contents with the following code:

@page "/report"
@inject ElectronServerBlazorEf.Data.NorthwindService service

<h1>Categories by product</h1>

@if (data == null) {
  <p><em>Loading...</em></p>
} else {
  <table class='table table-hover'>
    <thead>
      <tr>
        <th>Category</th>
        <th># of products</th>
      </tr>
    </thead>
    <tbody>
      @foreach (var item in data)
      {
        <tr>
            <td>@item.GetType().GetProperty("Name").GetValue(item)</td>
            <td>@item.GetType().GetProperty("Count").GetValue(item)</td>
       </tr>
      }
    </tbody>
  </table>
}


@code {
  List<object> data;

  protected override async Task OnInitializedAsync() {
    data = await service.GetCategoriesByProductAsync();
  }
}

Let us add a menu item to the left-side navigation of our Blazor application. Open Shared/NavMenu.razor in the editor and add the following <li> to the <ul> block (around line 24):

<li class="nav-item px-3">
  <NavLink class="nav-link" href="report">
    <span class="oi oi-list-rich" aria-hidden="true"></span> Report
  </NavLink>
</li>

Type in the following command in a terminal window to test the Electron.NET / Blazor application:

electronize start

Click on the Report menu item on the left-side. You should see the following output:

Save data to file system as CSV file

Add a SaveAs.razor file to the Pages folder with the following content:

@page "/saveas/{filepath}"

@inject ElectronServerBlazorEf.Data.NorthwindService service

<h1>Export data to CSV format</h1>

<p>File successfully saved to @Filepath.</p>

@code {
  [Parameter]
  public string Filepath { get; set; }

  public string Message { get; set; }

  protected override async Task OnInitializedAsync() {
    Filepath = System.Web.HttpUtility.UrlDecode(Filepath);

    System.IO.StringWriter writer = new System.IO.StringWriter();
    writer.WriteLine("Name,Count");

    var query = await service.GetCategoriesByProductAsync();
    query.ForEach(item =>
    {
      writer.Write(item.GetType().GetProperty("Name").GetValue(item));
      writer.Write(",");
      writer.WriteLine(item.GetType().GetProperty("Count").GetValue(item));
    });

    await System.IO.File.WriteAllTextAsync(Filepath, writer.ToString());
  }
}

Menu customization

Electron.NET provides a default application menu. Note that there are differences between macOS and other platforms. On macOS, applications have their own menu to the left of the standard File/Edit/View menus.

Add this CreateMenu() method to Startup.cs:

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 {
        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)) {
                result = System.Web.HttpUtility.UrlEncode(result);
                string url = $"http://localhost:{BridgeSettings.WebPort}/saveas/{result}";
                mainWindow.LoadURL(url);
            }
        }
    },
    new MenuItem { Type = MenuType.separator },
    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);
}

Add following statement at the top of the ElectronBootstrap() method in Startup.cs:

CreateMenu();

In order for the . (dot) in filenames to be handled properly during the routing process, we need to add the following endpoint at the bottom of app.UseEndpoints() inside the Configure() method in Startup.cs:

// necessary when routing parameter includes a .
endpoints.MapFallbackToPage("/saveas/{filepath}", "/_Host");

Your routing endpoints would eventulayy look like this:

app.UseEndpoints (endpoints => {
  endpoints.MapBlazorHub ();
  endpoints.MapFallbackToPage ("/_Host");

  // necessary when routing parameter includes a .
  endpoints.MapFallbackToPage("/saveas/{filepath}", "/_Host");
});

Test the save-as functionality by starting the Electron app with the following terminal-window command:

electronize start



Click on File >> Save As ...


Upon successful saving of data, you should receive the following message:



Select a location and give the export file a name (like data), then click on save. The content of data.csv should look like this:

Build for specific platform:

Exit the application with File >> Exit.

You can produce a setup application for Windows, macOS & Linux. To generate the setup application for Windows, execute the following command from a terminal window:

electronize build /target win /PublishReadyToRun false 

The result is a setup application located in bin/Desktop that you can distribute. Be patient because it takes time to generate.




If you run the setup exe file, it will install a desktop application on your computer that you can easily uninstall.

I hope you found this article useful and hope you build great Electron.NET / Server-side Blazor apps.

Reference:
    https://blog.jetbrains.com/dotnet/2020/11/05/run-blazor-apps-within-electron-shell/

No comments:

Post a Comment