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()
})
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")
Show command input
Now let's create this fragment:
<div class="input-line">
<span class="prompt">$</span>
<span class="input">${command}</span>
</div>
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
}
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>
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)
}
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()
})
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:
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.
Top comments (0)