In this article, we'll learn how to build cross-platform desktop apps for Windows, Linux and macOS using Electron and web technologies such as HTML, TypeScript and Angular.

Electron was initially developped for GitHub's Atom editor. Nowadays, it's being used by big companies like Microsoft and Slack to power their desktop apps. Visual Studio Code is a powerful and popular code editor built by Microsoft using Electron.

You can check out more apps built with Electron from this link.

What Electron Precisely Does?

So you are a frontend web developer - you know JavaScript, HTML and CSS which is great but you need to build a desktop application. Thanks to Electron that's now possible and you don't have to learn classic programming languages like C++ or Java to build your application, you can simply use your web development skills to target all the popular desktop platforms such as macOS, Linux and Windows with one code base. You only need to rebuild your code for each target platform.

Electron simply provides a native container for your web application so it looks and feels like a desktop application. If you are familiar with hybrid mobile development, Electron is quite similar to Apache Cordova but targets desktop systems instead of mobile operating systems.

Electron is actually an embedded web browser (Chromuim) bundled with Node.js and a set of APIs for interfacing with the underlying operating system and providing the services that are commonly needed by native desktop apps such as:

Let's now see how we can use Electron and web technologies (TypeScript and Angular) to create a desktop app.

Prerequisites

In this tutorial, we'll build a simple desktop application from scratch, so you are going to need the following prerequisistes:

  • Familiarity with the three pillars of the web i.e JavaScript, CSS and HTML.
  • A working knowledge of Angular as we'll be using it to build our application along with Electron.
  • Git, Node.js and NPM installed on your system.

Installing Angular CLI 8

We'll be using Angular for creating our web application that will be wrapped by Electron. The Angular team provides a command line interface that can be used to quickly scaffold Angular projects and work with them locally.

Angular CLI is based on Node, so provided that you have Node and NPM installed on your machine, you can simply run the following command to install the CLI:

$ npm install -g @angular/cli

As the time of this writing, Angular CLI v8.1.0 will be installed on your system.

Creating a Project

Let's now create a project. In your terminal, run the following command:

$ ng new electron-app

The CLI will prompt you if Would you like to add Angular routing? (y/N) type y if you want to add routing and N otherwise. You will also be asked for Which stylesheet format would you like to use? Let's keep it simple and choose CSS.

That's it, the CLI will create a folder structure with the necessary files for configuring and bootsrapping your project and start installing the dependencies from npm.

You can make sure your application works as expected by serving it locally using the following commands:

$ cd electron-app
$ ng serve

You can see your app up and running by going to the http://localhost:4200/ address in your web browser. You should see the following interface:

Let's now see how we can turn this simple web app into a desktop application using Electron.

Installing & Setting up Electron

After creating our Angular project, let's now install Electron using the following commands:

$ npm install --save-dev electron@latest

This will install Electron as a development dependncy in your project.

As of this writing, electron v5.0.6 is installed.

Next, create an app.js file inside the root folder of your project and add the following content:

const {app, BrowserWindow} = require('electron')
    const url = require("url");
    const path = require("path");

    let mainWindow

    function createWindow () {
      mainWindow = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
          nodeIntegration: true
        }
      })

      mainWindow.loadURL(
        url.format({
          pathname: path.join(__dirname, `/dist/electron-app/index.html`),
          protocol: "file:",
          slashes: true
        })
      );
      // Open the DevTools.
      mainWindow.webContents.openDevTools()

      mainWindow.on('closed', function () {
        mainWindow = null
      })
    }

    app.on('ready', createWindow)

    app.on('window-all-closed', function () {
      if (process.platform !== 'darwin') app.quit()
    })

    app.on('activate', function () {
      if (mainWindow === null) createWindow()
    })

We simply create a GUI window using the BrowserWindow API provided by Electron and we call the loadURL() method to load the index.html file from the dist folder.

Note: At this point, if you look at your project's folder you will not find a dist folder because we didin't build our Angular project yet.

According to the docs, BrowserWindow can be used to create and control browser windows. You can use the backgroundColor property to set the background color. You can invoke BrowserWindow multiple times in your application to create multiple windows and use the parent property to add parent-child relationships between the various windows and also create modals.

