From 9820e7996f12779dc10e3cb0e949713d97f3da6a Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:07:28 -0600 Subject: [PATCH] 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 (cherry picked from commit 6f5a23c80f2ecd26e6a96c86b102be36d518ee25) --- docs/reference.md | 6 +-- layout/ft_layout.py | 34 ++++++++++++--- session/frametop-session.sh | 9 ++-- session/vnc-bridge.sh | 82 +++++++++++++++++++++++++++++-------- setup/dev-container.sh | 2 +- 5 files changed, 102 insertions(+), 31 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index d69c912..62b8f1a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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 diff --git a/layout/ft_layout.py b/layout/ft_layout.py index df90e50..6a98ab9 100755 --- a/layout/ft_layout.py +++ b/layout/ft_layout.py @@ -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": diff --git a/session/frametop-session.sh b/session/frametop-session.sh index 470e090..fdee47a 100755 --- a/session/frametop-session.sh +++ b/session/frametop-session.sh @@ -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 diff --git a/session/vnc-bridge.sh b/session/vnc-bridge.sh index 0be4ff3..5dcf7fc 100755 --- a/session/vnc-bridge.sh +++ b/session/vnc-bridge.sh @@ -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" diff --git a/setup/dev-container.sh b/setup/dev-container.sh index e5b6188..4442188 100755 --- a/setup/dev-container.sh +++ b/setup/dev-container.sh @@ -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 )