diff --git a/README.md b/README.md index 2067813..0158a91 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ This is an early release, tested on one Steam Frame (SteamOS 0.3.0 build 2026092 - Typing follows your last click. A controller click on a panel other than the screens (the dashboard, a Steam app) doesn't move typing there; click it with the mouse, or click a screen to bring typing back. - The screens don't draw a mouse cursor of their own. The 3D mouse's dot or SteamVR's laser shows where you're pointing. - On SteamVR's Settings page, the 3D mouse shows a laser beam and a larger hit dot, like a controller. SteamVR doesn't tell other programs where that page is (unlike Steam's pages, such as Library), so the mouse used to miss most of it: clicks went through to a desktop screen behind, and the dot disappeared. As a workaround, on that page only, the laser starts near your eye and SteamVR finds the page itself. See docs/design.md. -- Remote desktop over VNC (`./desktops.sh remote on`) needs Tailscale on the Frame. +- Remote desktop over VNC (Frametop Remote Access in the app menu, or `./desktops.sh remote on`) needs Tailscale on the Frame. It shows the primary screen only. The app turns it on and off, shows the address, and shows, copies, or changes the VNC password. The password is made at random on the Frame and kept in `~/.config/frametop-remote` (only you can read it); VNC limits it to 8 characters, and the tailnet encrypts the connection. Turning it on in a desktop that started with it off takes a desktop restart. - Turning the displays off on a stand only turns their backlight off. SteamVR has no way for other programs to put the headset in standby, so tracking and rendering keep running, and the headset draws nearly its full power. ## Reporting problems diff --git a/install.sh b/install.sh index e718f1b..8e216c8 100755 --- a/install.sh +++ b/install.sh @@ -78,6 +78,7 @@ step "7/9 multi-screen desktop (ft-screens), Frametop Input Settings, and Framet "$root/desktops.sh" install >/dev/null "$root/input-settings/install.sh" "$root/display-settings/install.sh" +"$root/remote/install.sh" on_frame "sed -i 's/^POINTER=0/POINTER=1/' ~/.config/frametop.conf; grep -q '^POINTER=' ~/.config/frametop.conf || echo 'POINTER=1' >> ~/.config/frametop.conf" echo "the launcher's Desktop entry now opens the multi-screen desktop; 3D mouse on (POINTER=1 in ~/.config/frametop.conf)" diff --git a/remote/ft-remote-settings b/remote/ft-remote-settings new file mode 100755 index 0000000..2e8ef25 --- /dev/null +++ b/remote/ft-remote-settings @@ -0,0 +1,244 @@ +#!/usr/bin/python3 +"""Frametop Remote Access: settings for seeing and using the Frametop desktop from another +computer on your tailnet, over VNC (session/remote-desktop.sh and session/vnc-bridge.sh). + +A GTK 4 / libadwaita app on the Frame host's own Python (like the gaze probe). It turns remote +access on and off (REMOTE in ~/.config/frametop.conf, applied now through +session/remote-ctl.sh when this desktop allows it), shows the address to connect to, and shows, +copies, or replaces the VNC password (~/.config/frametop-remote/vnc-password). Nothing is +stored anywhere else, and no password is in the code: the first start makes a random one. +""" +import os +import secrets +import string +import subprocess +import sys + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402 + +REPO = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +CTL = os.path.join(REPO, "session", "remote-ctl.sh") +CONF = os.path.expanduser("~/.config/frametop.conf") +CREDS = os.path.expanduser("~/.config/frametop-remote") +VNC_PASSWORD = os.path.join(CREDS, "vnc-password") +HIDDEN = "••••••••" + + +def read_conf(key, default=""): + value = default + try: + with open(CONF) as f: + for line in f: + line = line.split("#", 1)[0].strip() + if line.startswith(key + "="): + value = line.split("=", 1)[1].strip() + except OSError: + pass + return value + + +def write_conf(key, value): + """Set KEY=value in frametop.conf, keeping its comments and the rest of the file.""" + try: + with open(CONF) as f: + lines = f.read().splitlines() + except OSError: + lines = [] + for i, line in enumerate(lines): + if line.split("#", 1)[0].strip().startswith(key + "="): + comment = line[line.index("#"):] if "#" in line else "" + lines[i] = f"{key}={value}" + (f" {comment}" if comment else "") + break + else: + lines.append(f"{key}={value}") + with open(CONF, "w") as f: + f.write("\n".join(lines) + "\n") + + +def read_password(): + try: + with open(VNC_PASSWORD) as f: + return f.read().strip() + except OSError: + return "" + + +def new_password(): + """8 random letters and digits: VNC's own authentication takes at most 8 characters.""" + os.makedirs(CREDS, mode=0o700, exist_ok=True) + pw = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8)) + fd = os.open(VNC_PASSWORD, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(pw + "\n") + return pw + + +class Window(Adw.ApplicationWindow): + def __init__(self, app): + super().__init__(application=app, title="Frametop Remote Access", default_width=560, default_height=620) + self.status = {} + self.revealed = False + self.setting = False # the switch is being set from the status, not by the user + + self.toasts = Adw.ToastOverlay() + view = Adw.ToolbarView() + view.add_top_bar(Adw.HeaderBar()) + view.set_content(self.toasts) + self.set_content(view) + page = Adw.PreferencesPage() + self.toasts.set_child(page) + + access = Adw.PreferencesGroup( + title="Remote access", + description="See and use the Frametop desktop from another computer on your tailnet, with any VNC " + "viewer (RealVNC Viewer, TigerVNC, or macOS Screen Sharing). It shows the primary " + "screen, the one with the taskbar, and follows it when you change the layout.") + page.add(access) + self.switch = Adw.SwitchRow(title="Remote access over VNC") + self.switch.connect("notify::active", self.on_switch) + access.add(self.switch) + self.address = Adw.ActionRow(title="Address", subtitle="…", subtitle_selectable=True) + copy = Gtk.Button(icon_name="edit-copy-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Copy the address") + copy.add_css_class("flat") + copy.connect("clicked", lambda *_: self.copy(self.address_text(), "Address copied")) + self.address.add_suffix(copy) + access.add(self.address) + + secret = Adw.PreferencesGroup( + title="Password", + description="VNC takes at most 8 characters. The connection itself is encrypted by the tailnet, and " + "only devices on it can reach the Frame.") + page.add(secret) + self.password = Adw.ActionRow(title="VNC password", subtitle=HIDDEN, subtitle_selectable=True) + self.eye = Gtk.ToggleButton(icon_name="view-reveal-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Show") + self.eye.add_css_class("flat") + self.eye.connect("toggled", self.on_reveal) + self.password.add_suffix(self.eye) + copy_pw = Gtk.Button(icon_name="edit-copy-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Copy the password") + copy_pw.add_css_class("flat") + copy_pw.connect("clicked", lambda *_: self.copy(read_password(), "Password copied") + if read_password() else self.toast("No password yet: turn remote access on")) + self.password.add_suffix(copy_pw) + secret.add(self.password) + change = Adw.ActionRow(title="New password", + subtitle="Viewers connected now are disconnected and need the new one") + change_button = Gtk.Button(label="Change…", valign=Gtk.Align.CENTER) + change_button.connect("clicked", self.on_change) + change.add_suffix(change_button) + secret.add(change) + + about = Adw.PreferencesGroup( + title="How it works", + description="KDE's krdp captures the desktop on 127.0.0.1 only, and a VNC server on the tailnet " + "address shows its primary screen. Turning it on in a desktop that started with it off " + "takes a desktop restart (KWin allows the capture only from its start). Logs: " + "/tmp/frametop-remote.log and /tmp/frametop-vnc.log.") + page.add(about) + + self.refresh() + GLib.timeout_add_seconds(2, self.refresh) + + # --- status --- + def refresh(self): + proc = Gio.Subprocess.new([CTL, "status"], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE) + proc.communicate_utf8_async(None, None, self.on_status) + return True + + def on_status(self, proc, result): + try: + _, out, _ = proc.communicate_utf8_finish(result) + except GLib.Error: + return + self.status = dict(line.split("=", 1) for line in (out or "").splitlines() if "=" in line) + wanted = read_conf("REMOTE", "0") == "1" + running = self.status.get("running") == "1" + self.setting = True + self.switch.set_active(wanted) + self.setting = False + if not self.status.get("address"): + state = "The tailnet (tailscale0) has no address: remote access can't start" + elif running: + state = "On: waiting for viewers" + elif wanted and self.status.get("capable") != "1": + state = "Turns on at the next desktop restart" + elif wanted: + state = "Starting…" + else: + state = "Off" + self.switch.set_subtitle(state) + self.address.set_subtitle(self.address_text() or "no tailnet address") + pw = read_password() + if not pw: + self.password.set_subtitle("made when remote access first starts") + else: + self.password.set_subtitle(pw if self.revealed else HIDDEN) + + def address_text(self): + port = self.status.get("port", "5900") + name, addr = self.status.get("name"), self.status.get("address") + if name and addr: + return f"{name}:{port} ({addr})" if port != "5900" else f"{name} ({addr})" + return f"{addr}:{port}" if addr and port != "5900" else addr or "" + + # --- actions --- + def on_switch(self, row, _param): + if self.setting: + return + on = row.get_active() + write_conf("REMOTE", "1" if on else "0") + if not on: + subprocess.run([CTL, "stop"], stdin=subprocess.DEVNULL, capture_output=True) + self.toast("Remote access off") + elif subprocess.run([CTL, "start"], stdin=subprocess.DEVNULL, capture_output=True).returncode == 3: + self.toast("Remote access turns on at the next desktop restart") + else: + self.toast("Remote access on") + self.refresh() + + def on_reveal(self, button): + self.revealed = button.get_active() + button.set_icon_name("view-conceal-symbolic" if self.revealed else "view-reveal-symbolic") + button.set_tooltip_text("Hide" if self.revealed else "Show") + self.refresh() + + def on_change(self, _button): + dialog = Adw.AlertDialog(heading="Change the VNC password?", + body="Viewers connected now are disconnected and need the new password.") + dialog.add_response("cancel", "Cancel") + dialog.add_response("change", "Change") + dialog.set_response_appearance("change", Adw.ResponseAppearance.SUGGESTED) + dialog.connect("response", self.on_change_response) + dialog.present(self) + + def on_change_response(self, _dialog, response): + if response != "change": + return + new_password() + if self.status.get("running") == "1": + subprocess.run([CTL, "restart"], stdin=subprocess.DEVNULL, capture_output=True) + self.toast("New password set" + (": remote access restarted" if self.status.get("running") == "1" else "")) + self.refresh() + + def copy(self, text, done): + if text: + Gdk.Display.get_default().get_clipboard().set(text) + self.toast(done) + + def toast(self, text): + self.toasts.add_toast(Adw.Toast(title=text, timeout=3)) + + +class App(Adw.Application): + def __init__(self): + super().__init__(application_id="org.frametop.RemoteAccess", flags=Gio.ApplicationFlags.DEFAULT_FLAGS) + + def do_activate(self): + (self.props.active_window or Window(self)).present() + + +if __name__ == "__main__": + sys.exit(App().run(sys.argv)) diff --git a/remote/ft-remote-settings.desktop b/remote/ft-remote-settings.desktop new file mode 100644 index 0000000..9282144 --- /dev/null +++ b/remote/ft-remote-settings.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Frametop Remote Access +GenericName=VNC access to the VR desktop +Comment=See and use the Frametop desktop from another computer on your tailnet +Exec=@REPO@/remote/ft-remote-settings +Icon=preferences-desktop-remote-desktop +Categories=Settings;Network; +Keywords=vnc;remote;desktop;tailscale;screen sharing;frametop; diff --git a/remote/install.sh b/remote/install.sh new file mode 100755 index 0000000..49a3ec3 --- /dev/null +++ b/remote/install.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Install (or remove) Frametop Remote Access in the desktop's app menu. +# Usage: remote/install.sh [install|uninstall] +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +. "$root/scripts/_env.sh" +"$root/scripts/sync.sh" >/dev/null +apps=.local/share/applications +case ${1:-install} in + install) + fill_template "$root/remote/ft-remote-settings.desktop" | on_frame "mkdir -p ~/$apps && cat > ~/$apps/ft-remote-settings.desktop" + on_frame "chmod +x remote/ft-remote-settings session/remote-ctl.sh" + echo "installed: Frametop Remote Access" ;; + uninstall) + on_frame "rm -f ~/$apps/ft-remote-settings.desktop; echo removed" ;; + *) echo "usage: $0 [install|uninstall]" >&2; exit 2 ;; +esac diff --git a/session/frametop-session.sh b/session/frametop-session.sh index fdee47a..75ef9a1 100755 --- a/session/frametop-session.sh +++ b/session/frametop-session.sh @@ -114,9 +114,7 @@ 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 + "$here/remote-ctl.sh" stop fusermount3 -u -z "$runtime/doc" 2>/dev/null || true umount --recursive "$runtime" 2>/dev/null || true rm -rf "$runtime" @@ -173,10 +171,12 @@ mkdir -p "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" # match it to an installed app. KWin's permission check for screencast and fake # 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. +# The marker lets remote-ctl.sh (and Frametop Remote Access) start and stop it later in +# this session; a desktop started without it can't capture. if [ "$remote" = 1 ]; then 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" > /tmp/frametop-vnc.log 2>&1 & + touch "$runtime/remote-capable" + "$here/remote-ctl.sh" start fi # ft-floatd (floating windows) runs inside the Plasma session, on its D-Bus: started from diff --git a/session/frametop.conf.example b/session/frametop.conf.example index 1b85579..d01966f 100644 --- a/session/frametop.conf.example +++ b/session/frametop.conf.example @@ -6,7 +6,7 @@ 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) +REMOTE=0 # 1 = serve the desktop's primary screen over VNC on the tailnet (port 5900; Frametop Remote Access, see README) FLOAT_SLOTS=8 # screens backend: how many windows can float in VR at once (spare outputs; 0 turns it off; restart the desktop after a change) FLOAT_MARGIN=300 # floating windows: pixels around each window on its output, so menus have room past its edges POINTER=0 # 1 = the mouse drives the universal 3D pointer (ft_pointer driver) instead of a plain mouse diff --git a/session/remote-ctl.sh b/session/remote-ctl.sh new file mode 100755 index 0000000..4a478de --- /dev/null +++ b/session/remote-ctl.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Start, stop, or ask about remote access to the Frametop desktop (VNC over the tailnet): +# remote-desktop.sh (krdp capturing the desktop on 127.0.0.1) and vnc-bridge.sh (VNC of its +# primary screen). Runs on the Frame host; frametop-session.sh and Frametop Remote Access +# (remote/ft-remote-settings) use it. +# +# remote-ctl.sh start | stop | restart | status +# +# Capture only works in a desktop that started with REMOTE=1: KWin's permission checks for +# screencast and fake input are turned off when it starts, and only then. Such a session +# leaves $runtime/remote-capable; without it, start says so (exit 3) and the setting applies +# at the next desktop start. status prints key=value lines: capable, running, address, name, +# port. +set -u + +here=$(dirname "$(readlink -f "$0")") +runtime=/run/user/$(id -u)/frametop +vnc_port=${VNC_PORT:-5900} + +running() { pgrep -f '[X]vnc :20 ' >/dev/null && pgrep -f '[k]rdpserver --plasma' >/dev/null; } + +stop() { + pkill -f "[v]nc-bridge.sh" 2>/dev/null + pkill -f "[r]emote-desktop.sh" 2>/dev/null + pkill -f '[k]rdpserver --plasma' 2>/dev/null + pkill -f '[X]vnc :20 ' 2>/dev/null + pkill -f '[x]freerdp /v:127.0.0.1:' 2>/dev/null + return 0 +} + +start() { + if [ ! -e "$runtime/remote-capable" ]; then + echo "this desktop didn't start with remote access on: it applies at the next desktop start" >&2 + return 3 + fi + running && return 0 + stop + setsid "$here/remote-desktop.sh" "$runtime" > /tmp/frametop-remote.log 2>&1 < /dev/null & + setsid "$here/vnc-bridge.sh" > /tmp/frametop-vnc.log 2>&1 < /dev/null & + return 0 +} + +case ${1:-status} in + start) start ;; + stop) stop ;; + restart) stop; sleep 1; start ;; + status) + addr=$(ip -4 -o addr show tailscale0 2>/dev/null | awk '{print $4}' | cut -d/ -f1) + name=$(curl -s --max-time 2 --unix-socket /run/tailscale/tailscaled.sock \ + http://local-tailscaled.sock/localapi/v0/status 2>/dev/null | + python3 -c 'import json, sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))' 2>/dev/null) + echo "capable=$([ -e "$runtime/remote-capable" ] && echo 1 || echo 0)" + echo "running=$(running && echo 1 || echo 0)" + echo "address=$addr" + echo "name=$name" + echo "port=$vnc_port" ;; + *) echo "usage: $0 start|stop|restart|status" >&2; exit 2 ;; +esac diff --git a/session/remote-desktop.sh b/session/remote-desktop.sh index ef800ee..bd4a85b 100755 --- a/session/remote-desktop.sh +++ b/session/remote-desktop.sh @@ -12,9 +12,9 @@ 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 +# The password between krdp and vnc-bridge.sh (both on this host), new at every start: krdp +# takes it only on its command line, which other local users can read while it runs. +(umask 077; head -c 24 /dev/urandom | base64 | tr -d '/+=' | cut -c1-20 > "$creds/password") 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)