Creating an app for mac using Typescript, Electron, React, Express, Rollup, Yarn, and Supercollider

David Pocknee
Level Up Coding
Published in
15 min readApr 7, 2020

--

This is an article about the things I learned converting an existing node and react project into a standalone app for mac using electron.

I had an existing project with a React-powered web-browser-based frontend that communicated to an Express.js backend. The backend could then read, write and query files on the file system and run the application Supercollider to manipulate audio. Recently, I decided to try and convert this project into a standalone app using electron. Part of the reason for this was to try and put the frontend and backend into one handy package, and part of it was to see how easy the technology is to use. While there are plenty of guides about using electron online there were often pretty straightforward questions which required a lot of research to solve, so I thought that I would write this to share some of the knowledge I have gained from this process in the hope that other people won’t have to go through the same amount of effort.

DISCLAIMER: As web-based technologies are changing so quickly at the moment, it is worth noting that I am writing this in March 2020, and that this advice may not work by the time you read it.  Also, I am only dealing with building an app on mac - there are slightly different idiosyncrasies for both linux and windows which I will not cover here, as I don't have first-hand experience of them.

What is electron?

Electron.js is away of packaging up node.js- based projects such that they can be run as standalone apps. Or, as their website describes it: "Build cross-platform desktop apps with JavaScript, HTML, and CSS"

Why would I want to convert my react and express project into a standalone electron app?

In my existing project I was using React as an interactive frontend that ran in the web-browser. Interacting with the frontend would allow the user to generate, manipulate, and read and write audio and text files on the local file system. Due to security limitations, a web-browser is not allowed direct access to the file system via javascript scripting (so node commands such as exec, fsRead or fsWrite will not run inside the browser). Therefore, it is useful to have a backend server running outside the browser that can use node to control filesystem access and which communicates to the frontend via http. However, if you want to share this project with someone, it can sometimes be fiddly to set up; therefore, packaging it as a standalone app using electron can be useful for distributing it to people who may not be familiar with node/npm/javascript technologies or may not have the interest or time to go through the process of installing a whole load of dependencies to get your project to work.

What tools do you use to create a standalone app?

Electron, obviously. Electron wraps your components into a reduced version of chromium, but you will need a separate program to package it into a standalone app. Currently, there are three commonly-used ways of packaging your existing project into a standalone app using electron:

Which one you choose is largely dependent on how much patience you have, the size of file you want to output, and the level of customization you need.

If you want results very quickly and don’t care about file size, use electron-forge. If you are building your project from scratch, it is heavily react-based, or you need the package as small as possible, use electron-react-boilerplate. I have not used electron-builder, so I can't speak to it. electron-react-boilerplate has tweaked a lot of settings behind the scenes to ensure that the final build size is as small as possible. electron-forge offers a vast set of configuration options, but as it is built upon electron-rebuild, electron-packager, and contains an extensive plugin system, understanding what to change and how to change it requires an enormous amount of time. I ended up using electron-react-boilerplate.

How big will my finished app be?

On mac, it is virtually impossible to get the finished app below 130MB, this is due to the fact that a build of chromium is bundled in with the app. Compression can reduce this size by up to half. In my experience, electron-forge tended to be around 50MB bigger than electron-react-boilerplate. The finished .dmg file for my app was around 90MB, as was the .zip file automatically generated.

Also see: https://stackoverflow.com/questions/47597283/electron-package-reduce-the-package-size

yarn vs npm

For electron apps, it is best to use yarn over npm - this is due to the fact that many of the electron tools use yarn exclusively, as does lerna. If you are using npm, I suggest switching over before trying to do anything with electron, although electron-forge is one of the few tools which works with both package managers.

Using your frontend

If you are building an app with a frontend and backend it is likely you might have created two separate folders for the front and back parts of the project.

