Electron Adventures: Episode 98: Ferrum Sinatra Terminal App

In the previous episode I said that Ferrum could be a viable alternative to Electron if someone added bidirectional communication between frontend and backend.

Well, I'm not seeing anyone else volunteering for that role.

So here's the world's first (as far as I know) Ferrum based desktop app.

  • we'll be using using Ferrum and Chrome DevTools Protocol to send messages to the frontend
  • we'll be using fetch to send messages to the backend over HTTP (really should be axios, but it's a static app)
  • we'll be using Sinatra to handle those messages

Why is this a good idea?

This is a somewhat convoluted setup, and it's not very performant, but it still has huge advantages over Electron:

  • you can use any language you want for the backend
  • your app is tiny, you just require the user to install Chrome (or Chromium), and most have already done that

Of course it also has big downsides too:

  • if you need a lot of messages between frontend and backend, this solution will be a lot slower than Electron's IPC
  • "whichever version of Chrome user has" can still lead to some incompatibilities
  • there's no packaging out of the box
  • Electron has many operating system integrations like menus you'll lose

It also has some advantages over "just launch a web server and open it in user's browser" (like notably Jupyter Notebook does):

  • your app will be properly isolated from user's cookies, browser extensions etc.
  • your app can control window creation, positioning, etc.
  • at least we know it's going to be Chrome, so we don't need to test every possible browser

Gemfile

The Gemfile needs sinatra and ferrum, but I also got some extra packages to make JSON parsing and returning more automatic. They don't really save any lines of code for this trivial app, but it's one less thing to think about.

source "https://rubygems.org"

gem "sinatra"
gem "sinatra-contrib"
gem "rack-contrib"
gem "ferrum"

public/index.html

It's the terminal app again:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Ferrum Sinatra Terminal App</title>
    <link href="app.css" rel="stylesheet" type="text/css" />
  </head>
  <body>
    <h1>Ferrum Sinatra Terminal App</h1>
    <div id="terminal">
      <div id="history">
      </div>

      <div class="input-line">
        <span class="prompt">$</span>
        <form>
          <input type="text" autofocus />
        </form>
      </div>
    </div>

    <script src="app.js"></script>
  </body>
</html>

public/app.css

Styling is identical to all previous terminal apps:

body {
  background-color: #444;
  color: #fff;
}

h1 {
  font-family: monospace;
}

#terminal {
  font-family: monospace;
}

.input-line {
  display: flex;
}

.input-line > * {
  flex: 1;
}

.input-line > .prompt {
  flex: 0;
  padding-right: 0.5rem;
}

.output {
  padding-bottom: 0.5rem;
}

.input {
  color: #ffa;
}

.output {
  color: #afa;
  white-space: pre;
}

form {
  display: flex;
}

input {
  flex: 1;
  font-family: monospace;
  background-color: #444;
  color: #fff;
  border: none;
}

public/app.js

Most of the code is the same except for how we call the backend:

let form = document.querySelector("form")
let input = document.querySelector("input")
let terminalHistory = document.querySelector("#history")

function createInputLine(command) {
  let inputLine = document.createElement("div")
  inputLine.className = "input-line"

  let promptSpan = document.createElement("span")
  promptSpan.className = "prompt"
  promptSpan.append("$")
  let inputSpan = document.createElement("span")
  inputSpan.className = "input"
  inputSpan.append(command)

  inputLine.append(promptSpan)
  inputLine.append(inputSpan)

  return inputLine
}

function createTerminalHistoryEntry(command, commandOutput) {
  let inputLine = createInputLine(command)
  let output = document.createElement("div")
  output.className = "output"
  output.append(commandOutput)
  terminalHistory.append(inputLine)
  terminalHistory.append(output)
}

async function runCommand(command) {
  let response = await fetch(
    "http://localhost:4567/execute",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({command}),
    },
  )
  if (!response.ok) {
    throw `HTTP error ${response.status}`
  }
  return await response.json()
}

form.addEventListener("submit", async (e) => {
  e.preventDefault()
  let command = input.value
  let {output} = await runCommand(command)
  createTerminalHistoryEntry(command, output)
  input.value = ""
  input.scrollIntoView()
})

Note the runCommand function. Mostly to demonstrate "why you should use axios" point I mentioned before. This is a fetch code with correct wrappers for checking HTTP status, dealing with JSON input and output and so on. All this functionality would be provided by axios code, so if we used axios it would be a one-liner.

