This chapter covers
In chapter 1, we discussed what Electron is at a high level. That said, this book is called Electron in Action, right? In this chapter, we learn the ropes of Electron by setting up and building a simple application from the ground up to manage a list of bookmarks. The application will take advantage of features available only in the most modern browsers.
In that high-level discussion from the previous chapter, I mentioned that Electron is a runtime like Node. That’s still true, but I want to revisit that point for a moment. Electron is not a framework—it does not provide any scaffolding or have strong rules about how you structure your application or name your files. Those choices are left up to us, the developers. On the bright side, it also doesn’t enforce any conventions, and we have less conceptual boilerplate information to discuss before getting our hands dirty.
Let’s start by building a simple and somewhat naive Electron application to reinforce everything we’ve covered. Our application accepts URLs. When the user provides a URL, we fetch the title of the page that the URL refers to and save it in our application’s localStorage. Finally, we display all the links in the application. You can find the completed source code for this chapter on GitHub (https://github.com/electron-in-action/bookmarker).
Along the way, we uncover some of the advantages of building an application in Electron, such as the ability to bypass the need for a server and use cutting-edge web APIs that do not have wide support across all the browsers but are implemented in modern versions of Chromium. Figure 2.1 is a wireframe of the application we build in this chapter.
When users add the URL of a website that they would like to save to the list below the input fields, the application sends a request to the website to fetch the markup. After it successfully receives the markup, the application pulls the title of the website and appends both the title and URL to the list of websites, which is stored in the browser’s localStorage. When the application starts, it reads from localStorage and restores the list. We add a button with a command to clear localStorage in case anything goes wrong. Because this simple application is designed to help you get comfortable with Electron, we won’t implement advanced moves, such as removing individual websites from the list.
How you choose to structure your application is up to your team or the individual working on the application. Many developers take slightly different approaches. Looking at some of the more established Electron applications, we can discern common patterns and make decisions on how we’d like to approach our applications in this book.
For our purposes, let’s agree upon a file structure for the remainder of this book. We have an app directory where we store all of our application code. We also have a package.json that will store a list of dependencies, metadata about our application, and scripts and declare where Electron should look for our main process. After we install our dependencies, we end up with a node_modules directory that Electron creates on our behalf, but we won’t include that in the initial setup.
As far as files are concerned, let’s start with two files in our app: main.js and renderer.js. These are purposely simple filenames so we can track the two types of processes. The start of all the applications that we build in this book roughly follows the directory structure shown in figure 2.2. (If you’re running macOS, you can install the tree command using brew install tree.)
Make a directory called “bookmarker,” and navigate to it. You can create this structure quickly by running the following two commands from the command line. You will generate a package.json file later using npm init.
mkdir app touch app/main.js app/renderer.js app/style.css app/index.html
Electron doesn’t require this structure, but it is inspired by some of the best practices established by other Electron applications. Atom keeps all of the application code in an app directory and all of its stylesheets and other assets such as images in a static directory. LevelUI has an index.js and a client.js on the top level and keeps all the dependent files in an src directory and stylesheets in a styles directory. Yoda keeps all of its files—including the file that loads the rest of the application—in an src directory. app, src, and lib are common names for the folder that holds the majority of the application’s code, and styles, static, and assets are common names for the directory that holds the static assets used in the application.
The package.json manifest is used in many—if not most—Node projects. This manifest contains important information about the project. It lists metadata such as the name of the author as well as their email address, which license the project is released under, the location of the project’s git repository, and where to file issues. It also defines scripts for common tasks such as running the test suite or—pertinent to our needs—building the application. The package.json file also lists all of the dependencies used to run and develop the application.
In theory, you could potentially have a Node project that does not have a package.json. But Electron relies on this file and its main property to figure out where to start when it loads or builds your application.
npm, the package manager that ships with Node, comes with a helpful tool for generating package.json. From the “bookmarker” directory you created earlier, run npm init. If you leave a prompt blank, npm uses whatever is in the parentheses after the colon as the default answer. Your answers should look something like figure 2.3, with the exception of the author’s name, of course.
Of note is the main entry in the sample package.json. Here, you can see that I set it to point to ./app/main.js, based on how we set up the application. You can point to any file you want. The main file we’re going to use happens to be called main.js, but it could be named anything (e.g., sandwich.js, index.js, app.js).
We have the basic structure of our application set up, but Electron is nowhere to be found. Building Electron from source takes a while and can be tedious. We rely on prebuilt versions of Electron for each platform (macOS, Windows, and Linux) and both architectures (32- and 64-bit). We install Electron using npm.
npm allows us to install binaries globally or locally to each project. Installing Electron globally seems convenient, but it can cause trouble down the road if we have multiple applications using different versions of Electron. We’re better off specifying and installing a unique version of Electron for each project we work on.
Downloading and installing Electron is easy. Run the following command from inside the project directory where you ran npm init previously:
npm install electron–-save
This command will download and install Electron in your project’s node_modules directory. (It will also create the directory if you don’t already have one.) The --save flag adds it to the list of dependencies in our package.json. This means that if someone downloads the project and runs npm install, they will get electron by default.
As you acclimate yourself to the world of Electron, you may see blog posts, documentation, and even earlier versions of this book that refer to the electron-prebuilt package instead of electron. In the past, the former was the preferred way to install a precompiled version of Electron for your operating system. The latter is the new preferred way. As of early 2017, electron-prebuilt is no longer supported.
npm also lets you define shortcuts for running common scripts in your package.json. When you run a script defined in your package.json, npm automatically adds node_modules to the path. This means that it will use the locally installed version of Electron by default. Let’s add a start script to our package.json.
{ "name": "bookmarker", "version": "1.0.0", "description": "Our very first Electron application", "main": "./app/main.js", "scripts": { "start": "electron .", 1 "test": "echo "Error: no test specified" && exit 1" }, "author": "Steve Kinney", "license": "ISC", "dependencies": { "electron": "^2.0.4" } }
Now when we run npm start, npm uses our locally installed version of electron to start the Electron application. You’ll notice that not much seems to happen. You should see the following code in your terminal application:
> [email protected] start /Users/stevekinney/Projects/bookmarker > electron .
You’ll also see a new application in your dock or task bar—the Electron application we just set up—as shown in figure 2.4. Right now, it’s called simply “Electron,” and it uses Electron’s default application icon. In later chapters, we’ll see how we can customize these properties, but the default is good enough for now. All of our implementation files are completely blank. As a result, there isn’t a lot going on with this application, but it exists and starts up correctly. We count that as a win for the time being. Closing all windows of the application on Windows or selecting Quit from the application menu terminates the process. Alternatively, you can press Control-C in the Windows Command prompt or Terminal to quit the application. Pressing Command-Period terminates a process on macOS.
Now that we have an Electron application, it would be cool if we could actually get it to do something. If you recall from chapter 1, Electron starts with a main process that can create one or more renderer processes. We start by writing code in main.js to get our application off the ground.
To work with Electron, we need to import the electron library. Electron comes with a number of useful modules that we use throughout this book. The first—and arguably, most important—is the app module.
const {app} = require('electron'); app.on('ready', () => { 1 console.log('Hello from Electron'); });
app is a module that handles the lifecycle and configuration of our application. We can use it to quit, hide, and show the application as well as get and set the application’s properties. The app module also runs events—including before-quit, window -all-closed, browser-window-blur, and browser-window-focus—when the application enters different states.
We cannot work with our application until it has completely started up and is ready to go. Luckily, app fires a ready event. This means we need to wait patiently and listen for the application to start the ready event before we do anything. In the previous code, we logged into the console, which is something we could easily do without Electron, but this code highlights how to listen for the ready event.
Our main process is a lot like any other Node process. It has access to all of Node’s built-in libraries as well as a special set of modules provided by Electron, which we explore over the course of this book. But, like any other Node process, our main process does not have a DOM (Document Object Model) and cannot render a UI. The main process is responsible for interacting with the operating system, managing state, and coordinating with all the other processes in our application. It is not in charge of rendering HTML and CSS. That’s the job of the renderer processes. One of the primary reasons we signed up for this whole Electron adventure is that we wanted to create a GUI for Node processes.
The main process can create multiple renderer processes using the Browser-Window module. Each BrowserWindow is a separate and unique renderer process that includes a DOM, access to the Chromium web APIs, and the Node built-in module. We can access the BrowserWindow module the same way we got our hands on the app module.
const {app, BrowserWindow} = require('electron');
You may have noticed that the BrowserWindow module starts with a capital letter. According to standard JavaScript convention, this usually means that we call it as a constructor with the new keyword. We can use this constructor to create as many renderer processes as we like or our computer can handle. When the application is ready, we create a BrowserWindow instance. Let’s update our code as follows.
const {app, BrowserWindow} = require('electron'); let mainWindow = null; 1 app.on('ready', () => { console.log('Hello from Electron.'); mainWindow = new BrowserWindow(); 2 });
We declared mainWindow outside the ready event listener. JavaScript uses function scope. If we declared mainWindow inside the event listener, mainWindow would be eligible for garbage collection because the function assigned to the ready event has run to completion. If garbage is collected, our window would mysteriously disappear. If we run this code, we see a humble little window displayed in the center of our screen, as shown in figure 2.5.
It’s a window, but it’s not much to look at. The next step is to load an HTML page into that BrowserWindow instance we created. All BrowserWindow instances have a web-Contents property, which has several useful features, such as loading an HTML file into the renderer process’s window, sending messages from the main process to the renderer process, printing the page to either PDF or a printer, and much more. Right now, our biggest concern is loading content into that boring window we just created.
We need an HTML page to load, so create an index.html in the app directory of your project. Let’s add the following content to the HTML page to make it a valid document.
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta http-equiv="Content-Security-Policy" content=" default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src * " > <meta name="viewport" content="width=device-width,initial-scale=1"> <title>Bookmarker</title> </head> <body> <h1>Hello from Electron</h1> </body> </html>
It’s simple, but it gets the job done and gives a good foundation on which to build. We add the following to app/main.js to tell the renderer process to load this HTML document inside of the window we created earlier.
app.on('ready', () => { console.log('Hello from Electron.'); mainWindow = new BrowserWindow(); mainWindow.webContents.loadFile('index.html'); 1 });
We use the file:// protocol and the __dirname variable, which is globally available in Node. __dirname is the full path to the directory where our Node process is being executed. In my case, __dirname expands to /Users/stevekinney/Projects/bookmarker/ app. It’s like typing pwd in macOS and Linux or chdir in Windows.
Now, we can use npm start to start our application and watch it load our new HTML file. If all goes well, you should see something resembling figure 2.6.
From the HTML file loaded by the renderer process, we can load any other files we might need just like we would in a traditional browser-based web application—namely, <script> and <link> tags.
What makes Electron different from what we’re used to in the browser is that we have access to all of Node—even from what we would normally consider “the client.” This means that we can use require or even Node-only objects and variables like __dirname or the process module. At the same time, we have all the browser APIs available as well. The division between what we can do only on the client and what we can do only on the server begins to fade away.
Let’s look at this in action. __dirname is not available in the traditional browser environment, and document or alert are not available in Node. But in Electron we can seamlessly use them together. Let’s add a button to the page.
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta http-equiv="Content-Security-Policy" content=" default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src * " > <meta name="viewport" content="width=device-width,initial-scale=1"> <title>Bookmarker</title> </head> <body> <h1>Hello from Electron</h1> <p> 1 <button class="alert">Current Directory</button> </p> </body> </html>
Now that we have our button, let’s add an event listener that alerts us to the current directory from which our application is running.
<script> const button = document.querySelector('.alert'); button.addEventListener('click', () => { alert(__dirname); 1 }); </script>
alert() is available only in the browser. __dirname is available only in Node. When we click the button, we are treated to Node and Chromium working together in sweet, sweet harmony, as shown in figure 2.7.
Writing code in our HTML file clearly works, but it’s probably not hard to imagine a situation where our code might grow to the point where this method is no longer feasible. We can add script tags with src attributes to reference other files, but this also becomes cumbersome quickly.
This is where web development gets tricky. Although modules were added to the ECMAScript specification, no browsers currently have a working implementation of a module system. On the client, this is the point where we might consider some kind of build tool like Browserify (http://browserify.org) or the module bundler, webpack, and possibly a task runner like Gulp or Grunt.
We can use Node’s module system with no additional configuration. Let’s move all of the code from inside those <script> tags to our—currently empty—app/renderer.js file. Now we can replace the contents inside of the <script> tags with just a reference to renderer.js.
<script> require('./renderer'); 1 </script>
If we start up our application, you’ll see that its functionality hasn’t changed. Everything still works as it should. That rarely happens in software development. Let’s briefly savor that feeling before moving on.
Few surprises occur when we reference stylesheets in our Electron applications. Later, we talk about using Sass and Less with Electron. Adding a stylesheet in an Electron application isn’t much different than it would be with a traditional web application. That said, a few nuances are worth talking about.
Let’s start by adding a style.css file to our app directory. We add the following content to that style.css.
html { box-sizing: border-box; } *, *:before, *:after { box-sizing: inherit; } body, input { font: menu; 1 }
That last declaration might look a little unfamiliar. It is unique to Chromium and allows us to use the system font in CSS. This ability is important to make our application fit in with its native siblings. On macOS, it’s the only way to use San Francisco, the system font that ships with El Capitan 10.11 and later.
We should consider one other important distinction when working with CSS inside of our Electron applications. Our applications will run only in the version of Chromium that we ship with the application. We don’t have to worry about cross-browser support or legacy compatibility. As mentioned in chapter 1, Electron ships with a relatively recent version of Chromium. This means we can freely use technologies like flexbox and CSS variables.
We reference our new stylesheet just like we would in the traditional browser environment, then add the following to the <head> section of index.html. I’ll include the HTML tag for linking to a stylesheet—because, in my 20 years as a web developer, I still can never remember how to do it on the first try.
<link rel="stylesheet" href="style.css" type="text/css">
We start by updating our index.html with the markup that we need for the UI.
<h1>Bookmarker</h1> <div class="error-message"></div> <section class="add-new-link"> <form class="new-link-form"> <input type="url" class="new-link-url" placeholder="URL"size="100" required> <input type="submit" class="new-link-submit" value="Submit" disabled> </form> </section> <section class="links"></section> <section class="controls"> <button class="clear-storage">Clear Storage</button> </section>
We have a section for adding a new link, a section for displaying all of our wonderful links, and a button for clearing all links and starting over. The <script> tag in your application should be just as we left it earlier in this chapter, but just in case it isn’t, here is what it should look like at this point:
<script> require('./renderer'); </script>
With our markup in place, we can now turn our attention to the functionality. Let’s clear away anything we might have in app/renderer.js and start fresh. Throughout our time together, we’re going to need to work with a few of the elements we added to the markup, so let’s start by querying for those selectors and caching them into variables. Add the following to app/renderer.js.
const linksSection = document.querySelector('.links'); const errorMessage = document.querySelector('.error-message'); const newLinkForm = document.querySelector('.new-link-form'); const newLinkUrl = document.querySelector('.new-link-url'); const newLinkSubmit = document.querySelector('.new-link-submit'); const clearStorageButton = document.querySelector('.clear-storage');
If you look back at listing 2.12, you’ll notice that we set the input element’s type attribute to "url" in the markup. Chromium will mark the field as invalid if the contents do not match a valid URL pattern. We can style valid and invalid states of the element and even check its state using JavaScript. Unfortunately, we don’t have access to the built-in error message popups in Chrome or Firefox. Those popups are not part of the Chromium content module and—as a result—not part of Electron. For now, we disable the start button by default and then check to see if we have a valid URL pattern every time the user types a letter into the URL field.
If the user has provided a valid URL, then we flip the switch on that submit button and allow them to submit the URL. Let’s add this code to app/renderer.js.
newLinkUrl.addEventListener('keyup', () => { newLinkSubmit.disabled = !newLinkUrl.validity.valid; 1 });
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 call this whenever we’ve successfully stored the link.
const clearForm= () => { newLinkUrl.value = null; 1 };
When the user submits a link, we want the browser to make a request for that URL and then take the response body, parse it, find the title element, get the text from that title element, store the title and URL of the bookmark in localStorage, and then—finally—update the page with the bookmark.
You may or may not feel some of the hairs on the back of your neck begin to stand at attention. You might even be thinking to yourself, “There is no way that this plan will work. You can’t make requests to third-party servers. The browser doesn’t allow this.”
Normally, you’d be right. In a traditional browser-based application, you’re not allowed to have your client-side code make requests to other servers. Typically, your client-side code makes a request to your server which in turn proxies the request to the third-party server. When it hears back, it proxies the response back to the client. We discussed some of the reasoning behind this in chapter 1.
Electron has all the abilities of a Node server along with all the bells and whistles of a browser. This means that we’re free to make cross-origin requests without the need for a server to get in the way.
Another perk of writing this application in Electron is that we’re able to use the up-and-coming Fetch API to make requests to remote servers. The Fetch API spares us the hassle of setting up XMLHttpRequests by hand and gives a nice, promise-based interface for working with our requests. As of this writing, Fetch has limited support among the major browsers. That said, it has full support in the current version of Chromium, which means we can use it.
We add an event listener to the form to spring into action whenever the form has been submitted. We don’t have a server, so we need to be sure to prevent the default action of making a request. We do this by preventing the default action. We also cache the value of the URL input field for future use.
newLinkForm.addEventListener('submit', (event) => { event.preventDefault(); 1 const url = newLinkUrl.value; 2 // More code to come... });
The Fetch API is available as a globally available fetch variable. Fetching a URL returns a promise object, which will be fulfilled when the browser has completed fetching the remote resource. With this promise object, we could handle the response differently depending on if we decided to fetch a webpage, an image, or some other kind of content. In this case, we’re fetching a webpage, so we convert the response to text. We start with the following code inside our event listener.
fetch(url) 1 .then(response => response.text()); 2
Promises are chainable. We can take the return value of the previous promise and tack on another call to then. Additionally, response.text() itself returns a promise. Our next step will be to take the big block of markup that we received and parse it to traverse it and find the <title> element.
Chromium provides a parser that will do this for us, but we need to instantiate it. At the top of app/renderer.js, we create an instance of DOMParser and store it for later use.
const parser = new DOMParser(); 1
Let’s set up a pair of helper functions that parse the response and find the title for us.
const parseResponse = (text) => { return parser.parseFromString(text, 'text/html'); 1 } const findTitle = (nodes) =>{ return nodes.querySelector('title').innerText; 2 }
We can now add those two steps to our promise chain.
fetch(url) .then(response => response.text()) .then(parseResponse) .then(findTitle);
At this point, the code in app/renderer.js looks like this.
const parser = new DOMParser(); const linksSection = document.querySelector('.links'); const errorMessage = document.querySelector('.error-message'); const newLinkForm = document.querySelector('.new-link-form'); const newLinkUrl = document.querySelector('.new-link-url'); const newLinkSubmit = document.querySelector('.new-link-submit'); const clearStorageButton = document.querySelector('.clear-storage'); newLinkUrl.addEventListener('keyup', () => { newLinkSubmit.disabled = !newLinkUrl.validity.valid; }); newLinkForm.addEventListener('submit', (event) => { event.preventDefault(); const url = newLinkUrl.value; fetch(url) .then(response => response.text()) .then(parseResponse) .then(findTitle) }); const clearForm = () => { newLinkUrl.value = null; } const parseResponse = (text) => { return parser.parseFromString(text, 'text/html'); } const findTitle = (nodes) => { return nodes.querySelector('title').innerText; }
localStorage is a simple key/value store that is built into the browser and persists between sessions. You can store simple data types like strings and numbers under an arbitrary key. Let’s set up another helper function that will make a simple object out of the title and URL, convert it into a string using the built-in JSON library, and then store it using the URL as the key.
const storeLink = (title, url) => { localStorage.setItem(url, JSON.stringify({ title: title, url: url })); };
Our new storeLink function needs the title as well as the URL to get its job done, but the previous promise returns only the title. We use an arrow function to wrap our call to storeLink in an anonymous function that has access to the url variable in scope. If that is successful, we clear the form as well.
fetch(url) .then(response => response.text()) .then(parseResponse) .then(findTitle) .then(title => storeLink(title, url)) 1 .then(clearForm);
Storing the links is not enough. We also want to display them to the user. This means that we need to create the functionality to go through all the links that we stored, turn them into DOM nodes, and then add them to the page.
Let’s start with the ability to get all the links out of localStorage. If you recall, localStorage is a key/value storage. We can use Object.keys to get all the keys out of an object. We have to give ourselves another helper function to get all the links out of localStorage. This isn’t a huge deal because we needed to convert them from strings back into real objects anyway. Let’s define a getLinks function.
const getLinks = () => { return Object.keys(localStorage) 1 .map(key => JSON.parse(localStorage.getItem(key))); 2 }
Next, we take these simple objects and convert them into markup so that we can add them to the DOM later. We create a simple convertToElement helper that can take care of this as well. It’s important to mention that our convertToElement function is a bit naive and does not try to sanitize user input. In theory, your application is vulnerable to script-injection attacks. It’s a bit outside of the scope of this chapter, so we do just the bare minimum to render these links onto the page. I’ll leave it as an exercise to the reader to secure this feature.
const convertToElement = (link) => { return ` <div class="link"> <h3>${link.title}</h3> <p> <a href="${link.url}">${link.url}</a> </p> </div> `; };
Finally, we create a renderLinks() function that calls getLinks, concatenates them, converts the collection using convertToElement(), and then replaces the linksSection element on the page.
const renderLinks = () => { const linkElements = getLinks().map(convertToElement).join(''); 1 linksSection.innerHTML = linkElements; 2 };
We can now add now add this final step to our promise chain.
fetch(url) .then(response => response.text()) .then(parseResponse) .then(findTitle) .then(title => storeLink(title, url)) .then(clearForm) .then(renderLinks);
We also render all of the links when the page initially loads simply by calling renderLinks() at the top-level scope.
renderLinks(); 1
One of the advantages of using promises in coordination with breaking out functionality into named helper functions is that it’s very clear what our code is doing as it works through fetching the external webpage, parsing it, storing the result, and re-rendering the list of links.
The final thing we need to complete all of the functionality for our simple application is to wire up the Clear Storage button. We call the clear method on localStorage and then empty the list in linksSection.
clearStorageButton.addEventListener('click', () => { localStorage.clear(); 1 linksSection.innerHTML = ''; 2 });
With the Clear Storage button in place, it seems we have most of the functionality in place. Our application now looks something like figure 2.8. At this point, our code for our renderer process should look like listing 2.30.
const parser = new DOMParser(); const linksSection = document.querySelector('.links'); const errorMessage = document.querySelector('.error-message'); const newLinkForm = document.querySelector('.new-link-form'); const newLinkUrl = document.querySelector('.new-link-url'); const newLinkSubmit = document.querySelector('.new-link-submit'); const clearStorageButton = document.querySelector('.clear-storage'); newLinkUrl.addEventListener('keyup', () => { newLinkSubmit.disabled = !newLinkUrl.validity.valid; }); newLinkForm.addEventListener('submit', (event) => { event.preventDefault(); const url = newLinkUrl.value; fetch(url) .then(response => response.text()) .then(parseResponse) .then(findTitle) .then(title => storeLink(title, url)) .then(clearForm) .then(renderLinks); }); clearStorageButton.addEventListener('click', () => { localStorage.clear(); linksSection.innerHTML = ''; }); const clearForm = () => { newLinkUrl.value = null; } const parseResponse = (text) => { return parser.parseFromString(text, 'text/html'); } const findTitle = (nodes) => { return nodes.querySelector('title').innerText; } const storeLink = (title, url) => { localStorage.setItem(url, JSON.stringify({ title: title, url: url })); } const getLinks = () => { return Object.keys(localStorage) .map(key => JSON.parse(localStorage.getItem(key))); } const convertToElement = (link) => { return `<div class="link"><h3>${link.title}</h3> <p><a href="${link.url}">${link.url}</a></p></div>`; } const renderLinks = () => { const linkElements = getLinks().map(convertToElement).join(''); linksSection.innerHTML = linkElements; } renderLinks();
So far, everything appears to work. Our application fetches the title from the external webpage, stores the link locally, renders the links on the page, and clears them from the page when we ask it to.
But what happens if something goes wrong? What happens if we give it an invalid link? What happens if the request times out? We’ll handle the two most likely cases: when the user provides a URL that passed the validation check on the input field but is not in fact valid, and when the URL is valid but the server returns a 400- or 500-level error.
The first thing we add is the ability to handle any error. Promise chains support a catch method, which is called into action in the event of an uncaught error. We define another helper method in this event.
const handleError = (error, url) => { errorMessage.innerHTML = ` There was an issue adding "${url}": ${error.message} `.trim(); 1 setTimeout(() => errorMessage.innerText = null, 5000); 2 }
We can add that to the chain. We use another anonymous function to pass along the URL with our error message. This is primarily for providing better error messages. It’s not necessary if you don’t want to include the URL in the error message.
fetch(url) .then(response => response.text()) .then(parseResponse) .then(findTitle) .then(title => storeLink(title, url)) .then(clearForm) .then(renderLinks) .catch(error => handleError(error, url)); 1
We also add a step early on to the chain that checks to see if the request was successful. If so, it passes the request along to the next promise in the chain. If it was not successful, then we throw an error, which circumvents the rest of the promises in the chain and skips directly to the handleError() step. There is an edge case here that I didn’t handle: the promise returned from the Fetch API rejects outright if it cannot establish a network connection. I leave that as an exercise to the reader to handle because we have a lot to cover in this book and a limited number of pages to do it in. response.ok will be false if its status code is in the 400- or 500-range.
const validateResponse = (response) => { if (response.ok) { return response; } 1 throw new Error(`Status code of ${response.status} ${response.statusText}`); 2 }
This code passes the response object along if there is nothing wrong. But if there is something wrong, it throws an error, which is caught by handleError() and dealt with accordingly.
fetch(url) .then(validateResponse) .then(response => response.text()) .then(parseResponse) .then(findTitle) .then(title => storeLink(title, url)) .then(clearForm) .then(renderLinks) .catch(error => handleError(error, url));
We’re not out of the woods yet—we also have an issue in the event that everything goes well. What happens if we click one of the links in our application? Perhaps unsurprisingly, it goes to that link. The Chromium part of our Electron application thinks that it is a web browser, and so it does what web browsers do best—it goes to the page.
Except our application is not really a web browser. It lacks important things like a Back button or a location bar. If we click any of the links in our application, we’re pretty much stuck there. Our only option is to kill the application and start all over.
The solution is to open the links in a real browser. But this raises the question, which browser? How can we tell what the user has set as their default browser? We certainly don’t want to take any lucky guesses because we don’t know what browsers the user has installed and no one likes seeing the wrong application start opening just because they clicked a link.
Electron ships with the shell module, which provides some functions related to high-level desktop integration. The shell module can ask the user’s operating system what browser they prefer and pass the URL to that browser to open. Let’s start by pulling in Electron and storing a reference to its shell module at the top of app/renderer.js.
const {shell} = require('electron');
We can use JavaScript to determine which URLs we want to handle in our application and which ones we want to pass along to the default browser. In our simple application, the distinction is easy. We want all of the links to open in the default browser. Links are being added and removed in this application, so we set an event listener on the linksSection element and allow click events to bubble up. If the target element has an href attribute, we prevent the default action and pass the URL to the default browser instead.
linksSection.addEventListener('click', (event) => { if (event.target.href) { 1 event.preventDefault(); 2 shell.openExternal(event.target.href); 3 } });
With that relatively simple change, our code behaves as expected. Clicking a link will open that page in the user’s default browser. We have a simple—yet fully functional—desktop application.
Our finished code should look something like the following code example. You may have your functions in a different order.
const {shell} = require('electron'); const parser = new DOMParser(); const linksSection = document.querySelector('.links'); const errorMessage = document.querySelector('.error-message'); const newLinkForm = document.querySelector('.new-link-form'); const newLinkUrl = document.querySelector('.new-link-url'); const newLinkSubmit = document.querySelector('.new-link-submit'); const clearStorageButton = document.querySelector('.clear-storage'); newLinkUrl.addEventListener('keyup', () => { newLinkSubmit.disabled = !newLinkUrl.validity.valid; }); newLinkForm.addEventListener('submit', (event) => { event.preventDefault(); const url = newLinkUrl.value; fetch(url) .then(response => response.text()) .then(parseResponse) .then(findTitle) .then(title => storeLink(title, url)) .then(clearForm) .then(renderLinks) .catch(error => handleError(error, url)); }); clearStorageButton.addEventListener('click', () => { localStorage.clear(); linksSection.innerHTML = ''; }); linksSection.addEventListener('click', (event) => { if (event.target.href) { event.preventDefault(); shell.openExternal(event.target.href); } }); const clearForm = () => { newLinkUrl.value = null; }; const parseResponse = (text) => { return parser.parseFromString(text, 'text/html'); }; const findTitle = (nodes) => { return nodes.querySelector('title').innerText; }; const storeLink = (title, url) => { localStorage.setItem(url, JSON.stringify({ title: title, url: url })); }; const getLinks = () => { return Object.keys(localStorage) .map(key => JSON.parse(localStorage.getItem(key))); }; const convertToElement = (link) => { return `<div class="link"><h3>${link.title}</h3> <p><a href="${link.url}">${link.url}</a></p></div>`; }; const renderLinks = () => { const linkElements = getLinks().map(convertToElement).join(''); linksSection.innerHTML = linkElements; }; const handleError = (error, url) => { errorMessage.innerHTML = ` There was an issue adding "${url}": ${error.message} `.trim(); setTimeout(() => errorMessage.innerText = null, 5000); }; const validateResponse = (response) => { if (response.ok) { return response; } throw new Error(`Status code of ${response.status} ${response.statusText}`); } renderLinks();
13.59.231.155