Serve only the desktop's primary screen over VNC

krdp streams every screen, so the VNC screen is now the primary's size and the
FreeRDP window is shifted so the primary fills it (ft-layout remote-view gives
the offset). It resizes and reconnects when the layout changes. With remote
access on, KWin's D-Bus screenshot interface is open too, for scripts that look
at the screens without the headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 6f5a23c80f2ecd26e6a96c86b102be36d518ee25)
This commit is contained in:
DeeJanuz committed 2026-09-30 15:50:46 -06:00
1 parent 97f853130d
commit 9820e7996f
5 files changed
+102 -31

No files matched your search

+3 -3
View File
@@ -185,13 +185,13 @@ Details, options, and the recording and replay tools are in [hands/README.md](..
## Remote desktop over VNC
With `REMOTE=1` in the config (`desktops.sh remote on`), the desktop is also served over VNC, for RealVNC Viewer or macOS Screen Sharing. `desktops.sh remote info` prints the address and password.
With `REMOTE=1` in the config (`desktops.sh remote on`), the desktop's primary screen (the one with the taskbar) is also served over VNC, at that screen's resolution, for RealVNC Viewer or macOS Screen Sharing. `desktops.sh remote info` prints the address and password.
It listens on port 5900 on the Frame's Tailscale address only, not the LAN, so it needs Tailscale on the Frame ([deck-tailscale](https://github.com/tailscale-dev/deck-tailscale)). VNC authentication has no encryption of its own, so viewers warn about it, but the tailnet encrypts the traffic. The password is in `~/.config/frametop-remote/vnc-password` and VNC limits it to 8 characters. To change it, delete that folder and restart the desktop.
No VNC server can capture KWin on SteamOS directly: `krfb` needs `xdg-desktop-portal-kde`, which SteamOS doesn't ship, and `wayvnc` only works with wlroots compositors. So `session/remote-desktop.sh` captures the desktop with KDE's `krdpserver --plasma` on `127.0.0.1:3390`, and `session/vnc-bridge.sh` runs TigerVNC's `Xvnc` on display `:20` with a full-screen FreeRDP client inside it and serves that. Both run in the `dev` container, and the extra hop adds a little latency.
No VNC server can capture KWin on SteamOS directly: `krfb` needs `xdg-desktop-portal-kde`, which SteamOS doesn't ship, and `wayvnc` only works with wlroots compositors. So `session/remote-desktop.sh` captures the desktop with KDE's `krdpserver --plasma` on `127.0.0.1:3390`, and `session/vnc-bridge.sh` runs TigerVNC's `Xvnc` on display `:20` with a FreeRDP client inside it and serves that. Both run in the `dev` container, and the extra hop adds a little latency. krdp streams every screen; the VNC screen is the primary's size, and the FreeRDP window is shifted so the primary fills it (`ft-layout remote-view` gives the offset). krdp's own `--monitor` would stream just one screen, but it maps the pointer as if that screen sat at 0,0, so clicks would miss. When the layout changes, the VNC screen resizes and FreeRDP reconnects within a few seconds.
With remote access on, the nested KWin runs with `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`, so any app in the Frametop desktop could capture its screen or inject input. This applies only to that desktop, not the stock one. Port 3389 is SteamOS's own `xrdp`, which starts a separate X11 session rather than showing the VR desktop.
With remote access on, the nested KWin runs with `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` and `KWIN_SCREENSHOT_NO_PERMISSION_CHECKS=1`, so any app in the Frametop desktop could capture its screens or inject input. The second one lets scripts take screenshots through KWin's `org.kde.KWin.ScreenShot2` D-Bus interface. This applies only to that desktop, not the stock one. Port 3389 is SteamOS's own `xrdp`, which starts a separate X11 session rather than showing the VR desktop.
## Limits
+29 -5
View File
@@ -50,6 +50,7 @@ Usage (on the Frame host; Frametop Display Settings calls it too):
ft-layout scale per-screen scale, positions (as the screens are around
you), and primary to KWin
ft-layout screen-args ft-screens' --screen arguments for the session script
ft-layout remote-view the primary screen's place in the workspace, for the VNC bridge
ft-layout toggle hide or show all screens (ft-screens)
ft-layout pin all|N left|right|head pin screens to a wrist or your head as they are;
unpin all|N
@@ -754,6 +755,13 @@ def send_scales(outs):
log(f"scale: {e}")
def logical_size(o):
"""An output's size in logical units. kscreen's "size" is in pixels (already turned for a
rotation); positions are logical, the pixels divided by the scale (KWin rounds up)."""
s = float(o.get("scale", 1))
return (math.ceil(o["size"]["width"] / s - 1e-6), math.ceil(o["size"]["height"] / s - 1e-6))
def apply_scales():
"""Per-screen scale and rotation, positions side by side, and the primary screen (the
taskbar goes there) to KWin, which keeps them in the session's config."""
@@ -780,11 +788,7 @@ def apply_scales():
# the pointer and dragged windows cross to the screen you see next to this one.
outs = outputs(env)
send_scales(outs)
# kscreen's "size" is in pixels (already turned for a rotation); positions are in
# logical units, the pixels divided by the scale (KWin rounds up).
sizes = [(math.ceil(o["size"]["width"] / float(o.get("scale", 1)) - 1e-6),
math.ceil(o["size"]["height"] / float(o.get("scale", 1)) - 1e-6))
for o in outs if o.get("size")]
sizes = [logical_size(o) for o in outs if o.get("size")]
if len(sizes) == len(outs) and outs:
columns = arrangement(len(outs))
if not columns or sorted(i for c in columns for i in c) != list(range(len(outs))):
@@ -818,6 +822,24 @@ def kwin_follow():
log(f"kwin: {e}")
def remote_view():
"""Where the primary screen sits in the workspace (all screens' bounding box), in
logical units: "x y width height workspace_width workspace_height". The VNC bridge
(session/vnc-bridge.sh) shows that part of krdp's workspace stream."""
env = nested_env()
if not env:
raise RuntimeError("the Frametop desktop isn't running")
outs = [o for o in outputs(env) if o.get("enabled", True) and o.get("size") and o.get("pos")]
if not outs:
raise RuntimeError("the Frametop desktop has no screens yet")
rects = [(o["pos"]["x"], o["pos"]["y"], *logical_size(o)) for o in outs]
left, top = min(r[0] for r in rects), min(r[1] for r in rects)
right, bottom = max(r[0] + r[2] for r in rects), max(r[1] + r[3] for r in rects)
p = next((i for i, o in enumerate(outs) if o.get("priority") == 1), 0)
x, y, w, h = rects[p]
return f"{x - left} {y - top} {w} {h} {right - left} {bottom - top}"
def main(argv):
if len(argv) < 2 or argv[1] in ("-h", "--help"):
print(__doc__.split("Usage")[1].split("\n", 1)[1])
@@ -829,6 +851,8 @@ def main(argv):
print(json.dumps(plan(layout, screen_count(layout))))
elif cmd == "screen-args":
print(screen_args())
elif cmd == "remote-view":
print(remote_view())
elif cmd == "toggle":
log(screens_socket().ask("toggle"))
elif cmd == "layouts":
+5 -4
View File
@@ -169,13 +169,14 @@ export XDG_STATE_HOME=$HOME/.local/state/frametop
mkdir -p "$XDG_CONFIG_HOME" "$XDG_STATE_HOME"
# Remote desktop over VNC: session/remote-desktop.sh captures the desktop with
# krdp on 127.0.0.1, and session/vnc-bridge.sh re-serves it over VNC. krdpserver runs from the container, so KWin can't
# krdp on 127.0.0.1, and session/vnc-bridge.sh re-serves its primary screen over VNC. krdpserver runs from the container, so KWin can't
# match it to an installed app. KWin's permission check for screencast and fake
# input is turned off for this nested session only.
# input is turned off for this nested session only, and so is the check on KWin's
# D-Bus screenshot interface, which scripts use to see the screens without the headset.
if [ "$remote" = 1 ]; then
export KWIN_WAYLAND_NO_PERMISSION_CHECKS=1
export KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 KWIN_SCREENSHOT_NO_PERMISSION_CHECKS=1
"$here/remote-desktop.sh" "$runtime" > /tmp/frametop-remote.log 2>&1 &
"$here/vnc-bridge.sh" "$width" "$height" > /tmp/frametop-vnc.log 2>&1 &
"$here/vnc-bridge.sh" > /tmp/frametop-vnc.log 2>&1 &
fi
# ft-floatd (floating windows) runs inside the Plasma session, on its D-Bus: started from
+64 -18
View File
@@ -1,14 +1,19 @@
#!/bin/bash
# Runs on the Frame host. Serves the Frametop desktop over VNC for clients
# like RealVNC Viewer or macOS Screen Sharing. No VNC server here can capture
# KWin directly, so this bridges through krdp: Xvnc (a virtual X screen served
# over VNC) runs a full-screen FreeRDP client connected to krdpserver on
# 127.0.0.1. Both run in the dev container. VNC listens on the tailnet address only.
# Runs on the Frame host. Serves the Frametop desktop's primary screen (the one with the
# taskbar) over VNC for clients like RealVNC Viewer or macOS Screen Sharing. No VNC server
# here can capture KWin directly, so this bridges through krdp: Xvnc (a virtual X screen
# served over VNC) runs a FreeRDP client connected to krdpserver on 127.0.0.1. Both run in
# the dev container. VNC listens on the tailnet address only.
# Started by frametop-session.sh when REMOTE=1, after remote-desktop.sh.
#
# krdp streams the whole workspace (every screen). Its --monitor would stream one screen,
# but krdp 6.7 then maps the pointer as if that screen sat at 0,0, so clicks miss on a
# screen placed lower or further right. Instead the VNC screen is the primary's size, and
# the workspace-sized RDP window inside it is shifted so the primary fills it. The pointer
# maps 1:1. When the layout changes, the VNC screen resizes and the RDP client reconnects.
set -eu
width=${1:-1920}
height=${2:-1080}
here=$(dirname "$(readlink -f "$0")")
vnc_port=${VNC_PORT:-5900}
rdp_port=${RDP_PORT:-3390}
display=:20
@@ -32,24 +37,65 @@ for _ in $(seq 60); do
sleep 1
done
# "x y width height workspace_width workspace_height" of the primary screen, once Plasma is up.
view() { "$here/../layout/ft-layout" remote-view 2>/dev/null | grep -xE '[0-9]+( [0-9]+){5}'; }
v=
for _ in $(seq 90); do
v=$(view) && [ -n "$v" ] && break
v=
sleep 1
done
if [ -z "$v" ]; then
echo "couldn't read the desktop's screens, not starting VNC" >&2
exit 1
fi
read -r _ _ w h _ _ <<< "$v"
export XDG_RUNTIME_DIR=/run/user/$(id -u)
exec ~/.local/bin/distrobox enter dev -- bash -c '
set -eu
creds=$1 addr=$2 vnc_port=$3 rdp_port=$4 display=$5 width=$6 height=$7
vncpasswd -f < "$creds/vnc-password" > "$creds/vnc-passwd.bin"
chmod 600 "$creds/vnc-passwd.bin"
Xvnc "$display" -geometry "${width}x${height}" -depth 24 \
box() { "$HOME/.local/bin/distrobox" enter dev -- "$@"; }
stop_rdp() { pkill -f "[x]freerdp /v:127.0.0.1:$rdp_port " 2>/dev/null || true; }
trap 'stop_rdp; pkill -f "[X]vnc $display " 2>/dev/null || true' EXIT
box bash -c 'vncpasswd -f < "$1/vnc-password" > "$1/vnc-passwd.bin" && chmod 600 "$1/vnc-passwd.bin"' - "$creds"
box Xvnc "$display" -geometry "${w}x${h}" -depth 24 \
-interface "$addr" -rfbport "$vnc_port" \
-SecurityTypes VncAuth -PasswordFile "$creds/vnc-passwd.bin" \
-AlwaysShared -desktop "Steam Frame (Frametop)" &
xvnc=$!
trap "kill $xvnc 2>/dev/null" EXIT
sleep 2
# Keep an RDP connection open inside the VNC screen. Reconnect if it drops.
# Keep an RDP connection open inside the VNC screen. Reconnect if it drops or the layout changes.
# /cert:ignore is fine here: the connection never leaves this host.
while kill -0 $xvnc 2>/dev/null; do
DISPLAY=$display xfreerdp /v:"127.0.0.1:$rdp_port" /u:steamos /p:"$(cat "$creds/password")" \
/cert:ignore /size:"${width}x${height}" -decorations /f +clipboard >/dev/null 2>&1 || true
read -r x y w h ww wh <<< "$v"
box env DISPLAY=$display bash -c '
size=$1 x=$2 y=$3 ww=$4 wh=$5 creds=$6 rdp_port=$7
if [ "$(xrandr | sed -n "s/.*current \([0-9]*\) x \([0-9]*\),.*/\1x\2/p")" != "$size" ]; then
xrandr --newmode "$size" 0 "${size%x*}" 0 0 0 "${size#*x}" 0 0 0 2>/dev/null || true
xrandr --addmode VNC-0 "$size" 2>/dev/null || true
xrandr --fb "$size" --output VNC-0 --mode "$size"
fi
xfreerdp /v:127.0.0.1:"$rdp_port" /u:steamos /p:"$(cat "$creds/password")" \
/cert:ignore /size:"${ww}x${wh}" -decorations +clipboard >/dev/null 2>&1 &
rdp=$!
# FreeRDP takes no negative position, so move its window once it is up.
for _ in $(seq 60); do
win=$(xdotool search --class xfreerdp 2>/dev/null | tail -1)
[ -n "$win" ] && break
sleep 0.5
done
[ -n "$win" ] && xdotool windowmove "$win" "$((-x))" "$((-y))"
wait $rdp
' vnc-rdp "${w}x$h" "$x" "$y" "$ww" "$wh" "$creds" "$rdp_port" || true &
rdp=$!
while kill -0 $rdp 2>/dev/null; do
sleep 5
now=$(view) || continue
[ -n "$now" ] && [ "$now" != "$v" ] || continue
echo "layout changed: $v -> $now"
v=$now
stop_rdp
done
wait $rdp 2>/dev/null || true
sleep 2
done
' vnc-bridge "$creds" "$addr" "$vnc_port" "$rdp_port" "$display" "$width" "$height"
+1 -1
View File
@@ -24,7 +24,7 @@ packages=(
# Frametop Input Settings app (Kirigami, PySide6)
python3-pyside6 kf6-kirigami kf6-qqc2-desktop-style qt6-qtwayland breeze-icon-theme plasma-breeze
# Frametop remote desktop (VNC bridge through krdp)
krdp freerdp tigervnc-x11-server
krdp freerdp tigervnc-x11-server xrandr
# diagnostics and remote UI testing
wayland-utils xorg-x11-server-Xvfb ImageMagick xdotool
)