Powering the Linux Desktop with Haskell

It feels like every year for the last fifteen years has been "The Year of the Linux Desktop," that fabled utopia where desktop computing is freed from the shackles of operating systems like Windows. It's likely the milestone has come and gone though. Reflecting on the past decade of desktop Linux yields an almost unrecognizable landscape. From the proliferation of driver support to the usability of modern desktop environments like GNOME and KDE, there's never been an easier time to make Linux one's daily driver.

It might be surprising, then, to consider ditching the shrinkwrapped experiences of mainstay distributions like Ubuntu and Fedora in favor of a purpose-built desktop environment, but this is exactly what I've been doing for the past five years.

I couldn't be happier with the experience.

In addition to finding enjoyment in the process of building, I'm also finding additional productivity gains by specializing the way my desktop behaves for my most common tasks. My machine only runs the software that I need, when I need it, and with minimal fuss. Windows are automatically arranged for me on the screen, and resizing or re-organizing them are only a few keystrokes away. If there's a task I can't easily perform, it's only a brief Haskell session away from becoming reality. I don't have to wait for the feature to ship with the next release from upstream. This setup plays to my strengths, and exemplifies everything that I've loved about Linux for the past two decades.

What Does a Linux Desktop Look Like Anyway?

Ignoring a lot of nuance, most Linux distributions are differentiated by their composition of:

  • Package Manager
  • Init System
  • Desktop Environment

Desktop environments themselves are composed of a session manager, window manager, and suite of tools for managing common tasks like connecting to the internet, choosing language input, and launching programs.

Packages like GNOME and KDE are comfortably considered desktop environments and take the "batteries included" approach to computing. It provides a no fuss environment that does 80% of what you'd want and, typically, have plugins or extensions for the remaining 20%. This approach does come at a cost though, generally in the form of battery life and system performance. For most users, this is where the story ends---the computer adequately meets their needs.

The longer I use computers, however, the likelier the chance the experience falls apart because I run into some subset of the following issues:

  • Configuration drift causes my system to behave differently over time, and it's difficult to execute a set of changes (or rollbacks) repeatably
  • Tweaking system behavior requires that the behavior is actually configurable or learning the underlying language and implementing fixes or new features upstream
  • The batteries included approach inevitably means there are services running in the background that don't actively improve my workflow and exist just to consume system resources

Sooner or later, I find myself going against the grain of the intended user experience, resulting in more time debugging the system than actually using it. So after some soul searching, I now have a list of requirements for my ideal desktop experience. I should be able to:

  1. Version the desktop in a source control system like Git
  2. Extend, fix, and test the desktop in a language I have proficiency with
  3. Manage desktop windows automatically
  4. Run the bare minimum of services to satisfy my most common tasks, with the option to start less common services on demand
  5. Execute most tasks via some combination of keystrokes, minimizing the need for a mouse or trackpad

I can approximate these requirements with a confluence of tools that are controlled by XMonad and XMobar, giving me really tight control of my desktop via Haskell APIs. XMonad and XMobar are programs written in Haskell that manage window management and status bar information.

Haskell™, Experience What's Inside®

XMonad and XMobar manage and power my desktop, but what are they exactly?

XMonad is a tiling window manager amidst a crowded field of well known alternatives like i3, awesome, and dwm. The perceived productivity boost is large; Rather than resizing windows when plugging and unplugging monitors, XMonad simply reflows my windows according to the dimensions of the screen and my prefered layout method. I further specialize layouts for widescreen monitors and the laptop screen itself, which allow me to maxmimize the screen real estate no matter where I am. As I write this post, I have an even split of editor, browser, and build log so that I can preview this post in progress!

While XMonad handles the desktop layout, XMobar manages the status bar. It does so primarily through text and is written and configurable via Haskell. It provides a number of "monitors" out of the box for things like volume, wireless status, and battery information. Building custom monitors is simplified when building the project from source, requiring you to implement an interface and adding it to the overall configuration.

These two programs are perfectly usable out of the box, but they become a knotted mess over time. The single-file configuration strategy creates monolithic configs, obfuscates which parts of Haskell you can use, and removes package management and debugging tools when tweaking. While you can version them in Git, it becomes tricky when you need the files in particular locations. While a number of "dotfiles" managers have sprung up to handle this case, I'd much rather have a single binary that I can push around. There's also no way to test these configurations, so if you've gotten past the compiler but still have some logical error, you won't find out until the desktop fails to behave the way you want.

It might sound like the end of our journey with all these drawbacks, but let's dig a little deeper into the methodology I use today to manage the desktop and uncover what's working well.

Versioning with Git

I've organized the entire project into a monorepo so that I can make sure everything is built within the same "universe" (i.e. same GHC version and same sets of dependencies). Using submodules, I can also check out tagged releases of XMonad and friends or build off master. I currently pull in the following projects as sub-modules:

I also maintain my XMonad and XMobar configurations as their own projects.

I haven't had cause to manage sensitive information yet, but I imagine if I need to, I'll simply document where that information can be accessed on the system rather than committing it directly.

Building with Stack

Since I'm not interested in using system packages (I'd like to pull in and fix issues I find), I need a way to compile everything I need from source. Rolling release distributions like Arch and Gentoo offer robust build systems, but, for my comfort, I choose to use the language-native tooling for managing projects. As a matter of preference, I use Stack and Hpack, but if you're following along at home, there's no reason to avoid Cabal.

