mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 08:00:09 +02:00
Frametop: a multi-screen desktop and universal 3D mouse for the Steam Frame
Several KDE Plasma screens floating in SteamVR, each a real monitor of any resolution and shape, shown by our own compositor (ft-screens), with a layout, wrist pinning, and visibility modes; a Bluetooth mouse that drives all of SteamVR as a room-anchored 3D pointer (input relay, ft-pointer helper, ft_pointer SteamVR driver); two settings apps; and Bluetooth LE fixes. Installs on the headset with ./install.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d439bc3f25
61 files changed
+8785
No files matched your search
@@ -0,0 +1,17 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Desktop
|
||||
Comment=Plasma desktop across several VR screens (Frametop)
|
||||
Exec=@SESSION@
|
||||
Categories=Utility;
|
||||
|
||||
# Installed by desktops.sh install into ~/.local/share/applications,
|
||||
# where it overrides /usr/share/applications/deckard-nested-desktop.desktop.
|
||||
# Remove it with desktops.sh uninstall to get the stock desktop back.
|
||||
Icon=desktop
|
||||
X-Steam-Library-Capsule=/usr/share/applications/steam/steamos-nested-desktop/capsule.png
|
||||
X-Steam-Library-Hero=/usr/share/applications/steam/steamos-nested-desktop/hero.png
|
||||
X-Steam-Library-Logo=/usr/share/applications/steam/steamos-nested-desktop/logo.png
|
||||
X-Steam-Library-StoreCapsule=/usr/share/applications/steam/steamos-nested-desktop/store-logo.png
|
||||
X-Steam-Controller-Template=Desktop
|
||||
X-Steam-Special=Desktop
|
||||
Executable
+135
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# Runs on the Frame host (not in the container). Starts a Plasma desktop with several
|
||||
# screens, each its own SteamVR panel. Modeled on /usr/bin/steamos-nested-desktop, which
|
||||
# does the same with one screen in gamescope.
|
||||
#
|
||||
# Backends (BACKEND in ~/.config/frametop.conf):
|
||||
# screens (default) ft-screens (screens), our own compositor: KWin opens a
|
||||
# window per screen, ft-screens gives each its size (from the layout, see
|
||||
# layout) and shows it as its own panel. Any resolution and shape.
|
||||
# gamescope gamescope in PerWindow mode: every screen one size, at most 1920x1080
|
||||
# worth of pixels, panels owned by the SteamVR dashboard.
|
||||
#
|
||||
# Settings come from ~/.config/frametop.conf (see frametop.conf.example) and, for the
|
||||
# screens backend, ~/.config/frametop-layout.json (Frametop Display Settings writes both).
|
||||
# FT_BACKEND, FT_SCREENS, FT_WIDTH, FT_HEIGHT, and FT_PHYS_WIDTH override them.
|
||||
set -eu
|
||||
|
||||
here=$(dirname "$(readlink -f "$0")")
|
||||
conf=$HOME/.config/frametop.conf
|
||||
BACKEND=screens SCREENS=2 WIDTH=1920 HEIGHT=1080 PHYS_WIDTH=1.6 REMOTE=0
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$conf" ] && . "$conf"
|
||||
backend=${FT_BACKEND:-$BACKEND}
|
||||
screens=${FT_SCREENS:-$SCREENS}
|
||||
width=${FT_WIDTH:-$WIDTH}
|
||||
height=${FT_HEIGHT:-$HEIGHT}
|
||||
phys_width=${FT_PHYS_WIDTH:-$PHYS_WIDTH}
|
||||
remote=${FT_REMOTE:-$REMOTE}
|
||||
if [ "$backend" = gamescope ] && [ $((width * height)) -gt $((1920 * 1080)) ]; then
|
||||
# gamescope's VR backend aborts above 1920x1080 worth of pixels (its upload buffer;
|
||||
# see docs/design.md). Shrink a bigger size to fit, keeping its shape.
|
||||
read -r width height < <(awk -v w="$width" -v h="$height" 'BEGIN { k = sqrt(1920 * 1080 / (w * h));
|
||||
printf "%d %d\n", int(w * k / 8) * 8, int(h * k / 8) * 8 }')
|
||||
echo "frametop: resolution too big for gamescope's VR mode; using ${width}x${height}" >&2
|
||||
fi
|
||||
|
||||
if [ "${1:-}" != --inner ]; then
|
||||
# One instance at a time. The launcher can be clicked twice.
|
||||
if pgrep -f '[v]r-overlay-key frametop ' >/dev/null || pgrep -x ft-screens >/dev/null; then
|
||||
echo "Frametop is already running" >&2
|
||||
exit 0
|
||||
fi
|
||||
set -a; . /usr/share/deckard/mesavars.sh; set +a
|
||||
# Flatpak apps (Chromium) publish their launcher entries under the Flatpak
|
||||
# exports dirs. SSH and launcher environments may lack XDG_DATA_DIRS, and then
|
||||
# Plasma can't find them and opens Discover instead.
|
||||
export XDG_DATA_DIRS=${XDG_DATA_DIRS:-/usr/local/share:/usr/share}
|
||||
set +u; . /etc/profile.d/flatpak.sh; set -u
|
||||
unset LD_PRELOAD
|
||||
# Arrange the screens in the saved layout once they're up (layout; skipped
|
||||
# when auto-arrange is off).
|
||||
setsid "$here/../layout/ft-layout" apply --wait 90 > /tmp/frametop-layout.log 2>&1 < /dev/null &
|
||||
|
||||
if [ "$backend" = gamescope ]; then
|
||||
export ENABLE_GAMESCOPE_WSI=1 GAMESCOPE_MANGOAPP_SOCKET_DISABLE=1
|
||||
exec gamescope --backend openvr --virtual-connector-strategy PerWindow \
|
||||
-W "$width" -H "$height" -w "$width" -h "$height" \
|
||||
--vr-overlay-key frametop --vr-overlay-default-name frametop \
|
||||
--vr-overlay-physical-width "$phys_width" \
|
||||
--vr-overlay-show-immediately --vr-overlay-enable-click-stabilization \
|
||||
--vr-overlay-enable-control-bar --vr-overlay-enable-control-bar-keyboard \
|
||||
--expose-wayland \
|
||||
--cursor-hotspot 5,3 --cursor /usr/share/steamos/steamos-cursor.png \
|
||||
-- "$0" --inner
|
||||
fi
|
||||
|
||||
# ft-screens runs in the dev container (it's built against Fedora's wlroots); KWin and
|
||||
# Plasma stay on the host and connect to its socket.
|
||||
socket=ft-screens-0
|
||||
read -ra screen_args <<< "$("$here/../layout/ft-layout" screen-args)"
|
||||
export FT_SCREEN_COUNT=$(( ${#screen_args[@]} / 2 ))
|
||||
"$HOME/.local/bin/distrobox" enter dev -- "$here/../screens/build/ft-screens" --socket "$socket" \
|
||||
"${screen_args[@]}" > /tmp/frametop-screens.log 2>&1 < /dev/null &
|
||||
stop_screens() { pkill -x ft-screens 2>/dev/null || true; }
|
||||
trap stop_screens EXIT
|
||||
for _ in $(seq 100); do [ -S "$XDG_RUNTIME_DIR/$socket" ] && break; sleep 0.2; done
|
||||
[ -S "$XDG_RUNTIME_DIR/$socket" ] || { echo "ft-screens didn't start (see /tmp/frametop-screens.log)" >&2; exit 1; }
|
||||
WAYLAND_DISPLAY=$XDG_RUNTIME_DIR/$socket "$0" --inner
|
||||
exit
|
||||
fi
|
||||
|
||||
# Inside the host compositor (ft-screens or gamescope) from here on.
|
||||
unset LD_PRELOAD XDG_DESKTOP_PORTAL_DIR
|
||||
[ "$backend" = gamescope ] || screens=${FT_SCREEN_COUNT:-$screens}
|
||||
|
||||
host_runtime=$XDG_RUNTIME_DIR
|
||||
runtime=$host_runtime/frametop
|
||||
|
||||
cleanup() {
|
||||
pkill -f '[k]rdpserver --plasma' 2>/dev/null || true
|
||||
pkill -f '[X]vnc :20 ' 2>/dev/null || true
|
||||
pkill -f '[x]freerdp /v:.*:3390' 2>/dev/null || true
|
||||
fusermount3 -u -z "$runtime/doc" 2>/dev/null || true
|
||||
umount --recursive "$runtime" 2>/dev/null || true
|
||||
rm -rf "$runtime"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
mkdir -m 0700 "$runtime" "$runtime/pulse" "$runtime/bin"
|
||||
ln -s "$host_runtime/pulse/native" "$runtime/pulse/native"
|
||||
ln -s "$host_runtime"/pipewire* "$runtime/"
|
||||
|
||||
# plasma-session starts KWin through kwin_wayland_wrapper. Shadow it to add our outputs.
|
||||
# With ft-screens the size is only the starting one: ft-screens sets each screen's own.
|
||||
cat > "$runtime/bin/kwin_wayland_wrapper" <<EOF
|
||||
#!/bin/sh
|
||||
exec /usr/bin/kwin_wayland_wrapper --width $width --height $height --output-count $screens --no-lockscreen "\$@"
|
||||
EOF
|
||||
chmod +x "$runtime/bin/kwin_wayland_wrapper"
|
||||
export PATH=$runtime/bin:$PATH
|
||||
|
||||
# Keep the host compositor's Wayland socket reachable after moving XDG_RUNTIME_DIR.
|
||||
case ${WAYLAND_DISPLAY:-gamescope-0} in
|
||||
/*) ;;
|
||||
*) export WAYLAND_DISPLAY=$host_runtime/${WAYLAND_DISPLAY:-gamescope-0} ;;
|
||||
esac
|
||||
export XDG_RUNTIME_DIR=$runtime
|
||||
|
||||
# Separate Plasma/KWin config and state, so this session and the built-in
|
||||
# desktop never overwrite each other's screen layout or panels.
|
||||
export XDG_CONFIG_HOME=$HOME/.config/frametop
|
||||
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
|
||||
# match it to an installed app. KWin's permission check for screencast and fake
|
||||
# input is turned off for this nested session only.
|
||||
if [ "$remote" = 1 ]; then
|
||||
export KWIN_WAYLAND_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 &
|
||||
fi
|
||||
|
||||
dbus-run-session startplasma-wayland
|
||||
@@ -0,0 +1,22 @@
|
||||
# Frametop settings, read by session/frametop-session.sh on the Frame.
|
||||
# Installed to ~/.config/frametop.conf. Environment variables with an FT_ prefix override these.
|
||||
BACKEND=screens # screens: ft-screens, each screen its own resolution and size (set in Frametop Display Settings,
|
||||
# saved in ~/.config/frametop-layout.json) | gamescope: the old path, settings below
|
||||
SCREENS=2 # gamescope: number of desktop screens (VR panels)
|
||||
WIDTH=1920 # gamescope: pixels per screen (at most 1920x1080 worth)
|
||||
HEIGHT=1080
|
||||
PHYS_WIDTH=1.6 # gamescope: panel width in metres, docked
|
||||
REMOTE=0 # 1 = serve the desktop over VNC on the tailnet (port 5900; see README)
|
||||
POINTER=0 # 1 = the mouse drives the universal 3D pointer (ft_pointer driver) instead of a plain mouse
|
||||
POINTER_SENSITIVITY=0.03 # degrees per mouse count
|
||||
POINTER_IDLE=30 # seconds without mouse use before the controllers get their laser back
|
||||
POINTER_WAKE_COUNTS=40 # mouse counts within 1 s needed to wake or re-claim the laser (ignores desk jitter)
|
||||
POINTER_DISTANCE=1.5 # metres to the cursor when it isn't on a panel
|
||||
POINTER_CURSOR_DEG=0.4 # size of the free-space dot, in degrees
|
||||
POINTER_ORIGIN_FRACTION=0.95 # laser starts this far along eye->cursor: its beam is a few cm, SteamVR's hit dot tiny
|
||||
POINTER_LASER_WIDTH=0.8 # controller beam width (dashboard.laserRayWidthScale), set once at helper start
|
||||
POINTER_ORIGIN_MARGIN=0.15 # the laser starts at least this far (m) in front of its target, so small floating controls stay hittable
|
||||
POINTER_SCENE_RADIUS=0.5 # texture-less dashboard overlays (dock, window controls): hit radius (m) around their origin
|
||||
POINTER_EDGE_REACH=0.3 # just off a panel, the cursor stays on its plane this far (m), to reach its resize edges and window controls
|
||||
LAYOUT_GRAB_OFFSET=0.075 # arranging screens: where the floating window's grab bar is, in metres below the screen
|
||||
LAYOUT_SLIDE_SPEED=0.5 # arranging screens: how fast the invisible controller slides a grabbed screen (m/s)
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Runs on the Frame host just before the desktop stops (desktops.sh stop). Moves the
|
||||
# programs started inside the desktop out of its systemd unit (frametop-desktop) into a
|
||||
# scope of their own, so stopping the unit (which kills everything left in it) only ends
|
||||
# the desktop itself. Windows still close: a GUI app exits when its compositor goes.
|
||||
# Background work started from the desktop keeps running: servers, agents, tmux, builds.
|
||||
unit=frametop-desktop.service
|
||||
cg=$(systemctl --user show -p ControlGroup --value "$unit" 2>/dev/null)
|
||||
[ -n "$cg" ] && [ -r "/sys/fs/cgroup$cg/cgroup.procs" ] || exit 0
|
||||
|
||||
# The desktop's own processes stay in the unit and stop with it: the session, KWin,
|
||||
# Plasma, and the session services it started (portals, input methods, kded, wallet...).
|
||||
own='^(frametop-sessi|dbus-|startplasma|plasma|kwin|Xwayland|ksmserver|krdpserver|Xvnc|xfreerdp|ft-layout|'
|
||||
own+='kded|kactivitymanage|kaccess|kglobalaccel|kscreen|kwalletd|ksecretd|polkit-kde|org_kde_|baloo|'
|
||||
own+='xembedsniproxy|gmenudbusmenu|DiscoverNotifie|kimpanel|ibus|xdg-|at-spi|dconf-service|fusermount|agent)'
|
||||
keep=()
|
||||
while read -r pid; do
|
||||
comm=$(cat "/proc/$pid/comm" 2>/dev/null) || continue
|
||||
[[ $comm =~ $own ]] && continue
|
||||
# ft-screens' launcher (distrobox enter, podman exec) stays too.
|
||||
tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q -e 'ft-screens' -e 'ft-layout' -e 'frametop-session' && continue
|
||||
keep+=("$pid")
|
||||
done < "/sys/fs/cgroup$cg/cgroup.procs"
|
||||
[ ${#keep[@]} -gt 0 ] || exit 0
|
||||
|
||||
scope=frametop-apps-$(date +%s).scope
|
||||
if busctl --user call org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager \
|
||||
StartTransientUnit 'ssa(sv)a(sa(sv))' "$scope" fail 3 \
|
||||
PIDs au "${#keep[@]}" "${keep[@]}" \
|
||||
Description s "Programs started in the Frametop desktop, kept past its restart" \
|
||||
CollectMode s inactive-or-failed 0 >/dev/null; then
|
||||
echo "kept ${#keep[@]} process(es) in $scope"
|
||||
else
|
||||
echo "couldn't move the desktop's programs out of $unit; they stop with it" >&2
|
||||
fi
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Runs on the Frame host. Internal capture server for remote access: KRdp's
|
||||
# krdpserver (from the dev container) talks to the nested KWin directly
|
||||
# (--plasma, no desktop portal) and serves it over RDP on 127.0.0.1 only.
|
||||
# Nothing outside the Frame can reach it. vnc-bridge.sh connects to it and
|
||||
# re-serves the desktop over VNC. Started by frametop-session.sh when REMOTE=1.
|
||||
set -eu
|
||||
|
||||
runtime=${1:?usage: remote-desktop.sh <nested XDG_RUNTIME_DIR>}
|
||||
# 3389 is taken by SteamOS's own xrdp (a separate X11 session, not the VR desktop).
|
||||
port=${RDP_PORT:-3390}
|
||||
creds=$HOME/.config/frametop-remote
|
||||
|
||||
mkdir -p -m 0700 "$creds"
|
||||
if [ ! -s "$creds/password" ]; then
|
||||
(umask 077; head -c 24 /dev/urandom | base64 | tr -d '/+=' | cut -c1-20 > "$creds/password")
|
||||
fi
|
||||
if [ ! -s "$creds/cert.pem" ]; then
|
||||
(umask 077; openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj /CN=steam-frame \
|
||||
-keyout "$creds/key.pem" -out "$creds/cert.pem" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Wait for the nested KWin to come up.
|
||||
for _ in $(seq 60); do
|
||||
[ -S "$runtime/wayland-0" ] && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# podman needs the real runtime dir. The nested one is passed only to krdpserver.
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
exec ~/.local/bin/distrobox enter dev -- env XDG_RUNTIME_DIR="$runtime" WAYLAND_DISPLAY=wayland-0 QT_QPA_PLATFORM=wayland \
|
||||
krdpserver --plasma --address 127.0.0.1 --port "$port" \
|
||||
-u steamos -p "$(cat "$creds/password")" \
|
||||
--certificate "$creds/cert.pem" --certificate-key "$creds/key.pem"
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/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.
|
||||
# Started by frametop-session.sh when REMOTE=1, after remote-desktop.sh.
|
||||
set -eu
|
||||
|
||||
width=${1:-1920}
|
||||
height=${2:-1080}
|
||||
vnc_port=${VNC_PORT:-5900}
|
||||
rdp_port=${RDP_PORT:-3390}
|
||||
display=:20
|
||||
creds=$HOME/.config/frametop-remote
|
||||
|
||||
addr=$(ip -4 -o addr show tailscale0 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
|
||||
if [ -z "$addr" ]; then
|
||||
echo "tailscale0 has no address, not starting VNC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The VNC password is limited to 8 characters by the protocol. The traffic is
|
||||
# still encrypted by the tailnet (WireGuard).
|
||||
if [ ! -s "$creds/vnc-password" ]; then
|
||||
(umask 077; head -c 12 /dev/urandom | base64 | tr -d '/+=' | cut -c1-8 > "$creds/vnc-password")
|
||||
fi
|
||||
|
||||
# Wait for krdpserver (started by remote-desktop.sh).
|
||||
for _ in $(seq 60); do
|
||||
ss -ltn | grep -q "127.0.0.1:$rdp_port " && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
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 \
|
||||
-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.
|
||||
# /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
|
||||
sleep 2
|
||||
done
|
||||
' vnc-bridge "$creds" "$addr" "$vnc_port" "$rdp_port" "$display" "$width" "$height"
|
||||
Reference in new issue
Block a user