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

+1 -1
View File
@@ -93,7 +93,7 @@ already ships. [How each feature works](docs/frame-control.md).
| | Download | Needs | | | Download | Needs |
|---|---|---| |---|---|---|
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Python 3 (`xcode-select --install`) | | **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Python 3 (`xcode-select --install`) |
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra: Python is bundled | | **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra: Python is bundled, and SSH is built into Windows |
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `python3` and `ssh` (most desktops have both) | | **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `python3` and `ssh` (most desktops have both) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | same | | **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | same |
+2 -1
View File
@@ -28,7 +28,8 @@ function get(url) {
} }
(async () => { (async () => {
if (fs.existsSync(path.join(OUT, "python.exe")) && fs.readFileSync(path.join(OUT, ".version"), "utf8") === VERSION) { const stamp = path.join(OUT, ".version");
if (fs.existsSync(path.join(OUT, "python.exe")) && fs.existsSync(stamp) && fs.readFileSync(stamp, "utf8") === VERSION) {
console.log(`Python ${VERSION} already in ${OUT}`); console.log(`Python ${VERSION} already in ${OUT}`);
return; return;
} }
+3 -2
View File
@@ -109,7 +109,7 @@ function ping(target) {
async function startServer() { async function startServer() {
const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1", const env = { ...process.env, PATH: await loginPath(), PYTHONUNBUFFERED: "1", PYTHONDONTWRITEBYTECODE: "1",
PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", FRAME_CONTROL_APP: "1" }; // UTF-8 even on Windows PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", FRAME_CONTROL_APP: "1" };
python = await findPython(env); python = await findPython(env);
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`); if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`); if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
@@ -118,7 +118,8 @@ async function startServer() {
const log = fs.openSync(LOG, "a"); const log = fs.openSync(LOG, "a");
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`); fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
// stdin stays open while the app runs; the server exits cleanly when it closes. // stdin stays open while the app runs; the server exits cleanly when it closes.
const child = spawn(python, [SERVER, "--port", String(port), "--exit-on-eof"], // -X utf8: the bundled Windows Python ignores PYTHON* variables (isolated mode).
const child = spawn(python, ["-X", "utf8", SERVER, "--port", String(port), "--exit-on-eof"],
{ env, stdio: ["pipe", log, log], windowsHide: true }); { env, stdio: ["pipe", log, log], windowsHide: true });
child.stdin.on("error", () => {}); child.stdin.on("error", () => {});
fs.closeSync(log); fs.closeSync(log);
+2 -1
View File
@@ -27,7 +27,8 @@
}, },
"files": [ "files": [
"main.js", "main.js",
"package.json" "package.json",
"build/icon.png"
], ],
"extraResources": [ "extraResources": [
{ {
+3 -3
View File
@@ -8,11 +8,11 @@ As of 2026-09-25 no other desktop app manages the Frame end to end.
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
Windows-only. Steam Link views the headset. Windows-only. Steam Link views the headset.
You can also run the same UI in a browser without the app, from a checkout on You can also run the same UI in a browser without the app, from a checkout:
macOS or Linux:
```sh ```sh
./scripts/frame-ui.sh # opens http://127.0.0.1:47810 in its own window ./scripts/frame-ui.sh # macOS: opens http://127.0.0.1:47810 in its own window
python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
``` ```
## Features ## Features
+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): def _copy(src, dest, executable=False, timeout=600):
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp.""" """Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
name = os.path.basename(src) 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 []), cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
'-e', shlex.join(['ssh', *SSH_OPTS]), src, f'{FRAME}:{dest}'] '-e', shlex.join(['ssh', *SSH_OPTS]), src, f'{FRAME}:{dest}']
else: else:
@@ -119,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
raise FrameError(f'copying {name} to the Frame timed out') raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
raise FrameError(f'copying {name} to the Frame failed: {(e.stderr or "").strip()[-300:]}') 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)}') 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'], p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
capture_output=True, text=True) capture_output=True, text=True)
if p is None or p.returncode != 0 or not p.stdout.strip(): if p is None or p.returncode != 0 or not p.stdout.strip():
raise DBError('No compatibility-database key in the Keychain ' raise DBError('No compatibility-database key (set FRAME_CONTROL_KEY, or on macOS the Keychain '
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})') f'item service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
return p.stdout.strip() 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 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. 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) Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
""" """
import base64 import base64
import os import os
import platform import platform
import re
import socket import socket
import subprocess import subprocess
import sys import sys
import time
from pathlib import Path from pathlib import Path
FRAME_USER = os.environ.get("FRAME_USER", "steamos") FRAME_USER = os.environ.get("FRAME_USER", "steamos")
@@ -36,26 +38,50 @@ def say(msg):
print(msg, flush=True) 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: try:
with socket.create_connection((host, 22), timeout=3): with socket.create_connection((host, port), timeout=3):
return True return True
except OSError: except OSError:
return False return False
HOST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9.:%-]*")
def pick_host(arg): def pick_host(arg):
for host in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]: if arg and not HOST_RE.fullmatch(arg):
if port_open(host): say(f" - {arg!r} isn't a host name or IP address")
return host return None
say(f" - {host}: not resolvable or port 22 closed") 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 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 """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.""" 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 "" old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
kept, skip = [], False kept, skip = [], False
for line in old.splitlines(): for line in old.splitlines():
@@ -65,12 +91,27 @@ def write_config(host):
skip = False skip = False
elif not skip: elif not skip:
kept.append(line) 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", " IdentityFile ~/.ssh/id_ed25519_frame", " IdentitiesOnly yes",
" ServerAliveInterval 30", "Host *", END] " 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": 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(): def key_login_works():
@@ -82,8 +123,8 @@ def main(argv):
if argv and argv[0] in ("-h", "--help"): if argv and argv[0] in ("-h", "--help"):
sys.exit(__doc__) sys.exit(__doc__)
say("==> Looking for the Steam Frame") say("==> Looking for the Steam Frame")
host = pick_host(argv[0] if argv else None) found = pick_host(argv[0] if argv else None)
while not host: while not found:
say("Could not reach the Frame on port 22.") say("Could not reach the Frame on port 22.")
say("Check: Developer Mode on and a user password set; same network; no client isolation.") say("Check: Developer Mode on and a user password set; same network; no client isolation.")
try: try:
@@ -92,11 +133,12 @@ def main(argv):
typed = "" typed = ""
if not typed: if not typed:
return 1 return 1
host = pick_host(typed) found = pick_host(typed)
say(f" found: {host}") host, port = found
say(f" found: {host}" + (f" port {port}" if port != 22 else ""))
say("==> SSH key") say("==> SSH key")
SSH_DIR.mkdir(mode=0o700, exist_ok=True) make_ssh_dir()
if KEY.exists(): if KEY.exists():
say(f" exists: {KEY}") say(f" exists: {KEY}")
else: else:
@@ -105,7 +147,7 @@ def main(argv):
say(f" created {KEY}") say(f" created {KEY}")
say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}") say(f"==> ~/.ssh/config alias '{FRAME_ALIAS}' -> {host}")
write_config(host) write_config(host, port)
say("==> Checking key login") say("==> Checking key login")
if key_login_works(): if key_login_works():
@@ -114,7 +156,7 @@ def main(argv):
say(" copying the key: enter the Developer Mode password when asked") say(" copying the key: enter the Developer Mode password when asked")
pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip() pub = KEY.with_suffix(".pub").read_text(encoding="utf-8").strip()
r = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "PubkeyAuthentication=no", 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(): if r.returncode != 0 or not key_login_works():
say("Key login still isn't working. Check the password and run this again.") say("Key login still isn't working. Check the password and run this again.")
return 1 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. # `start` gives the command its own console window; cmd /k keeps it open.
# One hand-built command line: quoting it twice through list2cmdline would # One hand-built command line: quoting it twice through list2cmdline would
# produce backslash-escaped quotes, which cmd doesn't understand. # 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) subprocess.Popen(f'cmd.exe /c start "{title}" cmd.exe /k "{inner}"', **DETACHED)
return "a terminal window" return "a terminal window"
script = f'{shlex.join(argv)}; echo; read -r -p "Press Enter to close. " _' 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"), for name, flags in (("x-terminal-emulator", ["-e"]), ("gnome-terminal", ["--"]), ("ptyxis", ["--"]),
("xfce4-terminal", "-x"), ("kitty", None), ("alacritty", "-e"), ("kgx", ["--"]), ("konsole", ["-e"]), ("xfce4-terminal", ["-x"]),
("foot", None), ("xterm", "-e")): ("tilix", ["-e"]), ("lxterminal", ["-e"]), ("kitty", []), ("alacritty", ["-e"]),
("wezterm", ["start", "--"]), ("foot", []), ("xterm", ["-e"])):
exe = which(name) exe = which(name)
if exe: if exe:
_spawn([exe, *([flag] if flag else []), "bash", "-c", script]) _spawn([exe, *flags, "bash", "-c", script])
return name return name
raise HostError("No terminal program found (tried gnome-terminal, konsole, xterm and others)") 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) { async function sendFiles(files) {
for (const f of 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"); const apk = f.name.toLowerCase().endsWith(".apk");
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push")); 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 HERE = Path(__file__).resolve().parent
FRAME = os.environ.get("FRAME_ALIAS", "frame") 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 # Reuse one SSH connection for the frequent status/screenshot calls, where ssh
# supports it (not on Windows: there every command connects on its own). # supports it (not on Windows: there every command connects on its own).
CONTROL = frame_host.control_path() CONTROL = frame_host.control_path()
@@ -763,7 +765,7 @@ POST = {"/api/android/display": android_display, "/api/android": android,"/api/l
def _pipe_reader(pipe): def _pipe_reader(pipe):
"""Chunks from a pipe via a thread; select() can't wait on pipes on Windows.""" """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(): def pump():
try: 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.""" """Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
name = Path(path).name name = Path(path).name
try: 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)}"] cmd = ["rsync", "-a", "-e", shlex.join(SSH), str(path), f"{FRAME}:{shlex.quote(dest)}"]
else: else:
# Modern scp uses SFTP, so the remote path isn't parsed by a shell. # Modern scp uses SFTP, so the remote path isn't parsed by a shell.