How to create Angular Desktop Apps

Create desktop apps based on Angular CLI and Electron

How to create Angular Desktop Apps

Angular is one of the most popular JavaScript front-end frameworks. With Angular CLI we have a very powerful tool that makes it easy to create Angular applications. The CLI provides lots of possibilities how to setup the application and comes beyond that with extensive ways to maintain it. For the scope of this article we’ll just use it as a straightforward way to create the Angular part.

Electron can be described as a wrapper that embeds the JavaScript application in an own window on the desktop — aside and independent from the usual browser, and in addition to this on any platform.

Let’s start and create our own Electron application with Angular!

Requirements

Angular CLI

At first we have to create a new Angular application with the help of Angular CLI. It should be installed globally. Open a terminal and type in the following command:

npm install -g @angular/cli

After that the Angular CLI can generate the whole Angular application with:

ng new my-electron-app

The project can be opened with an editor of your choice. Visual Studio Code provides an easy way to open the project directly from the command line:

code my-electron-app

Electron

Navigate into the project folder:

cd my-electron-app

Electron can be added to the project with the following command:

npm install electron --save-dev

Electron differentiates between the so called renderer and the main process. The renderer process is nothing else than our Angular application. The renderer process is wrapped by the main process that is responsible for the window in which the application will be shown.

In the next step we create a file in which we configure the main process — or in other words — how the window of our application should look like.

Create a electron.dev.js file under the src folder and insert the following code:

electron.dev.js
const { app, BrowserWindow } = require('electron');
const path = require('path');
const url = require('url');

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let win;

const createWindow = () => {
  // set timeout to render the window not until the Angular
  // compiler is ready to show the project
  setTimeout(() => {
    // Create the browser window.
    win = new BrowserWindow({
      width: 800,
      height: 600,
      icon: './src/favicon.ico',
    });

    // and load the app.
    win.loadURL(
      url.format({
        pathname: 'localhost:4200',
        protocol: 'http:',
        slashes: true,
      }),
    );

    win.webContents.openDevTools();

    // Emitted when the window is closed.
    win.on('closed', () => {
      // Dereference the window object, usually you would store windows
      // in an array if your app supports multi windows, this is the time
      // when you should delete the corresponding element.
      win = null;
    });
  }, 10000);
};

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);

// Quit when all windows are closed.
app.on('window-all-closed', () => {
  // On macOS it is common for applications and their menu bar
  // to stay active until the user quits explicitly with Cmd + Q
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  // On macOS it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (win === null) {
    createWindow();
  }
});

With the method createWindow we create a new BrowserWindow instance that is responsible for the window that will be opened when we start the main process later. Here we can define things like the size of the window, the icon and lots of other things.

The main process needs to know where the renderer process runs. This will be achieved through the loadUrl method. For development purposes the renderer process runs on the default localhost address that comes with Angular CLI (e.g. http://localhost:4200). The benefit of this approach is that the app will be automatically reloaded if we change the code somewhere.

Additionally the createWindow method is delayed by a timeout to wait until the compiler has compiled the Angular application completely. The timeout is not absolutely necessary but without it the Electron window could be opened too early and it would only show a blank page. In this case you have to reload the window manually with CTRL + R when the application is ready.

On the other hand the main process for production should not contain any tools related to development. We create another file called electron.prod.js under the src folder that loads all required files directly from the dist folder:

electron.prod.js
const { app, BrowserWindow } = require('electron');
const path = require('path');
const url = require('url');

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let win;

const createWindow = () => {
  // Create the browser window.
  win = new BrowserWindow({
    width: 800,
    height: 600,
    icon: path.join(__dirname, 'favicon.ico'),
  });

  // and load the index.html of the app.
  win.loadURL(
    url.format({
      pathname: path.join(__dirname, 'index.html'),
      protocol: 'file:',
      slashes: true,
    }),
  );

  // Emitted when the window is closed.
  win.on('closed', () => {
    // Dereference the window object, usually you would store windows
    // in an array if your app supports multi windows, this is the time
    // when you should delete the corresponding element.
    win = null;
  });
};

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);

// Quit when all windows are closed.
app.on('window-all-closed', () => {
  // On macOS it is common for applications and their menu bar
  // to stay active until the user quits explicitly with Cmd + Q
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  // On macOS it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (win === null) {
    createWindow();
  }
});

