Skip to content

Instantly share code, notes, and snippets.

@timeopochin
Last active October 20, 2025 19:47
Show Gist options
  • Save timeopochin/d9d9aa3328d3ebec8027bd71594d8f29 to your computer and use it in GitHub Desktop.
Save timeopochin/d9d9aa3328d3ebec8027bd71594d8f29 to your computer and use it in GitHub Desktop.
How I launch my Wayland compositor

How I launch my Wayland compositor

I’ve used Linux for a while now, and after ~15 years I think I’m finally happy with how I’m handling

  • My login
  • My environmental variables
  • My daemons

What is this about ?

This ( article ? blog ? ) is going to go over my personal understanding of how, in what order, and why things happen when I login to my graphical session.

I will be going over my setup using Greetd and tuigreet to launch River or Sway, making sure my environments are properly setup, as well as how I handle my daemons such as Mako, Gammastep and foot.

I use Arch btw, which uses Systemd as it’s init system by default. Though some of this will probably be useful regardless of the init system you are using.

Preface : The “minimal” Wayland session experience

If you’ve played around with any sort of minimal, non-desktop environment, setups, you’ve almost certainly run into the problem of needing to set some specific environmental variables for things to work correctly.

Like the MOZ_WAYLAND_ENABLE for example. If you’re like me, you simply placed export MOZ_WAYLAND_ENABLE=1 line in your shell’s rc file. It worked fine because you were still launching sway from your shell after login in using the default [email protected] with tiny writing on your High DPI display.

A couple days of configuration later, you decide to try out a login manager. You set it up, it’s great, it’s pretty, it launches your compositor for you. Life seems great…

But then you open Firefox, and you witness the horror. What once was your beautiful tridactyl workspace has become an ugly pixelated mess of badly anti-aliased fonts and improperly scaled UI.

Okay ? Ah yes ! I use fractional scaling !

Hmm… immediately :

you open a terminal, you type xpropas you hover your cursor over what can barely be called a window, you realize you already know what will happen… the forever feared X11 cross appears before you.

Okay, keep calm, then :

after enjoying the wonderful orange fox displayed in all of its caca glory, you frenetically type echo $MOZ<Tab>… oh no… it autocompletes. You press <Enter>, a part of you still hoping to see a 0.

Hope seemed lost, but you know your stuff :

“I’ll just set it when I open Firefox in my sway config !”

It works for a while, you’ve almost forgotten about that unpleasant experience. But one day you open a link from an application, and that day, Firefox wasn’t running, our favorite browser launches, again looking like he was bullied by Chrome into limiting its resolution to 480p.

Right… lets fix this.

What I need and where I need it

I used Sway for a long time, but my current favorite compositor is River, and it is the one I use. I’ll be using it in my examples.

There are three places I need environmental variables to be set ( as far as I am aware ) :

  • Whenever I’m in my shell, both in my graphical session and in the tty
  • For my compositor and its children
  • For my Systemd session services

There are many variables that I need, but thankfully, the only ones I need to worry about are the ones I set manually ( well, almost… Damn you WAYLAND_DISPLAY ! I’ll deal with you later though… ). So, here is what I need :

  • EDITOR=nvim - 😎
  • PATH="$HOME/.local/bin:$PATH" - I want my scripts to be in my PATH
  • Wayland stuff :
    • XDG_SESSION_TYPE=wayland
    • GDK_BACKEND=wayland
    • QT_QPA_PLATFORM=wayland
    • MOZ_ENABLE_WAYLAND=1
  • Desktop stuff :
    • XDG_SESSION_DESKTOP=river
    • XDG_CURRENT_DESKTOP=river

Variables like HOME, XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS are handled by PAM. So they won’t make too much of an appearance in this ( article ? blog ? ).

Now : what where ?

EDITOR and PATH need to always be set for me. That is to say, whenever I’m in an interactive shell, graphical session or not. They should also be part of my compositor’s environment so that I can access and use them in my config, and they can be inherited by apps launched by my compositor.

