DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on • Updated on

Electron Adventures: Episode 9: Terminal App

This episode was created in collaboration with the amazing Amanda Cavallaro.

So now that we have styling for our terminal app, let's make it run commands!

Electron security

As I said a few episodes before, backend and frontend tend to follow different rules:

  • backend code has full access to your computer, but it assumes you only run code you trust
  • frontend code just runs anyone's code from random sites on the internet, but it has (almost) no access to anything outside the browser, and even in-browser, (almost) only to stuff from the same domain

The proper way to do this is to do all the restricted things on the backend, and only expose that functionality to the frontend over secure channels.

For this episode, we'll just disregard such best practices, and just let the frontend do whatever it wants. We'll do better in the future.

Turn on high risk mode

Here's how we can start such highly privileged frontend code:

let { app, BrowserWindow } = require("electron")

function createWindow() {
  let win = new BrowserWindow({
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    }
  })
  win.maximize()
  win.loadFile("index.html")
}

app.on("ready", createWindow)

app.on("window-all-closed", () => {
  app.quit()
})
Enter fullscreen mode Exit fullscreen mode

We added two options - nodeIntegration: true exposes node functionality in the browser, and contextIsolation: false disables security isolation.

Side note on frontend framework

For now I'll be doing all DOM manipulations the hard way, using browser APIs directly. Mostly because most frontend frameworks rely on bundlers like rollup or webpack, and I don't want to introduce extra complexity here. We have a lot of complexity to cover already.

If this becomes too distracting, I might add jQuery at some point, so we spend less time on the DOM, and more time on the actual logic. Or some simple templating system that doesn't require a bundler.

Or maybe I'll reorder the episodes a bit and we'll do rollup and Svelte earlier than I initially planned to.

Get relevant DOM elements

Only three nodes do anything:

  • form which tells us when user pressed Enter
  • input which holds the command user typed
  • #history where we'll be appending command and its output
let form = document.querySelector("form")
let input = document.querySelector("input")
let terminalHistory = document.querySelector("#history")
Enter fullscreen mode Exit fullscreen mode

Show command input

Now let's create this fragment:

<div class="input-line">
  <span class="prompt">$</span>
  <span class="input">${command}</span>
</div>
Enter fullscreen mode Exit fullscreen mode

With DOM commands, that will be:

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
}
Enter fullscreen mode Exit fullscreen mode

Show command input and output

We also want to show command output, so I wrote another helper. It will append to #history the following fragment:

<div class="input-line">
  <span class="prompt">$</span>
  <span class="input">${command}</span>
</div>
<div class="output">${commandOutput}</div>
Enter fullscreen mode Exit fullscreen mode

Here's the HTML:

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)
}
Enter fullscreen mode Exit fullscreen mode

Run the command

With so much code needed to display the output, it's actually surprisingly easy to run the command.

let child_process = require("child_process")

form.addEventListener("submit", (e) => {
  e.preventDefault()
  let command = input.value
  let output = child_process.execSync(command).toString().trim()
  createTerminalHistoryEntry(command, output)
  input.value = ""
  input.scrollIntoView()
})
Enter fullscreen mode Exit fullscreen mode

We do the usual addEventListener / preventDefault to attach Javascript code to HTML events.

Then we run the same child_process.execSync we did on the backend, except we're in the frontend now. It works as we disabled context isolation.

After that we add the command and its output to the history view, clear the line, and make sure the input remains scrolled ito view.

Limitations

Our terminal app is already somewhat useful, but it's extremely limited.

Commands we execute have empty stdin, and we cannot type any input to them.

We don't capture stderr - so if you have any errors, they currently won't appear anywhere.

As everything is done synchronously, it's best not to use any commands that might hang.

We cannot do any special shell operations like using cd to change current directory.

And of course we don't support any extra formatting functionality like colors, moving cursor around and so on.

Result

This is what it looks like, with actual commands:

Episode 9 screenshot

As you can see ls worked just fine, but cal tried to use some special codes to highlight current day, and that came out messed up a bit.

Over the next few episodes, we'll be improving the app.

As usual, all the code for the episode is here.

Discussion (0)