← 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:
pipewire_capture.rs— consumes the virtual output’s existing PipeWire node (the same stream any screenshare consumer would use). Negotiates raw 32-bit BGRx video without modifiers; handles pre-mappedMemFd/MemPtrbuffers and mmap-able linearDmaBufs. Frames are re-packed to tight stride and fanned out on atokio::sync::broadcastchannel (lagging RDP connections skip frames rather than backlogging).wl_input.rs— a Wayland client on Otto’s own socket. Finds the target output by name via xdg-output, then creates a virtual pointer with that output (create_virtual_pointer_with_output) somotion_absolutecoordinates map into the output’s geometry server-side, plus a virtual keyboard with a default libxkbcommon keymap.rdp.rs/main.rs—ironrdp-serverglue: full-frameDisplayUpdate::Bitmaps from the broadcast channel (ironrdp applies RDP bitmap compression), RDP mouse/keyboard events translated to the input thread. Mouse handles both absoluteMoveand relativeRelMove/Scroll{x,y}(touchpad-mode mobile clients send the relative variants). Keyboard handles set-1 scancodes (→ evdev keycodes, with an extended-code table for arrows/nav/meta) and Unicode (mobile/on-screen keyboards sendUnicodePressed): ASCII codepoints are injected by tapping the matching US-QWERTY keycode + Shift against the fixed startup keymap. This is deliberately not a per-keystroke keymap swap — swapping races the client applying the new keymap and yields the wrong character.
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
- No auth: trusted-network only. TLS security with a self-signed
certificate (generated once, persisted under
~/.local/state/otto-rdp, key 0600) is on by default — required bymstscand Microsoft’s mobile clients, which refuse the plain-RDP layer. Pass--no-tlsfor theRdpServerSecurity::Nonelistener instead (FreeRDP/sec:rdp). CredSSP/NLA auth is the natural next step; ironrdp-server supports it. - Full-frame updates every frame — no damage-based partial updates yet. The
virtual output renders at its configured refresh (30 Hz default) and
ironrdp’s bitmap compression keeps this workable on a LAN; wiring Otto’s
damage tracking into
BitmapUpdaterects is the obvious optimization. - No clipboard or audio. The desktop size is negotiated once at connect: the client’s reported box is served verbatim (letterboxed server-side — native picture aspect-fit and centered, black bars baked in), and mouse input (absolute and relative alike) is mapped from box space back to native pixels through the picture rect; bar positions clamp to the picture edge. No dynamic re-negotiation after connect — rotating a phone mid-session keeps the originally negotiated size.
- Client scaling quirks (no RDPGFX in ironrdp-server, legacy bitmap
updates only; the client’s
desktopScaleFactorhints are dropped). Microsoft’s iOS Windows App: a served desktop that matches its requested box renders 1:1 physical in a corner (no upscaling); a mismatched one is stretched non-uniformly to fill the view. The workaround is--desktop WxHset to the device’s physical screen resolution: that box is served verbatim with the picture aspect-fit and centered inside, so the app’s stretch is uniform (desktop aspect == view aspect) and the picture displays full-screen and undistorted. Input is normalized from the client’s reported box through the picture rect. The proper fix is RDPGFX support upstream. - Keyboard injection covers ASCII only. Non-ASCII Unicode (accents, emoji, non-Latin scripts) is dropped with a debug log — a compose/dead-key or dynamic-keymap path is the follow-up.
- Conversely, a client that only speaks plain-RDP (
xfreerdp /sec:rdp) cannot connect once--no-tlsis passed to drop TLS for that reason.