mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
433 lines
19 KiB
Python
433 lines
19 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 hashlib
|
|
import io
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import socket
|
|
import ssl
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
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
|
|
|
|
|
|
class Unreachable(HostError):
|
|
"""The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
|
|
|
|
|
|
def run_ssh(argv, **kwargs):
|
|
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
|
|
|
|
A real temporary file avoids OpenSSH's blocked asynchronous stderr writes,
|
|
while keeping subprocess.run's captured output, text, check and timeout API.
|
|
"""
|
|
if not WINDOWS:
|
|
return subprocess.run(argv, **kwargs)
|
|
if kwargs.pop("capture_output", False):
|
|
if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
|
|
raise ValueError("stdout and stderr arguments may not be used with capture_output")
|
|
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if kwargs.get("stderr") != subprocess.PIPE:
|
|
return subprocess.run(argv, **kwargs)
|
|
check = kwargs.pop("check", False)
|
|
text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors"))
|
|
with tempfile.TemporaryFile() as stderr:
|
|
kwargs["stderr"] = stderr
|
|
try:
|
|
result = subprocess.run(argv, **kwargs)
|
|
except subprocess.TimeoutExpired as error:
|
|
stderr.seek(0)
|
|
error.stderr = stderr.read()
|
|
raise
|
|
stderr.seek(0)
|
|
if text:
|
|
with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader:
|
|
result.stderr = reader.read()
|
|
else:
|
|
result.stderr = stderr.read()
|
|
if check:
|
|
result.check_returncode()
|
|
return result
|
|
|
|
|
|
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 = f'explorer /select,"{path}"' # as one string: Explorer wants the quotes after the comma
|
|
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")
|
|
|
|
|
|
# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
|
|
BAD_PERMISSIONS = "Bad owner or permissions on "
|
|
|
|
|
|
def make_private(path):
|
|
"""Leave only this user able to open PATH, as ssh insists for ~/.ssh/config.
|
|
Windows: an ACL of just this user, SYSTEM and Administrators, inherited nothing.
|
|
A file written into ~/.ssh otherwise takes the folder's ACL, and Windows' OpenSSH
|
|
refuses it if that grants anyone else, even an account deleted long ago
|
|
("Bad owner or permissions"). Best effort: -> False if it couldn't."""
|
|
if not WINDOWS:
|
|
try:
|
|
os.chmod(path, 0o600)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
me = os.environ.get("USERNAME", "")
|
|
try: # "desktop\me","S-1-5-21-..."
|
|
# Bytes: the account name is in the console's code page, the SID is ASCII.
|
|
out = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True,
|
|
stdin=subprocess.DEVNULL, timeout=10).stdout
|
|
sid = out.decode("ascii", "replace").strip().rsplit(",", 1)[-1].strip('"')
|
|
if sid.startswith("S-1-"):
|
|
me = "*" + sid
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
pass
|
|
if not me:
|
|
return False
|
|
try:
|
|
return subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"{me}:F",
|
|
"*S-1-5-18:F", "*S-1-5-32-544:F"], capture_output=True,
|
|
stdin=subprocess.DEVNULL, timeout=10).returncode == 0
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
|
|
|
|
def ssh_hostname(alias):
|
|
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
|
try:
|
|
out = run_ssh(["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"
|
|
|
|
|
|
RDP_PORT = 3389
|
|
RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's
|
|
# xrdp's certificate is its own, so every client warns about it first.
|
|
RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} "
|
|
"with your Developer Mode password")
|
|
|
|
|
|
def check_rdp(host, timeout=3):
|
|
"""Raise Unreachable, saying why, unless the Frame's RDP port takes a connection."""
|
|
try:
|
|
with socket.create_connection((host, RDP_PORT), timeout=timeout):
|
|
return
|
|
except ConnectionRefusedError:
|
|
raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} "
|
|
"refused). Turn on Developer Mode in Steam Settings > System on the headset, "
|
|
"then restart it and try again.") from None
|
|
except socket.gaierror:
|
|
raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's "
|
|
"address on the Devices tab.") from None
|
|
except OSError as e:
|
|
raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, "
|
|
"switched off or on another network; if it's on, check Developer Mode is on "
|
|
"in Steam Settings > System.") from None
|
|
|
|
|
|
def rdp_file(host):
|
|
"""A Remote Desktop connection file for the Frame. mstsc /v: alone offers this
|
|
computer's Windows account, which xrdp turns away; the file names steamos instead."""
|
|
if any(c in host for c in "\r\n"):
|
|
raise HostError("That headset address can't be used for remote desktop")
|
|
# One file per address, so two launches close together can't swap headsets.
|
|
path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+
|
|
f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n")
|
|
return path
|
|
|
|
|
|
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)
|
|
# The client would open either way and then fail on its own, with nothing said here.
|
|
check_rdp(host)
|
|
if MAC:
|
|
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
|
return f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
|
|
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", str(rdp_file(host))])
|
|
# Windows asks about the unsigned connection file first.
|
|
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
|
|
if which("remmina"):
|
|
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
|
|
return f"Opened Remmina to {host}: {RDP_LOGIN}"
|
|
for name in ("xfreerdp3", "xfreerdp"):
|
|
if which(name):
|
|
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
|
|
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
|
|
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:])
|