• Electron: How to build, package and sign 3rd-party binaries with Electron-Builder

Working on Blazing Bookkeeper, an ORC based document scanner, I had to build and package various libraries and binaries through electron-builder. I couldn’t find any guides detailing this, so I’ll outline the process we took to make it work.

I used electron-boilerplate as the base. It includes build scripts using electron-builder. This guide details MacOS builds, but could easily be modified to make Windows and Linux builds work.

Build directories

We’ll create two new directories. ./thirdparty for build source, and ./build for build output. We’ll add both to .gitignore.

$ mkdir thirdparty build
$ echo "/thirdparty" >> .gitignore
$ echo "/build" >> .gitignore

3rd-party build scripts

Now we’ll add the build scripts for our 3rd-party dependencies to the ./scripts folder.

There are many ways you can do this, but to save you some headache, I’ve included functionality to make build scripts take care of most use cases. The scripts are platform aware, hence the ./scripts/darwin subdirectory.

The main build script takes care of installing build tools, cleaning up builds for unnecessary files (e.g. man files and include files), and finally sets relative dylib paths so we can package the binaries in the app, and be able to run it anywhere.

In this example, we’ll build libjpeg and libpng. There are two environment variables, THIRDPARTYDIR and BUILDDIR, that’ll be set in the electron build script in the next section.

## ./scripts/build/darwin/build.sh

#!/bin/bash
set -e

BASEDIR=$(dirname "$0")

echo [darwin-build] Building development dependencies with brew
brew install automake autoconf libtool pkg-config cmake

echo [darwin-build] Building dependencies
cd $BASEDIR && ./dependencies.sh

echo [darwin-build] Cleaning up builds
cd $THIRDPARTYDIR
rm -rf dependencies/share dependencies/include dependencies/bin

echo [darwin-build] Set relative dylib paths
for f in $(find $THIRDPARTYDIR -type f -name '*.dylib' -or -path '*/bin/*')
do
  if [ -f "$f" ]
  then
    DIR_PREFIX="@loader_path/../.."
    DYLIBS=`otool -L $f | grep "$THIRDPARTYDIR" | awk -F' ' '{ print $1 }'`
    for dylib in $DYLIBS
    do
      install_name_tool -change $dylib "$DIR_PREFIX${dylib#$THIRDPARTYDIR}" "$f"
    done
  fi
done
## ./scripts/build/darwin/dependencies.sh

#!/bin/bash
set -e

mkdir -p $BUILDDIR
DEST_DEPENDENCIES_DIR=$THIRDPARTYDIR/dependencies

# Compile jpeg
cd $BUILDDIR
if [ ! -d "$BUILDDIR/jpeg-src" ]
then
  if [ ! -f "$BUILDDIR/jpeg-v8d.tar.gz" ]
  then
    curl -o jpeg-v8d.tar.gz -L -z jpeg-v8d.tar.gz http://www.ijg.org/files/jpegsrc.v8d.tar.gz
  fi
  tar xzf jpeg-v8d.tar.gz
  mv jpeg-8d jpeg-src
fi
if [ ! -f "$DEST_DEPENDENCIES_DIR/lib/libjpeg.dylib" ]
then
  cd jpeg-src
  ./configure \
    --prefix=$DEST_DEPENDENCIES_DIR \
    --disable-dependency-tracking
  make install
fi

# Compile libpng
cd $BUILDDIR
if [ ! -d "$BUILDDIR/libpng-src" ]
then
  if [ ! -f "$BUILDDIR/libpng-1.6.26.tar.xz" ]
  then
    curl -o libpng-1.6.26.tar.xz -L -z libpng-1.6.26.tar.xz ftp://ftp.simplesystems.org/pub/libpng/png/src/libpng16/libpng-1.6.26.tar.xz
  fi
  tar xzf libpng-1.6.26.tar.xz
  mv libpng-1.6.26 libpng-src
fi
if [ ! -f "$DEST_DEPENDENCIES_DIR/lib/libpng.dylib" ]
then
  cd libpng-src
  ./configure \
    --prefix=$DEST_DEPENDENCIES_DIR \
    --disable-dependency-tracking
  make
  make install
fi

Run the build scripts

We want the binaries to be built (if not built already) when running npm start and npm run release. To achieve this we’ll add a gulp task to ./tasks/build_app.js.

// ./tasks/build_app.js

// ...
var exec = require('child_process').exec;
var gutil = require('gulp-util');

// ...