Note: If you want to create a window without chrome, or a transparent window with an arbitrary shape, you need to use the Frameless Window API instead.

Next, you need to open the package.json file and add the app.js file as the main entry point of our project:

    {
      "name": "electron-app",
      "version": "0.0.0",
      "main": "app.js",
      // [...]
    }

You can find more information about the main key from the official docs.

Next, let's modify the start script in the package.json file to make it easy to build the project and run the Electron application with one command:

    {
      "name": "electron-app",
      "version": "0.0.0",
      "main": "app.js",
      "scripts": {
        "ng": "ng",
        "start": "ng build --base-href ./ && electron .",
        "build": "ng build",
        "test": "ng test",
        "lint": "ng lint",
        "e2e": "ng e2e"
      }, 
      // [...]
    }

Now, if we run the start script, our project will be built with base href set to ./ then our Electron app will run from the current folder.

Note: The base href attribute is used to set the base URL that will be used for the relative links in the document.

We can test our application by running the following command:

$ npm start

The Angular project will be built inside the dist/electron-app folder and Electron will be started with a GUI window that should display the Angular application.

Since we are using Angular 8, the latest version as of this writing, which makes use of differential loading by default - A feature that enables the web browser to make a choice between loading modern (ES6+) or legacy JavaScript (ES5) source files based on its own capabilities. After compiling the application, we'll have two builds, a modern build (es2015) and a legacy build (es5).

If you look at the console of your Electron application, you'll see that it tries to load the modern bundles which gives us the Failed to load module script error.

This is a screenshot of our application with the error displayed in the console:

One way to solve this issue is by instrcuting the TypeScript compiler to generate only a legacy ES5 build. Open the tsconfig.json file and change target from es2015 to es5:

