Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Environment & toolchain

0xin is built and run on Arch Linux, as ordinary stable-Rust userspace (no no_std, no custom target) — the toolchain is pinned in rust-toolchain.toml so a fresh checkout always builds with the exact version it was developed against.

System dependencies

wlroots0.19 wayland wayland-protocols libxkbcommon libinput libdrm seatd mesa pixman pkgconf clang

The version that matters most is wlroots: 0xin targets wlroots 0.19 specifically (Arch package wlroots0.19, pkg-config name wlroots-0.19). wlroots’ API moves between minor versions, so this pin isn’t cosmetic — build.rs resolves flags via pkg-config wlroots-0.19 rather than a bare wlroots, and every wlroots header requires -DWLR_USE_UNSTABLE defined or it expands to #error (wlroots treats most of its own API as unstable by design; the flag is an explicit “I know” acknowledgement, not a mistake to work around).

clang/libclang is a build dependency, not a runtime one — bindgen needs it to parse the wlroots C headers into Rust FFI declarations.

A direnv .envrc is committed (run direnv allow once after cloning, if you use direnv — it’s optional). It turns backtraces on (RUST_BACKTRACE=1) and, on a machine that has Nix, enters the flake’s devshell — the check is guarded, so the file stays a no-op here on Arch. It’s also the designated place for PKG_CONFIG_PATH/LD_LIBRARY_PATH if we ever pin our own wlroots build instead of the Arch package. Per-scenario variables (WLR_BACKENDS, WLR_WL_OUTPUTS, OXIN_MOD, …) deliberately stay on the command line — they select a run mode and don’t belong in ambient env.

Installing

Cargo builds; make installs. The split is deliberate and narrow: cargo install places binaries only, and only in ~/.cargo/bin, which is not where a display manager looks for a session entry. So the Makefile does one job — put files where the system expects them:

make               # cargo build --release
sudo make install  # PREFIX ?= /usr/local
make uninstall

$PREFIX/bin/0xin, $PREFIX/bin/0xinctl, and $PREFIX/share/wayland-sessions/0xin.desktop. PREFIX and DESTDIR are both honoured, so a distro package builds with make install PREFIX=/usr DESTDIR=....

The prefix and layout follow Hyprland, which is the same kind of program: its Makefile defaults to /usr/local and its CMake installs the binary to $PREFIX/bin and example/hyprland.desktop to $PREFIX/share/wayland-sessions/, with Arch’s package overriding PREFIX=/usr. Rust compositors differ on whether to have a Makefile at all — cosmic-comp ships one of exactly this shape, niri ships none and leaves the placing to packagers — but both keep the session entry as a real file in the tree, which is why dist/0xin.desktop exists rather than text generated inside flake.nix.

Building with Nix

Arch is where 0xin is developed, but it isn’t the only way to build it. A flake.nix is committed, and it is what makes 0xin installable on NixOS as a real session rather than a checkout somebody has to build by hand.

nix build          # packages.default — the 0xin and 0xinctl binaries
nix develop        # devShells.default — the system dependencies above, plus cargo
nix flake check    # builds the package and runs the test suite in the sandbox
nix run . -- kitty # apps.default

On Arch itself, Nix needs three things done once before any of that works. The package installs the binaries and the systemd units but does not create the store, and because the client resolves its store path before it ever connects to the daemon, every command fails with opening file "/nix/store": No such file or directory until it exists:

sudo systemctl enable --now nix-daemon.socket
sudo nix-store --init     # creates /nix/store — the step that is easy to miss
mkdir -p ~/.config/nix && printf 'experimental-features = nix-command flakes\n' >> ~/.config/nix/nix.conf

Flakes are not a separate package — they are an experimental feature of the nix already installed, which is what the third line turns on.

The flake follows the one in oslo — nixpkgs pinned by revision, flake-utils over x86_64 and aarch64 (the FP5 profile is the aarch64 target), rust-overlay for the toolchain, and the version read out of Cargo.toml — with three deliberate differences:

  • The Rust version is not written in flake.nix. oslo deleted its rust-toolchain.toml and moved the number into the flake, because two copies drift. 0xin needs the file — it is how the rustup build above gets 1.96.0 — so the flake reads the channel out of it instead. The number still lives in exactly one place.
  • No static build. oslo ships a static musl binary; a compositor cannot. 0xin links wlroots, EGL/GLESv2, libinput, libdrm and libseat dynamically, and the GPU driver has to come from the host at runtime.
  • A different nixpkgs revision. Not a preference: crates.io now answers every /api/v1/crates/<crate>/<version>/download with a 403, and nixpkgs only switched crate fetching to the static.crates.io CDN after the revision oslo pins. Against an older nixpkgs the build cannot vendor its dependencies at all. The revision here carries that fix and still has wlroots 0.19.3 — the same version Arch ships.

buildInputs is short, and there is one entry per pkg-config module build.rs probes: wlroots-0.19, wayland-server, xkbcommon, libdrm, pixman-1, glesv2 and egl, plus wayland-protocols for its pkgdatadir. Everything else 0xin links — libseat, libgbm, libinput, libdisplay-info, libliftoff — is pulled in by wlroots and needs no entry. libclang comes from rustPlatform.bindgenHook, which sets the include arguments bindgen needs.