gulp.task('dependencies', function () {
  var rootDir = jetpack.cwd('./').path()
  var platform = process.platform
  var buildScript = jetpack.cwd('./scripts').path('build/' + platform + '/build.sh')
  var buildDir = rootDir + '/build'
  var buildDestDir = rootDir + '/thirdparty'

  return new Promise(function (resolve, reject) {
    if (!jetpack.exists(buildScript)) {
      gutil.log('No build file for', `'${gutil.colors.cyan(platform)}'`)
      return resolve()
    }

    gutil.log('Going to build for', `'${gutil.colors.cyan(platform)}'`)

    var buildCommand = [
      'BUILDDIR=' + buildDir,
      'THIRDPARTYDIR=' + buildDestDir,
      'sh ' + buildScript
    ].join(' ')
    var child = exec(buildCommand, {
      maxBuffer: 1024 * 1024 * 10
    },
            function (error, stdout, stderr) {
              if (!error) {
                gutil.log('Build succesful')
                resolve()
              } else {
                gutil.log('Build failed')
                reject()
                throw error
              }
            })
    child.stdout.pipe(process.stdout)
    child.stderr.pipe(process.stderr)
    return child
  })
})

// ...

gulp.task('build', ['dependencies', 'bundle', 'less', 'environment'])

The maxBuffer argument can be increased or decreased depending on how much your build scripts output.

Set the third party paths

To make it possible for our Electron app to access the binaries we’ll need to set the paths in the environment. To make it easier to manage with potentially multiple paths within the ./3rdparty directory, the following script can be used.

It’s the same as running PATH=$PATH:$(pwd)/thirdparty/path/to/bin DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:$(pwd)/thirdparty/dependencies/lib npm start.

// ./src/utils/thirdparty_env.js

import getResourcesPath from './resources'

var thirdpartyPath = getResourcesPath('thirdparty')
var env = {}

if (process.platform === 'darwin') {
  // 3rd party binaries path
  env.PATH = [
    '$PATH'
    // Add third-party binaries paths here
  ].join(':')

  // 3rd party libraries
  env.DYLD_LIBRARY_PATH = [
    '$DYLD_LIBRARY_PATH',
    '/System/Library/Frameworks/ImageIO.framework/Versions/A/Resources/', // Core Graphics package
    thirdpartyPath + '/dependencies/lib'
    // Add more third-party lib paths here
  ].join(':')

  // Add any extra environment paths here, e.g. PKG_CONFIG_PATH
}

export default env

The following utility script makes sure to get the right resource path since npm start and npm build/npm run release has different paths.

// ./src/utils/resources.js

import path from 'path'

var getResourcesPath = function () {
  var paths = Array.from(arguments)

  if (/[\\/]Electron\.app[\\/]/.test(process.execPath)) {
    // Development mode resources are located in project root.
    paths.unshift(process.cwd())
  } else {
    // In builds the resources directory is located in 'Contents/Resources'
    paths.unshift(process.resourcesPath)
  }

  return path.join.apply(null, paths)
}

export default getResourcesPath

And finally we can load the environment:

// ./src/background.js

// ...
import thirdparty_env from './utils/thirdparty_env'

//...
Object.assign(process.env, thirdparty_env);
console.log('Settings thirdparty environment variables:', thirdparty_env);

Get electron-builder to package the binaries

We can use the extraResources key to get electron-builder to package the binaries into the app. Modify package.json, and add the directory to the build like so:

// package.json

"build": {
    // ...
    "extraResources": [
      "thirdparty"
    ]
    // ...
}

During build, electron-builder will now copy the ./thirdparty directory into the Resources directory in our app.

Set up CI builds

Let’s set up Travis to generate the build automatically. You can set a GH_TOKEN environment variable to automatically publish the app on GitHub.

// .travis.yml

// ...
cache:
  directories:
  // ...
  - build
  - thirdparty

before_install:
  - npm install --only=dev
  - node_modules/.bin/gulp dependencies

deploy:
  provider: script
  script: npm build --x64

Code signing

It’s very easy to code sign the 3rd party binaries. In package.json you just add the following:

// package.json

{
  "build": {
    // ...
    "osx-sign": {
      "binaries": [
        "thirdparty/path/to/bin"
      ]
    },
    // ...
  },
  // ...
}

You’ll need to use electron-builder v7.9.0, as osx-sign has been deprecated since that version.

// package.json

{
  // ...
  "devDependencies": {
    // ...
    "electron-builder": "7.9.0",
    // ...
  },
  // ...
}

Caveats

With Blazing Bookkeeper we were building OpenCV. A pkg-config path was needed, which meant that we had to set the PKG_CONFIG_PATH environment variable. It’s easy enough, but you’ll need to set it before any npm build calls, like: PKG_CONFIG_PATH=$(pwd)/thirdparty/opencv/lib/pkgconfig npm install

Production example

Our app is running with OpenCV, Tesseract, Poppler and many dependencies just fine. You can take a look at it right here: https://blazingbookkeeper.com

If you would like to see the actual code changes, you can find the pull requests in the GitHub repo (#7, #28).

The Author

Dan Schultzer is an active experienced entrepreneur, starting the Being Proactive groups, Dream Conception organization, among other things. You can find him at twitter

Like this post? More from Dan Schultzer

Comments? We would love to hear from you, write us at @dreamconception.