Building an Electron App with Bazel

参考文档: https://maori.geek.nz/building-an-electron-app-with-bazel-d124ed550957

I want to build a desktop app with:

  1. Electron.js: A framework to build a desktop application using a Node.js and the Chromium browser
  2. Bazel: a build system to quickly build, test and run applications

They seem like they might go well together, so let’s see. Note: we will be focusing on macOS only for simplicity.

All code is located at https://github.com/grahamjenson/bazel-electron

The Electron Application

An electron app (in macOS) is a folder with the electron binaries (Node.js Chromium, and libraries downloaded from https://github.com/electron/electron/releases) and three application files located at:

electron/Electron.app/Contents/Resources/app/
├── package.json
├── main.js
└── index.html

package.json can just be {"main":"main.js"} to reference the main entry point for the Node.js app main.js. main.js must initialize the main Chromium window of the frontend application defined in index.html.

A simple main.js starts with some definitions:

const {app, BrowserWindow} = require('electron')
let mainWindow = null

Then set up an initialize function for the app:

function initialize () {
  app.setName('Electron Simple App')
  app.on('ready', () => {
    createWindow()
  })
  app.on('window-all-closed', () => {
      app.quit()
  })
  app.on('activate', () => {
    if (mainWindow === null) {
      createWindow()
    }
  })
}

Then write the createWindow function:

function createWindow () {
  const windowOptions = {
   width: 600,
   minWidth: 600,
   height: 500,
   title: app.getName()
  }
  mainWindow = new BrowserWindow(windowOptions)
  mainWindow.loadURL('file://' + __dirname + '/index.html')
  mainWindow.webContents.openDevTools()
  mainWindow.on('closed', () => {
  mainWindow = null
 })
}

Finally call the initialize() function.

The index.html is the entry point into your frontend application linked above with loadURL. This can be as simple as:

<html>
 <body> Hello </body>
 <script> console.log("World") </script>
</html>

This electron app renders as:

Bazel bits

I got the above working by just writing and copying files around in the electron folders, but I want Bazel to do that for me. I want a Bazel rule like:

load(":electron.bzl", "electron_app")
electron_app(
  name = "simple-app",
  app_name = "simple-app",
  index_html = ":index.html",
  main_js = ":main.js",
)

So that I can run bazel run :simple-app to build then start the electron app.

The first step is to download the electron binaries in the WORKSPACE file:

http_file(
  name = "electron_release",
  sha256 = "594326256...ca1f41ec",
  urls = ["https://github.com/electron/electron/releases/download/v8.4.1/electron-v8.4.1-darwin-x64.zip"],
)

Note: I would like to use the <em class="oh">http_archive</em> rule instead, but the app uses symlinked folders that confuse Bazel’s <em class="oh">glob</em>* function. To fix this the rule unzips the file (which is not optimal but it works)*

So the rule will look like:

electron_app = rule(
  implementation = electron_app_,
  executable = True,
  attrs = {
    "app_name": attr.string(),
    "main_js": attr.label(allow_single_file = True),
    "index_html": attr.label(allow_single_file = True),
    "_electron_release": attr.label(
      allow_single_file = True,
      default = Label("@electron_release//file"),
    ),
    "_electron_bundle_tool": attr.label(
      executable = True,
      cfg = "host",
      allow_files = True,
      default = Label("//:bundle"),
    ),
    "_electron_app_script_tpl": attr.label(
      allow_single_file = True,
      default = Label("//:run.sh.tpl"),
    ),
  },
  outputs = {
    "apptar": "%{name}.tar",
    "run": "%{name}.sh",
  },
)

This takes the app_name, main.js and index.html from the user. It then uses

  1. _electron_release: downloaded release from github
  2. _electron_bundle_tool: a golang script to create the electron app
  3. _electron_app_script_tpl: the script used to run the application

The _electron_bundle_toolgolang script bundle.go:

  1. Unzip the Electron release, add those files to tar
  2. copy package.json main.js and index.html add those files to tar
  3. write tar

This looks like:

func main() {
 outputFile := os.Args[1]
 name := os.Args[2]
 mainJS := os.Args[3]
 indexHTML := os.Args[4]
 electronZIP := os.Args[5]
 appName := name + ".app/"
 // Unzip
 rawFiles, _ := Unzip(electronZIP, "electronZIP")
 tarFiles := map[string]string{}
 // Add Electron Files to tar
 for _, f := range rawFiles {
  zipPrefix := "electronZIP/Electron.app/"
  if strings.HasPrefix(f, zipPrefix) {
   tarFiles[f] = appName + strings.TrimPrefix(f, zipPrefix)
  }
 }
 // Add App files to tar
 appFolder := "Contents/Resources/app/"
 tarFiles[mainJS] = appName + appFolder + "main.js"
 tarFiles[indexHTML] = appName + appFolder + "index.html"
 ioutil.WriteFile("package.json", []byte(PACKAGE_JSON), 0644)
 tarFiles["package.json"] = appName + appFolder + "package.json"
// Write Tar File
 writeTar(outputFile, tarFiles)
}

The _electron_app_script_tpl is the script to run to open the electron app, which just un-tars the app, then opens it, i.e.:

tar -xf {{app}}
// Open app and wait for exit
open -W {{name}}.app

All together

With this all setup bazel run :simple-app will start the application up. There is no live reload or other nice dev tools, so there is lots to improve about this workflow. The nice thing is that the built tar is easy to distribute as a completed app immediately.

Bazel to Electron

Bazel is a pretty useful tool and electron is surprisingly simple to get a basic application built. Next steps would be adding tools like go-app to build applications using golang or maybe use lorca instead of electron to reduce the final tar size to something more easily distributable.

Again, all code is located at https://github.com/grahamjenson/bazel-electron