libdrm and pixman-1 are probed by name for a reason worth knowing: wlroots lists its dependencies under Requires.private, and whether their include paths reach a compile depends on how the .pc chain gets resolved. On Arch they arrive for free, so shim/output.c‘s #include <drm_fourcc.h> and wlroots’ own #include <pixman.h> just work; in a Nix build sandbox they do not, and the compile fails on a missing header. Naming both in build.rs costs nothing and makes the build independent of that difference — which is why the fix lives in build.rs rather than in flake.nix.

Running the Nix build on a non-NixOS host

nix build produces a working compositor, but running it on Arch will not get you a picture: it fails at Could not initialize renderer, with no EGL client extensions and ERROR_INCOMPATIBLE_DRIVER from Vulkan. Nothing is wrong with the build — a Nix-built binary links Nix’s libglvnd, which looks for the GPU driver Nix knows about, and Arch’s Mesa is not it. On NixOS the driver comes from hardware.graphics, which programs.oxin.enable turns on, and the same binary runs fine; elsewhere it needs nixGL.

So on Arch, use Nix to build and cargo to run — which is the order this chapter already recommends. The same caveat applies to running the compositor out of nix develop; building there is fine.

The luna dependency is a git tag, so cargoLock.outputHashes carries a hash for it. It only ever changes when the tag in Cargo.toml does: set it to lib.fakeHash, run nix build, and copy the got: hash from the error.

As a NixOS session

nixosModules.default turns the package into a session the machine offers:

imports = [ inputs.oxin.nixosModules.default ];

programs.oxin = {
  enable = true;
  extraPackages = [ pkgs.kitty ];  # Mod+Return spawns kitty by default
  config = ''
    local oxin = require("oxin")
    oxin.gap = 10
  '';
  plugins = [ ./my-0xin-plugin ];
};

enable installs both binaries and registers the wayland-sessions entry the package ships, so a display manager lists 0xin to log into. It also turns on hardware.graphics and polkit; seatd is deliberately left alone, because logind hands the active VT its devices — the same thing LIBSEAT_BACKEND=logind arranges on Arch.

config is written to /etc/xdg/0xin/init.lua and plugins are linked under /etc/xdg/0xin/pack/nix/start/, which is a runtimepath root 0xin already scans (see Configuration). A user’s own ~/.config/0xin/init.lua still takes precedence over the system one.

Releases

.github/workflows/release.yml builds 0xin-linux-arm64-musl.tar.gz — both binaries, 0xin.desktop and install.sh — and attaches it to a GitHub release. It also runs on workflow_dispatch, so a binary can be produced without cutting a release, and so a tag is never the thing that discovers a broken build.

It builds inside Alpine edge on a native arm64 runner, and the reason is specific rather than stylistic. The reference phone is musl, so a glibc build cannot run on it — and the phone cannot build 0xin itself:

$ apk add --simulate wlroots0.19-dev      # on postmarketOS with systemd
ERROR: unable to select packages:
  elogind-dev-255.24-r0:
    conflicts: systemd-dev-261.2-r0
    satisfies: libseat-dev-0.9.3-r1[pc:libelogind]

Alpine’s libseat-dev is built against elogind, so on a systemd postmarketOS the whole -dev chain is uninstallable. A clean Alpine has no systemd-dev and no conflict, which is why the build happens in CI and the device gets a binary. The runtime is unaffected: apk add wlroots0.19 resolves cleanly, so the released binary runs natively.

Unlike oslo’s release workflow, this one does not assert the binary is static — a compositor links wlroots, EGL, libinput and libseat dynamically and takes its GPU driver from the host. It asserts what is true instead: every NEEDED library resolves against a plain Alpine root, and 0xinctl with no arguments prints its usage and exits 2.

The FFI pipeline

build.rs does four things, in order, every build:

  1. Resolves wlroots/wayland/etc. include and link flags via pkg-config.
  2. Generates the xdg-shell protocol header with wayland-scanner into OUT_DIR (wlroots’ own xdg-shell header #includes this, and it isn’t a system header — it has to be generated from the protocol XML on every machine that builds 0xin).
  3. Compiles the C shim (shim/*.c) via the cc crate.
  4. Runs bindgen over wrapper.h, allowlisting only the functions/types 0xin actually calls (see Architecture for why the allowlist exists and what it means for opaque struct types).

See build.rs for the exact allowlist and flag wiring.

Running it

Two run modes, both via cargo aliases in .cargo/config.toml:

  • cargo nested — the fast dev loop. Inside an existing Wayland session, wlr_backend_autocreate picks the nested Wayland backend automatically and 0xin opens as an ordinary window on the host desktop. OXIN_MOD=alt cargo nested -- kitty sets the modifier to Alt (since the host compositor usually grabs Super-chords before a nested client sees them) and launches a test client against 0xin’s own socket.
  • Real TTY (DRM/KMS) — from a free virtual terminal, logged in: LIBSEAT_BACKEND=logind ~/proj/0xin/target/debug/0xin kitty 2>~/0xin-tty.log. wlr_backend_autocreate detects there’s no WAYLAND_DISPLAY and picks the DRM/KMS backend instead — this is 0xin as a real session, not a nested toy. LIBSEAT_BACKEND=logind lets logind hand the active VT its devices without needing the seat group.

Full recipes, verification commands, and known gotchas (multi-GPU device selection, VT-switch repaint behavior, headless screenshot verification) live in Running & Verifying and the in-repo notes/ directory, which is the day-to-day working reference this chapter is distilled from.