/etc/ssl/certs on NixOS holds two symlinks into /etc/static, which is not bound and must not be, so binding the directory gave a guest two dangling links and not one trust anchor. Firefox shows it because nixpkgs nss ships libnssckbi.so as a symlink to p11-kit-trust.so: the browser has no built-in roots of its own, so the empty store surfaced as a certificate warning on every page. Bind each anchor as what it resolves to instead. That reaches no further, the targets being in /nix/store, already bound whole. Measured through the same trust module the browser loads: 0 roots and SEC_ERROR_UNKNOWN_ISSUER before, the host's own 763 and a good chain after; jailprobe now asserts the count from inside a real Session.
banquette
A banquette is a long bench that seats several people.
One command on a Linux box starts a shared desktop and prints an invitation. Everyone who takes it gets their own mouse pointer and keyboard inside the same set of windows, at the same time. Not screen sharing with a "you have control now" token — genuinely concurrent, one cursor per person, two people typing into two different windows at once.
$ nix run git+https://git.clan.lol/pinpox/banquette
A window opens: the Session's canvas, with your applications on it.
To join this session, from anywhere:
nix run git+https://git.clan.lol/pinpox/banquette -- join endpointabvvfyf3aegehhkcdgfogq7fkhsp7wykh7t52cy2bs3dnlrwasnaiaa
Send that line to somebody. They run it and the same window opens on their machine: an endless canvas holding the Session's windows, live, with their own pointer in them. From another network, with no port forward, no public address and no server in the middle — the invitation carries an iroh ticket and the two machines find each other (ADR-0013). That is a whole ticket above, not an elided one: it is the Session's public key and nothing else, so it fits in a sentence.
The canvas is a nested river with banquette as its window manager, so the windows inside it are real windows: each has a titlebar with the application's own name on it, a close button, and edges you can drag.
| middle-drag the background | pan the canvas — no modifier, so nothing outside can steal it |
| drag the titlebar | move a window on the plane |
| drag an edge or a corner | resize the window itself, for everybody in the session |
Super + drag a window |
move it without aiming at the titlebar |
Super + right-drag |
resize it without aiming at an edge |
Super + arrows or hjkl |
pan the canvas, 200px |
Super+Shift + the same |
pan 800px |
Super+0 |
back to the origin |
| a window's ✕ | close that window on the host, for everybody |
Super+q |
leave — the Session as a joiner, your own viewer as the host |
A resize is a real resize: the application on the host relays out at the new size and everybody's tile follows, because a tile is the window it shows and never a scaled picture of one (ADR-0018). It travels one round trip, so the window changes a moment after the pointer does.
The Super bindings are the viewer's, and the compositor you are running it inside may
want them first. A stock sway takes Super+drag for floating_modifier and Super
plus the arrows, hjkl and the digits for its own focus, movement and workspaces — none
of which ever reach the nested river. Panning the background needs no modifier for exactly
that reason, and is the one gesture nothing else can intercept.
It is the middle button rather than the left, and that is not a preference. River hands a bound button to the window manager instead of to the surface under the pointer, and the surfaces under that pointer include the viewer's own dock — so a plain left binding ate every click on it, which looked exactly like a launcher that did not work (ADR-0021). The left button is left alone, for the dock and for every application on the plane.
The plane has no edges: windows are placed left to right as they open, panning past them is how you get more room, and a window you move by hand stays where you put it — for everyone. The plane is shared the way the windows are: the Session decides where a new Window goes and everybody is told the same spot, and dragging one moves it on every screen at once (ADR-0023). What stays yours is the camera: everyone pans their own viewport over the one arrangement, and everybody's cursor — over a window or on the bare plane between them — is drawn where that person really is.
Along the top is a list of every Window on the plane, because one you have panned away from is otherwise indistinguishable from one that never opened. Along the bottom is a dock: one icon per application the Session was configured with — the application's own icon and its own name, read out of its desktop entry rather than guessed from the name of its binary — so a canvas with nothing on it is not a dead end. Click one and it opens on the Stage, for everybody — centred on your viewport, with your keyboard already in it, and nobody's camera moves. The click crosses the same wire a click on a Window does, and the Session is what starts the program: the dock is a client of your compositor, not of the Session's (ADR-0021).
They are two separate surfaces on opposite edges, and that is not decoration. A bar anchored to an edge is as wide as what is in it and the compositor centres the rest, so a dock sharing a card with a list of Windows moves every time a Window opens — which means the icon you are about to click for the second time is no longer where you clicked it. What is pressed and what is only read are kept apart on purpose.
The ground under it all is dotted, and the dots move with the plane, because otherwise a
drag across empty space changes no pixel and the gesture looks broken. None of this is a
desktop we wrote: it is Astal on layer-shell surfaces,
told what to draw down a pipe, and banquette's window manager binds river_layer_shell_v1
so that river admits them at all
(ADR-0017).
There is no web page anywhere in this project. Both commands open the same
program, and the Host is a participant of their own Session, on a pooled Seat like
everybody else. A machine nobody is sitting at takes --headless and opens nothing; a
Joiner who would rather have the windows loose on their own desktop, for their own
window manager to place, takes join --windows
(ADR-0014).
Anyone holding the ticket gets a Seat, and a Seat is a keyboard — so what a ticket is
worth is decided by the Sandbox, which every Session runs behind: the directory you
started it in and nothing else of your filesystem, the applications you named and nothing
else of your PATH, a home that vanishes with the Session, no handle on the desktop you
are sitting at, and a network that reaches out but never back in. A ticket is an
invitation, not a password, and the boundary rather than the invitation is what bounds it
(ADR-0019,
ADR-0005). --share PATH
adds a directory, --net host gives up the network half, and --no-sandbox gives up all
of it and says so.
Status: the first slice is done. Two people, two canvases, two windows, typing at
the same time, each seeing where the other is pointing — proved by three machines on a
LAN rather than one desktop: nix build .#checks.x86_64-linux.multiclient. A river you
already run works too: banquette --attach takes the window-manager slot on the desktop in
front of you instead of starting a Stage of its own. What is left is in
open questions: clipboards, and what ten
people in one Window should see.
Getting here cost three upstream patches, all carried in the flake and all worth sending: xdg-desktop-portal-wlr segfaults when a capture session closes and kills itself with a protocol error on a machine without a GPU (ADR-0003), and river attaches every virtual pointer to the default seat (ADR-0011).
Running one
nix run git+https://git.clan.lol/pinpox/banquette # a Session, and a ticket
nix run git+https://git.clan.lol/pinpox/banquette -- gimp # something specific instead
nix run git+https://git.clan.lol/pinpox/banquette -- --local # this machine only
nix run git+https://git.clan.lol/pinpox/banquette -- join TICKET # somebody else's Session
Told nothing to start, a Session starts a terminal and — if the machine has one —
Firefox, in a profile of its own. That last part matters: a firefox sharing your
profile is not a second browser, it is a new window on your desktop, where no
Joiner can see it.
Anything you name is on the dock, and --app names one without starting it. As many as
you like, in the order the dock draws them:
banquette --app nixpkgs#gimp -- --app inkscape -- foot -T one -- firefox
# └─ built here, at startup └─ on PATH └─ these two also start now
A name is a program on your PATH, a path, or a flake reference — anything with a # in
it, which nix build realises on this machine before the Session starts, so a Joiner can
launch something you never installed. It is rooted for as long as the Session runs and
resolved outside the Sandbox, because nix inside one would mean handing the daemon socket
through the only boundary there is
(ADR-0022).
With a desktop of your own, the Stage is nested: river runs as a window on it, so
its seat0 is your own mouse and keyboard and you are at the table alongside the
Joiners. On a machine nobody is sitting at, it is headless and identical otherwise.
Everything a Session needs, it starts: river, a session bus, xdg-desktop-portal and its wlroots backend, one iroh tunnel. Nothing has to be arranged first and nothing is left running after.
What crosses the tunnel is signalling — a few kilobytes of SDP. The Canvas is served by whoever opens it, from their own copy, and the video and input go directly between the two machines, because WebRTC does its own NAT traversal (ADR-0013).
As a machine
The flake also ships a NixOS module and a VM that is nothing but a Session:
services.banquette = {
enable = true;
user = "banquette"; # every application a Joiner starts runs as this user
applications = [ pkgs.foot ]; # started with the Session, and on the dock
offered = [ pkgs.gimp ]; # on the dock, started by whoever presses it
};
A package there is a store path by the time the unit is written, so a deployed Session runs no nix and needs no network to have its own dock.
nix run .#vm boots exactly that. One caveat worth knowing first: WebRTC needs an
address the browser can reach. QEMU's user-mode networking forwards TCP ports but
cannot carry ICE, so the Canvas loads and no video arrives. A bridged VM, a container
on the LAN, the host itself, or a ticket all work.
How it works, in one breath
An unmodified river is the shared desktop, whether
banquette starts one or you point banquette --attach at one you are already running.
Before any application starts, banquette creates a pool of Wayland seats — one per
prospective joiner, each with its own virtual pointer and keyboard. Each window is captured
individually through the standard ScreenCast portal and shipped as its own WebRTC video
track, so a Canvas can lay the windows out on a plane instead of mirroring a screen.
Applications are launched from the host CLI; anything they spawn themselves inherits
WAYLAND_DISPLAY and lands on the same desktop.
Banquette writes almost none of this — under 2k lines of glue between a compositor, xdg-desktop-portal, PipeWire and GStreamer. The one real exception is that river splits window management into a separate process, so banquette fills that slot. That is layout policy over a small protocol, not a compositor (ADR-0001).
Reading order
- CONTEXT.md — the vocabulary. Short, read it first.
- docs/DESIGN.md — architecture, what we reuse, what we write, and every open question with its answer.
- docs/adr/ — the twenty-two decisions that shaped the above, and why.
- dev/ — one command starts a real Stage, headless or nested, to build and test against.
- probe/ — the throwaway programs that proved the design works before we committed to it.