All the Wayland related variables need to be in my compositor’s environment. As well as in my shell, but only when I’m in my graphical session. Luckly, I ( and almost definitely you ) launch my terminal emulator from my compositor. This means that once we have them in the compositor’s environment, we don’t have to worry about conditionally needing it in the shell.

The XDG desktop variables have more or less the same requirements as the Wayland ones, but they are dependent on the compositor, so let’s keep a distinction between them.

And that’s it really, I do have some more obscure variables for specific things, but they all fit in those three situations :

  • shell + compositor
  • compositor
  • compositor ( dependent )

So let’s group them like that in some POSIX shell scripts ( because I like POSIX ) :

~/.config/env/core - the variables I always need.

#!/bin/sh

export PATH="$HOME/.local/bin:$PATH"
export EDITOR=nvim

~/.config/env/compositor - the variables I need in my compositor.

#!/bin/sh

# The variables I need in my compositor

[ -f "${XDG_CONFIG_HOME:-$HOME/.config}/env/core" ] && . "${XDG_CONFIG_HOME:-$HOME/.config}/env/core"

export XDG_SESSION_TYPE=wayland
export GDK_BACKEND=wayland
export QT_QPA_PLATFORM=wayland
export MOZ_ENABLE_WAYLAND=1

Here I am sourcing my core variables inside my compositor variables script, and just to avoid annoying file does not f****** exist errors, I make sure the file exists first. As you can see, I’m using ${XDG_CONFIG_HOME:-$HOME/.config}, this means that I will use $XDG_CONFIG_HOME and default to $HOME/.config if it is not set. For most people, simply using $HOME/.config would work, but for the sake of portability, this ensures that if XDG_CONFIG_HOME is set to a custom value ( usually early on by PAM ), that we look for the configs in the right place.

Greetd and tuigreet

We can install both packages and enable greetd.service, I personally like Greetd because of it’s very simple config :

/etc/greetd/config.toml - mmmmm… TOML <3

[terminal]
vt = 1

[default_session]
command = "tuigreet --session-wrapper compositor-wrapper"
user = "greeter"

Nothing crazy here :

  • vt tells Greetd which virtual terminal to start on
  • command is the greeter command run by Greetd
  • user is the user that will run the greeter command ; this should be left as the default greeter unless you know what you are doing. It should not be your username

Now looking at the greeter command itself, we notice that we do not have the --cmd option like we usually see in example Greetd configs ; though we do have this --session-wrapper option. tuigreet will look for available Wayland sessions in /usr/share/wayland-sessions, these will be selectable at when on the login screen. The session wrapper is an executable ( in our case, a POSIX script, because I like POSIX ), tuigreet will run this executable and pass in the selected session’s Exec as arguments when login in.

For example, let’s have a look at /usr/share/wayland-sessions/river.desktop - this should already exist if you have River installed.

[Desktop Entry]
Name=River
Comment=A dynamic tiling Wayland compositor
Exec=river
Type=Application

The Exec is set to river so when login in, the actual command that will be run by our greeter is compositor-wrapper river when the River session is selected. This is nice because the wrapper script will be run for any Wayland session I chose on the login screen.

So what does this script look like ?

/usr/local/bin/compositor-wrapper - /usr/local/bin is in PATH by default, this is also the suggested directory on the Greetd website.

#!/bin/sh

[ -f "${XDG_CONFIG_HOME:-$HOME/.config}/env/compositor" ] && . "${XDG_CONFIG_HOME:-$HOME/.config}/env/compositor"

systemd-cat -t compositor "$@"

Yep, that’s it. Very simple. All we are doing is sourcing our compositor variables the same way we did with the core variables earlier. We don’t have to source the core variables because they are sourced in the compositor variables script. Then we run the compositor, there are a few things to unpack here :

Firstly, what is "$@" ? its a magic variable that corresponds to the arguments passed to the script. In our case, its simply river. Our script could simply have "$@", or exec "$@", but I decided to use systemd-cat -t compositor "$@" to redirect my compositor’s log to the journal, this allows me to easily view them with journalctl -t compositor.

