Files
DeeJanuz--frametop/remote/ft-remote-settings
DeeJanuzandClaude Opus 5.5 6cae2e0afe Remote Access: check the status every 5 s instead of every 2 s
While its window was open, Frametop Remote Access ran remote-ctl.sh status every 2 s,
and each run spawns bash, curl (the tailnet name from tailscaled) and python3 to parse
it. It now checks every 5 s, plus when the window comes to the front and once more 2 s
after turning remote access on or off or changing the password, so a change still
shows within a couple of seconds. A check doesn't start while one is still running.
Doing the check in-process would duplicate remote-ctl.sh's idea of "running", which
the session and the pause code share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:20:32 -06:00

257 lines
11 KiB
Python
Executable File

#!/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.asking = False # a status check is running
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)
# Each check runs remote-ctl.sh (bash, curl, and python3 for the tailnet name), so
# it runs every 5 seconds, when the window comes to the front, and after a change.
self.refresh()
GLib.timeout_add_seconds(5, self.refresh)
self.connect("notify::is-active", lambda win, _param: win.is_active() and win.refresh())
# --- status ---
def refresh(self):
if not self.asking:
self.asking = True
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 refresh_soon(self):
"""Again in a moment: remote access takes a second or two to start or stop."""
self.refresh()
GLib.timeout_add_seconds(2, lambda: self.refresh() and False)
def on_status(self, proc, result):
self.asking = False
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_soon()
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_soon()
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))