Fix review findings and Windows setup issues found in testing

- Run the server with -X utf8: the bundled Windows Python ignores PYTHON* variables.
- Quote every argument in Windows terminal commands, so cmd metacharacters are literal.
- frame_connect: accept HOST:PORT, validate input, retry the config swap while
  Windows' ssh.exe holds ~/.ssh/config locked, and don't apply 0o700 on Windows.
- Never use rsync on Windows; unbounded stream queue; validate FRAME_ALIAS;
  more Linux terminals; bundle the window icon; docs and wording fixes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-26 09:41:04 +10:00
1 parent fc2fa65f0d
commit e210407f31
11 files changed
+90 -39

No files matched your search

+3 -2
View File
@@ -108,7 +108,8 @@ _install_lock = threading.Lock() # installs are rare; one at a time avoids ever
def _copy(src, dest, executable=False, timeout=600):
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
name = os.path.basename(src)
if shutil.which('rsync'):
rsync = None if frame_host.WINDOWS else shutil.which('rsync') # see server.push_file
if rsync:
cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
'-e', shlex.join(['ssh', *SSH_OPTS]), src, f'{FRAME}:{dest}']
else:
@@ -119,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
raise FrameError(f'copying {name} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
if executable and not shutil.which('rsync'):
if executable and not rsync:
ssh(f'chmod u+x {shlex.quote(dest)}')
+2 -2
View File
@@ -40,8 +40,8 @@ def key():
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
capture_output=True, text=True)
if p is None or p.returncode != 0 or not p.stdout.strip():
raise DBError('No compatibility-database key in the Keychain '
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
raise DBError('No compatibility-database key (set FRAME_CONTROL_KEY, or on macOS the Keychain '
f'item service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
return p.stdout.strip()
+61 -19
View File
@@ -3,15 +3,17 @@ 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]
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")
@@ -36,26 +38,50 @@ def say(msg):
print(msg, flush=True)
def port_open(host):
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, 22), timeout=3):
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):
for host in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]:
if port_open(host):
return host
say(f" - {host}: not resolvable or port 22 closed")
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 write_config(host):
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."""
SSH_DIR.mkdir(mode=0o700, exist_ok=True)
make_ssh_dir()
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
kept, skip = [], False
for line in old.splitlines():
@@ -65,12 +91,27 @@ def write_config(host):
skip = False
elif not skip:
kept.append(line)
block = [BEGIN, f"Host {FRAME_ALIAS}", f" HostName {host}", f" User {FRAME_USER}",
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]
CONFIG.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
tmp = CONFIG.with_name("config.frame-control.tmp")
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
if os.name != "nt":
CONFIG.chmod(0o600)
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():
@@ -82,8 +123,8 @@ def main(argv):
if argv and argv[0] in ("-h", "--help"):
sys.exit(__doc__)
say("==> Looking for the Steam Frame")
host = pick_host(argv[0] if argv else None)
while not host:
found = pick_host(argv[0] if argv else None)
while not found:
say("Could not reach the Frame on port 22.")
say("Check: Developer Mode on and a user password set; same network; no client isolation.")
try:
@@ -92,11 +133,12 @@ def main(argv):
typed = ""
if not typed:
return 1
host = pick_host(typed)
say(f" found: {host}")
found = pick_host(typed)
host, port = found
say(f" found: {host}" + (f" port {port}" if port != 22 else ""))
say("==> SSH key")
SSH_DIR.mkdir(mode=0o700, exist_ok=True)
make_ssh_dir()
if KEY.exists():
say(f" exists: {KEY}")
else:
@@ -105,7 +147,7 @@ def main(argv):
say(f" created {KEY}")
say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}")
write_config(host)
write_config(host, port)
say("==> Checking key login")
if key_login_works():
@@ -114,7 +156,7 @@ def main(argv):
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",
f"{FRAME_USER}@{host}", ADD_KEY_CMD], input=pub + "\n", text=True)
"-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
+7 -5
View File
@@ -146,16 +146,18 @@ def open_terminal(argv, title="Frame Control"):
# `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.
inner = subprocess.list2cmdline(argv)
# Every argument is quoted, so cmd treats & | < > ^ in them literally.
inner = " ".join('"%s"' % a.replace('"', '\\"') 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. " _'
for name, flag in (("x-terminal-emulator", "-e"), ("gnome-terminal", "--"), ("konsole", "-e"),
("xfce4-terminal", "-x"), ("kitty", None), ("alacritty", "-e"),
("foot", None), ("xterm", "-e")):
for name, flags in (("x-terminal-emulator", ["-e"]), ("gnome-terminal", ["--"]), ("ptyxis", ["--"]),
("kgx", ["--"]), ("konsole", ["-e"]), ("xfce4-terminal", ["-x"]),
("tilix", ["-e"]), ("lxterminal", ["-e"]), ("kitty", []), ("alacritty", ["-e"]),
("wezterm", ["start", "--"]), ("foot", []), ("xterm", ["-e"])):
exe = which(name)
if exe:
_spawn([exe, *([flag] if flag else []), "bash", "-c", script])
_spawn([exe, *flags, "bash", "-c", script])
return name
raise HostError("No terminal program found (tried gnome-terminal, konsole, xterm and others)")
+1 -1
View File
@@ -1100,7 +1100,7 @@ function upload(file, mode) {
}
async function sendFiles(files) {
for (const f of files) {
if (!f.size) { toast(`${f.name}: folders and empty files aren't supported here; use scripts/push.sh`, true); continue; }
if (!f.size) { toast(`${f.name}: folders and empty files aren't supported here; zip the folder first`, true); continue; }
const apk = f.name.toLowerCase().endsWith(".apk");
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
}
+5 -2
View File
@@ -39,6 +39,8 @@ import frame_store # noqa: E402
HERE = Path(__file__).resolve().parent
FRAME = os.environ.get("FRAME_ALIAS", "frame")
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
# supports it (not on Windows: there every command connects on its own).
CONTROL = frame_host.control_path()
@@ -763,7 +765,7 @@ POST = {"/api/android/display": android_display, "/api/android": android,"/api/l
def _pipe_reader(pipe):
"""Chunks from a pipe via a thread; select() can't wait on pipes on Windows."""
chunks = queue.Queue(maxsize=64)
chunks = queue.Queue() # unbounded: the pump never blocks, so it ends at EOF
def pump():
try:
@@ -791,7 +793,8 @@ def push_file(path, dest="Downloads/"):
"""Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
name = Path(path).name
try:
if shutil.which("rsync") and ssh("command -v rsync >/dev/null && echo yes || true").strip() == "yes":
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take our POSIX -e quoting.
if not frame_host.WINDOWS and shutil.which("rsync") and ssh("command -v rsync >/dev/null && echo yes || true").strip() == "yes":
cmd = ["rsync", "-a", "-e", shlex.join(SSH), str(path), f"{FRAME}:{shlex.quote(dest)}"]
else:
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.