We’re almost there !

How does this look on a sequence diagram you ask ?

sequenceDiagram

    participant pam as PAM
    participant greetd as Greetd service
    participant tuigreet
    actor user as The user
    participant compositor as Compositor
    participant wrapper as Wrapper script
    participant compositorvars as Compositor variables script
    participant corevars as Core variables script

    Note over pam,greetd: The system boots, system<br/>services are started

    activate pam
    activate greetd

    loop
        greetd-)pam: Please start a session for<br/>`greeter` with tuigreet

        Note over pam: The `greeter` session is created
        activate pam
        pam-)+tuigreet: Starts tuigreet

        Note over tuigreet: We are inside the `greeter` session

        tuigreet->>user: Displays login screen
        user-)tuigreet: Enters login info for `username`
        tuigreet->>+greetd: Please start a session for<br/>`username` with the wrapper
        greetd--)-tuigreet: Not if you don’t give me the correct password
        Note over greetd,tuigreet: Auth is actually much more complex, and I am only<br/>showing what we need to know kinda goes down.
        tuigreet->>+user: Displays a password incorrect message
        user-)tuigreet: Enters the password again<br/>but correctly this time
        tuigreet->>+greetd: Please start a session for<br/>`username` with the wrapper
        greetd--)-tuigreet: Sure, will do !<br/>( but he doesn’t do it ! yet… )
        tuigreet--)-pam: Exits
        Note over pam: The `greeter` session is destroyed
        deactivate pam
        Note over greetd: Greetd notices his greeter has died<br/>and will grant his last request
        greetd-)pam: Please start a session for<br/>`username` with the wrapper


        Note over pam: The `username` session is created
        activate pam
        Note over pam: From this point on, the other<br/>diagrams start and run in parallel

        pam-)+wrapper: Launch the wrapper as `username`
        Note over wrapper: We are inside the `username` session

        wrapper->>+compositorvars: Source compositor variables script
        compositorvars->>+corevars: Source core variables script
        corevars--)-compositorvars: Exits
        compositorvars--)-wrapper: Exits

        wrapper->>+compositor: Start the compositor
        deactivate wrapper
        Note over compositor: The XDG Desktop vars are set
        Note over compositor: We have access to all our variables here !

        compositor-)user: Displays GUI
        Note over user,compositor: More stuff happens, some can<br/>be seen in the other diagrams
        user-)compositor: Press logout keybind
        compositor--)-pam: Exits
        Note over pam: The `username` session is destroyed
        deactivate pam
        Note over greetd: Greetd notices the session has<br/>stopped and restarts the loop
    end

    deactivate pam
    deactivate greetd
Loading

WAYLAND_DISPLAY and Systemd session units

This is where things start getting a little annoying. When the user session is created, PAM also starts a separate user session for Systemd session units ( that’s the units you deal with when using systemctl --user ). You should be able to see both sessions with the loginctl command.

$ loginctl
SESSION  UID USER   SEAT  LEADER CLASS   TTY  IDLE SINCE
      4 1000 lmaooo -     801    manager -    no   -
      7 1000 lmaooo seat0 2604   user    tty1 no   -

2 sessions listed.

The session that has the luxurious possibility to sit down is the session your compositor is running in. The other one is the one Systemd is able to do stuff in.

The Systemd session has the same privileges because it uses the same user. But it has its own environment.

Thankfully, both sessions are provided with all the basic environmental variables we need, thank you PAM, when they are created. But here is the catch, WAYLAND_DISPLAY is set in the compositor’s environment. And this happens after the sessions were initialized by PAM.

And why do we need this variable ? Well, it’s because we have services that neet to interact with our compositor, they do this through a socket. The WAYLAND_DISPLAY variable gives us the name of that socket.

The Wayland socket has a predictable path, it’s "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" which is likely /run/user/1000/wayland-0 or /run/user/1000/wayland-1.

