A shell-forward desktop

This is just a short description of the desktop (technically, laptop) setup that I've converged onto over the years. Because of my long-abiding interest in es, I have tended to make choices that lean into using it as much as I can.

You might notice I also have slightly strange preferences—in general, I prefer my computer to do as little as possible, because I don't mind adding behavior to a too-minimal system, but dialing back annoying behaviors from a feature-rich one annoys the hell out of me.

For example, for logging in, I just use a getty (on Archlinux, agetty(8)) and login(1). I don't even have a lock screen; I prefer to just make the logging-in process quick and easy in the first place. Moreover, login doesn't try to do anything funny; it just starts a login es shell.

As users of es know, the .esrc script run by login shells is especially important, as es (like rc before it) only has that single script for customization and does everything else by passing strings down through the environment. (Another reason I try to have a convenient logout/login process—I have to do it fairly often anyway!)

river

So when login starts a login shell, after .esrc does the usual environment customization, it has the following right at the end:

let (x-wayland = true)
if {!pgrep river > /dev/null} {
	fortune | cowsay -f `{echo <={%flatten \n /usr/share/cowsay/cows/*} | shuf -n 1}
	mv -f /var/log/river/session /var/log/river/session.old
	exec {river <={
		if {!$x-wayland} {
			result -no-xwayland
		} {
			result ()
		}
	}} > /var/log/river/session >[2=1]
}

This prints a cute little creature-uttered fortune(1), and then starts the river Wayland compositor. River is an interesting compositor in a few ways, but I initially chose to use it primarily because it looked to me like “the Wayland bspwm”, especially because the two share a critical design choice: configuration happens via a script.

TODO: Add an obligatory screenshot here to (1) fix how annoying it is that a page about a desktop setup doesn't have any screenshots, and (2) prove that, visually, the setup isn't very interesting at all.

When river starts up, it runs the executable file ~/.config/river/init if it exists. On my laptop, of course, this executable file is an es script. This script does a few things; many of them are riverctl(1) commands, used to control and configure river from the outside. The following is an excerpted version of my river config, skipping the not-very-interesting parts which set colors and basic keybindings.

#!/usr/local/bin/es -x

# start the terminal server.  This prevents the junk below from impacting the environment
foot -Ss &

# core utilities
riverctl map normal Super Return spawn footclient
riverctl map normal Super Space spawn 'rofi -show combi'

for ((media-key bin args notif) = (
	XF86AudioRaiseVolume	pamixer		'--unmute --increase 5'	'"Set volume" "$(pamixer --get-volume-human)"'
	XF86AudioLowerVolume	pamixer		'--decrease 5'		'"Set volume" "$(pamixer --get-volume-human)"'
	XF86AudioMute		pamixer		'--mute --set-volume 0'	'"Audio muted"'
	XF86MonBrightnessDown	brightnessctl	'-e set 5%-'		'"Set brightness" "$(brightnessctl get)"'
	XF86MonBrightnessUp	brightnessctl	'-e set 5%+'		'"Set brightness" "$(brightnessctl get)"'
)) {
	riverctl map normal None $media-key spawn <={
		%flatten ' ' $bin $args '&&' \
		notcat send -a $bin -h canonical:^$bin -u low $notif
	}
}

waybar &

get-online && {
	forever { sync-mail.es; sleep 60 }
} &

# layout settings
riverctl default-layout rivertile
rivertile -view-padding 0 -outer-padding 0 -main-ratio 0.52 &

For my terminal I use foot(1), particularly using the client-server model via foot -s and footclient(1). This speeds up terminal startup and reduces resource use. After the foot server, this script adds keybindings to spawn footclient, rofi(1), and media keys including notcat send commands for them (more on notcat below). Then, we spawn a waybar(5), and finally rivertile(1).

rivertile is part of what makes river interesting; it's a separate program which communicates over a custom Wayland protocol to control the window management of the graphical session. Other programs can be used in place of rivertile, as long as they implement the same protocol. In theory a shell script could do it, if there were a helper program to translate between plain text and the Wayland protocol (like ncat(1) does for this site's web server, or notcat does for notifications, which I describe below) or, someday, if there were a module that could be loaded into es.

waybar and notcat

Waybar, which I use for my status bar, is one of the few graphical programs I use other than the terminal and firefox. My configuration is extremely basic; it just displays a battery percentage and clock on the right side and my notification server on the left.

My entire waybar config looks like this:

{
	"margin": "0 10",
	"modules-left": ["custom/notcat"],
	"modules-right": ["battery", "clock"],
	"clock": { "timezone": "America/Los_Angeles" },

	"custom/notcat": {
		"exec": "echo; notcat --capabilities=body-markup,body-hyperlinks --on-notify=tee-note.es --on-empty=tee-note.es '%i' '%s%(?B: - %b)'",
		"on-click": "act-note.es"
	}
}

The only interesting part of this is the notcat module.

The notcat notification server, which I wrote, is meant to be somewhat like a netcat in its function; it allows a user to write scripts on top of a binary which can function either as a notification server or client. Like a netcat, at its most simple, it prints received notifications to its standard output, but it is also able to run subcommands when notifications are received or closed. Notcat also implements an extension to the normal D-Bus notification protocol so that external programs can invoke actions on notifications—that ability is used in this config in the "on-click" script.

The notcat page itself goes deeper in detail on what this line in waybar's config does, and what the scripts it invokes contain. However, other references to notcat are littered throughout my desktop config, because I use it for all sorts of information. Every time I change the volume or brightness, notcat send is used to send a notification about it. I also get notifications for online status on login; the get-online script in my river init file is a simple wrapper around ping in a loop, sending notifications if pings initially fail, and on the first time pings succeed after failing. Then, the sync-mail.es script checks for mail and runs a notcat send each time my local mail directory has changed. What a lot of people use a lot of graphical widgets, windows, and modules for, I narrowed into a single scriptable and non-disruptive mechanism.

rofi

In addition to waybar, I also rely heavily on rofi in my desktop config. When rofi(1) is run, it pops up a menu of items (and a text field to filter them), and selecting one of the items invokes some action. Typically, rofi is used as an application launcher or window switcher. I use the default application launcher logic, both the simple run mode which simply runs a binary in $PATH as well as the drun mode which looks up .desktop files for binaries which fancy themselves “applications”.

On top of run and drun, I also use rofi to get passwords from the password manager pass, using the rofi-pass.es script that I wrote. I'll describe the setup, though it is a bit of a mess.

When rofi uses a script (see rofi-script(5)), it calls it twice. The first time, when it wants to print the menu, it executes the script with no arguments, and makes an option from each line that script prints to standard output. Then, if an option is selected, rofi will call the script again with that option as its first argument. We see the rofi-pass.es script here:

#!/usr/local/bin/es

# prints gpg files in the 'base' directory, recursing down directories
fn rec-ls base prefix {
	for (ff = $base/*)
	let (f = <={~~ $ff $base/*}) {
		if {!access $ff} {	# probably a globbing problem
			return
		}
		if {access -d $ff} {
			rec-ls $ff $^prefix/$f
		} {
			echo <={~~ $^prefix/$f /*.gpg}
		}
	}
}

fn get-pass path {
	while {pgrep -x rofi < /dev/null <[2] /dev/null} {
		sleep 0.1
	}

	local (PINENTRY_USER_DATA = rofi)
		pass -c $path
}

if {!~ $* ()} {
	get-pass $* > /dev/null >[2=1] &
} {
	rec-ls ~/.password-store
}

The first call to this script leads to the rec-ls function being called, which prints each of the entries in ~/.password-store so that rofi can use them as options. Then, if an entry is selected, this script is called with that, at which point it calls get-pass to get the password from that file.

When pass (which is, by the way, just a bash script) tries to retrieve the password from the given file, it will use gpg(1) to decrypt the file, and gpg will likely need a password from the user to do so. This is what the PINENTRY_USER_DATA variable is for. In ~/.gnupg/gpg-agent.conf is the line

pinentry-program /home/jpco/.local/bin/pinentry-switch.es

This configures gpg to use the pinentry-switch.es script for pinentry, which reads:

#!/usr/local/bin/es

if {~ $PINENTRY_USER_DATA 'rofi'} {
	/home/jpco/.local/bin/pinentry-rofi.es
} {
	/usr/bin/pinentry-tty
}

This makes it so that, when invoked from rofi, gpg uses rofi to prompt the user for a password, but when invoked any other time, it just uses the basic tty prompt. This is more convenient, in terms of keeping the user (me) from having to jump between windows unnecessarily.

Then there's the the pinentry-rofi.es script. This script is an extremely rough-and-ready implementation against the Assuan protocol that was in part (especially the sed invocation) ripped from somewhere I can't remember now (whoops!)

#!/usr/local/bin/es


command- = {throw continue}
command-BYE = {exit}

prompt = ()
command-SETPROMPT = @ {prompt = <={~~ $* *:}}

desc = ()
command-SETDESC = @ {desc = $*}

error = ()
command-SETERROR = @ {error = $*^\n}

command-GETINFO = @ info {
	match $info (
		flavor	{echo D rofi}
		version	{echo D 0.1}
		ttyinfo	{echo D - - -}
		pid	{echo D $pid}
	)
}

command-GETPIN = {
	let (message = `` \n {sed (
		-e 's|%0A|\n|g'
		-e 's|%22||g'
		-e 's|key:|key:\n|g'
		-e 's|>|>\n|g'
		-e 's|<|\&lt;|g'
		-e 's|>|\&gt;|g'
		-e 's|,created|,\ncreated|g'
		-e 's|_ERO_|<span fgcolor=''#ab4642''>|g'
		-e 's|_ERC_|</span>\n|g'
	) <<< $^error^$^desc^\n})
	let (password = `` \n {
		rofi -dmenu -input /dev/null -password -lines 0 \
			-p $^prompt -mesg <={%flatten \n $message}
	}) {
		if {!~ $^password ''} {
			echo D $password
		}
	}
}

echo OK get to it

let ((line cmd rest) = ())
while {!~ <={line = <=%read} ()} {
	if {~ $line *^$ifs^*} {
		(cmd rest) = <={~~ $line *^$ifs^*}
	} {
		cmd = $line
	}
	catch @ e rest {
		if {~ $e exit} {
			echo OK bye now
		} {~ $e continue} {
			throw retry
		}
		throw $e $rest
	} {
		if {!~ $#(command-^$cmd) 0} {
			$(command-^$cmd) $rest
		}
		echo OK
	}
}

There are, on the internet, quite a few similar “rofi+pass” implementations that look a lot like this one, implemented in quite a few different languages. I suppose this is just my entry to that family using es.