Creating cross-platform desktop applications has never been easier with Electron

In the early months of this year, Github unveiled Atom-Shell, the heart of its renowned open-source editor Atom. They took the opportunity to rechristen it as Electron.

Unlike its Node.js-based desktop application counterparts, Electron distinguishes itself in this established arena. It merges the robustness of Node.js (io.js in earlier versions) with the Chromium Engine. This potent blend delivers the best of server-side and client-side JavaScript.

Envision a world where we can construct high-performance, data-driven, cross-platform desktop applications. These applications would not only leverage the expanding NPM module repository but also the entire Bower registry for client-side needs.

This is where Electron comes in.

Building Cross-platform Desktop Apps with Electron
Building Cross-platform Desktop Apps with Electron

This tutorial guides you through building a straightforward password keychain application. We’ll utilize Electron, Angular.js, and Loki.js](http://lokijs.org/#/), a lightweight, in-memory database with a syntax familiar to [MongoDB developers.

You can access the complete source code for this application here.

This tutorial presumes:

  • You have Node.js and Bower installed.
  • You are acquainted with Node.js, Angular.js, and MongoDB-like query syntax.

Gathering the Essentials

First and foremost, we need the Electron binaries for local app testing. We can install Electron globally for use as a CLI or locally within our application’s directory. I suggest a global installation to avoid repetitive setups for each app.

Later, we’ll explore packaging our application for distribution using Gulp. This process involves copying Electron binaries, making manual installation in the application path redundant.

To install the Electron CLI, run this command:

1
$ npm install -g electron-prebuilt

Verify the installation by typing electron -h. It should show the Electron CLI version.

During this article’s writing, the Electron version was 0.31.2.

Project Setup

Let’s use this basic folder structure:

1
2
3
4
5
6
my-app
|- cache/
|- dist/
|- src/
|-- app.js
| gulpfile.js

… where: - cache/ will store downloaded Electron binaries during the build. - dist/ will hold the generated distribution files. - src/ will contain our source code. - src/app.js will be our application’s entry point.

Navigate to the src/ folder in your terminal and create package.json and bower.json:

1
2
$ npm init
$ bower init

We’ll install the required packages later.

Grasping Electron Processes

Electron works with two process types:

  • The Main Process: This is the application’s starting point, the file executed on app launch. It typically declares the app’s windows and can define global event listeners using Electron’s IPC module.
  • The Renderer Process: This acts as the controller for a specific application window. Each window spawns its own Renderer Process.

For code readability, use a separate file for each Renderer Process. Let’s define our Main Process in src/app.js. We’ll include the app module for starting the app and the browser-window module to create application windows (both are part of Electron’s core):

1
2
var app = require('app'),
    BrowserWindow = require('browser-window');

Upon starting, the app triggers a ready event, which we can bind to. This is where we can instantiate the main window:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var mainWindow = null;

app.on('ready', function() {
    mainWindow = new BrowserWindow({
        width: 1024,
        height: 768
    });
    
    mainWindow.loadUrl('file://' + __dirname + '/windows/main/main.html');
    mainWindow.openDevTools();
});

Key points:

  • Creating a new BrowserWindow object instance creates a new window.
  • It accepts an object argument to define various settings, including the default width and height of the window.
  • The window instance has a loadUrl() method to load an HTML file, either local or remote.
  • The openDevTools() method (optional) opens Chrome Dev Tools for debugging.

Let’s organize our code. In the src/ folder, create a windows/ folder. Within it, create a subfolder for each window:

1
2
3
4
5
6
7
my-app
|- src/
|-- windows/
|--- main/
|---- main.controller.js
|---- main.html
|---- main.view.js

… where main.controller.js handles the “server-side” logic, and main.view.js manages the “client-side” logic.

main.html is a simple HTML5 webpage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Password Keychain</title>
</head>
<body>
    <h1>Password Keychain</h1>
</body>
</html>

Our app should now be runnable. From the src folder, run:

1
$ electron .

Automate this using the start script in package.son.

Building a Password Keychain App

Our password keychain application needs: - Functionality to add, generate, and save passwords. - A user-friendly way to copy and remove passwords.

Generating and Saving Passwords

A straightforward form will be sufficient for entering new passwords. To demonstrate communication between multiple windows in Electron, let’s add a second window for the “insert” form. We’ll wrap this logic in a method for easy reuse:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function createInsertWindow() {
    insertWindow = new BrowserWindow({
        width: 640,
        height: 480,
        show: false
    });
    
    insertWindow.loadUrl('file://' + __dirname + '/windows/insert/insert.html');
    
    insertWindow.on('closed',function() {
        insertWindow = null;
    });
}

Key points:

  • Set show to false in the BrowserWindow options to prevent the window from opening on app startup.
  • Destroy the BrowserWindow instance when the window emits a closed event.

Opening and Closing the “Insert” Window

We want to trigger the “insert” window when the user clicks a button in the “main” window. This requires sending a message from the main window to the Main Process to open the insert window. We’ll use Electron’s IPC module. There are two IPC module variants:

  • One for the Main Process, allowing subscription to messages from windows.
  • One for the Renderer Process, enabling message sending to the main process.

While Electron’s communication is largely uni-directional, you can access the Main Process’ IPC module in a Renderer Process using the remote module. The Main Process can also reply to the originating Renderer Process using the Event.sender.send() method.

Require the IPC module in your Main Process script like any NPM module:

1
var ipc = require('ipc');

… then, bind to events using the on() method:

1
2
3
4
5
6
ipc.on('toggle-insert-view', function() {
    if(!insertWindow) {
        createInsertWindow();
    }
    return (!insertWindow.isClosed() && insertWindow.isVisible()) ? insertWindow.hide() : insertWindow.show();
});

Key Points:

  • Event names are arbitrary; the example is for illustration.
  • Check if the BrowserWindow instance exists before instantiation.
  • Useful BrowserWindow methods:
    • isClosed(): Returns a boolean indicating if the window is closed.
    • isVisible(): Returns a boolean indicating window visibility.
    • show() / hide(): Convenience methods for showing and hiding the window.

Let’s create a new main.view.js file and include it in our HTML:

1
<script src="./main.view.js"></script>

Loading the script via the HTML script tag puts it in a client-side context. Global variables are accessible as window.<varname>. For server-side loading, use require('./main.controller.js'); directly in the HTML.

Even in a client-side context, we can access the Renderer Process’ IPC module and send our event:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var ipc = require('ipc');

angular
    .module('Utils', [])
    .directive('toggleInsertView', function() {
        return function(scope, el) {
            el.bind('click', function(e) {
                e.preventDefault();
                ipc.send('toggle-insert-view');
            });
        };
    });

A sendSync() method is available for synchronous event sending.

To open the “insert” window, create an HTML button with the corresponding Angular directive:

1
2
3
4
5
<div ng-controller="MainCtrl as vm">
    <button toggle-insert-view class="mdl-button">
        <i class="material-icons">add</i>
    </button>
</div>

Add the directive as a dependency in the main window’s Angular controller:

1
2
3
4
5
angular
    .module('MainWindow', ['Utils'])
    .controller('MainCtrl', function() {
        var vm = this;
    });

Generating Passwords

For simplicity, we’ll use the NPM uuid module to generate unique IDs as passwords. Install it like other NPM modules, require it in our ‘Utils’ script, and create a factory returning a unique ID:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var uuid = require('uuid');

angular
    .module('Utils', [])
    
    ...
    
    .factory('Generator', function() {
        return {
            create: function() {
                return uuid.v4();
            }
        };
    })

Now, add a button to the insert view and attach a directive to handle clicks and call the create() method:

1
2
<!-- in insert.html -->
<button generate-password class="mdl-button">generate</button>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// in Utils.js
angular
    .module('Utils', [])
    
    ...
    
    .directive('generatePassword', ['Generator', function(Generator) {
        return function(scope, el) {
            el.bind('click', function(e) {
                e.preventDefault();
                if(!scope.vm.formData) scope.vm.formData = {};
                scope.vm.formData.password = Generator.create();
                scope.$apply();
            });
        };
    }])

Saving Passwords

Let’s store our passwords. The data structure is simple:

1
2
3
4
5
6
{
    "id": String
    "description": String,
    "username": String,
    "password": String
}

We need an in-memory database with optional file syncing for backup. Loki.js fits perfectly, offering the needed functionality and Dynamic Views akin to MongoDB’s Aggregation.

Dynamic Views don’t have all of MongodDB’s Aggregation features. Refer to the documentation for details.

Create a basic HTML form:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div class="insert" ng-controller="InsertCtrl as vm">
    <form name="insertForm" no-validate>
        <fieldset ng-disabled="!vm.loaded">
            <div class="mdl-textfield">
                <input class="mdl-textfield__input" type="text" id="description" ng-model="vm.formData.description" required />
                <label class="mdl-textfield__label" for="description">Description...</label>
            </div>
            <div class="mdl-textfield">
                <input class="mdl-textfield__input" type="text" id="username" ng-model="vm.formData.username" />
                <label class="mdl-textfield__label" for="username">Username...</label>
            </div>
            <div class="mdl-textfield">
                <input class="mdl-textfield__input" type="password" id="password" ng-model="vm.formData.password" required />
                <label class="mdl-textfield__label" for="password">Password...</label>
            </div>
            <div class="">
                <button generate-password class="mdl-button">generate</button>
                <button toggle-insert-view class="mdl-button">cancel</button>
                <button save-password class="mdl-button" ng-disabled="insertForm.$invalid">save</button>
            </div>
        </fieldset>
    </form>
</div>

Add the JavaScript logic for handling form submission and saving:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
var loki = require('lokijs'),
    path = require('path');

angular
    .module('Utils', [])
    
    ...
    
    .service('Storage', ['$q', function($q) {
        this.db = new loki(path.resolve(__dirname, '../..', 'app.db'));
        this.collection = null;
        this.loaded = false;
        
        this.init = function() {
            var d = $q.defer();
            
            this.reload()
                .then(function() {
                    this.collection = this.db.getCollection('keychain');
                    d.resolve(this);
                }.bind(this))
                .catch(function(e) {
                    // create collection
                    this.db.addCollection('keychain');
                    // save and create file
                    this.db.saveDatabase();
                    
                    this.collection = this.db.getCollection('keychain');
                    d.resolve(this);
                }.bind(this));
                
                return d.promise;
        };
        
        this.addDoc = function(data) {
            var d = $q.defer();
            
            if(this.isLoaded() && this.getCollection()) {
                this.getCollection().insert(data);
                this.db.saveDatabase();
                
                d.resolve(this.getCollection());
            } else {
                d.reject(new Error('DB NOT READY'));
            }
            
            return d.promise;
        };
    })
    
    .directive('savePassword', ['Storage', function(Storage) {
        return function(scope, el) {
            el.bind('click', function(e) {
                e.preventDefault();
                
                if(scope.vm.formData) {
                    Storage
                        .addDoc(scope.vm.formData)
                        .then(function() {
                           // reset form & close insert window
                           scope.vm.formData = {};
                           ipc.send('toggle-insert-view');
                        });
                }
            });
        };
    }])

Key Points:

  • Initialize the database by creating a new Loki object. Provide the database file path, check for its existence, create it if needed (including the ‘Keychain’ collection), and load its contents into memory.
  • Retrieve a collection using getCollection().
  • Collection objects have methods like insert() to add new documents.
  • Use the Loki object’s saveDatabase() to persist data.
  • Reset the form data and send an IPC event to close the window after saving.

We now have a form for generating and saving passwords. Let’s display these entries in the main view.

Listing Passwords

We need to:

  • Retrieve all documents from our collection.
  • Update the main view when a new password is saved.

Retrieve documents using the Loki object’s getCollection(). The returned object’s data property holds an array of all documents:

1
2
3
4
5
6
7
8
this.getCollection = function() {
    this.collection = this.db.getCollection('keychain');
    return this.collection;
};
        
this.getDocs = function() {
    return (this.getCollection()) ? this.getCollection().data : null;
};

Call getDocs() in our Angular controller to retrieve all passwords after database initialization:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
angular
    .module('MainView', ['Utils'])
    .controller('MainCtrl', ['Storage', function(Storage) {
        var vm = this;
        vm.keychain = null;
        
        Storage
            .init()
            .then(function(db) {
                vm.keychain = db.getDocs();
            });
    });     

With some Angular templating, we have our password list:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<tr ng-repeat="item in vm.keychain track by $index" class="item--{{$index}}">
    <td class="mdl-data-table__cell--non-numeric">{{item.description}}</td>
    <td>{{item.username || 'n/a'}}</td>
    <td>
        <span ng-repeat="n in [1,2,3,4,5,6]">&bull;</span>
    </td>
    <td>
        <a href="#" copy-password="{{$index}}">copy</a>
        <a href="#" remove-password="{{item}}">remove</a>
    </td>
</tr>

Refreshing the password list after adding a new entry is a good feature. Use Electron’s IPC module for this. As mentioned, the remote module lets us turn a Renderer Process into a listener by accessing the Main Process’ IPC module. Here’s how to implement it in main.view.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var remote = require('remote'),
    remoteIpc = remote.require('ipc');

angular
    .module('MainView', ['Utils'])
    .controller('MainCtrl', ['Storage', function(Storage) {
        var vm = this;
        vm.keychain = null;
        
        Storage
            .init()
            .then(function(db) {
                vm.keychain = db.getDocs();
                
                remoteIpc.on('update-main-view', function() {
                    Storage
                        .reload()
                        .then(function() {
                            vm.keychain = db.getDocs();
                        });
                });
            });
    }]);

Key Points:

  • Use the remote module’s require() to access the remote IPC module.
  • Set up event listeners in the Renderer Process using the on() method.

The insert view will dispatch this event after saving a new document:

1
2
3
4
5
6
7
8
9
Storage
    .addDoc(scope.vm.formData)
    .then(function() {
        // refresh list in main view
        ipc.send('update-main-view');
        // reset form & close insert window
        scope.vm.formData = {};
        ipc.send('toggle-insert-view');
    });

Copying Passwords

Displaying plain text passwords is generally inadvisable. Instead, we’ll hide passwords and provide a button for easy copying.

Electron offers a clipboard module with convenient methods for copying and pasting text, images, and HTML:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
var clipboard = require('clipboard');

angular
    .module('Utils', [])
    
    ...
    
    .directive('copyPassword', [function() {
        return function(scope, el, attrs) {
            el.bind('click', function(e) {
                e.preventDefault();
                var text = (scope.vm.keychain[attrs.copyPassword]) ? scope.vm.keychain[attrs.copyPassword].password : '';
                // atom's clipboard module
                clipboard.clear();
                clipboard.writeText(text);
            });
        };
    }]);

Since our passwords are simple strings, we’ll use writeText() to copy them to the clipboard. Update the main view HTML with the copy button and the copy-password directive, passing the password array index:

1
<a href="#" copy-password="{{$index}}">copy</a>

Removing Passwords

Allow users to delete obsolete passwords. To do this, call the remove() method on the keychain collection, providing the entire document:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
this.removeDoc = function(doc) {
    return function() {
        var d = $q.defer();
        
        if(this.isLoaded() && this.getCollection()) {
            // remove the doc from the collection & persist changes
            this.getCollection().remove(doc);
            this.db.saveDatabase();
            
            // inform the insert view that the db content has changed
            ipc.send('reload-insert-view');
            
            d.resolve(true);
        } else {
            d.reject(new Error('DB NOT READY'));
        }
        
        return d.promise;
    }.bind(this);
};

Loki.js documentation suggests removing by ID, but this doesn’t seem to work as expected.

Implementing a Desktop Menu

Electron seamlessly integrates with desktop environments, providing a native look and feel. It includes a Menu module for creating sophisticated desktop menus.

Menus are a broad topic deserving their own tutorial. Explore Electron’s Desktop Environment Integration tutorial for a comprehensive understanding of this module.

Here, we’ll cover creating a custom menu, adding a custom command, and implementing the standard quit command.

Creating & Assigning a Custom Menu

While menu logic typically resides in the main script file (Main Process), we can abstract it to a separate file and access the Menu module using the remote module:

1
2
var remote = require('remote'),
    Menu = remote.require('menu');

Use the buildFromTemplate() method to define a simple menu:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var appMenu = Menu.buildFromTemplate([
    {
        label: 'Electron',
        submenu: [{
            label: 'Credits',
            click: function() {
                alert('Built with Electron & Loki.js.');
            }
        }]
    }
]);

The first array item is always the “default” menu item.

The label value for the default menu item doesn’t significantly matter. In dev mode, it’s always Electron. We’ll customize this during the build process.

Assign the custom menu as the default using setApplicationMenu():

1
Menu.setApplicationMenu(appMenu);

Mapping Keyboard Shortcuts

Electron provides “accelerators”, predefined strings mapping to keyboard combinations like Command+A or Ctrl+Shift+Z.

The Command accelerator doesn’t work on Windows or Linux. For our password keychain, let’s add a File menu with these commands:

  • Create Password: Opens the insert view with Cmd (or Ctrl) + N
  • Quit: Quits the app with Cmd (or Ctrl) + Q
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
...
{
    label: 'File',
    submenu: [
        {
            label: 'Create Password',
            accelerator: 'CmdOrCtrl+N',
            click: function() {
                ipc.send('toggle-insert-view');
            }
        },
        {
            type: 'separator' // to create a visual separator
        },
        {
            label: 'Quit',
            accelerator: 'CmdOrCtrl+Q',
            selector: 'terminate:' // OS X only!!!
        }
    ]
}
...

Key Points:

  • Add a visual separator using an item with type set to separator.
  • CmdOrCtrl works on both Mac and PC keyboards.
  • The selector property is OSX-specific.

Styling Our App

You’ve probably noticed mdl- prefixed class names. This tutorial uses the Material Design Lite UI framework, but feel free to choose your preferred one.

Electron supports any HTML5 capabilities. However, be mindful of app size and potential performance impacts from excessive third-party libraries.

Packaging Electron Apps

Your Electron app is ready for distribution after thorough testing, perhaps using Selenium and WebDriver for e2e tests.

Let’s personalize it with a custom name instead of the default “Electron” and add custom icons for Mac and PC.

Building with Gulp

Gulp plugins exist for almost everything. Searching for “gulp electron” leads us to the gulp-electron plugin.

With the folder structure from the beginning, this plugin is straightforward to use. Install it like any Gulp plugin:

1
$ npm install gulp-electron --save-dev

Define the Gulp task as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var gulp = require('gulp'),
    electron = require('gulp-electron'),
    info = require('./src/package.json');

gulp.task('electron', function() {
    gulp.src("")
    .pipe(electron({
        src: './src',
        packageJson: info,
        release: './dist',
        cache: './cache',
        version: 'v0.31.2',
        packaging: true,
        platforms: ['win32-ia32', 'darwin-x64'],
        platformResources: {
            darwin: {
                CFBundleDisplayName: info.name,
                CFBundleIdentifier: info.bundle,
                CFBundleName: info.name,
                CFBundleVersion: info.version
            },
            win: {
                "version-string": info.version,
                "file-version": info.version,
                "product-version": info.version
            }
        }
    }))
    .pipe(gulp.dest(""));
});

Key Points:

  • The src/ folder should be different from the Gulpfile.js and distribution folders.
  • Specify target platforms in the platforms array.
  • Define a cache folder for downloaded Electron binaries.
  • Pass the package.json content via the packageJson property.
  • Optionally create zip archives using the packaging property.
  • Each platform has specific “platform resources” as outlined in can be defined.

Adding App Icons

The icon property within platformResources lets us set custom icons:

1
"icon": "keychain.ico"

OS X requires .icns icons. Numerous online tools can convert .png to .ico and .icns.

Conclusion

This tutorial provides a glimpse into Electron’s capabilities. Draw inspiration from remarkable apps like Atom or Slack.

Feel free to share your comments and experiences with Electron!

Licensed under CC BY-NC-SA 4.0