If you have used create-react-app for your react frontend, it might make the most sense to use the incredibly efficient packaging options built into that module, rather than trying to manually package it into a standalone module - I gave up trying to use rollup to package my react project, and instead ran yarn build to generate a build folder that I then copied into the app/dist/ folder of my electron app using gulp, by adding a new file called gulpfile.js in the root folder of my electron-react-boilerplate folder:

// gulpfile.js
const gulp = require('gulp');
const path = require('path');
function copyFrontend() {
return gulp
.src(['../frontend/build/**/*.*'])
.pipe(gulp.dest('app/dist/frontend'));
}
exports.default = gulp.series(copyFrontend);

and adding the following to my top-level package.json in the same folder:

// package.json
"scripts": {
...
"resources": "rm -r -f app/dist/frontend && gulp -f gulpfile.js",
"package": "yarn resources && yarn build && electron-builder build --publish never",
...
}

Using your backend

For the backend, it might be useful to package your module using a tool such as rollup, which seems to work better with straight javascript, than react components. The following was the rollup.config.js file I used for packaging up my express app:

import typescript from 'rollup-plugin-typescript2';
import external from 'rollup-plugin-peer-deps-external';
import commonjs from 'rollup-plugin-commonjs';
import resolve from 'rollup-plugin-node-resolve';
import url from 'rollup-plugin-url';
import json from '@rollup/plugin-json';
import pkg from './package.json';
export default {
input: 'src/server.ts',
output: [
{
file: pkg.main,
format: 'commonjs',
sourcemap: true,
},
{
file: pkg.module,
format: 'es',
sourcemap: true,
},
],
plugins: [
external(),
url(),
json(),
typescript({
typescript: require('typescript'),
rollupCommonJSResolveHack: true,
clean: true,
}),
resolve({ preferBuiltins: true }),
commonjs(),
],
};

my typescript configuration, as found in my tsconfig.json file was:

{
"compilerOptions": {
"target": "es5",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "node",
"noImplicitAny": false,
"resolveJsonModule": true,
"declaration": true,
"declarationDir": "./dist"
},
"include": [
"src/**/*"
],
"exclude": [
"**/*.spec.ts",
"node_modules",
"build"
]
}

and my package.json contained the following lines:

"main": "./dist/server.js",
"module": "./dist/server.es.js",
"jsnext:main": "./dist/server.es.js",

It’s worth bearing in mind that one of the irritating things about rollup, is that the configuration of options and plugins, AS WELL AS THEIR ORDER often needs to change dramatically based upon what modules you use in the project — often you will need to work this out in a very ad hoc, trial and error way, and sometimes the particular combination of modules you have used in your code makes the use of rollup impossible — this is one of the reasons I decided to use the built-in bundler in create-react-app, rather than creating a react module out of it.

Also, in recent years, many of the plugins for rollup have changed from being isolated to being brought under the @rollup scope on npm, with the older ones being deprecated and no longer maintained. Some older tutorials do not account for this.

After having a load of problems with rollup and plugins that were incompatible with each other on this project, for my next one I will be using parcel.js which seems to have many more capabilities without the incessant and irritating tweaking rollup requires.

The double package.json format

electron-react-boilerplate uses a "double package.json" format, in which the outer folder contains a package.json that controls the configuration of the building process for your app, whilst the package.json found inside the app folder, controls settings related to the app itself.

How do I use my beautifully packaged React website in electron?

In an electron-react-boilerplate project, the main file that controls the running of the finished app is found at app/main.dev.ts. In it you will see some code which initiates a new BrowserWindow, which will start off something like this:

mainWindow = new BrowserWindow({
show: false,
width: 820,
height: 640,
webPreferences: ...

Underneath it, you will find some code which specifies the main html file loaded up when the app starts. If, like me, you have copied your pre-built react app into the app/dist/frontend folder, your path should look like this (NOTE: the file:// prefix is important!):

mainWindow.loadURL(
`file://${__dirname}/dist/frontend/index.html`
);

Passing arguments to a packaged react app

If you are using a pre-packaged react app, you may find it difficult to pass in arguments that are generated at the app level, such as the http port used to communicate between your frontend and backend. A simple way of doing this might be passing in these elements as arguments used in the file path, such as:

mainWindow.loadURL(
`file://${__dirname}/dist/frontend/index.html?port=9009`
);

Your frontend can then query this information by using:

const currentPageUrl = new URL(window.location.href);
const port = currentPageUrl.searchParams.get('port');

Passing arguments to your express backend

A similar issue can occur when attempting to pass information to your express backend. For me, the easiest way was using the locals parameters that are built into each instance of an express app. You could use them in the following way:

// in app/main.dev.ts import myServer from 'myserver-component'const appPort = 9009;
const resourceFolder = '/me/Desktop/all-my-files'
const appServer = myServer.start(appPort, resourceFolder);

In the code above, I am passing a port number and path of a folder to the instance of the express app. Below, I assign this folder to the app.locals in the express backend such that it can be retrieved from the express instance elsewhere:

// in your express myserver-component componentimport app from './routers';const myServer = (port: string, resourceFolder: string) => {
const server = app.listen(port, err => {
if (err) throw err;
console.log(`listening on port ${port}`);
});
app.locals.resourceFolder = resourceFolder;
return server;
};
export default { start: myServer };

and in your express router you could then retrieve this information using req.app.locals.resourceFolder :

// in the ./routers file
import express from 'express';
import cors from 'cors';
import path from 'path';
import bodyParser from 'body-parser';
import { getFiles } from './getFiles';const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(cors());
const router = express.Router();
router.route('/file').get((req: any, res: any, next: any) => {
console.log('Server received request for files from', req.app.locals.resourceFolder);
return getFiles(req.app.locals.resourceFolder, res);
});

NOTE: If you are transferring large amounts of JSON data between your frontend and your server, it is worth noting that the bodyParser plugin has a default maximum size of data that it can handle. If necessary you can increase this using the following code when you add bodyParser to your server code:

app.use(bodyParser.urlencoded({ limit: '20mb', extended: true }));
app.use(bodyParser.json({ limit: '20mb' }));

Including pre-built binaries

I had a pre-built version of supercollider that I wanted to include in my application. If you are using electron-react-boilerplate, the way to do this is by adding a extraResources key to your package.json.

"extraResources": [
{
"from": "../resources",
"to": "myresources",
"filter": [
"**/*"
]
}

The code above would copy all of the files from the ../resources folder to the myresources folder of your packaged app. In a packaged mac app, this would be found in the folder example.app/Contents/Resources/myresources.

Referencing your pre-built binaries

You can use process.resourcesPath to get the path of the Resources folder within the packaged mac app. You might want to set up a variable in your code as below, which toggles between the local and relative file path depending on whether the electron app is in development or packaged production mode:

const resourceFolder =
process.env.NODE_ENV === 'development'
? '../resources'
: path.join(process.resourcesPath, 'myresources');

path.join vs. path.resolve

For some reason, path.resolve will not work in a packaged electron app - path.join is preferred.

Can I still run my pre-built binary if it gets packaged in a .asar file?

No, probably not — at least it didn’t work for me.

To reduce file package size, electron can package up the contents of the app into a .asar archive file, the contents of which should be able to be accessed using paths such as example.asar/example-folder/my-file.txt. However, this approach does not seem to work with pre-compiled binaries

Compiling supercollider

I wanted to use the program supercollider to manipulate audio files. However, since this program started using Qt to run its IDE, it has not been possible to separate the sclang and scsynth parts of the program for standalone use (this only seems to be a problem on mac https://doc.sccode.org/Classes/HelpBrowser.html), meaning that the file size can be prohibitively large (around 600MB). If you are using supercollider in an electron app, I suggest building it from source to reduce supercollider’s size to around 65MB. This is done by not including the QtWebEngine used for displaying the help files:

"Since the Qt WebEngine dependency is hefty and difficult to install on some systems, it is possible for sclang to have been built without WebView support (using the CMake flag -DSC_USE_QTWEBENGINE=OFF at compile). If so, attempting to invoke this class will throw an error."- https://doc.sccode.org/Classes/HelpBrowser.html

When building supercollider from source, you will need to follow the instructions found at https://github.com/supercollider/supercollider/blob/develop/README_MACOS.md. Once you have installed all the dependencies needed, you will need to use cmake with the flags below to ensure it does not bundle up the QtWebEngine with the build and creates a minimal size app without other unecessary components such as the debugger.

cd SuperCollider
mkdir -p build
cd build
cmake -G Xcode -DCMAKE_PREFIX_PATH=`brew --prefix qt5` -DSC_USE_QTWEBENGINE=OFF ..
cmake --build . --target install --config MinSizeRel

This approach was used on supercollider version 3.10.4, I cannot vouch for its efficacy on other versions.

Running supercollider from node

I was running supercollider from the backend of my express.js app in the following way:

export const runSupercollider = (resourceFolder: string) => {
const command = path.join(resourceFolder, '/myresources/supercollider.app/Contents/MacOS/sclang');
const commandArguments = [path.join(resourceFolder, '/myresources/my-supercollider-file.scd'), 'parameter1', 'parameter2'];
return new Promise((resolve, reject) => {
execFile(command, commandArguments, (error, stdout, stderr) => {
if (stdout) {
console.log('stdout', stdout);
resolve(stdout);
} else if (stderr) {
console.log(stderr);
reject(stderr);
} else {
console.log(error);
reject(error);
}
});
});
};

Using execFile(), the first argument will be the path of the file to run (in this case the sclang executable from the supercollider app), and the second argument will be an array of arguments, the first of which will be the .scd file for sclang to run, and the rest of the array will be any parameters will be passed to the .scd file. These can be referenced from within the .scd file by using thisProcess.argv, which returns an array of these arguments.

Getting supercollider to exit

When running supercollider’s sclang from the commandline, it can be difficult to get supercollider to quit when it has finished running its tasks. The only reliable way I found to do this is by using:

thisProcess.platform.killAll("sclang");

Also, it is important that if you are running supercollider from your electron app, that you explicitly handle errors within your supercollider code to ensure that it closes when it encounters an error, otherwise it can hang, blocking the thread and preventing your app from running. You will need to explicitly get supercollider to quit, as supercollider’s default behaviour when receiving an error via this method: Error(“ERROR: no output folder specified in file”).throw; is to still keep the process running. Instead, you should use the onError method to explicitly quit supercollider everytime an error is thrown:

~printSeperator = "---OUTPUT---"
~errorMessage = '';
~handleError = {
(~printSeperator ++ "error! supercollider had to quit because" + ~errorMessage).postln;
~server.remove;
thisProcess.platform.killAll("sclang");
};
OnError.add(~handleError);~generateError = { arg message;
~errorMessage = message;
Error(message).throw;
};

You can then throw an error with a custom error message anywhere in the program like this: ~generateError.value(“this file does not exist.”);

I appended the~printSeperator string of "---OUTPUT---” to the error message so that I can catch the stdout from the fs.exec function running supercollider on the server and use this string to separate the error messages I have inserted into it from the rest of the text the program outputs:

const runSupercollider = () => {
return new Promise((resolve, reject) => {
execFile('./min-supercollider.app/Contents/MacOS/sclang', './example.scd', (error, stdout, stderr) => {
if (stdout) {
resolve(stdout);
} else if (stderr) {
reject(stderr);
} else {
reject(error);
}
});
});
};
runSupercollider().then((output: unknown) => {
const reducedOutput = (output as string).split('---OUTPUT---')[1];
}).catch(err => console.log(err));

Changing the icon

By default, electron-react-boilerplate will use the default React icon when packaged. You can switch this out for your own icon using the build key in the outer package.json of the app.

"build": {
"productName": "myApp",
"icon": "./icons/icon.icns",
}

This will use the icons file found in the icons folder of your app, and copy it into the Contents/Resources folder of your packaged app. You can create a .icns file for use on the mac by using the following app: https://github.com/onmyway133/IconGenerator

Keeping your electron app secure

When you run your electron app in development mode via yarn start-main-dev, you will probably see an error in the debug console which looks like this:

Electron Security Warning (Insecure Content-Security-Policy) This renderer process has either no Content Security Policy set or a policy with "unsafe-eval" enabled. This exposes users of this app to unnecessary security risks.For more information and help, consult https://electronjs.org/docs/tutorial/security. This warning will not show up once the app is packaged.

This error indicates that you might not have gone through the electron security checklist. I thoroughly recommend doing this in order to keep your app secure. It can be found at: https://www.electronjs.org/docs/tutorial/security#checklist-security-recommendations.

This particular error can be removed by adding the following code to the app/main.dev.ts file inside the createWindow function:

if (process.env.NODE_ENV !== 'development') {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': ["default-src 'none'"]
}
});
});
}

