Using Electron with Haskell

Christian Kjær in a casual setting :)
Christian Kjær
8 min read

·

11. May 2016

·

,

If you want to grab the whole code from this post, it can be found at codetalkio/Haskell-Electron-app.

Not much literature exist on using Electron as a GUI tool for Haskell development, so I thought I'd explore the space a little. Being initially a bit clueless on how Electron would launch the Haskell web server, I was watching the Electron meetup talk by Mike Craig from Wagon HG (they use Electron with Haskell) and noticed they actually mention it on the slides:

  • Statically compiled Haskell executable
  • Shipped in Electron app bundle
  • main.js in Electron spawns the subprocess
  • Executable creates a localhost HTTP server
  • JS talks to Haskell over AJAX

Importantly the bit main.js in Electron spawns the subprocess is the part that was somehow missing in my mental model of how this would be structured (my JavaScript experience mainly lies in webdev and not really with Node.js and server-side/desktop JS).

Riding on this epiphany, I decided to document my exploration, seeing as this is an area that is sorely lacking (GUI programming in Haskell in general). Before we start anything though, let me lay out what the project structure will look like:

Haskell-Electron-app/
  haskell-app/
    resources/
      ...
    ...electron files
  backend/
    stack.yaml
    backend.cabal
    ...servant files

Let's dive into it:

Setting up Electron

Electron has a nice quick start guide, which helps you get going fairly, well, quick. For our purposes, the following will set up the initial app we will use throughout.

$ cd Haskell-Electron-app
$ git clone https://github.com/electron/electron-quick-start haskell-app
$ cd haskell-app
$ npm install && npm start

And that's it really. You've now got a basic Electron app running locally. The npm start command is what launches the app for you. But! Before doing anything more here, let's take a look at the Haskell side.

Setting up the Haskell webserver

We'll be using servant for a minimal application, but you could really use anything that will run a web server (such as Yesod, WAI, Snap, Happstack etc, you get the idea :).

Assuming that stack is installed, you can set up a new servant project with

$ cd Haskell-Electron-app
$ stack new backend servant
$ cd backend
$ stack build

which will download the servant project template for you (from the stack templates repo) and build it.

To test that it works run stack exec backend-exe which will start the executable that stack build produced. You now have a web server running at 127.0.0.1:8080 - try and navigate to 127.0.0.1:8080/users and check it out! :)

For the lack of a better named I have called the application backend, but it could really be anything you fancy.

Contacting Servant/Haskell from Electron

For now, let us proceed with Electron and servant running separately, and later on explore how we can start the servant server from inside Electron.

Since the servant template project has given us the endpoint 127.0.0.1:8080/users from which it serves JSON, let's set up Electron to call that and display the results.

By default the chromium developer tools are enabled in Electron. I suggest you keep them enabled while debugging, since that makes it a lot easier to see if anything went wrong. If you want to disable it, you just need to comment/remove a line in Haskell-Electron-app/haskell-app/main.js:

1...
2function createWindow () {
3 // Create the browser window,
4 mainWindow = new BrowserWindow({width: 800, height: 600})
5 // and load the index.html of the app.
6 mainWindow.loadURL('file://' + __dirname + '/index.html')
7 // Open the DevTools.
8 // mainWindow.webContents.openDevTools() <-- this one here
9 ...
10}
11...

Short interlude: we'll be a bit lazy and download jQuery 2.2.3 minified. Put that into Haskell-Electron-app/haskell-app/resources/jQuery-2.2.3.min.js so we can include it later on and get the nice AJAX functionality it provides.

Back to work in Haskell-Electron-app/haskell-app, lets change the index.html page and prepare it for our list of users.

1<!DOCTYPE html>
2<html>
3 <head>
4 <meta charset="UTF-8">
5 <title>Heya Servant!</title>
6 </head>
7 <body>
8 <h1>User list:</h1>
9 <div id="status"><!-- The request status --></div>
10 <div id="userList">
11 <!-- We'll fill this with the user information -->
12 </div>
13 </body>
14 <script>
15 // Avoid clashing Node.js/Electron with jQuery as per
16 // http://electron.atom.io/docs/v0.37.8/faq/electron-faq/.
17 window.nodeRequire = require;
18 delete window.require;
19 delete window.exports;
20 delete window.module;
21 // Fetch jQuery.
22 window.$ = window.jQuery = nodeRequire('./resources/jQuery-2.2.3.min.js')
23 // The JS file that will do the heavy lifting.
24 nodeRequire('./renderer.js')
25 </script>
26</html>

And finally we'll implement the logic in renderer.js,

