A simple bookmarker application using Electron.
Clone down the repository and install the dependencies.
npm install
Take participants on a tour of the package.json
. Take particular note of:
- The
main
entry, which should point toapp/main.js
. - The
start
script, which should beelectron
. - The dependencies, which should at the very least include
electron
.
There should be four files in the app
directory.
main.js
renderer.js
index.html
styles-light.css
styles-dark.css
Let's start by doing the simplest possible thing. In main.js
:
console.log('Hello world');
Seems silly, but it turns out that a few cool things are going on:
- We have an icon in the doc or taskbar.
- We can use the application switcher to get to it.
- On macOS, we can see a menu.
That said, there is not a lot here we couldn't do with any other Node process.
Killing the process from the command line with quit the application.
Electron comes with a set of small modules that we can use to build our applications. They are all keys when we require the Electron library.
const electron = require('electron');
console.log(Object.keys(electron));
We can see a short list of the modules that availabe to us in the main process. There are a different set of modules available in renderer processes, with some small overlap between the two.
Show how the documentation lists the components in available to each kind of process.
It would be cool if we could fire up a BrowserWindow
, right? We'll actually need two modules to make this work.
The app
module takes care of lifecycle events. Some events include:
ready
window-all-closed
before-quit
will-quit
quit
…and many more. You can hook into stuff like Handoff on macOS, Listen for GPU process crashes, and much more.
We can't create the BrowserWindow
instance we so desperately want until the application is fully started up and ready to rock.
So, we'll have to wait for the app
module to fire its ready
event.
const { app, BrowserWindow } = require('electron');
app.on('ready', () => {
console.log('The application is ready.');
});
(Take a quick minute to talk about destructuring.)
Okay, now it's time for the moment we've been waiting for. That sweet, sweet BrowserWindow
.
const { app, BrowserWindow } = require('electron');
app.on('ready', () => {
const mainWindow = new BrowserWindow();
console.log('The application is ready.');
});
This is a little dangerous and has a chance of ending poorly for us. mainWindow
is defined in the function scope of the function we're handing to the ready event. This function has run to completion, which means mainWindow
is ripe for garbage collection.
We're better off declaring the variable in the top level scope and then setting it when the browser is ready for it.
const { app, BrowserWindow } = require('electron');
let mainWindow = null;
app.on('ready', () => {
mainWindow = new BrowserWindow();
console.log('The application is ready.');
});
BrowserWindow
has a lot of fun settings.
Some useful ones:
width
height
x
y
center
(takes a Boolean)
Let's play with some of them for a second.
const { app, BrowserWindow } = require('electron');
let mainWindow = null;
app.on('ready', () => {
mainWindow = new BrowserWindow({
maxWidth: 800,
maxHeight: 600,
minWidth: 400,
minHeight: 300,
titleBarStyle: 'hidden-inset'
});
console.log('The application is ready.');
});
Okay, now that we've gotten that out of systems, let's keep it simple.
const { app, BrowserWindow } = require('electron');
let mainWindow = null;
app.on('ready', () => {
mainWindow = new BrowserWindow({
minWidth: 800,
minHeight: 600
});
console.log('The application is ready.');
});
Our window isn't terribly exciting just yet. Because it doesn't have any content to show.
We can take the window and ask it to load a URL.
const { app, BrowserWindow } = require('electron');
let mainWindow = null;
app.on('ready', () => {
mainWindow = new BrowserWindow({
minWidth: 800,
minHeight: 600
});
mainWindow.loadURL('https://frontendmasters.com');
});
Loading remote pages is cool. But there is that flash of white before the page load. That's not the best user experience in the world and we can do better.
const { app, BrowserWindow } = require('electron');
let mainWindow = null;
app.on('ready', () => {
mainWindow = new BrowserWindow({
minWidth: 800,
minHeight: 600,
show: false
});
mainWindow.loadURL('https://frontendmasters.com');
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
});
Let's start by showing something in index.html
.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Bookmarker</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="style-light.css" rel="stylesheet">
</head>
<body>
<h1>Bookmarker</h1>
</body>
</html>
Let's open up the developer tools an play around for a second.
We have some Node globals:
require
process
__dirname
module
We also have the stuff we're used to in the browser.
document
navigator
window
(Node'sglobal
is aliased towindow
)
const os = require('os');
const fs = require('fs');
const files = fs.readdirSync(os.homedir());
files.forEach(name => {
const file = document.createElement('li');
file.textContent = name;
document.body.appendChild(file);
});
We could use <script>
tags in the HTML. Or we could simply require the renderer file from here. I prefer this method because we get all of the advantages of modules being wrapped in closures.
<script>require('./renderer.js');</script>
Let's also update the content a bit for what we'll need to get this form up and running.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Bookmarker</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="styles-light.css" rel="stylesheet">
</head>
<body>
<div class="message"></div>
<form class="new-link-form">
<label class="new-link-form--url--label" for="new-link-url">New Bookmark URL</label>
<input type="url" class="new-link-form--url" id="new-link-url" placeholder="URL" required>
<input type="submit" class="new-link-form--submit" value="Submit" disabled>
</form>
<section class="links"></section>
<section class="controls">
<button class="controls--clear-storage">Clear Storage</button>
</section>
<script>require('./renderer.js');</script>
</body>
</html>
We're going to be working with these elements a bit, so let's store them in some variables for quick reference.
Chromium's Content Module doesn't provide helpful validity popups, like those in Chrome, but they will work under the hood.
Here we will only activate the button if it's a valid URL. In a perfect world, we could display a better URI for telling them why the URL isn't valid, but let's stick with this for now.
newLinkUrl.addEventListener('keyup', () => {
newLinkSubmit.disabled = !newLinkUrl.validity.valid;
});
Now is also a good time to add a small helper function to clear out the contents of the URL field. In a perfect world, we’ll call this whenever we’ve successfully stored the link.
const clearForm () => {
newLinkUrl.value = null;
};
newLinkForm.addEventListener('submit', (event) => {
event.preventDefault();
const url = newLinkUrl.value;
fetch(url)
.then(response => response.text())
.then(response => console.log(response))
.catch(error => {
errorMessage.textContent = `There was an error fetching "${url}."`
});
});
const parser = new DOMParser();
const parseResponse = (text) => parser.parseFromString(text, 'text/html');
const findTitle = (nodes) => nodes.querySelector('title').innerText;
We'll parse the response and find the title when it comes in.
fetch(url)
.then(response => response.text())
.then(parseResponse)
.then(findTitle)
.then(response => console.log(response))
.catch(error => {
console.error(error);
errorMessage.textContent = `There was an error fetching "${url}."`
});
Let's use the <template>
to define what the individual links we're adding to the page are. <template>
has no support in IE, but we're shipping a modern version of Chrome, so we don't particularly care.
<template id="link-template">
<article class="link">
<h3 class="link--title"></h3>
<p><a href="#" class="link--url"></a></p>
</article>
</template>
Now we can use that template each time we need to add a new one to the page.
const addToPage = ({ title, url }) => {
const newLink = linkTemplate.content.cloneNode(true);
const titleElement = newLink.querySelector('.link--title');
const urlElement = newLink.querySelector('.link--url')
titleElement.textContent = title;
urlElement.href = url;
urlElement.textContent = url;
linksSection.appendChild(newLink);
return { title, url };
};
Let's add it to the page.
fetch(url)
.then(response => response.text())
.then(parseResponse)
.then(findTitle)
.then(title => { title, url })
.then(addToPage)
.then(clearForm)
.catch(error => {
console.error(error);
errorMessage.textContent = `There was an error fetching "${url}."`
});
Electron applications can write to the file system, but we'll stick with localStorage
in this first example.
const storeLink = ({ title, url }) => {
localStorage.setItem(title, url);
return { title, url };
};
Let's add more to our chain of events.
fetch(url)
.then(response => response.text())
.then(parseResponse)
.then(findTitle)
.then(title => { title, url })
.then(addToPage)
.then(storeLink)
.then(clearForm)
.catch(error => {
console.error(error);
errorMessage.textContent = `There was an error fetching "${url}."`
});
When the page boots, pull all of the links out of localStorage
.
window.addEventListener('load', () => {
for (let title of Object.keys(localStorage)) {
addToPage({ title, url: localStorage.getItem(title) });
}
});
We're likely to make some mistakes so, let's add an event listener to that button to clear out the list.
clearStorageButton.addEventListener('click', () => {
localStorage.clear();
linksSection.innerHTML = '';
});
By default, links open in our app because our app thinks it's a browser. Maybe we want this to navigate around the internal state of our application, but right now, we definitely don't want this behavior.
const { shell } = require('electron');
linksSection.addEventListener('click', (event) => {
if (event.target.href) {
event.preventDefault();
shell.openExternal(event.target.href);
}
});
If we hit a 400- or 500-level status code, let's not save the link.
const validateResponse = (response) => {
if (response.ok) { return response; }
throw new Error(`Received a status code of ${response.status}`);
}
We can install Devtron, an officially supported set of tools.
require('devtron').install();
Let's pull in the remote
module and then grab access to the main process's modules.
const { shell, remote } = require('electron');
const { systemPreferences } = remote;
When the page loads. We'll query if macOS is in dark mode and if so, we'll swap style sheets.
window.addEventListener('load', () => {
for (let title of Object.keys(localStorage)) {
addToPage({ title, url: localStorage.getItem(title) });
}
if (systemPreferences.isDarkMode()) {
document.querySelector('link').href = 'styles-dark.css';
}
});