Wrapping Responsive UI Apps in Electron
Responsive web applications are built using HTML, CSS, and JavaScript. While these technologies can produce beautiful user interfaces, their ability to interact with the user’s environment is sand boxed in order to limit the damage that can be done by potentially malicious code.
This means that web apps have less access to their environment than desktop apps, by design. While HTML 5 allows web apps to access some features of mobile devices, a web app couldn’t say, drive cash dispense hardware.
Here are a few things that desktop apps can do that traditional web apps cannot:
- Work with the local file system
- Access/update the Windows registry
- Communicate with hardware devices
- Launch other desktop apps
- Display native OS dialogs, like the File Open dialog
- Access system information like the OS, screen resolution, etc
- Access user information like the home directory
- Take screen shots
When a Jack Henry responsive web app must be able to do the same kinds of things that desktop apps can do, we wrap it with a native wrapper: a cross-platform native executable for Windows, Mac, or Linux that wraps your responsive web app with a native executable that acts like any other native app: you launch it, minimize it, maximize it, close it, and switch to other apps just like any other native app. It shows up in the Windows taskbar and the Mac dock like any other native app. That’s because it is a native app, one that wraps your responsive web app. And in addition to wrapping your web app with a native executable, the native wrapper also extends your responsive web app with access to all the same kinds of functionality that a traditional desktop app has.
We support Electron as an open-source native wrapper solution. Electron wraps your web app with a native wrapper for Windows, Mac and/or Linux, displaying your app within an embedded copy of the Chromium browser. It also extends your web app with the same kinds of functionality available to native apps. This document covers the basic setup for getting your responsive web app wrapped in Electron; see the Electron site for more documentation.
Reference the Electron sample app repo to see how to wrap a Responsive UI app with Electron.
Progressive web apps are another good option for wrapping your Responsive UI web application; however, we only focus on Electron in this document.
Also, Electron creates a native wrapper for desktop environments: Windows, Mac, and Linux. Check out the Cordova project if you need a native wrapper for mobile environments.
In order to wrap your web app and extend it with native capabilities, Electron uses two separate processes:
- Main process: This is a non-visual Node.js-based process that (a) handles all the setup for wrapping your app and (b) provides native functionality that extends your app’s capabilities.
- Renderer process: This is your existing responsive web app. This is the process that users see and interact with.
These processes use two-way communication to coordinate activities. The renderer process (your web app) typically initiates that communication, whenever it needs the main process to perform some native functionality on its behalf. Depending on what the renderer process requests, the main process may simply perform the requested activity (say launch Excel) or it may respond with some kind of results, like a listing of files in a particular directory. However, the main process can also asynchronously initiate communicate with the renderer process, typically to let it know that some native-level event has occurred.
![Diagram of Electron processes](./ElectronProcesses1.png)
Wrapping your app in Electron
The Electron wrapper can either fully embed your Angular app within the wrapper and display the embedded app, or it can point to your app hosted on a web server.
The approach you choose here determines two things: how you provide ongoing updates to your application and how your renderer process communicates with your Electron main process.
Embedded app model
If you embed your Angular app within the Electron wrapper, it runs locally, fully hosted within the Electron wrapper. This model has one advantage and one major disadvantage.
- The advantage is simpler communication between the main process and renderer process. Because everything is packaged locally, the two processes can communicate directly using the simple Electron IPC mechanism.
- The downside is that, since the entire app is local, every update to the app requires you to update the app on the user’s workstation using an installer. This fact will make the embedded approach less attractive for most Jack Henry applications.
![The embedded app model allows simple, node-based IPC, but every update requires you to reinstall the Electron wrapper on the user's desktopElectron wrapping an embedded web app](./ElectronEmbedded.png)
Hosted app model
The hosted app model, where the Electron wrapper points to your app hosted on a web server, has one major advantage and one disadvantage.
The advantage is significantly easier updates. When you need to update your app, you can (for the most part) simply redeploy the app on the web server, and all copies of the Electron wrapper installed on user workstations will display the updated version.
- The only exception to this is when you add or update any of the electron functionality in the main process. Let’s say you add a new feature that launches Excel on the user’s workstation. Since that involves a change to the Electron wrapper itself (the new feature to launch Excel), you would need to update the Electron wrapper on each user’s workstation with an installer. Contrast this to the embedded app model, where every app update requires you to update the Electron wrapper on each user’s workstation. Fortunately, the majority of updates to your application won’t be updating the Electron wrapper, so installer updates will be the exception and not the rule.
The downside is that communication between the main process and the renderer process requires extra steps in order to avoid introducing potential security risks. The simple IPC communication mechanism in Electron is node-based. If your app is hosted on a server, you absolutely do not want to turn on node integration within the hosted renderer process (your web app) as this is a major security risk, so we have to set up a couple of extra layers to the communication in order to preserve security. We talk more about that below.
![The hosted app model involves more steps for communication between your app and Electron to ensure security, but allows you to provide most updates through simple deployment to the web serverElectron wrapping a hosted web app](./ElectronHosted.png)
Extending your app with Electron
We mentioned earlier that Electron extends your app’s potential by adding native app functionality, such as working with the local file system, communicating with hardware devices, launching other desktop apps, and much more. In Electron, the main process (electron.js) is where you add those kinds of functionality.
The Electron main process uses Node.js, which is a JavaScript runtime. The Node.js runtime out of the box includes most of the functionality your main process will need.
And if you need functionality beyond that, there are hundreds of thousands of additional modules (packages) that have been written for Node.js. You can explore all of this functionality at npm, a package manager for Node.js modules. npm helps you find, install, and manage the packages used by your Electron main process. In fact, Electron itself is installed with npm.
The electron.js file in the Electron sample app repo shows examples of using node functionality to extend the app with native functionality.
Any additional node modules that you reference from electron.js should be added to the main project’s package.json file and installed with npm, then they will be available for your electron.js file to use.
Node.js provides powerful native functionality. Node.js can potentially be used to harm the user’s workstation, network, and data.
Because of this, any time you add new functionality to your main process, ask yourself how a hacker might leverage it to harm the user. Keep this functionality as simple and benign as possible. NEVER code open-ended requests that might allow the renderer process to pass in unanticipated commands.
Secure communication between the main and renderer processes
As you look at Electron documentation and examples, you’ll see inter-process communication (IPC) used for the communication between the main process and the renderer process. The main process uses an ipcMain object to send and receive IPC messages, while the renderer process uses an ipcRenderer object to send and receive IPC messages. This communication can be synchronous for requests that can be fulfilled immediately or asynchronous for longer-running requests.
This IPC communication uses Node.js. In the embedded app model, it’s safe for both the main process and renderer process to use Node.js since all parts of the app are running local on the user’s workstation.
However, in the hosted app model (which most Jack Henry apps will likely use), your app is deployed to a web server. It is considered a security risk to enable node integration in a hosted web app due to the potential for cross-site scripting attacks against the user’s workstation. Enabling node integration in your web app would potentially allow a hacker to inject Node.js commands in your web app that could compromise the user’s workstation, network, and data. So, we must disable node integration in the renderer process for hosted apps.
But the Electron IPC mechanism itself is based on Node.js, so we’re unable to use that IPC mechanism directly from our renderer process.
Luckily there is a way around this. Electron can inject a preload script into your renderer process that acts as a communication bridge between your main and renderer processes (see the diagram above). This preload script is installed only on the user’s workstation, along with the Electron main script, so both are off limits to a hacker who compromises the web server. Your renderer process calls functions within the preload script, which in turn uses the Node-based IPC mechanism to pass the message to the main process, and vice versa. The worst a hacker can do in this scenario is modify your renderer process to call into the Node-based functionality that you’ve already defined, not add new, more potentially damaging Node-based functionality. Any time you add new native functionality to your main process, always ask yourself how a hacker might leverage it to harm the user.
We talk more below about setting up the communication bridge with the preload script.
Installing Electron
You must add the npm packages for both Electron and ngx-electron — an Angular wrapper that makes it simpler for your Angular app to use Electron — to your application’s package.json. Use npm install to install these packages for your solution.
Import NgxElectronModule into your app-module.
Setting Up the Electron main process
The Electron main process is simply a JavaScript file. We name this file electron.js in our examples, although it can have any name. (You’ll often see it named main.js in online examples.)
You’ll store all Electron-related files in the /src/electron folder in your solution, including electron.js, preload.js (the preload script for bridge communication), and the app’s icon. Start by copying this entire folder from the Electron sample app repo into your solution’s /src folder. It should contain electron.js, preload.js, icon.ico, and package.json files.
Let’s look at the electron.js file and talk about what it’s doing. Below is a listing of the entire file. As we look at the code, we see the following high-level things going on;
- It imports the Node.js modules we need.
- It defines a global JavaScript variable named win. We’ll assign the main browser window object to this variable in a moment, but for now, declaring the variable at this level makes it global so it won’t get garbage collected, which would close your Electron window.
- It defines a function named createWindow() to set up and display your main Electron window. More on this later.
- It creates an event handler for the app “ready” event that simply calls the createWindow() function to set up and display your main Electron window.
- It creates event handlers for the app “window-all-closed” and “activate” events to set up proper window handling in MacOS.
- It defines handlers for application-level IPC requests: getting the OS-level path separator character, getting the OS-defined home directory, getting the current Electron - version, displaying a progress value in the OS taskbar icon, and retrieving a list of files and folders within a specified directory.
Let’s take a closer look at the createWindow() function.
- It instantiates a new BrowserWindow object, passing in a JSON object that contains configuration parameters. BrowserWindow is the class for your Electron window. The configuration parameters include:
- show specifies whether the window shows immediately. We set this to false here because we show the window during the “ready-to-show” event, providing a smoother start-up experience for the user.
- width and height specify the size of the window. You have to hard code specific values here. You’ll want to ensure that the window size is large enough to comfortably display your app without being larger than a typical user’s monitor.
- backgroundColor specifies the color displayed as the window background while your app loads. Leave this at the default value for consistency with other Jack Henry applications. Once your app loads, Electron will use the themed body background color as the window background color. It only uses the value specified here while your app loads.
- resizable specifies whether your window is resizable. Leave this set to true since our application windows are resizable.
- icon specifies which ICO file to use for your Electron wrapper. This file must be in ICO format and should be at least 256x256 for best results. You can use this same icon file for both Electron and the Electron packager/installer you create for your app.
- The webPreferences section is critical if your app will be hosted on a web server and not fully embedded within the Electron wrapper. (See the “Wrapping your App in Electron” section above.) If you’re fully embedding your web app within the Electron wrapper, you should omit the webPreferences section. Otherwise you must include the webPreferences section and set the following values:
- nodeIntegration must be set to false. Leaving node integration on within a hosted web app is a major security risk!
- preload is the name of the preload script that Electron will load into the web app’s DOM. It contains the communication bridge. See the “Setting Up the Preload Script” section below for details.
- After the BrowserWindow constructor, the code calls loadURL. Provide the path to your web application. Electron will load this into its embedded Chromium browser.
- If your Electron wrapper points to a hosted web app (which should be the case for the majority of apps), simply provide the https:// path to the app.
- If your Electron wrapper fully embeds your web app, provide a file:// path to your app’s index.html. Note that the index.html for this case must specify for the app to be able to properly load all of its JavaScript and CSS bundles.
- Note that even if your Electron wrapper will point to a hosted web app in production, it can be handy to temporarily embed it in the Electron wrapper while you’re working on Electron integration. That allows you to build/run/test everything locally while developing, then switch it to point to the hosted production app when you’re ready.
- The createWindow() function sets up event handlers for “ready-to-show”, “closed”, and “new-window” events. Leave this all as it is in the file since these are critical to proper window management.
Setting up the preload script
As we discussed earlier, hosted web apps cannot have node integration turned on for security reasons. This means that your renderer process cannot use Electron IPC to directly to communicate with the main process.
Instead, we define a preload script (preload.js) that proxies the communication between the renderer and main processes. This script file only exists on the user’s workstation, not on the web server, so a hacker that compromises the web server cannot change either the preload script or electron.js. Electron injects the preload script before loading your web app, so your app has access to the functionality in the preload script. So the only way a hacker can leverage Node.js functionality would be to make your app call into the existing Electron functionality defined within electron.js and preload.js, minimizing the potential for damage.
Most of the IPC communication between the renderer and main processes is initiated by the renderer process, typically in response to an action taken by the user. For example, say the user presses a button in your UI that is supposed to launch Excel; the renderer process (your web app) calls a function within preload.js, which in turn sends an IPC message to the main process. The main process receives the IPC message and uses Node.js functionality to launch Excel.
Let’s look at what’s happening in preload.js:
- The first thing it does is define a NativeWrapperBridge object in the window object. Remember that preload.js is loaded into memory before your web app, so your web app can interact with this NativeWrapperBridge object in the DOM window object. The NativeWrapperBridge object contains pointers to each of its functions that the renderer process can call in order to initiate communication to the main process. You’ll have one entry in this object for every function that the renderer process will need to call.
- It then implements a function for each type of communication that will be initiated from the renderer process. You’ll notice that the simplest versions of these like setZoomLevel() and setTaskbarProgress() simply send a synchronous IPC message to the main process since they don’t need anything in return. They essentially “throw a message over the wall” for the main process to fulfill. In other cases, like getElectronVersion() and localFiles(), it looks for a response after sending the request. You can also pass values to the IPC requests, as seen in the setTaskbarProgress() and localFiles() functions. const { ipcRenderer, webFrame } = require(’electron’);
Setting up a service that wraps the preload script
The preload script (preload.js) is a plain JavaScript file. Because calling plain JavaScript from TypeScript can be awkward, it helps to wrap the functionality exposed in preload.js with an Angular service.
Check out the AppNativeWrapperService displayed below. You can see that it defines a TypeScript-based service call for every function defined in window.NativeWrapperBridge for the renderer process to call. It first verifies that the NativeWrapperBridge is defined in window.
Your Angular web app could technically make all of these calls directly. You don’t have to create an Angular service to wrap the functionality in preload.js. But doing so encapsulates the awkward TypeScript/JavaScript boundary within this service, making it much simpler and easier to call into this functionality from the rest of your Angular code. Remember to import the service in app.module and include it in the providers section so it’s available globally throughout your application.
Setting Up Electron scripts in package.json
Three things need to happen before running your app in Electron:
- You have to build your app. If you’re using the embedded model, the build process needs to set the base href to “./”.
- The contents of the electron folder need to be copied down into the dist folder so they’re with the rest of your compiled app.
- You have to shell execute this command to display your Electron-wrapped app:
electron dist/electron.js
It helps to define scripts within package.json for each of these steps. Here are some sample scripts that you can add to the scripts section in package.json:
"build-electron": "ng build --prod --base-href \"./\" && copy src\\electron\\* dist"
- Usage:
npm run build-electron
- This script builds your app, setting base href to “./” (something you only need to do for the embedded model), then copies the contents of the electron folder down into the dist folder.
- IMPORTANT: If you’re using the hosted model, omit the
--base-href
flag and just use"build-electron": "ng build --prod && copy src\\electron\\* dist"
- It can be handy to have two build-electron scripts, one that sets base href for quick local embedded testing, and a production build-electron script that omits the
--base-href
flag for the hosted model.
- Usage:
"electron": "npm run build-electron && electron dist/electron.js"
- Usage:
npm run electron
- This script runs the
build-electron
script above to build the app and copy files, then starts electron with the dist/electron.js file.
- Usage:
"test-electron": "copy src\\electron\\* dist && electron dist/electron.js"
- Usage:
npm run test-electron
- This script simply copies the electron files down into dist and starts electron with the
dist/electron.js
file. This is handy when you want to quickly re-test your Electron-wrapped app without rebuilding it.
- Usage:
You can see examples of these kinds of scripts in the package.json file in the Electron sample app repo. Note that the copy commands shown above will work in Windows but not in UNIX-based operating systems like Mac and Linux.
Setting up your app header for Electron
Jack Henry responsive apps wrapped in Electron display a back button in the application header since they are not displayed within the browser and don’t have access to a browser back button. This section walks you through how to do that.
main-header.component.html
First, modify the jha-header-left
element to display the back button when your app runs within a native wrapper. You do that by binding the jhaDisplayBackButton property to a boolean model value that you’ll set to true when running within Electron. You’ll also define a click event handler for the jhaBackButtonClicked event.
<jha-header-left [jhaDisplayBackButton]="isNativeWrapper" (jhaBackButtonClicked)="backButtonClicked()">
main-header.component.ts
Import ElectronService:
import { ElectronService } from 'ngx-electron';
Add a boolean property that specifies whether the app is in Electron:
public isNativeWrapper: boolean = false;
In the constructor, inject the Electron service and set isNativeWrapper to the isElectronApp property in this.electronService, which specifies whether your app is running within Electron.
constructor(private electronService: ElectronService) {
this.isNativeWrapper = this.electronService.isElectronApp;
}
Add an event handler for the jhaBackButtonClicked event that just goes back one step in history.
public backButtonClicked() {
window.history.back();
}
Creating an installer for your Electron native wrapper
While you as a developer can run your Electron-wrapped app by typing electron dist/electron.js
from a shell prompt, that obviously isn’t the way you want your users to run your app.
There are two main utilities for packaging your Electron-wrapped app and creating an installer for end users to run: electron-builder and electron-packager.
You can use either of these utilities to package your app and generate an installer. We found electron-builder to be a little simpler, so the Electron sample app repo and this documentation describes how to use that, although electron-packager may be a better fit for your needs. The BranchAnywhere product uses electron-packager because of its support for distribution through the Microsoft Store.
Setting up electron-builder is relatively straightforward:
- Add the sections highlighted in the code sample below to your
package.json
file. - Do an
npm install
from the shell to install electron-builder as a dev dependency. - Run
npm run build-package
from the shell to build your app for Electron then package it or runnpm run package
from the shell to simply package your app without rebuilding it. - electron-builder creates a packaged folder in your solution. This folder contains a setup EXE that anyone can run to install your app.
- The packaged folder also contains a win-unpacked subfolder that contains all files for your application, including the EXE. If you distribute this manually instead of an installer, you must distribute all files and subfolders within the win-unpacked folder for your app to run.
{
"name": "my-app",
. . .
"main": "./dist/electron.js",
"scripts": {
. . .
"build-package": "npm run build-electron && electron-builder",
"package": "electron-builder"
},
"build": {
"appId": "<arbitrary, unique ID for your solution>",
"win": {
"target": "NSIS",
"icon": "./dist/icon.ico"
},
"nsis": {
"allowElevation": false,
"allowToChangeInstallationDirectory": true,
"oneClick": false,
"perMachine": true
},
"electronDownload": {
"cache": "C:\<path to your solution>\src\electron\cache"
},
"files": [
"!**/node_modules/*"
],
"directories": {
"output": "packaged"
}
},
"private": true,
"dependencies": {
. . .
},
"devDependencies": {
. . .
"electron-builder": "20.29.0",
. . .
}
}