{
  "compilerOptions": {
    "target": "es5",
}

Now, restart your application, you should see your Angular app loaded inside Electron. This is a screenshot:

Calling Electron APIs by Example

Let's now see how we can call Electron APIs to invoke the services from the underlying system. We'll see how we can take screenshots of our desktop windows using this example.

Electron use two processes - The main process which runs the Node.js runtime and a renderer process that runs the Chromium browser. For most APIs, we need to use inter-process communication to call the Electron APIs from the renderer process so we'll make use of ngx-electron - A simple Angular wrapper for electron's Renderer API which makes calling Electron APIs from the renderer process much easier.

Note: ngx-electron is a third-party package that makes calling Electron APIs from Angular more straightforward but you can also use the ipcMain module for implementing asynchronous communication from the main process to renderer processes.

According to the docs:

The ipcMain module is an instance of the EventEmitter class. When used in the main process, it handles asynchronous and synchronous messages sent from a renderer process (web page). Messages sent from a renderer will be emitted to this module.

Electron provides many useful APIs. Among them, desktopCapturer which according to the cocs allows us to access information about media sources that can be used to capture audio and video from the desktop using the navigator.mediaDevices.getUserMedia API.

In your terminal, run the following command:

$ npm install ngx-electron --save

Next, open the src/app/app.module.ts file and add NgxElectronModule to the imports array:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { NgxElectronModule } from 'ngx-electron';
// [...]

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    NgxElectronModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Next, you can import and inject ElectronService in your component(s) to call Electron APIs.

Open the src/app/app.component.ts file and import ElectronService and inject it via the component constructor:

import { ElectronService } from 'ngx-electron';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit{
  title = 'electron-app';
  constructor(private _electronService: ElectronService) { }
}

Next, let's define the following variables:

export class AppComponent implements OnInit{
  sources = [];
  selectedSource;
  videostream;
  @ViewChild('videoElement', { static: true }) videoElement: any;  
  video: any;

We'll use:

  • the sources array to store the sources returned from the desktopCapturer API,
  • the selectedSource variable to store a source that we want to take a screenshot for,
  • the videostream variable to store the video steram from the webkitGetUserMedia() API,
  • the videoElement variable decorated with @ViewChild to create a query configuration that will be used to query for the <video> tag.
  • the video variable to store the native DOM element for the <video> tag.

Next, in the ngOnInit() method, add the following code which assigns the native DOM element of the <video> tag referenced by the videoElement reference to the video variable:

ngOnInit(){
   this.video  =  this.videoElement.nativeElement;
}

Note: We'll add the <video> tag in the HTML template of the component in the next paragraphs. This tag will be used by the webkitGetUserMedia() to capture the video from the desktop.

Next, let's define the displaySources() which populates the sources array with the available sources from the desktopCapturer API:

  displaySources(){
    if (this._electronService.isElectronApp) {
      this._electronService.desktopCapturer.getSources({ types: ['window', 'screen'] }, (error, sources) => {
        
        if (error) throw error;
        this.sources = sources;
        if(this.sources.length > 0){
          this.selectedSource = this.sources[0];
        }
      });
    }
  }

We first use the isElectronApp() method of ElectronService for making sure our app is running inside the Electron container and not inside a regular web browser. Next, we call the getSources() method of desktopCapturer to get the sources. If there is no error, we assign the sources to the sources variable we previously defined in our component. We also assign the first source in the array to the selectedSource variable.

Next, let's define the selectSource() method which selects a source that will be used to take a screenshot:

  selectSource(source){
    this.selectedSource = source;
  }

This method simply assigns the selected source to the selectedSource variable.

Finally, let's add the takeScreenshot() variable which will be called to actually take a screenshot of the selected source:

  takeScreenshot(){

    let nav = <any>navigator;

    nav.webkitGetUserMedia({
          audio: false,
          video: {
            mandatory: {
              chromeMediaSource: 'desktop',
              chromeMediaSourceId: this.selectedSource.id,
              minWidth: 1280,
              maxWidth: 1280,
              minHeight: 720,
              maxHeight: 720
            }
          }
        }, (stream) =>{
          this.video.srcObject = stream;
          this.video.onloadedmetadata = (e) => this.video.play()

        }, ()=>{
            console.log('getUserMediaError');
        });
        return;
      
    }

We simply call the webkitGetUserMedia() method of the navigator object to capture the video stream from the desktop media source. We specify the target window using the chromeMediaSourceId property. After getting the stream, we use the srcObject property to set the stream as the source of the <video> tag. When the meta data of the video is loaded we play the video by calling the play() method.

Now, let's add the UI. Open the src/app/app.component.html and remove the existing content and add the following code:


<div style="text-align:center">
  <h1>
    Take Screenshots
  </h1>
  <button (click)="takeScreenshot()">
    Take it!
  </button>
  <button (click)="displaySources()">
    Sources
  </button>
  
</div>
<ul>
  <li *ngFor="let source of sources">
    <a (click)="selectSource(source)">
      {{ source.name }}
    </a>
  </li>
</ul>

<video autoplay width="640" height="480" #videoElement></video>

We first create two buttons for displaying the sources of the screenshots and taking a screenshot of the selected source then we bind them to the takeScreenshot() and displaySources() methods using the click event. Next, we add a list and we iterate over the available sources using the ngFor directive then we display the name of each source. We also bind the selectSource() method to each source which enables us to select the source of our screenshot.

Finally, we add a <video> tag that allows us to dispaly the taken screenshot and we use the template variable reference (called #videoElement) to identify the tag so we can query it later in our component. Template reference variables all the Angular equivalent to HTML identifiers.

This is a screenshot of our Electron app:

When we click on the Sources button, the available sources are displayed. We can select any source and then click on the Take it! button to take a screenshot.

The screenshot will be diplayed on the <video> tag below the sources.

Note: This is a simple example that demonstartes how to access Electron APIs from a renderer process running Angular. You can improve this application to allow the user to save screenshots using HTML canvas and add other features that go beyond the intent of our example which is demonstrating how to call Electron APIs from the renderer process.

Packaging your Electron App

After creating your Electron application, you need to package it before you can distribute it to your users.

You can one of the following packaging tools to package your app:

These tools will help you to create a final distributable Electron application, by packaging your application, rebranding the executable, setting icons and also creating the installers for your target systems.

You can also package your application manually. Read more information about this process from the docs.

Conclusion

In this tutorial, we've seen what's Electron and how we can use it to create a cross-platform desktop application for Linux, macOS and Windows. We've also seen how we can integrate Angular 8 with Electron and used the desktopCapturer API to create a simple screenshot tool from scratch using web technologies only.

Next in series: Building a Desktop App with Electron and Vue.js