Files
saphid--frame-control/ui/frame_host.py
T
saphidandClaude Opus 5.5 83d74548cd Screenshots: Copy button, right-click menu, and new shots appear on their own
Each screenshot card and the viewer get a Copy button that puts the image on
the clipboard (natively in the desktop app, as PNG in a browser). Right-click
a screenshot to open, copy, save, show it in Finder, or copy its path or name;
right-click the viewer to copy or save. The shelf re-lists the Frame's
screenshots every 8 s while the window is visible and connected, redraws only
when something changed, and keeps thumbnails it already has. Switching
headsets clears the list and ignores answers still on their way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:08:36 +10:00

313 lines
13 KiB
Python

"""What differs between the computers Frame Control runs on: macOS, Linux, Windows.
Everything here runs on your computer, not the Frame. Python stdlib only.
CLI (used by the Electron app, so terminal handling lives in one place):
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
"""
import os
import shlex
import shutil
import ssl
import subprocess
import sys
from pathlib import Path
MAC = sys.platform == "darwin"
WINDOWS = os.name == "nt"
LINUX = not MAC and not WINDOWS
NAME = "macOS" if MAC else "Windows" if WINDOWS else "Linux"
FILE_MANAGER = "Finder" if MAC else "File Explorer" if WINDOWS else "your file manager"
# Windows' OpenSSH client can't share one connection between commands
# (no ControlMaster), so there each command opens its own.
MUX = not WINDOWS
# Popen() keyword arguments that detach a child from our console and signals.
DETACHED = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if WINDOWS
else {"start_new_session": True})
class HostError(RuntimeError):
pass
def data_dir(*parts):
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
if os.environ.get("FRAME_CONTROL_DATA_DIR"):
base = Path(os.environ["FRAME_CONTROL_DATA_DIR"])
elif MAC:
base = Path.home() / "Library" / "Application Support" / "Frame Control"
elif WINDOWS:
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Frame Control"
else:
base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "frame-control"
return base.joinpath(*parts)
def cache_dir(*parts):
if MAC:
base = Path.home() / "Library" / "Caches" / "Frame Control"
elif WINDOWS:
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "Frame Control" / "Cache"
else:
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "frame-control"
return base.joinpath(*parts)
def control_path(tag="x", *, private=None):
"""ssh ControlPath for the shared connection, or None where it isn't supported.
`tag` names the headset: ssh's %C hashes only the address, user and port, so two
headsets reached at the same address (one of them moved) would otherwise share a
connection, and one's commands would run on the other.
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
"""
# A private server (the MCP adapter's) keeps its own masters: FRAME_PRIVATE_SSH=1.
if private is None:
private = os.environ.get("FRAME_PRIVATE_SSH") == "1"
suffix = f"-{os.getpid()}" if private else ""
return f"/tmp/frame-ui-{os.getuid()}{suffix}-{tag}-%C" if MUX else None
def which(name, *extra):
"""First executable among PATH and the extra candidate paths."""
for cand in (shutil.which(name), *extra):
if cand and os.path.isfile(cand) and os.access(cand, os.X_OK):
return cand
return None
def install_hint(tool):
"""How to get a missing command-line tool on this computer."""
hints = {
"adb": {"mac": "brew install android-platform-tools",
"win": "winget install Google.PlatformTools",
"linux": "install your distribution's adb package (e.g. sudo apt install adb)"},
}
return hints[tool]["mac" if MAC else "win" if WINDOWS else "linux"]
def android_sdk_dirs():
"""Where the Android SDK usually lives, for adb."""
dirs = [os.environ.get("ANDROID_HOME"), os.environ.get("ANDROID_SDK_ROOT")]
if MAC:
dirs += ["~/Library/Android/sdk", "/opt/homebrew/share/android-commandlinetools",
"~/.homebrew/share/android-commandlinetools"]
elif WINDOWS:
dirs += [os.path.join(os.environ.get("LOCALAPPDATA", ""), "Android", "Sdk")]
else:
dirs += ["~/Android/Sdk", "/usr/lib/android-sdk"]
return [os.path.expanduser(d) for d in dirs if d]
def adb():
exe = "adb.exe" if WINDOWS else "adb"
extra = [os.path.join(d, "platform-tools", exe) for d in android_sdk_dirs()]
if MAC:
extra += ["/opt/homebrew/bin/adb", str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"]
# The app bundles adb as a last resort: an adb you already use goes first, so
# two different adb versions don't keep restarting each other's server.
tools = os.environ.get("FRAME_CONTROL_TOOLS")
if tools:
extra.append(os.path.join(tools, exe))
env = os.environ.get("ADB")
found = (env if env and os.access(env, os.X_OK) else None) or which("adb", *extra)
if not found:
raise HostError(f"adb isn't installed on this computer: {install_hint('adb')}")
return found
def trust_bundled_cas():
"""Trust the app's CA bundle for HTTPS as well as the system's certificates.
Python on Windows only sees the root certificates already in the Windows
store, and a fresh install fetches those lazily, so Steam and F-Droid can
fail with CERTIFICATE_VERIFY_FAILED. The app bundles curl's copy of Mozilla's
CA list (app/build/fetch-deps.js); outside the app this does nothing. Call it
before the first urlopen: urllib keeps the HTTPS context it builds then.
"""
tools = os.environ.get("FRAME_CONTROL_TOOLS")
cafile = os.path.join(tools, "cacert.pem") if tools else None
if not cafile or not os.path.isfile(cafile):
return
def context(*args, **kwargs):
ctx = ssl.create_default_context(*args, **kwargs)
ctx.load_verify_locations(cafile)
return ctx
ssl._create_default_https_context = context # urllib's default for HTTPS
def open_path(path):
"""Show a folder or file in the file manager."""
path = str(path)
if WINDOWS:
os.startfile(path) # noqa: pylint only on Windows
return
opener = "open" if MAC else which("xdg-open")
if not opener:
raise HostError("xdg-open isn't installed, so the folder can't be opened")
subprocess.Popen([opener, path], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, **DETACHED)
def reveal_path(path):
"""Show a file selected in its folder (Linux file managers vary, so there the folder opens)."""
path = Path(path)
if MAC:
cmd = ["open", "-R", str(path)]
elif WINDOWS:
cmd = ["explorer", f"/select,{path}"]
else:
return open_path(path.parent)
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, **DETACHED)
open_url = open_path # the same openers hand URLs to the default browser
def _spawn(argv):
subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, **DETACHED)
def open_terminal(argv, title="Frame Control"):
"""Run argv in a new terminal window, for anything that asks for a password.
The window stays open after the command ends, so its output can be read.
"""
argv = [str(a) for a in argv]
if MAC:
command = shlex.join(argv).replace("\\", "\\\\").replace('"', '\\"')
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
"-e", f'do script "{command}"', "-e", "activate", "-e", "end tell"],
capture_output=True, text=True)
if r.returncode != 0:
# Usually macOS Automation consent for Terminal was denied.
raise HostError(f"Couldn't open Terminal: {r.stderr.strip()}")
return "Terminal"
if WINDOWS:
# `start` gives the command its own console window; cmd /k keeps it open.
# One hand-built command line: quoting it twice through list2cmdline would
# produce backslash-escaped quotes, which cmd doesn't understand.
# Every argument is quoted, so cmd treats & | < > ^ in them literally. cmd has
# no escape for a quote inside quotes (and expands %VAR% regardless), so refuse those.
if any(c in a for a in argv for c in '"%\r\n'):
raise HostError("Can't pass quotes or % to a Windows terminal")
inner = " ".join(f'"{a}"' for a in argv)
subprocess.Popen(f'cmd.exe /c start "{title}" cmd.exe /k "{inner}"', **DETACHED)
return "a terminal window"
script = f'{shlex.join(argv)}; echo; read -r -p "Press Enter to close. " _'
# flags=None: the terminal takes the whole command as one string after -e.
for name, flags in (("x-terminal-emulator", ["-e"]), ("gnome-terminal", ["--"]), ("ptyxis", ["--"]),
("kgx", ["--"]), ("konsole", ["-e"]), ("xfce4-terminal", ["-x"]),
("tilix", None), ("lxterminal", None), ("kitty", []), ("alacritty", ["-e"]),
("wezterm", ["start", "--"]), ("foot", []), ("xterm", ["-e"])):
exe = which(name)
if not exe:
continue
if flags is None:
_spawn([exe, "-e", "bash -c " + shlex.quote(script)])
elif name == "x-terminal-emulator" and "lxterminal" in os.path.realpath(exe):
_spawn([exe, "-e", "bash -c " + shlex.quote(script)]) # Debian alternative -> lxterminal
else:
_spawn([exe, *flags, "bash", "-c", script])
return name
raise HostError("No terminal program found (tried gnome-terminal, konsole, xterm and others)")
def clipboard_text():
"""The text on this computer's clipboard."""
if MAC:
cmds = [["pbpaste"]]
elif WINDOWS:
cmds = [["powershell.exe", "-NoProfile", "-Command",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-Clipboard -Raw"]]
else:
cmds = [["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"],
["xsel", "--clipboard", "--output"]]
for cmd in cmds:
if not shutil.which(cmd[0]):
continue
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, timeout=10)
if r.returncode == 0:
text = r.stdout.decode("utf-8", errors="replace")
return text[:-2] if WINDOWS and text.endswith("\r\n") else text
if LINUX:
raise HostError("Can't read the clipboard: install wl-clipboard (Wayland) or xclip (X11)")
raise HostError("Can't read the clipboard")
def ssh_hostname(alias):
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
try:
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return alias
for line in out.splitlines():
if line.startswith("hostname "):
return line.split(None, 1)[1].strip()
return alias
# Apps the UI can hand off to, per platform: (installed-check, launch argv) pairs,
# and where to get the app when none is installed.
def open_steam_link():
if MAC:
if subprocess.run(["open", "-a", "Steam Link"], capture_output=True).returncode == 0:
return "Opened Steam Link"
elif WINDOWS:
for base in (os.environ.get("ProgramFiles(x86)"), os.environ.get("ProgramFiles")):
exe = base and os.path.join(base, "Steam Link", "SteamLink.exe")
if exe and os.path.isfile(exe):
_spawn([exe])
return "Opened Steam Link"
else:
if which("steamlink"):
_spawn([which("steamlink")])
return "Opened Steam Link"
if which("flatpak") and subprocess.run(["flatpak", "info", "com.valvesoftware.SteamLink"],
capture_output=True).returncode == 0:
_spawn(["flatpak", "run", "com.valvesoftware.SteamLink"])
return "Opened Steam Link"
open_url("https://store.steampowered.com/remoteplay")
return "Steam Link isn't installed; opened its download page"
def open_rdp(alias, host=None):
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
host = host or ssh_hostname(alias)
if MAC:
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
return "Opened Windows App"
open_url("https://apps.apple.com/app/windows-app/id1295203466")
return "Windows App isn't installed; opened its App Store page"
if WINDOWS:
_spawn(["mstsc.exe", f"/v:{host}"])
return f"Opened Remote Desktop to {host}"
if which("remmina"):
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
return f"Opened Remmina to {host}"
for name in ("xfreerdp3", "xfreerdp"):
if which(name):
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}"
raise HostError("No RDP client found: install Remmina or FreeRDP")
def main(argv):
if len(argv) >= 3 and argv[0] == "terminal" and argv[1] == "--":
try:
print(f"Opened {open_terminal(argv[2:])}")
except HostError as e:
sys.exit(str(e))
return
sys.exit(__doc__)
if __name__ == "__main__":
main(sys.argv[1:])