cages
cages runs a graphical program in a headless Wayland compositor of its own, and a daemon keeps it there. You start a program, list what is running, take its picture, type and click into it, resize it, and leave it running with nobody looking.
A cage is one cage, a kiosk compositor that shows a single program at full size, started with WLR_BACKENDS=headless and WLR_RENDERER=pixman. No display, GPU or seat is needed, so a machine with no screen will do. It does for Wayland programs what xpra does for X ones, short of a viewer window of its own, which is not built yet.
cages type and cages keyPNG, 25,556 bytes- A graphical program on a machine with no screen
- Started once and looked at from wherever you are. It stays until its program ends or the daemon stops.
- A program an agent drives
type,key,clickandshotare a hand and an eye, with nobody's desktop involved.- A pane in Suplex
- Each cage is served as
suplex-frames/0, the stream Suplex's clients draw.
A session, as recorded
Everything below was run once, in this order, against a daemon of its own with a scratch home and none of the machine's environment but its PATH. The commands are as typed, the output is as printed, and each picture is a file cages shot wrote, shown pixel for pixel where the page is wide enough. cages 0.1.0, cage 0.3.1, foot 1.28.0 and Chromium 156 on x86_64-linux.
keepslooksdrives
-
01Start the daemon
Here it is started by hand. A user service runs it in practice (Install). Pictures are served on this machine alone, on a port the system picked.
cages daemon &cages versioncages 0.1.0 (pictures at 127.0.0.1:33991, suplex-frames/0)
-
02A terminal in a cage
runprints the cage's name. With no--nameit is the program's, with a number where that is taken.foot.inihere sets the shell, a font, colours and a$prompt, so the pictures are the same on any machine.cages run --name term --size 760x440 -- foot --config=foot.initerm
-
03What is running
listprints a line a cage: name, size, the title of the program's window, and what was run.infois one cage in full.cages listterm 760x440 foot foot --config=foot.inicages info term{ "name": "term", "command": [ "foot", "--config=foot.ini" ], "cwd": "~/demo", "pid": 2184333, "display": "wayland-0", "since": 1794514925, "title": "foot", "app": "foot", "size": [ 760, 440 ], "views": 0, "log": "~/.local/state/cages/logs/term.log" }
-
04Text, and a key
cages type term 'echo hello from a cage'cages key term Entercages type term 'echo "naïve café, λ"'cages key term Enter
typesends text as the characters themselves, by a keymap made for that text, so it does not depend on a keyboard layout.keypresses one key by where it is on a US board, named as the DOM names it:KeyA,Enter,F5,ArrowLeft.
term · both lines typed, both ranPNG, 25,556 bytes -
05A page in a browser
cages run --name card --size 480x300 -- ./kiosk.sh card.htmlcard
kiosk.shis Chromium with--ozone-platform=waylandand--app=. The page is a test card: it shows the width and height it was given, and the last press, key and text it received.
card · the test card, 480 × 300 as askedPNG, 21,864 bytes -
06A click
cages click card 0.7 0.45A place is two fractions of the cage, from 0 to 1, so it means the same at any size. The card drew a cross where the press landed and wrote down where that was.
card · pressed at 0.70 0.45PNG, 26,269 bytes -
07A key with a modifier, and text
cages key card KeyK --mods ctrlcages type card 'naïve café'
Modifiers are
alt,ctrl,metaandshift, separated by commas. The card names the key by its position,KeyK, and shows the text with its accents.
card · ctrl + KeyK, then textPNG, 30,255 bytes -
08Another size
cages resize card 640x360The compositor's output is set to the new size and the program is told, as when a window is resized. The card now reports 640 × 360, and the cross is still at 0.70 0.45 of it.
card · resized to 640 × 360PNG, 31,518 bytes -
09A picture, and the list again
shotwrites a PNG of the cage as it is now. Every picture on this page is a file it wrote, and the one in step 08 is this file, byte for byte.cages shot card card.png~/demo/card.png (640x360)cages listcard 640x360 test card ./kiosk.sh card.html term 760x440 foot foot --config=foot.ini
-
10What is refused
exit 0It was done. exit 1The daemon refused. With --jsonits answer is printed as it came.exit 2It was asked wrongly, or there is no daemon to ask. cages --json kill nothing{"ok":false,"error":{"code":"not_found","message":"no cage nothing"}}exit 1cages click card 2 2cages: 2 is no fraction of the cage (0 to 1)exit 2
-
11Ending
closeasks the program to close, as its window's own button would. A program that ends takes its cage with it:exitwas typed into the terminal.killends a cage and every process started in it. The lastlistprinted nothing.cages close cardcages type term exitcages key term Entercages list
Commands
--json before any command prints the daemon's answer as it came: {"ok":true,"result":…} or {"ok":false,"error":{"code":…,"message":…}}.
| Command | What it does |
|---|---|
| Keep | |
cages daemon [--listen ADDR:PORT] [--restore] [--cage PATH] | The daemon. --restore starts again the cages that were running when it last stopped, each with a new program in it. |
cages run [--name N] [--size WxH] [--cwd DIR] [--env K=V]... -- COMMAND [ARGS...] | A program in a cage of its own. Prints the cage's name. |
cages close NAME | Ask its program to close, as its window's own button would. |
cages kill NAME | End the cage and every process started in it. |
| Look | |
cages list | A line a cage: name, size, window title, what was run. |
cages info NAME | One cage: also its folder, pid, Wayland display, app id, how many views, and where its log is. |
cages logs NAME [--lines N] | What the compositor and the program said. Kept after the cage has ended. |
cages shot NAME FILE.png | A picture of it as it is now. |
cages view NAME [--only-look] | Open a view: prints where its pictures are served, and the view's ID. |
cages unview VIEW | Close a view. Its stream ends. |
cages version | The daemon's version, and the address it serves pictures at. |
| Drive | |
cages type NAME TEXT | Text, as the characters themselves. All of it, however long: typed no faster than its program takes it. |
cages key NAME CODE [--mods alt,ctrl,meta,shift] | One key, by where it is on a US board, named as the DOM names it. |
cages click NAME X Y [--button left|right|middle] | A press and a release at a place, as fractions of the cage. |
cages resize NAME WxH | Another size, a side from 1 to 16,384 pixels. The program is told. |
Inside
One Rust binary is both the command and the daemon. The daemon starts a compositor for each cage and then talks to it as an ordinary Wayland client, on a thread of its own for each, through the protocols wlroots compositors offer to tools.
cages run · type · key · click · shota Unix socket: one line of JSON asked, one answeredsuplex-frames/0pictures out · pointer and keys back · a WebSocket| What | By | |
|---|---|---|
| Its picture | wlr-screencopy | Asked for only while somebody can take a picture, and then only when the cage has changed. Only the rows that changed are read. |
| Its size | wlr-output-management | A cage is the size it is asked to be. |
| The pointer and the wheel | wlr-virtual-pointer | A place is a fraction of the cage. |
| Keys | virtual-keyboard | With a US board's keymap: a key is where it is on the board. |
| Text | virtual-keyboard | With a keymap made for the text, a key for each different character, and then the board's again. |
| Its title and app id, and asking it to close | wlr-foreign-toplevel-management |
Where things are
| The daemon's socket | cages/cages.sock under $CAGES_RUNTIME, else $XDG_RUNTIME_DIR, else /tmp/cages-UID |
|---|---|
| Each cage's Wayland display | The same folder: wayland-0, wayland-1 and so on |
| What was asked for, and each cage's log | $CAGES_STATE, else $XDG_STATE_HOME/cages, else ~/.local/state/cages |
What is typed waits for the program
A compositor passes keys on as fast as it is sent them, holds what a program has not yet taken, and ends a program that falls too far behind: cage holds a megabyte. So after every thousand presses and releases, in one text or many, the daemon asks the kernel how much the program has yet to read, and types on only once that is little. In the session above, 100,000 characters typed with one cages type into a terminal arrived whole, 1.0 seconds after the command was run.
A cage lives while its program does, whatever windows it has. A program that closes its window and opens another is followed on, and one with no window is listed by no title.
The socket
The socket takes one line of JSON, {"method":"list","params":{}}, and answers with one. A program that keeps a list of its own in step asks wait with the version that list last answered, and is answered when the list is no longer as it was.
The stream
cages view NAME opens a view and prints a WebSocket address and the view's ID. The protocol is suplex-frames/0, which Suplex's clients draw. This is one view of the terminal above, followed by a small client that wrote down everything said and sent.
cages view termws://127.0.0.1:33991/frames/v_0ad8…a232 v_0ad8…a232
{"t":"hello","view":"v_0ad8…a232"} {"t":"meta","width":760, "height":440, "scale":1.0, "title":"foot", "app":"foot", "cursor":"default"} - picture 1 · the whole, 760 × 440 · PNG, 25,556 bytes
{"t":"ack","seq":1} - 3 seconds with nothing changing in the cage: no message either way
- elsewhere:
cages type term x - picture 2 · 20 × 20 at 34, 92 · PNG, 622 bytes
{"t":"ack","seq":2} - elsewhere:
cages key term Backspace - picture 3 · 20 × 20 at 34, 92 · PNG, 369 bytes
{"t":"ack","seq":3} {"t":"text","text":"y"} - picture 4 · 20 × 20 at 34, 92 · PNG, 677 bytes
{"t":"ack","seq":4} {"t":"key","key":"Backspace", "code":"Backspace", "mods":0, "down":true} - picture 5 · 20 × 20 at 34, 92 · PNG, 369 bytes
{"t":"ack","seq":5} {"t":"key","key":"Backspace", "code":"Backspace", "mods":0, "down":false} - elsewhere:
cages unview v_0ad8…a232 close 1000 "ended"
A picture's sixteen bytes
02 00 00 00seq222 00x345c 00y9214 00width2014 00height2001encoding100 00 00 Picture 2 above, little-endian. The image follows the header.
- Only what changed is sent. The first picture was the whole cage, 25,556 bytes. One character typed sent 20 × 20 pixels of it: 622 bytes.
- Nothing is sent while nothing changes, and nothing is captured while nobody can take a picture.
- PNG, exact to the bit. A part of 16,384 pixels or more (128 × 128) with very many colours, as a photograph or a film has, goes as a JPEG instead.
- At most three pictures ahead. The viewer says
ackfor each one it took. A viewer that falls behind is sent the cage as it is by then, not what it missed. - What a person did comes back:
pointer,wheel,key,text,resize,focus. A view opened with--only-lookis sent the same pictures, and nothing it says is done. - Who is let in. The ID is 128 bits of the system's randomness and is all a stream is asked for by. A view nobody opened was answered
404 Not Found. A request naming anOrigin, as a browser's page does, was answered403 Forbidden: browsers are not admitted.
Install
A Nix flake for x86_64-linux and aarch64-linux: packages.<system>.cages, overlays.default for pkgs.cages, and a Home Manager module. The package wraps the command so that it finds cage whatever a service's PATH is. The repository is private, so the input below resolves only for someone with access to it.
inputs.cages.url = "github:harryaskham/cages"; # in a Home Manager configuration imports = [ inputs.cages.homeManagerModules.cages ]; services.cages.enable = true;
nix build .#cages # the units, and the daemon whole with real cages nix flake check # the same by hand: needs cage and foot on PATH cargo test
| Option | Default | |
|---|---|---|
programs.cages.enable | false | The command alone. |
services.cages.enable | false | The daemon as a user service, and the command. |
services.cages.restore | true | Start again, when the daemon starts, the cages that were running when it last stopped. |
services.cages.listen | "127.0.0.1:0" | Where pictures are served: this machine alone, on a port the system picks. |
services.cages.addressCommand | null | A shell command that prints this machine's address, a tailnet's for one. Pictures are then served there, so another device can follow a cage. |
services.cages.addressWaitSeconds | 30 | How long to wait for that address before falling back to listen. |
services.cages.port | 0 | The port used with addressCommand. 0 is one the system picks. |
services.cages.supervisor | "systemd" | "none" where there is no systemd: run services.cages.launcher under a supervisor of your own. |
Limits
What it does not do, as of 0.1.0. What it does do is held by nix flake check, which runs the daemon whole with real cages, a terminal in each: started, typed into, followed as pictures, resized, ended and started again. It passes on x86_64-linux and aarch64-linux.
- No viewer window of its own
cages attach NAME, a window on your desktop that shows a cage and passes the pointer and keys, is not written. Today a cage is looked at withshot, in Suplex, or by a program that follows the stream.- Cages end with the daemon
- When the daemon stops, its cages go with it.
--restorestarts them again with a new program in each; what the old program held is gone. - No clipboard, no sound, no GPU
- cage offers no clipboard protocol to tools. Sound is not carried: a program plays through the machine's own sound server, if there is one. Drawing is in memory (
pixman), so a program that needs a GPU gets none. - X programs
- Not tested. On the machine this page was recorded on, cage could not start XWayland, and
xtermin a cage showed no window. - A window's later titles
listshows the title a window had when it appeared. cage does not pass on one set afterwards: a terminal whose program then set its title to “a later title” was still listed as “foot”.- Not yet run on a phone
- The flake builds for aarch64-linux and its check passes there, but a cage inside Nix-on-Droid has not been tried. An app there may not be told how far behind a program is, so a long text is typed at a fixed pace instead.
- A display the daemon did not start
- A session of your own cannot be adopted. Each cage is a compositor the daemon started.
- The stream has no login
- It is plain
ws://, and a view's ID is all that guards it. Serve it beyond this machine only on a network you trust. - Linux only
- A cage is a wlroots compositor.