1// Backend and endpoint details.
2const host = 'http://127.0.0.1:8080'
3const endpoint = '/users'
4// Retry configuration.
5let maxNoOfAttempts = 50,
6 waitTimeBetweenAttempt = 250
7
8let _fetchUserList = function(waitTime, maxAttempts, currentAttemptNo) {
9 $.getJSON(host + endpoint, function(users) {
10 $('#status').html(`Fetched the content after attemt no.
11 ${currentAttemptNo}!`)
12 // Construct the user list HTML output
13 let output = "";
14 for (let i in users) {
15 let user = users[i]
16 output += `ID: ${user.userId},
17 Firstname: ${user.userFirstName},
18 Lastname: ${user.userLastName}
19 <br>`
20 }
21 $('#userList').html(output)
22 }).fail(function() {
23 $('#status').html(`Attempt no. <b>${currentAttemptNo}</b>. Are you sure the
24 server is running on <b>${host}</b>, and the endpoint
25 <b>${endpoint}</b> is correct?`)
26 // Keep trying until we get an answer or reach the maximum number of retries.
27 if (currentAttemptNo < maxAttempts) {
28 setTimeout(function() {
29 _fetchUserList(waitTime, maxAttempts, currentAttemptNo+1)
30 }, waitTime)
31 }
32 })
33}
34
35// Convenience function for `_fetchUserList`.
36let fetchUserList = function(waitTimeBetweenAttempt, maxNoOfAttempts) {
37 _fetchUserList(waitTimeBetweenAttempt, maxNoOfAttempts, 1)
38}
39
40// Start trying to fetch the user list.
41fetchUserList(waitTimeBetweenAttempt, maxNoOfAttempts)

We simply request the JSON data at http://127.0.0.1:8080/users, with $.getJSON(...), and display it if we received the data. If the request failed, we keep retrying until we either get a response or reach the maximum number of attempts (here set to 50 via maxNoOfAttempts).

The real purpose behind the retry will become apparent later on, when we might need to wait for the server to become available. Normally you will use a status endpoint that you are 100% sure is correct and not failing to check for the availability (inspired by the answer Mike from Wagon HQ gave here).

Launching the Haskell web server from Electron

Now to the interesting part, let's try to launch the Haskell web server from inside of Electron.

First though, let us set the haskell-app/resources folder as the target for our binary, in the stack.yaml file, with the local-bin-path configuration value.

1resolver: lts-5.15
2local-bin-path: ../haskell-app/resources
3...

Now let's compile the executable.

$ cd Haskell-Electron-app/backend
$ stack build --copy-bins

The --copy-bins (or alternatively you can just do stack install) will copy over the binary to Haskell-Electron-app/haskell-app/resources as we specified (it defaults to ~/.local/bin if local-bin-path is not set).

After that it is surprisingly easy to launch the executable from within Electron—well, easy once you already know how. We will change main.js to spawn a process for the web server upon app initialization (that is, the ready state).

Since there are bits and pieces that are added I'll include the whole Haskell-Electron-app/haskell-app/main.js file, with most of the comments removed.

1const electron = require('electron')
2// Used to spawn processes.
3const child_process = require('child_process')
4const app = electron.app
5const BrowserWindow = electron.BrowserWindow
6
7// Keep a global reference of the window object, if you don't, the window will
8// be closed automatically when the JavaScript object is garbage collected.
9let mainWindow
10// Do the same for the backend web server.
11let backendServer
12
13function createWindow () {
14 mainWindow = new BrowserWindow({width: 800, height: 600})
15 mainWindow.loadURL('file://' + __dirname + '/index.html')
16 mainWindow.webContents.openDevTools()
17 mainWindow.on('closed', function () {
18 mainWindow = null
19 })
20}
21
22function createBackendServer () {
23 backendServer = child_process.spawn('./resources/backend-exe')
24}
25
26app.on('ready', createWindow)
27// Start the backend web server when Electron has finished initializing.
28app.on('ready', createBackendServer)
29// Close the server when the application is shut down.
30app.on('will-quit', function() {
31 backendServer.kill()
32})
33app.on('window-all-closed', function () {
34 if (process.platform !== 'darwin') {
35 app.quit()
36 }
37})
38app.on('activate', function () {
39 if (mainWindow === null) {
40 createWindow()
41 }
42})

Let's briefly go through what is happening:

  • We are using the child_process.spawn command to launch our backend web server
  • We imported the child_process module with const child_process = require('child_process')
  • Defined a variable let backendServer that'll let us keep the backend server from being garbage collected
  • Added a function createBackendServer that runs child_process.spawn('./resources/backend-exe') to spawn the process
  • Added the createBackendServer function to the ready hook with app.on('ready', createBackendServer)
  • Close the backendServer when the event will-quit occurs

And voila! We now have Electron spawning a process that runs a Haskell web server! :)

Next step would be to package the app up for distribution to see if that affects anything, but I'll save that for another time (and Electron already has a page on distribution here).