Creating a Desktop App with Electron, Typescript, and Angular
In this article, we'll learn how to build a cross platform desktop app for Windows, Linux and macOS using web technologies such as Angular, Electron, HTML and TypeScript.
Prerequisites:
- 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 Angular application along with Electron
- Git, Node.js and NPM installed on your system
What is Electron
Electron was initially developed 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.
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 (Chromium) 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:
What is an Electron app?
An Electron app is a desktop application that is built using web technologies such as HTML, CSS, and JavaScript. It allows developers to create cross-platform applications using familiar web development tools. Electron apps have access to the full capabilities of the operating system, making it possible to create applications with native-like features and functionalities. They can interact with the file system, utilize system-level APIs, display native notifications, and more.
Let's now see how we can use Electron and web technologies (TypeScript and Angular) to create a desktop app.
Installing Angular CLI 8
We are going to build an Angular app and wrap it with Electron. The Angular team provides a command line interface that can be used to quickly scaffold Angular projects and work with them locally.
The 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:
bash$ npm install -g @angular/cli
$$
Creating a project
Let's now create a project. In your terminal, run the following command:
bash$ ng new electron-app
$$
The CLI will prompt you Would you like to add Angular routing? (Y/N). Yype 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:
bash$ 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:
Image loading...
Let's now see how we can turn this simple web app into a desktop application using Electron.
Installing & setting up Electron
After creating the project for Angular, Electron can now be installed using the following commands:
bash$ npm install --save-dev electron@latest
$$
This will install Electron as a development dependency in your project.
With Electron installed, create an app.js
file inside the root folder of your project and add the following content:
javascriptconst {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.
dist
folder because, we haven't built 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.
Next, you need to open the package.json
file and add the app.js
file as the main entry point of our project:
json{ "name": "electron-app", "version": "0.0.0", "main": "app.js", // [...] }
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:
javascript{ "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.
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:
bash$ 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 Angular 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:
Image loading...
One way to solve this issue is by instructing the TypeScript compiler to generate only a legacy ES5 build. Open the tsconfig.json
file and change target from es2015
to es5
:
json{ "compilerOptions": { "target": "es5", }
Now, restart your application, you should see your Angular app loaded inside Electron:
Image loading...
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 the Electron's Renderer API which makes calling Electron APIs from the renderer process much easier.
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 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:
bash$ npm install ngx-electron --save
$$
Next, open the src/app/app.module.ts
file and add NgxElectronModule
to the imports
array:
jsimport { 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, import ElectronService
, and inject it via the component constructor:
jsimport { 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:
jsexport 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 thedesktopCapturer
API, - the
selectedSource
variable to store a source that we want to take a screenshot for, - the
videostream
variable to store the video stream from thewebkitGetUserMedia()
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:
jsngOnInit(){ this.video = this.videoElement.nativeElement; }
<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:
jsdisplaySources(){ 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]; } }); } }
- First, we use the
isElectronApp()
method ofElectronService
to ensure sure our app is running inside the Electron container and not inside a regular web browser. - Next, we call the
getSources()
method ofdesktopCapturer
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.
Let's define the selectSource()
method which selects a source that will be used to take a screenshot:
jsselectSource(source){ this.selectedSource = source; }
selectedSource
variable.
Finally, let's add the takeScreenshot()
variable which will be called to actually take a screenshot of the selected source:
jstakeScreenshot(){ 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 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 metadata 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
, remove the existing content, and add the following code:
html<!--The content below is only a placeholder and can be replaced.--> <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>
- First we create two buttons for displaying the sources of the screenshots and taking a screenshot of the selected source.
- Next, we bind them to the
takeScreenshot()
anddisplaySources()
methods using the click event. - In the following step, we add a list and 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. - In the end, we add a
<video>
tag that allows us to display 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.
This is a screenshot of our Electron app:
Image loading...
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 displayed on the <video>
tag below the sources.
Packaging your Electron app
After creating your Angular 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.
Conclusion
In this tutorial, we've seen what's Electron and how we can use it to create a cross platform desktop app 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.