mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 01:00:06 +02:00
Add Frametop Remote Access, a settings app for the VNC view
A GTK 4 / libadwaita app (remote/ft-remote-settings, on the host's own Python like the gaze probe): remote access on and off (REMOTE, applied at once when the desktop allows it), the tailnet name and address to connect to, and the VNC password shown, copied, or replaced. The password stays random and made on the Frame, in ~/.config/frametop-remote; none is in the code. session/remote-ctl.sh starts, stops, and reports remote access; the session uses it and leaves a remote-capable marker, since KWin allows the capture only in a desktop that started with REMOTE=1. The password between krdp and the VNC bridge (local only, but on krdp's command line) is now new at every start. The installer adds the app to the menu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9820e7996f
commit
1f19732aaf
9 files changed
+339
-10
No files matched your search
Executable
+244
@@ -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))
|
||||
@@ -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;
|
||||
Executable
+17
@@ -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
|
||||
Reference in new issue
Block a user