Skip to content
slides and sample app for learning Electron
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
app
.gitignore
LICENSE
configuration.js
main.js
package.json
readme.md
sound-machine-config.json

readme.md

building desktop apps with electron? main idea = build 1 codebase and package it for each OS separately. this abstracts away OS-specific knowledge to make maintenance easier.

Electron

  • provides a runtime to build desktop apps with pure JS

How?

  1. Electron takes a Main file defined in your package.json file and executes it. Usually called main.js, this file then creates application windows which contain rendered web pages w the added power of interacting with the native GUI of your OS.

detail

once you start up an app using electron, a main process is created. this process is responsible for interacting with native GUI of your OS and creates the GUI of your app (the actual windows).

application windows are created by a 'BrowserWindow' module. each browser window then runs its own renderer process. This _renderer process takes a web page and renders it in the window. The process is rendered with Chromium so its pretty compatible.

EX: calculator app: main process would instantiate a window with a web page where your actual web page, the calculator is.

  • while typically only the main process interacts with the native GUI of your OS, there are techniques to offload some of that work to renderer processes. The main process can access the native GUI through a series of modules available directly in Electron.

How to:

  1. create a package.json file with these contents:
{
    "name": "sound_machine",
    "version": "0.1.0",
    "main": "./main.js",
    "scripts": {
        "start": "electron ."
    }
}

^ this is the name,version, and file which will start the main process, in addition to script shortcut names.

  1. run npm install --save-dev electron-prebuilt QUESTION: what is a 'pre-built binary'?

  2. create an /app folder and place an index.html file in it with an h1 of "hello world."

  3. create the main.js file. This file will set up our main process. In this file, include these contents:

'use strict';

var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
// var app = require('app');
// var BrowserWindow = require('browser-window');

var mainWindow = null;

app.on('ready', function() {
    mainWindow = new BrowserWindow({
        height: 600,
        width: 800
    });

    mainWindow.loadURL('file://' + __dirname + '/app/index.html');
});

^ Here's whats going on here:

  • app controls the state of your app, BrowserWindow of course controls the browser window, and mainWindow is set to null initially.
  • once app hits the ready event, we change the main window from null to being an instance of a new browser window with specific width and height, and then render index.html inside of that browser window using the window's renderer process.
  1. Next run npm start and you should see your app start up with "Hello World"

  2. add this to the new BrowserWindow() function:

 mainWindow = new BrowserWindow({
     frame: false,
     height: 700,
     resizable: false,
     width: 368
 });

^ this gives your window a solid dimensions based on the example width and height of the sound machine demo.

  1. add this code to your index.js file:
'use strict';

var soundButtons = document.querySelectorAll('.button-sound');

for (var i = 0; i < soundButtons.length; i++) {
  var soundButton = soundButtons[i];
  var soundName = soundButton.attributes['data-sound'].value;

  prepareButton(soundButton, soundName);
}

function prepareButton(buttonEl, soundName) {
  buttonEl.querySelector('span').style.backgroundImage = 'url("img/icons/' + soundName + '.png")';

  var audio = new Audio(__dirname + '/wav/' + soundName + '.wav');
  buttonEl.addEventListener('click', function () {
      audio.currentTime = 0;
      audio.play();
  });
}

^ what this does is create a nodelist of all instances of the class 'button-sound', then a for loop grabs its individual element and the elements data-sound attribute value. It then creates a variable called 'audio' creating a path to an audio .wav file based on the data-sound attribute value previously. A click event listener is created to watch for when the element is clicked, and when it is, set the sound time to 0 and play that sound.

Now we're going to look into closing the browser window via remote events.

  1. IMPORTANT: application windows (or their 'renderer process') shouldnt be interacting with the GUI, and thats what closing a window is.

^ the offical docs say: "In web pages it isnt allowed to cal native GUI related APIs because manageing native GUI resources in web pages is dangerous and its easy to leak resources. if you want to to perform GUI operation in a web page, the renderer process of the webpage must communivate with the main process to request the main process perform those operations."

^ interpretation: its dangerous and expensive for your actual application's renderer processes (that control your application windows) to interact with the native GUI of your OS. thats what the main process has been designed for, so you need to go through the main process if you want to interact with your OS's native GUI, and electron provides what they call 'ipc' or inter-process communication module for exactly that.

