A self-hosted remote-desktop server for KDE Plasma / Wayland that lets you control your existing logged-in session from a browser. Unlike RDP/xrdp — which spin up a new session on Wayland — this attaches to the live one, so you see and drive exactly what's on the screen.
Video is captured via the xdg-desktop-portal ScreenCast API, encoded on your GPU
(NVENC, software x264 fallback), and streamed to a browser over WebRTC. Mouse
and keyboard travel back over a WebRTC data channel and are injected with a virtual
uinput device. It works over a private overlay like Twingate with no public
exposure.
Laptop/phone browser ──(Twingate)──► Your PC
<video>/<audio> (WebRTC) aiohttp : serves the client + WebSocket signaling
input capture (JS) GStreamer: pipewiresrc → NVENC → webrtcbin
│ portal : live-session screen capture
└─ input over datachannel ──────► uinput : virtual keyboard + absolute pointer
Highlights: multi-monitor with live screen switching, system audio, adaptive codec fallback (H.264 → VP8 so even Linux/Firefox clients work), browser-side resolution & bitrate, fullscreen-takes-control with keyboard shortcuts, pinch-zoom for phones, TLS, and an unattended mode you can start over SSH.
- KDE Plasma on Wayland (developed on Plasma 6 / Kubuntu). Needs
xdg-desktop-portal-kde, PipeWire, WirePlumber — standard on Kubuntu. - NVIDIA GPU with NVENC recommended (software x264 works but is heavier).
- A browser on the client (Chrome/Edge/Safari ideal; Firefox & Linux browsers work via the automatic VP8 fallback).
- Optional: Twingate (or any VPN/overlay) for remote access without exposing ports.
git clone https://github.com/irobinson010/rd-linux.git
cd rd-linux
./install.sh # installs deps, generates a random token, sets up the service
./rd.sh start # start it (approve the KDE share dialog once)./rd.sh start prints your connect URL, e.g.:
connect: https://192.168.1.50:8098/?token=<your-random-token>
Open that in the client browser. Because TLS uses a self-signed cert, the browser warns once — accept it. Click Take control (or go Fullscreen, which takes control and captures keyboard shortcuts). Double-tap Esc to release control.
The access token is generated by
install.shand stored in~/.config/rdserver/rd.env(mode 600) — it is not in the repo. Treat the URL like a password: anyone with it can control your machine.
rd.sh is the control script. It exports the session environment needed to reach the
portal/PipeWire (so it also works over SSH) and drives the systemd --user service:
./rd.sh start # start (default) ./rd.sh status # is it running
./rd.sh stop # stop ./rd.sh log # follow logs / show URL
./rd.sh install # (re)install the unit after editing deploy/rdserver.serviceAuto-start at login:
systemctl --user enable rdserverStart it over SSH (the script handles the environment, so this just works):
ssh you@your-pc
cd rd-linux && ./rd.sh startThe service runs inside your user session manager, so it keeps running after the SSH session closes, and restarts itself on failure.
Normally the screen-share portal that also grants input control can't be remembered,
so you'd have to click "Allow" at the machine every start. --unattended avoids that:
- Input goes through a virtual uinput device (no input portal, ever). Needs
write access to
/dev/uinput, which your graphical session already grants you via a systemd-logind ACL — no setup. - Capture uses a persistent ScreenCast grant: you approve the share dialog once,
a restore token is saved to
~/.cache/rdserver/, and later starts (including over SSH) restore the capture silently.
Caveats: the machine must be logged in to the desktop (use auto-login for a fully headless boot), and a locked session may capture as black depending on your KDE version.
Twingate gives the client a private path to the PC — no public exposure or TURN needed. Define the PC as a Twingate Resource and allow:
- TCP
8098— the web client + signaling (change with--port). - UDP to the host for the WebRTC media (ephemeral high ports).
Use the PC's Twingate address (or LAN IP locally) in the URL. If the browser's WebRTC
connectionState reaches failed, UDP isn't getting through — check that first.
Several people can connect at once: one controller + a few view-only viewers. A viewer gets the live video and audio but can't move the mouse or type — though they can still pick their own resolution/bitrate/screen/zoom.
Two ways to hand out view-only access:
- From the control session, click "Share view" — it mints a view-only token and copies a ready-to-send link to your clipboard. Valid until the server restarts.
- A permanent link: start with
--view-token <token>; that URL always grants view-only access.
Each viewer is its own hardware encode, so keep the count modest (--max-viewers,
default 4). Recipients must be able to reach the host (same Twingate/LAN), and the
link carries a token — treat it like any other access link.
Set these in ~/.config/rdserver/rd.env (RD_OPTS="...") for the service, or pass them
to python3 -m rdserver directly.
| flag | meaning |
|---|---|
--port N |
HTTP/signaling port (default 8089; the service uses 8098) |
--token T |
fixed access token (default: random each start; install.sh pins one) |
--view-token T |
second token for view-only access (watch, no control); also generatable in-app via "Share view" |
--max-viewers N |
max simultaneous view-only sessions (each is its own encode; default 4) |
--tls |
serve HTTPS/WSS. With no cert flags it auto-generates a self-signed cert |
--tls-cert FILE |
use a real certificate (PEM, full chain) — see "Custom domain" below |
--tls-key FILE |
matching private key (PEM); must be readable by the service user |
--audio |
stream system audio (taps the default sink monitor, stereo 48 kHz) |
--unattended |
uinput input + persistent capture (SSH-startable; see above) |
--bitrate K |
initial video bitrate kbps (default 20000; also live in the toolbar) |
--abr |
adaptive bitrate (WebRTC congestion control). Off by default — see note |
--av1 |
force NVENC AV1 for all clients (needs install-av1.sh + a HW-AV1 client) |
--udp-ports A-B |
RTP UDP port range (default 50000-50019) |
--software |
force x264 software encoding instead of NVENC |
--no-cursor |
don't embed the cursor in the video |
-v |
verbose logging |
--abr/ audio note: adaptive bitrate is off by default because driving the encoder from the live bandwidth estimate churns the shared transport over tunneled links (Twingate) and makes audio choppy. Leave it off for steady audio; enable it only on variable real networks.
The default --tls uses a self-signed cert, so the browser warns once. To get rid of
that, point a domain at the machine and feed in a real certificate with --tls-cert /
--tls-key. Example for rd.labxp.net:
- DNS: make
rd.labxp.netresolve to the PC (a Twingate-accessible address, or your internal DNS). - Get a trusted cert. If the host isn't publicly reachable on port 80/443 (e.g.
Twingate-only), use an ACME DNS-01 challenge — it proves ownership via a DNS TXT
record, no inbound exposure:
sudo certbot certonly --preferred-challenges dns -d rd.labxp.net # (or acme.sh / your DNS provider's plugin) - Make the files readable by your user — the service runs as you, not root, and
Let's Encrypt's
privkey.pemis root-only by default. Copy them somewhere you own (and re-copy on renewal, e.g. via a certbot--deploy-hook):install -m 644 /etc/letsencrypt/live/rd.labxp.net/fullchain.pem ~/.config/rdserver/ install -m 600 /etc/letsencrypt/live/rd.labxp.net/privkey.pem ~/.config/rdserver/
- Point the server at them — edit
~/.config/rdserver/rd.env:thenRD_OPTS="--port 8098 --tls --tls-cert /home/you/.config/rdserver/fullchain.pem --tls-key /home/you/.config/rdserver/privkey.pem --audio --unattended --token <yours>"./rd.sh start. Connect viahttps://rd.labxp.net:8098/?token=...— no warning.
Certs renew ~every 90 days; restart the service after each renewal (a certbot deploy
hook that copies the files and runs systemctl --user restart rdserver automates it).
The server encodes H.264 (High or constrained-baseline), VP8, or AV1. You don't pick —
the client auto-negotiates down a chain until frames actually decode, and remembers
the winner per device (localStorage):
high— H.264 High profile (best; hardware-decoded on phones, Windows, macOS).baseline— H.264 constrained-baseline, if High won't decode.vp8— decodes in every browser in software; the universal fallback.
This matters because Firefox and Chromium/Brave on Linux with NVIDIA often have no usable H.264 decoder in WebRTC at all and would otherwise show black — they land on VP8 automatically. VP8 is software-encoded, so drop the resolution in the toolbar if it's heavy. To re-test a sharper codec on a device that fell back, clear that site's data.
- Access = the token (and your overlay/VPN). It's randomly generated at install,
stored in
~/.config/rdserver/rd.env(mode 600), and never committed. Anyone with the URL can control the machine — don't share or paste it anywhere. - TLS (
--tls) encrypts the signaling/token on the wire with a self-signed cert; the WebRTC media is always DTLS/SRTP-encrypted. Still, don't bind this to a public interface — keep it behind Twingate/VPN. - One controller at a time — a new connection replaces the previous one.
- This grants full control of your desktop. The first capture approval is a real KDE
security prompt;
--unattendedremembers only the capture grant (input is local uinput). Revoking the share in KDE forces re-approval.
./uninstall.shRemoves the service and the generated config/cache (token + cert). It leaves apt packages, the optional AV1 plugin, and this source directory in place.
./install-av1.shBuilds the AV1 RTP payloader (rtpav1pay) from gst-plugins-rs (not packaged on
Ubuntu) into ~/.local/lib/gstreamer-1.0. Then run with --av1. Only useful for clients
that can decode AV1 in hardware — software AV1 decode is usually too slow (stutters).
- Black screen in the browser → it's almost always codec decode; the client should
auto-fall-back to VP8 within a few seconds. Check the page console for
framesDecoded. connectionState: failed→ WebRTC UDP isn't reaching the host (Twingate/firewall).- Audio choppy → make sure
--abris off (default). - Service won't start / restart loop →
./rd.sh log. Over SSH, use./rd.sh(it sets the env) rather than callingpython3orsystemctlbare. - Re-prompted for the share dialog → the saved grant was revoked (KDE "stop sharing")
or the session wasn't reachable; in
--unattendedit restores silently otherwise.
install.sh install deps + generate token + set up the service
uninstall.sh remove the service + generated config
install-av1.sh optional: build the AV1 RTP payloader
rd.sh control script (start/stop/status/log/install), SSH-safe
deploy/rdserver.service systemd --user unit (token comes from ~/.config/rdserver/rd.env)
rdserver/
__main__.py entry point + CLI
portal.py ScreenCast / RemoteDesktop portal negotiation (+ persistence)
media.py GStreamer webrtcbin pipeline (capture → NVENC → WebRTC) + dispatch
uinput_inject.py virtual keyboard + absolute pointer (unattended input)
signaling.py aiohttp HTTP + WebSocket signaling, token auth
keymap.py browser event codes → evdev codes
smoketest.py standalone capture+encode validator
web/
index.html / app.js / style.css browser client
tests/ offer/level regression tests + a manual uinput mouse test