The case for using a web browser as your terminal
by pomdtr
4 min read
I don't actively use a terminal emulator app on my computer anymore, and use tweety instead from my web browser. I think you should consider doing the same.
My browser is eating all my apps, and I like it
I don't really have a usecase for native apps anymore. All of the apps I use are web-based, so it make sense for me to use a web browser as my only opened app. Instead of having a bunch of window with their own set of tabs, I can manage everything from a single one.
However, as a developer, it is unthinkable to not have a terminal emulator app opened to run commands, manage and edit files, or interact with remote servers. Ideally, I would like those two remaining apps to be merged into a single one.
There have been some experiments on adding webviews to terminal emulators, such as the Wave Terminal or awrit. While interesting, these projects are not a substitute to running a capable web browser.
I feel like going the other way around is much more interesting. ttyd allows you to run a terminal emulator from a browser tab. It is mainly intended to share a terminal session over the web, but you can also run it completely locally.
$ ttyd --writable bash
Listening on port: 7681
It just works great! When paired with a capable web browser like Arc or Zen, you can easily even split websites and terminal sessions in the same view.
Ttyd was missing some features that I wanted (ex: automatic light/dark mode,or a set of builtin themes), so I created my own project inspired by it, called tweety. The rest of this article will focus on this specific project, but you can apply the same ideas to ttyd.
You can install tweety
on macOS and Linux using Homebrew:
brew install pomdtr/tap/tweety
Going one step further: mapping URL to commands
tweety
supports passing args to a script using by passing the args
query parameter. For example, if you run tweety <entrypoint>
the http://localhost:9999/?args=nvim+~/.bashrc
url will be mapped to the command <entrypoint> nvim ~/.bashrc
.
The naive implementation of the entrypoint script would look like this:
#!/bin/sh
# ⚠️ Do not do this, this is dangerous!
exec "$@"
But of course, you don't want to directly exec shell commands coming from GET
requests, as someone could just easily redirect you to a malicious url like http://localhost:9999/?args=rm+-rf+%2F
and delete your entire filesystem.
So we'll the input use our entrypoint scripts to only allow a set of commands and arguments that we trust.
#!/usr/bin/env -S deno run --allow-run
import { program } from 'npm:@commander-js/extra-typings'
import { existsSync } from "jsr:@std/fs"
// little helper to run commands
async function run(command: string, ...args: string[]) {
const cmd = new Deno.Command(command, { args });
const process = cmd.spawn();
await process.status;
}
// handle http://localhost:9999/
program.action(async () => {
await run("bash")
})
// handle http://localhost:9999?args=htop
program.command("htop").action(async () => {
await run("htop");
})
// handle http://localhost:9999?args=ssh+<host>
program.command("ssh").argument("<host>").action(async (host: string) => {
await run("ssh", host);
})
// handle http://localhost:9999?args=config
program.command("config").action(async () => {
const scriptPath = new URL(import.meta.url).pathname;
await run("nvim", scriptPath)
})
// handle http://localhost:9999?args=nvim+<file>
program.command("nvim").argument("<file>").action(async (file) => {
// protect use again `nvim 'term://<malicious-command>'`
if (file.startsWith("term://")) {
console.error("Invalid file path: cannot use 'term://' prefix");
Deno.exitCode = 1;
return;
}
await run("nvim", file)
})
if (import.meta.main) {
// parse arguments and run the appropriate command
await program.parseAsync();
}
As a general rule, you should only allow commands that do not perform any destructive actions, such as rm
, mv
, or cp
and instead use interactive commands like nvim
, htop
or ssh
that requires user input to perform actions.
Using a custom search engine to run commands
All modern web browsers supports defining custom search engines. You can use this feature to run commands directly from the address bar.
Just use http://localhost:9999/?args=%s
as the URL, and %s
as the placeholder for the command. You can then use tweety
as a search engine to run commands directly from the address bar.
Saving commands as bookmarks
Since each command has a unique URL, you can save them as bookmarks in your web browser. This allows you to quickly access frequently used commands without having to type them every time.
In the arc browser, I make use of the Favorites feature to group commands into folders. The ability to rename them and assign icons makes it easy to identify them at a glance.
Using https
and local certificates
By pairing a reverse proxy like Caddy, you can easily get a https://
URL for your terminal emulator, protected behind local certificates.
tweety.localhost {
tls internal
reverse_proxy localhost:9999
}
Now you can acces your terminal emulator at https://ttyd.localhost
.
Starting tweety
at login
Here is my plist file to start tweety
at login on macOS. You can save it as ~/Library/LaunchAgents/com.github.pomdtr.tweety.plist
and load it with launchctl load ~/Library/LaunchAgents/com.github.pomdtr.tweety.plist
.
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>com.github.pomdtr.tweety</string>
<key>LimitLoadToSessionType</key>
<array>
<string>Aqua</string>
<string>LoginWindow</string>
</array>
<key>ProgramArguments</key>
<array>
<string>/Users/pomdtr/go/bin/tweety</string>
<string>--theme=Tomorrow</string>
<string>--theme-dark=Tomorrow Night</string>
<string>/Users/pomdtr/.config/tweety/entrypoint.ts</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardErrorPath</key>
<string>/Users/pomdtr/Library/Logs/tweety.log</string>
<key>StandardOutPath</key>
<string>/Users/pomdtr/Library/Logs/tweety.log</string>
<key>WorkingDirectory</key>
<string>/Users/pomdtr</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/Users/pomdtr/go/bin/</string>
</dict>
</dict>
</plist>
On Linux, you should be able to use a systemd service instead.