Stop struggling with kiosk applications: modern electron-vue-balena workflow

Working for Escape Rooms, I wanted to get an idea of how to implement a kiosk application sooner or later. How cool would it be, if there was something like a touchscreen inside an escape room, where you have to solve some sort of graphical riddle. And as soon as I caught this thought: boom — there was a new project coming in. The client wanted a kiosk application which should run on a raspberry pi. So my journey began:

Since I already worked with electron and with vue.js separately, I thought: “Hey, I could combine these two”, since it’s a pretty common approach.
There was no problem at first, considering there are cool tools out there like https://electronforge.io/ which give you minimal starting difficulties when starting a new electron project.

But there was one thing which was bothering me the whole time: “How can I provide a simple, easy to use and consistent way to deploy and watch the necessary devices?”

And then I found balena.io, exactly the kind of helping hand I was looking for.
Only thing was, there was no “working” starting point for my kind of project. There are some repositories out there, but those are >1 years old.
Long story short: I started my journey in exploring the combination of vuecli3 + electron + balena.io.

While trying to work something out, there were some really annoying hurdles (like hidden version conflicts etc.), kinda the reason why I’m actually writing this. While some were maybe tricky figure out, I won’t explain every single one, since some of them have no extra value if you just use the Github repository starter project

If you are already familiar with all the technologies separately, you can skip the next three parts.

1. “Hello World” Vue App

You can find all vue-related files inside /app .
The project is build with the latest vue-cli (3), check out the documentation if you have any questions, it is written pretty easy to understand.

The app is served classically through yourmain.js file as a starting point.

2. Electron Integration

Electron was included with a vue-cli-electron plugin.
The most important file is background.js , where mainly all the magic happens. This is where you can define window sizes, what to render and other system relevant configs. It mainly enables you to use system resources which are hidden from a normal browser application, like using the filesystem for example. I will speak about the stuff happening in background.js later on.

3. BalenaCloud

The reason for this article would be BalenaCloud since I didn’t find a cleaner and more reliable way to deploy and maintain your single board computers and sadly, there are not enough people who blog or contribute to the project.

First, you gotta register yourself and an Application. While doing so, you can choose from a list of supported SBCs and start developing. 
As soon as you register an Application at BalenaCloud, it works like a git hoster.
It is as simple as just doing a git push balena to deploy your code to the platform. But be careful, because you have to make your application compactible to BalenaCloud.

Basically, there is a web runner in Balena’s cloud, which is building a docker image out of your config ( Dockerfile.template ). You can checkout the balena documentation for standard configs. I will talk about docker steps for this kind of project

...
# Move to app dir
WORKDIR /usr/src/app
# Move package.json to filesystem
COPY ./app/package.json ./
RUN npm install -g @vue/cli
# Install npm modules for the application
RUN JOBS=MAX npm install && npm cache clean --force
# rm -rf /tmp/*
# Move app to filesystem
COPY ./app/ ./
RUN npm run build:pi
## uncomment if you want systemd
ENV INITSYSTEM on
# Start app
CMD ["bash", "/usr/src/app/start.sh"]

In order to build your application you need to install the vue-cli-3 inside the image. Afterwards you want to install all necessary dependencies for your vue app and clean your cache afterwards to make it more lightweight. Afterwards you just build your electron app and start the application with the startscript start.sh:

#!/bin/bash
# By default docker gives us 64MB of shared memory size but to display heavy
# pages we need more.
umount /dev/shm && mount -t tmpfs shm /dev/shm
# using local electron module instead of the global electron lets you
# easily control specific version dependency between your app and electron itself.
# the syntax below starts an X istance with ONLY our electronJS fired up,
# it saves you a LOT of resources avoiding full-desktops envs
rm /tmp/.X0-lock &>/dev/null || true
NODE_ENV='production' startx /usr/src/app/node_modules/electron/dist/electron /usr/src/app/dist_electron/bundled --enable-logging

This is also the standard start script for balena x electron.
In the last step, you just start the prod bundled app (which you built with RUN npm run build:piinside your docker config).

So far so good, let’s go back to the previously mentioned background.js :

There are only a few things to consider.
First: It is good practice to have a wrapper for all environment variables