It is better to divide development and production settings in two different files for better maintainability and clarity. We better ship no code that is not relevant for production purposes.

Update scripts

As a next step we have to adjust some scripts in our package.json. For that we need to install the concurrently package:

npm install concurrently --save-dev

Then we add a script that runs Electron:

"electron": "electron ./src/electron.dev"

… and change the start script to:

"start": "concurrently \"ng serve\" \"npm run electron\""

The concurrently module allows us to run the ng serve -command and the electron script simultaneously.

To sum up all changes we made in package.json:

"scripts": {
  "start": "concurrently \"ng serve\" \"npm run electron\"",
  "electron": "electron ./src/electron.dev",
  ...
}

Run the application

Let’s run npm start in a terminal and the window will be opened with the loaded Angular application in it.

Error Handling IllustrationError Handling Illustration

Try to make some changes in the source code! You’ll see that the app will be automatically reloaded when changes were made somewhere in the code.

Error Handling IllustrationError Handling Illustration

Tip: You can also press CTRL + R to reload the window manually.

Executable files for production

Executable files allow us to run the Angular application directly as a desktop application. This simplifies the delivery of the app to our customers — especially if they prefer desktop applications.

At first we have to change the base tag in the index.html file from

<base href="/" />

to

<base href="./" />

The base tag specifies how to handle relative URLs in the html document. To run Electron from the dist folder and not from the root folder we have to add the dot before the slash.

Next we create a package.json file under the src folder:

src/package.json
{
  "name": "angular-cli-electron",
  "productName": "Angular CLI + Electron",
  "version": "1.0.0",
  "description": "Angular CLI + Electron",
  "main": "electron.prod.js",
  "license": "MIT"
}

This package.json file contains the required information for the executable files. Not only this package.json but also the electron.prod.js file have to be included inside the dist folder after the build process.

“Ok, but why don’t we create the files directly in the dist folder?”

This is why the dist folder will be deleted every time we build the project. In addition to this the dist folder is not — and should not be — checked in into the versioning system.

Angular CLI provides a mechanism to copy files into the dist folder. We amend the assets array inside the angular.json file:

angular.json
"assets": [
  ...
  "src/package.json",
  "src/electron.prod.js"
],

As a last step we have to create the scripts for packaging. Please install the required packages:

npm install electron-packager cross-var --save-dev

and add the following scripts to the global package.json file:

package.json
"scripts": {
  ...
  "package:win": "npm run build && cross-var electron-packager dist/angular-cli-electron $npm_package_name-$npm_package_version --out=packages --platform=win32 --arch=all --overwrite ",
  "package:linux": "npm run build && cross-var electron-packager dist/angular-cli-electron $npm_package_name-$npm_package_version --out=packages --platform=linux --arch=all --overwrite ",
  "package:osx": "npm run build && cross-var electron-packager dist/angular-cli-electron $npm_package_name-$npm_package_version --out=packages --platform=darwin --arch=all --overwrite ",
  "package:all": "npm run build && cross-var electron-packager dist/angular-cli-electron $npm_package_name-$npm_package_version --out=packages --all --arch=all --overwrite ",
},

The package-scripts start the build process with npm run build. After that the dist folder will have been created with the compiled JavaScript files. The part after the && can only be executed when the build process has been finished. The electron-packager module does the most important part here: It packages the compiled files of the dist folder into an executable file. We use the cross-var module to automatically use both the package name and the version number from the Node environment variables for the executable’s file name. The --out argument defines the output folder for the executable files, the --platform the type of platform, --arch the platform architecture and --overwrite that the existing files can be overwritten.

Now we can run npm run package:win to create executable files for Windows or npm run package:linux for Linux or npm run package:osx for OS X. Please be a little bit patient here. The packager needs to download some required files to package everything for each platform.

After that you can find the executable files inside the packages folder.

Tip: You should add the packages folder to the .gitignore file. Those files should not be checked in into your versioning system.

Conclusion

The combination of Angular CLI and Electron makes it easy to create desktop applications based on Node.js. The only drawback with whom I’m currently quarreling is the big size of the executable files. Our example application has a package size with more than 100 MB and that is absolutely too much for a small desktop application — and in the most cases web applications aren’t too big to get on with those sizes.

However there might be use cases where a project with Electron is welcome and useful. Popular applications like Visual Studio Code or Github Desktop that are all based on Electron prove that it’s a great framework with lots of features and possibilities.