Hello and welcome to the Desktop Apps Workshop!
Developing native desktop applications is quite challenging. Packaging, installation, and update management are huge points of frustration that deter many developers from pursing desktop apps. Each operating system also requires its own specific language to be used to develop native apps, which further adds to the build complexity, build time, and the managing of multiple os-specific applications that at the end of the day are supposed to be the same app. Traditional developers would not only need to learn additional programming languages to build desktop applications for Windows, OSX and Linux, but also dive into each OS's APIs, patterns, file structures and requirements.
Desktop applications do have multiple advantages over their web counterparts. Native features like notifications and menus can be leveraged along with building browser-independent experiences that can further add to your user's experience.
Web applications, however, are far more approachable. The languages required to build web applications are far more approachable - in fact, all of us here are already familiar with the three most common/required ones (HTML, CSS, JavaScript). These languages are universal and cross platform, allowing us to build once for every browser/user. Browsers also exist on pretty much everyone's OS in one form or another. This makes web applications a lot easier to adopt as no additional tooling/software is required to access/use them. Newer browsers also provide fantastic development tooling. Devtools, Page Inspectors, Firebug (lol), and an incredible list of browser plugins really speed up and simplify the development process for building web applications. There's also a huge selection of libraries and frameworks developers can leverage to further assist in building bigger, faster applications more efficiently. Some of the most popular ones today include React, Angular, and good ol' jQuery.
Things aren't entirely magical in the world of web applications. Developers are required to handle browser-specific requirements, fallbacks, and conditional rules which can prevent the usage of newer/faster browser features. Browsers are also quite rubbish at interacting with the file system and are hugely dependent on the network quality and strength.
An ideal scenario would entail the flexibility and accessibility of web technology, the cross-platform flexibility of browsers, and the native experience and features of a desktop application. All this would be wrapped into a simple executible. All the advantages from both worlds, with non of the disadvantages, barriers, and limitations!
The Electron framework allows us to create cross-platform native applications that rely on web technology, HTML, CSS and JavaScript. Electron provides a robust set of JavaScript APIs that interface with various operating systems and their particular nuances. It uses web pages to create user interfaces, just like a web application.
You can look at it as a simplified, watered-down browser capable of interacting with the native operating system. This 'browser' becomes part of your application's packaging and is distributed to whoever installs it. You code it once, and create distributions for Windows, Mac OSX, and Linux in one go.
Electron also allows you to focus on building your application and what it does, rather than how it does it. When calling functions that interact with the operating system, you can be confident knowing that Electron handles the OS specifics itself, allowing you to speed on ahead instead of worrying about the differences between each operating system's requirements.
Electron includes Chromium's APIs allowing you to leverage browser technology to its fullest, Node JS's modules, and supports including third party modules too.
Although Electron is entirely open source, its most definitely used for a lot of production grade applications by developers around the world. Slack, the Atom Text Editor, Nylas's N1 Mail Client, WordPress.com's Desktop App, and Microsoft's Visual Studio Code are all excellent examples of large complex applications built using Electron. Additional examples
- Windows
- Windows 7 or later. Older Windows versions are not supported and will not work.
- x86 and x64 binaries are provided for Windows.
- Mac
- OS X 10.9 or later. Older macOS versions are not supported and will not work.
- Only 64bit binaries are provided.
- Linux
- Ubuntu 12.04 is the most stable and guaranteed to work.
- Versions later than Ubuntu 12.04, Fedora 21, or Debian 8 are verified but not guaranteed.
- Node.js - A JavaScript runtime built on Chrome's V8 engine. Node.js also provides a massive package ecosystem, npm, which we will be leveraging to bring in third party libraries.
A standard Electron app begins with a structure as follows:
gif-app/
├── package.json
├── main.js
└── index.html
The package.json
file will include a few simple items to get the ball moving.
{
"name" : "the-app",
"version" : "0.1.0",
"main" : "main.js"
}
The script specified for the main
field is our startup script. If no value is present, Electron will attempt to load index.js
.
The first package required to get going is the Electron package itself. npm install electron --save-dev
will install the Electron package as a development dependency.
Start the main.js
file by importing a few basic utilities from Electron along with a few other dependencies.
const {app, BrowserWindow} = require('electron');
const path = require('path');
const url = require('url');
Next, we need to write a function that creates a new BrowserWindow
instance. We can pass in some options to specify the size of the window created. We also want this window instance to load the index.html
file that's in the project directory.
let win;
// a global reference to our windows. this can be a single window or an array of windows.
function createWindow() {
win = new BrowserWindow({
width: 800,
height: 500
});
win.loadURL(url.format({
pathname: path.join(__dirname, 'index.html'),
protocol: 'file:',
slashes: true
}));
}
We then tell Electron to run this createWindow
when the application's state is ready to execute commands. Think of this as jQuery's .ready
.
app.on('ready', createWindow);
To properly handle closing of windows, we need to set our window win
variable back to null in the createWindow()
function. The main.js
file should now look as follows:
const {app, BrowserWindow} = require('electron');
const path = require('path');
const url = require('url');
let win;
function createWindow() {
win = new BrowserWindow({
width: 800,
height: 500
});
win.loadURL(url.format({
pathname: path.join(__dirname, 'index.html'),
protocol: 'file:',
slashes: true
}));
win.on('closed', () => {
win = null;
});
}
app.on('ready', createWindow);
Add a tiny bit of basic markup in the index.html
file. The following code will do for now.
<!DOCTYPE html>
<html lang="en">
<head>
<title>GIF!</title>
</head>
<body>
<h1>hello world!</h1>
</body>
</html>
To run an Electron app, call Electron along with the path to our app. We didn't install the Electron package globally, as we can utilize the version sitting in our node_modules
folder.
# macOS
./node_modules/.bin/electron .
# Windows
.\node_modules\.bin\electron .
Where .
represents the current folder directory (our app).
Success! Let's add an npm script to our package.json
file to handle running our Electron app.
{
"name": "the-app",
"version": "0.1.0",
"main": "main.js",
"scripts": {
"start": "./node_modules/.bin/electron ."
},
"devDependencies": {
"electron": "^1.6.6"
}
}
A core feature in Electron is its ability to run two or more operating system level processes concurrently. These are referred to as the 'main' and 'renderer' processes.
A process is an instance of a computer program being executed. If we run an Electron application and check the Activity Monitor in MacOS we'd see the following:
The 'Electron' process is the main process, one of the helpers is a GPU process, and the remaining helpers are various renderer processes.
The main thing to remember here is that they run concurrently and completely isolated from one another. This is extremely valuable as it keeps any issues/errors isolated from the other renderer instances preventing the entire app from crashing if one particular renderer instance falls apart.
Any low-level system related functionality should exist in the main process. Everything else goes in a (or many) renderer process(es). Keep the main process as light as possible to prevent constant "beach ball"-ing.
A renderer is fairly straightforward to create. Its essentially a JavaScript file linked up to an HTML file that loads up into an Electron BrowserWindow
instance.
Let's create a renderer.js
file in our folder and hook it up to our index.html
file.
// renderer.js
console.log('Renderer Process');
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>GIF!</title>
</head>
<body>
<h1>hello world</h1>
</body>
<script>
require('./renderer.js')
</script>
</html>
We'll continue building on top of our starter app. Our end goal is to create a desktop app that allows us to search through and share GIFs. We'll be utilizing GIPHY.com's API service to request for GIFs that pertain to a particular search query.
The GIPHY API has a public beta key that we can use when developing applications. You'll need a production key if and when you decide to publish and distribute your app. Request a public key details.
Thanks to the awesomesness of developer culture, someone also created a JavaScript module to help make API calls to GIPHY that supports promises and callbacks. This further simplifies our application's development. Let's install this npm package now.
npm install giphy-api --save
To get a list of GIFs rendering in our application we need to create a wrapper in our HTML where we can dump our GIF list.
<div id="gif-list"></div>
In renderer.js
, we first create a giphy
variable and initialize a giphy-api
instance.
const giphy = require('giphy-api')();
This creates a giphy-api
instance using the development key. The same initialization with a production key would look as follows:
const giphy = require('giphy-api')('API KEY HERE');
Next, we make the GIPHY api call with testing data (I'll be using the query pokemon
), and calling additional functions when the request returns data and resolves the promise.
const gifContainer = document.getElementById('gif-list');
giphy.search({
q: 'pokemon'
}).then(function (res) {
// Res contains gif data!
updateGIFList(res.data);
});
function updateGIFList(data) {
gifContainer.innerHTML = buildList(data);
}
function buildList(data) {
return data.map((gif) => {
return `<button data-url="${gif.images.original.url}" class="gif-item">
<img class="gif-image" src="${gif.images.original.url}">
</button>`;
}).join('');
}
We'll also take this time to add a bit of CSS to the application. The following will suffice for the time being.
body {
margin: 0;
padding: 0 10px;
background-color: black;
color: white;
font-family: sans-serif;
}
h1 {
margin: 0;
padding: 10px 15px;
font-size: 16px;
}
.gif-item {
display: block;
padding: 0;
outline: 0;
border: 0;
width: 100%;
cursor: pointer;
position: relative;
}
.gif-item ~ .gif-item {
margin-top: 10px;
}
.gif-image {
width: 100%;
display: block;
}
As users, we'd like to click on one of the GIFs and have the GIF's URL copy to our clipboard. Clipboard management is normally quite tedious when development web applications, or native applications, but Electron does all the heavy lifting for us. <3 you Electron.
First, require clipboard from the electron
package.
const {clipboard} = require('electron');
// or
const clipboard = require('electron').clipboard;
Since the GIF list is built dynamically, we need to add event listeners to the gif container to listen for clicks on any img
element.
gifContainer.addEventListener('click', clickHandler, true);
function clickHandler(e) {
if (e.target.classList.contains('gif-image')) {
console.log('click on a gif!');
console.log(e.target.src);
}
}
And finally, we can use the writeText
method on the clipboard
instance to write the image's src
value to the clipboard.
clipboard.writeText(e.target.src);
Give it a shot! Hashtag Magical.
So, lets be a little crazy by utilizing Electron's notifications API to notify the user every time they copy a GIF to their clipboard. Because why the heck not.
let notif = new window.Notification('GIF Copied!', {
body: `You copied a GIF!`,
silent: true
});
As hilarious as the current GIFs are, our application will be far more useful if we can serach for particular queries. Let's update the markup to add a text input with some corresponding styles.
<input type="text" id="gif-search" placeholder="search...">
#gif-search {
display: block;
width: 100%;
padding: 10px 15px;
font-size: 16px;
margin-bottom: 20px;
background: none;
border: 0;
color: white;
border-bottom: 3px solid rgba(255, 255, 255, 0.5);
transition: all 0.1s ease-in-out;
}
#gif-search:focus {
outline: none;
border-color: rgba(255, 255, 255, 1);
}
Great! Now lets add some functionality to this input field. An event listener on the gif-search
element looking for the keyup
event will allow us to continuously run new giphy API queries.
document.getElementById('gif-search').addEventListener('keyup', (e) => {
giphy.search({
q: e.target.value
}).then(function (res) {
updateGIFList(res.data);
});
});
This isn't super smart as the keyup
event fires when we use shortcuts to select text, etc, so lets add in some checking to only make the API calls if and when the query is different from the last keyup
event.
let currentSearch = '';
document.getElementById('gif-search').addEventListener('keyup', (e) => {
if ((e.target.value !== currentSearch) && e.target.value !== '') {
giphy.search({
q: e.target.value
}).then(function (res) {
currentSearch = e.target.value;
updateGIFList(res.data);
});
} else if (e.target.value === '') {
giphy.trending().then((res) => {
updateGIFList(res.data);
});
currentSearch = e.target.value;
}
});
Tray icons are a great way for users to quickly access their most used applications. Our GIF application is a great candidate for this use case, as we'll be GIF-ying it up all day.
Firstly, create a Tray
variable and require it from the electron
package.
const {app, BrowserWindow, Tray} = require('electron');
Then, build out a new function that initializes a new Tray instance. A Tray
instance requires a path to the icon we wish to use. We need to make sure that appIcon is declared outside of the makeTray function, otherwise our icon will get garbage collected by JavaScript and disappear from our tray!
let appIcon;
function makeTray() {
appIcon = new Tray('./app-icon.png');
appIcon.setToolTip('Electron.js App');
}
We can then set the icon's tooltip text so a user always knows which application the icon is for.
appIcon.setToolTip('GIF All The Things!');
Add a click handler to run createWindow
if a window instance doesn't already exist.
appIcon.on('click', function() {
if (!win) {
createWindow();
}
});
Our function should look like this:
let appIcon;
function makeTray() {
appIcon = new Tray('./app-icon.png');
appIcon.setToolTip('Electron.js App');
appIcon.on('click', function() {
if (!win) {
createWindow();
}
});
}
And finally, update the application's ready
callback to run createWindow()
and our newly created makeTray()
;
app.on('ready', function() {
createWindow();
makeTray();
});
The application looks a little choppy, but a tiny bit of CSS animations can address some of the flickering/janky issues when updating the GIF listing.
.gif-item {
...
-webkit-animation: fadein 0.5s;
animation: fadein 0.5s;
}
@keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}
The application initially loads a white browser shell followed by the HTML and CSS loading up. This causes a brief white flicker when first loading the application. This can be avoided by providing a backgroundColor
value when initializing the BrowserWindow
.
win = new BrowserWindow({
width: 800,
height: 500,
backgroundColor: "#000"
});
Simple applications such as ours normally don't allow a user to resize the application frame. Let's restrict resizing by setting the resizable
flag when creating the BrowserWindow
instance.
win = new BrowserWindow({
..
resizable: false,
..
});
On macOS, its common and very normal for applications and menu bars to stay active until a user explicitly quits the application through the menu bar, or with Cmd + Q
. Adding in some additional code in the main process will only quit the application on non-mac operating systems unless explicitly asked.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (win === null) {
createWindow();
}
});
Packaging an Electron application allows us to create an executable file for users. We accomplish this by first including the electron-packager
npm package.
npm install electron-packager --save-dev
Similar to running the application, we'll add a new npm script to use electron-package
to package the application.
"build": "electron-packager . GIFApp
Note: GIFApp
is the name you decide for the packaged application.
The electron-packager
will by default produce a package for your current platform and architecture type. For example, on macOS it produces a package for the 'darwin' platform for the '64bit' architecture type. You can use the --all
flag to build bundles for all valid combinations for platforms and architectures.
You'll notice that the icon representing our running application is the Electron logo. Right click the packaged application and select Show Package Contents
and navigate to Contents>Resources
. Here you'll see the .icns
file that the application uses.
At build time, we need to copy our own .icns
file in the above mentioned folder to use a different icon. The build
script should now look as follows:
"build": "electron-packager . GIFApp && cp Icon.icns GIFApp-darwin-x64/GIFApp.app/Contents/Resources/electron.icns"
Note: This snippet shows the required path for macOS. Windows users will have a slightly different path, but the concept should remain the same.
Making to first delete the previously built package (rm -rf [foldername]
) running the build script again gives us a packaged application with the correct icon in use!
You may have noticed that the Contents/Resources/app
folder essentially gives a user full access to the application's files and functionality. Another node package by the name of asar
helps address this issue by creating an archive of the app
folder.
Once again, we first need to bring in this new dependency.
npm install asar --save-dev
Then, add a new npm script to update a built app's packaging.
"package": "asar pack GIFApp-darwin-x64/GIFApp.app/Contents/Resources/app GIFApp-darwin-x64/GIFApp.app/Contents/Resources/app.asar"
Running the package
script after the build
script should create an unaccessible archive of the app itself, allowing us to delete the app
folder from Contents/Resources
.
There's a wide range of improvements we could make to our application. A list of a few of those are as follows
- Favourites: Leveraging Local Storage could allow a user to 'favourite' their most used/liked GIFs for future reuse in a dedicated separate listing.
- Positioning: Our application currently doesn't have properly defined positioning. Using packages such as electron-positioner would allow us to ensure our application opens up centered perfectly underneath the tray icon on macOS and centered perfectly above the tray section on Windows/Linux.
I'd recommend installing the Electron API Demos
app from the Electron website that further explains the various APIs Electron has to offer.
Electron is still quite a young framework and has incredibly huge potential, and I'm incredibly excited to see where and how it continues to progress.
I hope this workshop has been valuable, educational and most of all, motivational to get you building some super cool cross platform desktop applications!