pinpox a35692cae6 sandbox: the jail gets root CAs, so a browser in there trusts a certificate
/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.
2026-09-01 16:58:28 +02:00

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

  1. CONTEXT.md — the vocabulary. Short, read it first.
  2. docs/DESIGN.md — architecture, what we reuse, what we write, and every open question with its answer.
  3. docs/adr/ — the twenty-two decisions that shaped the above, and why.
  4. dev/ — one command starts a real Stage, headless or nested, to build and test against.
  5. probe/ — the throwaway programs that proved the design works before we committed to it.
S
Description
No description provided
Readme
2.2 MiB
Languages
Rust 85.2%
Shell 6.3%
Nix 5.8%
JavaScript 2.7%