Here is the plan : let’s create a Systemd unit that watches the XDG runtime directory. If anything matches wayland-*, store it in WAYLAND_DISPLAY and make it accessible to Systemd units in the session.

Our unit files

We start with the Systemd path file.

~/.config/systemd/user/wayland-env.path

[Unit]
Description=Watch for new Wayland display socket(s) and trigger import

[Path]
PathExistsGlob=%t/wayland-*
Unit=wayland-env.service

[Install]
WantedBy=default.target

I won’t go all the syntax, but I’ll explain what the important lines do.

  • PathExistsGlob - here %t expands to the XDG runtime directory.
  • Unit - this is the unit that is triggered when the path exists.
  • WantedBy - the [Install] section contains constraints to apply when the file is enabled ( systemctl --user enable wayland-env.path )

default.target is a Systemd target. If I understand correctly, this will trigger as soon as Systemd starts.

When a unit Wants another unit, it will attempt to start it when it starts. For us, it means that we will start watching for the socket as soon as Systemd starts.

Let’s have a look at our Systemd service file :

~/.config/systemd/user/wayland-env.service

[Unit]
Description=Import WAYLAND_DISPLAY into the user systemd environment
Wants=graphical-session.target
Before=graphical-session.target
After=default.target

[Service]
Type=oneshot
RemainAfterExit=true
ExecStart=/bin/sh -c '\
  socket="$$(ls -1t "%t"/wayland-* 2>/dev/null | head -n1)"; \
  : "$${socket:?No Wayland socket found}"; \
  export WAYLAND_DISPLAY="$${socket##*/}"; \
  /usr/bin/systemctl --user import-environment WAYLAND_DISPLAY'
ExecStop=/usr/bin/systemctl --user unset-environment WAYLAND_DISPLAY

We see Wants straid away, which means that when we start, we try to start graphical-session.target too.

Before and After tells us to wait until default.target, and then to only start graphical-session.target after we have finished.

This target we are starting will start units that require it. For me that is Mako, Gammastep when it’s enabled, and the Foot server.

Just a sidenote, Foot is my terminal emulator. It has the ability to run in a daemon mode.

All three of these behave differently, but I’ll go over that after.

Now the [Service] section :

  • Type=oneshot - the process will run once or something. Read the docs if you want a better explanation.
  • RemainAfterExit=true - means the unit will still be in an active state after the process exits. This is important so that our Path file doesn’t try to launch it again.
  • ExecStart and ExecStop are the processes to run when starting and stopping the service. In practice, ExecStop might not be very useful because our service won’t stop being active. This line simply means that if you stop the service manually, with systemctl --user stop wayland-env.service for example, it would run the process.

ExecStart runs /bin/sh, our POSIX shell, -c is basically allowing us to put our script inline as an argument. Here it is cleaned up a little :

socket="$(ls -1t "/run/user/1000"/wayland-* 2>/dev/null | head -n1)"
: "${socket:?No Wayland socket found}"
export WAYLAND_DISPLAY="${socket##*/}"
/usr/bin/systemctl --user import-environment WAYLAND_DISPLAY

Notice that I’ve replaced %t with /run/user/1000, this is to show that it is expanded by Systemd before being used by /bin/sh. This %" would not be expanded in a script.

Also, to avoid Systemd trying to expand our variables, we had to escape the $ with $$.

And finally, using ; \, so we can avoid having it all on a single line.

