spin up desktop apps with electron.js

69
Spin Up Desktop Apps with Electron.js whilestevego stevegodin

Upload: steve-godin

Post on 08-Jan-2017

785 views

Category:

Software


0 download

TRANSCRIPT

Spin Up Desktop Apps with Electron.js

whilestevego stevegodin

About Me

My name is Steve. I have a love ~ hate relantionship with Javascript. And, I just spent way too much time playing with Electron.js this past month.

About Electron.js

Raise your hand if you know what it is? Raise your hand if you've built an application with it before? It's a desktop application framework built on top of Chromium and Node.js. It abstracts standard native GUI with a simple API. Node.js is used for all other low level interactions (e.g. file system manipulation)

What apps are built with it? Visual Studio Code, Slack, Atom.Git Book: Uses git to collaborate writing documentation, books and research papers; Avocode: Helps prepare mocks for a web application; Zoommy: Helps you find good free stock photos; Mapbox: Tool to help style and design maps.

Why?

Because you have web development skills, you can do mobile apps already, but you also want to desktop apps. This is an easy framework to work with that gives you a decent native API.

Disclaimer

The talk assumes that you know some basic Javascript. You will see some ES6 JS, but only syntax supported in Node 4.1. I'm not using any build tools for this project nor am I using any frontend framework. I'm keeping it simple.

part1: "Hello, world!"

Building an obligatory "Hello, world!" app. Before that, I want to give overview of how Electron works.

$ mkdir project && cd project$ npm init$ npm install electron prebuilt --save-dev

Assuming you have Node.js installed. This is how you get started.

{ "name": "animal-advisor", "version": "0.0.0", "description": "A desktop application for generating Advice Animals.", "main": "entry.js", "scripts": { "start": "electron ." }, "devDependencies": { "electron-prebuilt": "^0.35.1" }}package.json

Emphasize lines are important. You could install Electron.js globally and just run `electron .`, but that would be less portable. `npm start` runs the electron binary downloaded in the node_modules folder.

. app assets images hey-hey-hey.gif main main.js renderer main index.html entry.js package.json.../animal-advisor/

I have app folder. Inside, I keep assets, the main process and renderer processes in their own folder.

require("./app/main/main.js");const App = require("app");const BrowserWindow = require("browser-window");...App.on("ready", function () { const mainWindow = new BrowserWindow({ frame: true, resizable: true, });

mainWindow.loadURL(`file://${pathTo.renderer}/main/index.html`);});entry.js.../main/main.js

My entry file for the main process just requires a main js. We wait for the application to be loaded and ready, then we create the main window with the BrowserWindow module. The module is part of Electron's Native GUI API that only the main process is allowed to use.

Animal Advisor Hey, World!

.../main/index.html

The BrowserWindow is created with an html file. You can load stylesheets, scripts or anything else that might load in a web page.

$ npm start

That's it for "Hello, world!". We just run the start script.

part2: "Tying it together"

Building an obligatory "Hello, world!" app. Before that, I want to give overview of how Electron works.

Main ProcessMain file in package.json

Renderer ProcessRenderer ProcessRenderer ProcessWeb pages created with BrowserWindow

Electron boots > Runs entry file in package.json as Main process (runs like any other Node.js script) > BrowserWindow module create new renderer processes (behaves just like a web page).