terminal_app

This will be quite some code, so let's do that in parts.

First, Sinatra lacks any callback for when server is ready to serve requests, so this function will poll given URL every second until it returns what we expect:

def wait_for_url(url, response)
  loop do
    begin
      sleep 1
      break if URI.open(url).read == response
    rescue
    end
  end
end

Then we use this code in a separate thread to start the frontend when the backend is ready:

APP_URL = "http://localhost:4567"
SECRET_TOKEN = Random.alphanumeric(64)

Thread.new do
  wait_for_url "#{APP_URL}/ping", "pong"
  $browser = Ferrum::Browser.new(
    headless: false,
    browser_options: {
      "app" => "#{APP_URL}/start?token=#{SECRET_TOKEN}",
    },
  )
  puts "#{APP_URL}/start?token=#{SECRET_TOKEN}"
end

This code prints the backend start URL for debugging, and saves Ferrum browser object to $browser global variable. We don't do anything with the frontend except start it, but in principle we have full control over the frontend through it if we wanted.

The secret token is there to prevent anyone except our frontend from executing commands on our backend. Which is definitely a good idea, as the backend literally executes shell commands.

Now we just need one endpoint to return static data, it's needed to know when the backend is ready:

get "/ping" do
  "pong"
end

And the other to check the token and save it in the session cookie and redirect to /index.html. For whichever reason Sinatra won't treat / as /index.html as same request, so redirect "/" would need some extra code telling it that these mean the same thing:

enable :sessions

get "/start" do
  raise "Invalid token" unless params["token"] == SECRET_TOKEN
  session["token"] = params["token"]
  redirect "/index.html"
end

And finally the /execute endpoint:

use Rack::JSONBodyParser

post "/execute" do
  raise "Invalid token" unless session["token"] == SECRET_TOKEN
  command = params["command"]
  # \n to force Ruby to go through shell even when it thinks it doesn't need to
  output, status = Open3.capture2e("\n"+command)
  json output: output
end

Thanks to code from sinatra-contrib and rack-contrib we don't need to JSON.parse and .to_json ourselves.

This endpoint checks the token (in session cookie now, not in the URL) to verify that the request is coming from our frontend. Then it executes the command and returns the output.

Unfortunately Ruby is a bit too smart for its own good here, and tries to figure out if it needs to use shell or not. This complicates things as executing nonexistent_command will raise exception instead of printing shell message we want. We can force it to use Shell with the \n trick - it's a special character so it always triggers shell, but shell then ignores it. Really there should be shell: true optional keyword argument.

What Ruby does is generally reasonable, as spawning just one process instead of two can significantly improve performance, while keeping the API simple, it just fails for our use case.

And here's the whole file together, the world's first Ferrum + Sinatra app!

#!/usr/bin/env ruby

require "ferrum"
require "sinatra"
require "open-uri"
require "open3"
require "sinatra/json"
require "rack/contrib"

APP_URL = "http://localhost:4567"
SECRET_TOKEN = Random.alphanumeric(64)

enable :sessions

use Rack::JSONBodyParser

get "/ping" do
  "pong"
end

get "/start" do
  raise "Invalid token" unless params["token"] == SECRET_TOKEN
  session["token"] = params["token"]
  redirect "/index.html"
end

post "/execute" do
  raise "Invalid token" unless session["token"] == SECRET_TOKEN
  command = params["command"]
  # \n to force Ruby to go through shell even when it thinks it doesn't need to
  output, status = Open3.capture2e("\n"+command)
  json output: output
end

def wait_for_url(url, response)
  loop do
    begin
      sleep 1
      break if URI.open(url).read == response
    rescue
    end
  end
end

Thread.new do
  wait_for_url "#{APP_URL}/ping", "pong"
  $browser = Ferrum::Browser.new(
    headless: false,
    browser_options: {
      "app" => "#{APP_URL}/start?token=#{SECRET_TOKEN}",
    },
  )
  puts "#{APP_URL}/start?token=#{SECRET_TOKEN}"
end

Results

And here's the result:

From what we've seen so far, Ferrum + Sinatra (or other Chrome DevTools Protocol + HTTP server) looks like a surprisingly viable way of coding frontend apps, far more than most of the "Electron Alternatives" we tried. It could use some polish to hide all the low level issues, but it could be a thing.

And this will be the last app of the series. For the final two episodes I'll just summarize the series and do a bit of retrospecting.

14