I’ll go through it quickly :

  • we list the Wayland sockets one item per line, we order them so that the most recent is on top ( ls -1t ), we ignore ls’s errors ( 2>/dev/null ), and finally we get the first line ( head -n1 ) and put it in socket.
  • We exit with an error if socket is empty. Here, : simply forces the evaluation of our string, making sh exit if socket is empty ( ${...:?error message} ).
  • We export WAYLAND_DISPLAY with the basename ( ${...##*/} ) of the socket as its value.
  • We make it available to other Systemd units !

That’s it. We should have everything working. Gammastep will launch as soon as your compositor is open ( if it is enabled ). Mako will be launched by DBus when it is needed. Foot can be socket activated, assuming foot-server.socket is enabled, the Foot server will be launched when you first run footclient.

Let’s look at Gammastep on a diagram :

sequenceDiagram

    participant compositor as Compositor
    participant waysock as Wayland socket
    participant path as Our path file
    participant service as Our service file
    participant dt as default.target
    participant gst as graphical-session.target
    participant gammastep as Gammastep

    activate dt

    dt->>+path: Oi, wake up.
    deactivate dt
    Note over path: Starts watching for the<br/>Wayland socket

    Note over compositor: The compositor opens
    activate compositor

    compositor-)+waysock: Creates the socket

    Note over path: Sees the socket

    path-)+service: Oi, wake up.
    activate service

    Note over service: Gets and sets `WAYLAND_DISPLAY`
    service-)-gst:
    activate gst

    Note over service: Stays active even if the process has ended

    gst-)+gammastep: Oi, wake up.

    Note over gammastep: Wakes up and has access to `WAYLAND_DISPLAY`
    gammastep--)gst: Okay, okay
    deactivate gst

    gammastep-)waysock: Be more orange
    waysock-)compositor: Be more orange

    Note over compositor: Becomes more orange

    deactivate compositor
    deactivate path
    deactivate service
    deactivate gammastep
    deactivate waysock
Loading

This is why we specify Before=graphical-session.target in our service file. So Gammastep doesn’t start running until we have made WAYLAND_DISPLAY accessible.

Mako is activated by DBus, it goes something like :

sequenceDiagram

    actor user as The user
    participant compositor as Compositor
    participant dbus as DBus
    participant mako as Mako

    activate compositor
    activate dbus

    Note over mako: At this point, `WAYLAND_DISPLAY` has already<br/>been made accessible by our unit.

    user-)compositor: Presses keybind to see a notification
    compositor-)+dbus: Notification please
    dbus->>+mako: Oi, wake up.
    mako--)dbus: I’m awake
    dbus-)+mako: There’s a notification to show
    deactivate dbus
    mako-)-compositor: Draw this notification on the screen please
    Note over mako: Stays awake for future notifications
    compositor-)user: Displays the notification

    user-)compositor: Presses keybind to see a notification
    compositor-)+dbus: Notification please
    dbus-)-mako: There’s a notification to show
    activate mako
    mako-)-compositor: Draw this notification on the screen please
    compositor-)user: Displays the notification

    deactivate mako
    deactivate compositor
    deactivate dbus
Loading

Foot is socket activated, so its unit has two parts ( like our unit ! ) :

  • A service file
  • A systemd.socket file, this creates a socket ( if enabled ) after graphical-session.target. It then launches the service file when somebody sends something to the socket.
sequenceDiagram

    actor user as The user
    participant compositor as Compositor
    participant gst as graphical-session.target
    participant footclient as Foot client process
    participant footsock as Foot socket
    participant foot as Foot server
    participant foot.sock as Foot socket watcher

    activate compositor
    Note over compositor,gst: The compositor has finished loading and `WAYLAND_DISPLAY`<br/>has been made accessible to services.
    activate gst

    gst-)+foot.sock: Oi, wake up.
    activate foot.sock
    foot.sock-)footsock: Creates socket
    activate footsock
    foot.sock--)-gst: I’m awake, I’m awake
    deactivate gst

    user-)compositor: Presses keybind to<br/>launch `footclient`
    compositor-)+footclient: Runs footclient
    footclient->>+footsock: I want to talk with the server

    Note over foot.sock: Notices that something happened in the socket
    foot.sock-)+foot: Oi, wake up.
    foot->>+footsock: Any news ?
    footsock--)-foot: A client wants to talk
    foot-)footsock: I’m listening
    footsock--)-footclient: He’s listening
    footclient-)compositor: Please display the terminal
    compositor-)user: Displays the terminal window

    deactivate foot
    deactivate footclient
    deactivate foot.sock
    deactivate footsock
    deactivate compositor
Loading

The cherry on top