const electronConfig = {
URL_LAUNCHER_TOUCH: process.env.URL_LAUNCHER_TOUCH === '1' ? 1 : 0,
URL_LAUNCHER_TOUCH_SIMULATE: process.env.URL_LAUNCHER_TOUCH_SIMULATE === '1' ? 1 : 0,
URL_LAUNCHER_FRAME: process.env.URL_LAUNCHER_FRAME === '1' ? 1 : 0,
URL_LAUNCHER_KIOSK: process.env.URL_LAUNCHER_KIOSK === '1' ? 1 : 0,
URL_LAUNCHER_NODE: process.env.URL_LAUNCHER_NODE === '1' ? 1 : 0,
URL_LAUNCHER_WIDTH: parseInt(process.env.URL_LAUNCHER_WIDTH || 1920, 10),
URL_LAUNCHER_HEIGHT: parseInt(process.env.URL_LAUNCHER_HEIGHT || 1080, 10),
URL_LAUNCHER_TITLE: process.env.URL_LAUNCHER_TITLE || 'BALENA.IO',
URL_LAUNCHER_CONSOLE: process.env.URL_LAUNCHER_CONSOLE === '1' ? 1 : 0,
URL_LAUNCHER_URL: process.env.URL_LAUNCHER_URL ||
formatUrl({pathname: path.join(__dirname, 'index.html'),
protocol: 'file',
slashes: true
}),
URL_LAUNCHER_ZOOM: parseFloat(process.env.URL_LAUNCHER_ZOOM || 1.0),
URL_LAUNCHER_OVERLAY_SCROLLBARS: process.env.URL_LAUNCHER_OVERLAY_SCROLLBARS === '1' ? 1 : 0,
ELECTRON_ENABLE_HW_ACCELERATION: process.env.ELECTRON_ENABLE_HW_ACCELERATION === '1',
ELECTRON_BALENA_UPDATE_LOCK: process.env.ELECTRON_BALENA_UPDATE_LOCK === '1'
}

Always remember to have a default value on all envs or else you can get pretty nasty bugs.

Second: there is a way to temporarily prevent anyone from updating the application. You can fire the events from the vue-app itself.

Electron is waiting for an event to do so:

if (electronConfig.ELECTRON_BALENA_UPDATE_LOCK) {
const lockFile = require('lockfile')
app.ipcMain.on('resin-update-lock', (event, command) => {
switch (command) {
case 'lock':
lockFile.lock('/tmp/resin/resin-updates.lock', error => {
event.sender.send('resin-update-lock', error)
})
break
case 'unlock':
lockFile.unlock('/tmp/resin/resin-updates.lock', error => {
event.sender.send('resin-update-lock', error)
})
break
case 'check':
lockFile.check('/tmp/resin/resin-updates.lock', (error, isLocked) => {
event.sender.send('resin-update-lock', error, isLocked)
})
break
default:
event.sender.send('resin-update-lock', new Error(`Unknown command "${command}"`))
break
}
})
}

In order to use this feature, you have to get the renderer inside your vue-app

const {ipcRenderer} = require('electron')

Then, you can easily just send the event to your background.jslisteners and wait for a response only once in order to get a clean request/response flow.

// Listen for a response
ipcRenderer.once('resin-update-lock', (event, error) => {
if (error) { ... }
})

// Send the 'lock' command to acquire the lock
ipcRenderer.send('resin-update-lock', 'lock')

4. Getting your Service Environment Variables from balenaCloud into your vue app

So, getting env variables into your app is actually pretty easy. The only thing to consider is that your env name must start with VUE_APP_ to be exposed inside your vue app. All envs to customize your electron part can start as you like.

Side info: If you have multiple services on your device or your fleet, you can use “service variables” in your balenaCloud-dashboard, in case you need different values for different services.

5. What about JSONs?

Well, what if you have i18n or similar libs inside your projects, where you want to be able to change values inside your device, without pushing a whole new image on to it?

The best solution I came up with was to use the /data folder on your balena device. This folder will persist through restarts and other device changes.

Create a config.json there and fill it with data.

In order to get the data inside your application, first read the file via fs inside background.js:

try {
fs.statSync('/data/config.json')
configContent = fs.readFileSync('/data/config.json', 'utf8')
console.log('Config found')
} catch (err) {
console.log('Error while reading config.json: ', err)
}

Then, make sure that the window Object electron is creating has a reference to the data:

window.configuration = configContent

Last but not least, expose the config to your vue app insidemain.js:

var ELECTRON_DETECTED = (window && window.process && window.process.type) == 'renderer'
let tmpConfig
if (ELECTRON_DETECTED) {
const { remote } = require('electron')
let currentWindow = remote.getCurrentWindow()
tmpConfig = currentWindow.configuration ? Object.assign({}, currentWindow.configuration) : jsonconfig
} else {
tmpConfig = jsonconfig
}
export const config = tmpConfig

We check if the app was started with electron or as a web app by checking if there is a renderer window. Then we get the object from window and export it.

At the very end you should see the classical hello world vue screen on your device:

I hope I managed to give you a rough overview of an up to date 
electron-vue-balena stack.
Feel free to check it out and reach out to me if you have any questions, suggestions, critics.

Very exited about my very first medium blogpost

Cheers :)