← 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, one per module:

Compositor-side support

Two compositor fixes were needed to make this work. Both have landed; they are recorded here because they affect every synthesized-input consumer, not just this bridge.

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). Previously the output argument was ignored and absolute motion always mapped to the first output, so the bridge could not aim input at the virtual screen.

Virtual-keyboard delivery. on_keyboard_event (src/state/virtual_keyboard_handler.rs) was a no-op. 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 — the compositor must forward it (as smithay’s anvil example does). Without this, every synthesized key — this bridge, plus wlrctl / ydotool / KDE Connect — was silently dropped. The handler now forwards to the focused surface (not through the shortcut filter, so remote typing reaches apps and never triggers compositor shortcuts).

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

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