I want pretty colours and a nice font when I’m login in. Even if I’m in a TUI.

I also want to avoid my kernel info messages being printed directly on top of my login screen. But I don’t want to turn my kernel quiet.

Let’s fix this with Cage. Basically, instead of running tuigreet in the virtual terminal that has limited capabilities we open cage, we open Foot ( in standalone mode, not server-client mode ) inside of cage, and we run tuigreet in Foot.

Here is the greetd config that I use :

/etc/greetd/config.toml - <3

[terminal]
vt = 1

[default_session]
command = "cage -s -- foot -F -f monospace:size=14 -- tuigreet --session-wrapper compositor-wrapper -r --remember-session --asterisks -g 'おはよう〜' -t --theme 'border=magenta;text=cyan;prompt=green;time=red;action=blue;button=yellow;container=black;input=red'"
user = "greeter"

Important : If for some reason you decide not to use Cage’s -s option, you cannot use Control+Alt+F2 to switch to the second virtual terminal. If for some reason your compositor fails to load, you will be stuck in a login loop. Don’t worry ! You can press F2 inside tuigreet to set the command to launch after login. You can set this to /bin/bash or /bin/zsh ( bash or zsh should be sufficient ). And tuigreet will log you into your virtual terminal with your shell.

Things to watch out for

When login out and in quickly ( we’re talking a couple seconds ), the manager session does not reset ( I don’t know why, just my observation ). This doesn’t cause much of a problem unless the Wayland socket changes. Because the manager session hasn’t reset, it doesn't update WAYLAND_DISPLAY and things get a little sad.

This will almost never happen, but it is possible. It happened for me when switching between River and Cage, they use wayland-0 and wayland-1 by default.

If this is something that you would come across often I suggest these updated unit files :

~/.config/systemd/user/wayland-env.service

[Unit]
Description=Update WAYLAND_DISPLAY in the user systemd environment
Wants=graphical-session.target
Before=graphical-session.target
After=default.target

[Service]
Type=notify
ExecStart=%E/scripts/watch-wayland
NotifyAccess=all
Restart=on-failure

~/.config/scripts/watch-wayland

#!/bin/sh

dir=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}

update() {
    socket=$(find "$dir" -maxdepth 1 -type s -name 'wayland-*' -print0 |
        xargs -0 -r stat -c '%Y %n' |
        sort -n |
        tail -n 1 |
        cut -d' ' -f2-)

    current=$(systemctl --user show-environment 2>/dev/null |
        sed -n 's/^WAYLAND_DISPLAY=//p')

    if [ -z "$socket" ]; then
        if [ -n "$current" ]; then
            systemctl --user unset-environment WAYLAND_DISPLAY
        fi
    else
        name=${socket##*/}
        if [ "$name" != "$current" ]; then
            systemctl --user set-environment WAYLAND_DISPLAY="$name"
        fi
    fi
}

update

if command -v systemd-notify >/dev/null 2>&1; then
    systemd-notify --ready
fi

while inotifywait -e create,delete -q "$dir" --include "^$dir/wayland-[0-9]+$"; do
    update
done

I’m not going to go over exactly what I changed, but this should keep updating WAYLAND_DISPLAY every time a socket is created or deleted.

For completeness

I use ZSH, so I also set variables in my ~/.zshenv and ~/.zshrc.

Actually, I don’t currently define any in my ~/.zshrc, which is sourced whenever you are in an interactive shell.

And I simply source my core variables in my ~/zshenv. This one is always sourced by ZSH.

XDG_SESSION_DESKTOP=river and XDG_CURRENT_DESKTOP=river are also set in my ~/.config/river/init.

And for those who want to make sure they have the setup I have :

  • I have the packages mako, gammastep, foot ( and foot-terminfo ), greetd, tuigreet, river and cage.
  • I have enabled my path file, Gammastep and Foot’s socket activation with systemctl --user enable wayland-env.path gammastep foot-server.socket.
  • I have enabled Greetd with sudo systemctl enable greetd.

Thanks for reading and happy configurating !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment