mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 10:00:34 +02:00
- Child processes never inherit the server's stdin. Under the app it's the pipe held open for --exit-on-eof, and Windows' ssh.exe waited on it forever, so captures, the screenshot list and Android apps timed out. - frame_connect's key check accepts a first-seen host key (as the copy step does), so an already-authorized key doesn't trigger a password prompt. Verified on Windows 11, Ubuntu and macOS against a Steam Frame: status, headset and desktop captures, live video, library, Android apps, screenshots and upload. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
176 lines
6.5 KiB
Python
176 lines
6.5 KiB
Python
"""Connect this computer to the Steam Frame: find it, create a key, add a `Host frame`
|
|
alias to ~/.ssh/config and copy the key over, asking for the Developer Mode
|
|
password once. The Linux and Windows twin of scripts/connect.sh (which the Mac
|
|
app uses); same config block, so either can re-run over the other. Idempotent.
|
|
|
|
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
|
|
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
|
|
"""
|
|
import base64
|
|
import os
|
|
import platform
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
|
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
|
SSH_DIR = Path.home() / ".ssh"
|
|
KEY = SSH_DIR / "id_ed25519_frame"
|
|
CONFIG = SSH_DIR / "config"
|
|
# Both go into ~/.ssh/config, so nothing that could add a line or a directive.
|
|
for _name, _value in (("FRAME_ALIAS", FRAME_ALIAS), ("FRAME_USER", FRAME_USER)):
|
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", _value):
|
|
sys.exit(f"{_name} must be a plain name, not {_value!r}")
|
|
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
|
|
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
|
|
|
|
# Appends the key from stdin unless it's already there. base64 keeps it intact
|
|
# through Windows' command-line quoting.
|
|
ADD_KEY = """umask 077
|
|
mkdir -p ~/.ssh
|
|
k=$(cat)
|
|
grep -qxF "$k" ~/.ssh/authorized_keys 2>/dev/null || printf '%s\\n' "$k" >> ~/.ssh/authorized_keys
|
|
"""
|
|
ADD_KEY_CMD = 'sh -c "$(echo %s | base64 -d)"' % base64.b64encode(ADD_KEY.encode()).decode()
|
|
|
|
|
|
def say(msg):
|
|
print(msg, flush=True)
|
|
|
|
|
|
def split_port(arg):
|
|
""""host:2222" -> ("host", 2222); anything else (IPv6 too) keeps port 22."""
|
|
host, sep, port = arg.rpartition(":")
|
|
if sep and port.isdigit() and ":" not in host:
|
|
return host, int(port)
|
|
return arg, 22
|
|
|
|
|
|
def port_open(host, port=22):
|
|
try:
|
|
with socket.create_connection((host, port), timeout=3):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
HOST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9.:%-]*")
|
|
|
|
|
|
def pick_host(arg):
|
|
if arg and not HOST_RE.fullmatch(arg):
|
|
say(f" - {arg!r} isn't a host name or IP address")
|
|
return None
|
|
for cand in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]:
|
|
host, port = split_port(cand)
|
|
if port_open(host, port):
|
|
return host, port
|
|
say(f" - {cand}: not resolvable or port {port} closed")
|
|
return None
|
|
|
|
|
|
def make_ssh_dir():
|
|
# Windows: no mode. Python 3.12.4+ turns 0o700 into an owner-only ACL, which locks
|
|
# the user out if the folder's owner is Administrators; the profile's ACL suffices.
|
|
if os.name == "nt":
|
|
SSH_DIR.mkdir(exist_ok=True)
|
|
else:
|
|
SSH_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
|
|
|
|
def write_config(host, port=22):
|
|
"""Replace our managed block and put it first: ssh uses the first value it sees per
|
|
option. The trailing "Host *" returns the rest of the file to global scope."""
|
|
make_ssh_dir()
|
|
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
|
kept, skip = [], False
|
|
for line in old.splitlines():
|
|
if line == BEGIN:
|
|
skip = True
|
|
elif line == END:
|
|
skip = False
|
|
elif not skip:
|
|
kept.append(line)
|
|
block = [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", *([f" Port {port}"] if port != 22 else []),
|
|
f" User {FRAME_USER}",
|
|
" IdentityFile ~/.ssh/id_ed25519_frame", " IdentitiesOnly yes",
|
|
" ServerAliveInterval 30", "Host *", END]
|
|
tmp = CONFIG.with_name("config.frame-control.tmp")
|
|
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
|
if os.name != "nt":
|
|
tmp.chmod(0o600)
|
|
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
|
|
# and locked, so the swap can fail for a moment; keep trying for a while.
|
|
for attempt in range(60):
|
|
try:
|
|
os.replace(tmp, CONFIG)
|
|
return
|
|
except PermissionError:
|
|
if attempt == 0:
|
|
say(" ~/.ssh/config is in use by another ssh; waiting for it...")
|
|
time.sleep(0.5)
|
|
tmp.unlink(missing_ok=True)
|
|
raise SystemExit("~/.ssh/config stayed locked by another program. Quit Frame Control "
|
|
"and any ssh windows, then run the setup again.")
|
|
|
|
|
|
def key_login_works():
|
|
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
|
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
|
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
|
capture_output=True).returncode == 0
|
|
|
|
|
|
def main(argv):
|
|
if argv and argv[0] in ("-h", "--help"):
|
|
sys.exit(__doc__)
|
|
say("==> Looking for the Steam Frame")
|
|
found = pick_host(argv[0] if argv else None)
|
|
while not found:
|
|
say("Could not reach the Frame over SSH.")
|
|
say("Check: Developer Mode on and a user password set; same network; no client isolation.")
|
|
try:
|
|
typed = input("Type the Frame's IP address (Quick Settings shows it), or press Enter to quit: ").strip()
|
|
except EOFError:
|
|
typed = ""
|
|
if not typed:
|
|
return 1
|
|
found = pick_host(typed)
|
|
host, port = found
|
|
say(f" found: {host}" + (f" port {port}" if port != 22 else ""))
|
|
|
|
say("==> SSH key")
|
|
make_ssh_dir()
|
|
if KEY.exists():
|
|
say(f" exists: {KEY}")
|
|
else:
|
|
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C",
|
|
f"{platform.node() or 'computer'}->steam-frame", "-f", str(KEY)], check=True)
|
|
say(f" created {KEY}")
|
|
|
|
say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}")
|
|
write_config(host, port)
|
|
|
|
say("==> Checking key login")
|
|
if key_login_works():
|
|
say(" key login already works")
|
|
else:
|
|
say(" copying the key: enter the Developer Mode password when asked")
|
|
pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip()
|
|
r = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "PubkeyAuthentication=no",
|
|
"-p", str(port), f"{FRAME_USER}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True)
|
|
if r.returncode != 0 or not key_login_works():
|
|
say("Key login still isn't working. Check the password and run this again.")
|
|
return 1
|
|
say(" key login OK")
|
|
say(f"\nDone. Frame Control can reach the Frame now. In a terminal: ssh {FRAME_ALIAS}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|