Stack makes it simple to build the project, allowing me to:

  • Declare local projects as packages and have them shadow upstream
  • Set compile flags
    • This makes it handy to enable features for XMobar, for example, while also allowing me to set global compilation options for all packages
  • Choose a "universe" that sets the version of GHC and compatible packages (Stack calls them "resolvers")

Compiling the entire desktop is now one command:

stack build --copy-bins

Or if I need to re-build a particular sub-project:

stack build :my-xmonad --copy-bins

Another advantage of building from source and using a native build chain is that I can organize functionality into Haskell modules that I can import into the main file. This means I can organize imports, use type signatures, and only expose the functions I need to other modules without cluttering the global namespace. This isn't nearly so bad for XMonad as it is for XMobar since the former is essentially a valid Haskell file while the latter really only allows you to define the Config record.

Error messages also end up being more legible. Instead of depending on the quality of error messages in either tool, you get them straight from GHC. It's far easier for me to parse error messages at compile time than from a log on the file system.

Here's an example where I misconfigure a keybinding:

my-xmonad> configure (lib + exe)
Configuring my-xmonad-0.1.0...
my-xmonad> build (lib + exe)
Preprocessing library for my-xmonad-0.1.0..
Building library for my-xmonad-0.1.0..
[9 of 9] Compiling MyXMonad.KeyMapping

/home/ffreire/git/my-xmonad/my-xmonad/src/MyXMonad/KeyMapping.hs:32:15: error:
    * Variable not in scope: xK_ :: Graphics.X11.Types.KeySym
    * Perhaps you meant one of these:
        `xK_h' (imported from XMonad), `xK_l' (imported from XMonad),
        `xK_n' (imported from XMonad)
   |
32 |     [ ((modm, xK_)                 , spawnHere rofi)
   |               ^^^


--  While building package my-xmonad-0.1.0 (scroll up to its section to see the error) using:
      /home/ffreire/.stack/setup-exe-cache/x86_64-linux-tinfo6/Cabal-simple_mPHDZzAJ_2.4.0.1_ghc-8.6.5 --builddir=.stack-work/dist/x86_64-linux-tinfo6/Cabal-2.4.0.1 build lib:my-xmonad exe:my-xmonad --ghc-options " -fdiagnostics-color=always"
Process exited with code: ExitFailure 1

GHC helpfully recognizes the value I should be using and the package it comes from. It also suggests a few alternatives that I might have meant. This helps me easily fix the error and return to using my machine.

Rebuilding Without Restarting

It's well and good to be able to build from source, but how does XMonad know about our custom setup?

Users are likely familiar with XMonad's ability to reload configuration without restarting, but fewer know that as of the 0.13 release, you can dictate how this recompilation occurs. By creating a file called ~/.xmonad/build and making it executable, you can script whatever behavior you need. Mine looks like this:

#!/bin/sh

# Print all commands to stdout
set -x
# Stop script execution on first error
set -e

PROJECT_PATH="$HOME/git/my-xmonad"

# Use stack to build the xmonad/xmobar binaries
stack --stack-yaml "$PROJECT_PATH/stack.yaml" \
    install :my-xmonad :my-xmobar

# Create a hard link from the stack binary to the location that xmonad wants
ln -f -T \
  "$(stack --stack-yaml $PROJECT_PATH/stack.yaml exec -- which my-xmonad)" "$1"

The next time you run xmonad --recompile, either through a keybind or manually at the terminal, you should be able to see build output in ~/.xmonad/xmonad.errors.

Building Local Documentation

No setup would be complete without a compass to guide future endeavors. Building documentation was an initial goal that I had, but it's been a huge boon to developing new features. Stack's concept of a "universe" of packages means that if I go looking for documentation on Hackage, I may not be seeing the same version that I'm using locally. This doesn't bite me often. Library authors tend to be very cautious about changing APIs once they're live, but it's extra peace of mind to know that what I'm looking at locally is exactly what I'm compiling.

Once again, Stack comes to the rescue with the haddock command:

stack build --haddock --open

This will build documentation for all packages that are required for my configuration. If you're trying this at home, you can play with a few flags (found through stack haddock --help) to control which packages you generate documentation for. This is useful if you're not as interested in generating documentation for common packages like base.

Increasing Confidence Through Testing

The biggest area of potential for this setup is the ability to write tests that mitigate logical errors creeping into userland behavior. Since the project is valid Haskell, I can levy the ecosystem of testing libraries and strategies to ensure the desktop behaves the way I want. I'm particularly fond of tasty for its ability to blend different strategies together, which usually means pulling in hunit and smallcheck to cover unit and generative testing respectively.

Using a similar workflow to other projects I have in Haskell], I can continuously run tests in the background when developing new features to keep me honest and make sure I don't break my own environment. I'll very likely do a follow-up post as I explore this space further and find what works well and what doesn't.

Has It Been Worth It?

Yes, the progression towards this point has been incredibly fulfilling. I've learned more about what is running on my system, how components interact with each other in the Linux userland, and gained a deeper appreciation for the type safety and ergonomics of the Haskell language.

This setup satisfies all the requirements I have for a desktop environment and allows me to be more productive than I would otherwise. I appreciate that this approach isn't for everyone, but if you're on the fence about experimenting with your desktop experience, I'd highly encourage it. Computers are a tool that we increasingly use for longer periods of time, so even seemingly minor adjustments have outsized impact on day-to-day productivity.

Experiment, find what works for you, and build towards an experience that plays to your strengths.