Electron Adventures: Episode 9: Terminal App
Tags: electron, javascript
Published: 2021-08-02 17:34:12

This episode was created in collaboration with the amazing [Amanda Cavallaro](https://www.amandacavallaro.com/).

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:

```javascript
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()
})
```

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

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

### Show command input

Now let's create this fragment:

```html
<div class="input-line">
  <span class="prompt">$</span>
  <span class="input">${command}</span>
</div>
```

With DOM commands, that will be:

```javascript
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
}
```

### 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:

```html
<div class="input-line">
  <span class="prompt">$</span>
  <span class="input">${command}</span>
</div>
<div class="output">${commandOutput}</div>
```

Here's the HTML:

```javascript
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)
}
```

### Run the command

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

```javascript
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()
})
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u55ffc8p14yvrh57bujf.png)

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](https://github.com/taw/electron-adventures/tree/master/episode-09-terminal-app).