diff --git a/README.md b/README.md index 2e5055b..d532d50 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/app/build/fetch-python.js b/app/build/fetch-python.js index ffc28e1..d883b9a 100644 --- a/app/build/fetch-python.js +++ b/app/build/fetch-python.js @@ -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; } diff --git a/app/main.js b/app/main.js index ada05d1..4f5a522 100644 --- a/app/main.js +++ b/app/main.js @@ -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); diff --git a/app/package.json b/app/package.json index 930c114..75add75 100644 --- a/app/package.json +++ b/app/package.json @@ -27,7 +27,8 @@ }, "files": [ "main.js", - "package.json" + "package.json", + "build/icon.png" ], "extraResources": [ { diff --git a/docs/frame-control.md b/docs/frame-control.md index e3df8ee..e612947 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -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 diff --git a/ui/frame_android.py b/ui/frame_android.py index 2537d66..c015eb5 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -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)}') diff --git a/ui/frame_compat_db.py b/ui/frame_compat_db.py index 66ef861..98c67c4 100644 --- a/ui/frame_compat_db.py +++ b/ui/frame_compat_db.py @@ -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() diff --git a/ui/frame_connect.py b/ui/frame_connect.py index 959ae25..0728a94 100644 --- a/ui/frame_connect.py +++ b/ui/frame_connect.py @@ -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 diff --git a/ui/frame_host.py b/ui/frame_host.py index 321b550..6b2dde7 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -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)") diff --git a/ui/index.html b/ui/index.html index 1e5f53e..b242cf3 100644 --- a/ui/index.html +++ b/ui/index.html @@ -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")); } diff --git a/ui/server.py b/ui/server.py index 8634f3a..6a48e14 100755 --- a/ui/server.py +++ b/ui/server.py @@ -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.