#!/usr/bin/env python3 """Deploy Pixel VR Player to a Steam Frame that is in Developer Mode. This copies the aarch64 binary, the compiled SPIR-V shaders and the direct-run helper into ``~/devkit-game/PixelVRPlayer`` on the headset and registers it as a Non-Steam "Devkit Game" that runs *directly on the host* (no Steam Linux Runtime container), which is how a native SteamOS/ARM64 OpenXR app must launch so the SteamVR runtime and compositor are wired up. Prerequisites on the headset: * Developer Mode enabled and paired with the SteamOS Devkit Client (this installs ``~/devkit-utils`` and authorises the ``devkit_rsa`` key). Examples: python3 tools/deploy.py # copy + register only python3 tools/deploy.py --launch # copy, register and start it python3 tools/deploy.py --launch -- /home/steamos/Videos/clip_360_tb.mp4 python3 tools/deploy.py --host steamos@192.168.1.50 --launch Environment overrides: FRAME_HOST, FRAME_SSH_KEY. """ from __future__ import annotations import argparse import json import os import shlex import subprocess import sys from pathlib import Path GAME = "PixelVRPlayer" REMOTE_DIR = f"devkit-game/{GAME}" # relative to the remote $HOME PROJECT_ROOT = Path(__file__).resolve().parent.parent def default_devkit_key() -> str | None: """Locate the SteamOS Devkit Client SSH key for the current OS.""" if sys.platform == "win32": base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) candidates = [base / "steamos-devkit" / "steamos-devkit" / "devkit_rsa"] elif sys.platform == "darwin": base = Path.home() / "Library" / "Application Support" candidates = [base / "steamos-devkit" / "devkit_rsa"] else: base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) candidates = [base / "steamos-devkit" / "devkit_rsa"] for key in candidates: if key.is_file(): return str(key) return None def find_build_outputs() -> tuple[Path, Path]: """Return (binary, shader_dir), preferring the aarch64 build.""" for build in ("build-aarch64", "build"): binary = PROJECT_ROOT / build / "bin" / "pixelvr" if binary.is_file(): return binary, PROJECT_ROOT / build / "shaders" raise FileNotFoundError( "No pixelvr binary found. Build first, e.g.: ./tools/build.sh --aarch64" ) def main() -> int: parser = argparse.ArgumentParser( description="Deploy Pixel VR Player to a Steam Frame.", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--host", default=os.environ.get("FRAME_HOST", "steamos@frame"), help="SSH target (default: steamos@frame or $FRAME_HOST).", ) parser.add_argument( "--key", default=os.environ.get("FRAME_SSH_KEY") or default_devkit_key(), help="SSH private key (default: SteamOS Devkit devkit_rsa).", ) parser.add_argument( "--launch", action="store_true", help="Start the app on the headset after deploying.", ) args, player_args = parser.parse_known_args() if player_args and player_args[0] == "--": player_args = player_args[1:] binary, shader_dir = find_build_outputs() shaders = sorted(shader_dir.glob("*.spv")) if not shaders: print(f"warning: no compiled shaders in {shader_dir}", file=sys.stderr) host = args.host if "@" in args.host else f"steamos@{args.host}" ssh_opts = ["-o", "ConnectTimeout=10", "-o", "BatchMode=yes"] if args.key: ssh_opts += ["-i", str(Path(args.key).expanduser())] def ssh(command: str) -> str: result = subprocess.run( ["ssh", *ssh_opts, host, command], check=True, stdout=subprocess.PIPE, text=True, timeout=180, ) return result.stdout.strip() def scp(sources: list[str], dest: str) -> None: subprocess.run( ["scp", *ssh_opts, *sources, f"{host}:{dest}"], check=True, timeout=300, ) # 1. Verify Developer Mode, ensure the target dir, refuse to clobber a run. print(f"Connecting to {host} ...") remote_dir = ssh( "set -eu; " 'test -x "$HOME/devkit-utils/steam-client-create-shortcut" || ' "{ echo 'error: ~/devkit-utils missing - enable Developer Mode and pair " "the SteamOS Devkit Client first.' >&2; exit 1; }; " f'target="$HOME/{REMOTE_DIR}"; ' 'if command -v fuser >/dev/null 2>&1 && fuser "$target/pixelvr" ' ">/dev/null 2>&1; then echo 'error: pixelvr is running - stop it in Steam " "before deploying.' >&2; exit 1; fi; " 'mkdir -p "$target/shaders"; printf %s "$target"' ) # 2. Copy binary, shaders, launcher, and icon. print("Copying binary ...") scp([str(binary)], f"{REMOTE_DIR}/pixelvr") if shaders: print(f"Copying {len(shaders)} shader(s) ...") scp([str(p) for p in shaders], f"{REMOTE_DIR}/shaders/") launch_helper = PROJECT_ROOT / "tools" / "launch.sh" if launch_helper.is_file(): scp([str(launch_helper)], f"{REMOTE_DIR}/launch.sh") for asset_name in ("icon.png", "hero.png", "capsule.png", "header.png", "logo.png"): asset_file = PROJECT_ROOT / "assets" / asset_name if asset_file.is_file(): scp([str(asset_file)], f"{REMOTE_DIR}/{asset_name}") # 3. Register the Non-Steam shortcut (runs on the host, not in Sniper). request = { "gameid": GAME, "directory": remote_dir, "argv": ["./pixelvr", *player_args], "settings": {"steam_play": "0", "compat_tool": ""}, } print("Registering the Non-Steam shortcut ...") raw = ssh( f"chmod u+x {shlex.quote(f'{REMOTE_DIR}/pixelvr')} " f"{shlex.quote(f'{REMOTE_DIR}/launch.sh')} 2>/dev/null; " + shlex.join( [ "python3", "-B", "devkit-utils/steam-client-create-shortcut", "--parms", json.dumps(request), ] ) ) try: response = json.loads(raw) if raw else {} except json.JSONDecodeError: response = {"raw": raw} if isinstance(response, dict) and response.get("error"): raise RuntimeError(f"shortcut registration failed: {response['error']}") # 3b. Sanitize shortcuts.vdf to remove the 'Devkit Game: ' prefix and set icon. sanitize_script = ( "import glob, os, struct, shutil\n" "OBJ, STR, INT, U64, END = 0, 1, 2, 7, 8\n" "def read_cstr(buf, pos):\n" " end = buf.find(b'\\0', pos)\n" " return buf[pos:end].decode('utf-8', 'surrogateescape'), end + 1\n" "def parse_obj(buf, pos):\n" " entries = {}\n" " while pos < len(buf):\n" " kind = buf[pos]\n" " if kind in (END, 0x0B): return entries, pos + 1\n" " key, pos = read_cstr(buf, pos + 1)\n" " if kind == OBJ: value, pos = parse_obj(buf, pos)\n" " elif kind == STR: value, pos = read_cstr(buf, pos)\n" " elif kind == INT: value = struct.unpack_from(' Library > Non-Steam > 'Pixel VR Player'") print(f" Or over SSH: ssh {host} '{REMOTE_DIR}/launch.sh