You have an amazing web app. But now you want the same app packaged as a desktop app, but with a little extra native magic sprinkled on top. For example, you want your todo app to show a little red counter on the dock icon with the remaining todo count on it, like this:
The web app is integrating with the Electron app to keep the dock icon count to the right up to date.
Or you want to add a menu item to mark them all as completed, like this:
The Electron app is integrating with the web app to modify it’s state.
Achieving this requires you to solve several problems:
Load a web page into your Electron app but make it look like you’re not just loading a web page
Don’t expose Electron and node.js APIs to the web app (you don’t want an XSS vulnerability in the web app to be able todo this:
require('child_process').exec('rm -rf /*')
)Make it so that the web app can safely trigger native actions (e.g. app badge update like above) and the Electron app can trigger web app actions (e.g. “Mark All As Complete” menu item above)
As a demonstration, I created the above app using TodoMVC’s vanilla implementation with a few minor additions. The code for that is here: ccnokes/electron-tutorials - Collection of small sample Electron apps
Let’s get to it.
Wait, why not just bundle my web app code into my Electron app and ship that?
You could do definitely this. The main pro of this approach that I can think of is that the initial load is faster because you don’t need to make network requests to download HTML, JS, and CSS. (Although you could use caching or a service worker to mitigate this). The drawback is keeping your Electron app’s version of the web app in sync with the actual web app. For organizations that do incremental rollouts to their customers, this becomes even more difficult or basically impossible. So this just depends.
How do you load a web app into Electron?
*Update (6/2019): BrowserView is a newer, better supported API for hosting web contents that replaces webviews, which are now deprecated. Use that instead. It has a very different API than webviews, unfortunately. Someday, I’ll update this article… 😬*
Similar to native iOS and Android apps, Electron has the concept of a webview. A webview is a custom HTML tag that is an embedded web context. It’s like an iframe except a different API and it runs in it’s own renderer process. In our HTML we just do this:
<webview src="{web app URL here}"></webview>
The complete HTML for my example is here.
By default node-integration, which provides access to Electron and node.js APIs, is turned off. This is what we want for security reasons. All we need to do now is display a loader so that as soon as the app starts we see a nice loader, not a white screen.
The webview emits events when the loading status of it’s contents changes. We hook into those events and hide/show our loader based on them.
First, at the bottom of our HTML, we can add a script tag and require our renderer Javascript like so:
<script>require('./hybrid-renderer')</script>
Then in the Javascript, we’ll wire up our loader hide/show logic:
How do I integrate my web app and Electron app?
Electron lets you define a preload script on a webview. A preload script is a local JavaScript file that gets executed in the context of the remote web page before any other scripts on the remote web page. The preload script has access to Electron and node.js APIs, but when it has finished executing, those APIs are removed from the global scope so that the remote web page doesn’t have access to them. The preload script also has access to the DOM of the remote page.
We add the preload script to our webview like so:
<webview src="{web app URL here}" preload="./preload.js">
So in the preload script we create a “bridge” between our Electron app and the remote web app. We create a global object on window and add methods/properties to it. The web app, once loaded, will be able to see and interact with that object like normal. It’s almost like adding a new DOM API. This way, the web app can safely call those methods without being able to access Electron/node.js APIs. Also, in the web app, we can use the presence of that object on window as a reliable way to feature detect if we’re in the Electron app.
Let’s look at the preload script:
Now we need to integrate setDockBadge into the todo app. We don’t want to call setDockBadge unless the web app is running in Electron, so first let’s define a function to check for that.
window.isElectron = function() { return 'Bridge' in window; };
The convention in vanilla TodoMVC is attaching helper functions to window, but you could define this however you want in your web app code.
For the setDockBadge integration, I don’t want to dive deep into TodoMVC’s code, but this is where I’ve integrated it. The app’s controller has an _updateCount method where it updates the view with the correct values. I simply added setDockBadge at the end of that method, like so:
if(isElectron()) {
window.Bridge.setDockBadge(todos.active);
}
The markAllAsComplete implementation is also pretty simple. The controller has a toggleAll method, so we simply define a method on window.Bridge that calls it, like this:
if(isElectron()) {
window.Bridge.markAllComplete = () =>
this.controller.toggleAll(true);
}
Our Electron app will have access to that method as well, and we call it when we receive an IPC message from the main process.
Back in our Electron app’s main process, we define a click handler on our “Mark All As Complete” menu item that sends the IPC message to the webview. (Code for that is here).
That’s the gist of how you wire together a remote web app and an Electron app. Because the code for this is spread out over multiple files, it might be easier to just browse the repository.