cages0.1.0

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.

A dark terminal, 760 by 440 pixels. Two commands were typed into it and ran: echo hello from a cage, which printed hello from a cage, and an echo of the words naïve café and a lambda, which printed the same. A prompt with an orange block cursor waits below.
term · foot, after 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, click and shot are 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

  1. 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)
  2. 02A terminal in a cage

    run prints the cage's name. With no --name it is the program's, with a number where that is taken. foot.ini here 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
  3. 03What is running

    list prints a line a cage: name, size, the title of the program's window, and what was run. info is 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"
    }
  4. 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

    type sends text as the characters themselves, by a keymap made for that text, so it does not depend on a keyboard layout. key presses one key by where it is on a US board, named as the DOM names it: KeyA, Enter, F5, ArrowLeft.

    The same terminal after the two commands were typed and ran: hello from a cage, then naïve café and a lambda, each echoed on the line below its command.
    term · both lines typed, both ranPNG, 25,556 bytes
  5. 05A page in a browser

    cages run --name card --size 480x300 -- ./kiosk.sh card.htmlcard

    kiosk.sh is Chromium with --ozone-platform=wayland and --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.

    A test card, 480 by 300 pixels: a strip of seven colour bars along the top, the figures 480 × 300 in large type, and three empty rows labelled press, key and text.
    card · the test card, 480 × 300 as askedPNG, 21,864 bytes
  6. 06A click

    cages click card 0.7 0.45

    A 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.

    The test card with an orange cross right of centre, labelled 0.70 0.45, and the press row now reading 0.70 0.45 left.
    card · pressed at 0.70 0.45PNG, 26,269 bytes
  7. 07A key with a modifier, and text

    cages key card KeyK --mods ctrlcages type card 'naïve café'

    Modifiers are alt, ctrl, meta and shift, separated by commas. The card names the key by its position, KeyK, and shows the text with its accents.

    The test card with its key row reading ctrl + KeyK and its text row reading naïve café.
    card · ctrl + KeyK, then textPNG, 30,255 bytes
  8. 08Another size

    cages resize card 640x360

    The 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.

    The test card at 640 by 360 pixels: the large figures now read 640 × 360, and the cross and the three rows are as before.
    card · resized to 640 × 360PNG, 31,518 bytes
  9. 09A picture, and the list again

    shot writes 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
  10. 10What is refused

    exit 0It was done.
    exit 1The daemon refused. With --json its 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
  11. 11Ending

    close asks the program to close, as its window's own button would. A program that ends takes its cage with it: exit was typed into the terminal. kill ends a cage and every process started in it. The last list printed 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":…}}.

CommandWhat 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 NAMEAsk its program to close, as its window's own button would.
cages kill NAMEEnd the cage and every process started in it.
Look
cages listA line a cage: name, size, window title, what was run.
cages info NAMEOne 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.pngA 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 VIEWClose a view. Its stream ends.
cages versionThe daemon's version, and the address it serves pictures at.
Drive
cages type NAME TEXTText, 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 WxHAnother 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.

WhatBy
Its picturewlr-screencopyAsked for only while somebody can take a picture, and then only when the cage has changed. Only the rows that changed are read.
Its sizewlr-output-managementA cage is the size it is asked to be.
The pointer and the wheelwlr-virtual-pointerA place is a fraction of the cage.
Keysvirtual-keyboardWith a US board's keymap: a key is where it is on the board.
Textvirtual-keyboardWith 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 closewlr-foreign-toplevel-management

Where things are

The daemon's socketcages/cages.sock under $CAGES_RUNTIME, else $XDG_RUNTIME_DIR, else /tmp/cages-UID
Each cage's Wayland displayThe 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
  1. {"t":"hello","view":"v_0ad8…a232"}
  2. {"t":"meta","width":760,"height":440,"scale":1.0,"title":"foot","app":"foot","cursor":"default"}
  3. picture 1 · the whole, 760 × 440 · PNG, 25,556 bytes
  4. {"t":"ack","seq":1}
  5. 3 seconds with nothing changing in the cage: no message either way
  6. elsewhere: cages type term x
  7. picture 2 · 20 × 20 at 34, 92 · PNG, 622 bytes
  8. {"t":"ack","seq":2}
  9. elsewhere: cages key term Backspace
  10. picture 3 · 20 × 20 at 34, 92 · PNG, 369 bytes
  11. {"t":"ack","seq":3}
  12. {"t":"text","text":"y"}
  13. picture 4 · 20 × 20 at 34, 92 · PNG, 677 bytes
  14. {"t":"ack","seq":4}
  15. {"t":"key","key":"Backspace","code":"Backspace","mods":0,"down":true}
  16. picture 5 · 20 × 20 at 34, 92 · PNG, 369 bytes
  17. {"t":"ack","seq":5}
  18. {"t":"key","key":"Backspace","code":"Backspace","mods":0,"down":false}
  19. elsewhere: cages unview v_0ad8…a232
  20. close 1000 "ended"

A picture's sixteen bytes

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 ack for 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-look is 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 an Origin, as a browser's page does, was answered 403 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
OptionDefault
programs.cages.enablefalseThe command alone.
services.cages.enablefalseThe daemon as a user service, and the command.
services.cages.restoretrueStart 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.addressCommandnullA 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.addressWaitSeconds30How long to wait for that address before falling back to listen.
services.cages.port0The 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 with shot, in Suplex, or by a program that follows the stream.
Cages end with the daemon
When the daemon stops, its cages go with it. --restore starts 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 xterm in a cage showed no window.
A window's later titles
list shows 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.