I recommend adding the if (process.env.NODE_ENV !== 'development') { condition, as it will ensure that the error will always show up in development mode and having it there helps prevent you from forgetting to add it during production or packaging.

When disabling node.js integration (point 2 in the security checklist), you will have to add a similar conditional, as this integration is needed for development mode to run, but not when packaged:

mainWindow = new BrowserWindow({
show: false,
width: 820,
height: 640,
webPreferences:
process.env.NODE_ENV === 'development' || process.env.E2E_BUILD === 'true'
? {
enableRemoteModule: false,
nodeIntegration: true
}
: {
preload: path.join(__dirname, 'dist/renderer.prod.js'),
enableRemoteModule: false,
nodeIntegration: false
}
});
...

Debugging

One of the drawbacks of building electron apps is that they can be difficult to debug once packaged. However, one way of reading console logs in your packaged app is by opening it via the terminal, which will cause an extra terminal window to open to display those logs:

open myApp.app/Contents/MacOS/myApp

It is worth noting that electron apps run two processes: a main process and a renderer process (more info here: https://www.electronjs.org/docs/tutorial/application-architecture), The terminal will only show logs from the main process, meaning that logs from your frontend React pages will not be shown, as they are handled by BrowserWindow instance which runs the web page in its own renderer process.

Catalina

Getting your app to work on Catalina can be a lot more awkward than other versions of MacOS because of the way this operating system forces more restrictions on programs that have not gone through Apple’s notarization process.

One thing to watch out for is that any .zip file of your electron app built with electron-builder will not work on catalina for the issues outlined in this article: https://medium.com/cacher-app/getting-your-electron-app-working-on-macos-catalina-10-15-63e53f397da2. Electron-builder is used behind the scenes in electron-react-boilerplate, so any .zip file generated as part of your packaging process will be unusable on Catalina. Instead, distribute your app using the generated .dmg file and not the .zip.

You will also have to add some extra files so that your app conforms to the Catalina security standards (the following information is partly adapted from https://kilianvalkhof.com/2019/electron/notarizing-your-electron-application/). In contrast to some advice you will read, it is not necessary to have your app officially notarized to run on Catalina if you follow the instructions below.

  1. Create an entitlements.mac.plist file. I keep mine in an entitlements subfolder of my app folder. This file should contain the following information, which will explicitly lay out what entitlements your app has:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.debugger</key>
<true/>
</dict>
</plist>

2. Change the build key in the package.json in your app folder so that it contains the information below. Catalina requires apps to have a “hardened runtime” and this option will ensure your app will be built using this setting. The entitlements and entitlementsInherit keys should point to the location of the entitlements file created above.

"build": {
...
"mac": {
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "entitlements/entitlements.mac.plist",
"entitlementsInherit": "entitlements/entitlements.mac.plist"
},
"dmg": {
...
"sign": false
},

Well, I hope this helps answer some electron questions — good luck!

--

--