Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/desktop-host/bin/desklink-host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ USAGE:
desklink-host bridge [--listen H:P] [--token T] [--source portal|x11|display] [--display :0] [--display-id ID]
re-serve the engine's protocol over a WebSocket and
print the URL to open; no signaling of your own needed
desklink-host <engine command> ... run the engine directly (serve, capture-probe, setup-input, version)
desklink-host <engine command> ... run the engine directly (serve, keep, capture-probe, setup-input, version)

Set MUXR_DESKLINK_ENGINE to use an engine built somewhere else.`);
return 0;
Expand Down
57 changes: 56 additions & 1 deletion packages/desktop-host/docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ to `stderr`. One JSON object per line, no length prefix, UTF-8.
Why stdio rather than a socket: the consumer already holds the process, so the
channel inherits the consumer's own access control (same uid, same session, no
filesystem permission to get wrong) and no second authorization mechanism has to
exist. A consumer that wants a socket can wrap this process.
exist. A consumer that wants a socket can wrap this process. (A screen that
needs no protocol at all — a private `Xvfb` whose windows should simply be kept
in order and reported — can run the engine as a display keeper instead; see
"Display keeper" below.)

Cancellation is by closing the consumer's end. The engine treats EOF on `stdin`
exactly like `shutdown`: it releases held input, stops capture, closes peers, and
Expand Down Expand Up @@ -529,6 +532,58 @@ session that has already ended is refused with `error.code = "session"`.

Closes every session and exits.

## Display keeper

```
desklink-host keep --display :42
```

A kiosk window manager for one X screen the consumer created itself — typically
one `Xvfb` per screen, with its own `-auth` cookie file. It is not for the
user's desktop: it takes SubstructureRedirect on the root window, so it is that
screen's window manager, and a display that already has one refuses the keeper.
Authentication is the ambient `XAUTHORITY`; a screen the consumer created should
have its own cookie file, and the keeper inherits whatever the consumer's
environment carries. There is no handshake and no stdin: the contract is a
stdout JSON-lines stream, and closing the consumer's stdout end ends the keeper.

The keeper fills the screen with every normal (non-override-redirect,
input-output) top-level window at `0,0,W,H`, raises the newest one and keeps the
input focus on the newest content window, so keyboard events an engine session
injects on that display land where a person looking at the screen would expect. A top-level window
smaller than 120 px on either side is not content — an emulator draws its side
toolbar as a tiny second window — and is parked far off-screen instead, so a
capture of the screen never shows it.

On startup, and after every change on the screen, it writes one line:

```jsonc
{"windows":[{"id":4194305,"title":"Pricing — Acme Store - Chromium","class":"Chromium","pid":31671,"width":1280,"height":800}]}
```

`windows` lists every visible top-level window, bottom of the stacking order
first, newest last. `id` is the X window id; `title` comes from `_NET_WM_NAME`
with `WM_NAME` as the fallback; `class` is the second string of `WM_CLASS` (the
one consumers filter on, e.g. `Chromium`, `Google-chrome`); `pid` is
`_NET_WM_PID`. Fields the window does not carry are `null`, never absent. A
change is reported after 100 ms of quiet, and never later than 300 ms after the
first unreported change, so a busy screen cannot starve its consumer.

A refused startup is one error line and a non-zero exit:

```jsonc
{"error":"another window manager"}
```

The same shape reports a display that could not be opened. When the display
goes away — an `Xvfb` the consumer stopped, most commonly — the keeper exits 0
on its own; a consumer treats the process ending as the screen ending.

The keeper deliberately does not decorate, move, resize on request, or remember
anything across runs. A screen with no windows produces one `"windows":[]` line
at startup and then nothing: the absence of a report is the absence of
anything to show.

## What the engine deliberately does not do

- It does not decide *who* the user is. It trusts the consumer's local
Expand Down
254 changes: 254 additions & 0 deletions packages/desktop-host/engine/examples/keeper_fixture.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
//! Scriptable X client fixture for the display keeper's live proof.
//!
//! One long-lived client on a display the test owns. The driver writes one
//! command per line on stdin and reads exactly one reply line per command on
//! stdout, so a test can create, map, unmap, resize and inspect real
//! top-level windows while a keeper manages the screen:
//!
//! ```text
//! screen -> screen <root> <w> <h>
//! create <W>x<H> [name ..] [class <i> <c>] [pid <n>]
//! -> created <id>
//! map <id> | unmap <id> | destroy <id> -> ok <id>
//! askgeo <id> <W>x<H> -> ok <id> (client resize request)
//! geometry <id> -> geometry <id> <x> <y> <w> <h>
//! focus -> focus none|pointer-root|<id>
//! quit -> bye
//! ```

use anyhow::{bail, Context, Result};
use std::io::{BufRead, Write};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
Atom, AtomEnum, ConfigureWindowAux, ConnectionExt as _, CreateWindowAux, EventMask, PropMode,
WindowClass,
};
use x11rb::rust_connection::RustConnection;