const ipcMain = require('electron').ipcMain;ipcMain.on('asynchronous-message', function(event, arg) { console.log(arg); // prints "ping" event.sender.send('asynchronous-reply', 'pong');});Main Process (.../main.js)const ipcRenderer = require('electron').ipcRenderer;ipcRenderer.on('asynchronous-reply', function(event, arg) { console.log(arg); // prints "pong"});ipcRenderer.send('asynchronous-message', 'ping');Renderer Process (.../index.js)

Communicating between processes is fairly simple. The inter-process communication (IPC) can create channels that both, main and renderer, processes can listen on. Their interface is nearly the same as events.

const ipcMain = require('electron').ipcMain;ipcMain.on('asynchronous-message', function(event, arg) { console.log(arg); // prints "ping" event.sender.send('asynchronous-reply', 'pong');});Main Process (.../main.js)const ipcRenderer = require('electron').ipcRenderer;ipcRenderer.on('asynchronous-reply', function(event, arg) { console.log(arg); // prints "pong"});ipcRenderer.send('asynchronous-message', 'ping');Renderer Process (.../index.js)

To set it up, we create an listener on a channel and give it a handler.In the other process, we simply send messages to the channel.

const ipcMain = require('electron').ipcMain;ipcMain.on('asynchronous-message', function(event, arg) { console.log(arg); // prints "ping" event.sender.send('asynchronous-reply', 'pong');});Main Process (.../main.js)const ipcRenderer = require('electron').ipcRenderer;ipcRenderer.on('asynchronous-reply', function(event, arg) { console.log(arg); // prints "pong"});ipcRenderer.send('asynchronous-message', 'ping');Renderer Process (.../index.js)

In this scenario, the handler for the "asynchronous-message" channel sends off a reply to the "asynchronous-reply" channel. The renderer process is ready for it and prints out a reply.

const Remote = require('electron').remote;

const BrowserWindow = remote.BrowserWindow;const Dialog = remote.dialog;const GlobalShortcut = remote.globalShortcut;const Menu = remote.menu;const MenuItem = remote.menuItem;const Tray = remote.tray;Renderer Process (.../index.js)

A very common use case for communicating to Main is to call Native GUI modules that only the main process can do. The Remote module abstracts that communication. Instead of IPC, I'll be using the Remote module throughout this talk.

part3: "Animal Advisor"

To demo Electron's Native GUI interface, I'll build an app named Animal Advisor.

It's a silly app that generates advice animals like these. I won't get into the details of how I built the generator. Suffice it to say that it uses the Meme Captain API & I spent too much time on it!

animal-advisor app assets fonts images stylesheets base.css photon.css main main.js renderer main index.html index.js entry.js lib meme.js package.json.../animal-advisor/

What's changed? I've added CSS & fonts. There is javascript file executed by main/index.html. And, we've added an internal library. That's the bit that fetches the generated Advice Animals. It makes use of the Meme Captain API.

Here it is in action. Type a sentence. It's compared to some matchers, then the relevant advice animal is generated.

System Notifications

We're going start by implementing a system notification that triggers once the image is ready.

function sendNotification (path) { const title = "Animal Advisor says..." const options = { body: "Image download complete!", icon: path } new Notification(title, options);}.../renderer/main/index.js

Electron doesn't implement it's own notification module. It makes use of the HTML5 Notification API.

And, this is what it looks like!

Tray Icon

We're going start by implementing a system notification that triggers once the image is ready.

const Tray = require("tray");const Menu = require("menu");...App.on("ready", function () { ... const menuConfig = [ { label: "Toggle DevTools", accelerator: "Alt+Command+I", click: function() { mainWindow.show(); mainWindow.toggleDevTools(); } }, { label: "Quit", accelerator: "Command+Q", selector: "terminate:" } ]; const contextMenu = Menu.buildFromTemplate(menuConfig); ...});.../main/main.js

Before creating the tray icon, we'll create a menu for it. Otherwise, we'll just have a tray that does nothing. To create a menu, we get the Electron module, menu. Create a template. The first item in the menu will toggle the chromium dev tools. We pass the menu item a click handler and which toggles it on the mainWindow.

const Tray = require("tray");const Menu = require("menu");...App.on("ready", function () { ... const menuConfig = [ { label: "Toggle DevTools", accelerator: "Alt+Command+I", click: function() { mainWindow.show(); mainWindow.toggleDevTools(); } }, { label: "Quit", accelerator: "Command+Q", selector: "terminate:" } ]; const contextMenu = Menu.buildFromTemplate(menuConfig); ...});.../main/main.js

The second menu item implements a quit command. It's a standard action that is implemented by Electron. selector: "terminate:" will do.

const Tray = require("tray");const Menu = require("menu");...App.on("ready", function () { ... const menuConfig = [ { label: "Toggle DevTools", accelerator: "Alt+Command+I", click: function() { mainWindow.show(); mainWindow.toggleDevTools(); } }, { label: "Quit", accelerator: "Command+Q", selector: "terminate:" } ]; const contextMenu = Menu.buildFromTemplate(menuConfig); ...});.../main/main.js

Finally, we create the Menu object with .buildFromTemplate. This gives us the menu that we'll bind to the Tray icon.

const Tray = require("tray");const Menu = require("menu");App.on("ready", function () { ... const menuConfig = [ { label: "Toggle DevTools", accelerator: "Alt+Command+I", click: function() { mainWindow.show(); mainWindow.toggleDevTools(); } }, { label: "Quit", accelerator: "Command+Q", selector: "terminate:" } ]; const contextMenu = Menu.buildFromTemplate(menuConfig); ...});.../main/main.js

Before creating the tray icon, we'll create a menu for it. Otherwise, we'll just have a tray that does nothing. To create a menu, we provide the `buildFromTemplate` method array of objects. The accelerator property is Electron's API for creating application keyboard shorcuts. We'll see more of it later. **What is the selector?**

const Tray = require("tray");const Menu = require("menu");...App.on("ready", function () { ... const trayIcon = new Tray(trayIconPath); trayIcon.setToolTip("Animal Advisor"); trayIcon.setContextMenu(contextMenu);});.../main/main.js

We create the Tray icon, but create a new instances of it. It takes a path to an icon which can be a png. We can then set a tool tip and a context menu which is the menu we created in the prior slides.

Here's a demo.

Application Menu

The menu at the top of your application. It commonly has File, Edit, Window, Help and more as menu items.

... const applicationMenuConfig = [ { label: appName, submenu: [ { label: `About ${appName}`, role: "about" }, { type: "separator" }, { label: "Services", role: "services", submenu: [] }, { type: "separator" }, { label: `Hide ${appName}`, accelerator: "Command+H", role: "hide" } ... ] ....../main/main.js

All menus make use of the same module. The tray icon menu, application menu and context menu. For OS X (only), the first menu item (or object) in the array will always be named after the application. This is the menu item we see next to the Apple icon.

... const applicationMenuConfig = [ { label: appName, submenu: [ { label: `About ${appName}`, role: "about" }, { type: "separator" }, { label: "Services", role: "services", submenu: [] }, { type: "separator" }, { label: `Hide ${appName}`, accelerator: "Command+H", role: "hide" } ... ] ....../main/main.js

Each application menu items have their own submenu. Or, the menu that appears when you click on File or Window.

const appName = _.startCase(App.getName()); const applicationMenuConfig = [ { label: appName, submenu: [ { label: `About ${appName}`, role: "about" }, { type: "separator" }, { label: "Services", role: "services", submenu: [] }, { type: "separator" }, { label: `Hide ${appName}`, accelerator: "Command+H", role: "hide" } ....../main/main.js

The role property on the submenu are for standard actions provided by the OS. In this case, Electron gives us the ability to implement about, services and hide simply by defining a role on the sub menu item.

const applicationMenuConfig = [ { ... }, { label: 'Window', role: 'window', submenu: [ { label: 'Minimize', accelerator: 'CmdOrCtrl+M', role: 'minimize' }, { label: 'Close', accelerator: 'CmdOrCtrl+W', role: 'close' }, { type: 'separator' }, { label: 'Bring All to Front', role: 'front' } ] } ];.../main/main.js

Still in the config array, we create another menu labeled "Window". It's given the role of window, a standard menu. This is the window menu you see in just about every OS X applicaton. Like a standard action, we can just give it the role of window.

const applicationMenuConfig = [ { ... }, { label: 'Window', role: 'window', submenu: [ { label: 'Minimize', accelerator: 'CmdOrCtrl+M', role: 'minimize' }, { label: 'Close', accelerator: 'CmdOrCtrl+W', role: 'close' }, { type: 'separator' }, { label: 'Bring All to Front', role: 'front' } ] } ];.../main/main.js

Window has standard submenu items. You're probably all familiar with minimize, close and front. Electron has several other standard menus and standard sub menus that you can check out in its docs.

...App.on("ready", function () { ... const applicationMenuConfig = [ ... ];

const applicationMenu = Menu.buildFromTemplate(applicationMenuConfig);

Menu.setApplicationMenu(applicationMenu); ...});.../main/main.js

Finally, we pass our configuration array to buildFromTemplates. Using the same Menu module, we set the newly created menu as our app's application menu.

Voila!

Context Menu

We haven't done a whole lot of interesting things with our menus so far. What if I want a context menu that I can use to save our Advice Animal to disk or send to the clipboard? Let's start with the Context Menu first.

const Remote = require("remote");const pathTo = Remote.getGlobal("pathTo");const Menu = Remote.require('menu');const MenuItem = Remote.require('menu-item');...const adviceAnimalMenu = new Menu();adviceAnimalMenu.append(new MenuItem({ label: 'Reset', click: resetAdviceAnimal}));adviceAnimalMenu.append(new MenuItem({ label: 'Click me!', click: function (menuItem) { console.log('\_()_/'); }}));...adviceAnimalImg.addEventListener('contextmenu', function (event) { event.preventDefault(); adviceAnimalMenu.popup(Remote.getCurrentWindow());}, false);....../renderer/main/index.js

We'll be working in renderer process for our main window. Because menu creation is a native operation, only main process can create them. We could use the IPC module to send events over a channel and handle them in main process. Instead, we'll just use Electron's Remote module which abstracts that for us.

const Remote = require("remote");const pathTo = Remote.getGlobal("pathTo");const Menu = Remote.require('menu');const MenuItem = Remote.require('menu-item');...const adviceAnimalMenu = new Menu();adviceAnimalMenu.append(new MenuItem({ label: 'Reset', click: resetAdviceAnimal}));adviceAnimalMenu.append(new MenuItem({ label: 'Click me!', click: function (menuItem) { console.log('\_()_/'); }}));...adviceAnimalImg.addEventListener('contextmenu', function (event) { event.preventDefault(); adviceAnimalMenu.popup(Remote.getCurrentWindow());}, false);....../renderer/main/index.js

This time around. We're working with Menu and MenuItem methods to create it. We'll handle menu clicks with callback functions. This gives us a lot of freedom.

const Remote = require("remote");const pathTo = Remote.getGlobal("pathTo");const Menu = Remote.require('menu');const MenuItem = Remote.require('menu-item');...const adviceAnimalMenu = new Menu();adviceAnimalMenu.append(new MenuItem({ label: 'Reset', click: resetAdviceAnimal}));adviceAnimalMenu.append(new MenuItem({ label: 'Click me!', click: function (menuItem) { console.log('\_()_/'); }}));...adviceAnimalImg.addEventListener('contextmenu', function (event) { event.preventDefault(); adviceAnimalMenu.popup(Remote.getCurrentWindow());}, false);....../renderer/main/index.js

Finally, we add an event listener for the contextmenu event on the advice animal image element. We'll prevent the regular menu from showing, then we'll popup the menu we just created on the click location (by default).

This is what it looks like.

Native Dialogs

It's a common feature to be able to save an image to file by accessing its context menu. Let's add that ours for the advice animal image. We'll have the user pick the save location with a native save dialog.

.../renderer/main/index.js...const Shell = require("shell");const Remote = require("remote");const Menu = Remote.require("menu");const MenuItem = Remote.require("menu-item");const Dialog = Remote.require("dialog");...

To begin, let's require the dialog module. We'll have to use it through the Remote module, because it's a native command. It can only be called from within the main process. We're also requiring the shell module which we'll need to complete the effect.

.../renderer/main/index.js...adviceAnimalMenu.append(new MenuItem({ label: "Save Image as...", click: showSaveImageAsDialog, accelerator: "Command+Shift+S"}));...

Then, let's add a "Save Image as..." item to the context menu and give it a handler to create the save dialog.

.../renderer/main/index.js...function showSaveImageAsDialog() { const options = { title: "Save Image as..." }; Dialog.showSaveDialog( Remote.getCurrentWindow(), options, handleSaveDialog );}function handleSaveDialog (path) { const sourcePath = event.target.src.replace(/file:\/\//,""); const extension = Path.extname(sourcePath); const destinationPath = path + extension;

copyFile( sourcePath, destinationPath, handleCopy(destinationPath) );}...

We pass the showSaveDialog the current window, options and a callback. All arguments are optional. The callback will... (next slide)

.../renderer/main/index.js...function showSaveImageAsDialog() { const options = { title: "Save Image as..." }; Dialog.showSaveDialog( Remote.getCurrentWindow(), options, handleSaveDialog );}function handleSaveDialog (path) { const sourcePath = event.target.src.replace(/file:\/\//,""); const extension = Path.extname(sourcePath); const destinationPath = path + extension;

copyFile( sourcePath, destinationPath, handleCopy(destinationPath) );}...

...will be called with a path if the user saved and didn't cancel. In the handler, we just copy the cached picture to the location the user chooses with the save dialog. The handleCopy handler is called when the file copy is finished.

.../renderer/main/index.js...function handleCopy (path) { return function (error) { if (error) { Dialog.showErrorBox("Save Image as... failed", error) } else if (path) { Shell.showItemInFolder(path); }; }};...

In case the copy fails, we'll make use of the error dialog to alert the user. It only takes a title and text content as arguments.

.../renderer/main/index.js...function handleCopy (path) { return function (error) { if (error) { Dialog.showErrorBox("Save Image as... failed", error) } else if (path) { Shell.showItemInFolder(path); }; }};...

If there is a path (that is to say the user didn't cancel and there were no errors), we'll use the Shell module to open a finder (file explorer) window to save destination. Shell can be used in any process and it can be used to open folders, items and urls with system default applications. You can also use it send system beeps and send items to the trash.

If there is a path (that is to say the user didn't cancel and there were no errors), we'll use the Shell module to open a finder (file explorer) window to save destination. Shell can be used in any process and it can be used to open folders, items and urls with system default applications. You can also use it send system beeps and send items to the trash.

Clipboard

As a consumate shorcuter, I can't stand applications without good clipboard integration. Let's use Electron's API to integrate it.

.../renderer/main/index.js...const Clipboard = require("clipboard");...function setImage (path) { console.log("Setting image..."); adviceAnimalImg.src = path; adviceAnimalImg.className = ""; Clipboard.writeImage(path); sendNotification(path);}...adviceAnimalMenu.append(new MenuItem({ label: "Copy", click: copyToClipboard, accelerator: "Command+C"}));...function copyToClipboard () { const filePath = event.target.src.replace(/file:\/\//,""); Clipboard.writeImage(filePath);} ...

Writing to the clipboard is trivial. It's writable from both processes, main and renderer. First, we require the module.

.../renderer/main/index.js...const Clipboard = require("clipboard");...function setImage (path) { console.log("Setting image..."); adviceAnimalImg.src = path; adviceAnimalImg.className = ""; Clipboard.writeImage(path); sendNotification(path);}...adviceAnimalMenu.append(new MenuItem({ label: "Copy", click: copyToClipboard, accelerator: "Command+C"}));...function copyToClipboard () { const filePath = event.target.src.replace(/file:\/\//,""); Clipboard.writeImage(filePath);} ...

We create a new menu item for the copy command.

.../renderer/main/index.js...const Clipboard = require("clipboard");...function setImage (path) { console.log("Setting image..."); adviceAnimalImg.src = path; adviceAnimalImg.className = ""; Clipboard.writeImage(path); sendNotification(path);}...adviceAnimalMenu.append(new MenuItem({ label: "Copy", click: copyToClipboard, accelerator: "Command+C"}));...function copyToClipboard () { const filePath = event.target.src.replace(/file:\/\//,""); Clipboard.writeImage(filePath);} ...

Finally, I write the image to the clipboard when it is set and when the *copy* menu item is clicked. Passing image path to writeImage will do the job.

There several other methods to write and read html, text and images from the clipboard.

**Show code to create global shortcuts with Accelerators for the image with video of it in action**

**Show code to create a settings dialog (make it frameless) for the image with video of it in action**

**This should take more than one slide** Were going to go back an make global shortcuts, menu shortcuts and notifications configurable. Were probably going to use `nsconf` for this. *See sound box tutorial*

Modules Not Covered

I didn't talk about all the interesting things supported by Electron. Here's a few I missed.

autoUpdater Module

An interface for the Squirrel auto-updater framework.

globalShortcut Module

A module to register/unregister global keyboard shortcuts with the operating system. I really wanted to show how this is done, but I ran out of time. However, you can bet that the release of Animal Advisor will support it.

powerMonitor and powerSaveBlocker Modules

One is used to add event listeners to power states. The other is used to prevent the desktop from entering power saving modes.

crashReporter Module

This modules enables sending your apps crash reports.

part4: "Packaging"

It's time to package a release! Thankfully, there's a npm package that makes trivially easy for us.

$ npm install electron-packager --save-dev

Install electron packager as a development dependency.

{ "name": "animal-advisor", "version": "0.0.0", "description": "A desktop application for generating Advice Animals.", "main": "entry.js", "scripts": { "start": "electron .", "package": "electron-packager . \"Animal Advisor\" --platform=darwin --arch=x64 --out ./dist --version 0.35.1 --overwrite --icon=./app/assets/images/icon.icns",...package.json

We'll configure a packaging script for ease of use. The first purple text is your application name; the yellow, desktop platform; the pink, destination folder; the green, electron version; and, the last purple is the location of the app icon.

A small application like this builds pretty fast. You don't need to see this in action, but I felt I was on a roll with these video demos.

Closing Thoughts

I encourage you to use all the build tools you're familiar with. Electron works especially well with your favorite frontend framework. I will likely rewrite this application in React and build it with Webpack. Also, I just wanted to remind you that you have access

Demo & Questions whilestevego stevegodin