IPC allows subscribing to messages on a channel and seinding message to subscribers of a channel. A channel is used to differntiate between receivers of messages and is represented by a string (ex: channel-1, channel-2). The message can also contain data. After receiving a message, the subscriber can react by doing some work and can even answer. The biggest benefit of messaging is THE SEPARATION OF CONCERNS...the main process doesnt have to know which renderer processes there are or which one sent a message.

so that's what we'll do-subscribe the main process (main.js) to the 'close-main-window' channel and send a message on that channel from the renderer process (index.js) when someone clicks the close button.

^ I'm thinking of IPC as a middleman between the main process and renderer processes in keeping things flexible. this allows you to access the native gui in your renderer process files via the main process by using IPC.

IMPORTANT: this was not in the tutorial. instead of using simply var ipc = require("ipc");, IPC is broken into two modules, ipcMain for the main process and ipcRenderer for the renderer processes.

SO....add the following to your main.js file to subscribe to a channel:

var ipcMain = electron.ipcMain;

ipcMain.on('close-main-window', function () {
    app.quit();
});

^ after requiring the module, subscribing to messages on a channel is really easy. it involves using the on() method with the channel name and a callback function. so what we just did is subscribe the main process (main.js) to the close-main-window channel, and once a message is sent, it quits the app.

  1. to send a message on the close-main-window channel, add this to index.js:
var electron = require('electron');
var ipcRenderer = electron.ipcRenderer;

var closeEl = document.querySelector('.close');
closeEl.addEventListener('click', function () {
    ipcRenderer.send('close-main-window');
});

^ this grabs an element with the class of 'close' and adds a click event listener that will send a message to the main process using the created 'close-main-window' channel via IPC.

SO...now its working pretty well. when you click the X button existing in the application window, the renderer process sends a message to the main process via IPC, which then uses the native GUI to close the application once the message is received. NICE!

  1. Now we'll look into playing sounds via global keyboard shortcuts. so instead of pressing the buttons, the sound machine will just work from your keyboard.
  • Electron comes with a 'global shortcut' module where it listens for keyboard combinations and reacts to them. These combinations are called 'accelerators' and are string representations of keyboard presses like shift + cmd + 1.

  • so now what we'll do is use the ipc module to capture an keyboard combination event (using the main process) and play a sound (using the renderer process).

  1. things to consider beforehand though... -global shortcuts have to be considered AFTER the app.ready() event, putting the shortcuts ACTUALLY INTO that code block. -when sending messages from the main process to the renderer process, you have to use a reference to that window. EX: createdWindow.webContents.send('channel')

    ^ with this in mind, lets write some code. inside of your main.js file:

var globalShortcut = require('global-shortcut');

app.on('ready', function() {
    ... // existing code from earlier

    globalShortcut.register('ctrl+shift+1', function () {
            mainWindow.webContents.send('global-shortcut', 0);
    });
    globalShortcut.register('ctrl+shift+2', function () {
        mainWindow.webContents.send('global-shortcut', 1);
    });
});

^ so we require the global-shortcut module, then register two keyboard combinations (aka 'accelerators') and our callback function says that to the web contents of the mainWindow will send a message the created 'global-shortcut' channel with the argument of 0 or 1. This argument will be used to play the correct sound in the renderer process.

so add this to index.js:

ipc.on('global-shortcut', function (arg) {
    var event = new MouseEvent('click');
    soundButtons[arg].dispatchEvent(event);
});

^ this didnt work. Upon looking through a shit ton of documentation and troubleshooting, I found out this:

require('electron').ipcRenderer.on('ping', (event, message) => {
      console.log(message)  // Prints 'whoooooooh!'
    })

and I also console.log()'ed the 'arg' argument and got an object. I understood that the call back gives you back an object with 2 key/values: I've called them event and message. so I rendered it like this and works perfect:

ipcRenderer.on('global-shortcut', function (event, message) {
    // alert(message);
    var event = new MouseEvent('click');
    soundButtons[message].dispatchEvent(event);
});

SO now we have our keyboard combinations interacting with our desktop app by way of the main process using the globalShortcut module to listen in on a particular keycode combination and once that is done, a value is sent over to the renderer process to handle what should happen within the application window once the message is received. in our case, a sound plays according to its index number within the 'soundButtons' nodelist.

Next up, these keyboard shortcuts may have been taken by other applications running on our computer, so we'll set up a settings screen to store these shortcuts.

