mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 11:00:14 +02:00
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>
257 lines
11 KiB
Python
Executable File
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))
|