← Otto Developer GuideRDP bridge for virtual outputs (`otto-rdp`)

components/otto-rdp serves an Otto virtual output over RDP: remote clients see the frames Otto renders for that output and their mouse/keyboard input is injected back into the compositor, targeted at that output. With an interactive = true virtual output this behaves like a remote-accessible extra screen.

The design principle worth noting: the compositor knows nothing about RDP. A virtual output is just an output that has no physical connector; Otto renders it and publishes it as a PipeWire node exactly like any other. otto-rdp is an ordinary client that consumes that node and injects input back through standard virtual-pointer and virtual-keyboard protocols. Any other remote-display protocol could be added the same way, without touching src/.

Architecture

Otto ──renders──▶ virtual output PipeWire node ──▶ otto-rdp ──RDP──▶ client
  ▲                                                   │
  └──── zwlr_virtual_pointer (bound to the output) ◀──┤
  └──── zwp_virtual_keyboard (default xkb keymap) ◀───┘

Three subsystems:

Compositor-side support

Two pieces of compositor behaviour carry this bridge. They matter to every synthesized-input consumer, not just to RDP.

Virtual-pointer output binding. create_virtual_pointer_with_output stores the bound output, and motion_absolute maps normalized coordinates into that output’s global geometry (src/state/virtual_pointer.rs). Without the binding, absolute motion would land on the first output and the bridge could not aim input at the virtual screen.

Virtual-keyboard delivery. In the pinned smithay revision the virtual-keyboard dispatch sends the client the keymap but does not deliver the key on the non-IME path, so the compositor has to forward it, as smithay’s anvil example does. on_keyboard_event (src/state/virtual_keyboard_handler.rs) forwards to the focused surface, and not through the shortcut filter, so remote typing reaches apps and never fires compositor shortcuts. Without it every synthesized key would be dropped: this bridge’s, and wlrctl’s, ydotool’s and KDE Connect’s alike.

Running

# otto_config.toml
[[virtual_outputs]]
name = "virtual-1"
resolution = { width = 1920, height = 1080 }
refresh_hz = 30.0
position = { x = 1440, y = 0 }
interactive = true        # pointer/focus can reach it → remote control works
primary = true            # headless box only: chrome + default window
                          # placement move to this output
WAYLAND_DISPLAY=wayland-1 otto-rdp --output virtual-1 --listen 0.0.0.0:3389
# from the remote machine (TLS is on by default):
xfreerdp3 /v:<host>:3389 /cert:ignore

The bridge resolves the PipeWire node itself — no need to read the numeric id out of Otto’s log. Otto tags each virtual output’s node with a custom otto.output.name property (src/screenshare/pipewire_stream.rs), and otto-rdp’s discover module (components/otto-rdp/src/discover.rs) walks the PipeWire registry for a node matching --output (default virtual-1). Pass --node <id> directly to skip discovery if you already have the id.

The whole command line:

Flag
--listprint the nodes it can see and exit
--output <name>the virtual output to serve, by otto.output.name (default virtual-1)
--node <id>a PipeWire node id, skipping discovery; not with --connector
--connector <name>serve a physical output instead, through org.otto.ScreenCast
--listen <addr:port>the bind address
--port <n>the port alone (default 3389)
--desktop <WxH>announce a fixed desktop size; see the iOS note below
--tls / --no-tlsTLS is on by default
--bitmapfull-frame bitmaps instead of EGFX/H.264

Rendering is on-demand. render_virtual_outputs() (src/udev/render.rs) skips a virtual output’s composite/render work entirely while its PipeWire stream has no linked consumer, checked via PipeWireStream::is_streaming(), which mirrors PipeWire’s own StreamState::Streaming (true only once a consumer has connected and negotiated format). A configured-but-unwatched virtual output costs nothing until otto-rdp (or any other PipeWire consumer) connects.

Sharing indicator

While a client is being served, the bridge publishes a red dot in the top bar’s tray with a Stop Sharing menu. It uses StatusNotifierItem + dbusmenu, so no bar-side code is involved, and it cannot go stale; see Remote-Desktop Indicator.

Testing

The RDP wire protocol itself needs a real client (and a VA-API encoder), so it stays a manual check. What is covered automatically:

cargo test -p otto-rdp                            # the bridge's own logic
cargo test --features headless --test rdp_bridge  # what it asks of Otto

cargo test -p otto-rdp covers the decisions a client’s connection turns on: the served layout (client box verbatim, aspect-fit picture, even rounding on the EGFX path), the mouse mapping back through that letterbox, AVC-vs-bitmap transport selection from the advertised capability sets, and the RDP→evdev scancode table.

tests/rdp_bridge.rs drives a headless compositor as the bridge does: it binds the same globals in the same versions, reads the output’s logical geometry from xdg-output, and injects pointer motion, a click and keystrokes through zwlr_virtual_pointer_v1 / zwp_virtual_keyboard_v1, asserting they land on the right window and reach the application.

Current limitations