To do these things we need:

  • a settings button in our main window
  • a settings window (with its own html/css/js files)
  • ipc messages to open/close settings window and update our global shortcuts module
  • storing/reading of a settings JSON file from the user system.

settings button and window

add this to index.js:

var settingsEl = document.querySelector('.settings');
settingsEl.addEventListener('click', function () {
    ipcRenderer.send('open-settings-window');
});

^ this adds a click event listener to an element with the class 'settings.' when clicked, it will send a message from the renderer process to the main process using ipc using the 'open-settings-window' channel.

Now main.js can react to this event. In main.js, put this:

var settingsWindow = null;

ipcMain.on('open-settings-window', function () {
    if (settingsWindow) {
        return;
    }

    settingsWindow = new BrowserWindow({
        frame: false,
        height: 200,
        resizable: false,
        width: 200
    });

    settingsWindow.loadURL('file://' + __dirname + '/app/settings.html');

    settingsWindow.on('closed', function () {
        settingsWindow = null;
    });
});

^ so here we're creating a settingsWindow variable and initially setting it to null, then we're checking to make sure 'settingsWindow' doesnt exist yet, then creating a new BrowserWindow with settings.html rendered inside of the window, and when it is closed it's set back to null.

Now we need a way of closing that window. so we'll pass a message on a channel again, but instead of index.js, we'll use settings.js. create a file in /js called settings.js and put this inside:

'use strict';

var electron = require('electron');
var ipcRenderer = electron.ipcRenderer;

var closeEl = document.querySelector('.close');
closeEl.addEventListener('click', function (e) {
    ipcRenderer.send('close-settings-window');
});

^ Now we have an element with a click event attached that will send something via ipc and the 'close-settings-window' channel.

In main.js we're going to listen in on that channel, so put this in main.js:

ipcMain.on('close-settings-window', function () {
    if (settingsWindow) {
        settingsWindow.close();
    }
});

^ this handles the message sent in settings.js, closing the settings window.

SO our settings window is now ready to implement its own logic.

  1. storing and reading user settings.

The process of interacting with the setting windows, storing the settings and promoting them to our application will look like this:

  • create a way of storing and reading user settings in a JSON file
  • use these settings to display the initial state of the settings window
  • update the settings upon user interaction
  • let the main process know of the updated changes.

Now we could just implement the storing and reading of settings in our main.js file but this could be a great use case for writing a little module that can be then included in various places.

"This is why we're going to create a configuration.js file and require it whenever we need it. To make storing/reading easier, we'll use the nconf module which abstract the reading/writing of a JSON file for us. But first we have to include it in the project with the following command in Bash:

npm install --save nconf "

^ This tells NPM to install the nconf module as an application dependency and it will be included and used when we package our application for an an end user, as opposed to using --save-dev in the command, which only includes modules for DEVELOPMENT purposes.

12.1 Create a configuration.js file with these contents:

'use strict';

var nconf = require('nconf').file({file: getUserHome() + '/sound-machine-config.json'});

function saveSettings(settingKey, settingValue) {
    nconf.set(settingKey, settingValue);
    nconf.save();
}
// ^ this assigns a key/value pair via nconf and saves it to the json file.

function readSettings(settingKey) {
    nconf.load();
    return nconf.get(settingKey);
}

function getUserHome() { //This is used to differentiate 'home' folders in diff. OS
    return process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME'];
}

module.exports = {
    saveSettings: saveSettings,
    readSettings: readSettings
};

Storing or reading settings is accomplished by nconf's built in methods of set() and get(), and exporting the API by using the standard CommonJS module.exports syntax.

12.2 Initializating default shortcut key modifiers

SO NOW....before moving forward with the settings interaction, lets initialize the settings like we're starting an application for the first time. We'll store the modifier keys as an array with the key 'shortcutKeys' and initialize it in main.js. For all of this to work, we first have to require our configuration module...

Put this in main.js:

'use strict';

var configuration = require('./configuration');

