Earlier this year, Github released Atom-Shell, the core of its
famous open-source editor Atom, and renamed it to Electron for the special occasion.
Electron, unlike
other competitors in the category of Node.js-based desktop applications, brings
its own twist to this already well-established market by combining the power of
Node.js (io.js until recent releases) with the Chromium Engine to bring us the best of both server and client-side JavaScript.
Imagine a world where we could build performant, data-driven,
cross-platform desktop applications powered by not only the ever-growing
repository of NPM modules, but also the entire Bower registry to fulfill all
our client-side needs.
Enter Electron.
Building Cross-platform Desktop Apps with Electron:
In this tutorial, we will build a simple password keychain
application using Electron, Angular.js and Loki.js, a lightweight and
in-memory database with a familiar syntax for MongoDB developers.
The full source code for this application is available here.
This tutorial assumes that:
The
reader has Node.js and Bower installed on their machine.
They
are familiar with Node.js, Angular.js and MongoDB-like query syntax.
Getting the Goods
First things first, we will need to get the Electron binaries in
order to test our app locally. We can install it globally and use it as a CLI,
or install it locally in our application’s path. I recommend installing it
globally, so that way we do not have to do it over and over again for every app
we develop.
We will learn later how to package our application for
distribution using Gulp. This process involves copying the Electron binaries,
and therefore it makes little to no sense to manually install it in our
application’s path.
To install the Electron CLI, we can type the following command
in our terminal:
$ npm install -g electron-prebuilt
To test the installation, type electron -h and it should display the
version of the Electron CLI.
At the time this article was written, the version of Electron
was 0.31.2.
Setting up the Project
Let’s assume the following basic folder structure:
my-app
|- cache/
|- dist/
|- src/
|-- app.js
| gulpfile.js
… where: - cache/ will be used to download the Electron
binaries when building the app. - dist/ will contain the generated
distribution files. - src/ will contain our source code. - src/app.js
will be the entry point of our application.
Next, we will navigate to the src/ folder in our terminal and create the package.json and bower.json files for our app:
$ npm init
$ bower init
We will install the necessary packages later on in this
tutorial.
Understanding Electron Processes
Electron distinguishes between two types of processes:
The
Main Process: The entry point of our application,
the file that will be executed whenever we run the app. Typically, this file
declares the various windows of the app, and can optionally be used to define
global event listeners using Electron’s IPC
module.
The
Renderer Process:
The controller for a given window in
our application. Each window creates its own Renderer
Process.
For code clarity, a separate file should be used for each
Renderer Process. To define the Main Process for our app, we will open src/app.js and include the app module to start the app,
and the browser-window module to create the various windows of our app (both part of
the Electron core), as such:
var app = require('app'),
BrowserWindow = require('browser-window');
When the app is actually started, it fires a ready event, which we can bind
to. At this point, we can instantiate the main window of our app:
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:
We
create a new window by creating a new instance of the BrowserWindow object.
It
takes an object as a single argument, allowing us to define various
settings, amongst which the default width and
height of the window.
The
window instance has a loadUrl() method, allowing us to load the contents of an actual HTML file
in the current window. The HTML file can either be local or remote.
The
window instance has an optional openDevTools() method, allowing us to open an instance of the Chrome Dev Tools
in the current window for debugging purposes.
Next, we should organize our code a little. I recommend creating
a windows/
folder in our src/
folder, and where we can create a subfolder for each window, as such:
my-app
|- src/
|-- windows/
|--- main/
|---- main.controller.js
|---- main.html
|---- main.view.js
… where main.controller.js will contain the “server-side” logic of our application, and main.view.js will contain the
“client-side” logic of our application.
The main.html file is simply an HTML5 webpage, so we can simply start it like
this:
<html>
<head>
<meta charset="utf-8">
<title>Password Keychain</title>
</head>
<body>
<h1>Password Keychain</h1>
</body>
</html>
At this point, our app should be ready to run. To test it, we
can simply type the following in our terminal, at the root of the src folder:
$ electron .
We can automate this process by defining the start script of the package.son
file.
Building a Password Keychain Desktop
App
To build a password keychain application, we need: - A way to
add, generate and save passwords. - A convenient way to copy and remove
passwords.
Generating and Saving Passwords
A simple form will suffice to insert new passwords. For the sake
of demonstrating communication between multiple windows in Electron, start by
adding a second window in our application, which will display the “insert”
form.
Since we will open and close this window multiple times, we should wrap
up the logic in a method so that we can simply call it when needed:
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:
We
will need to set the show property to false in the options object
of the BrowserWindow constructor, in order to prevent the window from being
open by default when the applications starts.
We
will need to destroy the BrowserWindow instance whenever the window is firing a
closed event.
Opening and Closing the “Insert” Window
The idea is to be able to trigger the “insert” window when the
end user clicks a button in the “main” window. In order to do this, we will
need to send a message from the main window to the Main Process to instruct it
to open the insert window. We can achieve this using
Electron’s IPC module.
There are actually two variants of the IPC module:
One
for the Main
Process, allowing the app to subscribe to
messages sent from windows.
One
for the Renderer
Process, allowing the app to send messages to
the main process.
Although Electron’s communication channel is mostly
uni-directional, it is possible to access the Main Process’ IPC module in a
Renderer Process by making use of the remote module.
Also, the Main Process can send a message back to the
Renderer Process from which the event originated by using the Event.sender.send() method.
To use the IPC module, we just require it like any other NPM
module in our Main Process script:
var ipc = require('ipc');
… and then bind to events with the on() method:
ipc.on('toggle-insert-view', function() {
if(!insertWindow) {
createInsertWindow();
}
return (!insertWindow.isClosed() && insertWindow.isVisible())
? insertWindow.hide() : insertWindow.show();
});
Key Points:
We
can name the event however we want, the example is just arbitrary.
Do
not forget to check if the BrowserWindow instance is already created, if not
then instantiate it.
The
BrowserWindow instance has some useful methods:
isClosed() returns a boolean, whether or not the window is currently in a closed state.
isVisible(): returns a boolean, whether or not the window is currently
visible.
show()
/ hide(): convenience methods to show and hide the window.
Now we actually need to fire that event from the Renderer
Process. We will create a new script file called main.view.js, and add it to our HTML
page like we would with any normal script:
<script src="./main.view.js"></script>
Loading the script file via the HTML script tag loads this file in a
client-side context. This means that, for example, global variables are
available via window. . To load a script in a server-side context, we can use the require() method directly in our
HTML page: require('./main.controller.js');.
Even though the script is loaded in client-side context, we can
still access the IPC module for the Renderer Process in the same way that we
can for the Main Process, and then send our event as such:
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');
});
};
});
There is also a sendSync() method available, in case we need to
send our events synchronously.
Now, all we have left to do to open the “insert” window is to
create an HTML button with the matching Angular directive on it:
<div ng-controller="MainCtrl as vm">
<button toggle-insert-view class="mdl-button">
<i class="material-icons">add</i>
</button>
</div>
And add that directive as a dependency of the main window’s
Angular controller:
angular
.module('MainWindow', ['Utils'])
.controller('MainCtrl', function() {
var vm = this;
});
Generating Passwords
To keep things simple, we can just use the NPM uuid module to generate unique
ID’s that will act as passwords for the purpose of this tutorial. We can
install it like any other NPM module, require it in our ‘Utils’ script and then
create a simple factory that will return a unique ID:
var uuid = require('uuid');
angular
.module('Utils', [])
...
.factory('Generator', function() {
return {
create: function() {
return uuid.v4();
}
};
})
Now, all we have left to do is create a button in the insert
view, and attach a directive to it that will listen to click events on the
button and call the create() method:
<button generate-password class="mdl-button">generate</button>
// 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
At this point, we want to store our passwords. The data
structure for our password entries is fairly simple:
{
"id": String
"description": String,
"username": String,
"password": String
}
So all we really need is some kind of in-memory database that
can optionally sync to file for backup. For this purpose, Loki.js seems like
the ideal candidate. It does exactly what we need for the purpose of this
application, and offers on top of it the Dynamic Views feature, allowing us to
do things similar to MongoDB’s Aggregation module.
Dynamic Views do not offer all the functionality that
MongodDB’s Aggregation module does. Please refer to the documentation for more information.
Let’s start by creating a simple HTML form:
<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>
And now, let’s add the JavaScript logic to handle posting and
saving of the form’s contents:
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:
We
first need to initialize the database. This process involves creating a new
instance of the Loki Object, providing the path to the database file as an
argument, looking up if that backup file exists, creating it if needed
(including the ‘Keychain’ collection), and then loading the contents of this
file in memory.
We
can retrieve a specific collection in the database with the getCollection() method.
A
collection object exposes several methods, including an insert() method, allowing us to add
a new document to the collection.
To
persist the database contents to file, the Loki object exposes a saveDatabase() method.
We
will need to reset the form’s data and send an IPC event to tell the Main
Process to close the window once the document is saved.
We now have a simple form allowing us to generate and save new
passwords. Let’s go back to the main view to list these entries.
Listing Passwords
A few things need to happen here:
We
need to be able to get all the documents in our collection.
We
need to inform the main view whenever a new password is saved so it can refresh
the view.
We can retrieve the list of documents by calling the getCollection() method on the Loki
object. This method returns an object with a property called data, which
is simply an array of all the documents in that collection:
this.getCollection = function() {
this.collection = this.db.getCollection('keychain');
return this.collection;
};
this.getDocs = function() {
return (this.getCollection()) ? this.getCollection().data : null;
};
We can then call the getDocs() in our Angular controller and
retrieve all the passwords stored in the database, after we initialize it:
angular
.module('MainView', ['Utils'])
.controller('MainCtrl', ['Storage', function(Storage) {
var vm = this;
vm.keychain = null;
Storage
.init()
.then(function(db) {
vm.keychain = db.getDocs();
});
});
A bit of Angular templating, and we have a password list:
<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]">•</span>
</td>
<td>
<a href="#" copy-password="{{$index}}">copy</a>
<a href="#" remove-password="{{item}}">remove</a>
</td>
</tr>
A nice added feature would be to refresh the list of passwords
after inserting a new one.
For this, we can use Electron’s IPC module. As
mentioned earlier, the Main Process’ IPC module can be called in a Renderer
Process to turn it into a listener process, by using the remote module.
Here is
an example on how to implement it in main.view.js:
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:
We
will need to use the remote module via its own require() method to require the
remote IPC module from the Main Process.
We
can then setup our Renderer Process as an event listener via the on() method, and bind callback
functions to these events.
The insert view will then be in charge of dispatching this event
whenever a new document is saved:
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
It is usually not a good idea to display passwords in plain
text. Instead, we are going to hide and provide a convenience button allowing the
end user to copy the password directly for a specific entry.
Here again, Electron comes to our rescue by providing us with a clipboard module with easy methods to copy and paste not only text
content, but also images and HTML code:
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 the generated password will be a simple string, we can use
the writeText()
method to copy the password to the system’s clipboard.
We can then update our
main view HTML, and add the copy button with the copy-password directive on it, providing
the index of the array of passwords:
<a href="#" copy-password="{{$index}}">copy</a>
Removing
Passwords
Our end users might
also like to be able to delete passwords, in case they become obsolete. To do
this, all we need to do is call the remove() method on the keychain collection. We need
to provide the entire doc to the ‘remove()’ method, as such:
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
states that we can also remove a doc by its id, but it does not seem to be
working as expected.
Creating
a Desktop Menu
Electron integrates
seamlessly with our OS desktop environment to provide a “native” user
experience look & feel to our apps. Therefore, Electron comes bundled with
a Menu module, dedicated to creating complex desktop menu structures for our
app.
The menu module is a
vast topic and almost deserves a tutorial of its own. I strongly recommend you
read through Electron’s Desktop Environment Integration
tutorial to
discover all the features of this module.
For the scope of this
current tutorial, we will see how to create a custom menu, add a custom command
to it, and implement the standard quit command.
Creating
& Assigning a Custom Menu to Our App
Typically, the
JavaScript logic for an Electron menu would belong in the main script file of
our app, where our Main Process is defined.
However, we can abstract it to a
separate file, and access the Menu module via the remote module:
var remote = require('remote'),
Menu = remote.require('menu');
To define a simple
menu, we will need to use the buildFromTemplate() method:
var appMenu = Menu.buildFromTemplate([
{
label: 'Electron',
submenu: [{
label: 'Credits',
click: function() {
alert('Built with Electron & Loki.js.');
}
}]
}
]);
The first item in the
array is always used as the “default” menu item.
The value of the label property does not matter much for the default menu item. In dev
mode it will always display Electron. We will see later how to assign a custom
name to the default menu item during the build phase.
Finally, we need to
assign this custom menu as the default menu for our app with the setApplicationMenu() method:
Menu.setApplicationMenu(appMenu);
Mapping
Keyboard Shortcuts
Electron provides “accelerators”, a set of pre-defined strings that map to
actual keyboard combinations, e.g.: Command+A or Ctrl+Shift+Z.
The Command accelerator does not work on Windows or Linux. For our password
keychain application, we should add a File menu item, offering two commands:
Create Password: open the insert view with Cmd (or Ctrl) +
N
Quit: quit the app altogether with Cmd (or Ctrl)
+ Q
...
{
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:
We can add a visual separator by adding an
item to the array with the type property set to separator.
The CmdOrCtrl accelerator is compatible with both Mac and PC keyboards
The selector property is OSX-compatible only!
Styling
Our App
You probably noticed
throughout the various code examples references to class names starting with mdl-. For the purpose of this tutorial I opted to use the Material Design Lite UI framework, but feel free to use any UI
framework of your choice.
Anything that we can do
with HTML5 can be done in Electron; just keep in mind the growing size of the
app’s binaries, and the resulting performance issues that may occur if you use
too many third-party libraries.
Packaging
Electron Apps for Distribution
You made an Electron
app, it looks great, you wrote your e2e tests with Selenium and WebDriver, and you are ready to distribute it to the
world!
But you still want to
personalize it, give it a custom name other than the default “Electron”, and maybe
also provide custom application icons for both Mac and PC platforms.
Building
with Gulp
These days, there is a Gulp plugin for anything we can think of. All I had to do is type gulp electron in Google, and sure enough there is a gulp-electron plugin!
This plugin is fairly easy to use as long as the folder structure detailed at
the beginning of this tutorial was maintained. If not, you might have to move
things around a bit.
This plugin can be
installed like any other Gulp plugin:
$ npm install gulp-electron
--save-dev
And then we can define
our Gulp task as such:
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 cannot be the same as the folder where the Gulpfile.js is, nor the same
folder as the distribution folder.
We can define the platforms we wish to
export to via the platforms array.
We should define a cache
folder, where the Electron binaries will be download so they can be packaged
with our app.
The contents of the app’s package.json file
need to be passed to the gulp task via the packageJson property.
There is an optional packaging property, allowing us to also create zip archives of the
generated apps.
For each platform, there is a different set
of “platform resources” that can be defined.
Adding
App Icons
One of the platformResources properties is the icon
property, allowing us to define a custom icon for our app:
"icon": "keychain.ico"
OS X requires icons
with the .icns file extension. There are multiple online
tools allowing us to convert .png files into .ico
and .icns for free.
Conclusion
In this article we have
only scratched the surface of what Electron can actually do. Think of great
apps like Atom or Slack as a source of inspiration where you can go
with this tool.
I hope you found this
tutorial useful, please feel free to leave your comments and share your
experiences with Electron!
;;
Source: Originally published on the Toptal Engineering blog
Electron is a popular framework for building cross-platform desktop applications using web technologies such as HTML, CSS, and JavaScript. Electron uses Node.js and Chromium to provide a runtime environment for building desktop applications that can run on Windows, macOS, and Linux.
To build an Electron app, you need to have a good understanding of web technologies, particularly JavaScript and HTML. You will also need to learn how to use Node.js, which is the runtime environment that Electron is built on.
Here are the basic steps to build an Electron app:
Set up your development environment: You'll need to install Node.js and Electron on your machine, and set up a code editor such as Visual Studio Code.
Create your app: You can use HTML, CSS, and JavaScript to build the user interface of your app, just like you would for a web app.
Integrate with Node.js: You can use Node.js to perform file operations, access system resources, and interact with the operating system.
Package your app: Once your app is complete, you can use Electron's packaging tools to create an executable file for each platform you want to support.
Distribute your app: You can distribute your app through various channels, such as an app store or a website.
Electron provides a rich set of APIs for building desktop applications, including access to system resources such as the file system, the clipboard, and the network. With its ease of use and cross-platform capabilities, Electron is a great choice for building desktop applications that can run on multiple operating systems.