fn main() -> Result<()> {
let (conn, screen_number) = RustConnection::connect(None)?;
let screen = &conn.setup().roots[screen_number as usize];
let (root, white) = (screen.root, screen.white_pixel);
let utf8_string = conn.intern_atom(false, b"UTF8_STRING")?.reply()?.atom;
let net_wm_name = conn.intern_atom(false, b"_NET_WM_NAME")?.reply()?.atom;
let net_wm_pid = conn.intern_atom(false, b"_NET_WM_PID")?.reply()?.atom;
let stdout = std::io::stdout();
let mut out = stdout.lock();
say(&mut out, "ready")?;
for line in std::io::stdin().lock().lines() {
let line = line?;
let words: Vec<&str> = line.split_whitespace().collect();
let reply = match drive(
&conn,
root,
white,
utf8_string,
net_wm_name,
net_wm_pid,
&words,
) {
Ok(text) => text,
Err(error) => format!("err {error:#}"),
};
say(&mut out, &reply)?;
if words.first() == Some(&"quit") {
return Ok(());
}
}
Ok(())
}

fn say(out: &mut dyn Write, line: &str) -> std::io::Result<()> {
out.write_all(line.as_bytes())?;
out.write_all(b"\n")?;
out.flush()
}

fn drive(
conn: &RustConnection,
root: x11rb::protocol::xproto::Window,
white: u32,
utf8_string: Atom,
net_wm_name: Atom,
net_wm_pid: Atom,
words: &[&str],
) -> Result<String> {
match words {
["screen"] => {
let screen = &conn.setup().roots[conn
.setup()
.roots
.iter()
.position(|s| s.root == root)
.context("root vanished")?];
Ok(format!(
"screen {root} {} {}",
screen.width_in_pixels, screen.height_in_pixels
))
}
["create", size, rest @ ..] => create(
conn,
root,
white,
utf8_string,
net_wm_name,
net_wm_pid,
size,
rest,
),
["map", id] => {
conn.map_window(parse_id(id)?)?;
conn.flush()?;
Ok(format!("ok {id}"))
}
["unmap", id] => {
conn.unmap_window(parse_id(id)?)?;
conn.flush()?;
Ok(format!("ok {id}"))
}
["destroy", id] => {
conn.destroy_window(parse_id(id)?)?;
conn.flush()?;
Ok(format!("ok {id}"))
}
["askgeo", id, size] => {
let (width, height) = size_pair(size)?;
conn.configure_window(
parse_id(id)?,
&ConfigureWindowAux::new()
.width(u32::from(width))
.height(u32::from(height)),
)?;
conn.flush()?;
Ok(format!("ok {id}"))
}
["geometry", id] => {
let reply = conn.get_geometry(parse_id(id)?)?.reply()?;
Ok(format!(
"geometry {id} {} {} {} {}",
reply.x, reply.y, reply.width, reply.height
))
}
["focus"] => {
let reply = conn.get_input_focus()?.reply()?;
// X reports focus 0 as None and PointerRoot as 1.
Ok(match reply.focus {
0 => String::from("focus none"),
1 => String::from("focus pointer-root"),
window => format!("focus {window}"),
})
}
["quit"] => Ok(String::from("bye")),
_ => bail!("unknown command: {}", words.join(" ")),
}
}

fn parse_id(text: &str) -> Result<x11rb::protocol::xproto::Window> {
Ok(text.parse().context("bad window id")?)
}

fn create(
conn: &RustConnection,
root: x11rb::protocol::xproto::Window,
white: u32,
utf8_string: Atom,
net_wm_name: Atom,
net_wm_pid: Atom,
size: &str,
rest: &[&str],
) -> Result<String> {
let (width, height) = size_pair(size)?;
let (mut title, mut instance, mut class, mut pid) = (
String::from("probe"),
String::from("probe"),
String::from("Probe"),
0u32,
);
let mut i = 0;
while i < rest.len() {
match rest[i] {
"name" => {
i += 1;
let mut parts = Vec::new();
while i < rest.len() && rest[i] != "class" && rest[i] != "pid" {
parts.push(rest[i].to_string());
i += 1;
}
if !parts.is_empty() {
title = parts.join(" ");
}
}
"class" => {
instance = (*rest.get(i + 1).context("class needs two words")?).to_string();
class = (*rest.get(i + 2).context("class needs two words")?).to_string();
i += 3;
}
"pid" => {
pid = (*rest.get(i + 1).context("pid needs a value")?).parse()?;
i += 2;
}
other => bail!("unknown create arg {other}"),
}
}
let window = conn.generate_id()?;
conn.create_window(
x11rb::COPY_DEPTH_FROM_PARENT,
window,
root,
0,
0,
width,
height,
0,
WindowClass::INPUT_OUTPUT,
x11rb::COPY_FROM_PARENT,
&CreateWindowAux::new()
.background_pixel(white)
.event_mask(EventMask::EXPOSURE | EventMask::STRUCTURE_NOTIFY),
)?;
let name = title.as_bytes();
conn.change_property(
PropMode::REPLACE,
window,
AtomEnum::WM_NAME,
AtomEnum::STRING,
8,
name.len() as u32,
name,
)?;
conn.change_property(
PropMode::REPLACE,
window,
net_wm_name,
utf8_string,
8,
name.len() as u32,
name,
)?;
let class_data = format!("{instance}\0{class}\0");
conn.change_property(
PropMode::REPLACE,
window,
AtomEnum::WM_CLASS,
AtomEnum::STRING,
8,
class_data.len() as u32,
class_data.as_bytes(),
)?;
conn.change_property(
PropMode::REPLACE,
window,
net_wm_pid,
AtomEnum::CARDINAL,
32,
1,
pid.to_ne_bytes().as_slice(),
)?;
conn.flush()?;
Ok(format!("created {window}"))
}

fn size_pair(text: &str) -> Result<(u16, u16)> {
let (width, height) = text.split_once('x').context("size must be WxH")?;
Ok((width.parse()?, height.parse()?))
}
Loading
Loading