app.on('ready', function () {
    if (!configuration.readSettings('shortcutKeys')) {
        configuration.saveSettings('shortcutKeys', ['ctrl', 'shift']);
    }
    ...
}

^ SO here we require our configuration.js file, state that if there's nothing stored in the settingKey of 'shortcutKeys' then we will set some default values of ctrl and shift.

As an additional thing in main.js, we'll rewrite the registering of global shortcut keys as a function that we can call later when we update our settings. Remove the registering of shortcut keys from main.js and alter the file this way:

app.on('ready', function () {
    ...
    setGlobalShortcuts();
}

function setGlobalShortcuts() {
    globalShortcut.unregisterAll();

    var shortcutKeysSetting = configuration.readSettings('shortcutKeys');
    var shortcutPrefix = shortcutKeysSetting.length === 0 ? '' : shortcutKeysSetting.join('+') + '+';

    globalShortcut.register(shortcutPrefix + '1', function () {
        mainWindow.webContents.send('global-shortcut', 0);
    });
    globalShortcut.register(shortcutPrefix + '2', function () {
        mainWindow.webContents.send('global-shortcut', 1);
    });
}

^ so this function resets the global shortcuts so that we can set new ones, read the modifier keys array from settings, transforms it to a 'Accelerator-compatible' string and does the usual global shortcut key registration.

12.3 Interaction in the settings window

In the settings.js file, we need to bind click events which are going to change our global shortcuts. First we'll iterate through the checkboxes and mark the active ones (reading the values from the configuration module):

var configuration = require('../configuration.js');

var modifierCheckboxes = document.querySelectorAll('.global-shortcut');

for (var i = 0; i < modifierCheckboxes.length; i++) {
    var shortcutKeys = configuration.readSettings('shortcutKeys');
    var modifierKey = modifierCheckboxes[i].attributes['data-modifier-key'].value;
    modifierCheckboxes[i].checked = shortcutKeys.indexOf(modifierKey) !== -1;

    ... // Binding of clicks comes here
}

^ so this is going to go create a nodelist of html elements with the 'global-shortcut' class, and going to loop over them, grabbing each of their 'data-modifier-key' values and ???...then we'll bind the clicks. I REALLY dont understand what's going on with the last line modifierCheckboxes[i].checked...no clue.

SOOOOOO now we'll bind those checkbox clicks I guess!

Keep in mind that the settings window (and its renderer process) arent allowed to change GUI binding. That means that we'll need to send an IPC message from settings.js (and handle that message later). Put the following in the contents of your settings.js file:

for (var i = 0; i < modifierCheckboxes.length; i++) {
    ...

    modifierCheckboxes[i].addEventListener('click', function (e) {
        bindModifierCheckboxes(e);
    });
}

function bindModifierCheckboxes(e) {
    var shortcutKeys = configuration.readSettings('shortcutKeys');
    var modifierKey = e.target.attributes['data-modifier-key'].value;

    if (shortcutKeys.indexOf(modifierKey) !== -1) {
        var shortcutKeyIndex = shortcutKeys.indexOf(modifierKey);
        shortcutKeys.splice(shortcutKeyIndex, 1);
    }
    else {
        shortcutKeys.push(modifierKey);
    }

    configuration.saveSettings('shortcutKeys', shortcutKeys);
    ipcRenderer.send('set-global-shortcuts');
}

^ we iterate through all the checkboxes (with a for loop), bind a click event listener and on each event, check if the settings array contains the modifier key or not (where is this at???), and according to that result, modify the array, save the result to settins and send a message to the main process which should update our global shortcuts.

Just FOR MY SAKE, lets look through the above code one more time.

    if (shortcutKeys.indexOf(modifierKey) !== -1)  //does settings array contain the modifier key?
      {  //if so find the key and re-add it to the index?
         var shortcutKeyIndex = shortcutKeys.indexOf(modifierKey);
         shortcutKeys.splice(shortcutKeyIndex, 1);
      }
    else { //if not, add the new key to the shortcutKeys array
        shortcutKeys.push(modifierKey);
    }

    configuration.saveSettings('shortcutKeys', shortcutKeys); //use nconf to save the prop/values
    ipcRenderer.send('set-global-shortcuts'); //send a message of the `set-global-shortcuts` channel.

Now all we have to do is is go to our main process in main.js and handle that message on the set-global-shortcutschannel:

ipc.on('set-global-shortcuts', function () {
    setGlobalShortcuts();
});

^ and with alllllllll this, our shortcut keys are now configurable.

SO this works well...except for whatever reason the default values that should be there via this function executable do NOT work:

 if (!configuration.readSettings('shortcutKeys')) {
   configuration.saveSettings('shortcutKeys', ['ctrl']);
 }

It's not the worst thing in the world, and I need to look through it again but it doesnt break my shit so im going to keep going.

^ OK fixed it. literally took off the '!'... kill me.

  1. Menu tray stuff.

In our guide, we're going to add a tray icon. we're also going to use this as an opportunity to explore another of using inter-process communication (IPC)...the REMOTE MODULE.

The remote module makes RPC style calls from the renderer process to the main process. you require modules and work with them in the renderer process but theyre being instantiated in the main process and methods that you call on them are being executed in the main process.

^ what the .

In practice, this means that you remotely request native GUI modules in index.js and call methods on them but they get executed in main.js. In that way, you could require the BrowserWindow module from index.js and instantiate a new browser window. Behind the scenes, thats still a synchronous call to the main process which actually creates that new browser window.

^ SO what this is saying is: you know how you request native GUI functionality in files like index.js? well yea, of course, you basically ask for them and require methods on them but you use the main process or main.js to actually do that heavy lifting. The same way you request native GUI functionality, you can also instantiate new browser windows in index.js as well. why that is groundbreaking, though, I'm not so sure...

"Let's see how we'd create a menu and bind it to a tray icon while doing it in a renderer process..."

Add this to index.js:

var {Menu, Tray, Path} = require('electron').remote

//menu
var trayIcon = null;
if (process.platform === 'darwin') {
    trayIcon = new Tray('/Users/davidmatheson/Desktop/ME/professional-development/electron/app/img/tray-iconTemplate.png');
} else {
    trayIcon = new Tray('/Users/davidmatheson/Desktop/ME/professional-development/electron/app/img/tray-iconTemplate.png');
}

var trayMenuTemplate = [
    {
      label: 'sound machine',
      enable: false
    },
    {
      label: 'Settings',
      click: function() {
          ipcRenderer.send('open-settings-window');
      }
    },
    {
      label: 'Quit',
      click: function() {
          ipcRenderer.send('close-main-window');
      }
    }
];

var trayMenu = Menu.buildFromTemplate(trayMenuTemplate);
trayIcon.setContextMenu(trayMenu);

^ this adds an icon to the icon tray with the functionality of closing the program and modifying the user key settings. This is made possible via ipc sending a message over through both the 'open-settings-window' channel and the 'close-main-window' channel.

  1. Packaging your app
  • electron abstracts away the OS-specific knowledge needed to publish apps in various platforms. what I did was use electron-packager which does this for me and is installed via NPM but in this case (I was having issues) I installed it globally like:

npm install electron-packager -g

and the tutorial was totally messed up for understanding how this works and I'm still fairly in the dark about things, but after browsing a good bit of documentation and GH issues, I modified the given command that works for me on Mac:

"electron-packager . --arch='x64' --no-prune --overwrite"

lets break this down:

  • electron-packager - instantiate the packager
  • . - use root directory to start from
  • --arch='x64' - this states that it is for mac/linux platforms
  • --no-prune - this is to include the node_modules
  • --overwrite - this states that we can run the command again and it will create a new app folder for mac/linux platforms, overwriting the previous one.

I then put it in the package.json for future reference like:

  "scripts": {
    "start": "electron .",
    "package": "electron-packager . --arch='x64' --no-prune --overwrite"
  },

so now it can be called like: npm run-script package

And BAM! you have an Electron app, packaged and ready.

Next Steps

  • brain storm a small project that can be completed in a weekend
  • start working on it.

Ideas

  • web scraper, desktop notifications for cheap art books

    • go to amazon.com
    • enter Artist Name in search bar
    • filter to "books"
    • hit return
    • select "sort by" filter options and select "Price: Low to High"
    • on next page, grab:
      • the book's image
      • the book's amazon link
      • each individual title .a-link-normal h2
      • each individual price .a-spacing-top-mini .a-row a
    • click next page
    • do the same 2 steps for the next 10 pages
    • return data results in json format
    • evaluate json data to see if it matches price criteria
    • if data matches criteria, push it into a new array
    • display this new array to the user in a list format accessible via the menu tray icon
    • NOTE: run this script every 8 hours.
  • you could first make a rap soundboard desktop app that enables you to play audio snippets with the ability to search them through a 'spotlight-esque' capability and keyboard shortcuts for your favorite ones, saved in user settings.

capabilities:

  • play audio snippets

  • search for them via 'spotlight-esque' capability

  • save your favorites in user settings to access them with keyboard shortcuts

    • first create a folder and new gitr repo
    • grab all the images and audio from the site
    • WRITE IT ALL IN ES6!!!!
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.