bundling precompiled binary into electron app
Asked Answered
N

7

57

Is there a good solution on how to include third party pre compiled binaries like imagemagick into an electron app? there are node.js modules but they are all wrappers or native binding to the system wide installed libraries. I wonder if it's possible to bundle precompiled binaries within the distribution.

Narvaez answered 15/10, 2015 at 15:27 Comment(1)
See my answer to this other SO questionGilt
C
4

Update 2024

Building on the provided answers,

Here's a more concise solution that does not require any external dependencies,

  1. Create a folder at the root of your project (/resources/$os) where $os could be mac, linux or win and copy the required binary file there. Lets assume we have a ffmpeg binary and would like to package it with electron.
  2. Update package.json and put extraFiles option in your build config as follows:
 "build": {
    "extraFiles": [
      {
        "from": "resources/${os}", // $os => "mac/linux/win"
        "to": "Resources/bin",     // for Linux => "resources/bin"
        "filter": ["**/*"]
      }
    ],
  }
  1. Create a file (for example: utils.ts) with a few helper functions
// utils.ts

import path from 'path';
import { platform } from 'os';
import { app } from 'electron';

export function getPlatform() {
  switch (platform()) {
    case 'aix':
    case 'freebsd':
    case 'linux':
    case 'openbsd':
    case 'android':
      return 'linux';
    case 'darwin':
    case 'sunos':
      return 'mac';
    case 'win32':
      return 'win';
    default:
      return null;
  }
}

export function getBinariesPath() {
  const IS_PROD = process.env.NODE_ENV === 'production';
  const { isPackaged } = app;

  const binariesPath =
    IS_PROD && isPackaged
      ? path.join(process.resourcesPath, './bin')
      : path.join(app.getAppPath(), 'resources', getPlatform()!);

  return binariesPath;
}

// "ffmpeg" is the binary that we want to package
export const ffmpegPath = path.resolve(path.join(getBinariesPath(), './ffmpeg'));
  1. Now consume the binary wherever you need to. (For example: main.ts)
// main.ts

import { exec, spawn } from 'child_process';
import { ffmpegPath } from './utils';

// option 1: using exec
exec(`"${ffmpegPath}" -version`, (err, stdout, stderr) => {
    console.log('err, stdout, stderr :>> ', err, stdout, stderr);
});

// option 2: using spawn
const ffmpeg = spawn(ffmpegPath, ['-version']);
ffmpeg.stdout.on('data', (data) => {
  console.log(`spawn stdout: ${data}`);
});

ffmpeg.stderr.on('data', (data) => {
  console.error(`spawn stderr: ${data}`);
});

ffmpeg.on('close', (code) => {
  console.log(`spawn child process exited with code ${code}`);
});
Columbarium answered 10/1 at 21:55 Comment(0)
O
28

Here's another method, tested with Mac and Windows so far. Requires 'app-root-dir' package, doesn't require adding anything manually to node_modules dir.

  1. Put your files under resources/$os/, where $os is either "mac", "linux", or "win". The build process will copy files from those directories as per build target OS.

  2. Put extraFiles option in your build configs as follows:

package.json

  "build": {
    "extraFiles": [
      {
        "from": "resources/${os}",
        "to": "Resources/bin",
        "filter": ["**/*"]
      }
    ],
  1. Use something like this to determine the current platform.

get-platform.js

import { platform } from 'os';

export default () => {
  switch (platform()) {
    case 'aix':
    case 'freebsd':
    case 'linux':
    case 'openbsd':
    case 'android':
      return 'linux';
    case 'darwin':
    case 'sunos':
      return 'mac';
    case 'win32':
      return 'win';
  }
};
  1. Call the executable from your app depending on env and OS. Here I am assuming built versions are in production mode and source versions in other modes, but you can create your own calling logic.
import { join as joinPath, dirname } from 'path';
import { exec } from 'child_process';

import appRootDir from 'app-root-dir';

import env from './env';
import getPlatform from './get-platform';

const execPath = (env.name === 'production') ?
  joinPath(dirname(appRootDir.get()), 'bin'):
  joinPath(appRootDir.get(), 'resources', getPlatform());

const cmd = `${joinPath(execPath, 'my-executable')}`;

exec(cmd, (err, stdout, stderr) => {
  // do things
});

I think I was using electron-builder as base, the env file generation comes with it. Basically it's just a JSON config file.

Omaromara answered 17/7, 2017 at 19:28 Comment(1)
My filesystem ain´t got the env file and I am using the electron-builder.Rifkin
W
24

See UPDATE below (this method isn't ideal now).

I did find a solution to this, but I have no idea if this is considered best practice. I couldn't find any good documentation for including 3rd party precompiled binaries, so I just fiddled with it until it finally worked with my ffmpeg binary. Here's what I did (starting with the electron quick start, node.js v6):

Mac OS X method

From the app directory I ran the following commands in Terminal to include the ffmpeg binary as a module:

mkdir node_modules/ffmpeg
cp /usr/local/bin/ffmpeg node_modules/ffmpeg/
cd node_modules/.bin
ln -s ../ffmpeg/ffmpeg ffmpeg

(replace /usr/local/bin/ffmpeg with your current binary path, download it from here) Placing the link allowed electron-packager to include the binary I saved to node_modules/ffmpeg/.

Then to get the bundled app path (so that I could use an absolute path for my binary... relative paths didn't seem to work no matter what I did) I installed the npm package app-root-dir by running the following command:

npm i -S app-root-dir

Now that I had the root app directory, I just append the subfolder for my binary and spawned from there. This is the code that I placed in renderer.js:.

var appRootDir = require('app-root-dir').get();
var ffmpegpath=appRootDir+'/node_modules/ffmpeg/ffmpeg';
console.log(ffmpegpath);

const
    spawn = require( 'child_process' ).spawn,
    ffmpeg = spawn( ffmpegpath, ['-i',clips_input[0]]);  //add whatever switches you need here

ffmpeg.stdout.on( 'data', data => {
     console.log( `stdout: ${data}` );
    });
   ffmpeg.stderr.on( 'data', data => {
console.log( `stderr: ${data}` );
    });

Windows Method

  1. Open your electron base folder (electron-quick-start is the default name), then go into the node_modules folder. Create a folder there called ffmpeg, and copy your static binary into this directory. Note: it must be the static version of your binary, for ffmpeg I grabbed the latest Windows build here.

  2. To get the bundled app path (so that I could use an absolute path for my binary... relative paths didn't seem to work no matter what I did) I installed the npm package app-root-dir by running the following command from a command prompt in my app directory:

     npm i -S app-root-dir
    
  3. Within your node_modules folder, navigate to the .bin subfolder. You need to create a couple of text files here to tell node to include the binary exe file you just copied. Use your favorite text editor and create two files, one named ffmpeg with the following contents:

    #!/bin/sh
    basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
    
    case `uname` in
        *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
    esac
    
    if [ -x "$basedir/node" ]; then
      "$basedir/node"  "$basedir/../ffmpeg/ffmpeg" "$@"
      ret=$?
    else
      node  "$basedir/../ffmpeg/ffmpeg" "$@"
      ret=$?
    fi
    exit $ret
    

And the the second text file, named ffmpeg.cmd:

    @IF EXIST "%~dp0\node.exe" (
     "%~dp0\node.exe"  "%~dp0\..\ffmpeg\ffmpeg" %*
    ) ELSE (
       @SETLOCAL
     @SET PATHEXT=%PATHEXT:;.JS;=;%
     node  "%~dp0\..\ffmpeg\ffmpeg" %*
    )

Next you can run ffmpeg in your Windows electron distribution (in renderer.js) as follows (I'm using the app-root-dir node module as well). Note the quotes added to the binary path, if your app is installed to a directory with spaces (eg C:\Program Files\YourApp) it won't work without these.

var appRootDir = require('app-root-dir').get();
var ffmpegpath = appRootDir + '\\node_modules\\ffmpeg\\ffmpeg';

const
    spawn = require( 'child_process' ).spawn;
    var ffmpeg = spawn( 'cmd.exe', ['/c',  '"'+ffmpegpath+ '"', '-i', clips_input[0]]);  //add whatever switches you need here, test on command line first
ffmpeg.stdout.on( 'data', data => {
     console.log( `stdout: ${data}` );
 });
ffmpeg.stderr.on( 'data', data => {
     console.log( `stderr: ${data}` );
 });

UPDATE: Unified Simple Method

Well, as time as rolled on and Node has updated, this method is no longer the easiest way to include precompiled binaries. It still works, but when npm install is run the binary folders under node_modules will be deleted and have to be replaced again. The below method works for Node v12.

This new method obviates the need to symlink, and works similarly for Mac and Windows. Relative paths seem to work now.

  1. You will still need appRootDir: npm i -S app-root-dir

  2. Create a folder under your app's root directory named bin and place your precompiled static binaries here, I'm using ffmpeg as an example.

  3. Use the following code in your renderer script:

const appRootDir = require('app-root-dir').get();
const ffmpegpath = appRootDir + '/bin/ffmpeg';
const spawn = require( 'child_process' ).spawn;
const child = spawn( ffmpegpath, ['-i', inputfile, 'out.mp4']);  //add whatever switches you need here, test on command line first
child.stdout.on( 'data', data => {
    console.log( `stdout: ${data}` );
});
child.stderr.on( 'data', data => {
    console.log( `stderr: ${data}` );
});
Watterson answered 14/7, 2016 at 11:43 Comment(6)
To be clear, this method uses the electron-packager app and a precompiled ffmpeg binary. [Bundle modules with electron app] method above.Watterson
Could you please explain ln -s ../ffmpeg/ffmpeg node_modules/.bin/ffmpeg Are you linking the ffmpeg copy you just made? Did you change directory before ln -s?Traduce
I'm sorry, the code was incorrect. I did change directories and then create the link. Added and changed the code.Watterson
You saved my life. :-) Works great with dcraw for raw image converting in my electron appSemitone
glad to hear it helped. I added the Windows method just now.Watterson
As of node 12, this method still works but if you run npm install <package> after installing your static binaries, they get deleted. The symlinks remain, but you have to copy the binaries back.Watterson
K
20

The answers above helped me understand how it can be done, but there is a more efficient way to distribute binary files.

Drawing inspiration from tsuriga's answer, here is my revised code:

Note: Replace or add the OS path as needed.

Update - December 4, 2020

This answer has been updated. You can find the previous version of the code at the bottom of this answer.

  • Download the necessary packages.
    yarn add electron-root-path electron-is-packaged
    
    # or 
    
    npm i electron-root-path electron-is-packaged
  • Create a directory ./resources/mac/bin
    • Place you binaries inside this folder
  • Create a file ./app/binaries.js and paste the following code:
    import path from 'path';
    import { rootPath as root } from 'electron-root-path';
    import { isPackaged } from 'electron-is-packaged';
    import { getPlatform } from './getPlatform';
    
    const IS_PROD = process.env.NODE_ENV === 'production';
    
    const binariesPath =
      IS_PROD && isPackaged // the path to a bundled electron app.
        ? path.join(root, './Contents', './Resources', './bin')
        : path.join(root, './build', getPlatform(), './bin');
    
    export const execPath = path.resolve(
      path.join(binariesPath, './exec-file-name')
    );
  • Create a file ./app/get-platform.js and paste the following code:

    'use strict';
    
    import { platform } from 'os';
    
    export default () => {
      switch (platform()) {
        case 'aix':
        case 'freebsd':
        case 'linux':
        case 'openbsd':
        case 'android':
          return 'linux';
        case 'darwin':
        case 'sunos':
          return 'mac';
        case 'win32':
          return 'win';
      }
    };

  • Add these lines inside the ./package.json file:

    "build": {
    ....
    
     "extraFiles": [
          {
            "from": "resources/mac/bin",
            "to": "Resources/bin",
            "filter": [
              "**/*"
            ]
          }
        ],
    
    ....
    },

  • import binary as:

    import { execPath } from './binaries';
        
    #your program code:
    var command = spawn(execPath, arg, {});

Why is this a Better Approach?

  • Tsuriga's solution does not adequately address the build in a production environment (env=production) or the pre-packed versions. It focuses solely on the development and post-packaged versions.

Previous Answer

  • Refrain from using electron.remote since it has being deprecated.
  • Using app.getAppPath may lead to errors in the main process.

./app/binaries.js

    'use strict';
    
    import path from 'path';
    import { remote } from 'electron';
    import getPlatform from './get-platform';
    
    const IS_PROD = process.env.NODE_ENV === 'production';
    const root = process.cwd();
    const { isPackaged, getAppPath } = remote.app;
    
    const binariesPath =
      IS_PROD && isPackaged
        ? path.join(path.dirname(getAppPath()), '..', './Resources', './bin')
        : path.join(root, './resources', getPlatform(), './bin');
    
    export const execPath = path.resolve(path.join(binariesPath, './exec-file-name'));
Kid answered 25/10, 2018 at 13:50 Comment(2)
You are a lifesaver! Thank you so much!Amorino
You state "above ... require an additional package" as better, but your solution requires electron-root-path electron-is-packaged; you don't disclose in the answer that you wrote those packages. If both solutions have dependencies, why is not using app-root-dir better?Overdress
U
6

tl;dr:

yes you can! but it requires you to write your own self-contained addon which does not make any assumptions on system libraries. Moreover in some cases you have to make sure that your addon is compiled for the desired OS.


Lets break this question in several parts:

- Addons (Native modules):

Addons are dynamically linked shared objects.

In other words you can just write your own addon with no dependency on system wide libraries (e.g. by statically linking required modules) containing all the code you need.

You have to consider that such approach is OS-specific, meaning that you need to compile your addon for each OS that you want to support! (depending on what other libraries you may use)

- Native modules for electron:

The native Node modules are supported by Electron, but since Electron is using a different V8 version from official Node, you have to manually specify the location of Electron's headers when building native modules

This means that a native module which has been built against node headers must be rebuilt to be used inside electron. You can find how in electron docs.

- Bundle modules with electron app:

I suppose you want to have your app as a stand-alone executable without requiring users to install electron on their machines. If so, I can suggest using electron-packager.

Unholy answered 20/10, 2015 at 17:29 Comment(4)
so I could just download imagemagick pre compiled for each OS, pack it in a subfolder and execute against it?Narvaez
edit: as in a switch if it's Windows I use imagemagick.exe etcNarvaez
@Tobias if you get executable you can just run the program using child_process module. You need the whole native addons approach if you want to acess functions of a library.Unholy
did you ever figure out how to bundle your app with a precompiled binary?Watterson
C
4

Update 2024

Building on the provided answers,

Here's a more concise solution that does not require any external dependencies,

  1. Create a folder at the root of your project (/resources/$os) where $os could be mac, linux or win and copy the required binary file there. Lets assume we have a ffmpeg binary and would like to package it with electron.
  2. Update package.json and put extraFiles option in your build config as follows:
 "build": {
    "extraFiles": [
      {
        "from": "resources/${os}", // $os => "mac/linux/win"
        "to": "Resources/bin",     // for Linux => "resources/bin"
        "filter": ["**/*"]
      }
    ],
  }
  1. Create a file (for example: utils.ts) with a few helper functions
// utils.ts

import path from 'path';
import { platform } from 'os';
import { app } from 'electron';

export function getPlatform() {
  switch (platform()) {
    case 'aix':
    case 'freebsd':
    case 'linux':
    case 'openbsd':
    case 'android':
      return 'linux';
    case 'darwin':
    case 'sunos':
      return 'mac';
    case 'win32':
      return 'win';
    default:
      return null;
  }
}

export function getBinariesPath() {
  const IS_PROD = process.env.NODE_ENV === 'production';
  const { isPackaged } = app;

  const binariesPath =
    IS_PROD && isPackaged
      ? path.join(process.resourcesPath, './bin')
      : path.join(app.getAppPath(), 'resources', getPlatform()!);

  return binariesPath;
}

// "ffmpeg" is the binary that we want to package
export const ffmpegPath = path.resolve(path.join(getBinariesPath(), './ffmpeg'));
  1. Now consume the binary wherever you need to. (For example: main.ts)
// main.ts

import { exec, spawn } from 'child_process';
import { ffmpegPath } from './utils';

// option 1: using exec
exec(`"${ffmpegPath}" -version`, (err, stdout, stderr) => {
    console.log('err, stdout, stderr :>> ', err, stdout, stderr);
});

// option 2: using spawn
const ffmpeg = spawn(ffmpegPath, ['-version']);
ffmpeg.stdout.on('data', (data) => {
  console.log(`spawn stdout: ${data}`);
});

ffmpeg.stderr.on('data', (data) => {
  console.error(`spawn stderr: ${data}`);
});

ffmpeg.on('close', (code) => {
  console.log(`spawn child process exited with code ${code}`);
});
Columbarium answered 10/1 at 21:55 Comment(0)
P
0

following Ganesh answer's which was really a great help, in my case what was working in binaries.js (for a mac build - did not test for windows or linux) was:

"use strict";
import path from "path";
import { app } from "electron";

const IS_PROD = process.env.NODE_ENV === "production";
const root = process.cwd();
const { isPackaged } = app;

const binariesPath =
  IS_PROD && isPackaged
    ? path.join(process.resourcesPath, "./bin")
    : path.join(root, "./external");

export const execPath = path.join(binariesPath, "./my_exec_name");

Considering that my_exec_name was in the folder ./external/bin and copied in the app package in ./Resources/bin. I did not use the get_platforms.js script (not needed in my case). app.getAppPath() was generating a crash when the app was packaged. Hope it can help.

Perspicuity answered 1/2, 2019 at 23:28 Comment(0)
B
0

Heavily based on Ganesh's answer, but simplified somewhat. Also I am using the Vue CLI Electron Builder Plugin so the config has to go in a slightly different place.

  1. Create a resources directory. Place all your files in there.
  2. Add this to vue.config.js:
module.exports = {
  pluginOptions: {
    electronBuilder: {
      builderOptions: {
        ...
        "extraResources": [
          {
            "from": "resources",
            "to": ".",
            "filter": "**/*"
          }
        ],
        ...
      }
    }
  }
}
  1. Create a file called resources.ts in your src folder, with these contents:
import path from 'path';
import { remote } from 'electron';

// Get the path that `extraResources` are sent to. This is `<app>/Resources`
// on macOS. remote.app.getAppPath() returns `<app>/Resources/app.asar` so
// we just get the parent directory. If the app is not packaged we just use
// `<current working directory>/resources`.
export const resourcesPath = remote.app.isPackaged ?
                             path.dirname(remote.app.getAppPath()) :
                             path.resolve('resources');

Note I haven't tested this on Windows/Linux but it should work assuming app.asar is in the resources directory on those platforms (I assume so).

  1. Use it like this:
import { resourcesPath } from '../resources'; // Path to resources.ts

...
    loadFromFile(resourcesPath + '/your_file');
Brutify answered 22/10, 2019 at 13:53 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.