mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
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:
1 parent
fc2fa65f0d
commit
e210407f31
11 files changed
+90
-39
No files matched your search
@@ -93,7 +93,7 @@ already ships. [How each feature works](docs/frame-control.md).
|
||||
| | 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`) |
|
||||
| **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** (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 |
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ function get(url) {
|
||||
}
|
||||
|
||||
(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}`);
|
||||
return;
|
||||
}
|
||||
|
||||
+3
-2
@@ -109,7 +109,7 @@ function ping(target) {
|
||||
|
||||
async function startServer() {
|
||||
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);
|
||||
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}`);
|
||||
@@ -118,7 +118,8 @@ async function startServer() {
|
||||
const log = fs.openSync(LOG, "a");
|
||||
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.
|
||||
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 });
|
||||
child.stdin.on("error", () => {});
|
||||
fs.closeSync(log);
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"package.json"
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
Windows-only. Steam Link views the headset.
|
||||
|
||||
You can also run the same UI in a browser without the app, from a checkout on
|
||||
macOS or Linux:
|
||||
You can also run the same UI in a browser without the app, from a checkout:
|
||||
|
||||
```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
|
||||
|
||||
+3
-2
@@ -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)}')
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user