#!/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))