diff --git a/doc/electron.md b/doc/electron.md new file mode 100644 index 00000000..c4099100 --- /dev/null +++ b/doc/electron.md @@ -0,0 +1,109 @@ +# Electron +How to run and package Threepenny apps with [Electron](https://electron.atom.io) +and +[Electron Packager](https://github.com/electron-userland/electron-packager#electron-packager). + +For reference, a minimal working example is available +[here](https://github.com/barischj/threepenny-gui-electron-example). + +## Justification +Normally when running a Threepenny app we execute our Haskell, with `stack exec` +or otherwise, which starts a local server and we open our browser on a certain +port to view our app. However this has a few subtle drawbacks. + +One drawback is that most browsers are designed with remote servers in mind and +have security features which don't make much sense for local server +architectures like Threepenny's. Take file selection for example. When a user +selects a file in a browser, the browser only exposes the file name and contents +to the server, not allowing the server to receive the full file path. However +for local server architectures there isn't much point in sending the entire file +contents to the server since the server is on the same file system and could +read the file directly, if only it had the full file path. Electron displays our +app in a Chromium instance with many of these security features removed. + +Another drawback is that the user has to run the app from the command line. +Using electron-packager we can package native apps for Linux, macOS and Windows. + +## Running with Electron +To run a Threepenny app with Electron we need an Electron +[main process](https://electron.atom.io/docs/tutorial/quick-start/#main-process). +We provide this one: [electron.js](./electron/electron.js). It runs the +following on startup: +- selects a free port to run on +- executes our Threepenny app binary, passing the port to run on as an argument +- waits for Threepenny's server to start accepting connections +- opens an Electron window which loads the URL of our Threepenny app + +To get started with the linked `electron.js` first add +this [package.json]('./electron/package.json') to your project root directory. +You'll need Node installed and `npm` on your PATH, confirm by running `which +npm`. Now run `npm install` from the project root directory to install the +necessary dependencies. + +The linked `electron.js` executes the Threepenny app binary, passing the port to +run on as an argument. This of course means your Threepenny app needs to take +the port as an argument. We suggest also setting stdout to be line buffered, at +least while developing. Altogether it should look something like this: + +```Haskell +module Main where + +import System.Environment (getArgs) +import System.IO +import YourApp (start) + +main :: IO () +main = do + hSetBuffering stdout LineBuffering + [port, otherArg1, otherArg2] <- getArgs + start (read port) +``` + +Now copy the linked `electron.js` to your project root directory. You'll have to +edit the defined constants: `relBin`, which is the relative path from +`electron.js` to your Threepenny application binary; and `binArgs`, which +contains any additional arguments to pass to the binary. If you're not sure +about the relative path to your application binary, and you're using Stack, see +the [next section](#explicit-binary-location). + +Now run your app with Electron: `./node_modules/.bin/electron electron.js` + +### Explicit binary location +`relBin` is the relative path from `electron.js` to your Threepenny application +binary. This might change depending on which tool or platform you are building +with and thus can be a pain to set manually. If you are using Stack you can +easily build your application binary to an explicit location, possibly a `build` +directory: + +```stack install --local-bin-path build``` + +Now you can simply set `relBin` to `./build/your-app-exe`. + +## Packaging with electron-packager +This section assumes the app is already setup to run with Electron based on +the [above](#running-with-electron) instructions. + +First install electron-packager: `npm install electron-packager` + +Optionally edit the "name" field in `package.json` to set the name of the +packaged app. Then to package the app for the current platform, simply: + +``` +./node_modules/.bin/electron-packager . +``` + +This is the most basic way to package the app, it will copy the current +directory to the packaged app. However you'll likely want to avoid copying some +source files, which can be achieved with the `--ignore` flag. You might end up +using: + +``` +./node_modules/.bin/electron-packager . --ignore=app --ignore=src +``` + +If you are using Stack and building your application binary to an explicit +location, as explained [above](#explicit-binary-location), then you might want +to also ignore `.stack-work/`. An icon can be set by passing the icon path to +`--icon`, note that the icon format +[depends on the platform](https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#icon). +For more options use the `--help` flag. diff --git a/doc/electron/electron.js b/doc/electron/electron.js new file mode 100644 index 00000000..a6522ffe --- /dev/null +++ b/doc/electron/electron.js @@ -0,0 +1,81 @@ +const { app, BrowserWindow } = require('electron'); +const freeport = require('freeport'); +const spawn = require('child_process').spawn; +const path = require('path'); +const waitOn = require('wait-on'); + + // Time to wait for Threepenny server, milliseconds +const timeout = 10000; +// Relative path to the Threepenny binary. +const relBin = './build/your-app-exe'; +// Additional arguments to pass to the Threepenny binary. +const binArgs = ['otherArg1', 'otherArg2']; + +// Assign a random port to run on. +freeport((err, port) => { + if (err) throw err; + + const url = `http://localhost:${port}`; + let child = null; // Server process we spawn and kill + + // Keep a global reference of the window object, if we don't, the window will + // be closed automatically when the JavaScript object is garbage collected. + let win; + + function createWindow() { + // Create the browser window. + win = new BrowserWindow({ + width: 800, + height: 600, + webPreferences: { nodeIntegration: false }, + }); + + console.log(`Loading URL: ${url}`); + win.loadURL(url); + + // Emitted when the window is closed. + win.on('closed', () => { + // Dereference the window object for garbage collection. + win = null; + }); + } + + // Called when Electron has finished initialization and is ready to create + // browser windows. Some APIs can only be used after this event occurs. We + // start the child process and wait before loading the web page. + app.on('ready', () => { + child = spawn(path.join(__dirname, relBin), [port].concat(binArgs)); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', console.log); + child.stderr.on('data', console.log); + child.on('close', code => + console.log(`Threepenny app exited with code ${code}`)); + + // Wait until the Threepenny server is ready for connections. + waitOn({ resources: [url], timeout }, (err_) => { + if (err_) throw err_; + createWindow(); + }); + }); + + // Quit when all windows are closed, unless on macOS. On macOS it is common + // for applications and their menu bar to stay active until the user quits + // explicitly with Cmd + Q + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + // Kill the child process when quitting Electron. + app.on('will-quit', () => child.kill()); + + app.on('activate', () => { + // On macOS it's common to re-create a window in the app when the dock icon + // is clicked and there are no other windows open. + if (win === null) { + createWindow(); + } + }); +}); diff --git a/doc/electron/package.json b/doc/electron/package.json new file mode 100644 index 00000000..d6f73f1b --- /dev/null +++ b/doc/electron/package.json @@ -0,0 +1,9 @@ +{ + "name": "YourApp", + "main": "electron.js", + "dependencies": { + "electron": "^1.6.1", + "freeport": "^1.0.5", + "wait-on": "^2.0.2" + } +}