Files
spoopyghosty0--frameport/agent/frameport_agent.py
T

10587 lines
496 KiB
Python

#!/usr/bin/env python3
"""FramePort agent: runs ON the Steam Frame (SteamOS, python3 stdlib only).
The PC app uploads this file to ~/.local/share/frameport/agent/ and calls:
python3 frameport_agent.py <command> (JSON arguments on stdin, one JSON object on stdout)
Commands: info, prepare, finalize, shortcuts, shortcut_status, launch_test, stop, set_settings, uninstall,
install_lepton, list_installed, proton_status, install_proton, prepare_pcvr, finalize_pcvr,
controller_models, list_screenshots, delete_screenshots (and more: see the cmd_* functions).
Streaming: python3 frameport_agent.py _keyboard (a virtual keyboard: JSON lines on stdin, see keyboard_session)
python3 frameport_agent.py _backup_saves|_restore_saves '<json>' (save backups: tar.gz on stdout/stdin)
Install layout (one Lepton container per game; same as the manual installs from 2026-09):
~/Applications/quest-frame/<pkg>/ anchor: launch.sh, deployment.json, artwork/ (always internal storage)
<dest>/<pkg>/lepton-app/{game.apk,obb/} game files (dest defaults to ~/Applications/quest-frame; another
drive: <mount>/FramePort, see `drives`; `move` moves a game)
<dest>/<pkg>/lepton-data/ container data + saves (kept across reinstalls)
<dest>/<pkg>/lepton-shaders/, settings.conf, launch.log
PC VR (Oculus Rift) games packed for the Frame (id "rift.<slug>"), run by Proton (ARM64; x86 via FEX in Proton):
~/Applications/quest-frame/<id>/ anchor: launch.sh, deployment.json (kind "pcvr"), artwork/
<dest>/<id>/game/ the Windows game folder
<dest>/<id>/revive/ Revive (ReviveInjector.exe + DLLs)
<dest>/<id>/compatdata/ Proton prefix = saves (kept across reinstalls), launch.log
"""
import base64
import glob
import hashlib
import json
import math
import os
import re
import shlex
import shutil
import signal
import struct
import subprocess
import sys
import tempfile
import time
import zipfile
import zlib
from types import SimpleNamespace
try:
import fcntl
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 110
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
LEPTON_APPID = "3029110" # fallback when no appmanifest names Lepton
PKG_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$")
VIDEO_CODEC_DIR = os.path.join(HOME, ".local/share/frameport/video-codec")
VIDEO_CODEC_FILES = ("libstagefrighthw.so", "media_codecs_frameport.xml", "podman.py", "COPYING.FFmpeg")
# Hardware video decoding (patch frame.hw_video_decode, per game): only launchers of games whose recipe has it put the
# shared codec's Podman wrapper first on Lepton's PATH (it adds the codec plugin to that game's container). Off for
# every game: FramePort's setting (VIDEO_CODEC_DIR/disabled); one game: FRAMEPORT_NO_HW_VIDEO=1 in its launch options.
VIDEO_CODEC_LINE = ('codec_dir="$HOME/.local/share/frameport/video-codec"\n'
'[[ "${FRAMEPORT_NO_HW_VIDEO:-0}" != 0 || -e "$codec_dir/disabled" || '
'! -x "$codec_dir/current/bin/podman" ]] || export PATH="$codec_dir/current/bin:$PATH"')
HW_VIDEO_PATCH = "frame.hw_video_decode"
# earlier codec lines: agent <= 70 (per-game codec extracted from the APK, line in every launcher) and PR #128's
# shared line (in every Lepton launcher)
OLD_CODEC_LINES = ('[[ ! -x "$app_dir/frameport-codec/bin/podman" ]] || '
'export PATH="$app_dir/frameport-codec/bin:$PATH"',
'codec_bin="$HOME/.local/share/frameport/video-codec/current/bin"\n'
'[[ ! -x "$codec_bin/podman" ]] || export PATH="$codec_bin:$PATH"')
# Steam Input's virtual gamepad for 2D Android apps (patch device.steam_gamepad, per game, GitHub #162): Lepton's
# Android only gets keyboard/pointer/touch from the Wayland seat, so a game never sees a controller. The launcher of a
# game that has it puts FramePort's own Podman wrapper (PODMAN_WRAPPER, written next to the agent, independent of the
# video codec) first on Lepton's PATH; for that game's `podman run` it bind-mounts Steam's virtual pads
# (/dev/input/eventN) and a key layout, then hands on to the next Podman on PATH (the codec wrapper, if that game has
# it, else Podman itself). LEPTON_ENV_SDL_... = SDL's hint that stops it ignoring Steam's virtual pad (Lepton passes
# LEPTON_ENV_<NAME> to the app as <NAME>). Off for one game: FRAMEPORT_NO_GAMEPAD=1 in its launch options.
GAMEPAD_PATCH = "device.steam_gamepad"
PODMAN_BIN = os.path.join(HOME, ".local/share/frameport/agent/bin")
# Pads come and go (Steam makes its virtual pad only while the Frame's controllers are awake): the container gets the
# host's /dev/input at /dev/fp-input, and _gamepad_links keeps /dev/input/eventN links to Steam's pads in step while
# the game runs (Android's EventHub adds and removes them through inotify; agent v85, before only the pads present at
# the start were bound).
OLD_GAMEPAD_LINES = ('[[ "${FRAMEPORT_NO_GAMEPAD:-0}" != 0 ]] || { export FRAMEPORT_GAMEPAD=1 '
'LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=1; '
'[[ ! -x "$HOME/.local/share/frameport/agent/bin/podman" ]] || '
'export PATH="$HOME/.local/share/frameport/agent/bin:$PATH"; }',)
GAMEPAD_LINE = ('[[ "${FRAMEPORT_NO_GAMEPAD:-0}" != 0 ]] || { export FRAMEPORT_GAMEPAD=1 '
'LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=1; '
'[[ ! -x "$HOME/.local/share/frameport/agent/bin/podman" ]] || '
'export PATH="$HOME/.local/share/frameport/agent/bin:$PATH"; '
'python3 "$HOME/.local/share/frameport/agent/frameport_agent.py" _gamepad_links "$SteamAppId" $$ '
'>"$app_dir/gamepad.log" 2>&1 9>&- & }')
PAD_DIR = "/dev/fp-input" # the host's /dev/input inside the container
class AgentError(Exception):
pass
def run(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, errors="replace", **kw)
def sha256_file(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
return h.hexdigest()
def check_pkg(pkg):
if not pkg or not PKG_RE.match(pkg):
raise AgentError(f"bad package name {pkg!r}")
return pkg
# ------------------------------------------------------------------------------------------ Steam / Lepton discovery
def steam_libraries():
libs = [os.path.join(STEAM, "steamapps")]
vdf = os.path.join(STEAM, "steamapps/libraryfolders.vdf")
try:
for path in re.findall(r'"path"\s+"([^"]+)"', open(vdf, encoding="utf-8", errors="replace").read()):
p = os.path.join(path, "steamapps")
if p not in libs and os.path.isdir(p):
libs.append(p)
except OSError:
pass
return libs
def find_app(name_regex):
for lib in steam_libraries():
for acf in glob.glob(os.path.join(lib, "appmanifest_*.acf")):
try:
text = open(acf, encoding="utf-8", errors="replace").read()
except OSError:
continue
name = re.search(r'"name"\s+"([^"]*)"', text)
if name and re.fullmatch(name_regex, name[1]):
appid = re.search(r'"appid"\s+"(\d+)"', text)[1]
installdir = re.search(r'"installdir"\s+"([^"]*)"', text)[1]
return {"appid": appid, "name": name[1], "dir": os.path.join(lib, "common", installdir)}
return None
def lepton_path():
app = find_app(r"Lepton")
candidates = (([os.path.join(app["dir"], "lepton")] if app else [])
+ [os.path.join(STEAM, "steamapps/common/Lepton/lepton")])
for c in candidates:
if os.access(c, os.X_OK):
return c, app
return None, app
def find_app_id(appid):
"""{appid, name, dir} of an installed app by id (its appmanifest), or None."""
for lib in steam_libraries():
acf = os.path.join(lib, f"appmanifest_{appid}.acf")
try:
text = open(acf, encoding="utf-8", errors="replace").read()
except OSError:
continue
installdir = re.search(r'"installdir"\s+"([^"]*)"', text)
name = re.search(r'"name"\s+"([^"]*)"', text)
state = re.search(r'"StateFlags"\s+"(\d+)"', text)
d = os.path.join(lib, "common", installdir[1]) if installdir else None
num = {k: int(m[1]) for k in ("BytesDownloaded", "BytesToDownload", "SizeOnDisk")
for m in [re.search(rf'"{k}"\s+"(\d+)"', text)] if m}
return {"appid": str(appid), "name": name[1] if name else "", "dir": d, "state": int(state[1]) if state else 0,
"complete": bool(state and int(state[1]) & 4 and d and os.path.isdir(d)),
"downloaded": num.get("BytesDownloaded", 0), "to_download": num.get("BytesToDownload", 0)}
return None
# ------------------------------------------------------------------------------------------ Steam appinfo (binary)
APPINFO = os.path.join(STEAM, "appcache/appinfo.vdf")
def appinfo_entries(want=None, path=APPINFO):
"""Parse Steam's appinfo.vdf (v28/v29) into {appid: appinfo dict}; only the apps in `want` (or all)."""
data = open(path, "rb").read()
magic = struct.unpack_from("<I", data, 0)[0]
if magic >> 8 != 0x075644 or magic & 0xFF not in (0x28, 0x29):
raise AgentError(f"unknown appinfo.vdf version {magic:#x}")
v29 = magic & 0xFF == 0x29
strings = []
end = len(data)
if v29:
str_off = struct.unpack_from("<q", data, 8)[0]
n = struct.unpack_from("<I", data, str_off)[0]
p = str_off + 4
for _ in range(n):
e = data.index(b"\0", p)
strings.append(data[p:e].decode("utf-8", "replace"))
p = e + 1
end = str_off
def key(p):
if v29:
return strings[struct.unpack_from("<I", data, p)[0]], p + 4
e = data.index(b"\0", p)
return data[p:e].decode("utf-8", "replace"), e + 1
def kv(p):
obj = {}
while True:
t = data[p]
p += 1
if t == 8:
return obj, p
k, p = key(p)
if t == 0:
obj[k], p = kv(p)
elif t == 1:
e = data.index(b"\0", p)
obj[k] = data[p:e].decode("utf-8", "replace")
p = e + 1
elif t in (2, 3, 4, 6): # int32, float, pointer, color
obj[k] = struct.unpack_from("<f" if t == 3 else "<i", data, p)[0]
p += 4
elif t in (7, 10): # uint64 / int64
obj[k] = struct.unpack_from("<Q", data, p)[0]
p += 8
else:
raise AgentError(f"unsupported appinfo value type {t}")
out, p = {}, 16 if v29 else 8
while p + 8 <= end:
appid, size = struct.unpack_from("<II", data, p)
if appid == 0:
break
body = p + 8
if want is None or appid in want:
try:
obj, _ = kv(body + 60) # infostate, last updated, pics token, sha1, change number, binary sha1
out[appid] = obj.get("appinfo", obj)
except (AgentError, IndexError, struct.error, ValueError):
pass
p = body + size
return out
def arm64_compat_tools():
"""Steam compat tools for this device from Valve's ARM64 compat list app (appinfo extended.compat_tools):
{name: {appid, display_name, require_tool_appid, aliases, from_oslist}}. Found dynamically (no hardcoded ids)."""
best = {}
for info in appinfo_entries().values():
tools = ((info.get("extended") or {}).get("compat_tools"))
if isinstance(tools, dict) and any(k.endswith("-arm64") for k in tools):
if len(tools) > len(best):
best = tools
return best
def _compat_entries(keep):
"""Compat tools from the ARM64 compat list that `keep(name, entry)` selects, newest first, with install state."""
out = []
for name, t in arm64_compat_tools().items():
if not isinstance(t, dict) or "appid" not in t or not keep(name, t):
continue
app = find_app_id(t["appid"])
req = t.get("require_tool_appid")
if app and app["complete"]:
req = tool_manifest(app["dir"]).get("require_tool_appid") or req
req_app = find_app_id(req) if req else None
ver = re.findall(r"\d+", name)
out.append({"name": name, "appid": int(t["appid"]), "display_name": t.get("display_name", name),
"aliases": t.get("aliases", ""), "experimental": "experimental" in name,
"installed": bool(app and app["complete"]), "dir": app["dir"] if app else None,
"require_tool_appid": int(req) if req else None,
"require_installed": (not req) or bool(req_app and req_app["complete"]),
"require_dir": req_app["dir"] if req_app else None,
"sort": (0 if "experimental" in name else 1, int(ver[0]) if ver else 0)})
out.sort(key=lambda t: t.pop("sort"), reverse=True)
return out
def proton_tools():
"""Proton builds for Windows games on this (ARM64) device, newest first, with install state."""
return _compat_entries(lambda name, t: t.get("from_oslist") == "windows")
def linux_x86_tools():
"""Compat tools that run Linux x86_64 programs on this ARM64 device: FEX-Emu (app "fex" 3127680; its
fex-compat-tool runs the program with FEX on SteamOS's x86 guest root /usr/share/guestos/fex-mesa)."""
# (the Steam Linux Runtimes are listed as Linux tools too: they are what FEX needs, not a translator)
return _compat_entries(lambda name, t: "fex" in f"{name} {t.get('display_name', '')}".lower())
def compat_tools(kind):
return linux_x86_tools() if kind == "linux_x86" else proton_tools()
def pick_tool(kind, tools, wanted=None):
if kind != "linux_x86":
return pick_proton(tools, wanted)
if wanted:
return next((t for t in tools if wanted in (t["name"], t["display_name"])), None)
ready = [t for t in tools if t["installed"] and t["require_installed"]]
return (ready or tools or [None])[0]
def tool_manifest(tool_dir):
"""commandline / require_tool_appid from a compat tool's toolmanifest.vdf (text KeyValues)."""
try:
text = open(os.path.join(tool_dir, "toolmanifest.vdf"), encoding="utf-8", errors="replace").read()
except OSError:
return {}
out = {}
for k in ("commandline", "require_tool_appid", "version"):
m = re.search(rf'"{k}"\s+"((?:[^"\\]|\\.)*)"', text)
if m:
out[k] = m[1].replace('\\"', '"')
if "require_tool_appid" in out:
out["require_tool_appid"] = int(out["require_tool_appid"])
return out
def compat_command(tool_dir, verb="waitforexitandrun", depth=0):
"""argv prefix Steam would run for a compat tool: its required runtime's command first, then the tool's own
(e.g. SteamLinuxRuntime_4-arm64/_v2-entry-point --verb=... -- Proton/proton waitforexitandrun)."""
man = tool_manifest(tool_dir)
cmd = man.get("commandline")
if not cmd:
raise AgentError(f"no toolmanifest.vdf commandline in {tool_dir}")
own = [os.path.join(tool_dir, a.lstrip("/")) if i == 0 else a
for i, a in enumerate(shlex.split(cmd.replace("%verb%", verb)))]
req = man.get("require_tool_appid")
if req and depth < 3:
app = find_app_id(req)
if not app or not app["complete"]:
raise AgentError(f"{os.path.basename(tool_dir)} needs Steam app {req} (runtime), which isn't installed")
return compat_command(app["dir"], verb, depth + 1) + own
return own
def pick_proton(tools, wanted=None):
"""The Proton build a game uses: the one asked for (name, display name or alias such as proton-experimental),
else the newest stable one (owner's choice 2026-10-05 after an A/B in the headset: Rick and Morty felt much
smoother on Proton 11 than on Experimental; a game that fails gets "try Proton Experimental" suggested).
Returned even when not installed yet: the installer installs it."""
def matches(t, w):
return w in (t["name"], t["display_name"]) or w in [a.strip() for a in t["aliases"].split(",")]
if wanted:
return next((t for t in tools if matches(t, wanted)), None)
stable = [t for t in tools if not t["experimental"]]
ready = [t for t in stable if t["installed"] and t["require_installed"]]
return (ready or stable or tools or [None])[0]
def openxr_runtime():
for d in (os.path.join(HOME, ".config/openxr/1"), "/etc/xdg/openxr/1", "/usr/share/openxr/1"):
p = os.path.join(d, "active_runtime.json")
if os.path.exists(p):
try:
return {"path": os.path.realpath(p), "name": json.load(open(p))["runtime"].get("name")}
except (OSError, ValueError, KeyError):
return {"path": os.path.realpath(p), "name": None}
return None
def cmd_proton_status(args):
"""Proton (kind "proton", the default) or the Linux x86_64 tool FEX (kind "linux_x86"): installed or not."""
kind = args.get("kind") or "proton"
try:
tools = compat_tools(kind)
except (OSError, AgentError) as exc:
return {"tools": [], "ready": None, "error": str(exc), "openxr": openxr_runtime()}
ready = pick_tool(kind, tools, args.get("tool"))
ok = bool(ready and ready["installed"] and ready["require_installed"])
download = {"done": 0, "total": 0}
if ready and not ok:
for a in (ready["appid"], ready.get("require_tool_appid")):
app = find_app_id(a) if a else None
if app and not app["complete"]:
download["done"] += app["downloaded"]
download["total"] += app["to_download"]
return {"tools": tools, "ready": ready if ok else None, "suggested": ready, "openxr": openxr_runtime(),
"download": download}
SELFTEST_DIR = os.path.join(HOME, ".local/share/frameport/proton-selftest")
SESSION_VARS = ("DISPLAY", "WAYLAND_DISPLAY", "GAMESCOPE_WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE")
def session_env():
"""The display session variables of the running Steam client (gamescope's X/Wayland), for headless launches."""
p = run(["pgrep", "-x", "steam"])
for pid in p.stdout.split():
try:
raw = open(f"/proc/{pid}/environ", "rb").read().split(b"\0")
except OSError:
continue
env = dict(kv.decode(errors="replace").split("=", 1) for kv in raw if b"=" in kv)
return {k: env[k] for k in SESSION_VARS if k in env}
return {}
def run_tree(cmd, env, cwd, log_path, timeout):
"""Run a command in its own process group with output to a file; on timeout kill the whole group (Wine leaves
children that keep pipes open, so subprocess.run(timeout=...) would hang). Returns (output, exit code | None)."""
import signal
with open(log_path, "wb") as log:
p = subprocess.Popen(cmd, env=env, cwd=cwd, stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
start_new_session=True)
try:
code = p.wait(timeout=timeout)
except subprocess.TimeoutExpired:
code = None
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(p.pid, sig)
except ProcessLookupError:
break
try:
p.wait(timeout=10)
break
except subprocess.TimeoutExpired:
continue
return open(log_path, errors="replace").read(), code
def stop_prefix(tool, prefix):
"""wineserver -k for a Proton prefix (ends every process of that prefix)."""
wineserver = next(iter(glob.glob(os.path.join(tool["dir"], "files/bin*/wineserver"))), None)
if wineserver and os.path.isdir(prefix):
try:
run([wineserver, "-k"], env=dict(os.environ, WINEPREFIX=prefix), timeout=20)
except subprocess.TimeoutExpired:
pass
def cmd_proton_selftest(args):
"""Run a Windows program (cmd.exe) under the Frame's Proton, the way FramePort launches PC VR games (SteamGameId
set so Proton sets up VR), and report whether it ran and whether wineopenxr was registered as the OpenXR runtime."""
tool = pick_proton(proton_tools(), args.get("tool"))
if not tool or not (tool["installed"] and tool["require_installed"]):
raise AgentError("Proton isn't installed on the Frame yet")
os.makedirs(os.path.join(SELFTEST_DIR, "compatdata"), exist_ok=True)
appid = str(shortcut_appid("frameport-proton-selftest", "FramePort"))
env = dict(os.environ, SteamAppId=appid, STEAM_COMPAT_APP_ID=appid,
STEAM_COMPAT_DATA_PATH=os.path.join(SELFTEST_DIR, "compatdata"),
STEAM_COMPAT_CLIENT_INSTALL_PATH=STEAM, STEAM_COMPAT_INSTALL_PATH=SELFTEST_DIR,
STEAM_COMPAT_LIBRARY_PATHS=SELFTEST_DIR, XDG_RUNTIME_DIR=f"/run/user/{os.getuid()}",
PROTON_LOG_DIR=SELFTEST_DIR)
for k, v in session_env().items():
env.setdefault(k, v)
if args.get("vr", True):
env["SteamGameId"] = appid # Proton sets up vrclient/wineopenxr only for "game" processes
if args.get("log"):
env["PROTON_LOG"] = "1"
for k, v in (args.get("env") or {}).items():
env[str(k)] = str(v)
marker = os.path.join(SELFTEST_DIR, "marker.txt")
if os.path.exists(marker):
os.remove(marker)
cmd = compat_command(tool["dir"], args.get("verb", "waitforexitandrun")) + \
["c:\\windows\\system32\\cmd.exe", "/c", f"echo FRAMEPORT_PROTON_OK>{windows_path(marker)}"]
start = time.time()
out, code = run_tree(cmd, env, SELFTEST_DIR, os.path.join(SELFTEST_DIR, "selftest.log"),
int(args.get("timeout_s", 600)))
stop_prefix(tool, os.path.join(SELFTEST_DIR, "compatdata/pfx"))
reg = os.path.join(SELFTEST_DIR, "compatdata/pfx/system.reg")
text = open(reg, errors="replace").read() if os.path.exists(reg) else ""
m = re.search(r'\[Software\\\\Khronos\\\\OpenXR\\\\1\][^\[]*"ActiveRuntime"="([^"]*)"', text)
plog = os.path.join(SELFTEST_DIR, f"steam-{appid}.log")
ran = os.path.exists(marker) and "FRAMEPORT_PROTON_OK" in open(marker, errors="replace").read()
return {"tool": tool["name"], "ran": ran, "exit_code": code, "proton_log": plog
if os.path.exists(plog) else None,
"seconds": round(time.time() - start), "openxr_runtime": m[1] if m else None,
"prefix_created": bool(text), "log_tail": out[-3000:]}
def xr_probe(payload):
"""(child process) Create an OpenXR instance with XR_KHR_convert_timespec_time through Proton's loader and call
xrConvertTimespecTimeToTimeKHR. Prints one JSON line. The layer (if any) is enabled via the environment."""
import ctypes as C
args = json.loads(payload)
out = {"loader": args["loader"]}
try:
xr = C.CDLL(args["loader"])
xr.xrGetInstanceProcAddr.argtypes = [C.c_uint64, C.c_char_p, C.POINTER(C.c_void_p)]
xr.xrDestroyInstance.argtypes = [C.c_uint64]
class AppInfo(C.Structure):
_fields_ = [("applicationName", C.c_char * 128), ("applicationVersion", C.c_uint32),
("engineName", C.c_char * 128), ("engineVersion", C.c_uint32), ("apiVersion", C.c_uint64)]
class CreateInfo(C.Structure):
_fields_ = [("type", C.c_int), ("next", C.c_void_p), ("createFlags", C.c_uint64), ("app", AppInfo),
("layerCount", C.c_uint32), ("layers", C.c_void_p), ("extCount", C.c_uint32),
("exts", C.POINTER(C.c_char_p))]
class LayerProps(C.Structure):
_fields_ = [("type", C.c_int), ("next", C.c_void_p), ("layerName", C.c_char * 256),
("specVersion", C.c_uint64), ("layerVersion", C.c_uint32), ("description", C.c_char * 256)]
n = C.c_uint32()
xr.xrEnumerateApiLayerProperties(0, C.byref(n), None)
props = (LayerProps * max(n.value, 1))()
for p in props:
p.type = 1 # XR_TYPE_API_LAYER_PROPERTIES
xr.xrEnumerateApiLayerProperties(n.value, C.byref(n), props)
out["layers"] = [props[i].layerName.decode() for i in range(n.value)]
exts = (C.c_char_p * 1)(b"XR_KHR_convert_timespec_time")
ci = CreateInfo(3, None, 0, AppInfo(b"FramePort timefix probe", 1, b"FramePort", 1, 1 << 48), 0, None, 1, exts)
inst = C.c_uint64()
out["create"] = xr.xrCreateInstance(C.byref(ci), C.byref(inst))
if out["create"] == -4: # the runtime sometimes fails the first attempt (as Proton's own probe sees)
out["create"] = xr.xrCreateInstance(C.byref(ci), C.byref(inst))
if out["create"] == 0:
fn = C.c_void_p()
out["proc"] = xr.xrGetInstanceProcAddr(inst.value, b"xrConvertTimespecTimeToTimeKHR", C.byref(fn))
if out["proc"] == 0 and fn.value:
class Ts(C.Structure):
_fields_ = [("tv_sec", C.c_long), ("tv_nsec", C.c_long)]
now = Ts()
C.CDLL(None).clock_gettime(1, C.byref(now))
t = C.c_int64()
conv = C.CFUNCTYPE(C.c_int, C.c_uint64, C.POINTER(Ts), C.POINTER(C.c_int64))(fn.value)
out["convert"] = conv(inst.value, C.byref(now), C.byref(t))
out["time"] = t.value
xr.xrDestroyInstance(inst.value)
except Exception as exc: # noqa: BLE001
out["error"] = f"{type(exc).__name__}: {exc}"
print(json.dumps(out))
def cmd_xr_layer_test(args):
"""Does the timefix layer fix time conversion on this runtime? Runs xr_probe with Proton's OpenXR loader, without
and with the layer (layer_dir = folder with XR_APILAYER_FRAMEPORT_timefix.json)."""
tool = pick_proton(proton_tools(), args.get("tool"))
loader = next(iter(glob.glob(os.path.join(tool["dir"], "files/lib/aarch64-linux-gnu/libopenxr_loader.so.1")))
if tool and tool.get("dir") else [], None) or "/opt/steamvr/bin/linuxarm64/libopenxr_loader.so"
results = {}
for name, layer in (("without_layer", None), ("with_layer", args.get("layer_dir"))):
if name == "with_layer" and not layer:
continue
env = dict(os.environ, XDG_RUNTIME_DIR=f"/run/user/{os.getuid()}", XR_LOADER_DEBUG="error")
env.pop("XR_API_LAYER_PATH", None)
env.pop("XR_ENABLE_API_LAYERS", None)
for k, v in session_env().items():
env.setdefault(k, v)
if layer:
env.update(XR_API_LAYER_PATH=os.path.expanduser(layer), XR_ENABLE_API_LAYERS=XR_LAYER)
try:
p = run([sys.executable, os.path.abspath(__file__), "_xr_probe", json.dumps({"loader": loader})],
env=env, timeout=60)
line = next((ln for ln in reversed(p.stdout.splitlines()) if ln.startswith("{")), None)
results[name] = json.loads(line) if line else {"error": (p.stderr or p.stdout)[-800:]}
except subprocess.TimeoutExpired:
results[name] = {"error": "timed out"}
return results
def write_stub_manifest(appid, name, installdir, lib=None):
"""An appmanifest with StateFlags 'update required': Steam downloads the app on its next start."""
lib = lib or os.path.join(STEAM, "steamapps")
path = os.path.join(lib, f"appmanifest_{appid}.acf")
if os.path.exists(path):
return False
text = ('"AppState"\n{\n' + "".join(f'\t"{k}"\t\t"{v}"\n' for k, v in (
("appid", appid), ("Universe", 1), ("name", name), ("StateFlags", 1026), ("installdir", installdir),
("AutoUpdateBehavior", 0))) + "}\n")
with open(path + ".tmp", "w") as f:
f.write(text)
os.replace(path + ".tmp", path)
return True
def cmd_install_proton(args):
"""Install Proton for Windows games (plus the Steam Linux Runtime it needs). mode "request" asks Steam
(steam://install; the user confirms in the headset); mode "unattended" writes appmanifest stubs and restarts
Steam so it downloads them by itself. kind "linux_x86": FEX (+ its runtime) for x86_64 Linux apps instead."""
kind = args.get("kind") or "proton"
tools = compat_tools(kind)
tool = pick_tool(kind, tools, args.get("tool"))
if not tool:
raise AgentError("this Steam has no ARM64 Proton in its compat list (update SteamOS/Steam)" if kind == "proton"
else "this Steam has no x86 translation tool (FEX) in its compat list (update SteamOS/Steam)")
need = [a for a, ok in ((tool["appid"], tool["installed"]),
(tool["require_tool_appid"], tool["require_installed"])) if a and not ok]
if not need:
return {"tool": tool["name"], "installed": True, "requested": []}
if args.get("mode") == "unattended":
info = appinfo_entries(set(need))
stubs = []
for a in need:
common = (info.get(a) or {}).get("common") or {}
installdir = ((info.get(a) or {}).get("config") or {}).get("installdir")
if not installdir:
raise AgentError(f"Steam's app cache has no install folder for app {a}")
stubs.append({"appid": a, "name": common.get("name", str(a)), "installdir": installdir})
payload = json.dumps({"stubs": stubs})
unit = f"frameport-tools-{int(time.time())}"
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={unit}", "--setenv=HOME=" + HOME,
sys.executable, os.path.abspath(__file__), "_tools_worker", payload])
return {"tool": tool["name"], "installed": False, "requested": need, "mode": "unattended", "unit": unit,
"hint": f"Steam restarts once and downloads {tool['display_name']} in the background "
"(a few hundred MB)."}
for a in need:
subprocess.Popen(["steam", "-ifrunning", f"steam://install/{a}"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
return {"tool": tool["name"], "installed": False, "requested": need, "mode": "request",
"hint": "Put the headset on and confirm the install in Steam (one dialog per item)."}
def stop_steam():
service = run(["systemctl", "--user", "is-active", "--quiet", "steam.service"]).returncode == 0
if service:
run(["systemctl", "--user", "stop", "steam.service"])
else:
run(["steam", "-shutdown"])
for _ in range(40):
if run(["pgrep", "-x", "steam"]).returncode:
break
time.sleep(1)
if run(["pgrep", "-x", "steam"]).returncode == 0:
raise AgentError("Steam did not close")
for _ in range(15): # Steam's helpers can still be writing its config (shortcuts.vdf) for a moment
if run(["pgrep", "-f", "steamwebhelper|steam.sh|reaper SteamLaunch"]).returncode:
break
time.sleep(1)
time.sleep(2)
return service
def start_steam(service):
if service:
run(["systemctl", "--user", "start", "steam.service"])
else:
subprocess.Popen(["systemd-run", "--user", "--collect", f"--unit=frameport-steam-{int(time.time())}",
"/usr/bin/steam"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def tools_worker(payload):
args = json.loads(payload)
service = True
try:
service = stop_steam()
for st in args["stubs"]:
write_stub_manifest(st["appid"], st["name"], st["installdir"])
finally:
start_steam(service)
def steam_users():
return sorted(d for d in os.listdir(os.path.join(STEAM, "userdata")) if d.isdigit() and d != "0") \
if os.path.isdir(os.path.join(STEAM, "userdata")) else []
STEAMID64_BASE = 76561197960265728
def active_steam_user():
"""userdata folder (account id) of the most recent Steam login (config/loginusers.vdf), if it has one."""
try:
text = open(os.path.join(STEAM, "config/loginusers.vdf"), encoding="utf-8", errors="replace").read()
except OSError:
return None
users = steam_users()
for sid, body in re.findall(r'"(\d{17})"\s*\{([^}]*)\}', text):
if re.search(r'"MostRecent"\s+"1"', body) and str(int(sid) - STEAMID64_BASE) in users:
return str(int(sid) - STEAMID64_BASE)
return None
def library_users():
"""Steam accounts whose library gets FramePort's shortcuts: every account on the Frame, the signed-in one first
(loginusers.vdf's MostRecent isn't always the account signed in on the Frame: GitHub #4/#21 got shortcuts in the
other account; an extra shortcut in an unused account does no harm)."""
users = steam_users()
if not users:
raise AgentError("Steam has no signed-in account on this Frame yet; sign in to Steam on the Frame first")
active = active_steam_user()
return [active] + [u for u in users if u != active] if active else users
def shortcut_appid_for(exe):
"""appid of the shortcut whose Exe is `exe` in the signed-in account's library (any account if unknown); None
when it isn't in the library."""
for u in library_users():
vdf = os.path.join(STEAM, "userdata", u, "config/shortcuts.vdf")
try:
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
except (OSError, AgentError):
continue
for v in (root.get("shortcuts") or {}).values():
if isinstance(v, dict) and v.get("Exe") == exe and v.get("appid"):
return v["appid"] & 0xFFFFFFFF
return None
def container_running(appid):
p = run(["podman", "ps", "--format", "{{.Names}}"])
return f"lepton-steamlaunch-{appid}" in p.stdout.split()
CONTAINERS_CONF = os.path.join(HOME, ".config/containers/containers.conf")
def ensure_host_fixes():
"""Rootless podman (used by Lepton) leaks one kernel session keyring per container start; after ~200 launches
since boot every game fails with 'crun: create keyring ...: Disk quota exceeded'. keyring=false stops that."""
changed = []
text = open(CONTAINERS_CONF).read() if os.path.exists(CONTAINERS_CONF) else ""
if not re.search(r"^\s*keyring\s*=", text, re.M):
note = "# FramePort: stop rootless podman leaking a kernel keyring per container start (Lepton launches)\n"
if re.search(r"^\[containers\]\s*$", text, re.M):
text = re.sub(r"^\[containers\]\s*$", "[containers]\n" + note + "keyring = false", text, count=1,
flags=re.M)
else:
sep = "\n" if text and not text.endswith("\n") else ""
text = text + sep + "[containers]\n" + note + "keyring = false\n"
os.makedirs(os.path.dirname(CONTAINERS_CONF), exist_ok=True)
with open(CONTAINERS_CONF, "w") as f:
f.write(text)
changed.append("podman keyring=false")
try:
ensure_podman_wrapper() # used only by launchers with the gamepad line (device.steam_gamepad)
except OSError:
pass
try:
upgraded = upgrade_launchers()
except Exception: # noqa: BLE001
upgraded = []
if upgraded:
changed.append(f"launchers: exit watchdog, dashboard, play log ({len(upgraded)})")
try:
old = remove_old_codec_dirs()
except Exception: # noqa: BLE001
old = []
if old:
changed.append(f"per-game video codec folders removed ({len(old)})")
try:
entries = refresh_desktop_entries()
except Exception: # noqa: BLE001
entries = []
if entries:
changed.append(f"Desktop Mode entries ({len(entries)})")
try:
overlays = overlay_registrations()
except Exception: # noqa: BLE001
overlays = []
if overlays:
changed.append(f"SteamVR overlay apps registered ({len(overlays)})")
try:
service = ensure_overlay_service()
except Exception: # noqa: BLE001
service = None
if service:
changed.append(f"overlay autostart service {service}")
return changed
def key_usage():
try:
for line in open("/proc/key-users"):
f = line.split()
if f[0].rstrip(":") == str(os.getuid()):
used, limit = f[3].split("/")
return {"keys": int(used), "max_keys": int(limit)}
except (OSError, ValueError, IndexError):
pass
return {}
POWER_SUPPLY = "/sys/class/power_supply"
CHARGER_TYPES = ("Mains", "USB", "USB_C", "USB_PD", "USB_PD_DRP", "USB_DCP", "USB_CDP", "USB_ACA", "Wireless")
def battery_state():
"""The Frame's battery: {"percent", "status" (Charging/Discharging/Full/Not charging), "plugged", "draining"};
None without one. "plugged" = a charger reports online (or the battery says it's charging/full); "draining" = the
battery's own gauge says Discharging (with a charger: it supplies less than the Frame uses, or just booted).
On the Frame (2026-10-03): max1720x_bat (Battery), pm8550b-charger (Unknown), tcpm …typec (USB, online=1)."""
def read(path):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return ""
battery, plugged = None, False
try:
names = sorted(os.listdir(POWER_SUPPLY))
except OSError:
return None
for name in names:
d = os.path.join(POWER_SUPPLY, name)
kind = read(os.path.join(d, "type"))
if kind == "Battery" and battery is None and read(os.path.join(d, "capacity")).isdigit():
battery = {"percent": int(read(os.path.join(d, "capacity"))), "status": read(os.path.join(d, "status"))}
elif kind in CHARGER_TYPES and read(os.path.join(d, "online")) == "1":
plugged = True
if battery is not None:
battery["plugged"] = plugged or battery["status"] in ("Charging", "Full")
battery["draining"] = battery["status"] == "Discharging"
return battery
BOOT_STATE = os.path.join(HOME, ".cache/frameport-boot.json")
# what the last lines of a boot's journal say when it was shut down or rebooted on purpose (a crash, a GPU hang that
# reset the Frame or a pulled battery leaves none of these)
CLEAN_SHUTDOWN = ("Reached target System Power Off", "Reached target System Reboot", "Reached target Shutdown",
"System is powering down", "System is rebooting", "systemd-shutdown", "Power-Off", "Rebooting.")
def boot_state():
"""This boot and how the previous one ended: {"boot_id", "boot_time", "prev_clean" (True/False/None = unknown),
"last_launch" ({"package", "title", "time"}: the FramePort game started last before this boot)}. Worked out once
per boot (cached), so the app can say "your Frame restarted while <game> was running"."""
try:
boot_id = open("/proc/sys/kernel/random/boot_id").read().strip()
except OSError:
return None
try:
cached = json.load(open(BOOT_STATE))
if cached.get("boot_id") == boot_id:
return cached
except (OSError, ValueError):
pass
boot_time = None
try:
for line in open("/proc/stat"):
if line.startswith("btime "):
boot_time = int(line.split()[1])
except OSError:
pass
try:
tail = run(["journalctl", "-b", "-1", "-n", "120", "-q", "--no-pager", "-o", "cat"]).stdout
except OSError:
tail = ""
prev_clean = any(m in tail for m in CLEAN_SHUTDOWN) if tail.strip() else None
last = None
for d in cmd_list_installed({})["games"]:
log = os.path.join(d["base"], "launch.log")
try:
t = os.path.getmtime(log)
except OSError:
continue
if (boot_time is None or t < boot_time) and (last is None or t > last["time"]):
last = {"package": d["package"], "title": d.get("title") or d["package"], "time": t}
state = {"boot_id": boot_id, "boot_time": boot_time, "prev_clean": prev_clean, "last_launch": last}
try:
os.makedirs(os.path.dirname(BOOT_STATE), exist_ok=True)
with open(BOOT_STATE, "w") as f:
json.dump(state, f)
except OSError:
pass
return state
def cmd_battery(args):
return {"battery": battery_state()}
def cmd_info(args):
lepton, app = lepton_path()
osr = {}
try:
for line in open("/etc/os-release"):
k, _, v = line.strip().partition("=")
osr[k] = v.strip('"')
except OSError:
pass
st = os.statvfs(HOME)
return {
"agent_version": AGENT_VERSION, "hostname": os.uname().nodename, "arch": os.uname().machine,
"os": osr.get("NAME"), "os_version": osr.get("VERSION_ID"), "build_id": osr.get("BUILD_ID"),
"lepton": lepton, "lepton_app": app, "lepton_dev": find_app(r"Lepton Development"),
"steam_users": steam_users(), "free_bytes": st.f_bavail * st.f_frsize,
"installed": cmd_list_installed({})["games"],
"steam_running": run(["pgrep", "-x", "steam"]).returncode == 0,
"host_fixes": ensure_host_fixes(), "kernel_keys": key_usage(),
"proton": cmd_proton_status({}) if args.get("proton", True) else None,
"boot": boot_state(),
"battery": battery_state(),
}
def cmd_install_lepton(args):
"""Ask Steam to install Lepton (requires Developer Mode)."""
lepton, app = lepton_path()
if lepton:
return {"installed": True, "path": lepton}
appid = (app or {}).get("appid") or args.get("appid") or LEPTON_APPID
subprocess.Popen(["steam", f"steam://install/{appid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
return {"installed": False, "requested": appid,
"hint": "Enable Developer Mode, then confirm the install in Steam (or launch 'Lepton Development' once)."}
# ------------------------------------------------------------------------------------- Steam shortcuts (binary VDF)
TYPE_MAP, TYPE_STRING, TYPE_INT, TYPE_END = 0, 1, 2, 8
def shortcut_appid(exe, title):
return zlib.crc32((exe + title).encode("utf-8")) | 0x80000000
class VdfMap(dict):
"""Steam's KeyValues names are case-insensitive; keep their spelling when writing back (#263)."""
def _key(self, key):
return next((k for k in self if k.casefold() == key.casefold()), key)
def __contains__(self, key):
return super().__contains__(self._key(key))
def __getitem__(self, key):
return super().__getitem__(self._key(key))
def __setitem__(self, key, value):
super().__setitem__(self._key(key), value)
def get(self, key, default=None):
return super().get(self._key(key), default)
def setdefault(self, key, default=None):
return super().setdefault(self._key(key), default)
def update(self, *args, **kwargs):
for key, value in dict(*args, **kwargs).items():
self[key] = value
def vdf_decode(data):
pos = 0
def cstring():
nonlocal pos
end = data.index(b"\0", pos)
value = data[pos:end].decode("utf-8", "surrogateescape")
pos = end + 1
return value
def node():
nonlocal pos
out = VdfMap()
while True:
kind = data[pos]
pos += 1
if kind == TYPE_END:
return out
key = cstring()
if kind == TYPE_MAP:
value = node()
elif kind == TYPE_STRING:
value = cstring()
elif kind == TYPE_INT:
value = struct.unpack_from("<I", data, pos)[0]
pos += 4
else:
raise AgentError(f"unsupported VDF value type {kind}; not modifying shortcuts.vdf")
if key in out:
old_key = out._key(key)
# Older agents wrote lowercase twins of Steam's capitalized keys. Keep their position.
if key[:1].isupper() and not old_key[:1].isupper():
out = VdfMap((key if k == old_key else k, v) for k, v in out.items())
out[key] = value
root = node()
if any(b != TYPE_END for b in data[pos:]):
raise AgentError("unexpected trailing data in shortcuts.vdf; not modifying it")
return root
def vdf_encode(obj):
out = bytearray()
for key, value in obj.items():
name = key.encode("utf-8", "surrogateescape") + b"\0"
if isinstance(value, dict):
out += bytes([TYPE_MAP]) + name + vdf_encode(value)
elif isinstance(value, str):
out += bytes([TYPE_STRING]) + name + value.encode("utf-8", "surrogateescape") + b"\0"
else:
out += bytes([TYPE_INT]) + name + struct.pack("<I", value & 0xFFFFFFFF)
return bytes(out) + bytes([TYPE_END])
def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Frame", launch_options="", tags=None,
openvr=True, write=True):
"""Add/update a non-Steam shortcut (matched by Exe, so its appid never changes). `tags` (genres, the user's tags)
are merged with tags already on the shortcut, so ones set in Steam are kept. write=False: change nothing, return
(appid, whether shortcuts.vdf would change)."""
data = open(vdf_path, "rb").read() if os.path.exists(vdf_path) else b""
root = vdf_decode(data) if data else {"shortcuts": {}}
shortcuts = root.setdefault("shortcuts", {})
# matched by Exe; a launcher many games share (Revive's injector on a PC) also by the game in its launch options,
# or two Revive games overwrote each other's shortcut
shared = "reviveinjector" in exe.lower()
def game_of(options): # the game's program in the injector's arguments (its flags may change)
found = re.findall(r'"([^"]+\.exe)"|(\S+\.exe)', options or "", re.IGNORECASE)
return (found[-1][0] or found[-1][1]).lower() if found else (options or "")
entry = next((v for v in shortcuts.values() if isinstance(v, dict) and v.get("Exe") == exe
and (not shared or game_of(v.get("LaunchOptions", "")) == game_of(launch_options))), None)
ident = entry["appid"] if entry else shortcut_appid(exe, title)
if entry is None:
entry = {"appid": ident, "LastPlayTime": 0, "tags": {"0": tag}}
shortcuts[str(max([int(k) for k in shortcuts if k.isdigit()] + [-1]) + 1)] = entry
if tags:
have = [v for v in (entry.get("tags") or {}).values() if isinstance(v, str)]
merged = list(dict.fromkeys(have + [t for t in [tag] + list(tags) if t]))
entry["tags"] = {str(i): t for i, t in enumerate(merged)}
entry.update(appname=title, Exe=exe, StartDir=start_dir, icon=icon or entry.get("icon", ""), ShortcutPath="",
LaunchOptions=launch_options, IsHidden=0, AllowDesktopConfig=1, AllowOverlay=1,
OpenVR=1 if openvr else 0, Devkit=0, DevkitGameID="", DevkitOverrideAppID=0, FlatpakAppID="")
if not write:
return ident, vdf_encode(root) != data
if data:
backup_vdf(vdf_path)
os.makedirs(os.path.dirname(vdf_path), exist_ok=True)
tmp = vdf_path + ".tmp"
with open(tmp, "wb") as f:
f.write(vdf_encode(root))
os.replace(tmp, vdf_path)
return ident
def prune_shortcuts(vdf_path, title, keep_exe, tag):
"""Remove FramePort-tagged shortcuts for `title` whose Exe differs from keep_exe (a reinstall that changed the
launch command, e.g. Revive -> direct, would otherwise leave the old, crashing shortcut behind). Returns the
removed appids."""
if not os.path.exists(vdf_path):
return []
tags = (tag,) if isinstance(tag, str) else tuple(tag) # one tag, or several (current + earlier names)
root = vdf_decode(open(vdf_path, "rb").read())
sc = root.get("shortcuts", {})
def stale(v):
return (isinstance(v, dict) and v.get("appname") == title and v.get("Exe") != keep_exe
and any(t in (v.get("tags") or {}).values() for t in tags))
removed = [v.get("appid") for v in sc.values() if stale(v)]
if not removed:
return []
keep = [v for v in sc.values() if not stale(v)]
root["shortcuts"] = {str(i): v for i, v in enumerate(keep)}
backup_vdf(vdf_path)
with open(vdf_path + ".tmp", "wb") as f:
f.write(vdf_encode(root))
os.replace(vdf_path + ".tmp", vdf_path)
return removed
VDF_BACKUPS = 5
def backup_vdf(vdf_path):
"""Keep a copy before changing shortcuts.vdf (the last VDF_BACKUPS are kept)."""
shutil.copy2(vdf_path, f"{vdf_path}.backup-{time.strftime('%Y%m%d-%H%M%S')}")
for old in sorted(glob.glob(f"{vdf_path}.backup-*"))[:-VDF_BACKUPS]:
try:
os.remove(old)
except OSError:
pass
def remove_tree(path):
"""Remove a file or folder tree, including what Lepton's containers leave in a game's data: overlayfs work dirs
with mode 000 (and whiteout device files in them), which shutil.rmtree(ignore_errors=True) silently skipped, so
"remove saves" left lepton-data behind. Last resort: podman unshare (files owned by the container's user ids)."""
if not os.path.lexists(path):
return
if os.path.islink(path) or not os.path.isdir(path):
os.remove(path)
return
for root, dirs, _files in os.walk(path): # top-down: fix a folder's mode before walking into it
for d in dirs:
p = os.path.join(root, d)
if not os.path.islink(p):
try:
os.chmod(p, 0o700)
except OSError:
pass
shutil.rmtree(path, ignore_errors=True)
if os.path.lexists(path) and shutil.which("podman"):
run(["podman", "unshare", "rm", "-rf", path])
def grid_files(grid, appid):
"""A shortcut's grid artwork (<appid>p.jpg, <appid>_hero.png, …): exact names, never another appid that merely
starts with the same digits."""
pat = re.compile(rf"^{re.escape(str(appid))}(p|_hero|_logo|_icon)?\.[A-Za-z0-9]+$")
try:
return [os.path.join(grid, n) for n in os.listdir(grid) if pat.match(n)]
except OSError:
return []
def remove_shortcut(vdf_path, exe):
if not os.path.exists(vdf_path):
return False
root = vdf_decode(open(vdf_path, "rb").read())
sc = root.get("shortcuts", {})
keep = [v for v in sc.values() if not (isinstance(v, dict) and v.get("Exe") == exe)]
if len(keep) == len(sc):
return False
root["shortcuts"] = {str(i): v for i, v in enumerate(keep)}
backup_vdf(vdf_path)
with open(vdf_path + ".tmp", "wb") as f:
f.write(vdf_encode(root))
os.replace(vdf_path + ".tmp", vdf_path)
return True
STATUS_FILE = os.path.join(HOME, ".local/share/frameport/shortcuts-status.json")
LIBRARY_MODE_FILE = os.path.join(HOME, ".local/share/frameport/library-mode.json")
PENDING_FILE = os.path.join(HOME, ".local/share/frameport/library-pending.json")
RESTART_REQUEST_FILE = os.path.join(HOME, ".local/share/frameport/restart-request.json")
def library_mode(args=None):
mode = (args or {}).get("mode")
if mode is None:
try:
mode = json.load(open(LIBRARY_MODE_FILE))["mode"]
except (OSError, ValueError, KeyError):
mode = "now"
if mode not in ("now", "later", "off"):
raise AgentError("Steam library mode must be now, later or off")
return mode
def cmd_library_mode(args):
mode = library_mode(args)
_write_json(LIBRARY_MODE_FILE, {"mode": mode})
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
with open(STATUS_FILE + ".lock", "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
if mode == "off":
pending = pending_library()
pending["packages"] = []
_write_json(PENDING_FILE, pending)
drop_library_hooks_if_idle()
return {"mode": mode}
def pending_library():
try:
pending = json.load(open(PENDING_FILE))
except (OSError, ValueError):
pending = {}
if not isinstance(pending, dict):
pending = {}
pending["packages"] = [p for p in pending.get("packages") or [] if isinstance(p, str)]
pending["remove"] = [r for r in pending.get("remove") or [] if isinstance(r, dict)]
return pending
def queue_library(packages, remove):
# Serialize with the worker and Steam's startup hooks; persist before touching the live VDF.
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
with open(STATUS_FILE + ".lock", "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
return _queue_library(packages, remove)
def _queue_library(packages, remove):
"""Caller holds the shortcut lock."""
pending = pending_library()
pending["packages"] = sorted(set(pending["packages"]) | set(packages))
pending["remove"].extend(remove)
_write_json(PENDING_FILE, pending)
result = {"state": "pending", **pending, "added": [], "errors": []}
for user in library_users():
update_library(user, packages, result)
_write_json(STATUS_FILE, result)
return result
LIBRARY_HOOK_DIR = os.path.join(HOME, ".config/systemd/user/steam.service.d")
LIBRARY_HOOK = os.path.join(LIBRARY_HOOK_DIR, "frameport-library.conf")
TIMEOUT_BIN = "/usr/bin/timeout" # coreutils; present on SteamOS (checked on the Frame, 2026-10-16)
SYSTEMD_RUN_BIN = "/usr/bin/systemd-run"
LIBRARY_HOOK_SECONDS = 20 # steam.service's start timeout is 90 s: a stuck hook must never get near it
LIBRARY_VERIFY_UNIT = "frameport-library-verify"
def _unit_quote(value):
"""One argument of a systemd Exec= line: quoted, with the specifier (%) and variable ($) characters escaped."""
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$") + '"'
def library_hooks_text(python=None, agent=None, timeout_bin=None):
"""steam.service drop-in for the `later` mode. Both hooks start with '-' (a failure never fails Steam's start)
and run under timeout(1) (systemd's '-' ignores exit codes, not a hook that hangs until the unit's start
timeout), so a missing interpreter/agent (exit 127/2) or a hang can't keep Steam from starting. The
pre-start hook rewrites shortcuts.vdf before Steam reads it; the post-start check (it waits for Steam and may
register devkit entries) runs as its own transient unit, so Steam's start never waits for it."""
python = python or sys.executable
agent = agent or os.path.abspath(__file__)
timeout_bin = TIMEOUT_BIN if timeout_bin is None else timeout_bin
wrap = f"{timeout_bin} -k 5 {LIBRARY_HOOK_SECONDS} " if timeout_bin else ""
command = _unit_quote(python) + " " + _unit_quote(agent)
return ("# Written by FramePort (Steam library: \"later\"); removed again when nothing waits for Steam's start.\n"
"[Service]\n"
f"ExecStartPre=-{wrap}{command} library_pending\n"
f"ExecStartPost=-{wrap}{SYSTEMD_RUN_BIN} --user --collect --quiet --no-block "
f"--unit={LIBRARY_VERIFY_UNIT} --property=RuntimeMaxSec=300 {command} library_pending_verify\n")
def ensure_library_hooks():
"""SteamOS runs Steam as the user unit steam.service (Type=simple, Restart=always, TimeoutStartSec 90 s).
Apply AFTER its exit writes and BEFORE its next start; keep the journal until the post-start verification
succeeds, including across reboots/crashes."""
text = library_hooks_text(timeout_bin=TIMEOUT_BIN if os.access(TIMEOUT_BIN, os.X_OK) else "")
try:
if open(LIBRARY_HOOK).read() == text:
return
except OSError:
pass
os.makedirs(LIBRARY_HOOK_DIR, exist_ok=True)
with open(LIBRARY_HOOK + ".tmp", "w") as f:
f.write(text)
os.replace(LIBRARY_HOOK + ".tmp", LIBRARY_HOOK)
if run(["systemctl", "--user", "daemon-reload"]).returncode:
raise AgentError("Could not enable Steam library startup hooks")
def remove_library_hooks():
"""Remove the steam.service drop-in (mode switched away from `later` with nothing pending, purge).
Returns whether there was one."""
if not os.path.exists(LIBRARY_HOOK):
return False
try:
os.remove(LIBRARY_HOOK)
except FileNotFoundError:
return False
try:
os.rmdir(LIBRARY_HOOK_DIR) # only when empty: the user's own drop-ins (extra_args.conf) stay
except OSError:
pass
run(["systemctl", "--user", "daemon-reload"])
return True
def drop_library_hooks_if_idle():
"""The drop-in only stays while the `later` mode is on or something still waits for Steam's next start."""
pending = pending_library()
if library_mode() != "later" and not pending["packages"] and not pending["remove"]:
return remove_library_hooks()
return False
def cmd_library_pending(args):
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
with open(STATUS_FILE + ".lock", "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return {"worker_active": True} # that worker writes before start and verifies afterward
pending = pending_library()
result = {"added": [], "errors": []}
if not pending["packages"] and not pending["remove"]:
return result # every Steam start runs this: leave shortcuts.vdf alone when nothing waits
# An uninstall queued after an install wins; never resurrect a deleted deployment.
packages = [p for p in pending["packages"] if deployment(p)]
for user in steam_users():
remove_from_library(user, pending["remove"], result)
for user in library_users():
update_library(user, packages, result)
pending["errors"] = result["errors"]
_write_json(PENDING_FILE, pending)
return result
def cmd_library_pending_verify(args):
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
with open(STATUS_FILE + ".lock", "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return {"worker_active": True} # that worker writes before start and verifies afterward
pending = pending_library()
packages = [p for p in pending["packages"] if deployment(p)]
result = {"state": "done", "added": [], "errors": list(pending.get("errors", []))}
if not packages and not pending["remove"]:
if pending["packages"]: # only uninstalled games were left
_write_json(PENDING_FILE, {"packages": [], "remove": []})
drop_library_hooks_if_idle()
return result
for pkg in shortcuts_lost(library_users(), packages):
add_devkit_entry(pkg, result)
if not result["errors"]:
_write_json(PENDING_FILE, {"packages": [], "remove": []})
drop_library_hooks_if_idle()
_write_json(STATUS_FILE, result)
return result
def frameport_running():
"""A local GUI heartbeat is refreshed every second; crashed/closed windows expire in ten seconds."""
data = os.environ.get("XDG_DATA_HOME", os.path.join(HOME, ".local/share"))
try:
age = time.time() - os.path.getmtime(os.path.join(data, "frameport-app/gui.alive"))
return 0 <= age < 10
except OSError:
return False
def restart_hold():
return "game" if game_running() else "desktop" if desktop_mode() else "frameport" if frameport_running() else None
def cmd_restart_steam(args):
if game_running() and not args.get("confirm"):
return {"confirmation_required": True}
status = cmd_shortcut_status({})
if status.get("state") == "waiting" and time.time() - float(status.get("started") or 0) < 60:
# Wake the worker already holding the lock; a second worker would wait behind the local GUI hold.
_write_json(RESTART_REQUEST_FILE, {"requested": time.time()})
return {"started": True, "waiting_worker": True}
return cmd_shortcuts({"packages": pending_library()["packages"], "restart_now": True,
"remove": pending_library()["remove"], "mode": "now"})
def cmd_shortcuts(args):
"""Add library entries (+ grid artwork) for installed games. Steam must be closed while shortcuts.vdf is
rewritten, so the work runs in a detached systemd unit (terminals/SSH sessions started from Steam live in
steam.service's cgroup and would be killed with it). Poll shortcut_status for the result."""
packages = [check_pkg(p) for p in args.get("packages", [])]
remove = [r for r in args.get("remove", [])
if isinstance(r, dict) and str(r.get("exe", "")).startswith('"' + ANCHORS)]
mode = library_mode(args)
if mode == "off" and not remove:
return {"state": "done", "disabled": True, "added": [], "errors": []}
if mode == "off":
packages = []
if mode == "later":
ensure_library_hooks()
return queue_library(packages, remove)
if not packages and not remove and not args.get("restart_now"):
return {"started": False}
current = cmd_shortcut_status({})
alive = time.time() - float(current.get("started") or 0) < 60 # a waiting worker rewrites its status every 10 s
if (not args.get("restart_now") and current.get("state") == "waiting" and alive
and set(packages) <= set(current.get("packages") or []) and not remove):
return {"started": False, "waiting": True} # the update for these games already waits (Gaming Mode / game)
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
with open(STATUS_FILE, "w") as f:
json.dump({"state": "running", "packages": packages, "started": time.time()}, f)
unit = f"frameport-shortcuts-{int(time.time())}"
payload = json.dumps({"packages": packages, "remove": remove, "restart": args.get("restart", True),
"restart_now": bool(args.get("restart_now"))})
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={unit}", "--setenv=HOME=" + HOME,
sys.executable, os.path.abspath(__file__), "_shortcuts_worker", payload])
return {"started": True, "unit": unit}
def shortcut_args(pkg):
"""(exe, title, start dir, icon, tag, tags, openvr) of an installed game's shortcut."""
anchor = os.path.join(ANCHORS, pkg)
dep = json.load(open(os.path.join(anchor, "deployment.json")))
icon = app_icon_for(dep, anchor, steam=True)[0] # a Linux app's own icon unless the user chose one
flat = dep.get("vr") is False
kind = dep.get("kind")
tag = ("Windows game on Frame" if flat else "PC VR on Frame") if kind == "pcvr" else \
"Linux app on Frame" if kind == "linux" else "Quest on Frame"
return f'"{anchor}/launch.sh"', dep["title"], anchor, icon, tag, dep.get("tags") or [], not flat
def library_changes(users, packages):
"""Whether shortcuts.vdf would change for any of these games (a reinstall usually changes nothing)."""
for user in users:
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
for pkg in packages:
exe, title, start, icon, tag, tags, openvr = shortcut_args(pkg)
if upsert_shortcut(vdf, exe, title, start, icon, tag, tags=tags, openvr=openvr, write=False)[1]:
return True
return False
def shortcuts_lost(users, packages, wait=25):
"""Packages whose shortcut isn't in any account's shortcuts.vdf once Steam has started again (Steam saving its own
copy over ours on the way out looked like a successful install with no game in the library, GitHub #27)."""
for _ in range(wait): # until Steam runs again (it rewrites shortcuts.vdf while starting, too)
if run(["pgrep", "-x", "steam"]).returncode == 0:
break
time.sleep(1)
time.sleep(8)
lost = []
for pkg in packages:
exe = shortcut_args(pkg)[0]
found = False
for user in users:
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
try:
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
except (OSError, AgentError):
continue
entries = (root.get("shortcuts") or {}).values()
found = found or any(isinstance(v, dict) and v.get("Exe") == exe for v in entries)
if not found:
lost.append(pkg)
return lost
def desktop_mode():
"""Desktop Mode is open: it runs inside Steam's session, so a Steam restart would end it (and FramePort itself when
it runs on the Frame); library changes wait until the user is back in Gaming Mode."""
return run(["pgrep", "-x", "plasmashell"]).returncode == 0
def game_running():
"""A game is being played on the Frame (any Lepton game container, or a FramePort PC VR game)."""
names = run(["podman", "ps", "--format", "{{.Names}}"]).stdout.split()
if any(n.startswith("lepton-steamlaunch-") for n in names):
return True
# overlay apps (fpsVR & co.) run next to games, often all the time: they don't hold up installs
return any(pcvr_pids(d["base"]) for d in cmd_list_installed({})["games"]
if d.get("kind") in ("pcvr", "linux") and not is_overlay(d))
def shortcuts_worker(payload):
"""One library update at a time: two at once each read shortcuts.vdf, changed it and wrote it back, so the second
write brought back a shortcut the first had removed (uninstalling two games quickly left Roblox in Steam)."""
import fcntl
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
with open(STATUS_FILE + ".lock", "w") as lock:
if json.loads(payload).get("restart_now"):
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
_write_json(RESTART_REQUEST_FILE, {"requested": time.time()})
return # wake an existing hold, rather than queueing another restart behind it
else:
fcntl.flock(lock, fcntl.LOCK_EX)
_shortcuts_worker(payload)
def _shortcuts_worker(payload):
args = json.loads(payload)
result = {"state": "done", "added": [], "errors": [], "finished": None}
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
try: # new art (e.g. the user's own icon) also reaches Linux apps' Desktop Mode entries
refresh_desktop_entries()
except Exception: # noqa: BLE001
pass
try:
users = library_users()
# games Steam only knows through their devkit entry (this Frame's Steam drops FramePort's shortcuts.vdf
# entries, GitHub #41/#42): rewriting the vdf changes nothing for them, and the Steam restart would drop the
# live devkit entry too. They get their art only; Play re-adds a lost devkit entry live.
devkit = [p for p in args["packages"] if devkit_gameid(p)]
for pkg in devkit:
appid = devkit_appid(devkit_gameid(pkg))
if appid:
copy_grid_art(pkg, appid)
args["packages"] = [p for p in args["packages"] if p not in devkit]
result["devkit"] = devkit
if not args.get("restart_now") and not args.get("remove") and not library_changes(users, args["packages"]):
# nothing to change in shortcuts.vdf (e.g. a reinstall): only the artwork, no Steam restart
for user in users:
update_library(user, args["packages"], result, shortcuts=False)
result["unchanged"] = True
result["finished"] = time.time()
with open(STATUS_FILE, "w") as f:
json.dump(result, f)
return
deadline = time.time() + 12 * 3600
while not args.get("restart_now") and time.time() < deadline:
try:
requested = json.load(open(RESTART_REQUEST_FILE))["requested"]
if 0 <= time.time() - requested < 60:
os.remove(RESTART_REQUEST_FILE)
break
except (OSError, ValueError, KeyError):
pass
mode = library_mode()
if mode == "later":
ensure_library_hooks()
_queue_library(args["packages"], args.get("remove", []))
return
if mode == "off" and not args.get("remove"):
_write_json(STATUS_FILE, {"state": "done", "disabled": True, "added": [], "errors": []})
return
if mode == "off":
args["packages"] = []
reason = restart_hold()
if not reason:
break
with open(STATUS_FILE, "w") as f:
json.dump({"state": "waiting", "reason": reason, "packages": args["packages"],
"started": time.time()}, f)
time.sleep(10)
try:
service = stop_steam()
except AgentError:
raise AgentError("Steam did not close; library not modified") from None
pending = pending_library()
args["packages"] = sorted(set(args["packages"]) | {p for p in pending["packages"] if deployment(p)})
args.setdefault("remove", []).extend(pending["remove"])
for user in steam_users(): # uninstalled games leave every account's library
remove_from_library(user, args.get("remove", []), result)
for user in users:
update_library(user, args["packages"], result)
if service or args.get("restart", True):
start_steam(service)
lost = shortcuts_lost(users, args["packages"])
if lost: # Steam wrote its old copy back over ours (it was still saving): once more, then the devkit entry
result["retried"] = lost
service = stop_steam()
for user in users:
update_library(user, lost, {"added": [], "errors": result["errors"]})
start_steam(service)
for pkg in shortcuts_lost(users, lost):
result["added"] = [a for a in result["added"] if a["package"] != pkg]
add_devkit_entry(pkg, result)
except Exception as exc: # noqa: BLE001
result["state"] = "failed"
result["errors"].append(str(exc))
run(["systemctl", "--user", "start", "steam.service"])
if result["state"] == "done" and not result["errors"]:
_write_json(PENDING_FILE, {"packages": [], "remove": []})
drop_library_hooks_if_idle()
try:
os.remove(RESTART_REQUEST_FILE)
except OSError:
pass
result["finished"] = time.time()
with open(STATUS_FILE, "w") as f:
json.dump(result, f)
def add_devkit_entry(pkg, result):
"""Steam dropped the game's shortcuts.vdf entry twice: this Frame's Steam ignores FramePort's entries (GitHub #41),
so add it the way Valve's devkit tool does (Steam is running again). That entry is the one Steam keeps."""
try:
appid = devkit_register(pkg)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{pkg}: Steam removed the new library entry again after restarting, and the devkit "
f"entry failed too: {exc}")
return
result["added"].append({"package": pkg, "appid": appid, "expected": (deployment(pkg) or {}).get("appid"),
"via": "devkit"})
def remove_from_library(user, remove, result):
"""Remove uninstalled games' shortcuts + grid art from one Steam account (Steam is closed)."""
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
grid = os.path.join(os.path.dirname(vdf), "grid")
for r in remove:
try:
if remove_shortcut(vdf, r["exe"]):
result.setdefault("removed", []).append(r["exe"])
for art in grid_files(grid, r.get("appid") or ""):
os.remove(art)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{r.get('exe')}: {exc}")
def update_library(user, packages, result, shortcuts=True):
"""Add/update installed games' shortcuts + grid art in one Steam account (Steam is closed; shortcuts=False: only
the grid art of shortcuts that are already right, while Steam runs). A game's devkit entry (the Play fallback)
gets the same art: on Frames where Steam ignores shortcuts.vdf it is the one in the library (GitHub #41)."""
for pkg in packages:
gameid = devkit_gameid(pkg)
appid = devkit_appid(gameid) if gameid else None
if appid:
try:
copy_grid_art(pkg, appid, [user])
except OSError as exc:
result.setdefault("errors", []).append(f"{pkg}: devkit art: {exc}")
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
grid = os.path.join(os.path.dirname(vdf), "grid")
os.makedirs(grid, exist_ok=True)
for pkg in packages:
try:
anchor = os.path.join(ANCHORS, pkg)
dep = json.load(open(os.path.join(anchor, "deployment.json")))
exe, title, start, icon, tag, tags, openvr = shortcut_args(pkg)
got = upsert_shortcut(vdf, exe, title, start, icon, tag, tags=tags, openvr=openvr, write=shortcuts)
got = got[0] if not shortcuts else got
for kind, suffix in (("portrait", "p"), ("landscape", ""), ("hero", "_hero"), ("logo", "_logo")):
img = next(iter(glob.glob(os.path.join(anchor, f"artwork/{kind}.*"))), None)
if not img:
continue
for old in glob.glob(os.path.join(grid, f"{got}{suffix}.*")):
os.remove(old)
shutil.copy(img, os.path.join(grid, f"{got}{suffix}{os.path.splitext(img)[1]}"))
if not any(a["package"] == pkg for a in result["added"]):
result["added"].append({"package": pkg, "appid": got, "expected": dep["appid"]})
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{pkg}: {exc}")
def cmd_shortcut_status(args):
try:
return json.load(open(STATUS_FILE))
except (OSError, ValueError):
return {"state": "none"}
# ------------------------------------------------------------------------------------------ drives (GitHub #90)
# Games can live on another drive (a microSD card): their files go to <mount>/FramePort/<pkg>; the anchor (launch.sh,
# deployment.json, artwork) always stays on internal storage, so the Steam shortcut never changes. SteamOS mounts
# removable drives under /run/media/<user>/<label or uuid>.
PROC_MOUNTS = "/proc/mounts"
MEDIA_ROOT = "/run/media"
DRIVE_DIR = "FramePort"
# Lepton's container data and Proton prefixes need Unix permissions, owners and symlinks: these can't hold them
UNUSABLE_FS = ("vfat", "msdos", "exfat", "ntfs", "ntfs3", "fuseblk")
MOVE_STATUS = os.path.join(HOME, ".cache/frameport-move.json")
MOVE_HEADROOM = 512 << 20
# what stays in the anchor when a game's files live there too (base == anchor): never moved
ANCHOR_ITEMS = ("launch.sh", "launch.sh.tmp", "deployment.json", "deployment.json.tmp", "artwork", "plays.log")
def _unescape_mount(field):
"""/proc/mounts writes spaces, tabs, newlines and backslashes in paths as octal escapes (\\040 ...)."""
return re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), field)
def mounts(path=None):
"""[(device, mount point, fstype, options)] from /proc/mounts."""
out = []
try:
with open(path or PROC_MOUNTS, encoding="utf-8", errors="replace") as f:
for line in f:
p = line.split()
if len(p) >= 4:
out.append((_unescape_mount(p[0]), _unescape_mount(p[1]), p[2], p[3].split(",")))
except OSError:
pass
return out
def _inside(path, root):
path, root = os.path.normpath(path), os.path.normpath(root)
return path == root or path.startswith(root.rstrip("/") + "/")
def mount_of(path, table=None):
"""The mount entry holding `path` (longest mount point prefix), or None."""
best = None
for m in mounts() if table is None else table:
if _inside(path, m[1]) and (best is None or len(m[1]) > len(best[1])):
best = m
return best
def _space(path):
try:
st = os.statvfs(path)
except OSError:
return None, None
return st.f_bavail * st.f_frsize, st.f_blocks * st.f_frsize
def _drive(path, install_dir, m, internal=False, steam_library=False):
fstype = m[2] if m else ""
free, total = _space(path)
reason = ""
if not internal:
if fstype in UNUSABLE_FS:
reason = (f"{fstype} can't hold game data (no Unix permissions or symlinks); format the drive in SteamOS "
f"to install games on it")
elif m and "ro" in m[3]:
reason = "mounted read-only"
elif free is None:
reason = "can't be read"
return {"id": "internal" if internal else path, "path": path, "install_dir": install_dir,
"label": "Internal storage" if internal else (os.path.basename(path.rstrip("/")) or path),
"fstype": fstype, "device": m[0] if m else "", "internal": internal,
"read_only": bool(m and "ro" in m[3]),
"removable": not internal and _inside(path, MEDIA_ROOT), "steam_library": steam_library,
"free_bytes": free, "total_bytes": total, "usable": not reason, "reason": reason}
def list_drives(table=None):
"""Internal storage, every drive mounted under /run/media and the drives of Steam library folders."""
table = mounts() if table is None else table
home_m = mount_of(HOME, table)
libs = [os.path.dirname(lib) for lib in steam_libraries()]
drives = [_drive(HOME, ANCHORS, home_m, internal=True, steam_library=any(_inside(lib, HOME) for lib in libs))]
seen = {home_m[1] if home_m else HOME}
for m in sorted(table, key=lambda m: m[1]):
if _inside(m[1], MEDIA_ROOT) and m[1] != os.path.normpath(MEDIA_ROOT) and m[1] not in seen \
and os.path.isdir(m[1]):
seen.add(m[1])
drives.append(_drive(m[1], os.path.join(m[1], DRIVE_DIR), m,
steam_library=any(_inside(lib, m[1]) for lib in libs)))
for lib in libs: # a Steam library on a drive mounted elsewhere
m = mount_of(lib, table)
if m and m[1] not in seen and m[1] != "/" and not _inside(HOME, m[1]):
seen.add(m[1])
drives.append(_drive(m[1], os.path.join(m[1], DRIVE_DIR), m, steam_library=True))
return drives
def cmd_drives(args):
"""Where games can be installed: internal storage and other drives (microSD), with free space and whether they can
hold games (`usable`, else `reason`); `games` = how many installed games each holds."""
drives = list_drives()
games = cmd_list_installed({})["games"]
for d in drives:
d["games"] = sum(1 for g in games if drive_path(g["base"], drives) == d["path"])
return {"drives": drives}
def drive_path(base, drives):
"""The path of the listed drive holding `base` (internal storage: HOME), or None."""
best = None
for d in drives:
if not d["internal"] and _inside(base, d["path"]) and (best is None or len(d["path"]) > len(best)):
best = d["path"]
return best or (HOME if _inside(base, HOME) else None)
def drive_missing(base, table=None):
"""A game's files are on a drive that isn't there now (the microSD card was taken out)."""
if _inside(base, HOME):
return False
m = mount_of(base, table)
return m is None or m[1] == "/" or not os.path.isdir(base)
def resolve_dest(dest, table=None):
"""The install dir for new games: internal storage (None, "", "internal", ~/Applications/quest-frame) or a drive's
FramePort folder (its mount point is accepted too). A drive that isn't mounted is an error, never a silent
fallback to internal storage."""
if not dest or dest == "internal":
return ANCHORS
dest = os.path.normpath(os.path.expanduser(dest))
if dest == ANCHORS:
return ANCHORS
if not os.path.isabs(dest):
raise AgentError(f"bad install location {dest!r}")
for d in list_drives(table):
if d["internal"] or not _inside(dest, d["path"]):
continue
if not d["usable"]:
raise AgentError(f"{d['label']}: {d['reason']}")
if dest == d["path"]:
dest = d["install_dir"]
try:
os.makedirs(dest, exist_ok=True)
except OSError as exc:
raise AgentError(f"can't create {dest} on {d['label']}: {exc.strerror or exc}") from exc
return dest
raise AgentError(f"{dest}: that drive isn't inserted (or not mounted)")
def check_base_present(base, title):
if drive_missing(base):
raise AgentError(f"{title}'s files are on a drive that isn't inserted ({base})")
def new_base(args, pkg, dep):
"""(base, free bytes there) for prepare*: an installed game keeps its base; a new one goes to args["dest"]."""
if dep:
check_base_present(dep["base"], dep.get("title") or pkg)
base = dep["base"]
probe = base
while not os.path.exists(probe) and probe != os.path.dirname(probe):
probe = os.path.dirname(probe)
else:
dest = resolve_dest(args.get("dest"))
base = os.path.join(dest, pkg)
probe = dest if os.path.exists(dest) else HOME
st = os.statvfs(probe)
return base, st.f_bavail * st.f_frsize
# ------------------------------------------------------------------------------------------ install
def deployment(pkg):
path = os.path.join(ANCHORS, pkg, "deployment.json")
try:
dep = json.load(open(path))
except (OSError, ValueError):
return None
if not isinstance(dep, dict):
return None
dep.setdefault("base", os.path.dirname(path))
dep.setdefault("title", pkg)
return dep
def write_deployment(pkg, dep):
path = os.path.join(ANCHORS, pkg, "deployment.json")
with open(path + ".tmp", "w") as f:
json.dump(dep, f, indent=2)
os.replace(path + ".tmp", path)
def is_overlay(dep):
"""An installed SteamVR overlay app (runs next to games: no launch test, not a "running game")."""
return bool(isinstance(dep, dict) and dep.get("overlay"))
def deployment_summary(dep):
"""Keep routine connection/monitor samples small; detailed data manifests are fetched only for an update."""
return {key: value for key, value in dep.items()
if key not in ("data_manifest", "configuration", "managed_config_files")}
def cmd_list_installed(args):
games = []
table = mounts()
selected = check_pkg(args["package"]) if args.get("package") else "*"
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, selected, "deployment.json"))):
try:
dep = json.load(open(dep_path))
except (OSError, ValueError):
continue
# incomplete records (an interrupted install, another tool's files in this folder) are skipped or completed
# instead of failing every connection with a KeyError (GitHub #40)
if not isinstance(dep, dict) or not dep.get("package") or not dep.get("appid"):
continue
dep.setdefault("base", os.path.dirname(dep_path))
dep.setdefault("title", dep["package"])
dep.setdefault("kind", "quest")
dep["anchor"] = os.path.dirname(dep_path) # its artwork/ feeds the Steam grid (shortcuts)
# GitHub #90: games on another drive (microSD); drive_missing = that drive isn't inserted now
dep["drive_missing"] = drive_missing(dep["base"], table)
if _inside(dep["base"], HOME):
dep["drive"] = {"internal": True, "path": HOME, "label": "Internal storage"}
else:
m = mount_of(dep["base"], table)
mp = m[1] if m and m[1] != "/" and not dep["drive_missing"] else \
os.path.dirname(os.path.dirname(dep["base"].rstrip("/"))) # <mount>/FramePort/<pkg>
dep["drive"] = {"internal": False, "path": mp, "label": os.path.basename(mp.rstrip("/")) or mp}
if dep["kind"] == "linux":
exe = os.path.join(dep["base"], "app", dep.get("exe", ""))
dep["apk_present"] = os.path.isfile(exe)
dep["apk_size"] = sum((dep.get("files") or {}).get("app", {}).values())
dep.pop("files", None)
elif dep["kind"] == "pcvr":
exe = os.path.join(dep["base"], "game", dep.get("exe", ""))
dep["apk_present"] = os.path.isfile(exe)
dep["apk_size"] = sum((dep.get("files") or {}).get("game", {}).values())
dep.pop("files", None) # large; not needed by the PC
else:
apk = os.path.join(dep["base"], "lepton-app/game.apk")
dep["apk_present"] = os.path.exists(apk)
dep["apk_size"] = os.path.getsize(apk) if dep["apk_present"] else 0
dep["last_play"] = last_play(dep["anchor"]) # agent v70: the PC triages a finished play session's log
games.append(dep if args.get("details") else deployment_summary(dep))
return {"games": games}
def steam_gameid(appid):
"""steam://rungameid/ id of a non-Steam shortcut: the 32-bit shortcut appid in the high word, type 0x02000000."""
return (int(appid) << 32) | 0x02000000
NOT_IN_LIBRARY = "not in the Frame's Steam library"
# Fallback library entry (GitHub #21/#30): on some Frames Steam never picks up the shortcuts FramePort writes into
# shortcuts.vdf (Play: "Game configuration unavailable", Steam's log: RequestingLicense → AppError_9), while games
# registered through Steam's devkit interface work there (Valve's Devkit Management Tool, MIT: the same pipe command,
# ~/devkit-game/<gameid>/ + <gameid>-argv.json/-settings.json). Steam adds those live, without a restart, and runs
# argv[0] relative to the game's folder: a link to the game's launch.sh. Steam names them "Devkit Game: <gameid>"
# (no spaces allowed in the id), so they're only used where the normal entry fails.
DEVKIT_GAMES = os.path.join(HOME, "devkit-game")
def devkit_request(command, timeout=15):
"""Send a devkit-1 command to the running Steam (its command pipe + session token) and wait for its answer file.
Returns the answer text; AgentError on an error answer or no answer."""
import tempfile
from urllib.parse import quote_plus
try:
token = open(os.path.join(HOME, ".steam/steam.token")).read().strip()
except OSError:
raise AgentError("Steam isn't running on the Frame (no session token)") from None
with tempfile.TemporaryDirectory(prefix="frameport-devkit") as tmp:
resp = os.path.join(tmp, "response")
line = f"devkit-1 steam://devkit-1/{token}/{command.format(response=quote_plus(resp))}\n"
with open(os.path.realpath(os.path.join(HOME, ".steam/steam.pipe")), "wb", 0) as pipe:
pipe.write(line.encode())
for _ in range(timeout * 4):
time.sleep(0.25)
if os.path.exists(resp + ".error"):
raise AgentError(f"Steam: {open(resp + '.error', errors='replace').read().strip()}")
if os.path.exists(resp) and not os.path.exists(resp + ".lock"):
return open(resp, errors="replace").read()
raise AgentError("Steam didn't answer the devkit request")
def devkit_gameid(pkg):
"""The devkit game id whose launcher links to this game's launch.sh, if it has one."""
target = os.path.join(ANCHORS, pkg, "launch.sh")
try:
names = sorted(os.listdir(DEVKIT_GAMES))
except OSError:
return None
for name in names:
link = os.path.join(DEVKIT_GAMES, name, "launch.sh")
if os.path.islink(link) and os.readlink(link) == target:
return name
return None
def devkit_appid(gameid):
"""The appid Steam gave a devkit game (it picks it itself), from any account's shortcuts.vdf."""
for user in steam_users():
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
try:
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
except (OSError, AgentError):
continue
for v in (root.get("shortcuts") or {}).values():
if isinstance(v, dict) and v.get("DevkitGameID") == gameid and v.get("appid"):
return v["appid"] & 0xFFFFFFFF
return devkit_appid_from_log(gameid)
def devkit_appid_from_log(gameid):
"""The appid Steam picked for a devkit game, from its console log ('sanitize shortcut app id "<dir>/<id>/launch.sh":
replacing 0 with N'). Steam adds the entry live but may never save it (GitHub #41/#42), so the log of the running
Steam session is the fallback (console_log.previous.txt is the session before, whose live entries are gone). The
newest line wins."""
exe = os.path.join(DEVKIT_GAMES, gameid, "launch.sh")
pat = re.compile(r'sanitize shortcut app id "' + re.escape(exe) + r'": replacing \d+ with (\d+)')
found = None
for name in ("console_log.txt",): # this Steam session only: entries added live are gone after a restart
try:
with open(os.path.join(STEAM, "logs", name), errors="replace") as f:
for line in f:
if "sanitize shortcut app id" in line:
m = pat.search(line)
if m:
found = int(m.group(1)) & 0xFFFFFFFF
except OSError:
continue
return found
def devkit_register(pkg):
"""Add the game to Steam through the devkit interface (Steam must run); copies its art. Returns the appid."""
dep = deployment(pkg) or {}
gameid = devkit_gameid(pkg)
if not gameid:
# Steam only accepts ids like identifiers: a letter first, then letters, digits and "_" (no spaces or "-")
slug = re.sub(r"[^A-Za-z0-9]+", "_", dep.get("title") or "").strip("_")[:60] or pkg.replace(".", "_")
slug = re.sub(r"[^A-Za-z0-9_]", "_", slug)
if not slug[:1].isalpha():
slug = "Game_" + slug
gameid, n = slug, 2
while os.path.exists(os.path.join(DEVKIT_GAMES, gameid)):
gameid, n = f"{slug}_{n}", n + 1
folder = os.path.join(DEVKIT_GAMES, gameid)
os.makedirs(folder, exist_ok=True)
link = os.path.join(folder, "launch.sh")
if os.path.lexists(link):
os.remove(link)
os.symlink(os.path.join(ANCHORS, pkg, "launch.sh"), link)
with open(os.path.join(DEVKIT_GAMES, f"{gameid}-argv.json"), "w") as f:
json.dump(["launch.sh"], f)
with open(os.path.join(DEVKIT_GAMES, f"{gameid}-settings.json"), "w") as f:
json.dump({"steam_play": "0", "compat_tool": ""}, f)
from urllib.parse import quote_plus
try:
devkit_request("create-shortcut?response={response}&gameid=" + gameid + "&directory=" +
quote_plus(DEVKIT_GAMES))
except AgentError:
_devkit_remove_files(gameid)
raise
appid = None
for _ in range(20): # Steam writes the new entry to shortcuts.vdf right after answering
appid = devkit_appid(gameid)
if appid:
break
time.sleep(0.5)
if not appid:
raise AgentError("Steam added the devkit entry but didn't save it")
copy_grid_art(pkg, appid)
return appid
def copy_grid_art(pkg, appid, users=None):
"""The game's Steam art (portrait, landscape, hero, logo) as grid/<appid>* in every (or the given) account."""
anchor = os.path.join(ANCHORS, pkg)
for user in users if users is not None else steam_users():
grid = os.path.join(STEAM, "userdata", user, "config/grid")
os.makedirs(grid, exist_ok=True)
for kind, suffix in (("portrait", "p"), ("landscape", ""), ("hero", "_hero"), ("logo", "_logo")):
img = next(iter(glob.glob(os.path.join(anchor, f"artwork/{kind}.*"))), None)
if img:
for old in grid_files(grid, appid):
if re.match(rf"^{appid}{re.escape(suffix)}\.", os.path.basename(old)):
os.remove(old)
shutil.copy(img, os.path.join(grid, f"{appid}{suffix}{os.path.splitext(img)[1]}"))
def devkit_unregister(pkg, steam_running=True):
"""Remove the game's devkit entry: Steam's entry (live when Steam runs; else from shortcuts.vdf), its art and
the ~/devkit-game files. Returns whether there was one."""
gameid = devkit_gameid(pkg)
if not gameid:
return False
appid = devkit_appid(gameid)
exe = f'"{os.path.join(DEVKIT_GAMES, gameid, "launch.sh")}"'
if steam_running:
try:
devkit_request("delete-shortcut?response={response}&gameid=" + gameid)
except AgentError:
pass
else:
for user in steam_users():
remove_shortcut(os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf"), exe)
if appid:
for user in steam_users():
for art in grid_files(os.path.join(STEAM, "userdata", user, "config/grid"), appid):
os.remove(art)
_devkit_remove_files(gameid)
return True
def _devkit_remove_files(gameid):
remove_tree(os.path.join(DEVKIT_GAMES, gameid))
for suffix in ("argv", "settings", "env"):
p = os.path.join(DEVKIT_GAMES, f"{gameid}-{suffix}.json")
if os.path.exists(p):
os.remove(p)
def cmd_launch(args):
"""Start an installed game the way the headset's library does: ask the running Steam to launch its shortcut, so
it gets Steam's VR session, overlay and controller setup (unlike launch_test's direct, headless start)."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep or not dep.get("appid"):
raise AgentError(f"{pkg} is not installed")
if dep.get("base"):
check_base_present(dep["base"], dep.get("title") or pkg)
if run(["pgrep", "-x", "steam"]).returncode != 0:
raise AgentError("Steam isn't running on the Frame")
mode = library_mode(args)
if mode == "off" or (mode == "later" and pkg in pending_library()["packages"]):
# Detached from SSH/FramePort. The launcher keeps Lepton/Proton's environment; SteamVR must be running.
unit = f"frameport-play-{dep['appid']}"
if game_is_running(dep) or run(["systemctl", "--user", "is-active", "--quiet", unit]).returncode == 0:
raise AgentError("the game is already running") # (or its launcher is still starting it)
run(["systemctl", "--user", "reset-failed", unit]) # a failed earlier start would block the unit name
p = run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={unit}",
os.path.join(ANCHORS, pkg, "launch.sh")])
if p.returncode:
raise AgentError("could not start the launcher: " + p.stderr[-300:])
return {"package": pkg, "title": dep.get("title"), "via": "direct"}
devkit = devkit_gameid(pkg)
appid = devkit_appid(devkit) if devkit else None # this Frame needed the fallback entry before: use it
if devkit and not appid:
# Steam forgot the devkit entry (it never saves it on some Frames, and a restart drops it, GitHub #41): add it
# again live instead of reporting NOT_IN_LIBRARY, whose repair restarts Steam
try:
appid = devkit_register(pkg)
except AgentError:
appid = None
via = "devkit" if appid else "shortcut"
appid = appid or shortcut_appid_for(f'"{os.path.join(ANCHORS, pkg)}/launch.sh"')
if appid is None: # Steam would only say "Game configuration unavailable"
raise AgentError(f"{NOT_IN_LIBRARY}: {dep.get('title') or pkg}")
steam = steam_launch(appid, args.get("wait", 10))
out = {"package": pkg, "gameid": steam_gameid(appid), "title": dep.get("title"), "via": via, "steam": steam}
if steam["result"] == "error" and steam.get("code") == 9 and args.get("fallback", True):
# Steam doesn't know the shortcut at all (it tried to license a store app with that id): devkit entry. A devkit
# entry Steam never saved is gone after a Steam restart (every install restarts it, GitHub #41): add it again
try:
appid = devkit_register(pkg)
except AgentError as exc:
out["fallback_error"] = str(exc)
return out
out.update(via="devkit", gameid=steam_gameid(appid), first_try=steam,
steam=steam_launch(appid, args.get("wait", 10)))
return out
def steam_launch(appid, wait=10):
"""Ask the running Steam to start shortcut `appid`; what its log says about it (steam_launch_result)."""
log = os.path.join(STEAM, "logs/console_log.txt")
start = os.path.getsize(log) if os.path.exists(log) else 0
# systemd-run: the launch request must outlive this SSH session
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit=frameport-launch-{time.time_ns()}",
"steam", "-ifrunning", f"steam://rungameid/{steam_gameid(appid)}"])
return steam_launch_result(log, start, appid, wait=wait)
STEAM_LAUNCH_ERROR = re.compile(r"launch error (\d+) (\d+)(.*)")
STEAM_APP_ERROR = re.compile(r"LaunchApp failed with AppError_(\d+)")
def steam_launch_result(log, start, appid, wait=10):
"""What Steam's console log says about the launch of shortcut `appid` (lines written after byte `start`): "started"
(Steam created the process), "error" (e.g. "launch error 9 <appid>" = Steam's "Game configuration unavailable",
GitHub #21/#30), "silent" (Steam logged nothing about this app: it didn't know the shortcut) or "unknown"."""
tag, lines = f"[AppID {appid},", []
deadline = time.time() + wait
while True:
try:
with open(log, "rb") as f:
f.seek(start)
new = f.read(1 << 20).decode("utf-8", "replace").splitlines()
except OSError:
return {"result": "unknown", "lines": []}
lines = [ln for ln in new if tag in ln or "launch error" in ln or "rungameid" in ln][-40:]
mine = [ln for ln in lines if tag in ln]
err = next((m for m in map(STEAM_LAUNCH_ERROR.search, lines) if m and m.group(2) == str(appid)), None)
if err:
return {"result": "error", "code": int(err.group(1)), "detail": err.group(3).strip(), "lines": lines}
# e.g. "GameAction [AppID 3554078061, ActionID 3] : LaunchApp failed with AppError_9" (GitHub #21)
failed = next((m for m in map(STEAM_APP_ERROR.search, mine) if m), None)
if failed:
return {"result": "error", "code": int(failed.group(1)), "detail": failed.group(0), "lines": lines}
if any(tag in ln and ("CreatingProcess" in ln or "WaitingGameWindow" in ln or "Completed" in ln)
for ln in lines):
return {"result": "started", "lines": lines}
if time.time() >= deadline:
return {"result": "silent" if not any(tag in ln for ln in lines) else "unknown", "lines": lines}
time.sleep(1)
LEPTON_LINK = re.compile(r'ln -s "\$\{HOME\}/([^"/]+)" "\$\{TARGET_PATH\}/([^"/]+)"')
SHARED_DEFAULT = (("Documents", "Documents"), ("Downloads", "Download"), ("Videos", "Movies"))
def lepton_shared_folders():
"""(home folder, Android folder) pairs Lepton links into every app's storage (read from Lepton's mounting.sh;
it only links folders that exist when an app starts)."""
lepton, _app = lepton_path()
text = ""
if lepton:
try:
text = open(os.path.join(os.path.dirname(lepton), "liblepton", "mounting.sh"), errors="replace").read()
except OSError:
pass
pairs = LEPTON_LINK.findall(text)
return pairs or list(SHARED_DEFAULT)
# Folders Android itself creates in every app's /sdcard; anything else at the top level was made by the app.
ANDROID_STORAGE_DIRS = {"alarms", "android", "audiobooks", "dcim", "documents", "download", "movies", "music",
"notifications", "pictures", "podcasts", "recordings", "ringtones", "screenshots"}
def app_media_dirs(ext):
"""The app's own top-level folders in its /sdcard (e.g. 4XVR's 4XPlayer, which its "Internal Storage" list shows
instead of /sdcard/Movies): real folders only (not Lepton's links to the shared folders), not hidden."""
try:
names = sorted(os.listdir(ext))
except OSError:
return []
return [n for n in names if not n.startswith(".") and n.lower() not in ANDROID_STORAGE_DIRS
and os.path.isdir(os.path.join(ext, n)) and not os.path.islink(os.path.join(ext, n))]
def lepton_external(pkg):
dep = deployment(pkg)
if not dep or dep.get("kind") == "pcvr":
raise AgentError(f"{pkg} is not an installed Quest (Lepton) game")
return os.path.join(dep["base"], "lepton-data", "external")
def cmd_storage_targets(args):
"""Where files for Lepton apps go: shared folders (seen by every app, e.g. ~/Videos = /sdcard/Movies) and, with a
package, that app's own storage (/sdcard) and its files folder. Creates missing shared folders (Lepton links only
existing ones). Android's media index doesn't work in Lepton, so apps must browse folders to find files."""
out = []
for home_name, android in lepton_shared_folders():
path = os.path.join(HOME, home_name)
os.makedirs(path, exist_ok=True)
out.append({"id": home_name.lower(), "path": path, "android": "/sdcard/" + android, "shared": True})
pkg = args.get("package")
if pkg:
pkg = check_pkg(pkg)
ext = lepton_external(pkg)
files = os.path.join(ext, "Android", "data", pkg, "files")
os.makedirs(files, exist_ok=True)
out.append({"id": "app", "path": ext, "android": "/sdcard", "shared": False})
out.append({"id": "app-files", "path": files, "android": f"/sdcard/Android/data/{pkg}/files", "shared": False})
for i, name in enumerate(app_media_dirs(ext)): # the app's own folders (e.g. a video player's library)
out.append({"id": "app-media" if i == 0 else f"app-media:{name}", "path": os.path.join(ext, name),
"android": f"/sdcard/{name}", "shared": False, "folder": name})
return {"targets": out}
def cmd_link_media(args):
"""Make files from the shared folders (e.g. ~/Videos) appear in an app's own folder too, as hard links (no copy,
no extra space). For players that list their own folder rather than /sdcard/Movies (4XVR: 4XPlayer). args:
package, files (paths under ~/Videos, ~/Downloads, ~/Documents), folder (optional; default: the app's first own
folder, see app_media_dirs). Returns {folder, android, linked, existing, missing}; folder None when the app has no
own folder (it then finds the files in the shared folders)."""
pkg = check_pkg(args["package"])
ext = lepton_external(pkg)
folder = args.get("folder") or next(iter(app_media_dirs(ext)), None)
if not folder:
return {"folder": None, "android": None, "linked": [], "existing": [], "missing": []}
if "/" in folder or folder in (".", ".."):
raise AgentError(f"bad folder {folder!r}")
dest = os.path.join(ext, folder)
os.makedirs(dest, exist_ok=True)
shared = [os.path.realpath(os.path.join(HOME, h)) for h, _a in lepton_shared_folders()]
linked, existing, missing = [], [], []
for path in args.get("files") or []:
real = os.path.realpath(path)
if not any(real == s or real.startswith(s + os.sep) for s in shared):
raise AgentError(f"{path} is not in a shared folder ({', '.join(shared)})")
if not os.path.isfile(real):
missing.append(path)
continue
target = os.path.join(dest, os.path.basename(real))
if os.path.exists(target):
if os.path.samefile(target, real):
existing.append(os.path.basename(real))
continue
os.remove(target) # an older file of the same name: show the one just sent
try:
os.link(real, target)
except OSError:
shutil.copy2(real, target) # different file system (not the case with Lepton's layout)
linked.append(os.path.basename(real))
return {"folder": folder, "android": f"/sdcard/{folder}", "linked": linked, "existing": existing,
"missing": missing}
def cmd_list_files(args):
"""Every file of an installed game on the Frame, for the PC's file browser: {roots: [{name, path, files:
[[rel, size], ...]}], missing: [[rel, expected size, actual size or None], ...], truncated}. Roots are the install
folder (game files, Proton prefix / Lepton data) and, when separate, the launcher folder (launch.sh, artwork).
`missing` compares against the file list recorded at install time (PC VR games)."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
limit = int(args.get("limit", 200000))
anchor = os.path.join(ANCHORS, pkg)
roots, count, truncated = [], 0, False
for name, root in (("Install folder", dep["base"]), ("Launcher", anchor)):
if not os.path.isdir(root) or any(os.path.realpath(root) == os.path.realpath(r["path"]) for r in roots):
continue
files = []
for r, dirs, names in os.walk(root):
dirs.sort()
for n in sorted(names):
if count >= limit:
truncated = True
break
p = os.path.join(r, n)
try:
files.append([os.path.relpath(p, root), os.lstat(p).st_size])
except OSError:
continue
count += 1
roots.append({"name": name, "path": root, "files": files})
missing = []
for t, manifest in (dep.get("files") or {}).items():
for rel, size in manifest.items():
p = os.path.join(dep["base"], t, rel)
actual = os.path.getsize(p) if os.path.isfile(p) else None
if actual is None and rel.lower().endswith("crashreportclient.exe") and os.path.isfile(p + ".disabled"):
continue # renamed by the no-crash-reporter patch
if actual != size:
missing.append([f"{t}/{rel}", size, actual])
return {"roots": roots, "missing": missing[:1000], "truncated": truncated, "kind": dep.get("kind", "quest")}
# ------------------------------------------------------------------------------------------ screenshots
STEAMVR_APPID = "250820" # the Frame files every headset screenshot under SteamVR, whatever game was shown
PLAYS_LOG = "plays.log" # <anchor>/plays.log: "start <unix>" / "end <unix>" lines written by launch.sh
OPEN_SESSION = 12 * 3600 # a session without an "end" (Proton launchers exec the game; power loss) lasts at most this
SESSION_GRACE = 5 # s after "end": the screenshot's time can trail the key press slightly
SHOT_NAME = re.compile(r"^(\d{14})_\d+\.(jpe?g|png)$", re.I)
KV_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])|//[^\n]*|([^\s"{}]+)')
def kv_parse(text):
"""Steam's text KeyValues (screenshots.vdf, ...) as nested dicts. Tolerant: an unbalanced or cut-off file gives
what was read so far."""
root, stack, key = {}, [], None
cur = root
for m in KV_TOKEN.finditer(text):
quoted, brace, bare = m.groups()
if brace == "{":
new = {}
if key is not None:
cur[key] = new
stack.append(cur)
cur, key = new, None
elif brace == "}":
if not stack:
break
cur, key = stack.pop(), None
elif quoted is not None or bare is not None:
tok = quoted.replace('\\"', '"').replace("\\\\", "\\") if quoted is not None else bare
if key is None:
key = tok
else:
cur[key], key = tok, None
return root
def play_sessions():
"""[(start, end, package)] from every FramePort game's plays.log, by start time. A start without an end (Proton
launchers exec the game, so they log no end; a crash or power loss) lasts until the next game's start (one game
runs at a time), at most OPEN_SESSION."""
events = []
for path in glob.glob(os.path.join(ANCHORS, "*", PLAYS_LOG)):
pkg = os.path.basename(os.path.dirname(path))
try:
lines = open(path, encoding="utf-8", errors="replace").read().splitlines()
except OSError:
continue
for line in lines:
parts = line.split()
if len(parts) >= 2 and parts[0] in ("start", "end") and parts[1].isdigit():
events.append((int(parts[1]), parts[0], pkg))
sessions, open_ = [], {} # open_: pkg -> start
for t, kind, pkg in sorted(events):
if kind == "start":
if pkg in open_: # an earlier start never ended: it ended by this one
sessions.append([open_[pkg], t, pkg])
open_[pkg] = t
elif pkg in open_:
sessions.append([open_.pop(pkg), t, pkg])
sessions += [[s, None, pkg] for pkg, s in open_.items()]
sessions.sort(key=lambda s: s[0])
for i, s in enumerate(sessions):
if s[1] is None:
later = [x[0] for x in sessions[i + 1:] if x[0] > s[0]]
s[1] = min([s[0] + OPEN_SESSION] + later)
return [tuple(s) for s in sessions]
TEST_MARK_WINDOW = 60 # s: a "test <unix>" line in plays.log marks a session starting this soon after as a launch test
def last_play(anchor):
"""The newest play session in <anchor>/plays.log: {start, end (None while it runs, and for Proton launchers, which
exec the game), test (started by a launch test, not by the player)}, or None."""
text = _tail(os.path.join(anchor, PLAYS_LOG), 8192)
if not text:
return None
start = end = None
tests = []
for line in text.splitlines():
parts = line.split()
if len(parts) < 2 or not parts[1].isdigit():
continue
t = int(parts[1])
if parts[0] == "start":
start, end = t, None
elif parts[0] == "end" and start is not None and t >= start:
end = t
elif parts[0] == "test":
tests.append(t)
if start is None:
return None
return {"start": start, "end": end, "test": any(0 <= start - t <= TEST_MARK_WINDOW for t in tests)}
def mark_launch_test(anchor):
"""A launch test runs the game's launcher, which logs a play session: mark it so the PC doesn't triage it as one."""
try:
with open(os.path.join(anchor, PLAYS_LOG), "a") as f:
f.write(f"test {int(time.time())}\n")
except OSError:
pass
def session_game(t, sessions):
"""Package whose play session contains time t (the latest start wins), else None."""
hit = None
for start, end, pkg in sessions:
if start <= t <= end + SESSION_GRACE:
hit = pkg
return hit
def shot_time_from_name(name):
m = SHOT_NAME.match(name)
if not m:
return None
try:
return int(time.mktime(time.strptime(m[1], "%Y%m%d%H%M%S"))) # Steam names the files in local time
except (ValueError, OverflowError):
return None
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def user_screenshots(user):
"""Every screenshot file of one Steam account: [{path, thumb, time, width, height, appid}]. screenshots.vdf gives
times and sizes; files it doesn't list (Steam writes it at exit) come from the folders, timed by their name."""
remote = os.path.join(STEAM, "userdata", user, "760", "remote")
shots, seen = [], set()
def under(rel):
p = os.path.normpath(os.path.join(remote, rel)) if isinstance(rel, str) else None
return p if p and p.startswith(remote + os.sep) and os.path.isfile(p) else None
try:
text = open(os.path.join(STEAM, "userdata", user, "760", "screenshots.vdf"), encoding="utf-8",
errors="replace").read()
except OSError:
text = ""
entries = (kv_parse(text).get("screenshots") or {})
for game in entries.values() if isinstance(entries, dict) else ():
for e in game.values() if isinstance(game, dict) else ():
path = under(e.get("filename")) if isinstance(e, dict) else None
if not path or path in seen:
continue # deleted (Steam lists it until it rewrites the file)
seen.add(path)
shots.append({"path": path, "thumb": under(e.get("thumbnail")),
"time": _int(e.get("creation")) or shot_time_from_name(os.path.basename(path)),
"width": _int(e.get("width")), "height": _int(e.get("height")),
"appid": os.path.relpath(path, remote).split(os.sep)[0]})
for path in sorted(glob.glob(os.path.join(remote, "*", "screenshots", "*"))):
name = os.path.basename(path)
if path in seen or not SHOT_NAME.match(name) or not os.path.isfile(path):
continue
thumb = os.path.join(os.path.dirname(path), "thumbnails", name)
shots.append({"path": path, "thumb": thumb if os.path.isfile(thumb) else None,
"time": shot_time_from_name(name) or int(os.path.getmtime(path)), "width": None,
"height": None, "appid": os.path.relpath(path, remote).split(os.sep)[0]})
return shots
def shortcut_names(user):
"""{shortcut appid (str): name} of an account's non-Steam shortcuts."""
try:
data = vdf_decode(open(os.path.join(STEAM, "userdata", user, "config", "shortcuts.vdf"), "rb").read())
except (OSError, ValueError, IndexError, struct.error, AgentError):
return {}
out = {}
for e in (data.get("shortcuts") or {}).values():
if isinstance(e, dict) and isinstance(e.get("appid"), int):
out[str(e["appid"] & 0xFFFFFFFF)] = e.get("AppName") or e.get("appname") or ""
return out
def cmd_list_screenshots(args):
"""Steam screenshots on the Frame (every account), newest first: {shots: [{path, thumb, time, width, height, size,
account, appid, package, title}], total, games: [{package, title, count}]}. The Frame files every headset
screenshot under SteamVR (250820), so those are matched to the FramePort game whose play session (plays.log)
contains their time; the rest stay "SteamVR" (package null). args: offset, limit, package (a package, or "" for
the shots of no FramePort game); `total` and `games` count before offset/limit."""
titles, by_appid = {}, {}
for dep_path in glob.glob(os.path.join(ANCHORS, "*/deployment.json")):
try:
dep = json.load(open(dep_path))
except (OSError, ValueError):
continue
pkg = os.path.basename(os.path.dirname(dep_path))
titles[pkg] = dep.get("title") or pkg
if dep.get("appid") is not None:
by_appid[str(int(dep["appid"]) & 0xFFFFFFFF)] = pkg
sessions = play_sessions()
shots, names = [], {}
for user in steam_users():
names.update(shortcut_names(user))
for s in user_screenshots(user):
pkg = by_appid.get(s["appid"])
if pkg is None and s["appid"] == STEAMVR_APPID and s["time"]:
pkg = session_game(s["time"], sessions)
if pkg:
title = titles.get(pkg, pkg)
elif s["appid"] == STEAMVR_APPID:
title = "SteamVR"
else:
app = find_app_id(s["appid"]) if s["appid"] not in names else None
title = names.get(s["appid"]) or (app or {}).get("name") or f"App {s['appid']}"
try:
size = os.path.getsize(s["path"])
except OSError:
continue
shots.append({**s, "size": size, "account": user, "package": pkg, "title": title})
shots.sort(key=lambda s: (-(s["time"] or 0), s["path"]))
games = {}
for s in shots:
g = games.setdefault(s["package"] or s["title"], {"package": s["package"], "title": s["title"], "count": 0})
g["count"] += 1
want = args.get("package")
if want is not None:
shots = [s for s in shots if (s["package"] or "") == want]
offset = max(0, int(args.get("offset") or 0))
limit = int(args.get("limit") or 0)
return {"shots": shots[offset:offset + limit] if limit > 0 else shots[offset:], "total": len(shots),
"games": sorted(games.values(), key=lambda g: g["title"].lower())}
def screenshot_file(path):
"""`path` if it is a screenshot image in a Steam account's screenshot folder (userdata/<id>/760/remote/<appid>/
screenshots/<name>), else AgentError. Links and '..' are refused: nothing outside those folders can be deleted."""
if not isinstance(path, str) or "\0" in path or not path.startswith("/"):
raise AgentError(f"not a screenshot: {path!r}")
norm = os.path.normpath(path)
parts = os.path.relpath(norm, os.path.join(STEAM, "userdata")).split(os.sep)
if (norm != path or len(parts) != 6 or not parts[0].isdigit() or parts[1:3] != ["760", "remote"]
or not parts[3].isdigit() or parts[4] != "screenshots" or not SHOT_NAME.match(parts[5])):
raise AgentError(f"not a screenshot: {path}")
if os.path.islink(norm) or not os.path.isfile(norm) or not inside_userdata(os.path.dirname(norm)):
raise AgentError(f"not a screenshot file: {path}")
return norm
def inside_userdata(folder):
"""The folder, links resolved, is inside Steam's userdata (a linked screenshots folder could point anywhere)."""
return os.path.realpath(folder).startswith(os.path.realpath(os.path.join(STEAM, "userdata")) + os.sep)
def cmd_delete_screenshots(args):
"""Delete screenshots (+ their thumbnails); only image files in Steam's screenshot folders (screenshot_file; every
path is checked before anything is deleted). screenshots.vdf is left alone: Steam keeps it in memory and rewrites
it at exit, so an edit would be lost; Steam drops entries whose file is gone, list_screenshots skips them. Steam's
own screenshot list may show the deleted ones until Steam restarts."""
paths = [screenshot_file(p) for p in args.get("paths") or []]
deleted = []
for p in paths:
os.unlink(p)
deleted.append(p)
stem, ext = os.path.splitext(p)
try: # a VR screenshot's stereo copy (<name>_vr.jpg) next to it goes too
os.unlink(stem + "_vr" + ext)
except FileNotFoundError:
pass
thumbs = os.path.join(os.path.dirname(p), "thumbnails")
if inside_userdata(thumbs):
try:
os.unlink(os.path.join(thumbs, os.path.basename(p)))
except FileNotFoundError:
pass
return {"deleted": deleted}
# Headset screenshots through OpenVR's IVRScreenshots (SteamVR's own libopenvr_api.so via ctypes, no build step):
# connect as a background app (never starts SteamVR), RequestScreenshot(stereo) = SteamVR captures what the wearer
# sees into two files (a flat preview + the stereo image), SubmitScreenshot = the Steam client adds them to its
# library like a shot taken in the headset (under SteamVR, 250820). Verified on the device 2026-10-08. (SteamVR's
# dashboard debug command `screenshot_request` over its web mailbox did nothing here.) While the headset sleeps the
# compositor captures nothing.
OPENVR_LIBS = ("/opt/steamvr/bin/linuxarm64/libopenvr_api.so", "/opt/steamvr/bin/linux64/libopenvr_api.so")
VR_APP_BACKGROUND = 3
VR_SHOT_STEREO = 2
HMD_STATE = re.compile(r"\[CCVTrackedHmdDriver\] in State(\w+)")
def hmd_state():
"""The headset's state from SteamVR's driver log ("Normal", "Standby", ... ; None = unknown)."""
try:
with open(os.path.join(STEAM, "logs", "vrserver.txt"), "rb") as f:
f.seek(max(0, os.path.getsize(f.name) - 400_000))
text = f.read().decode(errors="replace")
except OSError:
return None
found = HMD_STATE.findall(text)
return found[-1] if found else None
def steamvr_shot_files():
return set(glob.glob(os.path.join(STEAM, "userdata", "*", "760", "remote", STEAMVR_APPID, "screenshots", "*")))
def vr_screenshot(capture_wait=6.0):
"""Ask SteamVR for a stereo screenshot and hand it to Steam. Returns None when submitted, else why not:
"steamvr" (not running / no OpenVR), "capture" (nothing captured: the headset sleeps), or an error text."""
import ctypes
import tempfile
lib = next((p for p in OPENVR_LIBS if os.path.isfile(p)), None)
if lib is None:
return "steamvr"
vr = ctypes.CDLL(lib)
vr.VR_InitInternal2.restype = ctypes.c_uint32
vr.VR_InitInternal2.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_int, ctypes.c_char_p]
vr.VR_GetGenericInterface.restype = ctypes.c_void_p
vr.VR_GetGenericInterface.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int)]
vr.VR_ShutdownInternal.restype = None
err = ctypes.c_int(0)
vr.VR_InitInternal2(ctypes.byref(err), VR_APP_BACKGROUND, None)
if err.value:
return "steamvr"
tmp = tempfile.mkdtemp(prefix="frameport-shot-")
try:
request = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(ctypes.c_uint32), ctypes.c_int, ctypes.c_char_p,
ctypes.c_char_p)
submit = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_uint32, ctypes.c_int, ctypes.c_char_p, ctypes.c_char_p)
class Table(ctypes.Structure): # openvr_capi.h VR_IVRScreenshots_FnTable (IVRScreenshots_001)
_fields_ = [("RequestScreenshot", request), ("HookScreenshot", ctypes.c_void_p),
("GetScreenshotPropertyType", ctypes.c_void_p),
("GetScreenshotPropertyFilename", ctypes.c_void_p),
("UpdateScreenshotProgress", ctypes.c_void_p), ("TakeStereoScreenshot", ctypes.c_void_p),
("SubmitScreenshot", submit)]
ierr = ctypes.c_int(0)
ptr = vr.VR_GetGenericInterface(b"FnTable:IVRScreenshots_001", ctypes.byref(ierr))
if not ptr:
return f"no screenshot interface ({ierr.value})"
table = Table.from_address(ptr)
base = os.path.join(tmp, "shot")
handle = ctypes.c_uint32(0)
r = table.RequestScreenshot(ctypes.byref(handle), VR_SHOT_STEREO, (base + "_preview").encode(),
(base + "_vr").encode())
if r:
return f"SteamVR refused the screenshot ({r})"
preview, full = base + "_preview.png", base + "_vr.png"
deadline = time.time() + capture_wait
while time.time() < deadline and not (os.path.isfile(preview) and os.path.isfile(full)):
time.sleep(0.2)
if not (os.path.isfile(preview) and os.path.isfile(full)):
return "capture"
time.sleep(0.4) # the second file can still be being written
r = table.SubmitScreenshot(handle.value, VR_SHOT_STEREO, preview.encode(), full.encode())
if r:
return f"Steam didn't take the screenshot ({r})"
time.sleep(1.5) # Steam copies the files: keep them until it has
return None
finally:
vr.VR_ShutdownInternal()
shutil.rmtree(tmp, ignore_errors=True)
def cmd_take_screenshot(args):
"""A headset screenshot now (vr_screenshot), saved by Steam under SteamVR; waits up to `wait` s for its file:
{taken, path, reason, hmd}. reason: "steamvr" (SteamVR not running), "capture" (the headset sleeps), other text =
an error."""
wait = float(args.get("wait") or 8)
before = steamvr_shot_files()
reason = vr_screenshot()
if reason is None:
deadline = time.time() + wait
while time.time() < deadline:
new = [p for p in steamvr_shot_files() - before if SHOT_NAME.match(os.path.basename(p))]
if new:
return {"taken": True, "path": max(new, key=os.path.getmtime), "reason": None, "hmd": hmd_state()}
time.sleep(0.4)
reason = "not saved"
return {"taken": False, "path": None, "reason": reason, "hmd": hmd_state()}
# ------------------------------------------------------------------------------------------ SteamVR overlay apps (v75)
# OpenVR overlay applications (VRApplication_Overlay: fpsVR, wrist watches, ...) draw no scene; SteamVR's compositor
# draws their overlays over whatever runs, Lepton (Quest) games and Proton (PC VR) games included (verified on the
# device 2026-10-10, docs/FRAME_RUNTIME.md "Overlay apps"). FramePort installs one like a PC VR game (Proton) or a
# Linux app and registers an application manifest with SteamVR (IVRApplications, live: SteamVR keeps the path in its
# appconfig.json) whose binary is the app's launch.sh, so SteamVR can start it, also at every SteamVR start
# ("autostart" = SteamVR's own auto-launch flag). The app's Steam shortcut still starts it from the library.
OVERLAY_MANIFEST = "frameport-overlay.vrmanifest"
VR_APP_UTILITY = 4
# openvr_capi.h VR_IVRApplications_FnTable (IVRApplications_007) slots used here
VR_APPS = {"AddApplicationManifest": 0, "RemoveApplicationManifest": 1, "IsApplicationInstalled": 2,
"LaunchApplication": 6, "GetApplicationProcessId": 12, "GetApplicationsErrorNameFromEnum": 13,
"SetApplicationAutoLaunch": 17, "GetApplicationAutoLaunch": 18}
def overlay_key(pkg, app=None):
"""The overlay's SteamVR application key: the app's own (from its bundled manifest, so registering it by hand
too can't make a second entry), else "frameport.<package>"."""
key = (app or {}).get("app_key")
if isinstance(key, str) and re.fullmatch(r"[A-Za-z0-9_.-]{3,100}", key):
return key
return "frameport." + re.sub(r"[^a-z0-9_.-]", "_", pkg.lower())
def overlay_app_dir(dep):
"""Where the app's files are on the Frame (its bundled manifest's paths are relative to manifest_dir there)."""
tree = "app" if dep.get("kind") == "linux" else "game"
rel = os.path.normpath((dep.get("overlay") or {}).get("manifest_dir") or ".")
if rel.startswith("..") or os.path.isabs(rel):
rel = "."
return os.path.normpath(os.path.join(dep["base"], tree, rel))
def overlay_manifest(anchor, pkg, dep):
"""SteamVR application manifest for an installed overlay app: the app's own bundled manifest entry (key, name,
image) when it has one, with its binary replaced by FramePort's launch.sh (Proton or the Linux launcher, which
set up the environment) and every path absolute."""
title = dep.get("title") or pkg
app = dict((dep.get("overlay") or {}).get("app") or {})
src = overlay_app_dir(dep)
image = app.get("image_path")
image = os.path.join(src, image) if isinstance(image, str) and image else ""
if not (image and os.path.isfile(image)):
image = os.path.join(anchor, "artwork", "icon.png")
strings = app.get("strings") if isinstance(app.get("strings"), dict) else \
{"en_us": {"name": title, "description": f"{title} (installed by FramePort)"}}
# SteamVR on the Frame (linuxarm64) only reads binary_path_linux_arm ("must specify binary_path for launch_type
# binary. Skipping" for a manifest with binary_path_linux alone); both for SteamVR builds that read the other
launcher = os.path.join(anchor, "launch.sh")
entry = {"app_key": overlay_key(pkg, app), "launch_type": "binary",
"binary_path_linux_arm": launcher, "binary_path_linux": launcher, "working_directory": anchor,
"is_dashboard_overlay": True, "image_path": image, "strings": strings}
if dep.get("kind") == "linux" and app.get("arguments"): # the Linux launcher passes them on ("$@")
entry["arguments"] = app["arguments"]
return {"source": "builtin", "applications": [entry]}
class SteamVRApps:
"""IVRApplications of the running SteamVR, through its libopenvr_api.so (ctypes) as a utility app (never starts
SteamVR). `None` from open() = SteamVR isn't running."""
interface = b"FnTable:IVRApplications_007"
table_size = 31
@classmethod
def open(cls):
import ctypes
lib = next((p for p in OPENVR_LIBS if os.path.isfile(p)), None)
if lib is None:
return None
vr = ctypes.CDLL(lib)
vr.VR_InitInternal2.restype = ctypes.c_uint32
vr.VR_InitInternal2.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_int, ctypes.c_char_p]
vr.VR_GetGenericInterface.restype = ctypes.c_void_p
vr.VR_GetGenericInterface.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int)]
vr.VR_IsRuntimeInstalled.restype = ctypes.c_bool
vr.VR_ShutdownInternal.restype = None
err = ctypes.c_int(0)
vr.VR_InitInternal2(ctypes.byref(err), VR_APP_UTILITY, None)
if err.value:
return None
ptr = vr.VR_GetGenericInterface(cls.interface, ctypes.byref(err))
if not ptr:
vr.VR_ShutdownInternal()
return None
self = cls()
self.vr, self.ct = vr, ctypes
self.table = (ctypes.c_void_p * cls.table_size).from_address(ptr)
return self
def fn(self, name, restype, *argtypes):
return self.ct.CFUNCTYPE(restype, *argtypes)(self.table[VR_APPS[name]])
def error(self, code):
if not code:
return None
name = self.fn("GetApplicationsErrorNameFromEnum", self.ct.c_char_p, self.ct.c_int)(code)
return (name or b"").decode() or str(code)
def add(self, path):
return self.error(self.fn("AddApplicationManifest", self.ct.c_int, self.ct.c_char_p, self.ct.c_bool)(
path.encode(), False))
def remove(self, path):
return self.error(self.fn("RemoveApplicationManifest", self.ct.c_int, self.ct.c_char_p)(path.encode()))
def installed(self, key):
return bool(self.fn("IsApplicationInstalled", self.ct.c_bool, self.ct.c_char_p)(key.encode()))
def set_autolaunch(self, key, on):
return self.error(self.fn("SetApplicationAutoLaunch", self.ct.c_int, self.ct.c_char_p, self.ct.c_bool)(
key.encode(), bool(on)))
def autolaunch(self, key):
return bool(self.fn("GetApplicationAutoLaunch", self.ct.c_bool, self.ct.c_char_p)(key.encode()))
def launch(self, key):
return self.error(self.fn("LaunchApplication", self.ct.c_int, self.ct.c_char_p)(key.encode()))
def pid(self, key):
return int(self.fn("GetApplicationProcessId", self.ct.c_uint32, self.ct.c_char_p)(key.encode()))
def close(self):
self.vr.VR_ShutdownInternal()
def register_overlay(pkg, autostart=False):
"""Write the overlay app's manifest next to its launcher and register it with SteamVR (live). Returns
{key, manifest, registered, autostart, error}; registered False + error "steamvr" = SteamVR isn't running (the
deployment keeps "overlay" and ensure_host_fixes registers it at the next connection)."""
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
anchor = os.path.join(ANCHORS, pkg)
path = os.path.join(anchor, OVERLAY_MANIFEST)
manifest = overlay_manifest(anchor, pkg, dep)
with open(path + ".tmp", "w") as f:
json.dump(manifest, f, indent=2)
os.replace(path + ".tmp", path)
key = manifest["applications"][0]["app_key"]
out = {"key": key, "manifest": path, "registered": False, "autostart": False, "error": None}
apps = SteamVRApps.open()
if apps is None:
out["error"] = "steamvr"
return out
try:
out["error"] = apps.add(path)
out["registered"] = apps.installed(key)
if out["registered"]:
out["error"] = apps.set_autolaunch(key, autostart) or out["error"]
out["autostart"] = apps.autolaunch(key)
finally:
apps.close()
return out
def dep_overlay_key(pkg, dep=None):
dep = dep if dep is not None else deployment(pkg)
return overlay_key(pkg, ((dep or {}).get("overlay") or {}).get("app"))
def unregister_overlay(pkg):
"""Remove the overlay app's manifest from SteamVR (and the file). True when SteamVR was reached."""
anchor = os.path.join(ANCHORS, pkg)
path = os.path.join(anchor, OVERLAY_MANIFEST)
key = None
try:
key = json.load(open(path))["applications"][0]["app_key"]
except (OSError, ValueError, KeyError, IndexError, TypeError):
key = dep_overlay_key(pkg)
apps = SteamVRApps.open()
reached = apps is not None
if apps is not None:
try:
apps.set_autolaunch(key, False)
apps.remove(path)
finally:
apps.close()
if os.path.exists(path):
os.remove(path)
return reached
def overlay_entry(app):
"""The overlay application entry of an app's bundled .vrmanifest (validated; None if there is none)."""
if not isinstance(app, dict):
return None
keep = {k: app[k] for k in ("app_key", "image_path", "strings", "arguments") if k in app}
if not isinstance(keep.get("image_path", ""), str) or not isinstance(keep.get("arguments", ""), str):
return None
return keep
def cmd_register_vr_overlay(args):
"""Register an installed app as a SteamVR overlay app: {package, autostart, app (its bundled manifest's overlay
entry, optional), manifest_dir (that manifest's folder in the app's files)} → register_overlay()'s result. The
deployment records it (overlay apps skip launch tests and don't count as a running game)."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
old = dep.get("overlay") or {}
app = overlay_entry(args["app"]) if "app" in args else old.get("app")
dep["overlay"] = {"autostart": bool(args.get("autostart")), "app": app,
"manifest_dir": args.get("manifest_dir", old.get("manifest_dir")) or ".", "registered": False}
dep["overlay"]["key"] = overlay_key(pkg, app)
if old.get("key") and old["key"] != dep["overlay"]["key"]:
unregister_overlay(pkg)
write_deployment(pkg, dep)
out = register_overlay(pkg, bool(args.get("autostart")))
dep["overlay"]["registered"] = out["registered"]
write_deployment(pkg, dep)
out["service"] = overlay_service_quiet()
return out
def cmd_unregister_vr_overlay(args):
pkg = check_pkg(args["package"])
reached = unregister_overlay(pkg)
dep = deployment(pkg)
if dep and "overlay" in dep:
dep.pop("overlay")
write_deployment(pkg, dep)
overlay_service_quiet()
return {"unregistered": True, "steamvr": reached}
def cmd_launch_vr_overlay(args):
"""Start an installed overlay app through SteamVR (LaunchApplication of its manifest), not as a Steam game, so a
game can be played next to it. {started, pid, error}."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep or not dep.get("overlay"):
raise AgentError(f"{pkg} is not installed as an overlay app")
if not dep["overlay"].get("registered"):
register_overlay(pkg, dep["overlay"].get("autostart"))
apps = SteamVRApps.open()
if apps is None:
raise AgentError("SteamVR isn't running on the Frame")
try:
key = dep_overlay_key(pkg, dep)
if not apps.installed(key):
apps.add(os.path.join(ANCHORS, pkg, OVERLAY_MANIFEST))
err = apps.launch(key)
pid = 0
for _ in range(20):
pid = apps.pid(key)
if pid or err:
break
time.sleep(0.5)
finally:
apps.close()
return {"started": not err, "pid": pid or None, "error": err}
def overlay_service_quiet():
"""ensure_overlay_service() after an overlay app's autostart flag changed; a systemd problem never fails the
command (ensure_host_fixes tries again at the next connection)."""
try:
return ensure_overlay_service()
except Exception as exc: # noqa: BLE001
return f"error: {exc}"
def overlay_registrations():
"""Register overlay apps whose registration is missing (SteamVR wasn't running at install, or forgot it)."""
done = []
for dep_path in glob.glob(os.path.join(ANCHORS, "*/deployment.json")):
try:
dep = json.load(open(dep_path))
except (OSError, ValueError):
continue
ov = dep.get("overlay") if isinstance(dep, dict) else None
if not ov or not dep.get("package"):
continue
apps = SteamVRApps.open()
if apps is None:
return done
try:
ok = apps.installed(dep_overlay_key(dep["package"], dep))
finally:
apps.close()
if not ok:
r = register_overlay(dep["package"], ov.get("autostart"))
if r["registered"]:
ov["registered"] = True
write_deployment(dep["package"], dep)
done.append(dep["package"])
return done
# ------------------------------------------------------------------------------------------ overlay autostart (v76)
# SteamVR's own auto-launch flag (SetApplicationAutoLaunch) was accepted on the dev Frame but never written to
# steamvr.vrsettings, and a registered watch didn't start by itself (2026-10-10). FramePort's deployment flag
# (overlay.autostart) is the source of truth: a small user service (only while at least one installed overlay app has
# autostart on) notices every new vrserver process (SteamVR start or restart, boot), waits until SteamVR answers,
# gives SteamVR's own auto-launch a moment, then starts each autostart overlay app that isn't running yet, the same
# way launch_vr_overlay does (LaunchApplication: SteamVR spawns it, in its own environment). Off for every app: the
# flag file VR_OVERLAY_DISABLED or FRAMEPORT_NO_OVERLAY_AUTOSTART=1 (the service is then removed).
VR_OVERLAY_UNIT = "frameport-vr-overlays"
SYSTEMD_USER_DIR = os.path.join(HOME, ".config/systemd/user")
VR_OVERLAY_UNIT_PATH = os.path.join(SYSTEMD_USER_DIR, VR_OVERLAY_UNIT + ".service")
VR_OVERLAY_DISABLED = os.path.join(HOME, ".local/share/frameport/vr-overlays.disabled")
VR_OVERLAY_LOG = os.path.join(HOME, ".local/share/frameport/vr-overlays.log")
VR_OVERLAY_STATE = os.path.join(HOME, ".cache/frameport-vr-overlays.json")
VR_OVERLAY_LOG_MAX = 128 * 1024
VR_OVERLAY_POLL = 5 # s between /proc scans (nothing else happens while SteamVR keeps running)
VR_OVERLAY_READY_WAIT = 180 # s for a new SteamVR to answer IVRApplications
VR_OVERLAY_GRACE = 15 # s after SteamVR answers: its own auto-launch may start the app first
def overlay_autostart_disabled():
return os.environ.get("FRAMEPORT_NO_OVERLAY_AUTOSTART", "0") not in ("", "0") or \
os.path.exists(VR_OVERLAY_DISABLED)
def autostart_overlays():
"""Installed overlay apps whose deployment has autostart on: [(package, deployment)]."""
found = []
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, "*/deployment.json"))):
try:
dep = json.load(open(dep_path))
except (OSError, ValueError):
continue
if isinstance(dep, dict) and dep.get("package") and (dep.get("overlay") or {}).get("autostart"):
dep.setdefault("base", os.path.dirname(dep_path))
found.append((dep["package"], dep))
return found
def systemd_quote(arg):
"""One ExecStart= word (systemd's own quoting: double quotes, % and $ doubled)."""
arg = arg.replace("%", "%%").replace("$", "$$")
if re.fullmatch(r"[A-Za-z0-9_./+:=@-]+", arg):
return arg
return '"' + arg.replace("\\", "\\\\").replace('"', '\\"') + '"'
def vr_overlay_unit():
"""The user service's unit text (the agent's path and Python are written in: the agent lives in AGENT_HOME)."""
agent = os.path.abspath(__file__)
return ("[Unit]\n"
"Description=FramePort: start SteamVR overlay apps with SteamVR\n"
"\n[Service]\n"
"Type=simple\n"
f"ExecStart={systemd_quote(sys.executable or '/usr/bin/python3')} {systemd_quote(agent)} "
"_vr_overlay_watch\n"
"Restart=always\n"
"RestartSec=10\n"
"Nice=10\n"
"\n[Install]\n"
"WantedBy=default.target\n")
def user_systemctl(*args):
return run(["systemctl", "--user", *args])
def ensure_overlay_service():
"""Install (or update) the overlay autostart service when an installed overlay app has autostart on, remove it
when none has (or the kill switch is set). Returns "installed", "updated", "removed" or None (no change)."""
want = bool(autostart_overlays()) and not overlay_autostart_disabled()
text = vr_overlay_unit()
try:
current = open(VR_OVERLAY_UNIT_PATH).read()
except OSError:
current = None
if not want:
if current is None:
return None
user_systemctl("disable", "--now", VR_OVERLAY_UNIT + ".service")
try:
os.remove(VR_OVERLAY_UNIT_PATH)
except OSError:
pass
user_systemctl("daemon-reload")
user_systemctl("reset-failed", VR_OVERLAY_UNIT + ".service")
return "removed"
if current == text:
if user_systemctl("is-active", "--quiet", VR_OVERLAY_UNIT + ".service").returncode == 0:
return None
user_systemctl("enable", "--now", VR_OVERLAY_UNIT + ".service")
return "started"
os.makedirs(SYSTEMD_USER_DIR, exist_ok=True)
tmp = VR_OVERLAY_UNIT_PATH + ".tmp"
with open(tmp, "w") as f:
f.write(text)
os.replace(tmp, VR_OVERLAY_UNIT_PATH)
user_systemctl("daemon-reload")
user_systemctl("enable", VR_OVERLAY_UNIT + ".service")
user_systemctl("restart", VR_OVERLAY_UNIT + ".service")
return "installed" if current is None else "updated"
def overlay_log(msg):
try:
os.makedirs(os.path.dirname(VR_OVERLAY_LOG), exist_ok=True)
if os.path.exists(VR_OVERLAY_LOG) and os.path.getsize(VR_OVERLAY_LOG) > VR_OVERLAY_LOG_MAX:
os.replace(VR_OVERLAY_LOG, VR_OVERLAY_LOG + ".1")
with open(VR_OVERLAY_LOG, "a") as f:
f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n")
except OSError:
pass
def vrserver_ident(pid):
""""<pid>:<start ticks>" when process `pid` is a vrserver (the start time tells a reused pid apart), else None."""
try:
with open(f"/proc/{pid}/stat") as f:
stat = f.read()
except OSError:
return None
if stat[stat.find("(") + 1:stat.rfind(")")] != "vrserver":
return None
return f"{pid}:{stat[stat.rfind(')') + 2:].split()[19]}"
def vrserver_process(known=None):
"""The running vrserver's vrserver_ident, None if none. `known` (the last one seen) is checked first: while
SteamVR keeps running a poll reads one file instead of scanning /proc."""
if known and vrserver_ident(known.split(":")[0]) == known:
return known
for d in os.listdir("/proc"):
if d.isdigit():
found = vrserver_ident(d)
if found:
return found
return None
def overlay_running(pkg, dep, apps=None):
"""The overlay app runs already: a process of its install folder or launcher (also when Steam or SteamVR started
it), or SteamVR knows its process (GetApplicationProcessId)."""
if pcvr_pids(dep.get("base") or os.path.join(ANCHORS, pkg)) or pcvr_pids(os.path.join(ANCHORS, pkg)):
return True
if apps is not None:
try:
return bool(apps.pid(dep_overlay_key(pkg, dep)))
except Exception: # noqa: BLE001
return False
return False
def overlay_autostart_round():
"""One new SteamVR: wait until it answers, give its own auto-launch a moment, then start each autostart overlay
app that isn't running. Returns {package: "started" | "running" | error text}; {} without SteamVR."""
deadline = time.time() + VR_OVERLAY_READY_WAIT
apps = SteamVRApps.open()
while apps is None and time.time() < deadline:
time.sleep(3)
apps = SteamVRApps.open()
if apps is None:
overlay_log("SteamVR didn't answer: nothing started")
return {}
try:
time.sleep(VR_OVERLAY_GRACE)
todo = [(pkg, dep) for pkg, dep in autostart_overlays() if not overlay_running(pkg, dep, apps)]
result = {pkg: "running" for pkg, _ in autostart_overlays() if pkg not in dict(todo)}
finally:
apps.close()
for pkg, _ in todo:
try:
r = cmd_launch_vr_overlay({"package": pkg})
result[pkg] = "started" if r.get("started") else (r.get("error") or "not started")
except Exception as exc: # noqa: BLE001
result[pkg] = str(exc) or type(exc).__name__
return result
def overlay_watch_tick(state):
"""One poll of the watcher: a vrserver not handled before (state["vrserver"]) gets one autostart round. Returns
the round's result, or None when there was nothing to do."""
if overlay_autostart_disabled():
return None
proc = vrserver_process(state.get("vrserver"))
if not proc or proc == state.get("vrserver"):
return None
state["vrserver"] = proc
overlay_save_state(state) # a restarted watcher (agent update) doesn't start apps the user closed again
if not autostart_overlays():
return None
overlay_log(f"new SteamVR (vrserver {proc.split(':')[0]})")
result = run_overlay_round()
for pkg, what in result.items():
overlay_log(f"{pkg}: {what}" if what in ("started", "running") else f"{pkg}: couldn't start it: {what}")
state["last"] = {"time": time.time(), "result": result}
overlay_save_state(state)
return result
def run_overlay_round():
"""overlay_autostart_round() in a child process (_vr_overlay_round): OpenVR is loaded only there, and a SteamVR
that never answers can't hang the watcher (timeout)."""
try:
p = subprocess.run([sys.executable, os.path.abspath(__file__), "_vr_overlay_round"], capture_output=True,
text=True, errors="replace", timeout=VR_OVERLAY_READY_WAIT + VR_OVERLAY_GRACE + 120)
except subprocess.TimeoutExpired:
return {"*": "timed out"}
try:
out = json.loads(p.stdout.strip().splitlines()[-1])
return out if isinstance(out, dict) else {}
except (ValueError, IndexError):
return {"*": (p.stderr.strip().splitlines() or [f"exit {p.returncode}"])[-1]}
def overlay_save_state(state):
try:
os.makedirs(os.path.dirname(VR_OVERLAY_STATE), exist_ok=True)
with open(VR_OVERLAY_STATE + ".tmp", "w") as f:
json.dump(state, f)
os.replace(VR_OVERLAY_STATE + ".tmp", VR_OVERLAY_STATE)
except OSError:
pass
def vr_overlay_watch():
"""The service's loop (_vr_overlay_watch): a /proc scan every VR_OVERLAY_POLL s. Exits when its own file changes
(an agent update: systemd starts the new one)."""
me = os.path.abspath(__file__)
try:
mine = os.path.getmtime(me)
except OSError:
mine = None
try:
state = json.load(open(VR_OVERLAY_STATE))
state = state if isinstance(state, dict) else {}
except (OSError, ValueError):
state = {}
overlay_log(f"watching (agent v{AGENT_VERSION}, overlay apps with autostart: "
f"{', '.join(p for p, _ in autostart_overlays()) or 'none'})")
while True:
try:
if os.path.getmtime(me) != mine:
overlay_log("agent updated: restarting")
return
except OSError:
return
try:
overlay_watch_tick(state)
except Exception as exc: # noqa: BLE001 (keep watching)
overlay_log(f"error: {exc}")
time.sleep(VR_OVERLAY_POLL)
def cmd_prepare(args):
"""Where to upload, and what the Frame already has (so unchanged data is not re-sent)."""
pkg = check_pkg(args["package"])
title = args["title"]
ensure_host_fixes()
anchor = os.path.join(ANCHORS, pkg)
dep = deployment(pkg)
base, free = new_base(args, pkg, dep)
appid = dep["appid"] if dep else shortcut_appid(f'"{anchor}/launch.sh"', title)
if container_running(appid):
raise AgentError(f"{title} is running on the Frame. Close it first.")
incoming = os.path.join(base, "incoming")
os.makedirs(os.path.join(incoming, "obb"), exist_ok=True)
app = os.path.join(base, "lepton-app")
existing = {}
obb = os.path.join(app, "obb")
for root, _, files in os.walk(obb):
for name in files:
p = os.path.join(root, name)
existing[os.path.relpath(p, obb)] = os.path.getsize(p)
existing.update(tree_manifest(os.path.join(incoming, "obb"))) # uploaded before an interruption
apk = os.path.join(app, "game.apk")
want_sha = args.get("apk_sha256")
same_apk = bool(want_sha and os.path.exists(apk) and os.path.getsize(apk) == args.get("apk_size")
and sha256_file(apk) == want_sha)
lepton, _ = lepton_path()
return {"package": pkg, "base": base, "anchor": anchor, "appid": appid, "incoming": incoming,
"installed": bool(dep), "same_apk": same_apk, "existing_obb": existing,
"free_bytes": free, "lepton": lepton}
# Valve's Lepton pastes the game's paths unquoted into shell commands (liblepton mounting.sh: `sh -c "... ln -s
# ${STEAM_COMPAT_DATA_PATH}/internal/<pkg> /data/data/<pkg>"`, `lepton_basename ${APK}`), so a game on a drive whose
# name has a space ("Steam Frame 512g") never gets its internal storage: no saves, no SharedPreferences, and e.g. Epic's
# EOS SDK retries its keychain forever (GitHub #213/#214/#215/#221). Such games reach Lepton through a link without
# spaces, made again at every start (a card can be mounted elsewhere); deployment.json keeps the real path.
LEPTON_PATHS = os.path.join(HOME, ".local/share/frameport/lepton-paths")
LEPTON_SAFE_RE = re.compile(r"[A-Za-z0-9._/+@%=:,-]+")
APP_DIR_RE = re.compile(r"^(?:mkdir -p \S*lepton-paths\S* && ln -sfn .*\n)?app_dir=(.*)$", re.M)
def app_dir_lines(base, pkg):
"""The launcher's app_dir line for a game in `base` (a link line first when Lepton can't take that path)."""
if LEPTON_SAFE_RE.fullmatch(base):
return "app_dir=" + shlex.quote(base)
link = os.path.join(LEPTON_PATHS, pkg)
return (f"mkdir -p {shlex.quote(LEPTON_PATHS)} && ln -sfn {shlex.quote(base)} {shlex.quote(link)}"
f" # Lepton can't take paths with spaces\napp_dir={shlex.quote(link)}")
def remove_lepton_path(pkg):
try:
os.remove(os.path.join(LEPTON_PATHS, pkg))
except OSError:
pass
def set_app_dir(text, base, pkg):
"""launch.sh with its app_dir line(s) for `base`; (text, False) when it has none."""
text, n = APP_DIR_RE.subn(lambda m: app_dir_lines(base, pkg), text, count=1)
return text, bool(n)
def launcher_base(text, pkg):
"""The game folder a Quest launcher points at (the link's target when it goes through LEPTON_PATHS), or None."""
m = APP_DIR_RE.search(text)
if not m:
return None
try:
words = shlex.split(m.group(0).splitlines()[0], comments=True)
except ValueError:
return None
if m.group(0).startswith("mkdir -p ") and len(words) >= 8 and words[3:6] == ["&&", "ln", "-sfn"]:
return words[6]
value = shlex.split(m.group(1)) if m.group(1) else []
return value[0] if len(value) == 1 else None
LAUNCH_SH = (r"""#!/usr/bin/env bash
# Steam Frame launcher for {title} ({pkg}). Generated by FramePort.
set -euo pipefail
{app_dir}
[[ -d "$app_dir/lepton-app" ]] || {{ echo "Game files missing at $app_dir (storage not mounted?)" >&2; exit 1; }}
{single}
# Some games (Unreal cloud saves, SUPERHOT's cloud/data: mode 1700) create folders without write/search permission
# for the app inside Lepton (it writes through the folder's group), which breaks saving or makes the game quit.
# Repair them before and during every launch.
fix_perms() {{ find "$app_dir/lepton-data/external" -type d \( ! -perm -u+rwx -o ! -perm -g+rwx \) """
r"""-exec chmod u+rwx,g+rwx {{}} + 2>/dev/null ||"""
r""" true; }}
# Android can't create an app's external files/cache folders inside Lepton ("Invalid mkdirs path ... not a known app
# path"): getExternalCacheDir() then returns nothing, which breaks e.g. Whirligig's video player cache. Create them"""
r""" here.
mkdir -p "$app_dir/lepton-data/external/Android/data/{pkg}/files" """
r""""$app_dir/lepton-data/external/Android/data/{pkg}/cache" 2>/dev/null || true
# Prepare cloud saves before startup: a newly created cloud folder can have mode 1700, and the game may try to
# create its data subfolder before the permission watchdog repairs it (Jurassic World Aftermath, GitHub #239).
{cloud_dirs}
fix_perms
{watchdog}
export SteamAppId={appid}
export STEAM_COMPAT_INSTALL_PATH="$app_dir/lepton-app"
export STEAM_COMPAT_DATA_PATH="$app_dir/lepton-data"
export STEAM_COMPAT_SHADER_PATH="$app_dir/lepton-shaders"
export STEAM_COMPAT_LIBRARY_PATHS="$app_dir"
export LEPTON_ENV_FRAMEBRIDGE_CONFIG="$app_dir/settings.conf"
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
export IS_PARENT=true
{extra_env}
{video_codec}
child=''
stop() {{
trap - EXIT INT TERM
kill $permfix 2>/dev/null || true
[[ -n "$child" ]] && kill -TERM -- "-$child" 2>/dev/null || true
podman kill "lepton-steamlaunch-$SteamAppId" >/dev/null 2>&1 || true
{plays_end}
{save_snapshot}
}}
trap stop EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
{plays_start}
setsid {lepton_q} start >"$app_dir/launch.log" 2>&1 &
child=$!
{dashboard}
{logcat}
{restart}
""")
# Every 2 s: repair folder permissions, and end the game when whoever started this launcher is gone. Steam starts it
# under its "reaper"; when Steam stops only the reaper (seen: SIGTERM to the reaper left launch.sh, Lepton and the
# container running), the game used to keep running with nothing in Steam to close it (GitHub #36).
OLD_WATCHDOG = "( while sleep 2 && kill -0 $$ 2>/dev/null; do fix_perms; done ) & permfix=$!"
# SteamVR's dashboard (Resume game / controller / VR options) is open when a FramePort game starts and has to be closed
# by hand (also when Lepton shows its Android launcher first). Once the game's first VR frames are logged, the agent
# closes it through Steam's UI (_dashboard_worker); opt out per game with FRAMEPORT_KEEP_DASHBOARD=1.
DASHBOARD_LINE = ('[[ -n "${{FRAMEPORT_KEEP_DASHBOARD:-}}" ]] || python3 {agent_q} _dashboard_worker '
'"$app_dir/launch.log" $$ >"$app_dir/dashboard.log" 2>&1 9>&- &')
WATCHDOG = ("parent=$PPID\n"
"( while sleep 2 && kill -0 $$ 2>/dev/null; do fix_perms;"
" if [[ $parent -gt 1 ]] && ! kill -0 $parent 2>/dev/null; then kill -TERM $$; fi; done )"
" 9>&- & permfix=$!")
# One launcher per game: Play pressed again while Lepton still boots (~10 s with nothing to see) made the second Lepton
# stop the first one's container, and both died (Vader Immortal, BattleSisters). The lock is held by Lepton's process
# (inherited fd), so it is released when the game ends.
SINGLE_LINE = ('exec 9>"$app_dir/.launch.lock"; flock -n 9 || '
'{ echo "$(date +%s) already starting or running: second launch ignored" >>"$app_dir/launch-dup.log"; '
'exit 0; }')
# Lepton mirrors the game's logcat into launch.log, and that reader sometimes fails: it dies right after the game
# starts ("logcat: Unexpected EOF!", Vader Immortal on Lepton 3.0.5, Under Cover on 2.8.14), can't read ("Logcat read
# failure"), or runs and writes nothing (Lepton 3.0.5, 2026-10-14). The game runs on, but launch.log stays empty: no
# dashboard auto-hide, no launch-test result, no session triage. _logcat_keeper then reads the container's logcat
# itself and appends it to launch.log.
LOGCAT_LINE = ('python3 {agent_q} _logcat_keeper "$app_dir/launch.log" "$SteamAppId" $$ '
'>"$app_dir/logcat-keeper.log" 2>&1 9>&- &')
CLOUD_DIRS_LINE = 'mkdir -p "$app_dir/lepton-data/external/Android/data/{pkg}/files/cloud/data" 2>/dev/null || true'
def cloud_dirs_line(pkg):
return CLOUD_DIRS_LINE.format(pkg=pkg)
def dashboard_line():
return DASHBOARD_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__)))
# Unreal (4.21+, r.ProgramBinaryCache.RestartAndroidAfterPrecompile) restarts the app once after its first-run shader
# precompile (the "optimizing" screen): it starts its own launcher activity and exits. Lepton ends the container when
# the first process exits, the restarted game with it (Sports Scramble stopped after its first start; skipping the
# restart left it on that screen). The launcher then starts Lepton once more, in the same Steam launch
# (_restart_requested reads the two logs). FRAMEPORT_NO_RESTART=1 turns it off.
RESTART_BLOCK = """rc=0; wait "$child" || rc=$?
if [[ -z "${{FRAMEPORT_NO_RESTART:-}}" ]] && python3 {agent_q} _restart_requested "$app_dir/launch.log" "$SteamAppId" \\
{pkg_q} 9>&-; then
mv -f "$app_dir/launch.log" "$app_dir/launch-before-restart.log" 2>/dev/null || true
setsid {lepton_q} start >"$app_dir/launch.log" 2>&1 &
child=$!
{dashboard}
rc=0; wait "$child" || rc=$?
fi
exit $rc"""
def restart_block(pkg, lepton):
return RESTART_BLOCK.format(agent_q=shlex.quote(os.path.abspath(__file__)), pkg_q=shlex.quote(pkg),
lepton_q=shlex.quote(lepton), dashboard=dashboard_line())
LOGCAT_TIME = re.compile(r"^(?:\x1b\[[0-9;]*m)?(\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})")
def restart_requested(log, appid, pkg, max_gap=5.0):
"""Whether the game that just ended asked to be started again (RESTART_BLOCK): in Lepton's system log (dumped to
lepton-logcats/steamlaunch-<appid>/ when the container ends) the app itself (uid >= 10000; Lepton's own start is
uid 0) started its LAUNCHER activity by package, and at most max_gap s later the game's process called
System.exit(0) (launch.log; Android's WebView helper does too at every boot)."""
def when(line):
m = LOGCAT_TIME.match(line)
if not m:
return None
try:
t = time.strptime("2000-" + m.group(1)[:14], "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
return time.mktime(t) + float(m.group(1)[14:])
try:
with open(log, errors="replace") as f:
exits = [when(ln) for ln in f if "System.exit called, status: 0" in ln and "WebViewLoader" not in ln]
syslog = os.path.join(STEAM, "logs", "lepton-logcats", f"steamlaunch-{appid}", "logcat-system.log")
with open(syslog, errors="replace") as f:
starts = []
for ln in f:
m = re.search(r"ActivityTaskManager: START u0 \{act=android\.intent\.action\.MAIN "
r"cat=\[android\.intent\.category\.LAUNCHER\][^}]* pkg=" + re.escape(pkg)
+ r"[ }][^\n]* from uid (\d+)", ln)
if m and int(m.group(1)) >= 10000:
starts.append(when(ln))
except OSError:
return False
for e in exits:
if e is not None and any(s is not None and 0 <= e - s <= max_gap for s in starts):
try:
with open(log, "a") as f:
f.write("FramePort: the game restarted itself (e.g. Unreal after its first-run shader "
"precompile): starting it again\n")
except OSError:
pass
return True
return False
def logcat_line():
return LOGCAT_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__)))
def plays_lines(anchor):
"""launch.sh lines that log play sessions to <anchor>/plays.log (screenshots are matched to games by time: the
Frame files every headset screenshot under SteamVR). Never fail the launcher."""
q = shlex.quote(os.path.join(anchor, PLAYS_LOG))
return (f'echo "start $(date +%s)" >>{q} 2>/dev/null || true',
f'echo "end $(date +%s)" >>{q} 2>/dev/null || true')
def snapshot_line(pkg):
return (f"timeout -k 5 90 python3 {shlex.quote(os.path.abspath(__file__))} _snapshot_close "
f"{shlex.quote(pkg)} >>\"$app_dir/save-snapshot.log\" 2>&1 || true")
def upgrade_launchers():
"""Give launchers written by older agents the parent watchdog, the dashboard closer and the play-session log (in
place: a new file, so a running launcher keeps reading the old one). Returns the packages changed."""
changed = []
for path in glob.glob(os.path.join(ANCHORS, "*/launch.sh")):
try:
text = open(path).read()
except OSError:
continue
new = text
# the codec line follows the game's deployment (only Lepton launchers have this variable; Proton/Linux
# launchers are never touched). Running launchers keep reading their old file.
if "export LEPTON_ENV_FRAMEBRIDGE_CONFIG=" in new:
cloud = cloud_dirs_line(os.path.basename(os.path.dirname(path)))
if cloud not in new:
new = new.replace("\nfix_perms\n", "\n" + cloud + "\nfix_perms\n", 1)
new = set_codec_line(new, wants_hw_video(os.path.dirname(path)))
new = set_gamepad_line(new, wants_gamepad(os.path.dirname(path)))
pkg = os.path.basename(os.path.dirname(path))
dep = deployment(pkg) or {}
new = set_openxr_manifest_line(new, pkg, dep.get("static_openxr_loader", False))
base = launcher_base(new, pkg)
if base: # games on a drive whose path has a space reach Lepton through a link (agent v83)
new = set_app_dir(new, base, pkg)[0]
if "a Linux app. Generated by FramePort" in new and "FRAMEPORT_DESKTOP" not in new:
new = upgrade_linux_launcher(new)
if OLD_WATCHDOG in new and "parent=$PPID" not in new:
new = new.replace(OLD_WATCHDOG, WATCHDOG, 1)
if "_dashboard_worker" not in new and 'child=$!\nwait "$child"' in new:
new = new.replace('child=$!\nwait "$child"', 'child=$!\n' + dashboard_line() + '\nwait "$child"', 1)
if "_logcat_keeper" not in new and "_dashboard_worker" in new and '\nwait "$child"' in new:
new = new.replace('\nwait "$child"', '\n' + logcat_line() + '\nwait "$child"', 1)
guard = '[[ -d "$app_dir/lepton-app" ]] ||'
if ".launch.lock" not in new and guard in new:
i = new.index("\n", new.index(guard)) + 1
new = new[:i] + SINGLE_LINE + "\n" + new[i:]
# the lock belongs to Lepton only: helpers that outlive the game must not keep it
new = new.replace("; fi; done ) & permfix=$!", "; fi; done ) 9>&- & permfix=$!", 1)
new = new.replace('>"$app_dir/dashboard.log" 2>&1 &', '>"$app_dir/dashboard.log" 2>&1 9>&- &', 1)
if "_restart_requested" not in new and new.endswith('\nwait "$child"\n') and "_dashboard_worker" in new:
m = re.search(r'\nsetsid (\S+) start >', new)
pkg = os.path.basename(os.path.dirname(path))
try:
lepton = shlex.split(m.group(1))[0] if m else None
except ValueError:
lepton = None
if lepton:
new = new[:-len('wait "$child"\n')] + restart_block(pkg, lepton) + "\n"
if PLAYS_LOG not in new:
start, end = plays_lines(os.path.dirname(path))
if "\nsetsid " in new and "\n trap - EXIT INT TERM\n" in new: # Lepton launcher
new = new.replace("\nsetsid ", f"\n{start}\nsetsid ", 1)
new = new.replace("\n trap - EXIT INT TERM\n", f"\n trap - EXIT INT TERM\n {end}\n", 1)
elif "\nexec " in new: # Proton launcher: exec replaces the shell, so only the start is logged
new = new.replace("\nexec ", f"\n{start}\nexec ", 1)
if "export LEPTON_ENV_FRAMEBRIDGE_CONFIG=" in new:
# Normalize startup helpers after restoring the play log, so the first upgrade has the final order.
pkg = os.path.basename(os.path.dirname(path))
new = set_config_sync_line(new, pkg, wants_config_sync(os.path.dirname(path)))
new = set_room_sync_line(new, pkg, wants_scene_room(os.path.dirname(path)))
new = set_refresh_rate_line(new, pkg)
if "export LEPTON_ENV_FRAMEBRIDGE_CONFIG=" in new and "_snapshot_close" not in new:
# The end log follows podman kill in stop(), while the launcher still owns fd 9.
end = plays_lines(os.path.dirname(path))[1]
new = new.replace(end, end + "\n " + snapshot_line(os.path.basename(os.path.dirname(path))), 1)
if new == text:
continue
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(new)
os.chmod(tmp, os.stat(path).st_mode)
os.replace(tmp, path)
changed.append(os.path.basename(os.path.dirname(path)))
return changed
def wants_hw_video(anchor):
"""Whether this game's launcher gets the shared codec (frame.hw_video_decode): deployment.json's choice (written
at finalize), else its recipe's patches. A game that had agent <= 70's per-game codec (extracted from its APK:
Batman) keeps it; that choice is saved, since ensure_host_fixes then removes the old codec folder."""
path = os.path.join(anchor, "deployment.json")
try:
with open(path) as f:
dep = json.load(f)
except (OSError, ValueError):
return False
if not isinstance(dep, dict):
return False
if "hw_video_decode" in dep:
return bool(dep["hw_video_decode"])
recipe = dep.get("recipe") if isinstance(dep.get("recipe"), dict) else {}
if HW_VIDEO_PATCH in (recipe.get("patches") or []):
return True
base = dep.get("base")
if isinstance(base, str) and os.path.exists(os.path.join(base, "frameport-codec/bin/podman")):
dep["hw_video_decode"] = True
with open(path + ".tmp", "w") as f:
json.dump(dep, f, indent=2)
os.replace(path + ".tmp", path)
return True
return False
def set_codec_line(text, want):
"""A Lepton launcher with the current codec line where it belongs (want) or none; earlier lines are dropped."""
at = -1
for line in (VIDEO_CODEC_LINE, *OLD_CODEC_LINES):
i = text.find(line + "\n")
while i >= 0:
text = text[:i] + text[i + len(line) + 1:]
at = i if at < 0 else min(at, i) # the earliest one's place (text before it is unchanged)
i = text.find(line + "\n")
if want:
if at < 0:
for anchor in ("\nchild=''\n", "\nsetsid "):
if anchor in text:
at = text.index(anchor) + 1
break
if at >= 0:
text = text[:at] + VIDEO_CODEC_LINE + "\n" + text[at:]
return text
def wants_gamepad(anchor):
"""Whether this game's launcher passes Steam Input's virtual gamepad into its container (device.steam_gamepad):
deployment.json's choice (written at finalize), else its recipe's patches."""
try:
with open(os.path.join(anchor, "deployment.json")) as f:
dep = json.load(f)
except (OSError, ValueError):
return False
if not isinstance(dep, dict):
return False
if "steam_gamepad" in dep:
return bool(dep["steam_gamepad"])
recipe = dep.get("recipe") if isinstance(dep.get("recipe"), dict) else {}
return GAMEPAD_PATCH in (recipe.get("patches") or [])
def wrapper_lines(hw_video, gamepad):
"""The launcher's Podman wrapper lines (empty, one or both). The gamepad line comes last, so FramePort's wrapper is
first on PATH and hands on to the codec wrapper."""
return "\n".join(line for line, want in ((VIDEO_CODEC_LINE, hw_video), (GAMEPAD_LINE, gamepad)) if want)
def set_gamepad_line(text, want):
"""A Lepton launcher with the gamepad line (want) or without it. It always follows the codec line (when there is
one), else stands where the codec line would."""
for line in (GAMEPAD_LINE, *OLD_GAMEPAD_LINES):
text = text.replace(line + "\n", "")
if not want:
return text
i = text.find(VIDEO_CODEC_LINE + "\n")
if i >= 0:
at = i + len(VIDEO_CODEC_LINE) + 1
else:
at = next((text.index(a) + 1 for a in ("\nchild=''\n", "\nsetsid ") if a in text), -1)
return text if at < 0 else text[:at] + GAMEPAD_LINE + "\n" + text[at:]
# Android's layout for an Xbox 360 pad (AOSP's Vendor_045e_Product_028e.kl, with Select as BUTTON_SELECT instead of
# BACK, which would close many apps): Steam's virtual pad reports Valve's ids, so Android would fall back to Generic.kl
# (triggers on Z/RZ, right stick on RX/RY: games then mix up the right stick and the triggers). The D-pad keys
# (BTN_DPAD_*, as AOSP's Generic.kl) cover a pad that sends its D-pad as buttons rather than the hat axes: the Frame's
# left controller has a D-pad; which inputs reach a 2D app still depends on its Steam Input layout (GitHub #162).
GAMEPAD_KL = """# Steam Input virtual gamepad (Xbox 360 layout). Written by FramePort (device.steam_gamepad).
key 304 BUTTON_A
key 305 BUTTON_B
key 307 BUTTON_X
key 308 BUTTON_Y
key 310 BUTTON_L1
key 311 BUTTON_R1
key 314 BUTTON_SELECT
key 315 BUTTON_START
key 316 BUTTON_MODE
key 317 BUTTON_THUMBL
key 318 BUTTON_THUMBR
key 544 DPAD_UP
key 545 DPAD_DOWN
key 546 DPAD_LEFT
key 547 DPAD_RIGHT
axis 0x00 X flat 4096
axis 0x01 Y flat 4096
axis 0x03 Z flat 4096
axis 0x04 RZ flat 4096
axis 0x02 LTRIGGER
axis 0x05 RTRIGGER
axis 0x10 HAT_X
axis 0x11 HAT_Y
"""
BTN_SOUTH = 0x130 # BTN_A / BTN_GAMEPAD: Linux's gamepad button range starts here
# A game's own button layout (device.steam_gamepad's options, GitHub #162: Steam Input's per-game remapping doesn't
# reach a 2D app): its launcher exports FRAMEPORT_GAMEPAD_LAYOUT (comma list: swap_sticks = the right stick is
# Android's main stick, which Android's own D-pad navigation follows; swap_ab) and FRAMEPORT_GAMEPAD_KEYS (Android .kl
# lines separated by ';', each replacing the line for the same key code or axis), read by podman_run_args.
KL_LINE = re.compile(r"(key [0-9]{1,4} [A-Z][A-Z0-9_]{0,40}( (FUNCTION|WAKE))?"
r"|axis 0x[0-9a-f]{1,2} (invert )?[A-Z][A-Z0-9_]{0,40}( flat [0-9]{1,5})?)")
def gamepad_kl(layout="", keys=""):
"""GAMEPAD_KL with a game's layout options and own lines (invalid ones left out); the text and what was left out."""
lines = {}
for line in GAMEPAD_KL.splitlines()[1:]:
kind, code = line.split()[:2]
lines[(kind, code if kind == "axis" else str(int(code)))] = line
opts = {o.strip() for o in (layout or "").split(",") if o.strip()}
if "swap_sticks" in opts:
lines[("axis", "0x00")], lines[("axis", "0x03")] = "axis 0x00 Z flat 4096", "axis 0x03 X flat 4096"
lines[("axis", "0x01")], lines[("axis", "0x04")] = "axis 0x01 RZ flat 4096", "axis 0x04 Y flat 4096"
if "swap_ab" in opts:
lines[("key", "304")], lines[("key", "305")] = "key 304 BUTTON_B", "key 305 BUTTON_A"
skipped = []
for line in (keys or "").split(";"):
line = " ".join(line.split())
if not line:
continue
if not KL_LINE.fullmatch(line):
skipped.append(line)
continue
kind, code = line.split()[:2]
lines[(kind, code if kind == "axis" else str(int(code)))] = line
head = GAMEPAD_KL.splitlines()[0] + (f" Options: {layout or '-'}; own lines: {keys}" if opts or keys else "")
return "\n".join([head, *lines.values()]) + "\n", skipped
def has_key_bit(caps, code):
"""Whether a sysfs capabilities/key bitmap (hex words, most significant first, one per long) has `code`."""
words = caps.split()
bits = 8 * struct.calcsize("l") # the kernel's long (64 on the Frame); words aren't zero-padded
index = len(words) - 1 - code // bits
try:
return index >= 0 and bool(int(words[index], 16) >> (code % bits) & 1)
except ValueError:
return False
def steam_gamepads(sys_root="/sys", dev_root="/dev"):
"""Steam Input's virtual gamepads ([{event, product, name}]): input devices Steam creates through uinput
(/sys/devices/virtual/input) with Valve's vendor id (28de) and gamepad buttons. Steam names them "Microsoft X-Box
360 pad N" (seen on the Frame; older clients "Steam Virtual Gamepad"); Valve's other virtual devices (e.g.
steamos-manager's keys, 28de:0000) have no gamepad buttons."""
pads = []
for sys_event in sorted(glob.glob(os.path.join(sys_root, "class/input/event*")),
key=lambda p: int(re.sub(r"\D", "", os.path.basename(p)) or 0)):
event = os.path.basename(sys_event)
device = os.path.join(sys_event, "device")
def read(rel, device=device):
try:
with open(os.path.join(device, rel)) as f:
return f.read().strip()
except OSError:
return ""
if read("id/vendor").lower() != "28de" or not has_key_bit(read("capabilities/key"), BTN_SOUTH):
continue
if "/devices/virtual/" not in os.path.realpath(device) + "/":
continue # Valve hardware itself (a Steam Deck's controls): Steam Input reads it and makes a virtual pad
node = os.path.join(dev_root, "input", event)
if not os.path.exists(node) or not os.access(node, os.R_OK | os.W_OK):
continue
pads.append({"event": event, "product": read("id/product").lower() or "0000", "name": read("name")})
return pads
def podman_run_args(args, env, sys_root="/sys", dev_root="/dev"):
"""FramePort's Podman wrapper (PODMAN_WRAPPER) for `podman run`: the game's own container
(lepton-steamlaunch-<SteamAppId>, launcher env) gets the host's /dev/input at PAD_DIR and the gamepad key layout
when its launcher asked for them (FRAMEPORT_GAMEPAD=1, the gamepad line); _gamepad_links then links Steam's pads
into the container's /dev/input. Anything else: the arguments unchanged. Lepton mounts a tmpfs over /dev, so a
`--device` node would vanish under it, and its /dev/input holds its own Wayland input pipes: a bind mount next to
it, like Lepton's own GPU nodes."""
if args[:1] != ["run"] or env.get("FRAMEPORT_GAMEPAD") != "1" or \
env.get("FRAMEPORT_NO_GAMEPAD", "0") not in ("", "0"):
return args
appid = env.get("SteamAppId", "")
name = None
for i, arg in enumerate(args):
if arg == "--name" and i + 1 < len(args):
name = args[i + 1]
elif arg.startswith("--name="):
name = arg.partition("=")[2]
if not re.fullmatch(r"[0-9]+", appid) or name != f"lepton-steamlaunch-{appid}":
return args
taken = set()
for i, arg in enumerate(args): # destinations Lepton mounts itself (a future Lepton passing pads through)
spec = args[i + 1] if arg == "--mount" and i + 1 < len(args) else arg.partition("=")[2] \
if arg.startswith("--mount=") else ""
for item in spec.split(","):
key, _, value = item.partition("=")
if key in ("destination", "target", "dst"):
taken.add(value)
pads = steam_gamepads(sys_root, dev_root)
extra = []
if PAD_DIR not in taken:
extra += ["--mount", f"type=bind,source={os.path.join(dev_root, 'input')},destination={PAD_DIR},rw"]
text, skipped = gamepad_kl(env.get("FRAMEPORT_GAMEPAD_LAYOUT", ""), env.get("FRAMEPORT_GAMEPAD_KEYS", ""))
layout = gamepad_layout_file(text) if extra else ""
if skipped:
print(f"FramePort gamepad: key layout lines left out (not .kl syntax): {'; '.join(skipped)}", file=sys.stderr)
if text != GAMEPAD_KL:
print(f"FramePort gamepad: own button layout ({env.get('FRAMEPORT_GAMEPAD_LAYOUT') or '-'}"
f"{'; ' + env['FRAMEPORT_GAMEPAD_KEYS'] if env.get('FRAMEPORT_GAMEPAD_KEYS') else ''})", file=sys.stderr)
for product in sorted({"11ff", *(p["product"] for p in pads)}): # 11ff: Steam's pad, also when it comes later
target = f"/system/usr/keylayout/Vendor_28de_Product_{product}.kl"
if layout and target not in taken and re.fullmatch(r"[0-9a-f]{4}", product):
extra += ["--mount", f"type=bind,source={layout},destination={target},ro"]
names = ", ".join(f"{p['event']} ({p['name']}, 28de:{p['product']})" for p in pads) or "none"
print(f"FramePort gamepad: Steam Input virtual gamepads for this container: {names}", file=sys.stderr)
return [args[0], *extra, *args[1:]] if extra else args
def pid_alive(pid):
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except OSError:
return True
def gamepad_link_script(want, linked):
"""Shell for the container: /dev/input links to Steam's pads (want: event names) instead of linked ones."""
gone = sorted(set(linked) - set(want))
parts = [f"rm -f /dev/input/{e}" for e in gone if re.fullmatch(r"event[0-9]+", e)]
parts += [f"[ -e /dev/input/{e} ] || ln -s {PAD_DIR}/{e} /dev/input/{e}" for e in sorted(want)
if re.fullmatch(r"event[0-9]+", e)]
return "; ".join(parts) or "true"
def gamepad_links(appid, parent, poll=1.0, refresh=15.0, exec_=None, pads=steam_gamepads, alive=pid_alive):
"""While the launcher (parent) runs: keep the game's container's /dev/input links in step with Steam's virtual
pads (made when the Frame's controllers wake up, gone when they sleep). Re-applied every `refresh` s, so a
container Lepton started again gets them too."""
container = f"lepton-steamlaunch-{int(appid)}"
exec_ = exec_ or (lambda script: run(["podman", "exec", container, "sh", "-c", script], timeout=20))
linked, last = set(), 0.0
while alive(int(parent)):
want = {p["event"] for p in pads()}
now = time.monotonic()
if want != linked or (want and now - last >= refresh):
r = exec_(gamepad_link_script(want, linked))
if r.returncode == 0:
if want != linked:
print(f"{time.strftime('%H:%M:%S')} pads: {', '.join(sorted(want)) or 'none'}", flush=True)
linked, last = want, now
time.sleep(poll)
def gamepad_layout_file(text=GAMEPAD_KL):
"""The key layout file for `text`: steam-gamepad.kl, or one per distinct own layout (steam-gamepad-<hash>.kl)."""
name = "steam-gamepad.kl" if text == GAMEPAD_KL else \
f"steam-gamepad-{hashlib.sha256(text.encode()).hexdigest()[:12]}.kl"
path = os.path.join(os.path.dirname(PODMAN_BIN), name)
try:
with open(path) as f:
if f.read() == text:
return path
except OSError:
pass
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.{os.getpid()}.tmp"
with open(tmp, "w") as f:
f.write(text)
os.replace(tmp, path)
return path
# FramePort's Podman wrapper (agent/bin/podman). Small on purpose: every Podman call Lepton makes goes through it while
# a launcher has it on PATH, so only `run` loads the agent (podman_run_args); everything else, and any failure, goes
# straight to the next Podman on PATH: the one after this folder (the codec wrapper of a game with hardware video
# decoding, else Podman itself), with this folder taken off PATH so the next wrapper can't come back here.
PODMAN_WRAPPER = r'''#!/usr/bin/python3
"""FramePort's Podman wrapper (written by frameport_agent.py: ensure_podman_wrapper; see podman_run_args)."""
import os
import shutil
import sys
HERE = os.path.dirname(os.path.realpath(__file__))
def chain():
"""The next Podman (after this folder on PATH, else the first other one) and PATH without this folder."""
own = os.path.realpath(__file__)
entries = os.environ.get("PATH", os.defpath).split(os.pathsep)
mine = [i for i, e in enumerate(entries) if os.path.realpath(e or os.curdir) == HERE]
order = entries[mine[0] + 1:] if mine else entries
for entry in order + ["/usr/local/bin", "/usr/bin", "/bin"]:
folder = os.path.realpath(entry or os.curdir)
found = shutil.which("podman", path=folder) if folder != HERE else None
if found and os.path.realpath(found) != own:
return found, os.pathsep.join(e for i, e in enumerate(entries) if i not in mine)
sys.exit("FramePort: no Podman found after its wrapper")
podman, path = chain()
args = sys.argv[1:]
env = dict(os.environ, PATH=path)
if args[:1] == ["run"]:
try:
sys.path.insert(0, os.path.dirname(HERE))
import frameport_agent
args = frameport_agent.podman_run_args(args, env)
except Exception as exc: # a gamepad problem must never stop the game's container from starting
print(f"FramePort: Podman wrapper left the arguments unchanged: {exc}", file=sys.stderr)
args = sys.argv[1:]
os.execve(podman, [podman, *args], env)
'''
def ensure_podman_wrapper():
"""Write FramePort's Podman wrapper (PODMAN_BIN/podman) when it is missing or differs; returns whether it was
written. Launchers only use it while their gamepad line is there."""
path = os.path.join(PODMAN_BIN, "podman")
try:
with open(path) as f:
if f.read() == PODMAN_WRAPPER and os.access(path, os.X_OK):
return False
except OSError:
pass
os.makedirs(PODMAN_BIN, exist_ok=True)
tmp = f"{path}.{os.getpid()}.tmp"
with open(tmp, "w") as f:
f.write(PODMAN_WRAPPER)
os.chmod(tmp, 0o755)
os.replace(tmp, path)
return True
def remove_old_codec_dirs():
"""Agent <= 70 extracted a per-game codec into <base>/frameport-codec; the shared codec replaced it. Removed
once the game's launcher no longer uses it (upgrade_launchers converted it; the choice is in deployment.json or
the recipe's patches)."""
removed = []
for dep_path in glob.glob(os.path.join(ANCHORS, "*/deployment.json")):
try:
with open(dep_path) as f:
dep = json.load(f)
base = dep.get("base")
except (OSError, ValueError, AttributeError):
continue
old = os.path.join(base, "frameport-codec") if isinstance(base, str) else ""
if not old or not os.path.isdir(old) or os.path.islink(old):
continue
try:
with open(os.path.join(os.path.dirname(dep_path), "launch.sh")) as f:
launcher = f.read()
except OSError:
launcher = ""
if "frameport-codec" in launcher:
continue # its launcher hasn't been converted yet
shutil.rmtree(old, ignore_errors=True)
removed.append(dep.get("package") or os.path.basename(os.path.dirname(dep_path)))
return removed
def cmd_video_codec_switch(args):
"""FramePort's setting "Hardware video decoding" for every game on this Frame: off writes VIDEO_CODEC_DIR/disabled,
which launchers and the wrapper check at every start (no launcher rewrite, running games keep what they have)."""
flag = os.path.join(VIDEO_CODEC_DIR, "disabled")
if args.get("enabled", True):
if os.path.exists(flag):
os.remove(flag)
else:
os.makedirs(VIDEO_CODEC_DIR, exist_ok=True)
with open(flag, "w") as f:
f.write("switched off in FramePort\n")
return cmd_video_codec_status({})
def upgrade_linux_launcher(text):
"""A Linux app's launcher from before agent v63, made fit for Desktop Mode's menu entry (GitHub #84): no Steam
parent watchdog and no display taken from Steam when FRAMEPORT_DESKTOP is set."""
old_if = 'if [[ -z "${DISPLAY:-}" ]]; then'
if old_if in text:
text = text.replace(old_if, 'if [[ -z "${FRAMEPORT_DESKTOP:-}" && -z "${DISPLAY:-}" ]]; then', 1)
if "\nparent=$PPID\n" in text:
text = text.replace("\nparent=$PPID\n",
'\nparent=$PPID\n[[ -n "${FRAMEPORT_DESKTOP:-}${FRAMEPORT_OVERLAY:-}" ]] && parent=1\n', 1)
return text
def set_openxr_manifest_line(text, pkg, enabled):
line = (f"python3 {shlex.quote(os.path.abspath(__file__))} _openxr_manifest {shlex.quote(pkg)}")
text = "\n".join(ln for ln in text.split("\n") if " _openxr_manifest " not in ln)
if enabled:
text = text.replace("\nfix_perms\n", "\n" + line + "\nfix_perms\n", 1)
return text
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False,
config_sync=False, static_openxr=False, scene_room=False):
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
text = LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, app_dir=app_dir_lines(base, pkg), appid=appid,
lepton_q=shlex.quote(lepton), extra_env=extra, watchdog=WATCHDOG,
dashboard=dashboard_line(), logcat=logcat_line(), single=SINGLE_LINE,
cloud_dirs=cloud_dirs_line(pkg),
video_codec=wrapper_lines(hw_video, gamepad),
plays_start=plays_lines(anchor)[0],
plays_end=plays_lines(anchor)[1], save_snapshot=snapshot_line(pkg),
restart=restart_block(pkg, lepton))
text = set_openxr_manifest_line(text, pkg, static_openxr)
text = set_config_sync_line(text, pkg, config_sync)
text = set_room_sync_line(text, pkg, scene_room)
text = set_refresh_rate_line(text, pkg)
path = os.path.join(anchor, "launch.sh")
with open(path + ".tmp", "w") as f:
f.write(text)
os.chmod(path + ".tmp", 0o755)
os.replace(path + ".tmp", path)
def write_openxr_runtime_manifest(base, enabled):
"""Lepton maps this per-game external root to /sdcard. Never follow a game's symlink."""
if not enabled:
return
root = os.path.join(base, "lepton-data", "external")
os.makedirs(root, exist_ok=True)
payload = {"file_format_version": "1.0.0",
"runtime": {"library_path": "libfp_xrruntime.so", "name": "FramePort"}}
for rel in ("fp_openxr_runtime.json", "fp/1/active_runtime.json"):
parent = os.path.dirname(os.path.join(root, rel))
if not _inside(os.path.realpath(parent), os.path.realpath(root)):
raise AgentError("refusing linked OpenXR manifest directory outside game storage")
os.makedirs(parent, exist_ok=True)
target = os.path.join(root, rel)
# Atomic replace repairs stale manifests without following the final symlink.
fd, tmp = tempfile.mkstemp(prefix=".fp-openxr-", dir=parent)
try:
with os.fdopen(fd, "w") as f:
json.dump(payload, f, separators=(",", ":"))
os.chmod(tmp, 0o644)
os.replace(tmp, target)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def data_files_dir(base, pkg):
return os.path.join(base, "lepton-data/external/Android/data", pkg, "files")
# ------------------------------------------------- saved game settings in step with the eye buffers (agent v78)
# device.config_sync (per game, from its recipe; deployment.json "config_sync": {file relative to the game's
# Android/data/<pkg>/files: {setting: expression}}). Some games save their render resolution and load it after they
# created the headset's eye buffers: BlazeRush creates 2938x2938 buffers at scale 1.7, then loads its saved
# user_config.xml (r_3dwidth/r_3dheight 1728, r_width/r_height 3072x1728) and draws a displaced, stretched picture with
# black borders (Klownicle's guide, GitHub #57). The values are written while the game is stopped: at finalize, after
# a Game settings change, and by the launcher before Lepton starts (the game rewrites its file when it exits, so a scale
# changed while it ran is caught at the next start). The eye buffer = the runtime's recommended size (learned from the
# game's last launch.log: FrameBridge logs it; older builds: the bridge's stereo= size divided by the logged scale)
# times settings.conf's scale, rounded like FrameBridge does. Only existing numeric entries change; the previous file
# is kept as <file>.frameport-backup and the new one replaces it atomically.
CONFIG_EXPR_RE = re.compile(r"\s*([wh])\s*(?:\*\s*(\d+(?:\.\d+)?))?\s*(?:/\s*(\d+(?:\.\d+)?))?\s*")
CONFIG_SYNC_LINE = 'python3 {agent_q} _config_sync {pkg_q} >"$app_dir/config-sync.log" 2>&1 9>&- || true'
CONFIG_BACKUP = ".frameport-backup"
RUNTIME_EYE_RE = re.compile(r"FrameBridge: runtime recommended eye size (\d+)x(\d+)")
BRIDGE_STEREO_RE = re.compile(r"OVRPortVrApi: Initialized .*?stereo=(\d+)x(\d+)")
ADAPTER_VIEW_RE = re.compile(r"FrameBridge: view 0 recommended (\d+)x(\d+)")
ADAPTER_SCALE_RE = re.compile(r"FrameBridge: scale=([0-9.]+) ")
def config_value(expr, w, h):
"""An expression of the eye-buffer size: w, h, optionally *a and/or /b (for example h*16/9), rounded."""
m = CONFIG_EXPR_RE.fullmatch(str(expr))
if not m:
raise AgentError(f"bad config_sync expression {expr!r}")
value = (w if m.group(1) == "w" else h) * float(m.group(2) or 1) / float(m.group(3) or 1)
if not 0 < value < 1e6:
raise AgentError(f"config_sync expression {expr!r} out of range")
return int(value + 0.5)
def settings_scale(base):
"""FrameBridge's resolution scale for this game (settings.conf; out-of-range values are ignored like FrameBridge
does)."""
scale = 1.0
try:
for line in open(os.path.join(base, "settings.conf")):
k, _, v = line.strip().partition("=")
if k == "scale":
try:
value = float(v)
except ValueError:
continue
if 0.5 <= value <= 2.0:
scale = value
except OSError:
pass
return scale
def runtime_eye_size(base):
"""(width, height) the Frame's runtime recommends per eye before FrameBridge's scale, from the game's last
launch.log / session.log, or None."""
for name in ("launch.log", "session.log"):
try:
with open(os.path.join(base, name), errors="replace") as f:
text = f.read(16 << 20)
except OSError:
continue
found = RUNTIME_EYE_RE.findall(text)
if found:
return int(found[-1][0]), int(found[-1][1])
scales = ADAPTER_SCALE_RE.findall(text)
sized = BRIDGE_STEREO_RE.findall(text) or ADAPTER_VIEW_RE.findall(text)
if sized and scales:
try:
scale = float(scales[-1])
except ValueError:
continue
if 0.5 <= scale <= 2.0:
return int(int(sized[-1][0]) / scale + 0.5), int(int(sized[-1][1]) / scale + 0.5)
return None
def sync_config_text(text, values):
"""(new text, {name: (old, new)}, [names not found]) with the numeric value of every named setting replaced; the
rest of the text stays byte for byte. Matches <x name="NAME" ... value="V">, <x value="V" ... name="NAME">,
<NAME value="V"/> and <NAME>V</NAME>."""
changed, missing = {}, []
num = r"-?\d+(?:\.\d+)?"
for name, new in values.items():
n = re.escape(name)
patterns = (rf"""(<[^<>]*\bname\s*=\s*["']{n}["'][^<>]*?\bvalue\s*=\s*["'])({num})(["'])""",
rf"""(<[^<>]*?\bvalue\s*=\s*["'])({num})(["'][^<>]*\bname\s*=\s*["']{n}["'])""",
rf"""(<{n}\b[^<>]*?\bvalue\s*=\s*["'])({num})(["'])""",
rf"""(<{n}>\s*)({num})(\s*</{n}>)""")
for pattern in patterns:
m = re.search(pattern, text)
if m:
old = m.group(2)
if old != str(new):
text = text[:m.start(2)] + str(new) + text[m.end(2):]
changed[name] = (old, str(new))
break
else:
missing.append(name)
return text, changed, missing
def sync_game_config(pkg, dep, check_running=True):
"""Write the eye-buffer size into the game's saved settings (device.config_sync). Returns a report (None when the
game has no rules)."""
rules = (dep or {}).get("config_sync") or {}
if not isinstance(rules, dict) or not rules:
return None
base = dep["base"]
if check_running and dep.get("appid") and container_running(dep["appid"]):
return {"skipped": "the game is running (its next start corrects it)"}
runtime = runtime_eye_size(base)
if runtime and runtime != tuple(dep.get("eye_base") or ()):
dep["eye_base"] = list(runtime)
try:
write_deployment(pkg, dep)
except OSError:
pass
runtime = runtime or tuple(dep.get("eye_base") or ())
if len(runtime) != 2:
return {"skipped": "the Frame's eye-buffer size isn't known yet (it is after the game's first start)"}
scale = settings_scale(base)
w, h = int(runtime[0] * scale + 0.5), int(runtime[1] * scale + 0.5)
files_dir = os.path.realpath(data_files_dir(base, pkg))
out = {"eye": [w, h], "runtime": list(runtime), "scale": scale, "changed": {}, "missing": [], "files": []}
for rel, exprs in rules.items():
target = os.path.realpath(os.path.join(files_dir, str(rel)))
if not target.startswith(files_dir + os.sep) or not isinstance(exprs, dict):
out["missing"].append(str(rel))
continue
if not os.path.isfile(target):
out["files"].append({"file": rel, "exists": False}) # the game writes it itself, from the right size
continue
values = {str(k): config_value(v, w, h) for k, v in exprs.items()}
with open(target, encoding="utf-8", errors="surrogateescape", newline="") as f:
text = f.read()
new, changed, missing = sync_config_text(text, values)
out["missing"] += [f"{rel}:{k}" for k in missing]
out["files"].append({"file": rel, "exists": True, "changed": sorted(changed)})
if not changed:
continue
shutil.copy2(target, target + CONFIG_BACKUP)
tmp = target + ".frameport-tmp"
with open(tmp, "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
f.write(new)
shutil.copymode(target, tmp)
os.replace(tmp, target)
out["changed"].update(changed)
return out
def wants_config_sync(anchor):
try:
with open(os.path.join(anchor, "deployment.json")) as f:
dep = json.load(f)
except (OSError, ValueError):
return False
return isinstance(dep, dict) and isinstance(dep.get("config_sync"), dict) and bool(dep["config_sync"])
def set_config_sync_line(text, pkg, want):
"""A Lepton launcher that brings the game's saved settings in step before Lepton starts (want), or without it."""
text = "".join(line for line in text.splitlines(True) if " _config_sync " not in line)
if not want or "\nsetsid " not in text:
return text
line = CONFIG_SYNC_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__)), pkg_q=shlex.quote(pkg))
return text.replace("\nsetsid ", f"\n{line}\nsetsid ", 1)
def config_sync_worker(pkg):
"""launch.sh, before Lepton starts (the game isn't running: the launcher holds its launch lock)."""
dep = deployment(check_pkg(pkg))
print(json.dumps({"time": time.time(), "result": sync_game_config(pkg, dep, check_running=False) if dep else None}))
def cmd_config_sync(args):
"""Bring a game's saved settings in step with the eye buffers now (device.config_sync), unless it runs."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
return sync_game_config(pkg, dep)
# SteamVR's OpenXR runtime only enumerates its current rate and rejects requests for any other rate. Select the
# mode through the host's per-app setting before Lepton starts; the APK's refresh_rate setting alone cannot do it.
class SteamVRSettings(SteamVRApps):
interface = b"FnTable:IVRSettings_003"
table_size = 11
def refresh_rate(self, appid, rate):
ct = self.ct
section, key = f"steam.app.{int(appid)}".encode(), b"preferredRefreshRate"
err = ct.c_int(0)
if rate > 0:
ct.CFUNCTYPE(None, ct.c_char_p, ct.c_char_p, ct.c_float, ct.POINTER(ct.c_int))(
self.table[3])(section, key, rate, ct.byref(err)) # SetFloat
else:
ct.CFUNCTYPE(None, ct.c_char_p, ct.c_char_p, ct.POINTER(ct.c_int))(
self.table[10])(section, key, ct.byref(err)) # RemoveKeyInSection: restore the game's choice
if err.value:
name = ct.CFUNCTYPE(ct.c_char_p, ct.c_int)(self.table[0])(err.value)
raise AgentError((name or b"SteamVR settings error").decode())
REFRESH_RATE_MARKER = ".frameport-refresh-rate"
def sync_refresh_rate(pkg, dep, check_running=True):
"""Apply a saved Quest refresh rate to SteamVR. Retry from launch.sh when SteamVR wasn't available on save."""
if not dep or dep.get("kind", "quest") != "quest" or not dep.get("appid"):
return None
marker = os.path.join(dep["base"], REFRESH_RATE_MARKER)
rate = None
try:
for line in open(os.path.join(dep["base"], "settings.conf")):
key, _, value = line.strip().partition("=")
if key == "refresh_rate":
rate = float(value)
except (OSError, ValueError):
return {"skipped": "refresh rate settings missing or invalid"}
if rate is None and not os.path.exists(marker):
return None # don't touch manually configured SteamVR preferences for games with no FramePort override
rate = rate or 0.0
if not (rate == 0 or 60 <= rate <= 144):
return {"skipped": "invalid refresh rate"}
if check_running and container_running(dep["appid"]):
return {"skipped": "the game is running (applied at its next start)"}
vr = None
try:
vr = SteamVRSettings.open()
if vr is None:
return {"skipped": "SteamVR unavailable (applied at the next start)"}
vr.refresh_rate(dep["appid"], rate)
if rate > 0:
with open(marker, "w") as f:
f.write(str(rate))
elif os.path.exists(marker):
os.remove(marker)
return {"refresh_rate": rate, "app_key": f"steam.app.{dep['appid']}"}
except (OSError, AgentError) as exc:
return {"skipped": str(exc)}
finally:
if vr is not None:
vr.close()
def set_refresh_rate_line(text, pkg):
text = "".join(line for line in text.splitlines(True) if " _refresh_rate " not in line)
if "\nsetsid " not in text:
return text
agent = shlex.quote(os.path.abspath(__file__))
line = (f'timeout 10s python3 {agent} _refresh_rate {shlex.quote(pkg)} '
'>"$app_dir/refresh-rate.log" 2>&1 9>&- || true')
# Run before the wrappers, which are kept immediately ahead of child=''. Also stay ahead of the play log
# and config-sync steps so repairing those doesn't move this line on the next upgrade.
for anchor in ("\n" + VIDEO_CODEC_LINE + "\n", "\n" + GAMEPAD_LINE + "\n", "\nchild=''\n", "\nsetsid "):
if anchor in text:
return text.replace(anchor, f"\n{line}{anchor}", 1)
return text
def refresh_rate_worker(pkg):
pkg = check_pkg(pkg)
print(json.dumps(sync_refresh_rate(pkg, deployment(pkg), check_running=False)))
def cmd_video_codec_status(args):
"""The shared codec installed on this Frame ({digest, revision}, {} if none or damaged) and whether FramePort's
setting switched it off for every game ("disabled")."""
status = video_codec_installed()
status["disabled"] = os.path.exists(os.path.join(VIDEO_CODEC_DIR, "disabled"))
return status
def video_codec_installed():
path = os.path.join(VIDEO_CODEC_DIR, "current", "manifest.json")
try:
with open(path, "rb") as f:
raw = f.read()
manifest = json.loads(raw)
with open(os.path.join(os.path.dirname(path), "deployment.json")) as f:
config = json.load(f)
if not isinstance(config, dict) or config.get("scope") != "shared" or \
config.get("runtime_sha256") != manifest["runtime_sha256"]:
return {}
for name in VIDEO_CODEC_FILES:
local = "bin/podman" if name == "podman.py" else name
if sha256_file(os.path.join(os.path.dirname(path), local)) != manifest["files"][name]:
return {}
return {"digest": hashlib.sha256(raw).hexdigest(), "revision": manifest.get("revision", 1)}
except (OSError, ValueError, KeyError, TypeError):
return {}
def prune_video_codec_versions(versions, keep):
"""Each revision is ~15 MB. Keep the active one and the one it replaced (a launch that resolved the old
'current' just before the switch still mounts its files); running containers hold their mounts anyway."""
for name in os.listdir(versions):
if name in keep or not re.fullmatch(r"[0-9a-f]{64}(\.previous-[0-9]+)?|\.install-.*", name):
continue
path = os.path.join(versions, name)
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path, ignore_errors=True)
def cmd_install_video_codec(args):
"""Verify a shared payload, then publish its complete version in one step."""
encoded = args["bundle"]
if not isinstance(encoded, str) or len(encoded) > 32 * 1024 * 1024:
raise AgentError("oversized video codec bundle")
import io
raw = base64.b64decode(encoded, validate=True)
data = {}
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
if set(archive.namelist()) != {"manifest.json", *VIDEO_CODEC_FILES} or len(archive.infolist()) != 5:
raise AgentError("unexpected video codec bundle files")
for name in ("manifest.json", *VIDEO_CODEC_FILES):
if archive.getinfo(name).file_size > 16 * 1024 * 1024:
raise AgentError(f"oversized video codec asset: {name}")
data[name] = archive.read(name)
manifest = json.loads(data["manifest.json"])
if not isinstance(manifest, dict) or not isinstance(manifest.get("files"), dict) or \
not isinstance(manifest.get("revision", 1), int) or manifest.get("revision", 1) < 1:
raise AgentError("invalid video codec manifest")
digest = hashlib.sha256(data["manifest.json"]).hexdigest()
if digest != args["digest"]:
raise AgentError("video codec manifest checksum mismatch")
for name in VIDEO_CODEC_FILES:
if hashlib.sha256(data[name]).hexdigest() != manifest["files"][name]:
raise AgentError(f"video codec asset checksum mismatch: {name}")
# A second PC with older FramePort must not downgrade the shared codec.
os.makedirs(VIDEO_CODEC_DIR, exist_ok=True)
with open(os.path.join(VIDEO_CODEC_DIR, "install.lock"), "a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
current = video_codec_installed()
if current.get("digest") not in (None, digest) and current.get("revision", 0) >= manifest.get("revision", 1):
# another PC's FramePort installed this revision (or a newer one) built differently: keep it, two
# PCs mustn't replace each other's codec at every connection
return dict(current, kept=True)
versions = os.path.join(VIDEO_CODEC_DIR, "versions")
os.makedirs(versions, exist_ok=True)
version = os.path.join(versions, digest)
previous = os.path.basename(os.path.realpath(os.path.join(VIDEO_CODEC_DIR, "current")))
if current.get("digest") != digest:
stage = tempfile.mkdtemp(prefix=".install-", dir=versions)
try:
os.mkdir(os.path.join(stage, "bin"))
# Config first, executable last, then expose the entire version.
config = {"scope": "shared", "runtime_sha256": manifest["runtime_sha256"]}
with open(os.path.join(stage, "deployment.json"), "w") as f:
json.dump(config, f)
for name in ("manifest.json", *[n for n in VIDEO_CODEC_FILES if n != "podman.py"], "podman.py"):
target = os.path.join(stage, "bin/podman" if name == "podman.py" else name)
with open(target, "wb") as f:
f.write(data[name])
os.chmod(target, 0o755 if name == "podman.py" else 0o644)
if os.path.lexists(version):
# Preserve an interrupted/corrupt prior version for diagnosis.
os.rename(version, version + f".previous-{time.time_ns()}")
os.rename(stage, version)
link = os.path.join(VIDEO_CODEC_DIR, f".current-{os.getpid()}")
if os.path.lexists(link):
os.unlink(link)
os.symlink(os.path.join("versions", digest), link)
os.replace(link, os.path.join(VIDEO_CODEC_DIR, "current"))
finally:
if os.path.isdir(stage):
shutil.rmtree(stage)
prune_video_codec_versions(versions, {digest, previous})
upgraded = upgrade_launchers()
return {"digest": digest, "revision": manifest.get("revision", 1), "launchers": upgraded}
def set_flatscreen(app_dir, on):
"""Lepton shows an app as a flat (2D) window only when its app folder holds this marker
(liblepton/app_metadata.sh); otherwise the app runs headless and only OpenXR output reaches the headset."""
marker = os.path.join(app_dir, "lepton-show-flatscreen")
if on:
open(marker, "a").close()
elif os.path.exists(marker):
os.remove(marker)
# ---------------------------------------------------------------- mixed-reality room storage (format v1)
# Standalone stdlib code: the PC validates the complete geometry; the agent also checks records and mesh sizes
# before making an uploaded room current. Never import the desktop package on the Frame.
ROOMS_DIR = os.path.join(HOME, ".local/share/frameport/rooms")
ROOM_SCANNER = os.path.join(HOME, ".local/share/frameport/bin/fp_roomscan")
ROOM_SYNC_LINE = ('python3 {agent_q} _room_sync {pkg_q} >"$app_dir/room-sync.log" 2>&1 9>&- || true '
'# frameport-room-sync')
# Scan clean-up (fp_roomscan's refinement + label rules): the owner's tuned defaults are compiled into the scanner;
# refine.json holds only the settings that differ from them (PC: install/rooms.REFINE_DEFAULTS, same keys).
ROOM_REFINE_FILE = os.path.join(ROOMS_DIR, "refine.json")
ROOM_REFINE_DEFAULTS = {
"refine": 1, "walls": "panels", "band": 0.35, "facing_deg": 60, "band_in": 0.12, "floor_in": 0.08,
"ceil_in": 0.25, "top_strip": 0.5, "near": 0.03, "clip": 0.05, "clip_open": 0.10, "island": 0.20, "smooth": 8,
"tops": 0.10, "protect": 0, "tubes": 3.0, "tube_thick": 0.15, "walked": 0.40, "label_rules": 1,
}
ROOM_REFINE_LIMITS = { # key -> (lowest, highest)
"refine": (0, 1), "band": (0, 2), "facing_deg": (1, 89), "band_in": (0, 2), "floor_in": (0, 2), "ceil_in": (0, 2),
"top_strip": (0, 3), "near": (0, 2), "clip": (0, 10), "clip_open": (0, 10), "island": (0, 10), "smooth": (0, 50),
"tops": (0, 1), "protect": (0, 1), "tubes": (0, 50), "tube_thick": (0, 2), "walked": (0, 3), "label_rules": (0, 1),
}
ROOM_REFINE_INTS = ("refine", "smooth", "protect", "label_rules")
# besides room.txt + its mesh + scan.log: the scanner's unrefined output and its clean-up report, and the player's
# own furniture / door edits (kept over a new clean-up)
ROOM_EXTRA_FILES = {"room-raw.txt": 8 * 1024 * 1024, "room-mesh-raw.bin": 64 * 1024 * 1024,
"scan.json": 8 * 1024 * 1024, "edits.txt": 1024 * 1024}
def room_universe(value):
text = str(value)
if not re.fullmatch(r"[0-9]{1,20}", text) or int(text) >= 2**64:
raise AgentError("invalid chaperone universe")
return str(int(text))
def room_universe_from_log(text):
"""Last universe switch in vrserver.txt; 424242 is Valve's dummy universe (not a room)."""
found = None
for line in text.splitlines():
match = re.search(r"chaperone universe\s+(\d+)|Switch to\s+(?:.*?\s+)?universe\s*[:=]?\s*(\d+)", line, re.I)
if match:
found = int(match.group(1) or match.group(2))
return None if found is None else (0 if found == 424242 else int(room_universe(found)))
def room_chaperone():
try:
with open(os.path.join(HOME, ".config/openvr/config/chaperone_info.vrchap")) as f:
return json.load(f).get("universes") or []
except (OSError, ValueError, AttributeError):
return []
def room_current_universe():
try:
with open(os.path.join(STEAM, "logs/vrserver.txt"), "rb") as f:
f.seek(0, 2)
f.seek(max(0, f.tell() - 2 * 1024 * 1024))
found = room_universe_from_log(f.read().decode("utf-8", "replace"))
if found is not None:
return found
except (OSError, ValueError, AgentError):
pass
universes = [u for u in room_chaperone() if str(u.get("universeID", "0")) not in ("0", "424242")]
if not universes:
return 0
newest = max(enumerate(universes), key=lambda item: (str(item[1].get("time", "")), item[0]))[1]
try:
return int(room_universe(newest["universeID"]))
except (KeyError, AgentError):
return 0
def room_boundary(universes, current):
"""Chaperone collision quads' bottom edges, in standing/STAGE space, chained into an outline."""
import math
candidates = [u for u in universes if str(u.get("universeID", "0")) not in ("0", "424242")]
if not candidates:
raise AgentError("No play area found. Set up a play area in SteamVR first.")
u = next((u for u in candidates if str(u.get("universeID")) == str(current)), None)
if u is None:
u = max(enumerate(candidates), key=lambda item: (str(item[1].get("time", "")), item[0]))[1]
edges = []
for quad in u.get("collision_bounds") or []:
if len(quad) != 4 or any(len(p) != 3 for p in quad):
raise AgentError("Invalid chaperone wall")
quad = [[float(v) for v in p] for p in quad]
if not all(math.isfinite(v) for p in quad for v in p):
raise AgentError("Invalid chaperone coordinate")
bottom = min(p[1] for p in quad)
pts = list(dict.fromkeys((p[0], p[2]) for p in quad if abs(p[1] - bottom) < .001))
if len(pts) != 2:
raise AgentError("Invalid chaperone wall edge")
edges.append(tuple(pts))
if len(edges) < 3:
raise AgentError("No play area outline found")
outline = list(edges.pop(0))
while edges:
for i, (a, b) in enumerate(edges):
if a == outline[-1] or b == outline[-1]:
outline.append(b if a == outline[-1] else a)
edges.pop(i)
break
else:
raise AgentError("Play area outline is not closed")
if outline[-1] != outline[0]:
raise AgentError("Play area outline is not closed")
return int(room_universe(u["universeID"])), outline[:-1]
def room_default_circle(points):
import math
if len(points) != 20:
return False
cx = sum(p[0] for p in points) / 20
cz = sum(p[1] for p in points) / 20
radii = [math.hypot(x-cx, z-cz) for x, z in points]
return all(.85 <= r <= 1.15 for r in radii) and max(radii) - min(radii) < .05
def room_records(text):
"""Validate the wire format without desktop dependencies; return its status counts and mesh metadata."""
import math
lines = [line.split("#", 1)[0].split() for line in text.splitlines()]
lines = [line for line in lines if line]
if not lines or lines[0] != ["frameport-room", "1"]:
raise AgentError("expected frameport-room 1")
result = {"universe": 0, "source": "manual", "time": 0,
"summary": {"walls": 0, "volumes": {}, "planes": 0}}
outline, walls, floor, ceiling = [], [], 0, None
ids = set()
for row in lines[1:]:
k, v = row[0], row[1:]
try:
if k in ("room", "floor", "ceiling", "wall", "volume", "plane", "mesh"):
if not v or not re.fullmatch(r"[a-f0-9]{32}", v[0]) or v[0] in ids:
raise ValueError("invalid or duplicate entity UUID")
ids.add(v[0])
if k == "universe":
if len(v) != 1:
raise ValueError("expected universe")
result[k] = int(room_universe(v[0]))
elif k == "time":
if len(v) != 1 or not v[0].isdigit():
raise ValueError("invalid time")
result[k] = int(v[0])
elif k == "source":
if len(v) != 1 or v[0] not in ("scan", "boundary", "box", "manual"):
raise ValueError("invalid source")
result[k] = v[0]
elif k == "room":
if len(v) != 1:
raise ValueError("invalid room")
elif k in ("floor", "ceiling", "wall", "volume", "plane", "outline"):
expected = {"floor": 2, "ceiling": 2, "wall": 7, "volume": 9, "plane": 11}
if k == "wall" and len(v) == 8: # optional label: an edge the scanner closed itself
if v[7] not in ("WALL_FACE", "INVISIBLE_WALL_FACE"):
raise ValueError("invalid wall label")
v = v[:7]
if k != "outline" and len(v) != expected[k]:
raise ValueError("invalid field count")
nums = [float(x) for x in v[2 if k in ("volume", "plane") else 1:]]
if not all(math.isfinite(x) for x in nums):
raise ValueError("nonfinite coordinate")
if k == "floor":
floor = nums[0]
elif k == "ceiling":
ceiling = nums[0]
elif k == "outline":
if int(v[0]) < 3 or len(nums) != int(v[0]) * 2:
raise ValueError("invalid outline")
outline = list(zip(nums[::2], nums[1::2], strict=True))
elif k == "wall":
if nums[5] <= nums[4] or nums[:2] == nums[2:4]:
raise ValueError("invalid wall dimensions")
walls.append(nums)
elif k in ("volume", "plane"):
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", v[1]) or min(nums[-3 if k == "volume" else -2:]) <= 0:
raise ValueError("invalid label or dimensions")
if k == "plane":
if sum(x*x for x in nums[3:7]) < 1e-12:
raise ValueError("zero quaternion")
result["summary"]["planes"] += 1
else:
counts = result["summary"]["volumes"]
counts[v[1]] = counts.get(v[1], 0) + 1
elif k == "mesh":
ident, name, nv, ni = v
nv, ni = int(nv), int(ni)
if (not re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]*", name) or name in ("room.txt", "scan.log")
or nv < 0 or ni < 0 or ni % 3 or ni > 600000):
raise ValueError("invalid mesh record")
result["mesh"] = {"filename": name, "vertices": nv, "indices": ni}
except (ValueError, IndexError) as exc:
raise AgentError(f"Invalid room {k}: {exc}") from exc
if not outline and walls:
xs = [x for w in walls for x in (w[0], w[2])]
zs = [z for w in walls for z in (w[1], w[3])]
outline = [(min(xs), min(zs)), (max(xs), min(zs)), (max(xs), max(zs)), (min(xs), max(zs))]
area = abs(sum(x*b-a*z for (x, z), (a, b) in zip(outline, outline[1:]+outline[:1], strict=True))) / 2
height = (ceiling if ceiling is not None else floor + 2.5) - floor
if len(outline) < 3 or area < 1e-8 or height <= 0:
raise AgentError("Invalid room outline or ceiling")
result["summary"].update(walls=len(walls) or len(outline), floor_area=area, ceiling_height=height)
return result
def room_validate_mesh(data, record):
import math
if len(data) < 16:
raise AgentError("truncated room mesh")
magic, version, nv, ni = struct.unpack_from("<4sIII", data)
if (magic != b"FPRM" or version != 1 or nv != record["vertices"] or ni != record["indices"]
or len(data) != 16 + 12*nv + 4*ni):
raise AgentError("invalid room mesh header or counts")
if any(not math.isfinite(v) for (v,) in struct.iter_unpack("<f", data[16:16+12*nv])):
raise AgentError("invalid room mesh coordinate")
if any(i >= nv for (i,) in struct.iter_unpack("<I", data[16+12*nv:])):
raise AgentError("invalid room mesh index")
def room_selected(universe=None):
"""The matching universe, otherwise current; never follow a link outside the room store."""
for name in (room_universe(room_current_universe() if universe is None else universe), "current"):
path = os.path.realpath(os.path.join(ROOMS_DIR, name))
if (os.path.dirname(path) == os.path.realpath(ROOMS_DIR) and os.path.basename(path).isdigit()
and os.path.isfile(os.path.join(path, "room.txt"))):
return path
return None
def cmd_room_status(args):
current = room_current_universe()
selected = room_selected(current)
rooms = []
for path in glob.glob(os.path.join(ROOMS_DIR, "*")):
if not os.path.basename(path).isdigit() or os.path.islink(path):
continue
try:
with open(os.path.join(path, "room.txt")) as f:
record = room_records(f.read())
rooms.append(record)
except (OSError, AgentError):
continue
active = None
text = None
info = {}
if selected:
with open(os.path.join(selected, "room.txt")) as f:
text = f.read()
active = room_records(text)
info = room_scan_info(selected)
try:
_, points = room_boundary(room_chaperone(), current)
circle = room_default_circle(points)
except (AgentError, ValueError, TypeError):
circle = None
return {"rooms": sorted(rooms, key=lambda r: r["time"], reverse=True), "universe": current,
"room": active, "room_text": text,
"aligned": bool(active and current and active["universe"] == current),
"default_circle": circle, "scanner_installed": os.access(ROOM_SCANNER, os.X_OK),
# scan clean-up: the saved settings (non-defaults), what the last clean-up used, did and decided
"refine": room_refine_load(), "refine_used": info.get("refine"), "report": info.get("report"),
"labels": info.get("labels") or [], "suggestions": info.get("suggestions") or [],
"raw": bool(selected and os.path.isfile(os.path.join(selected, "room-raw.txt"))),
"edited": bool(selected and os.path.isfile(os.path.join(selected, "edits.txt")))}
def room_refine_settings(settings):
"""Validated clean-up settings, only the keys that differ from the defaults ({} = the defaults)."""
import math
if not isinstance(settings, dict):
raise AgentError("clean-up settings must be an object")
out = {}
for key, value in settings.items():
if key not in ROOM_REFINE_DEFAULTS:
raise AgentError(f"unknown clean-up setting: {key}")
if key == "walls":
if value not in ("off", "snap", "panels"):
raise AgentError("walls must be off, snap or panels")
else:
if isinstance(value, bool):
value = int(value)
if not isinstance(value, (int, float)) or not math.isfinite(value):
raise AgentError(f"invalid clean-up setting: {key}")
lo, hi = ROOM_REFINE_LIMITS[key]
if not lo <= value <= hi:
raise AgentError(f"clean-up setting out of range: {key}")
if key in ROOM_REFINE_INTS:
if value != int(value):
raise AgentError(f"{key} must be a whole number")
value = int(value)
else:
value = float(value)
if value != ROOM_REFINE_DEFAULTS[key]:
out[key] = value
return out
def room_refine_load():
try:
with open(ROOM_REFINE_FILE) as f:
return room_refine_settings(json.load(f))
except (OSError, ValueError, AgentError):
return {}
def room_refine_args(settings, erase=None):
"""fp_roomscan options for the stored settings (only non-defaults: with none, an older scanner without the
clean-up still runs) and optional erase spheres [[x, y, z, r], ...] (or {x, y, z, r})."""
import math
args = []
for key, value in settings.items():
args += ["--" + key.replace("_", "-"), value if isinstance(value, str) else format(value, "g")]
if erase:
spots = []
for spot in erase:
if isinstance(spot, dict):
spot = [spot.get(k) for k in ("x", "y", "z", "r")]
if (not isinstance(spot, (list, tuple)) or len(spot) != 4
or not all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
for v in spot) or spot[3] <= 0):
raise AgentError("invalid erase spot")
spots.append(",".join(format(float(v), ".4g") for v in spot))
if len(spots) > 500:
raise AgentError("too many erase spots")
args += ["--erase", ";".join(spots)]
return args
def room_extras(folder):
"""The extra files of a room folder (ROOM_EXTRA_FILES) as name -> bytes."""
out = {}
for name, limit in ROOM_EXTRA_FILES.items():
path = os.path.join(folder, name)
if os.path.isfile(path) and not os.path.islink(path):
with open(path, "rb") as f:
data = f.read(limit + 1)
if len(data) <= limit:
out[name] = data
return out
def room_scan_info(folder):
"""scan.json's clean-up settings, report, label decisions and door/closet suggestions ({} when there's none)."""
try:
with open(os.path.join(folder, "scan.json")) as f:
info = json.load(f)
except (OSError, ValueError, TypeError):
return {}
if not isinstance(info, dict):
return {}
out = {}
for key, kind in (("refine", dict), ("report", dict), ("labels", list), ("suggestions", list)):
if isinstance(info.get(key), kind):
out[key] = info[key]
return out
def room_merge_edits(text, edits):
"""The player's own furniture and doors/windows (volume + plane records) instead of the scanner's."""
keep = [line for line in text.splitlines() if line.split()[:1] not in (["volume"], ["plane"])]
mine = [line for line in edits.splitlines() if line.split()[:1] in (["volume"], ["plane"])]
merged = "\n".join(keep + mine) + "\n"
room_records(merged)
return merged
def room_store(text, mesh=None, scan_log=None, extras=None):
record = room_records(text)
for name in extras or {}:
if name not in ROOM_EXTRA_FILES:
raise AgentError(f"unexpected room file: {name}")
if record.get("mesh"):
if mesh is None:
raise AgentError("room mesh sidecar is missing")
room_validate_mesh(mesh, record["mesh"])
elif mesh is not None:
raise AgentError("room has no mesh record")
universe = room_universe(record["universe"])
os.makedirs(ROOMS_DIR, exist_ok=True)
with open(os.path.join(ROOMS_DIR, ".lock"), "a") as lock:
if fcntl:
fcntl.flock(lock, fcntl.LOCK_EX)
dest = os.path.join(ROOMS_DIR, universe)
if os.path.islink(dest):
raise AgentError("refusing a linked room folder")
stage = tempfile.mkdtemp(prefix=".room-", dir=ROOMS_DIR)
previous = dest + ".previous"
try:
with open(os.path.join(stage, "room.txt"), "w") as f:
f.write(text)
if record.get("mesh"):
with open(os.path.join(stage, record["mesh"]["filename"]), "wb") as f:
f.write(mesh)
if scan_log:
with open(os.path.join(stage, "scan.log"), "w") as f:
f.write(scan_log)
for name, data in (extras or {}).items():
with open(os.path.join(stage, name), "wb") as f:
f.write(data)
shutil.rmtree(previous, ignore_errors=True)
if os.path.isdir(dest):
os.rename(dest, previous)
try:
os.rename(stage, dest)
except OSError:
if os.path.isdir(previous):
os.rename(previous, dest)
raise
link = os.path.join(ROOMS_DIR, ".current-new")
if os.path.lexists(link):
os.unlink(link)
os.symlink(universe, link)
os.replace(link, os.path.join(ROOMS_DIR, "current"))
shutil.rmtree(previous, ignore_errors=True)
finally:
shutil.rmtree(stage, ignore_errors=True)
return record
def cmd_room_put(args):
# PC uploads to a unique directory below the room store, not to a game's files or an arbitrary host path.
path = os.path.realpath(args["path"])
if not _inside(path, os.path.realpath(ROOMS_DIR)) or os.path.basename(path) != "room.txt":
raise AgentError("upload room.txt below the room store")
with open(path) as f:
text = f.read(8*1024*1024)
record = room_records(text)
mesh = None
if record.get("mesh"):
mesh_path = os.path.realpath(os.path.join(os.path.dirname(path), record["mesh"]["filename"]))
if os.path.dirname(mesh_path) != os.path.dirname(path):
raise AgentError("linked mesh outside upload folder")
with open(mesh_path, "rb") as f:
mesh = f.read(64*1024*1024)
extras = None
if args.get("edited"):
# the Room tab's furniture / door editor: the same room with the player's boxes. Keep the scan's raw files
# (a new clean-up starts from them) and remember the edits, so a new clean-up keeps them.
folder = os.path.join(ROOMS_DIR, room_universe(record["universe"]))
extras = room_extras(folder) if os.path.isdir(folder) and not os.path.islink(folder) else {}
extras["edits.txt"] = "".join(line + "\n" for line in text.splitlines()
if line.split()[:1] in (["volume"], ["plane"])).encode()
return {"room": room_store(text, mesh, extras=extras)}
def cmd_room_delete(args):
selected = room_selected()
name = room_universe(args["universe"]) if "universe" in args else (os.path.basename(selected) if selected else None)
if name:
path = os.path.join(ROOMS_DIR, name)
if os.path.islink(path):
raise AgentError("refusing a linked room folder")
shutil.rmtree(path, ignore_errors=True)
current = os.path.join(ROOMS_DIR, "current")
if os.path.islink(current) and not os.path.exists(current):
os.unlink(current)
remaining = cmd_room_status({})["rooms"]
if remaining:
os.symlink(room_universe(remaining[0]["universe"]), current)
return cmd_room_status({})
def cmd_room_from_boundary(args):
import math
import uuid
universe, outline = room_boundary(room_chaperone(), room_current_universe())
if room_default_circle(outline):
raise AgentError("Valve's default circle is not a room. Set up your play area in SteamVR first.")
height = float(args.get("height", 2.5))
if not math.isfinite(height) or height <= 0:
raise AgentError("room height must be positive")
area = sum(x*b-a*z for (x, z), (a, b) in zip(outline, outline[1:]+outline[:1], strict=True))
if area < 0:
outline.reverse()
rows = ["frameport-room 1", f"universe {universe}", "source boundary", f"time {int(time.time())}",
f"room {uuid.uuid4().hex}", f"floor {uuid.uuid4().hex} 0", f"ceiling {uuid.uuid4().hex} {height}",
"outline " + str(len(outline)) + " " + " ".join(str(v) for p in outline for v in p)]
rows += [f"wall {uuid.uuid4().hex} {x} {z} {a} {b} 0 {height}"
for (x, z), (a, b) in zip(outline, outline[1:]+outline[:1], strict=True)]
room_store("\n".join(rows) + "\n")
return cmd_room_status({})
def cmd_room_build(args):
dump = os.path.realpath(args["dump"])
if not _inside(dump, os.path.realpath(ROOMS_DIR)):
raise AgentError("upload scan dump below the room store")
if not os.access(ROOM_SCANNER, os.X_OK):
raise AgentError("Room scanner is not installed")
universe = room_universe(args.get("universe", room_current_universe()))
with tempfile.TemporaryDirectory(prefix=".build-", dir=ROOMS_DIR) as out:
result = subprocess.run([ROOM_SCANNER, "--offline", dump, "--out", out, "--universe", universe, "--json",
*room_refine_args(room_refine_load())],
capture_output=True, text=True, timeout=300)
if result.returncode:
raise AgentError("Room scan failed: " + result.stderr[-4000:])
try:
report = json.loads(result.stdout)
except ValueError as exc:
raise AgentError("Room scanner returned invalid JSON") from exc
with open(os.path.join(out, "room.txt")) as f:
text = f.read()
record = room_records(text)
if str(record["universe"]) != universe:
raise AgentError("Room scanner returned the wrong universe")
mesh = None
if record.get("mesh"):
with open(os.path.join(out, record["mesh"]["filename"]), "rb") as f:
mesh = f.read()
scan_log = result.stderr
try:
with open(dump + ".log") as log:
scan_log += "\n" + log.read()
except OSError:
pass
room_store(text, mesh, scan_log, room_extras(out))
return {**cmd_room_status({}), "report": report} # (the scanner's summary, not scan.json's)
def cmd_room_refine(args):
"""Clean up the saved room's scan again (fp_roomscan --refine-only on a copy of the room folder, from its raw
files), then publish it like room_store (games pick it up through room_sync).
settings: the clean-up settings to save (null = keep the saved ones); erase: [[x, y, z, r], ...] spheres of scan
to remove; keep_edits (default true): the player's own furniture / doors stay (false = back to the scan's)."""
settings = args.get("settings")
if settings is not None:
settings = room_refine_settings(settings)
else:
settings = room_refine_load()
options = room_refine_args(settings, args.get("erase"))
if args.get("settings") is not None:
os.makedirs(ROOMS_DIR, exist_ok=True)
_write_json(ROOM_REFINE_FILE, settings)
if not os.access(ROOM_SCANNER, os.X_OK):
raise AgentError("Room scanner is not installed")
selected = room_selected()
if not selected:
raise AgentError("No room saved yet")
with open(os.path.join(ROOMS_DIR, ".scan.lock"), "a") as lock: # not while a scan builds a room
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise AgentError("A room scan is running. Try again when it has finished.") from None
with tempfile.TemporaryDirectory(prefix=".refine-", dir=ROOMS_DIR) as stage:
for name in os.listdir(selected):
path = os.path.join(selected, name)
if os.path.isfile(path) and not os.path.islink(path):
shutil.copy2(path, os.path.join(stage, name))
result = subprocess.run([ROOM_SCANNER, "--refine-only", stage, *options, "--json"],
capture_output=True, text=True, timeout=300)
if result.returncode:
raise AgentError("Room clean-up failed: " + (result.stderr or result.stdout or "")[-4000:])
try:
summary = json.loads(result.stdout or "{}")
except ValueError:
summary = {}
with open(os.path.join(stage, "room.txt")) as f:
text = f.read()
record = room_records(text)
if str(record["universe"]) != os.path.basename(selected):
raise AgentError("Room clean-up returned the wrong universe")
extras = room_extras(stage)
edits = extras.get("edits.txt")
if edits is not None and not args.get("keep_edits", True):
del extras["edits.txt"]
elif edits is not None:
text = room_merge_edits(text, edits.decode("utf-8", "replace"))
mesh = None
if record.get("mesh"):
with open(os.path.join(stage, record["mesh"]["filename"]), "rb") as f:
mesh = f.read()
scan_log = None
if os.path.isfile(os.path.join(stage, "scan.log")):
with open(os.path.join(stage, "scan.log"), errors="replace") as f:
scan_log = f.read()
room_store(text, mesh, scan_log, extras)
return {"summary": summary if isinstance(summary, dict) else {}, **cmd_room_status({})}
# --- RoomView 3D capture (docs/mr/PHASE0.md) -------------------------------------------------------------------
# Valve's passthrough computes a head-centred depth mesh (~30/s) and publishes it in two OpenVR block queues;
# FramePort records it into a .fprs scan dump (docs/mr/ROOM_FORMAT.md) for fp_roomscan. Background app only: reading
# never starts SteamVR or changes it. Poses are kept in SteamVR's raw space while recording and converted to STAGE
# with the raw->standing transform at the END: Valve's floor estimate moves that transform during a session.
ROOMVIEW_DATA = b"/xrservice/roomview3d/meshData"
ROOMVIEW_LAYOUT = b"/xrservice/roomview3d/meshLayout"
FPRS_FRAME = "<d7fII" # time, pose (px py pz qx qy qz qw), vertex count, index count
FPRS_POSE_OFFSET = 8 # the pose's byte offset inside a frame header (rewritten at the end)
ROOMVIEW_MAX_DEPTH = 4.0 # metres; beyond this Valve's depth is inpainted (only reported here)
VR_APP_OVERLAY = 2 # EVRApplicationType VRApplication_Overlay
def _m34_mul(a, b):
"""Row-major 3x4 affine a * b."""
out = []
for r in range(3):
for c in range(4):
v = sum(a[r * 4 + k] * b[k * 4 + c] for k in range(3))
out.append(v + (a[r * 4 + 3] if c == 3 else 0.0))
return out
def _m34_quat(m):
"""Rotation of a row-major 3x4 as (x, y, z, w)."""
r00, r01, r02, r10, r11, r12, r20, r21, r22 = m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]
tr = r00 + r11 + r22
if tr > 0:
s = math.sqrt(tr + 1.0) * 2
return ((r21 - r12) / s, (r02 - r20) / s, (r10 - r01) / s, 0.25 * s)
if r00 > r11 and r00 > r22:
s = math.sqrt(1.0 + r00 - r11 - r22) * 2
return (0.25 * s, (r01 + r10) / s, (r02 + r20) / s, (r21 - r12) / s)
if r11 > r22:
s = math.sqrt(1.0 + r11 - r00 - r22) * 2
return ((r01 + r10) / s, 0.25 * s, (r12 + r21) / s, (r02 - r20) / s)
s = math.sqrt(1.0 + r22 - r00 - r11) * 2
return ((r02 + r20) / s, (r12 + r21) / s, 0.25 * s, (r10 - r01) / s)
def _view_coverage(m, yaw_bins, pitch):
"""Note where the camera looked (+Z of the mesh = forward): yaw in 30 deg bins, how many frames looked up."""
fx, fy, fz = m[2], m[6], m[10]
yaw_bins.add(int((math.degrees(math.atan2(fx, fz)) % 360) // 30))
if fy > 0.6:
pitch["up"] += 1
elif fy < -0.6:
pitch["down"] += 1
class _RoomViewReader:
"""The two RoomView queues + their path properties through libopenvr_api.so (ctypes)."""
def __init__(self, app_type=None):
import ctypes
self.ct = ctypes
lib = next((p for p in OPENVR_LIBS if os.path.isfile(p)), None)
if lib is None:
raise RuntimeError("SteamVR isn't installed")
vr = self.vr = ctypes.CDLL(lib)
vr.VR_InitInternal2.restype = ctypes.c_uint32
vr.VR_InitInternal2.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_int, ctypes.c_char_p]
vr.VR_GetGenericInterface.restype = ctypes.c_void_p
vr.VR_GetGenericInterface.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int)]
vr.VR_ShutdownInternal.restype = None
err = ctypes.c_int(0)
# an Overlay app may create the prompt overlay; Background (no prompts) can't start SteamVR by accident
vr.VR_InitInternal2(ctypes.byref(err), VR_APP_BACKGROUND if app_type is None else app_type, None)
if err.value:
raise RuntimeError("SteamVR isn't running")
H, E, VP = ctypes.c_uint64, ctypes.c_int, ctypes.c_void_p
self.H, self.VP = H, VP
class BQ(ctypes.Structure): # openvr_capi.h VR_IVRBlockQueue_FnTable (IVRBlockQueue_005)
_fields_ = [("Create", VP), ("Connect", ctypes.CFUNCTYPE(E, ctypes.POINTER(H), ctypes.c_char_p)),
("Destroy", VP), ("AcquireWriteOnlyBlock", VP), ("ReleaseWriteOnlyBlock", VP),
("WaitAndAcquireReadOnlyBlock", ctypes.CFUNCTYPE(E, H, ctypes.POINTER(H), ctypes.POINTER(VP),
ctypes.c_int, ctypes.c_uint32)),
("AcquireReadOnlyBlock", VP), ("ReleaseReadOnlyBlock", ctypes.CFUNCTYPE(E, H, H)),
("QueueHasReader", VP)]
class PathRead(ctypes.Structure): # openvr_driver.h PathRead_t
_fields_ = [("ulPath", ctypes.c_uint64), ("pvBuffer", VP), ("unBufferSize", ctypes.c_uint32),
("unTag", ctypes.c_uint32), ("unRequiredBufferSize", ctypes.c_uint32), ("eError", ctypes.c_int),
("pszPath", ctypes.c_char_p)]
class M34(ctypes.Structure):
_fields_ = [("m", ctypes.c_float * 12)]
self.PathRead, self.M34 = PathRead, M34
e = ctypes.c_int(0)
ptr = vr.VR_GetGenericInterface(b"FnTable:IVRBlockQueue_005", ctypes.byref(e))
paths = vr.VR_GetGenericInterface(b"IVRPaths_002", ctypes.byref(e))
system = next((s for s in (vr.VR_GetGenericInterface(n, ctypes.byref(e))
for n in (b"IVRSystem_023", b"IVRSystem_022")) if s), None)
if not ptr or not paths or not system:
self.close()
raise RuntimeError("this SteamVR has no RoomView mesh interfaces")
self.bq = ctypes.cast(ptr, ctypes.POINTER(BQ)).contents
self.paths = paths
pvt = ctypes.cast(ctypes.cast(paths, ctypes.POINTER(VP))[0], ctypes.POINTER(VP))
self._read = ctypes.CFUNCTYPE(E, VP, ctypes.c_uint64, ctypes.POINTER(PathRead), ctypes.c_uint32)(pvt[0])
self._handle = ctypes.CFUNCTYPE(E, VP, ctypes.POINTER(ctypes.c_uint64), ctypes.c_char_p)(pvt[2])
self.system = system
svt = ctypes.cast(ctypes.cast(system, ctypes.POINTER(VP))[0], ctypes.POINTER(VP))
self._raw_to_standing = ctypes.CFUNCTYPE(M34, VP)(svt[13]) # GetRawZeroPoseToStandingAbsoluteTrackingPose
self.handles = {}
self.data, self.layout = self._connect(ROOMVIEW_DATA), self._connect(ROOMVIEW_LAYOUT)
if self.data is None or self.layout is None:
self.close()
raise RuntimeError("SteamVR has no RoomView mesh (passthrough never started since SteamVR started?)")
def _connect(self, path):
h = self.H(0)
return h if self.bq.Connect(self.ct.byref(h), path) == 0 else None
def prop(self, root, path, fmt):
ct = self.ct
if path not in self.handles:
h = ct.c_uint64(0)
self._handle(self.paths, ct.byref(h), path.encode())
self.handles[path] = h.value
buf = (ct.c_ubyte * 64)()
pr = self.PathRead(self.handles[path], ct.cast(buf, self.VP), 64, 0, 0, 0, None)
self._read(self.paths, root, ct.byref(pr), 1)
if pr.eError or pr.unRequiredBufferSize < struct.calcsize(fmt):
return None
vals = struct.unpack_from(fmt, bytes(buf))
return vals if len(vals) > 1 else vals[0]
def up_to_date(self):
return bool(self.prop(self.data.value, "/meshData/meshIsUpToDate", "<?"))
def acquire(self, queue, read_type, timeout_ms):
blk, buf = self.H(0), self.VP(0)
if self.bq.WaitAndAcquireReadOnlyBlock(queue, self.ct.byref(blk), self.ct.byref(buf), read_type, timeout_ms):
return None, None
return blk, buf.value
def release(self, queue, blk):
self.bq.ReleaseReadOnlyBlock(queue, blk)
def raw_to_standing(self):
return list(self._raw_to_standing(self.system).m)
def close(self):
try:
self.vr.VR_ShutdownInternal()
except Exception: # noqa: BLE001 - shutting down a half-initialised client
pass
class _ScanPrompt:
"""A head-locked SteamVR overlay showing FramePort's pre-rendered scan prompts (artifacts/roomscan/*.png,
uploaded next to fp_roomscan): IVROverlay_028 through its C function table. Failing to show it never stops a
scan (the PC app shows the same state)."""
SLOTS = {"Create": 1, "Destroy": 3, "SortOrder": 20, "Width": 22, "DeviceRelative": 35, "Show": 43, "Hide": 44,
"FromFile": 63}
def __init__(self, rv, image_dir):
ct = self.ct = rv.ct
self.dir, self.shown, self.handle = image_dir, None, None
e = ct.c_int(0)
table = rv.vr.VR_GetGenericInterface(b"FnTable:IVROverlay_028", ct.byref(e))
if not table or not os.path.isdir(image_dir):
return
fn = ct.cast(table, ct.POINTER(ct.c_void_p))
U64, E = ct.c_uint64, ct.c_int
self.create = ct.CFUNCTYPE(E, ct.c_char_p, ct.c_char_p, ct.POINTER(U64))(fn[self.SLOTS["Create"]])
self.destroy = ct.CFUNCTYPE(E, U64)(fn[self.SLOTS["Destroy"]])
self.sort = ct.CFUNCTYPE(E, U64, ct.c_uint32)(fn[self.SLOTS["SortOrder"]])
self.width = ct.CFUNCTYPE(E, U64, ct.c_float)(fn[self.SLOTS["Width"]])
self.relative = ct.CFUNCTYPE(E, U64, ct.c_uint32, ct.POINTER(rv.M34))(fn[self.SLOTS["DeviceRelative"]])
self.show_fn = ct.CFUNCTYPE(E, U64)(fn[self.SLOTS["Show"]])
self.from_file = ct.CFUNCTYPE(E, U64, ct.c_char_p)(fn[self.SLOTS["FromFile"]])
h = U64(0)
if self.create(b"frameport.roomscan", b"FramePort room scan", ct.byref(h)):
return
self.handle = h
m = rv.M34()
for i, v in enumerate((1, 0, 0, 0, 0, 1, 0, -0.25, 0, 0, 1, -1.2)): # 1.2 m ahead, a bit below eye level
m.m[i] = v
self.width(h, 0.8)
self.sort(h, 10)
self.relative(h, 0, ct.byref(m)) # k_unTrackedDeviceIndex_Hmd
def show(self, name):
if self.handle is None or name == self.shown:
return
path = os.path.join(self.dir, name + ".png")
if os.path.isfile(path) and self.from_file(self.handle, path.encode()) == 0:
self.show_fn(self.handle)
self.shown = name
def close(self, final=None, linger=4.0):
if self.handle is None:
return
if final:
self.show(final)
time.sleep(linger)
self.destroy(self.handle)
self.handle = None
def _scan_point(m, p):
return tuple(sum(m[r * 4 + c] * p[c] for c in range(3)) + m[r * 4 + 3] for r in range(3))
def _render_scan_preview(cells, raw_to_standing, center, size=256, width=6.0):
"""Raw 10 cm voxel keys -> top-down RGBA. Image top is standing -Z; tallest class wins overlaps.
One colour (FramePort's secondary blue), only the opacity says floor / furniture / wall, and every cell is one
filled square: overlays draw over hands and the passthrough (no depth), so the map stays faint and calm (owner,
2026-10-18: three colours in 3x3 blotches clashed)."""
pixels = bytearray(size * size * 4)
classes = bytearray(size * size)
colors = (bytes((58, 168, 255, 40)), bytes((58, 168, 255, 80)), bytes((58, 168, 255, 150)))
scale = size / width
for key in cells:
x, y, z = _scan_point(raw_to_standing, tuple((v + 0.5) * 0.1 for v in key))
if y < -0.3:
continue
kind = 0 if y < 0.3 else 1 if y <= 1.5 else 2
# the cell's 10 cm footprint around its centre, in pixels
c0 = int(math.floor((x - 0.05 - center[0]) * scale + size / 2))
c1 = int(math.ceil((x + 0.05 - center[0]) * scale + size / 2))
r0 = int(math.floor((z - 0.05 - center[2]) * scale + size / 2))
r1 = int(math.ceil((z + 0.05 - center[2]) * scale + size / 2))
for row in range(max(r0, 0), min(r1, size)):
for col in range(max(c0, 0), min(c1, size)):
i = row * size + col
if classes[i] <= kind:
pixels[i * 4:i * 4 + 4] = colors[kind]
classes[i] = kind + 1
return bytes(pixels)
class _ScanPreview:
"""Best-effort floor overlay; same IVROverlay_028 C table setup as _ScanPrompt.
Slots verified against openvr-2.15.6.h. Keep voxels in raw space because Valve moves STAGE during capture.
"""
SLOTS = {"Create": 1, "Destroy": 3, "SortOrder": 20, "Width": 22, "Absolute": 33, "Raw": 62, "Show": 43}
def __init__(self, rv, log=None):
self.rv, self.log = rv, log or (lambda message: print(message, file=sys.stderr, flush=True))
self.handle, self.disabled = None, False
self.cells, self.origin = {}, None
self.dirty, self.last_render, self.uploads = False, -float("inf"), 0
try:
ct = rv.ct
error = ct.c_int(0)
table = rv.vr.VR_GetGenericInterface(b"FnTable:IVROverlay_028", ct.byref(error))
if not table or error.value:
raise RuntimeError("IVROverlay_028 unavailable")
fn = ct.cast(table, ct.POINTER(ct.c_void_p))
U, E = ct.c_uint64, ct.c_int
signatures = {"Create": (ct.c_char_p, ct.c_char_p, ct.POINTER(U)), "Destroy": (U,),
"SortOrder": (U, ct.c_uint32), "Width": (U, ct.c_float),
"Absolute": (U, E, ct.POINTER(rv.M34)),
"Raw": (U, ct.c_void_p, ct.c_uint32, ct.c_uint32, ct.c_uint32), "Show": (U,)}
self.functions = {name: ct.CFUNCTYPE(E, *args)(fn[self.SLOTS[name]])
for name, args in signatures.items()}
h = U(0)
self._call("Create", b"frameport.roomscan.preview", b"FramePort floor coverage", ct.byref(h))
self.handle = h
self._call("SortOrder", h, 1) # always below the scan prompts (_ScanPrompt: 10)
self._call("Width", h, 6.0)
except Exception as exc:
self._disable(exc)
def _call(self, name, *args):
error = self.functions[name](*args)
if error:
raise RuntimeError(f"{name}: overlay error {error}")
def _disable(self, exc):
if not self.disabled:
self.disabled = True
self._log(f"scan preview disabled: {exc}")
self.close()
def _log(self, message):
try:
self.log(message)
except Exception:
pass # diagnostic I/O must never abort capture
def feed(self, vertices, pose, now=None):
if self.disabled:
return
try:
if self.origin is None:
self.origin = (pose[3], pose[7], pose[11])
for offset in range(0, len(vertices) - 11, 16 * 12):
point = struct.unpack_from("<3f", vertices, offset)
if not all(map(math.isfinite, point)) or not 0.09 <= sum(v * v for v in point) <= 16:
continue
raw = _scan_point(pose, point)
key = tuple(math.floor(v / 0.1) for v in raw)
if key not in self.cells:
self.dirty = True
self.cells[key] = self.cells.get(key, 0) + 1
now = time.monotonic() if now is None else now
if not self.dirty or now - self.last_render < 1.0:
return
transform = self.rv.raw_to_standing()
center = _scan_point(transform, self.origin)
matrix = self.rv.M34()
# Rx(-90 degrees): local +Z (front) -> standing +Y; local +Y -> -Z.
for i, value in enumerate((1, 0, 0, center[0], 0, 0, 1, 0.015, 0, -1, 0, center[2])):
matrix.m[i] = value
self._call("Absolute", self.handle, 1, self.rv.ct.byref(matrix)) # TrackingUniverseStanding
rgba = _render_scan_preview(self.cells, transform, center)
self.buffer = self.rv.ct.create_string_buffer(rgba)
started = time.monotonic()
try:
self._call("Raw", self.handle, self.buffer, 256, 256, 4)
finally:
elapsed = time.monotonic() - started
self.uploads += 1
if self.uploads <= 20 or self.uploads % 10 == 0:
self._log(f"scan preview SetOverlayRaw #{self.uploads}: {elapsed * 1000:.2f} ms")
self._call("Show", self.handle)
self.last_render, self.dirty = now, False
if elapsed > 0.05:
self._disable(f"SetOverlayRaw too slow ({elapsed * 1000:.2f} ms)")
except Exception as exc:
self._disable(exc)
def close(self):
if self.handle is not None:
handle, self.handle = self.handle, None
try:
self._call("Destroy", handle)
except Exception as exc:
self._disable(exc)
ROOM_SCAN_LOOK_FRAMES = 5 # recorded frames looking up / down (~1.7 s at 3/s) that count as "ceiling/floor seen"
ROOM_SCAN_MIN_SECONDS = 60 # a complete scan still records this long: a 32 s one found 2 of 7 furniture boxes
ROOMVIEW_STREAM_MESHES = 3 # new meshes within ROOMVIEW_STREAM_WINDOW s = passthrough is on (Valve: ~30/s)
ROOMVIEW_STREAM_WINDOW = 2.0
ROOM_SCAN_MIN_FRAMES = 30 # fewer recorded frames is no room: refused instead of replacing the saved one
def _prompt_for(started, yaw_bins, pitch, streaming=True, now=None):
"""Which prompt image the headset shows: "start" (turn on passthrough) whenever no meshes stream, also mid-scan."""
if started is None or not streaming:
return "start"
if (now is not None and len(yaw_bins) >= 12 and pitch["up"] >= ROOM_SCAN_LOOK_FRAMES
and pitch["down"] >= ROOM_SCAN_LOOK_FRAMES):
# everything seen before ROOM_SCAN_MIN_SECONDS: say so and show the time left (owner: "stuck on 12/12")
return f"finish_{min(12, int((now - started) / ROOM_SCAN_MIN_SECONDS * 12)):02d}"
if len(yaw_bins) >= 10 and pitch["up"] < ROOM_SCAN_LOOK_FRAMES:
return "ceiling"
if len(yaw_bins) >= 10 and pitch["down"] < ROOM_SCAN_LOOK_FRAMES:
return "floor"
return f"look_{min(12, len(yaw_bins)):02d}"
def _scan_complete(started, now, yaw_bins, pitch):
return (started is not None and now - started >= ROOM_SCAN_MIN_SECONDS and len(yaw_bins) >= 12
and pitch["up"] >= ROOM_SCAN_LOOK_FRAMES and pitch["down"] >= ROOM_SCAN_LOOK_FRAMES)
def capture_roomview(seconds, out_path, wait=0.0, max_hz=3.0, progress=None, prompts=None, preview=True,
preview_log=None):
"""Record RoomView 3D meshes into a .fprs dump: `seconds` of fresh meshes (after waiting up to `wait` s for the
first one), at most `max_hz` frames per second. progress(dict) is called about once a second. Returns a summary:
frames, duration, yaw coverage (30 deg bins seen), frames looking up/down, warnings."""
rv = _RoomViewReader(VR_APP_OVERLAY if prompts or preview else None)
ct = rv.ct
prompt = _ScanPrompt(rv, prompts) if prompts else None
floor_preview = _ScanPreview(rv, preview_log) if preview else None
tmp = out_path + ".part"
offsets, raw_poses = [], []
yaw_bins, pitch = set(), {"up": 0, "down": 0}
indices, layout_id, last_mesh, last_write = None, None, None, 0.0
# Valve only computes new meshes while passthrough is shown; a queue's last block (and meshIsUpToDate) stays
# after it stops. So "streaming" = ROOMVIEW_STREAM_MESHES new mesh ids within ROOMVIEW_STREAM_WINDOW seconds, and
# frames are recorded only then (2026-10-16: a scan started on one leftover block and recorded nothing else).
arrivals = []
seen_mesh = None
started, paused_since, paused_total = None, None, 0.0
t_end = time.time() + (wait or seconds)
next_progress = time.time()
try:
with open(tmp, "wb") as f:
f.write(b"FPRS" + struct.pack("<I", 1))
while time.time() < t_end:
now = time.time()
arrivals = [t for t in arrivals if now - t < ROOMVIEW_STREAM_WINDOW]
streaming = len(arrivals) >= ROOMVIEW_STREAM_MESHES
if started is not None: # passthrough off mid-scan: the countdown waits (at most `wait` in all)
if not streaming and paused_since is None:
paused_since = now
elif streaming and paused_since is not None:
pause = min(now - paused_since, max(0.0, (wait or seconds) - paused_total))
t_end += pause
paused_total += pause
paused_since = None
if _scan_complete(started, now, yaw_bins, pitch):
break # every direction, the ceiling and the floor seen: "Room scanned" at once
if now >= next_progress:
if prompt:
prompt.show(_prompt_for(started, yaw_bins, pitch, streaming, now))
if progress:
progress({"recording": started is not None, "frames": len(offsets),
"seconds": round(now - started - paused_total, 1) if started else 0,
"directions": len(yaw_bins), "looked_up": pitch["up"],
"looked_down": pitch["down"], "live": streaming})
next_progress = now + 1
blk, buf = rv.acquire(rv.data, 1, 200) # EBlockQueueReadType_New
if blk is None:
continue
try:
mesh_id = rv.prop(blk.value, "/meshData/meshId", "<Q")
if mesh_id != seen_mesh:
seen_mesh = mesh_id
arrivals.append(now)
if not streaming or not rv.up_to_date() or now - last_write < 1.0 / max_hz:
continue
lid = rv.prop(blk.value, "/meshData/layoutId", "<Q")
count = rv.prop(blk.value, "/meshData/elementCount", "<i") or 0
pose = rv.prop(blk.value, "/meshData/rawFromMeshPose", "<12f")
if mesh_id == last_mesh or pose is None or count < 9 or not all(map(math.isfinite, pose)):
continue
if lid != layout_id: # the index layout only changes with the mesh resolution
lb, lbuf = rv.acquire(rv.layout, 0, 500)
if lb is not None:
n = rv.prop(lb.value, "/meshLayout/indexCount", "<i") or 0
indices = ct.string_at(lbuf, n * 4)
layout_id = rv.prop(lb.value, "/meshLayout/layoutId", "<Q")
rv.release(rv.layout, lb)
if indices is None or layout_id != lid:
continue
verts = ct.string_at(buf, count * 4)
finally:
rv.release(rv.data, blk)
if started is None:
started = now
t_end = now + seconds
offsets.append(f.tell())
raw_poses.append(list(pose))
_view_coverage(pose, yaw_bins, pitch)
f.write(struct.pack(FPRS_FRAME, now, 0, 0, 0, 0, 0, 0, 1, count // 3, len(indices) // 4))
f.write(verts)
f.write(indices)
last_mesh, last_write = mesh_id, now
if floor_preview:
floor_preview.feed(verts, pose)
r2s = rv.raw_to_standing()
finally:
if floor_preview:
floor_preview.close()
if prompt:
prompt.close("done" if offsets else "failed")
rv.close()
with open(tmp, "r+b") as f: # raw -> STAGE with the final floor/universe transform
for off, raw in zip(offsets, raw_poses, strict=True):
m = _m34_mul(r2s, raw)
f.seek(off + FPRS_POSE_OFFSET)
f.write(struct.pack("<7f", m[3], m[7], m[11], *_m34_quat(m)))
warnings = []
if not offsets:
warnings.append("no passthrough mesh: turn on passthrough in the headset")
if len(yaw_bins) < 9:
warnings.append("not all directions seen: look around the whole room")
if pitch["up"] < 5:
warnings.append("ceiling not seen: look up")
os.replace(tmp, out_path)
return {"frames": len(offsets), "seconds": round(last_write - started, 1) if started else 0,
"directions": len(yaw_bins), "looked_up": pitch["up"], "looked_down": pitch["down"],
"bytes": os.path.getsize(out_path), "warnings": warnings}
ROOM_SCAN_STATUS = os.path.join(ROOMS_DIR, "scan-status.json")
ROOM_SCAN_DUMPS = os.path.join(ROOMS_DIR, "scans")
ROOM_SCAN_PROMPTS = os.path.join(HOME, ".local/share/frameport/bin/roomscan-prompts")
ROOM_SCAN_UNIT = "frameport-room-scan"
ROOM_SCAN_KEEP = 2 # newest dumps kept for diagnostics (each ~100-600 MB)
def cmd_room_capture(args):
"""Start a room scan in the headset: a detached job records the passthrough mesh (with prompts in the headset),
then builds and stores the room. Progress: room_capture_status."""
seconds = float(args.get("seconds", 90))
wait = float(args.get("wait", 180))
if not (10 <= seconds <= 900 and 0 <= wait <= 1800):
raise AgentError("scan length out of range")
if not os.access(ROOM_SCANNER, os.X_OK):
raise AgentError("Room scanner is not installed")
state = cmd_room_capture_status({})
if state.get("state") in ("waiting", "recording", "building") and _unit_active(ROOM_SCAN_UNIT):
raise AgentError("A room scan is already running")
os.makedirs(ROOMS_DIR, exist_ok=True)
_write_json(ROOM_SCAN_STATUS, {"state": "waiting", "started": time.time(), "seconds": seconds})
p = run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={ROOM_SCAN_UNIT}-{int(time.time())}",
"--setenv=HOME=" + HOME, sys.executable, os.path.abspath(__file__), "_room_capture",
json.dumps({"seconds": seconds, "wait": wait, "preview": args.get("preview", True)})])
if p.returncode != 0:
_write_json(ROOM_SCAN_STATUS, {"state": "failed", "error": p.stderr.strip()[-300:]})
raise AgentError(f"couldn't start the room scan: {p.stderr.strip()[-300:]}")
return {"started": True, "status": ROOM_SCAN_STATUS}
def _unit_active(prefix):
p = run(["systemctl", "--user", "list-units", "--no-legend", "--plain", f"{prefix}-*"])
return any(line.split()[2:3] == ["active"] for line in (p.stdout or "").splitlines() if line.strip())
def cmd_room_capture_status(args):
try:
with open(ROOM_SCAN_STATUS) as f:
return json.load(f)
except (OSError, ValueError):
return {"state": "none"}
def cmd_room_capture_stop(args):
"""Cancel a running room scan (the Room tab's Cancel): stop its job (systemd ends the recorder and the room
builder with it); the saved room stays as it was."""
state = cmd_room_capture_status({})
p = run(["systemctl", "--user", "list-units", "--no-legend", "--plain", f"{ROOM_SCAN_UNIT}-*"])
units = [line.split()[0] for line in (p.stdout or "").splitlines() if line.strip()]
stopped = [u for u in units if run(["systemctl", "--user", "stop", u]).returncode == 0]
if state.get("state") in ("waiting", "recording", "building"): # the job can't write its own end any more
state.update(state="failed", error="cancelled", cancelled=True, finished=time.time())
_write_json(ROOM_SCAN_STATUS, state)
return {"stopped": stopped, "state": state.get("state")}
def room_capture_worker(payload):
# Separate from .lock: cmd_room_build -> room_store takes that lock while publishing.
os.makedirs(ROOMS_DIR, exist_ok=True)
with open(os.path.join(ROOMS_DIR, ".scan.lock"), "a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise AgentError("A room scan is already running") from None
return _room_capture_worker(payload)
def _room_capture_worker(payload):
job = json.loads(payload)
status = {"state": "waiting", "started": time.time(), "seconds": job["seconds"]}
os.makedirs(ROOM_SCAN_DUMPS, exist_ok=True)
dump = os.path.join(ROOM_SCAN_DUMPS, time.strftime("scan-%Y%m%d-%H%M%S.fprs"))
def progress(p):
status.update(p, state="recording" if p.get("recording") else "waiting")
_write_json(ROOM_SCAN_STATUS, status)
def preview_log(message):
with open(dump + ".log", "a") as log:
log.write(message + "\n")
try:
summary = capture_roomview(job["seconds"], dump, wait=job["wait"], progress=progress,
prompts=ROOM_SCAN_PROMPTS if os.path.isdir(ROOM_SCAN_PROMPTS) else None,
preview=job.get("preview", True), preview_log=preview_log)
status.update(capture=summary, state="building")
_write_json(ROOM_SCAN_STATUS, status)
if summary["frames"] < ROOM_SCAN_MIN_FRAMES: # never replace a saved room with a scrap of one
raise AgentError("The scan saw too little of the room: keep passthrough on, look around until the "
"headset says \"Room scanned\", and scan again")
built = cmd_room_build({"dump": dump})
status.update(state="done", report=built.get("report"), room=built.get("room"), finished=time.time())
except Exception as exc: # noqa: BLE001 - every failure ends in the status file the PC polls
status.update(state="failed", error=str(exc)[-500:], finished=time.time())
finally:
_write_json(ROOM_SCAN_STATUS, status)
for old in sorted(glob.glob(os.path.join(ROOM_SCAN_DUMPS, "scan-*.fprs")))[:-ROOM_SCAN_KEEP]:
try:
os.remove(old)
if os.path.isfile(old + ".log"):
os.remove(old + ".log")
except OSError:
pass
def wants_scene_room(anchor):
try:
with open(os.path.join(anchor, "deployment.json")) as f:
dep = json.load(f)
# Live Game settings changes are in settings.conf; older recipes list patch ids only.
conf = os.path.join(dep.get("base", anchor), "settings.conf")
if os.path.isfile(conf):
for line in open(conf):
key, _, value = line.strip().partition("=")
if key == "scene_emul":
return float(value) != 0
recipe = dep.get("recipe") or {}
return dep.get("scene_room", "adapter.scene_emul" in (recipe.get("patches") or []))
except (OSError, ValueError, AttributeError):
return False
def set_room_sync_line(text, pkg, want):
text = set_scan_watch_line(text, pkg, want)
text = "".join(line for line in text.splitlines(True) if "# frameport-room-sync" not in line)
if not want or "\nsetsid " not in text:
return text
line = ROOM_SYNC_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__)), pkg_q=shlex.quote(pkg))
return text.replace("\nsetsid ", "\n" + line + "\nsetsid ", 1)
SCAN_WATCH_LINE = ('python3 {agent_q} _scan_watch {pkg_q} '
'>"$app_dir/scan-watch.log" 2>&1 9>&- & scanwatch=$! # frameport-scan-watch')
def set_scan_watch_line(text, pkg, want):
text = "".join(line for line in text.splitlines(True) if "# frameport-scan-watch" not in line)
if not want or "\nchild=$!\n" not in text:
return text
line = SCAN_WATCH_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__)), pkg_q=shlex.quote(pkg))
text = text.replace("\nchild=$!\n", "\nchild=$!\n" + line + "\n", 1)
if '${scanwatch:-}' in text:
return text
stop = ' [[ -z "${scanwatch:-}" ]] || kill "$scanwatch" 2>/dev/null || true\n'
return text.replace(' trap - EXIT INT TERM\n', ' trap - EXIT INT TERM\n' + stop, 1)
def scan_watch_write(folder, name, text):
path = os.path.join(folder, "framebridge-scan-" + name)
with open(path + ".tmp", "w") as f:
f.write(text + "\n")
os.replace(path + ".tmp", path)
def scan_watch_request(pkg, folder, request_id, alive):
scan_watch_write(folder, "ack", "ack " + request_id)
child = None
try:
# A process group lets cancellation stop fp_roomscan as well as the Python capture worker.
child = subprocess.Popen([sys.executable, os.path.abspath(__file__), "_room_capture",
json.dumps({"seconds": 90, "wait": 180})], start_new_session=True)
deadline = time.monotonic() + 350
while child.poll() is None:
if not alive():
raise AgentError("cancelled")
if time.monotonic() >= deadline:
raise AgentError("timeout")
time.sleep(1)
if child.returncode:
raise AgentError("scanner error (possibly another scan is running)")
status = cmd_room_capture_status({})
if status.get("state") != "done":
raise AgentError(status.get("error", "scanner error"))
copied = room_sync_worker(pkg)
if not copied or not copied.get("room"):
raise AgentError("no room copied")
scan_watch_write(folder, "result", "ok " + request_id)
except Exception as exc: # every acknowledged request gets a terminal result
reason = " ".join(str(exc).split())[:300] or "scanner error"
print("scan " + request_id + ": " + reason, flush=True)
scan_watch_write(folder, "result", "failed " + request_id + " " + reason)
finally:
if child is not None and child.poll() is None:
os.killpg(child.pid, signal.SIGTERM)
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
def scan_watch_worker(pkg):
dep = deployment(check_pkg(pkg))
if not dep or dep.get("kind", "quest") != "quest":
return
folder = os.path.join(dep["base"], "lepton-data/external/Android/data", pkg, "files")
os.makedirs(folder, exist_ok=True)
for name in ("request", "ack", "result"):
try:
os.unlink(os.path.join(folder, "framebridge-scan-" + name))
except FileNotFoundError:
pass
parent = os.getppid()
stopped = [False]
def stop(signum, frame):
stopped[0] = True
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
def alive():
return not stopped[0] and parent > 1 and os.getppid() == parent
seen = None
while alive():
try:
with open(os.path.join(folder, "framebridge-scan-request")) as f:
fields = f.read(512).split()
if len(fields) == 3 and fields[0] == "request" and fields[1] != seen:
float(fields[2])
seen = fields[1]
print("scan requested: " + seen, flush=True)
scan_watch_request(pkg, folder, seen, alive)
except (OSError, ValueError) as exc:
if not isinstance(exc, FileNotFoundError):
print(str(exc), flush=True)
time.sleep(1)
def room_sync_worker(pkg):
dep = deployment(check_pkg(pkg))
if not dep or dep.get("kind", "quest") != "quest":
return
base = dep["base"]
# Explicit scene_room follows the recipe at finalize; older deployments derive it on launcher upgrade.
selected = room_selected() if wants_scene_room(os.path.join(ANCHORS, pkg)) else None
record = None
if selected:
with open(os.path.join(selected, "room.txt")) as f:
text = f.read()
record = room_records(text)
if record.get("mesh"):
with open(os.path.join(selected, record["mesh"]["filename"]), "rb") as f:
mesh = f.read()
room_validate_mesh(mesh, record["mesh"])
# The public format allows any sidecar basename; per-game copies use the contract's fixed name.
text = re.sub(r"(?m)^(mesh\s+[a-f0-9]{32}\s+)\S+", r"\g<1>room-mesh.bin", text)
with open(os.path.join(base, "room-mesh.bin.tmp"), "wb") as f:
f.write(mesh)
os.replace(os.path.join(base, "room-mesh.bin.tmp"), os.path.join(base, "room-mesh.bin"))
with open(os.path.join(base, "room.txt.tmp"), "w") as f:
f.write(text)
os.replace(os.path.join(base, "room.txt.tmp"), os.path.join(base, "room.txt"))
for name in ("room.txt", "room-mesh.bin"):
if not selected or (name == "room-mesh.bin" and not record.get("mesh")):
try:
os.unlink(os.path.join(base, name))
except FileNotFoundError:
pass
return {"room": record, "aligned": bool(record and record["universe"] == room_current_universe())}
def room_setting_changed(pkg, dep, settings):
"""Live settings must update the launcher too, including switching emulation on after install."""
if "scene_emul" not in settings:
return
dep["scene_room"] = float(settings["scene_emul"]) != 0
write_deployment(pkg, dep)
path = os.path.join(ANCHORS, pkg, "launch.sh")
if os.path.isfile(path):
with open(path) as f:
old = f.read()
text = set_room_sync_line(old, pkg, dep["scene_room"])
if text != old:
with open(path + ".tmp", "w") as f:
f.write(text)
os.chmod(path + ".tmp", 0o755)
os.replace(path + ".tmp", path)
if not dep["scene_room"]:
room_sync_worker(pkg)
# ---------------------------------------------------------- end mixed-reality room storage
def cmd_finalize(args):
"""Move uploaded files into place, write launcher/settings/config files/deployment.json. Keeps saves."""
pkg = check_pkg(args["package"])
title = args["title"]
prep = cmd_prepare({"package": pkg, "title": title, "dest": args.get("dest")})
base, anchor, incoming = prep["base"], prep["anchor"], prep["incoming"]
lepton = prep["lepton"]
if not lepton:
raise AgentError("Lepton is not installed (Developer Mode → install/launch 'Lepton Development' once)")
app = os.path.join(base, "lepton-app")
os.makedirs(os.path.join(app, "obb"), exist_ok=True)
os.makedirs(anchor, exist_ok=True)
new_apk = os.path.join(incoming, "game.apk")
inc_obb = os.path.join(incoming, "obb")
expected = args.get("obb_manifest") # rel path -> size
if expected: # check the data as it will be after the move, before replacing anything (a failure keeps the old)
have = {}
for top in (os.path.join(app, "obb"), inc_obb):
for root, _, files in os.walk(top):
for name in files:
if not name.endswith(".part"):
p = os.path.join(root, name)
have[os.path.relpath(p, top)] = os.path.getsize(p)
bad = [k for k, v in expected.items() if have.get(k) != v]
if bad:
raise AgentError(f"{len(bad)} data file(s) missing or incomplete, e.g. {bad[0]}")
if args.get("existing_only") and os.path.exists(new_apk):
raise AgentError("an interrupted APK upload is pending; finish that install before updating data")
if args.get("existing_only"):
verify_installed_apk(deployment(pkg), args.get("apk_sha256"))
if os.path.exists(new_apk):
if args.get("apk_sha256") and sha256_file(new_apk) != args["apk_sha256"]:
raise AgentError("uploaded APK checksum mismatch (transfer corrupted?)")
cur = os.path.join(app, "game.apk")
if os.path.exists(cur):
os.replace(cur, os.path.join(base, "previous-game.apk")) # one generation for rollback
os.replace(new_apk, cur)
elif not os.path.exists(os.path.join(app, "game.apk")):
raise AgentError("no APK uploaded and none installed")
moved = 0
if not args.get("preserve_data"):
for root, _, files in os.walk(inc_obb):
for name in files:
if name.endswith(".part"):
continue
src = os.path.join(root, name)
rel = os.path.relpath(src, inc_obb)
if expected is not None and rel not in expected:
continue # an older interrupted upload is not part of this install
dst = os.path.join(app, "obb", rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
os.replace(src, dst)
moved += 1
shutil.rmtree(incoming, ignore_errors=True)
return configure_install({**args, "consume_artwork": args.get("consume_artwork", True)}, prep, moved)
def verify_installed_apk(dep, expected):
apk = os.path.join(dep["base"], "lepton-app", "game.apk")
if not expected or dep.get("sha256") != expected or not os.path.isfile(apk):
raise AgentError("installed APK is missing or changed; refresh and reinstall it")
st = os.stat(apk)
identity = {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
if identity != dep.get("apk_stat") and sha256_file(apk) != expected:
raise AgentError("installed APK content changed; reinstall it")
def cmd_configure(args):
"""Apply runtime configuration to an existing, matching APK without staging/replacing game files."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep or dep.get("kind", "quest") != "quest":
raise AgentError(f"{pkg} is not an installed Quest/Android app")
base, _ = new_base(args, pkg, dep) # validates a removable drive; never silently falls back
verify_installed_apk(dep, args.get("apk_sha256"))
if container_running(dep["appid"]):
raise AgentError("Close the game before updating its configuration")
lepton, _ = lepton_path()
if not lepton:
raise AgentError("Lepton is not installed")
return configure_install({**args, "title": dep["title"]},
{"base": base, "anchor": os.path.join(ANCHORS, pkg),
"appid": dep["appid"], "lepton": lepton}, 0)
def configure_install(args, prep, moved):
pkg, title = check_pkg(args["package"]), args["title"]
base, anchor, appid, lepton = prep["base"], prep["anchor"], prep["appid"], prep["lepton"]
app = os.path.join(base, "lepton-app")
previous = deployment(pkg) or {}
files_dir = data_files_dir(base, pkg)
for rel in set(args.get("files") or {}) | set(previous.get("managed_config_files") or {}):
target = os.path.realpath(os.path.join(files_dir, rel))
if not _inside(target, os.path.realpath(files_dir)) or target == os.path.realpath(files_dir):
raise AgentError(f"refusing to write outside the game's files dir: {rel}")
backup = os.path.join(files_dir, rel) + ".frameport-config-backup"
if os.path.islink(backup):
raise AgentError(f"refusing a linked configuration backup: {rel}")
for key, value in (args.get("settings") or {}).items():
if not re.fullmatch(r"[a-z_]+", key) or any(c in str(value) for c in "\r\n\x00"):
raise AgentError(f"bad setting {key}")
if "flatscreen" in args: # older clients don't send it: leave the marker as it is
set_flatscreen(app, args["flatscreen"])
# settings: settings.conf (read by the adapter via LEPTON_ENV_FRAMEBRIDGE_CONFIG) + framebridge.conf copy
files_dir = data_files_dir(base, pkg)
os.makedirs(files_dir, exist_ok=True)
os.makedirs(os.path.join(base, "lepton-shaders"), exist_ok=True)
settings = args.get("settings") or {}
conf = os.path.join(base, "settings.conf")
if settings:
if os.path.exists(conf):
shutil.copy2(conf, conf + ".previous")
text = "".join(f"{k}={v}\n" for k, v in settings.items())
with open(conf, "w") as f:
f.write(text)
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
f.write(text)
managed = previous.get("managed_config_files") or {}
wanted = args.get("files") or {}
for rel, record in managed.items():
if rel in wanted:
continue
target = os.path.realpath(os.path.join(files_dir, rel))
if not _inside(target, os.path.realpath(files_dir)):
raise AgentError(f"invalid previously managed path: {rel}")
if os.path.isfile(target) and sha256_file(target) == record["sha256"]:
backup = target + ".frameport-config-backup"
if record.get("backup") and os.path.isfile(backup):
os.replace(backup, target)
else:
os.unlink(target)
new_managed = {}
for rel, content in (args.get("files") or {}).items():
target = os.path.normpath(os.path.join(files_dir, rel))
if not target.startswith(files_dir + os.sep):
raise AgentError(f"refusing to write outside the game's files dir: {rel}")
os.makedirs(os.path.dirname(target), exist_ok=True)
record = managed.get(rel)
backup = target + ".frameport-config-backup"
# no record: an existing file is the user's, unless it already has exactly this content (written by
# FramePort before agent v86 kept records; backing it up would bring it back when the recipe drops it)
had_original = bool(record.get("backup")) if record else os.path.isfile(target) and \
open(target, "rb").read() != content.encode()
if not record and had_original:
shutil.copy2(target, backup)
with open(target, "w") as f:
f.write(content)
new_managed[rel] = {"sha256": sha256_file(target), "backup": had_original}
models = install_controller_models(files_dir, str(settings.get("controller_models", 0)) not in ("0", "0.0"))
recipe = args.get("recipe") if isinstance(args.get("recipe"), dict) else {}
static_openxr = "frame.static_openxr_loader" in (recipe.get("patches") or [])
write_openxr_runtime_manifest(base, static_openxr)
hw_video = HW_VIDEO_PATCH in (recipe.get("patches") or []) # the shared codec (install_video_codec)
gamepad = GAMEPAD_PATCH in (recipe.get("patches") or []) # Steam Input's virtual gamepad (PODMAN_WRAPPER)
if gamepad:
ensure_podman_wrapper()
config_sync = args.get("config_sync") if isinstance(args.get("config_sync"), dict) else {}
scene_value = settings.get("scene_emul", (recipe.get("adapter") or {}).get(
"scene_emul", int("adapter.scene_emul" in (recipe.get("patches") or []))))
scene_room = float(scene_value) != 0
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad, bool(config_sync),
static_openxr, scene_room)
art_in = os.path.join(base, "incoming-artwork")
art_moved = False
if args.get("consume_artwork") and os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
shutil.move(art_in, os.path.join(anchor, "artwork"))
art_moved = True
dep = {**previous, "package": pkg, "appid": int(appid), "base": base, "title": title,
"tags": args.get("tags", previous.get("tags")) or [],
"apk": args.get("apk_name", previous.get("apk", "game.apk")),
"sha256": args.get("apk_sha256") or previous.get("sha256"),
"recipe": args.get("recipe"), "installed_by": "frameport",
"static_openxr_loader": static_openxr,
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "config_sync": config_sync,
"scene_room": scene_room,
"agent_version": AGENT_VERSION, "time": time.time()}
if previous.get("eye_base"): # the runtime's eye size learned from an earlier start (device.config_sync)
dep["eye_base"] = previous["eye_base"]
dep["foveation_env"] = {key: (args.get("env") or {}).get(key) for key in FOVEATION_ENV_KEYS}
dep["managed_config_files"] = new_managed
# An app before agent v86 records none of these: what it installed is unknown, so a newer PC mustn't skip work
# on the strength of an older record. Art the agent didn't replace keeps its record.
art_replaced = bool(args.get("consume_artwork")) and art_moved
for key in ("configuration", "configuration_fp", "apk_recipe_fp", "source_version", "artwork_manifest"):
if key in args:
dep[key] = args[key]
elif key != "artwork_manifest" or art_replaced:
dep.pop(key, None)
if args.get("obb_manifest") is not None:
dep["data_manifest"] = args["obb_manifest"]
if "data_manifest" in dep:
dep["data_fp"] = hashlib.sha256(json.dumps(dep["data_manifest"], sort_keys=True).encode()).hexdigest()[:32]
st = os.stat(os.path.join(app, "game.apk"))
dep["apk_size"] = st.st_size
dep["apk_stat"] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
synced = sync_game_config(pkg, dep)
write_deployment(pkg, dep)
if not scene_room:
room_sync_worker(pkg)
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models,
"config_sync": synced, "deployment": deployment_summary(dep), "refresh_rate": sync_refresh_rate(pkg, dep)}
# --------------------------------------------------------------- Steam Frame controller models (XR_FB_render_model)
# The FrameBridge adapter (setting controller_models=1) serves these to games that ask the runtime for controller
# models.
# They are converted here, on the Frame, from the SteamVR render models the Frame already has (OBJ + PNG) into glTF
# binaries, so Valve's models never leave the device.
MODELS_CACHE = os.path.join(HOME, ".local/share/frameport/controller-models")
FRAME_MODEL_RE = re.compile(r"frame", re.I)
SKIP_COMPONENTS = ("status", "led", "scroll_wheel_touch")
def steamvr_roots():
roots = []
rt = openxr_runtime()
if rt and rt.get("path"):
roots.append(os.path.dirname(rt["path"]))
roots += [os.path.join(lib, "steamapps/common/SteamVR") for lib in steam_libraries()]
roots += ["/opt/steamvr", os.path.join(STEAM, "steamapps/common/SteamVR")]
out = []
for r in roots:
r = os.path.realpath(r)
if os.path.isdir(r) and r not in out:
out.append(r)
return out
def render_model_dirs(roots=None):
"""name -> directory of every SteamVR render model (a folder with .obj files)."""
found = {}
for root in roots if roots is not None else steamvr_roots():
for pattern in ("resources/rendermodels/*", "drivers/*/resources/rendermodels/*"):
for d in sorted(glob.glob(os.path.join(root, pattern))):
if os.path.isdir(d) and glob.glob(os.path.join(d, "*.obj")):
found.setdefault(os.path.basename(d), d)
return found
def model_side(name):
n = name.lower()
for side in ("left", "right"):
if side in n:
return side
for side in ("left", "right"): # e.g. controller_l / controller-r
if re.search(rf"(^|[_\-. ]){side[0]}([_\-. ]|$)", n):
return side
return None
def pick_controller_models(dirs, pattern=FRAME_MODEL_RE):
"""{'left': dir, 'right': dir} for the Steam Frame controllers (names matching `pattern`), or {}."""
best = {}
for name, d in dirs.items():
side = model_side(name)
if not side or not pattern.search(name):
continue
score = ("controller" in name.lower()) * 10 - len(name)
if side not in best or score > best[side][0]:
best[side] = (score, d)
return {s: d for s, (_, d) in best.items()} if len(best) == 2 else {}
def _rotation_xyz(deg):
"""3x3 rotation for SteamVR's rotate_xyz (degrees; applied about X, then Y, then Z)."""
import math
rx, ry, rz = (math.radians(float(v)) for v in (list(deg) + [0, 0, 0])[:3])
cx, sx, cy, sy, cz, sz = math.cos(rx), math.sin(rx), math.cos(ry), math.sin(ry), math.cos(rz), math.sin(rz)
X = [[1, 0, 0], [0, cx, -sx], [0, sx, cx]]
Y = [[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]]
Z = [[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]]
mul = lambda a, b: [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
return mul(Z, mul(Y, X))
def model_description(model_dir):
"""(obj files, grip transform or None) from the model's JSON (components) or the folder's .obj files."""
name = os.path.basename(model_dir)
desc = {}
for cand in (os.path.join(model_dir, name + ".json"), *sorted(glob.glob(os.path.join(model_dir, "*.json")))):
try:
desc = json.load(open(cand))
if isinstance(desc, dict) and "components" in desc:
break
except (OSError, ValueError):
desc = {}
objs, grip = [], None
for cname, comp in (desc.get("components") or {}).items():
if not isinstance(comp, dict):
continue
if cname == "openxr_grip" and isinstance(comp.get("component_local"), dict):
local = comp["component_local"]
grip = (list(local.get("origin") or [0, 0, 0]), _rotation_xyz(local.get("rotate_xyz") or [0, 0, 0]))
fn = comp.get("filename")
hidden = (comp.get("visibility") or {}).get("default") is False # e.g. status LEDs
if fn and not hidden and not any(s in cname.lower() for s in SKIP_COMPONENTS):
p = os.path.join(model_dir, fn)
if os.path.exists(p):
objs.append(p)
if not objs:
whole = os.path.join(model_dir, name + ".obj")
objs = [whole] if os.path.exists(whole) else sorted(glob.glob(os.path.join(model_dir, "*.obj")))
return objs, grip
def parse_mtl(path):
"""material name -> texture file (map_Kd)."""
out, cur = {}, None
try:
for line in open(path, errors="replace"):
parts = line.strip().split(None, 1)
if not parts:
continue
if parts[0] == "newmtl" and len(parts) > 1:
cur = parts[1].strip()
elif parts[0] == "map_Kd" and cur and len(parts) > 1:
out[cur] = os.path.join(os.path.dirname(path), parts[1].strip().split()[-1])
except OSError:
pass
return out
def parse_obj(path):
"""-> list of (texture path or None, positions, normals, uvs, triangles as (v, vt, vn) index triples)."""
pos, nrm, uv = [], [], []
groups = {} # texture -> list of corner triples
mats, tex = {}, None
for line in open(path, errors="replace"):
parts = line.split()
if not parts:
continue
tag = parts[0]
if tag == "v":
pos.append(tuple(float(x) for x in parts[1:4]))
elif tag == "vn":
nrm.append(tuple(float(x) for x in parts[1:4]))
elif tag == "vt":
uv.append((float(parts[1]), float(parts[2]) if len(parts) > 2 else 0.0))
elif tag == "mtllib":
mats.update(parse_mtl(os.path.join(os.path.dirname(path), " ".join(parts[1:]))))
elif tag == "usemtl":
tex = mats.get(" ".join(parts[1:]))
elif tag == "f":
corners = []
for c in parts[1:]:
idx = (c.split("/") + ["", ""])[:3]
ref = []
for i, n in zip(idx, (len(pos), len(uv), len(nrm)), strict=True):
ref.append(None if not i else (int(i) - 1 if int(i) > 0 else n + int(i)))
corners.append(tuple(ref))
for i in range(1, len(corners) - 1): # fan triangulation
groups.setdefault(tex, []).extend((corners[0], corners[i], corners[i + 1]))
return pos, nrm, uv, groups
def controller_glb(model_dir):
"""glTF binary (one mesh, one primitive per texture, PNG/JPEG textures, in the controller's OpenXR grip space
when the model defines openxr_grip) for a SteamVR render model folder."""
objs, grip = model_description(model_dir)
if not objs:
raise AgentError(f"no .obj files in {model_dir}")
# texture -> (positions, normals, uvs, indices), vertices de-duplicated per corner triple
prims = {}
for obj in objs:
pos, nrm, uv, groups = parse_obj(obj)
for tex, corners in groups.items():
if tex and os.path.splitext(tex)[1].lower() not in (".png", ".jpg", ".jpeg"):
tex = None
p = prims.setdefault(tex, {"pos": [], "nrm": [], "uv": [], "idx": [], "map": {}})
for c in corners:
key = (obj, c)
if key not in p["map"]:
p["map"][key] = len(p["pos"])
v = pos[c[0]]
n = nrm[c[2]] if c[2] is not None and c[2] < len(nrm) else (0.0, 0.0, 1.0)
t = uv[c[1]] if c[1] is not None and c[1] < len(uv) else (0.0, 0.0)
if grip: # raw device space -> grip space: R^T (v - origin)
o, r = grip
d = [v[k] - float(o[k]) for k in range(3)]
v = tuple(sum(r[k][j] * d[k] for k in range(3)) for j in range(3))
n = tuple(sum(r[k][j] * n[k] for k in range(3)) for j in range(3))
p["pos"].append(v)
p["nrm"].append(n)
p["uv"].append((t[0], 1.0 - t[1])) # OBJ origin bottom-left, glTF top-left
p["idx"].append(p["map"][key])
blob = bytearray()
views, accessors, images, textures, materials, primitives = [], [], [], [], [], []
def add_view(data, target=None):
while len(blob) % 4:
blob.append(0)
view = {"buffer": 0, "byteOffset": len(blob), "byteLength": len(data)}
if target:
view["target"] = target
blob.extend(data)
views.append(view)
return len(views) - 1
def add_accessor(values, width, ctype, kind, target, minmax=False):
fmt = {5126: "f", 5125: "I"}[ctype]
flat = [x for v in values for x in (v if width > 1 else (v,))]
acc = {"bufferView": add_view(struct.pack(f"<{len(flat)}{fmt}", *flat), target), "componentType": ctype,
"count": len(values), "type": kind}
if minmax:
acc["min"] = [min(v[k] for v in values) for k in range(width)]
acc["max"] = [max(v[k] for v in values) for k in range(width)]
accessors.append(acc)
return len(accessors) - 1
for tex, p in prims.items():
if not p["idx"]:
continue
attrs = {"POSITION": add_accessor(p["pos"], 3, 5126, "VEC3", 34962, minmax=True),
"NORMAL": add_accessor(p["nrm"], 3, 5126, "VEC3", 34962),
"TEXCOORD_0": add_accessor(p["uv"], 2, 5126, "VEC2", 34962)}
indices = add_accessor(p["idx"], 1, 5125, "SCALAR", 34963)
mat = {"pbrMetallicRoughness": {"metallicFactor": 0.0, "roughnessFactor": 0.8}}
if tex and os.path.exists(tex):
mime = "image/png" if tex.lower().endswith(".png") else "image/jpeg"
images.append({"bufferView": add_view(open(tex, "rb").read()), "mimeType": mime})
textures.append({"source": len(images) - 1})
mat["pbrMetallicRoughness"]["baseColorTexture"] = {"index": len(textures) - 1}
else:
mat["pbrMetallicRoughness"]["baseColorFactor"] = [0.1, 0.1, 0.1, 1.0]
materials.append(mat)
primitives.append({"attributes": attrs, "indices": indices, "material": len(materials) - 1})
if not primitives:
raise AgentError(f"no triangles in {model_dir}")
while len(blob) % 4:
blob.append(0)
gltf = {"asset": {"version": "2.0", "generator": "FramePort agent"}, "scene": 0, "scenes": [{"nodes": [0]}],
"nodes": [{"name": os.path.basename(model_dir), "mesh": 0}],
"meshes": [{"primitives": primitives}], "materials": materials, "accessors": accessors,
"bufferViews": views, "buffers": [{"byteLength": len(blob)}]}
if images:
gltf.update(images=images, textures=textures, samplers=[{}])
for t in textures:
t["sampler"] = 0
js = json.dumps(gltf, separators=(",", ":")).encode()
js += b" " * (-len(js) % 4)
total = 12 + 8 + len(js) + 8 + len(blob)
return (struct.pack("<III", 0x46546C67, 2, total) + struct.pack("<I4s", len(js), b"JSON") + js
+ struct.pack("<I4s", len(blob), b"BIN\x00") + bytes(blob))
def _tree_stamp(d):
h = hashlib.sha256()
for p in sorted(glob.glob(os.path.join(d, "*"))):
st = os.stat(p)
h.update(f"{os.path.basename(p)}:{st.st_size}:{int(st.st_mtime)}\n".encode())
return h.hexdigest()[:16]
def frame_controller_models():
"""Converted (cached) glb paths for the Frame controllers: {'left': path, 'right': path, 'sources': {...}}."""
picked = pick_controller_models(render_model_dirs())
if not picked:
raise AgentError("no Steam Frame controller render models found in SteamVR (looked in: "
+ ", ".join(steamvr_roots() or ["no SteamVR install"]) + ")")
os.makedirs(MODELS_CACHE, exist_ok=True)
out = {"sources": picked}
for side, d in picked.items():
cached = os.path.join(MODELS_CACHE, f"{os.path.basename(d)}-{_tree_stamp(d)}.glb")
if not os.path.exists(cached):
data = controller_glb(d)
with open(cached + ".tmp", "wb") as f:
f.write(data)
os.replace(cached + ".tmp", cached)
out[side] = cached
return out
def install_controller_models(files_dir, enabled):
"""Put (or remove) framebridge/controller_{left,right}.glb in a game's files dir. Never fails the install."""
target = os.path.join(files_dir, "framebridge")
if not enabled:
for side in ("left", "right"):
p = os.path.join(target, f"controller_{side}.glb")
if os.path.exists(p):
os.remove(p)
return None
try:
models = frame_controller_models()
except (AgentError, OSError, ValueError, IndexError) as e:
return {"ok": False, "error": str(e)}
os.makedirs(target, exist_ok=True)
for side in ("left", "right"):
shutil.copyfile(models[side], os.path.join(target, f"controller_{side}.glb"))
return {"ok": True, "sources": models["sources"]}
def cmd_controller_models(args):
"""Diagnostics: SteamVR roots, every render model found, and which ones are used as the Frame controllers."""
dirs = render_model_dirs()
result = {"roots": steamvr_roots(), "render_models": dirs, "picked": pick_controller_models(dirs)}
if args.get("convert"):
try:
result["converted"] = frame_controller_models()
except (AgentError, OSError, ValueError, IndexError) as e:
result["error"] = str(e)
return result
# ------------------------------------------------------------------------------------------ PC VR (Rift) under Proton
PCVR_TREES = ("game", "revive", "xrlayer", "helpers")
def tree_manifest(root):
out = {}
for r, _, files in os.walk(root):
for name in files:
if name.endswith(".part"):
continue
p = os.path.join(r, name)
rel = os.path.relpath(p, root)
if name.lower() == "crashreportclient.exe.disabled": # renamed by set_crash_reporter: same file
rel = rel[:-len(".disabled")]
out[rel] = os.path.getsize(p)
return out
def pcvr_pids(base):
"""Processes of a PC VR game (the Proton/Wine command lines contain its install folder)."""
p = run(["pgrep", "-f", re.escape(base.rstrip("/") + "/")])
return [x for x in p.stdout.split() if x != str(os.getpid())]
def cmd_prepare_pcvr(args):
"""Where to upload a Windows game + Revive, and what the Frame already has (unchanged files aren't re-sent)."""
pkg = check_pkg(args["package"])
title = args["title"]
anchor = os.path.join(ANCHORS, pkg)
dep = deployment(pkg)
base, free = new_base(args, pkg, dep)
appid = dep["appid"] if dep else shortcut_appid(f'"{anchor}/launch.sh"', title)
if dep and pcvr_pids(base):
raise AgentError(f"{title} is running on the Frame. Close it first.")
incoming = os.path.join(base, "incoming")
for t in PCVR_TREES:
os.makedirs(os.path.join(incoming, t), exist_ok=True)
# files already in place plus files uploaded by an interrupted install (still in incoming/): not sent again
existing = {t: {**tree_manifest(os.path.join(base, t)), **tree_manifest(os.path.join(incoming, t))}
for t in PCVR_TREES}
status = cmd_proton_status({"tool": args.get("tool")})
return {"package": pkg, "base": base, "anchor": anchor, "appid": appid, "incoming": incoming,
"installed": bool(dep), "existing": existing, "free_bytes": free,
"proton": status.get("ready"), "proton_suggested": status.get("suggested"), "openxr": status.get("openxr")}
LAUNCH_PROTON_SH = r"""#!/usr/bin/env bash
# Steam Frame launcher for {title} ({pkg}): Windows PC VR game under Proton ({tool}). Generated by FramePort.
set -euo pipefail
base={base_q}
[[ -d "$base/game" ]] || {{ echo "Game files missing at $base (storage not mounted?)" >&2; exit 1; }}
export SteamAppId={appid}
{steam_game_id}export STEAM_COMPAT_APP_ID={appid}
export STEAM_COMPAT_DATA_PATH="$base/compatdata"
export STEAM_COMPAT_CLIENT_INSTALL_PATH={steam_q}
export STEAM_COMPAT_INSTALL_PATH="$base/game"
export STEAM_COMPAT_LIBRARY_PATHS="$base"
export STEAM_COMPAT_SHADER_PATH="$base/shadercache"
export PROTON_LOG_DIR="$base"
{xr_layer}export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
# Wine needs the display session (gamescope's X/Wayland). Steam passes it; headless launches take it from Steam.
if [[ -z "${{DISPLAY:-}}" ]]; then
steam_pid=$(pgrep -x steam | head -n1 || true)
if [[ -n "$steam_pid" && -r "/proc/$steam_pid/environ" ]]; then
while IFS= read -r -d '' kv; do
case "$kv" in DISPLAY=*|WAYLAND_DISPLAY=*|GAMESCOPE_WAYLAND_DISPLAY=*|XAUTHORITY=*|XDG_SESSION_TYPE=*)
export "$kv";; esac
done < "/proc/$steam_pid/environ"
fi
fi
{extra_env}
mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_COMPAT_SHADER_PATH"
cd "$base/game"{workdir}
echo "FramePort: launching {pkg} with {tool}" >"$base/launch.log"
{plays_start}
exec {command} >>"$base/launch.log" 2>&1
"""
def windows_path(path):
"""Unix path as Wine sees it through drive Z: (the root filesystem)."""
return "Z:" + os.path.abspath(path).replace("/", "\\")
XR_LAYER = "XR_APILAYER_FRAMEPORT_timefix"
XR_LAYER_ENV = (f'# FramePort OpenXR layer: OpenXR 1.1 -> 1.0 fallback for the Frame runtime'
f' (+ timespec time emulation)\n'
f'export XR_API_LAYER_PATH="$base/xrlayer${{XR_API_LAYER_PATH:+:$XR_API_LAYER_PATH}}"\n'
f'export XR_ENABLE_API_LAYERS="{XR_LAYER}${{XR_ENABLE_API_LAYERS:+:$XR_ENABLE_API_LAYERS}}"\n')
XR_LAYER_MANIFEST = os.path.join(HOME, ".local/share/openxr/1/api_layers/explicit.d", XR_LAYER + ".json")
def install_xr_layer(base):
"""Proton's Steam Linux Runtime container drops XR_API_LAYER_PATH, so the layer is registered where the OpenXR
loader also looks for explicit layers ($XDG_DATA_HOME/openxr/1/api_layers/explicit.d, shared into the container
with the home dir). Explicit layers only load when XR_ENABLE_API_LAYERS names them (launch.sh does), so other apps
are unaffected. The library is a shared copy under the agent's folder."""
src = os.path.join(base, "xrlayer")
with open(os.path.join(src, XR_LAYER + ".json")) as f:
manifest = json.load(f)
lib_name = os.path.basename(manifest["api_layer"]["library_path"])
dest = os.path.join(AGENT_HOME, "xrlayer")
os.makedirs(dest, exist_ok=True)
tmp = os.path.join(dest, lib_name + ".tmp")
shutil.copyfile(os.path.join(src, lib_name), tmp)
os.replace(tmp, os.path.join(dest, lib_name))
manifest["api_layer"]["library_path"] = os.path.join(dest, lib_name)
os.makedirs(os.path.dirname(XR_LAYER_MANIFEST), exist_ok=True)
with open(XR_LAYER_MANIFEST + ".tmp", "w") as f:
json.dump(manifest, f, indent=4)
os.replace(XR_LAYER_MANIFEST + ".tmp", XR_LAYER_MANIFEST)
OCULUS_HMD_HELPER = "fp_oculushmd.exe"
def write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive, env, xr_layer=False, game_args=(),
oculus_hmd=False, vr=True):
exe = os.path.join(base, "game", exe_rel)
prefix = compat_command(tool["dir"])
injector = os.path.join(base, "revive", "ReviveInjector.exe")
if oculus_hmd:
# FramePort's helper provides the OculusHMDConnected event (Unreal's Oculus plugin checks for it) and runs the
# rest of its command line, staying alive until the game has exited
argv = prefix + [os.path.join(base, "helpers", OCULUS_HMD_HELPER)]
argv += [windows_path(injector), "/openxr", windows_path(exe)] if revive else [windows_path(exe)]
elif revive:
# ReviveInjector joins its arguments into one command line; /openxr = LibReviveXR (OpenXR -> wineopenxr)
argv = prefix + [injector, "/openxr", windows_path(exe)]
else:
argv = prefix + [exe]
argv += [a for a in game_args or () if re.fullmatch(r"[A-Za-z0-9_=.:/+-]+", a)] # e.g. -hmd=OpenXR, -vrmode OpenVR
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k))
workdir = os.path.dirname(exe_rel)
text = LAUNCH_PROTON_SH.format(
title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid, steam_q=shlex.quote(STEAM),
tool=tool["name"], extra_env=extra, workdir=("/" + shlex.quote(workdir)) if workdir else "",
# Proton sets up VR (vrclient, wineopenxr) only when SteamGameId is set: a flat Windows game goes without
steam_game_id=f"export SteamGameId={appid}\n" if vr else "",
xr_layer=XR_LAYER_ENV if xr_layer else "", plays_start=plays_lines(anchor)[0],
command=" ".join(shlex.quote(a) for a in argv))
path = os.path.join(anchor, "launch.sh")
with open(path + ".tmp", "w") as f:
f.write(text)
os.chmod(path + ".tmp", 0o755)
os.replace(path + ".tmp", path)
def set_crash_reporter(base, enabled):
"""Unreal's CrashReportClient.exe in the Frame copy of the game: renamed to .disabled so a crash just closes the
game (and back when enabled)."""
game = os.path.join(base, "game")
for root, _, files in os.walk(game):
for name in files:
low = name.lower()
p = os.path.join(root, name)
if not enabled and low == "crashreportclient.exe":
os.replace(p, p + ".disabled")
elif enabled and low == "crashreportclient.exe.disabled":
os.replace(p, p[:-len(".disabled")])
def set_libovr_redirect(base, exe_rel, enabled, bundled=False):
"""LoadLibrary redirect (pure runtime substitution): put LibOVRRT{64,32}_1.dll in the game's own DLL search dir as
a symlink to Revive's LibReviveXR runtime, so the game's Oculus SDK finds a runtime to load. The symlink keeps the
DLL in the revive/ folder, so its sibling dependencies still resolve. This does NOT touch the game's runtime
signature check — a build that verifies the Oculus signature of LibOVRRT will still reject Revive's (unsigned)
runtime; this only helps builds that don't verify it. We only ever create/remove our own symlink, never a real
DLL the game shipped. Removed when disabled."""
game = os.path.join(base, "game")
exe_dir = os.path.dirname(os.path.join(game, exe_rel))
# the runtime: FramePort's Revive (revive/LibReviveXR*), or with bundled=True the repack's own LibRevive*.dll
# next to the exe (a repack set up for SteamVR, whose Windows loader hook doesn't take effect under Proton)
runtimes = {bits: (os.path.join(exe_dir, f"LibRevive{bits}.dll") if bundled else
os.path.join(base, "revive", f"LibReviveXR{bits}.dll")) for bits in ("64", "32")}
# where the Oculus SDK looks for LibOVRRT: the game exe's dir (monolithic engines carry the shim in the exe) AND
# next to every OVRPlugin.dll (Unreal's shim searches its own module dir).
dirs = {os.path.dirname(os.path.join(game, exe_rel))}
for root, _, files in os.walk(game):
for n in files:
if n.lower() == "ovrplugin.dll":
dirs.add(root)
for d in dirs:
for bits, target in runtimes.items():
link = os.path.join(d, f"LibOVRRT{bits}_1.dll")
ours = os.path.islink(link) and os.path.basename(os.path.realpath(link)).lower().startswith("librevive")
if ours and os.path.realpath(link) != os.path.realpath(target):
os.remove(link) # switched between FramePort's and the bundled Revive
if enabled and os.path.isfile(target):
if os.path.islink(link) or not os.path.exists(link): # never clobber a real game-shipped DLL
if os.path.lexists(link):
os.remove(link)
os.symlink(target, link)
elif ours:
os.remove(link)
def cmd_finalize_pcvr(args):
"""Move uploaded game/Revive files into place, delete files the new version no longer has, write the Proton
launcher and deployment.json. The Proton prefix (saves) is kept."""
pkg = check_pkg(args["package"])
title = args["title"]
prep = cmd_prepare_pcvr({"package": pkg, "title": title, "dest": args.get("dest"), "tool": args.get("tool")})
base, anchor, appid, incoming = prep["base"], prep["anchor"], prep["appid"], prep["incoming"]
tool = prep["proton"]
if not tool:
raise AgentError("Proton isn't installed on the Frame yet (FramePort: Frame → Install Proton)")
exe_rel = os.path.normpath(args["exe"])
if exe_rel.startswith("..") or os.path.isabs(exe_rel):
raise AgentError(f"bad exe path {args['exe']!r}")
old = (deployment(pkg) or {}).get("files") or {}
manifests = args.get("manifests") or {}
moved = 0
for t in PCVR_TREES:
src_root, dst_root = os.path.join(incoming, t), os.path.join(base, t)
for rel in tree_manifest(src_root):
dst = os.path.join(dst_root, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
os.replace(os.path.join(src_root, rel), dst)
moved += 1
want = manifests.get(t)
if want is None:
continue
for rel in set(old.get(t) or {}) - set(want): # files of the previous version that are gone now
p = os.path.normpath(os.path.join(dst_root, rel))
if p.startswith(dst_root + os.sep) and os.path.isfile(p):
os.remove(p)
have = tree_manifest(dst_root)
bad = [k for k, v in want.items() if have.get(k) != v]
if bad:
raise AgentError(f"{len(bad)} {t} file(s) missing or incomplete, e.g. {bad[0]}")
if not os.path.isfile(os.path.join(base, "game", exe_rel)):
raise AgentError(f"game executable {exe_rel} missing after upload")
shutil.rmtree(incoming, ignore_errors=True)
os.makedirs(anchor, exist_ok=True)
revive = bool(args.get("revive", True))
if revive and not os.path.isfile(os.path.join(base, "revive", "ReviveInjector.exe")):
raise AgentError("Revive files missing")
xr_layer = bool(args.get("xr_layer")) and os.path.isfile(os.path.join(base, "xrlayer", XR_LAYER + ".json"))
if args.get("xr_layer") and not xr_layer:
raise AgentError("timefix layer files missing")
if xr_layer:
install_xr_layer(base)
oculus_hmd = bool(args.get("oculus_hmd"))
if oculus_hmd and not os.path.isfile(os.path.join(base, "helpers", OCULUS_HMD_HELPER)):
raise AgentError(f"{OCULUS_HMD_HELPER} missing")
set_crash_reporter(base, enabled=not args.get("no_crash_reporter"))
set_libovr_redirect(base, exe_rel, enabled=bool(args.get("libovr_redirect")), bundled=not revive)
vr = args.get("vr", True) is not False # False: a flat Windows game (no VR at all)
write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive, args.get("env"), xr_layer,
args.get("game_args") or [], oculus_hmd, vr=vr)
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
shutil.move(art_in, os.path.join(anchor, "artwork"))
dep = {"package": pkg, "kind": "pcvr", "appid": int(appid), "base": base, "title": title, "exe": exe_rel,
"tags": args.get("tags") or [],
"sha256": args.get("exe_sha256"), "revive": revive, "revive_version": args.get("revive_version"),
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd, "vr": vr,
"libovr_redirect": bool(args.get("libovr_redirect")),
"launcher": {"env": args.get("env") or {}, "game_args": args.get("game_args") or []},
"recipe": args.get("recipe"),
"installed_by": "frameport",
"files": {t: manifests.get(t) or {} for t in PCVR_TREES},
"agent_version": AGENT_VERSION, "time": time.time()}
with open(os.path.join(anchor, "deployment.json"), "w") as f:
json.dump(dep, f, indent=2)
overlay = finalize_overlay(pkg, args.get("overlay"))
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "proton": tool["name"], "overlay": overlay}
def finalize_overlay(pkg, overlay):
"""An install's overlay choice ({autostart, app, manifest_dir} = a SteamVR overlay app, None = not one):
register or unregister it with SteamVR. Returns register_vr_overlay's result, or None."""
if overlay:
return cmd_register_vr_overlay({"package": pkg, "autostart": bool(overlay.get("autostart")),
"app": overlay.get("app"), "manifest_dir": overlay.get("manifest_dir")})
if os.path.exists(os.path.join(ANCHORS, pkg, OVERLAY_MANIFEST)): # no longer one (the user turned it off)
cmd_unregister_vr_overlay({"package": pkg})
return None
# ------------------------------------------------------------------------------------------ Linux apps (arm64)
# Native arm64 Linux programs (AppImages, zip/tar builds) installed from FramePort (GitHub #31): files in
# <base>/app, AppImages extracted once (no FUSE in Steam's launch environment needed, faster start), a launcher with
# the same exit watchdog as the Quest launcher, and a Steam shortcut (VR flag for OpenXR apps).
LINUX_LAUNCH_SH = r"""#!/usr/bin/env bash
# Steam Frame launcher for {title} ({pkg}), a Linux app. Generated by FramePort.
set -uo pipefail
cd {run_dir_q} || {{ echo "App files missing at {run_dir_q}" >&2; exit 1; }}
export XDG_RUNTIME_DIR="${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}"
# Steam passes the display session (gamescope's X/Wayland); headless launches (launch tests) take it from Steam.
# Started from Desktop Mode's menu (FRAMEPORT_DESKTOP=1, GitHub #84) the desktop's own session is used.
if [[ -z "${{FRAMEPORT_DESKTOP:-}}" && -z "${{DISPLAY:-}}" ]]; then
steam_pid=$(pgrep -x steam | head -n1 || true)
if [[ -n "$steam_pid" && -r "/proc/$steam_pid/environ" ]]; then
while IFS= read -r -d '' kv; do
case "$kv" in DISPLAY=*|WAYLAND_DISPLAY=*|GAMESCOPE_WAYLAND_DISPLAY=*|XAUTHORITY=*|XDG_SESSION_TYPE=*)
export "$kv";; esac
done < "/proc/$steam_pid/environ"
fi
fi
{extra_env}parent=$PPID
# Desktop Mode's launcher exits right after starting this: no Steam parent to watch; SteamVR overlay apps
# (FRAMEPORT_OVERLAY) are started by SteamVR and run next to games
[[ -n "${{FRAMEPORT_DESKTOP:-}}${{FRAMEPORT_OVERLAY:-}}" ]] && parent=1
{exe_q} "$@" >{log_q} 2>&1 &
child=$!
trap 'kill -TERM $child 2>/dev/null' INT TERM
( while sleep 2 && kill -0 $child 2>/dev/null; do
if [[ $parent -gt 1 ]] && ! kill -0 $parent 2>/dev/null; then kill -TERM $child; fi; done ) &
wait $child
"""
def linux_run_target(base, exe_rel, appimage):
"""(working directory, program) for a Linux app: an extracted AppImage runs its AppRun."""
if appimage: # absolute paths: the agent finds the app's processes by its folder in their command lines
root = os.path.join(base, "app", "squashfs-root")
return root, os.path.join(root, "AppRun")
path = os.path.join(base, "app", exe_rel)
return os.path.dirname(path), path
SHARED_LIBRARY = re.compile(r"lib.+\.so(?:\.\d+)*")
def app_lib_dirs(root):
"""The folders of an app that hold shared libraries (lib*.so[.N]); Python extension modules don't count."""
out = set()
for dirpath, _dirs, files in os.walk(root):
if any(SHARED_LIBRARY.fullmatch(n) for n in files):
out.add(dirpath)
return sorted(out)
def missing_libraries(root, programs):
"""Shared libraries the app's main programs need (ldd follows their dependencies) that neither the Frame nor the
app itself has, with the app's own library folders on LD_LIBRARY_PATH as AppImages' AppRun sets it: e.g.
libwebkit2gtk-4.1 for Venera. Only the programs that start, not every bundled module (FramePort's own build
ships optional Python modules needing libjvm/libtcl that never load)."""
env = dict(os.environ, LD_LIBRARY_PATH=":".join(app_lib_dirs(root)))
missing = set()
for path in programs:
p = subprocess.run(["ldd", path], capture_output=True, text=True, env=env, timeout=30)
for line in p.stdout.splitlines():
if "=> not found" in line:
missing.add(line.split("=>")[0].strip())
return sorted(missing)
def appimage_programs(root):
"""The ELF programs an extracted AppImage starts: top-level and usr/bin executables (not libraries)."""
out = []
for d in (root, os.path.join(root, "usr", "bin")):
try:
names = sorted(os.listdir(d))
except OSError:
continue
for name in names:
path = os.path.join(d, name)
if ".so" in name or not os.path.isfile(path) or not os.access(path, os.X_OK):
continue
try:
with open(path, "rb") as f:
if f.read(4) == b"\x7fELF":
out.append(path)
except OSError:
pass
return out
def cmd_prepare_linux(args):
"""Where to upload a Linux app, and what the Frame already has (unchanged files aren't re-sent)."""
pkg = check_pkg(args["package"])
anchor = os.path.join(ANCHORS, pkg)
dep = deployment(pkg)
base, free = new_base(args, pkg, dep)
appid = dep["appid"] if dep else shortcut_appid(f'"{anchor}/launch.sh"', args["title"])
if dep and pcvr_pids(base):
raise AgentError(f"{args['title']} is running on the Frame. Close it first.")
incoming = os.path.join(base, "incoming")
os.makedirs(os.path.join(incoming, "app"), exist_ok=True)
existing = {"app": {**tree_manifest(os.path.join(base, "app")), **tree_manifest(os.path.join(incoming, "app"))}}
existing["app"] = {k: v for k, v in existing["app"].items() if not k.startswith("squashfs-root/")}
return {"package": pkg, "base": base, "anchor": anchor, "appid": appid, "incoming": incoming,
"installed": bool(dep), "existing": existing, "free_bytes": free}
def cmd_finalize_linux(args):
"""Move the uploaded app into place (files the new version lacks are deleted), extract an AppImage, check its
libraries, write the launcher and deployment.json."""
pkg = check_pkg(args["package"])
title = args["title"]
prep = cmd_prepare_linux({"package": pkg, "title": title, "dest": args.get("dest")})
base, anchor, appid, incoming = prep["base"], prep["anchor"], prep["appid"], prep["incoming"]
exe_rel = os.path.normpath(args["exe"])
if exe_rel.startswith("..") or os.path.isabs(exe_rel):
raise AgentError(f"bad program path {args['exe']!r}")
app = os.path.join(base, "app")
want = (args.get("manifests") or {}).get("app")
old = ((deployment(pkg) or {}).get("files") or {}).get("app") or {}
moved = 0
for rel in tree_manifest(os.path.join(incoming, "app")):
dst = os.path.join(app, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
os.replace(os.path.join(incoming, "app", rel), dst)
moved += 1
if want is not None:
for rel in set(old) - set(want):
p = os.path.normpath(os.path.join(app, rel))
if p.startswith(app + os.sep) and os.path.isfile(p):
os.remove(p)
have = tree_manifest(app)
bad = [k for k, v in want.items() if have.get(k) != v]
if bad:
raise AgentError(f"{len(bad)} app file(s) missing or incomplete, e.g. {bad[0]}")
exe = os.path.join(app, exe_rel)
if not os.path.isfile(exe):
raise AgentError(f"program {exe_rel} missing after upload")
shutil.rmtree(incoming, ignore_errors=True)
os.chmod(exe, os.stat(exe).st_mode | 0o111)
appimage = bool(args.get("appimage"))
x86 = bool(args.get("x86_64"))
prefix = []
if x86: # an x86_64 build: FEX runs it on SteamOS's x86 guest system (the chain Steam itself would use)
tool = pick_tool("linux_x86", linux_x86_tools())
if not tool or not tool["installed"] or not tool["require_installed"]:
raise AgentError("FEX (x86 translation for Linux apps) isn't installed on the Frame yet")
prefix = compat_command(tool["dir"])
if appimage:
shutil.rmtree(os.path.join(app, "squashfs-root"), ignore_errors=True)
# an x86_64 AppImage's own extractor is x86 code too: it runs through FEX like the app
p = subprocess.run(prefix + [exe, "--appimage-extract"], cwd=app, capture_output=True, text=True,
timeout=600)
if not os.path.isfile(os.path.join(app, "squashfs-root", "AppRun")):
raise AgentError(f"couldn't unpack the AppImage: {(p.stderr or p.stdout).strip()[-300:]}")
run_dir, program = linux_run_target(base, exe_rel, appimage)
for name in args.get("executables") or []: # other programs the app starts (archives may lose the x bit)
q = os.path.normpath(os.path.join(app, name))
if q.startswith(app + os.sep) and os.path.isfile(q):
os.chmod(q, os.stat(q).st_mode | 0o111)
# ldd can't read x86_64 programs here: their libraries come from FEX's x86 root (/usr/share/guestos/fex-mesa)
missing = [] if x86 else missing_libraries(app, appimage_programs(run_dir) if appimage else [exe])
os.makedirs(anchor, exist_ok=True)
env = dict(args.get("env") or {})
if args.get("overlay"):
env["FRAMEPORT_OVERLAY"] = "1"
write_linux_launcher(anchor, base, pkg, title, exe_rel, appimage, prefix, env)
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
shutil.move(art_in, os.path.join(anchor, "artwork"))
vr = bool(args.get("openxr"))
dep = {"package": pkg, "kind": "linux", "appid": int(appid), "base": base, "title": title, "exe": exe_rel,
"appimage": appimage, "vr": vr, "x86_64": x86, "tags": args.get("tags") or [],
"sha256": args.get("exe_sha256"),
"missing_libraries": missing, "recipe": args.get("recipe"), "installed_by": "frameport",
"launcher": {"env": args.get("env") or {}}, "desktop_entry": args.get("desktop_entry", True) is not False,
"desktop_icon": args.get("desktop_icon", True) is not False, "files": {"app": want or {}},
"agent_version": AGENT_VERSION, "time": time.time()}
with open(os.path.join(anchor, "deployment.json"), "w") as f:
json.dump(dep, f, indent=2)
try: # the app's own icon (GitHub #99), found again for every install: a new version may bring another
own_icon = ensure_app_icon(dep, anchor, refresh=True)["icon"]
except OSError:
own_icon = None
desktop = None
try:
if dep["desktop_entry"]:
desktop = write_desktop_entry(pkg)
else:
remove_desktop_entries(pkg)
except OSError:
pass
overlay = finalize_overlay(pkg, args.get("overlay"))
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "missing_libraries": missing,
"desktop_entry": desktop, "app_icon": app_icon_result(own_icon), "overlay": overlay}
def write_linux_launcher(anchor, base, pkg, title, exe_rel, appimage, prefix, env):
"""launch.sh of a Linux app; prefix = FEX's command for x86_64 builds ([] for arm64)."""
run_dir, program = linux_run_target(base, exe_rel, appimage)
command = list(prefix) + [program]
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
if not appimage and not prefix:
# an unpacked archive's own libraries (GitHub #473: Plezy's lib/libselinux.so.1, needed by its bundled GTK);
# AppImages' AppRun sets this itself, and missing_libraries checks with the same folders
dirs = app_lib_dirs(os.path.join(base, "app"))
if dirs:
extra = (f"export LD_LIBRARY_PATH={shlex.quote(':'.join(dirs))}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n"
+ extra)
if prefix: # fex-compat-tool exits ("No compat data path?") without it; FEX keeps its config in <it>/fex-emu
data = shlex.quote(os.path.join(base, "compatdata"))
extra = f"mkdir -p {data}\nexport STEAM_COMPAT_DATA_PATH={data}\n" + extra
text = LINUX_LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, run_dir_q=shlex.quote(run_dir),
exe_q=" ".join(shlex.quote(a) for a in command),
log_q=shlex.quote(os.path.join(base, "launch.log")),
extra_env=extra)
path = os.path.join(anchor, "launch.sh")
with open(path + ".tmp", "w") as f:
f.write(text)
os.chmod(path + ".tmp", 0o755)
os.replace(path + ".tmp", path)
# ------------------------------------------------------------------------------------------ Linux apps' own icons
# The icon an AppImage or app folder brings (GitHub #99): its .DirIcon, else the Icon= of its .desktop file, looked up
# next to it, in usr/share/icons/hicolor/<size>/apps and usr/share/pixmaps. Copied to <anchor>/artwork/app-icon.<ext>
# and used for the Desktop Mode entry and the Steam shortcut unless the user chose an icon on the PC: the PC writes
# what the art set's icon is into artwork/.icon-source ("custom" = the user's pick or store art, "app" = the app's own,
# "generated" = FramePort's placeholder). Never reads outside the app's folder (symlinks are resolved and checked).
APP_ICON = "app-icon"
ICON_SOURCE = ".icon-source"
APP_ICON_MAX = 4 << 20
APP_ICON_SEND = 1 << 20 # the PNG goes back to the PC (library artwork) up to this size
ICON_EXTS = (".png", ".svg")
def _real_file_in(root, path):
"""The real path of `path` when it is a file inside `root` (symlinks resolved), else None."""
real_root = os.path.realpath(root)
real = os.path.realpath(path)
if real.startswith(real_root + os.sep) and os.path.isfile(real):
return real
return None
def icon_format(path):
"""("png", width) or ("svg", 0) by content, None for anything else (xpm, ico, unreadable)."""
try:
with open(path, "rb") as f:
head = f.read(1024)
except OSError:
return None
if head[:8] == b"\x89PNG\r\n\x1a\n" and len(head) >= 24:
return "png", struct.unpack(">I", head[16:20])[0]
if b"<svg" in head:
return "svg", 0
return None
def desktop_fields(path):
"""The [Desktop Entry] group of a .desktop file as {key: value} (localised keys left out)."""
out, group = {}, None
try:
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if line.startswith("["):
group = line
elif group == "[Desktop Entry]" and "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
if "[" not in k:
out.setdefault(k.strip(), v.strip())
except OSError:
pass
return out
def app_desktop_file(root, appimage):
"""The app's own .desktop file: an AppImage's top-level one, else the first within three folder levels."""
if appimage:
found = sorted(glob.glob(os.path.join(glob.escape(root), "*.desktop")))
else:
found = []
for dirpath, dirs, files in os.walk(root):
depth = 0 if dirpath == root else os.path.relpath(dirpath, root).count(os.sep) + 1
dirs[:] = sorted(dirs) if depth < 3 else []
found += [os.path.join(dirpath, n) for n in sorted(files) if n.endswith(".desktop")]
for path in found:
if _real_file_in(root, path):
return path
return None
def _icon_candidates(base, name):
"""Files that may be the icon called `name` (an Icon= value) under the folder `base`."""
if not name or os.path.isabs(name) or ".." in name.split("/"):
return []
stem = name[:-4] if name.lower().endswith(ICON_EXTS + (".xpm",)) else name
out = [os.path.join(base, name)] if "/" in name or name != stem else []
if "/" in name:
return out
esc = glob.escape
for share in (os.path.join(base, "usr", "share"), os.path.join(base, "share")):
for ext in ICON_EXTS:
out += glob.glob(os.path.join(esc(share), "icons", "hicolor", "*", "apps", esc(stem) + ext))
out.append(os.path.join(share, "pixmaps", stem + ext))
out += [os.path.join(base, stem + ext) for ext in ICON_EXTS]
return out
def find_app_icon(root, appimage):
"""{"icon": real path or None, "wmclass": the .desktop file's StartupWMClass or None} of an app's folder (an
AppImage's squashfs-root). The icon: the biggest PNG when it is at least 128 px, else an SVG, else the biggest
PNG; .DirIcon (an AppImage's own icon, often a symlink) counts like the .desktop file's Icon=."""
if not os.path.isdir(root):
return {"icon": None, "wmclass": None}
desktop = app_desktop_file(root, appimage)
fields = desktop_fields(desktop) if desktop else {}
paths = [os.path.join(root, ".DirIcon")] if appimage else []
bases = [root]
if desktop: # a folder app's .desktop file may sit in a subfolder with the icon next to it
d = os.path.dirname(desktop)
while d.startswith(root + os.sep):
bases.append(d)
d = os.path.dirname(d)
for base in bases:
paths += _icon_candidates(base, fields.get("Icon", ""))
pngs, svgs, seen = [], [], set()
for p in paths:
real = _real_file_in(root, p)
if not real or real in seen:
continue
seen.add(real)
try:
if os.path.getsize(real) > APP_ICON_MAX:
continue
except OSError:
continue
fmt = icon_format(real)
if fmt and fmt[0] == "png":
pngs.append((fmt[1], real))
elif fmt:
svgs.append(real)
best = max(pngs, default=None, key=lambda t: t[0])
icon = best[1] if best and best[0] >= 128 else svgs[0] if svgs else best[1] if best else None
return {"icon": icon, "wmclass": fields.get("StartupWMClass") or None}
def linux_app_root(dep):
app = os.path.join(dep["base"], "app")
return os.path.join(app, "squashfs-root") if dep.get("appimage") else app
def ensure_app_icon(dep, anchor, refresh=False):
"""The Linux app's own icon in <anchor>/artwork/app-icon.<ext> (copied from its files when missing, or always with
`refresh`, e.g. after an install), plus its StartupWMClass: {"icon": path or None, "wmclass": ...}."""
found = find_app_icon(linux_app_root(dep), dep.get("appimage"))
art = os.path.join(anchor, "artwork")
have = sorted(glob.glob(os.path.join(glob.escape(art), APP_ICON + ".*")))
icon = have[0] if have else None
if found["icon"] and (refresh or not icon):
dst = os.path.join(art, f"{APP_ICON}.{icon_format(found['icon'])[0]}")
os.makedirs(art, exist_ok=True)
for p in have:
if p != dst:
os.remove(p)
shutil.copyfile(found["icon"], dst + ".tmp")
os.replace(dst + ".tmp", dst)
icon = dst
elif refresh and not found["icon"]:
for p in have:
os.remove(p)
icon = None
return {"icon": icon, "wmclass": found["wmclass"]}
def icon_source(anchor):
"""What the art set's icon is, as the PC wrote it ("custom", "app", "generated"), or None (older PC app)."""
try:
with open(os.path.join(anchor, "artwork", ICON_SOURCE), encoding="utf-8") as f:
return f.read().strip() or None
except OSError:
return None
def app_icon_for(dep, anchor, steam=False):
"""(icon file, StartupWMClass) for a game's Desktop Mode entry / Steam shortcut: the user's chosen icon (PC:
"custom") > a Linux app's own icon > the art set's icon (FramePort's placeholder). Steam gets PNGs only."""
art_icon = next(iter(sorted(glob.glob(os.path.join(glob.escape(anchor), "artwork", "icon.*")))), "")
if dep.get("kind") != "linux" or not dep.get("base"):
return art_icon, None
try:
own = ensure_app_icon(dep, anchor)
except OSError:
return art_icon, None
if icon_source(anchor) == "custom" and art_icon:
return art_icon, own["wmclass"]
if own["icon"] and (not steam or own["icon"].endswith(".png")):
return own["icon"], own["wmclass"]
return art_icon, own["wmclass"]
def app_icon_result(icon):
"""finalize_linux's report of the app's own icon; a PNG comes back (base64) for the PC's library artwork."""
if not icon:
return None
out = {"file": os.path.basename(icon), "size": os.path.getsize(icon)}
if icon.endswith(".png") and out["size"] <= APP_ICON_SEND:
with open(icon, "rb") as f:
out["png"] = base64.b64encode(f.read()).decode("ascii")
return out
# ------------------------------------------------------------------------------------------ Desktop Mode entries
# Linux apps also get an entry in Desktop Mode's application menu and an icon on its desktop (GitHub #84: some apps
# work better with the desktop's mouse and keyboard than in Gaming Mode, where Steam Input owns the controllers).
DESKTOP_APPS = os.path.join(HOME, ".local/share/applications")
DESKTOP_DIR = os.path.join(HOME, "Desktop")
DESKTOP_KEY = "X-FramePort-Package"
GAME_TAGS = {"game", "games", "action", "adventure", "arcade", "casual", "fighting", "platformer", "puzzle", "racing",
"rhythm", "rpg", "role playing", "shooter", "simulation", "sports", "strategy", "survival", "horror"}
def desktop_value(text):
"""A Desktop Entry string value: one line, backslashes escaped."""
return re.sub(r"[\r\n\t]", " ", str(text).replace("\\", "\\\\"))
def desktop_exec_arg(arg):
"""One Exec argument (Desktop Entry spec): in double quotes with \\ " ` $ backslash-escaped and % doubled, then
escaped once more as a string value."""
quoted = '"' + re.sub(r'([\\"`$])', r"\\\1", str(arg)).replace("%", "%%") + '"'
return desktop_value(quoted)
def desktop_file_name(pkg):
slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", pkg[len("linux."):] if pkg.startswith("linux.") else pkg)
return f"frameport-{slug.strip('-.') or 'app'}.desktop"
def desktop_entry_text(dep, anchor):
icon, wmclass = app_icon_for(dep, anchor)
tags = {str(t).lower() for t in dep.get("tags") or []}
category = "Game;" if tags & GAME_TAGS else "Utility;"
lines = ["[Desktop Entry]", "Type=Application", f"Name={desktop_value(dep.get('title') or dep['package'])}",
"Comment=Installed by FramePort",
f"Exec=env FRAMEPORT_DESKTOP=1 {desktop_exec_arg(os.path.join(anchor, 'launch.sh'))}",
f"Path={desktop_value(anchor)}", "Terminal=false", f"Categories={category}"]
if icon:
lines.append(f"Icon={desktop_value(icon)}")
if wmclass: # KDE's task bar groups the app's window under this entry (and shows its icon)
lines.append(f"StartupWMClass={desktop_value(wmclass)}")
lines.append(f"{DESKTOP_KEY}={dep['package']}")
return "\n".join(lines) + "\n"
def _write_if_changed(path, text, mode):
try:
with open(path, encoding="utf-8") as f:
if f.read() == text and os.stat(path).st_mode & 0o777 == mode:
return False
except OSError:
pass
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path + ".tmp", "w", encoding="utf-8") as f:
f.write(text)
os.chmod(path + ".tmp", mode)
os.replace(path + ".tmp", path)
return True
def desktop_entries(pkg=None, folder=None):
"""FramePort's .desktop files (of one app, or all) in the menu and desktop folders: {path: package}."""
out = {}
for d in [folder] if folder else (DESKTOP_APPS, DESKTOP_DIR):
for path in sorted(glob.glob(os.path.join(d, "frameport-*.desktop"))):
try:
with open(path, encoding="utf-8", errors="replace") as f:
m = re.search(rf"^{DESKTOP_KEY}=(.+)$", f.read(), re.M)
except OSError:
continue
if m and (pkg is None or m[1].strip() == pkg):
out[path] = m[1].strip()
return out
def write_desktop_entry(pkg, refresh=False):
"""The app's menu entry, plus a copy on ~/Desktop when that folder exists and deployment.json's "desktop_icon"
isn't off (Plasma starts executable .desktop files there without asking). `refresh` (every connection) only
updates a desktop copy that is there: one the user deleted stays deleted (GitHub #212). Returns {"menu": path,
"desktop": path or None, "changed": bool}."""
anchor = os.path.join(ANCHORS, pkg)
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
text = desktop_entry_text(dep, anchor)
name = desktop_file_name(pkg)
for path in desktop_entries(pkg): # the same app's entry under an older file name
if os.path.basename(path) != name:
os.remove(path)
menu = os.path.join(DESKTOP_APPS, name)
changed = _write_if_changed(menu, text, 0o755)
desk = os.path.join(DESKTOP_DIR, name)
if dep.get("desktop_icon", True) is False:
if os.path.exists(desk):
os.remove(desk)
changed = True
desk = None
elif os.path.isdir(DESKTOP_DIR) and not (refresh and not os.path.exists(desk)):
changed = _write_if_changed(desk, text, 0o755) or changed
else:
desk = None
return {"menu": menu, "desktop": desk, "changed": changed}
def remove_desktop_entries(pkg=None):
removed = []
for path in desktop_entries(pkg):
try:
os.remove(path)
removed.append(path)
except OSError:
pass
return removed
def refresh_desktop_entries():
"""Entries for every installed Linux app that wants one (older installs, changed art or titles); entries of apps
that are gone or switched off are removed. Returns the packages whose entries changed."""
changed, wanted = [], set()
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, "*/deployment.json"))):
try:
with open(dep_path) as f:
dep = json.load(f)
except (OSError, ValueError):
continue
if not isinstance(dep, dict) or dep.get("kind") != "linux" or not dep.get("package") \
or dep.get("desktop_entry", True) is False:
continue
wanted.add(dep["package"])
try:
if write_desktop_entry(dep["package"], refresh=True)["changed"]:
changed.append(dep["package"])
except (OSError, AgentError):
pass
for path, pkg in desktop_entries().items():
if pkg not in wanted:
os.remove(path)
changed.append(pkg)
return sorted(set(changed))
def cmd_desktop_entry(args):
"""Turn a Linux app's Desktop Mode entry on or off, and with `icon` its copy on the desktop (agent v83; deployment
.json keeps both choices)."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep or dep.get("kind") != "linux":
raise AgentError(f"{pkg} is not an installed Linux app")
on = bool(args.get("enabled", True))
path = os.path.join(ANCHORS, pkg, "deployment.json")
with open(path) as f:
raw = json.load(f)
raw["desktop_entry"] = on
if args.get("icon") is not None:
raw["desktop_icon"] = bool(args["icon"])
with open(path + ".tmp", "w") as f:
json.dump(raw, f, indent=2)
os.replace(path + ".tmp", path)
if on:
return {"enabled": True, **write_desktop_entry(pkg)}
return {"enabled": False, "removed": remove_desktop_entries(pkg)}
def cmd_usb_link(args):
"""What the Frame's USB network link (usb0) looks like, for "connect with a USB cable" (read only): its address
and state, the USB gadget functions behind it (ncm/ecm/rndis: decides which PCs need a driver), the USB speed,
whether the Frame gives the PC an address (NetworkManager "shared" = DHCP server, dnsmasq) and whether sshd
answers on it."""
def sh(cmd): # a missing tool (nmcli, ss) leaves its field empty instead of failing the whole probe
try:
return run(cmd)
except (OSError, subprocess.SubprocessError):
return SimpleNamespace(returncode=1, stdout="", stderr="")
def read(path):
try:
return open(path).read().strip()
except OSError:
return None
out = {"present": os.path.isdir("/sys/class/net/usb0")}
out["operstate"] = read("/sys/class/net/usb0/operstate")
out["carrier"] = read("/sys/class/net/usb0/carrier")
out["mac"] = read("/sys/class/net/usb0/address")
p = sh(["ip", "-j", "addr", "show", "usb0"])
try:
out["addresses"] = [f"{a['local']}/{a['prefixlen']}" for i in json.loads(p.stdout or "[]")
for a in i.get("addr_info", [])]
except ValueError:
out["addresses"] = []
functions = []
for g in glob.glob("/sys/kernel/config/usb_gadget/*"):
functions += [f"{os.path.basename(g)}:{os.path.basename(f)}" for f in glob.glob(os.path.join(g, "functions/*"))]
out.setdefault("udc", read(os.path.join(g, "UDC")))
out.setdefault("gadget_ids", f"{read(os.path.join(g, 'idVendor'))}:{read(os.path.join(g, 'idProduct'))}")
out["functions"] = functions
out["speed"] = {os.path.basename(u): read(os.path.join(u, "current_speed")) for u in glob.glob("/sys/class/udc/*")}
nm = sh(["nmcli", "-t", "-f", "DEVICE,STATE,CONNECTION", "device"])
line = next((ln for ln in nm.stdout.splitlines() if ln.startswith("usb0:")), "")
out["nm"] = line
conn = line.split(":", 2)[2] if line.count(":") >= 2 else ""
if conn:
out["nm_ipv4_method"] = sh(["nmcli", "-g", "ipv4.method", "connection", "show", conn]).stdout.strip()
out["dhcp_servers"] = [ln for ln in sh(["pgrep", "-a", "dnsmasq|udhcpd|kea|dhcpd"]).stdout.splitlines()][:5]
out["leases"] = sorted(glob.glob("/var/lib/NetworkManager/dnsmasq-usb0*.leases") +
glob.glob("/var/lib/misc/dnsmasq*.leases"))
neigh = sh(["ip", "-j", "neigh", "show", "dev", "usb0"])
try:
out["neighbours"] = [n.get("dst") for n in json.loads(neigh.stdout or "[]")]
except ValueError:
out["neighbours"] = []
out["sshd_listening"] = ":22 " in sh(["ss", "-ltn"]).stdout
out["devkit_mode"] = os.path.exists("/etc/steamos-devkit-enabled")
return out
POWER_ACTIONS = {"sleep": "suspend", "restart": "reboot", "shutdown": "poweroff"}
def cmd_power(args):
"""Put the Frame to sleep, restart or shut it down (the app's power button). logind only allows these from the
user's own units ("yes"), not an SSH session ("challenge" = a password prompt), so a transient user timer runs
systemctl a few seconds later: this command answers first. A running game is refused unless force."""
action = args.get("action")
if action not in POWER_ACTIONS:
raise AgentError(f"unknown power action {action!r}")
if game_running() and not args.get("force"):
raise AgentError("a game is running on the Frame")
delay = max(2, min(int(args.get("delay", 3)), 60))
p = run(["systemd-run", "--user", "--collect", "--quiet", f"--unit=frameport-power-{int(time.time())}",
f"--on-active={delay}", "systemctl", POWER_ACTIONS[action]])
if p.returncode:
raise AgentError(f"couldn't {action}: {(p.stderr or p.stdout).strip()[-300:]}")
return {"action": action, "in_seconds": delay}
AWAKE_UNIT = "frameport-awake"
def cmd_keep_awake(args):
"""Keep the Frame from going idle/asleep while FramePort installs games (on=False ends it). An inhibitor lock held
by a `sleep` in its own user unit, so it outlives this SSH command and ends by itself after `minutes` if FramePort
disappears. Blocking sleep needs a local session (polkit inhibit-block-sleep: auth_admin_keep for others), so
when logind refuses it, idle alone is blocked (allowed for any user)."""
run(["systemctl", "--user", "stop", f"{AWAKE_UNIT}.service"])
run(["systemctl", "--user", "reset-failed", f"{AWAKE_UNIT}.service"])
if not args.get("on", True):
return {"awake": False}
seconds = int(min(max(float(args.get("minutes", 60)), 1), 240) * 60)
for what in ("idle:sleep", "idle"):
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={AWAKE_UNIT}", "systemd-inhibit",
f"--what={what}", "--who=FramePort", "--why=Installing games", "--mode=block", "sleep", str(seconds)])
time.sleep(0.7) # a refused lock ends the unit right away
if run(["systemctl", "--user", "is-active", f"{AWAKE_UNIT}.service"]).stdout.strip() == "active":
return {"awake": True, "what": what, "seconds": seconds}
run(["systemctl", "--user", "reset-failed", f"{AWAKE_UNIT}.service"])
return {"awake": False}
FOVEATION_ENV_KEYS = {"FDM_DEBUG", "ENABLE_VULKAN_FDM_INJECTION_LAYER",
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "VK_INSTANCE_LAYERS", "FOVE_INNER", "FOVE_OUTER", "FOVE_LEVEL",
"LEPTON_ENV_FRAMEPORT_FOVEATION_TEST", "LEPTON_ENV_FRAMEPORT_CLIMB_STEREO_OFFSETS",
"LEPTON_ENV_FRAMEPORT_VRAPI_FDM_ISOLATE_MONO", "LEPTON_ENV_FRAMEPORT_VRAPI_KEEP_FOCUSED_IMAGE"}
def update_foveation_launcher(pkg, dep, env):
if not isinstance(env, dict) or set(env) - FOVEATION_ENV_KEYS or any(
v is not None and (not isinstance(v, str) or "\n" in v or "\r" in v) for v in env.values()):
raise AgentError("invalid foveation environment")
path = os.path.join(ANCHORS, pkg, "launch.sh")
text = open(path).read()
# Replace only managed exports; preserve all unrelated launcher customizations.
text = "".join(line for line in text.splitlines(keepends=True)
if not any(line.startswith("export " + key + "=") for key in env))
lines = "".join(f"export {key}={shlex.quote(value)}\n" for key, value in env.items() if value is not None)
text = text.replace("\n", "\n" + lines, 1)
with open(path + ".tmp", "w") as f:
f.write(text)
os.chmod(path + ".tmp", 0o755)
os.replace(path + ".tmp", path)
dep["foveation_env"] = env
write_deployment(pkg, dep)
def cmd_set_settings(args):
pkg = check_pkg(args["package"])
dep = deployment(pkg) or {}
if not dep:
raise AgentError(f"{pkg} is not installed")
base = dep["base"]
conf = os.path.join(base, "settings.conf")
current = {}
if os.path.exists(conf):
for line in open(conf):
k, _, v = line.strip().partition("=")
if k:
current[k] = v
for k, v in (args.get("settings") or {}).items():
if not re.fullmatch(r"[a-z_]+", k) or not re.fullmatch(r"-?(?:[0-9]+\.?[0-9]*|\.[0-9]+)", str(v)):
raise AgentError(f"bad setting {k}={v}") # checked before anything is written
current[k] = str(v)
if "foveation_env" in args:
update_foveation_launcher(pkg, dep, args["foveation_env"])
text = "".join(f"{k}={v}\n" for k, v in current.items())
with open(conf, "w") as f:
f.write(text)
files_dir = data_files_dir(base, pkg)
os.makedirs(files_dir, exist_ok=True)
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
f.write(text)
room_setting_changed(pkg, dep, args.get("settings") or {})
models = install_controller_models(files_dir, current.get("controller_models", "0") not in ("0", "0.0"))
synced = sync_game_config(pkg, dep)
if isinstance(dep.get("configuration"), dict):
applied = dep["configuration"]
settings = applied.get("settings") or {}
for key, value in (args.get("settings") or {}).items():
if key in settings:
number = float(value)
# keep the recorded type (the PC's fingerprint uses it); an int setting given 1.5 stays 1.5
settings[key] = number if isinstance(settings[key], float) or not number.is_integer() \
else int(number)
dep["configuration_fp"] = hashlib.sha256(json.dumps(applied, sort_keys=True).encode()).hexdigest()[:32]
write_deployment(pkg, dep)
return {"settings": current, "foveation_env": dep.get("foveation_env", {}),
"controller_models": models, "config_sync": synced,
"refresh_rate": sync_refresh_rate(pkg, dep)}
TITLE_MAX = 128
def clean_title(title):
"""A game's name for its Steam shortcut: one line, no control characters, at most TITLE_MAX characters."""
text = " ".join("".join(c if c.isprintable() else " " for c in str(title or "")).split())
if not text:
raise AgentError("the new name is empty")
return text[:TITLE_MAX]
def cmd_record_artwork(args):
"""Record artwork only after verifying the files actually uploaded to the game's anchor."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
manifest = args.get("manifest") or {}
root = os.path.join(ANCHORS, pkg, "artwork")
for name, digest in manifest.items():
if (not re.fullmatch(r"(?:portrait|landscape|hero|logo|icon)\.[a-zA-Z0-9]+|\.icon-source", name)
or not re.fullmatch(r"[a-f0-9]{64}", str(digest))):
raise AgentError("invalid artwork manifest")
path = os.path.join(root, name)
if not os.path.isfile(path) or sha256_file(path) != digest:
raise AgentError(f"artwork missing or checksum mismatch: {name}")
dep["artwork_manifest"] = manifest
write_deployment(pkg, dep)
return {"verified": True}
def cmd_rename(args):
"""Rename an installed game (agent v77): deployment.json's title is what its Steam shortcut shows. The shortcut
keeps its appid (upsert_shortcut matches by Exe, deployment.json keeps the appid), so Play, its grid art, launch
tests and the game's container stay the same. shortcuts (default on) rewrites the library entry like an install
does (Steam restarts once, after a running game or Desktop Mode). A devkit entry is named by its id: unchanged."""
pkg = check_pkg(args["package"])
title = clean_title(args.get("title"))
dep = deployment(pkg)
if not dep or not dep.get("appid"):
raise AgentError(f"{pkg} is not installed")
old = dep.get("title") or pkg
out = {"package": pkg, "title": title, "old": old, "appid": dep["appid"], "renamed": old != title}
if out["renamed"]:
dep["title"] = title
write_deployment(pkg, dep)
if (dep.get("overlay") or {}).get("registered"): # its SteamVR manifest shows the name too
try:
register_overlay(pkg, bool(dep["overlay"].get("autostart")))
except Exception as exc: # noqa: BLE001 - the shortcut matters more
out["overlay_error"] = str(exc)
out["shortcuts"] = cmd_shortcuts({"packages": [pkg], "restart": True}) \
if args.get("shortcuts", True) and out["renamed"] else {"started": False}
return out
def cmd_uninstall(args):
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
return {"removed": False}
base = dep["base"]
pcvr = dep.get("kind") == "pcvr"
linux = dep.get("kind") == "linux"
if (pcvr_pids(base) if pcvr or linux else container_running(dep["appid"])):
raise AgentError("the game is running")
if is_overlay(dep) or os.path.exists(os.path.join(ANCHORS, pkg, OVERLAY_MANIFEST)):
unregister_overlay(pkg) # SteamVR forgets the overlay app (before its manifest's folder goes)
keep_data = args.get("keep_data", True)
names = ("game", "revive", "xrlayer", "shadercache", "incoming", "incoming-artwork") if pcvr else \
("app", "incoming", "incoming-artwork", "launch.log", "session.log") if linux else \
("lepton-app", "lepton-shaders", "incoming", "previous-game.apk")
for name in names:
p = os.path.join(base, name)
if os.path.isdir(p):
remove_tree(p)
elif os.path.exists(p):
os.remove(p)
if not keep_data:
remove_tree(base)
remove_empty_drive_dir(base)
if linux:
remove_desktop_entries(pkg)
remove_lepton_path(pkg)
anchor = os.path.join(ANCHORS, pkg)
removed_sc = False
if args.get("remove_shortcut") and steam_users():
# Steam keeps its own copy of shortcuts.vdf and writes it back: change it only with Steam closed (worker)
# "later" queues the removal and "off" does nothing now: neither starts a worker (no "started")
removed_sc = bool(cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"',
"appid": dep.get("appid")}]}).get("started"))
if args.get("remove_shortcut"):
try:
devkit_unregister(pkg, steam_running=run(["pgrep", "-x", "steam"]).returncode == 0)
except Exception: # noqa: BLE001 (the files go anyway at the next purge)
pass
if not keep_data or base != anchor:
remove_tree(anchor)
else: # saves live next to the launcher (Quest games): keep them, drop what marks the game as installed
for name in ("deployment.json", "launch.sh", "artwork", "launch.log", "launch-test.log", "session.log"):
p = os.path.join(anchor, name)
remove_tree(p)
if is_overlay(dep):
overlay_service_quiet() # the last autostart overlay app gone: the service goes too
return {"removed": True, "kept_saves": keep_data, "shortcut_removed": removed_sc}
# ------------------------------------------------------------------------------------------ move (GitHub #90)
def remove_empty_drive_dir(base):
"""<drive>/FramePort once its last game is gone."""
parent = os.path.dirname(os.path.normpath(base))
if os.path.basename(parent) != DRIVE_DIR or _inside(parent, HOME):
return False
try:
os.rmdir(parent)
return True
except OSError:
return False
def base_items(base, anchor):
"""The entries of a game's base that are the game's files (with base == anchor, the anchor's own files stay)."""
try:
names = sorted(os.listdir(base))
except OSError:
return []
if os.path.normpath(base) == os.path.normpath(anchor):
names = [n for n in names if n not in ANCHOR_ITEMS]
return names
def as_owner():
"""Commands that must reach files owned by Lepton containers' user ids (overlayfs work dirs, the app's own
files) run in podman's user namespace, where the steamos user owns them all."""
return ["podman", "unshare"] if shutil.which("podman") else []
def tree_stats(root, names):
"""(files, bytes) of these entries of root: regular files and symlinks count (symlinks without size)."""
paths = [os.path.join(root, n) for n in names if os.path.lexists(os.path.join(root, n))]
if not paths:
return 0, 0
p = run(as_owner() + ["find"] + paths + ["(", "-type", "f", "-o", "-type", "l", ")", "-printf", r"%y %s\n"])
files = size = 0
for line in p.stdout.splitlines():
kind, _, n = line.partition(" ")
files += 1
if kind == "f" and n.isdigit():
size += int(n)
return files, size
def replace_path(text, old, new):
"""launch.sh text with the game's old folder replaced (the plain path and Wine's Z:\\ form). Paths that need
quoting differently can't be swapped as text: those launchers are written again instead."""
if "'" in new or (shlex.quote(old) == old and shlex.quote(new) != new):
raise AgentError(f"can't rewrite the launcher for {new!r}; reinstall the game once, then move it")
for o, n in ((old, new), (windows_path(old), windows_path(new))):
text = re.sub(re.escape(o) + r"(?=[/\\'\"\s;)|&]|$)", lambda m, n=n: n, text, flags=re.M)
return text
def rebase_launcher(dep, anchor, new):
"""launch.sh pointing at the game's new folder: the Quest launcher's app_dir line, Linux and PC VR launchers written
again from the install record (their settings are kept there since agent v63), else the path swapped as text."""
pkg, old = dep["package"], dep["base"]
path = os.path.join(anchor, "launch.sh")
with open(path) as f:
text = f.read()
kind = dep.get("kind", "quest")
launcher = dep.get("launcher")
if kind == "quest":
text, n = set_app_dir(text, new, pkg)
if not n:
raise AgentError("launch.sh has no app_dir line; reinstall the game once, then move it")
elif kind == "linux" and launcher is not None:
prefix = []
if dep.get("x86_64"):
tool = pick_tool("linux_x86", linux_x86_tools())
prefix = compat_command(tool["dir"]) if tool and tool.get("dir") else None
if prefix is not None:
write_linux_launcher(anchor, new, pkg, dep.get("title") or pkg, dep["exe"], dep.get("appimage"), prefix,
launcher.get("env"))
return
text = replace_path(text, old, new)
elif kind == "pcvr" and launcher is not None:
tool = next((t for t in proton_tools() if t["name"] == dep.get("proton")), None)
if tool and tool.get("dir"):
write_proton_launcher(anchor, new, pkg, dep.get("title") or pkg, dep["appid"], tool, dep["exe"],
dep.get("revive", True), launcher.get("env"), dep.get("xr_layer"),
launcher.get("game_args") or [], dep.get("oculus_hmd"), vr=dep.get("vr", True))
return
text = replace_path(text, old, new)
else:
text = replace_path(text, old, new)
with open(path + ".tmp", "w") as f:
f.write(text)
os.chmod(path + ".tmp", 0o755)
if shutil.which("bash") and run(["bash", "-n", path + ".tmp"]).returncode != 0:
os.remove(path + ".tmp")
raise AgentError("the rewritten launcher isn't valid; nothing was moved")
os.replace(path + ".tmp", path)
def retarget_symlinks(root, names, old, new):
"""Symlinks in the moved files that point into the old folder (PC VR: the LibOVRRT redirect to Revive's runtime,
links in the Proton prefix) point into the new one."""
fixed = 0
for name in names:
for r, dirs, files in os.walk(os.path.join(root, name)):
for n in files + dirs:
p = os.path.join(r, n)
if not os.path.islink(p):
continue
target = os.readlink(p)
if os.path.isabs(target) and _inside(target, old):
try:
os.remove(p)
os.symlink(new + target[len(os.path.normpath(old)):], p)
fixed += 1
except OSError:
pass
return fixed
def _write_json(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path + ".tmp", "w") as f:
json.dump(data, f, indent=2)
os.replace(path + ".tmp", path)
def same_device(a, b):
return os.stat(a).st_dev == os.stat(b).st_dev
def game_is_running(dep):
if dep.get("kind") in ("pcvr", "linux"):
return bool(pcvr_pids(dep["base"]))
return container_running(dep["appid"])
def cmd_move(args):
"""Move an installed game's files to another drive (or back to internal storage): `dest` as for prepare (a drive's
FramePort folder or its mount point, "internal"). The launcher, Steam shortcut and saves stay valid. Copies run
detached (poll move_status); detach=False runs it here (tests)."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
title = dep.get("title") or pkg
old = os.path.normpath(dep["base"])
check_base_present(old, title)
dest = resolve_dest(args.get("dest"))
new = os.path.normpath(os.path.join(dest, pkg))
anchor = os.path.join(ANCHORS, pkg)
if new == old:
raise AgentError(f"{title} is already there")
if game_is_running(dep):
raise AgentError(f"{title} is running on the Frame. Close it first.")
try:
cur = json.load(open(MOVE_STATUS))
if cur.get("state") == "running" and cur.get("pid") and os.path.exists(f"/proc/{cur['pid']}"):
raise AgentError(f"another move is running ({cur.get('title') or cur.get('package')})")
except (OSError, ValueError):
pass
items = base_items(old, anchor)
clash = [n for n in items if os.path.lexists(os.path.join(new, n))]
if new != anchor and os.path.isdir(new) and os.listdir(new):
clash = clash or sorted(os.listdir(new))
if clash:
raise AgentError(f"{new} already has files ({clash[0]}); remove them first")
os.makedirs(dest, exist_ok=True)
same_fs = same_device(old, dest)
files, size = tree_stats(old, items)
if not same_fs:
free = _space(dest)[0] or 0
if size > free - MOVE_HEADROOM:
raise AgentError(f"not enough space: {title} needs {size / 2**30:.1f} GiB, the drive has "
f"{free / 2**30:.1f} GiB free")
job = {"package": pkg, "title": title, "from": old, "to": new, "items": items, "same_fs": same_fs,
"files": files, "total_bytes": size, "status": args.get("status") or MOVE_STATUS}
_write_json(job["status"], {"state": "running", "phase": "starting", "package": pkg, "title": title, "from": old,
"to": new, "done_bytes": 0, "total_bytes": size, "started": time.time()})
if args.get("detach", True) is False:
move_worker(json.dumps(job))
return json.load(open(job["status"]))
p = run(["systemd-run", "--user", "--collect", "--quiet", f"--unit=frameport-move-{int(time.time())}",
"--setenv=HOME=" + HOME, sys.executable, os.path.abspath(__file__), "_move_worker", json.dumps(job)])
if p.returncode != 0:
_write_json(job["status"], {"state": "failed", "package": pkg, "error": p.stderr.strip()[-300:]})
raise AgentError(f"couldn't start the move: {p.stderr.strip()[-300:]}")
return {"started": True, "package": pkg, "from": old, "to": new, "total_bytes": size, "files": files,
"same_drive": same_fs, "status": job["status"]}
def cmd_move_status(args):
try:
return json.load(open(args.get("status") or MOVE_STATUS))
except (OSError, ValueError):
return {"state": "none"}
def move_worker(payload):
job = json.loads(payload)
status = {"state": "running", "phase": "copying", "package": job["package"], "title": job["title"],
"from": job["from"], "to": job["to"], "done_bytes": 0, "total_bytes": job["total_bytes"],
"started": time.time(), "pid": os.getpid()}
_write_json(job["status"], status)
try:
status.update(_move(job, status))
status["state"] = "done"
except Exception as exc: # noqa: BLE001
status.update(state="failed", error=str(exc) if isinstance(exc, AgentError) else f"{type(exc).__name__}: {exc}")
status.update(finished=time.time(), phase=status["state"])
_write_json(job["status"], status)
def _move(job, status):
import threading
pkg, old, new, items = job["package"], job["from"], job["to"], job["items"]
anchor = os.path.join(ANCHORS, pkg)
dep = deployment(pkg)
if not dep or os.path.normpath(dep["base"]) != old:
raise AgentError("the game's install record changed; nothing was moved")
os.makedirs(new, exist_ok=True)
done = []
try:
if job["same_fs"]: # same drive: rename, nothing to copy
for n in items:
os.rename(os.path.join(old, n), os.path.join(new, n))
done.append(n)
else:
stop = threading.Event()
def watch(): # progress for the PC: what has arrived so far
while not stop.wait(3):
arrived = [n for n in items if os.path.lexists(os.path.join(new, n))]
status["done_bytes"] = tree_stats(new, arrived)[1]
_write_json(job["status"], status)
t = threading.Thread(target=watch, daemon=True)
t.start()
try:
for n in items:
done.append(n)
p = run(as_owner() + ["cp", "-a", "--", os.path.join(old, n), new + "/"])
if p.returncode != 0:
raise AgentError(f"copy failed: {(p.stderr or p.stdout).strip()[-300:]}")
finally:
stop.set()
t.join(5)
status["phase"] = "verifying"
_write_json(job["status"], status)
have = tree_stats(new, items)
if have != (job["files"], job["total_bytes"]):
raise AgentError(f"the copy doesn't match ({have[0]} files, {have[1]} bytes; expected "
f"{job['files']} files, {job['total_bytes']} bytes)")
status["done_bytes"] = job["total_bytes"]
status["relinked"] = retarget_symlinks(new, items, old, new)
old_launcher = open(os.path.join(anchor, "launch.sh")).read()
rebase_launcher(dep, anchor, new)
except BaseException:
if job["same_fs"]:
for n in reversed(done):
try:
os.rename(os.path.join(new, n), os.path.join(old, n))
except OSError:
pass
else:
for n in done:
_remove_owned(os.path.join(new, n))
if new != anchor:
try:
os.rmdir(new)
except OSError:
pass
remove_empty_drive_dir(new)
raise
dep_path = os.path.join(anchor, "deployment.json")
try:
with open(dep_path) as f:
raw = json.load(f)
raw.update(base=new, moved={"from": old, "time": time.time()})
_write_json(dep_path, raw)
except Exception:
with open(os.path.join(anchor, "launch.sh"), "w") as f: # keep the game where it was
f.write(old_launcher)
raise
status["phase"] = "removing"
_write_json(job["status"], status)
if not job["same_fs"]:
for n in items:
_remove_owned(os.path.join(old, n))
if old != os.path.normpath(anchor):
try:
os.rmdir(old)
except OSError:
pass
remove_empty_drive_dir(old)
return {"base": new}
def _remove_owned(path):
"""Remove a copied or moved tree, including files owned by the container's user ids."""
remove_tree(path)
if os.path.lexists(path) and as_owner():
run(as_owner() + ["rm", "-rf", "--", path])
# ------------------------------------------------------------------------------------------ launch tests
def cmd_stop(args):
dep = deployment(check_pkg(args["package"]))
if dep:
run(["systemctl", "--user", "stop", f"frameport-test-{dep['appid']}"])
if dep.get("kind") == "pcvr":
stop_pcvr(dep)
elif dep.get("kind") == "linux":
for pid in pcvr_pids(dep["base"]):
run(["kill", "-TERM", pid])
else:
run(["podman", "kill", f"lepton-steamlaunch-{dep['appid']}"])
return {"stopped": bool(dep)}
def stop_pcvr(dep):
"""Stop a Proton game: wineserver -k in its prefix, then anything still using its folder."""
tool = next((t for t in proton_tools() if t["name"] == dep.get("proton")), None) if dep.get("proton") else None
if tool and tool.get("dir"):
stop_prefix(tool, os.path.join(dep["base"], "compatdata/pfx"))
for pid in pcvr_pids(dep["base"]):
run(["kill", "-TERM", pid])
PCVR_LOGS = ("compatdata/pfx/drive_c/users/steamuser/AppData/Local/Revive/ReviveInjector.txt",)
LOCAL_APPDATA = "compatdata/pfx/drive_c/users/steamuser/AppData/Local"
LOCAL_LOW = "compatdata/pfx/drive_c/users/steamuser/AppData/LocalLow"
def _head_tail(path, head=300, tail=1500):
lines = open(path, errors="replace").read().splitlines()
if len(lines) > head + tail:
lines = lines[:head] + [f"[... {len(lines) - head - tail} lines left out ...]"] + lines[-tail:]
return "\n".join(lines)
def unity_logs(base, since=0.0):
"""Unity's own logs of a PC VR game: LocalLow/<Company>/<Product>/Player.log + Player-prev.log (Unity 2018.3+;
company and product from <game>/<Name>_Data/app.info, else every Player log in LocalLow), the older
<Name>_Data/output_log.txt and the crash handler's Temp/<Company>/<Product>/Crashes/*/error.log. Unity logs VR
start-up (which SDK, init errors) at the top, so the head is kept as well as the tail."""
game = os.path.join(base, "game")
low = os.path.join(base, LOCAL_LOW)
temp = os.path.join(base, LOCAL_APPDATA, "Temp")
names = []
for info in glob.glob(os.path.join(glob.escape(game), "*_Data", "app.info")):
try:
lines = [ln.strip() for ln in open(info, errors="replace").read().splitlines()]
except OSError:
continue
if len(lines) >= 2 and lines[0] and lines[1] and "/" not in lines[0] + lines[1] and \
".." not in (lines[0], lines[1]):
names.append((lines[0], lines[1]))
dirs = [os.path.join(low, c, p) for c, p in names if os.path.isdir(os.path.join(low, c, p))]
logs = []
for d in dirs or glob.glob(os.path.join(glob.escape(low), "*", "*")):
logs += [os.path.join(d, n) for n in ("Player.log", "Player-prev.log")]
logs += glob.glob(os.path.join(glob.escape(game), "*_Data", "output_log.txt"))
crash_dirs = [os.path.join(temp, c, p) for c, p in names] or glob.glob(os.path.join(glob.escape(temp), "*", "*"))
crashes = [x for d in crash_dirs for x in glob.glob(os.path.join(glob.escape(d), "Crashes", "*", "error.log"))]
out = []
for log in [x for x in logs if os.path.isfile(x)]:
if os.path.getmtime(log) < since: # left over from an earlier run
continue
out.append(f"===== unity log {os.path.relpath(log, base)}\n" + _head_tail(log))
for err in sorted(crashes, key=os.path.getmtime, reverse=True)[:2]:
if os.path.getmtime(err) >= since:
out.append(f"===== unity crash {os.path.relpath(err, base)}\n" + _head_tail(err, 100, 400))
return out
def game_logs(base, since=0.0):
"""The game's own logs from the Proton prefix, newest first: Unreal Saved/Logs/*.log (tail) and crash summaries
(Saved/Crashes/*/CrashContext.runtime-xml → error message + call stack), Unity's Player.log / crash error.log
(agent v67), Revive's logs."""
out = unity_logs(base, since)
local = os.path.join(base, LOCAL_APPDATA)
for log in sorted(glob.glob(os.path.join(local, "*", "Saved", "Logs", "*.log")), key=os.path.getmtime,
reverse=True)[:1]:
if os.path.getmtime(log) < since: # left over from an earlier run
continue
text = open(log, errors="replace").read()
out.append(f"===== game log {os.path.relpath(log, base)}\n" + "\n".join(text.splitlines()[-1500:]))
for ctx in sorted(glob.glob(os.path.join(local, "*", "Saved", "Crashes", "*", "CrashContext.runtime-xml")),
key=os.path.getmtime, reverse=True)[:2]:
if os.path.getmtime(ctx) < since:
continue
raw = open(ctx, "rb").read().decode("utf-8", "replace")
fields = []
for tag in ("ErrorMessage", "CrashType", "EngineVersion", "CallStack", "SourceContext"):
m = re.search(rf"<{tag}>(.*?)</{tag}>", raw, re.S)
if m and m.group(1).strip():
fields.append(f"{tag}: {m.group(1).strip()[:3000]}")
out.append(f"===== crash {os.path.relpath(os.path.dirname(ctx), base)} (UE4CC)\n" + "\n".join(fields))
for rv in glob.glob(os.path.join(local, "Revive", "*.txt")):
if not rv.endswith("ReviveInjector.txt") and os.path.getmtime(rv) >= since:
out.append(f"===== {os.path.relpath(rv, base)}\n" + open(rv, errors="replace").read()[-100000:])
return out
LAUNCH_INSTALL_GRACE = 240 # s a launch test waits at most for Lepton's boot + app install before its own window
def cmd_launch_test(args):
"""Start the game headless (as Steam would), wait, classify, stop. Without the headset worn the OpenXR session
never reaches FOCUSED, so this proves startup, not visuals."""
pkg = check_pkg(args["package"])
seconds = int(args.get("seconds", 45))
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
anchor = os.path.join(ANCHORS, pkg)
log = os.path.join(dep["base"], "launch.log")
appid = dep["appid"]
if is_overlay(dep): # nothing to see without a game; register_vr_overlay's SteamVR check is the test
return {"state": "SKIPPED", "skipped": "overlay app", "elapsed": 0, "log": None, "log_size": 0,
"crash_log": None, "kind": dep.get("kind")}
if dep.get("kind") == "pcvr":
return launch_test_pcvr(dep, anchor, log, seconds)
if dep.get("kind") == "linux":
return launch_test_linux(dep, anchor, log, seconds)
if container_running(appid):
raise AgentError("the game is already running")
ensure_host_fixes()
keys = key_usage()
unit = f"frameport-test-{appid}"
run(["systemctl", "--user", "reset-failed", unit])
mark_launch_test(anchor)
p = run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={unit}", os.path.join(anchor, "launch.sh")])
if p.returncode:
raise AgentError("could not start the launcher: " + p.stderr[-300:])
start = time.time()
state = "RUNNING"
app_start = None # when Lepton started the app ("Waiting for app"): the test window counts from there
while True:
time.sleep(3)
now = time.time()
text = open(log, errors="replace").read() if os.path.exists(log) else ""
if "Exited!" in text:
state = "EXITED"
break
if "Early-exit" in text or not_started(text, now - start):
state = "NEVER_STARTED"
break
if app_start is None and "Waiting for app" in text:
app_start = now
# the first start after an APK change boots Lepton and installs the app first (a minute or more): stopping the
# container then left a half-installed APK ("base.apk is not zip") that never started again (VR HOT)
if app_start is not None and now - app_start >= seconds:
break
if now - start >= seconds + LAUNCH_INSTALL_GRACE:
break
elapsed = round(time.time() - start)
if state == "EXITED": # Lepton dumps the container's logcat buffers (crash backtraces) after "Exited!"
until = time.time() + 15
while time.time() < until and "Dumping logcat" not in (open(log, errors="replace").read()
if os.path.exists(log) else ""):
time.sleep(1)
time.sleep(2)
run(["systemctl", "--user", "stop", unit])
run(["podman", "kill", f"lepton-steamlaunch-{appid}"])
time.sleep(3)
crash = os.path.join(STEAM, "logs", "lepton-logcats", f"steamlaunch-{appid}", "logcat-crash.log")
fresh = os.path.exists(crash) and os.path.getmtime(crash) >= start - 1 and os.path.getsize(crash) > 0
return {"state": state, "elapsed": elapsed, "log": log,
"log_size": os.path.getsize(log) if os.path.exists(log) else 0,
"crash_log": crash if fresh else None,
"kernel_keys_before": keys, "kernel_keys_after": key_usage()}
def launch_test_linux(dep, anchor, log, seconds):
"""Start a Linux app headless (the launcher borrows Steam's display) and see whether it stays up."""
base, appid = dep["base"], dep["appid"]
if pcvr_pids(os.path.join(base, "app")):
raise AgentError("the app is already running")
unit = f"frameport-test-{appid}"
run(["systemctl", "--user", "reset-failed", unit])
mark_launch_test(anchor)
start = time.time()
p = run(["systemd-run", "--user", "--quiet", f"--unit={unit}", "--property=RemainAfterExit=no",
os.path.join(anchor, "launch.sh")])
if p.returncode:
raise AgentError("could not start the launcher: " + p.stderr[-300:])
state, seen = "RUNNING", False
while time.time() - start < seconds:
time.sleep(2)
seen = seen or bool(pcvr_pids(os.path.join(base, "app")))
if run(["systemctl", "--user", "is-active", "--quiet", unit]).returncode:
state = "EXITED" if seen else "NEVER_STARTED"
break
elapsed = round(time.time() - start)
run(["systemctl", "--user", "stop", unit])
for pid in pcvr_pids(os.path.join(base, "app")):
run(["kill", "-TERM", pid])
combined = os.path.join(base, "launch-test.log")
text = open(log, errors="replace").read()[-400000:] if os.path.exists(log) else ""
missing = dep.get("missing_libraries") or []
with open(combined, "w") as f:
if missing:
f.write("FramePort: libraries missing on the Frame: " + ", ".join(missing) + "\n")
f.write(text)
return {"state": state, "elapsed": elapsed, "log": combined, "log_size": os.path.getsize(combined),
"kind": "linux", "game_process": seen, "missing_libraries": missing}
def launch_test_pcvr(dep, anchor, log, seconds):
base, appid = dep["base"], dep["appid"]
if pcvr_pids(base):
raise AgentError("the game is already running")
unit = f"frameport-test-{appid}"
run(["systemctl", "--user", "reset-failed", unit])
mark_launch_test(anchor)
p = run(["systemd-run", "--user", "--quiet", f"--unit={unit}", "--property=RemainAfterExit=no",
os.path.join(anchor, "launch.sh")])
if p.returncode:
raise AgentError("could not start the launcher: " + p.stderr[-300:])
start = time.time()
state = "RUNNING"
game_seen = False
exe_name = os.path.basename(dep.get("exe", ""))
while time.time() - start < seconds:
time.sleep(3)
# Wine names game processes by their Windows path (X:\...\Game.exe); the Proton command line doesn't start so
game_seen = game_seen or bool(exe_name and run(
["pgrep", "-if", r"^[a-z]:\\.*" + re.escape(exe_name)]).returncode == 0)
active = run(["systemctl", "--user", "is-active", "--quiet", unit]).returncode == 0
if not active:
state = "EXITED" if game_seen else "NEVER_STARTED"
break
elapsed = round(time.time() - start)
if state == "RUNNING" and not game_seen:
state = "NEVER_STARTED" # the launcher is up but the game process never appeared
run(["systemctl", "--user", "stop", unit])
stop_pcvr(dep)
time.sleep(3)
# one log for triage: launcher/Proton output + Revive's logs + the game's own (Unreal) log and crash summaries +
# Proton's own log when enabled
parts = []
for rel in ("launch.log",) + PCVR_LOGS + (f"steam-{appid}.log",):
path = os.path.join(base, rel)
if os.path.exists(path) and os.path.getmtime(path) >= start - 5: # not left over from an earlier run
parts.append(f"===== {rel}\n" + open(path, errors="replace").read()[-400000:])
parts += game_logs(base, since=start - 5)
combined = os.path.join(base, "launch-test.log")
with open(combined, "w") as f:
f.write("\n".join(parts))
return {"state": state, "elapsed": elapsed, "log": combined, "log_size": os.path.getsize(combined),
"kind": "pcvr", "game_process": bool(game_seen)}
SESSION_LOG_MAX = 4 << 20 # bytes of a play session's log the PC triages
SESSION_READ_MAX = 64 << 20 # a longer launch.log is read from its end
SESSION_KEEP = re.compile(r"FrameBridge|focus|pacing|Fatal signal|FATAL|CRASH|#\d\d pc |Abort message|DEVICE.LOST|"
r"AndroidRuntime|vrclient|Start proc|lepton", re.I)
KERNEL_GPU = re.compile(r"hangcheck|gpu fault|adreno|kgsl|msm_drm.*(hang|recover)", re.I)
def slice_session_log(text, max_bytes=SESSION_LOG_MAX):
"""A long play session's log cut to max_bytes: its start (a quarter) and its end (half) whole, from the middle
only FrameBridge's, focus, pacing and crash lines (oldest first, while they fit). Returns (text, cut)."""
if len(text) <= max_bytes:
return text, False
head = text[:max_bytes // 4]
head = head[:head.rfind("\n") + 1]
tail = text[-(max_bytes // 2):]
tail = tail[tail.find("\n") + 1:]
middle = text[len(head):len(text) - len(tail)]
budget, kept = max_bytes - len(head) - len(tail) - 200, []
for line in middle.splitlines():
if SESSION_KEEP.search(line):
budget -= len(line) + 1
if budget < 0:
break
kept.append(line)
note = f"[FramePort: {len(middle)} bytes in the middle of this session cut, {len(kept)} lines kept]\n"
return head + note + "".join(ln + "\n" for ln in kept) + tail, True
def session_kernel_lines(start, end):
"""Kernel GPU lines (hangs, faults, recoveries) logged during a play session."""
until = (end or time.time()) + 120
try:
text = run(["journalctl", "-k", "--since", f"@{int(start)}", "--until", f"@{int(until)}", "-q", "--no-pager",
"-o", "short-unix"]).stdout
except OSError:
return ""
lines = [ln for ln in text.splitlines() if KERNEL_GPU.search(ln)]
return "".join(f"kernel: {ln}\n" for ln in lines[-200:])
def cmd_session_log(args):
"""The log of the game's newest play session (agent v70), for triage on the PC: {session: {start, end, test},
log: path of the session's log (launch.log sliced to 4 MB; PC VR: + Revive's and the game's own logs), log_size,
cut, crash: that session's crash logcat (tombstones), kernel: GPU hang/fault lines from the kernel log}.
session is None when the game was never played."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
anchor, base = os.path.join(ANCHORS, pkg), dep["base"]
session = last_play(anchor)
out = {"session": session, "log": None, "log_size": 0, "cut": False, "crash": "", "kernel": "",
"kind": dep.get("kind", "quest")}
if not session:
return out
start, end = session["start"], session["end"]
parts = []
log = os.path.join(base, "launch.log")
if os.path.exists(log) and os.path.getmtime(log) >= start - 5: # else no log of this session is left
size = os.path.getsize(log)
with open(log, "rb") as f:
if size > SESSION_READ_MAX:
f.seek(size - SESSION_READ_MAX)
parts.append(f.read().decode("utf-8", "replace"))
if dep.get("kind") == "pcvr":
for rel in PCVR_LOGS:
path = os.path.join(base, rel)
if os.path.exists(path) and os.path.getmtime(path) >= start - 5:
parts.append(f"===== {rel}\n" + (_tail(path, 400000) or ""))
parts += game_logs(base, since=start - 5)
text, cut = slice_session_log("\n".join(parts), int(args.get("max_bytes", SESSION_LOG_MAX)))
path = os.path.join(base, "session.log")
with open(path, "w") as f:
f.write(text)
out.update(log=path, log_size=os.path.getsize(path), cut=cut)
crash = os.path.join(STEAM, "logs", "lepton-logcats", f"steamlaunch-{dep['appid']}", "logcat-crash.log")
try:
mtime = os.path.getmtime(crash)
if start - 1 <= mtime <= (end or time.time()) + 120:
out["crash"] = _tail(crash, 256 * 1024) or ""
except OSError:
pass
out["kernel"] = session_kernel_lines(start, end)
return out
def steam_library_report():
"""Why a game installed by FramePort may be missing from Steam or fail with "Game configuration unavailable"
(GitHub #21/#30): where Steam really lives, Steam's client version/beta, each account's shortcuts.vdf (when it
was written vs when Steam started, FramePort's entries in it) and Steam's log lines about these shortcuts.
Accounts are numbered, not named (the PC redacts ids anyway)."""
rep = {"steam_dir": STEAM}
for link in ("~/.steam/steam", "~/.steam/root"):
path = os.path.expanduser(link)
rep[link] = os.path.realpath(path) if os.path.lexists(path) else None
pkg_dir = os.path.join(STEAM, "package")
rep["beta"] = (_tail(os.path.join(pkg_dir, "beta"), 200) or "").strip() or None
rep["client_manifests"] = sorted(n for n in os.listdir(pkg_dir) if n.endswith(".manifest"))[:10] \
if os.path.isdir(pkg_dir) else []
p = run(["pgrep", "-o", "-x", "steam"])
pid = p.stdout.split()[0] if p.returncode == 0 and p.stdout.split() else None
rep["steam_started"] = os.stat(f"/proc/{pid}").st_mtime if pid and os.path.exists(f"/proc/{pid}") else None
if pid:
try:
rep["steam_cmdline"] = open(f"/proc/{pid}/cmdline", "rb").read().replace(b"\0", b" ").decode()[:500]
except OSError:
pass
ours = {}
for dep_path in glob.glob(os.path.join(ANCHORS, "*/deployment.json")):
try:
dep = json.load(open(dep_path))
except (OSError, ValueError):
continue
ours[f'"{os.path.dirname(dep_path)}/launch.sh"'] = dep.get("package")
active = active_steam_user()
accounts, appids = [], set()
for i, user in enumerate(steam_users()):
acc = {"account": i + 1, "most_recent_login": user == active}
cfg = os.path.join(STEAM, "userdata", user, "config")
vdf = os.path.join(cfg, "shortcuts.vdf")
acc["shortcuts_vdf_written"] = os.path.getmtime(vdf) if os.path.exists(vdf) else None
acc["localconfig_written"] = os.path.getmtime(os.path.join(cfg, "localconfig.vdf")) \
if os.path.exists(os.path.join(cfg, "localconfig.vdf")) else None
acc["backups"] = len(glob.glob(vdf + ".backup-*"))
try:
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
entries = [v for v in (root.get("shortcuts") or {}).values() if isinstance(v, dict)]
acc["shortcuts"] = len(entries)
acc["frameport"] = []
for v in entries:
if v.get("Exe") in ours:
appids.add(v.get("appid", 0) & 0xFFFFFFFF)
acc["frameport"].append({"package": ours[v["Exe"]], "appid": v.get("appid", 0) & 0xFFFFFFFF,
"title": v.get("AppName", v.get("appname")),
"start_dir": v.get("StartDir"), "openvr": v.get("OpenVR"),
"options": v.get("LaunchOptions")})
acc["exe_dupes"] = len(entries) - len({v.get("Exe") for v in entries})
except Exception as exc: # noqa: BLE001
acc["error"] = f"shortcuts.vdf unreadable: {exc}"
accounts.append(acc)
rep["accounts"] = accounts
rep["devkit_games"] = sorted(n for n in os.listdir(DEVKIT_GAMES) if os.path.isdir(os.path.join(DEVKIT_GAMES, n))) \
if os.path.isdir(DEVKIT_GAMES) else []
lines = []
shortcut_log = _tail(os.path.join(STEAM, "logs/shortcuts.previous.txt"), 200000)
if shortcut_log:
lines += ["==> shortcuts.previous.txt", shortcut_log]
for name in ("console_log.previous.txt", "console_log.txt"):
text = _tail(os.path.join(STEAM, "logs", name), 8 << 20) or ""
keep = [ln for ln in text.splitlines() if "launch error" in ln or "rungameid" in ln
or ("shortcut" in ln.lower() and "path_shortcut" not in ln) or any(f"[AppID {a}" in ln for a in appids)]
if keep:
lines += [f"==> {name}"] + keep[-400:]
return rep, "\n".join(lines)
def _tail(path, max_bytes):
try:
size = os.path.getsize(path)
with open(path, "rb") as f:
if size > max_bytes:
f.seek(size - max_bytes)
data = f.read()
text = data.decode("utf-8", "replace")
return (f"[... first {size - max_bytes} bytes cut ...]\n" if size > max_bytes else "") + text
except OSError:
return None
# shader dumps in a game's files dir: the Vulkan shim's vk_shader_dump and the shader-fix layer's zink_shader_dump
SHADER_DUMP_DIRS = ("fp_vk_shaders", "fp_spirv")
SHADER_DUMP_BYTES = 4 << 20 # without an index: newest modules per folder (by time written), base64 on the wire
SHADER_DUMP_FILES = 200
SHADER_SESSION_BYTES = 30 << 20 # with an index: the newest session's modules, up to this much in all
SHADER_MODULE_BYTES = 4 << 20 # larger modules are left out
SHADER_INDEX_READ = 64 << 20 # index.txt bytes looked at (from the end) for the newest session
SHADER_INDEX_BYTES = 4 << 20 # index text sent (the newest session's lines)
_SPV_NAME = re.compile(r"\d+_[0-9a-f]{64}\.spv")
def shader_session(index_path):
"""(index text from the newest "# start" line on, [module names of that session, last used first]) from a
vk_shader_dump index.txt (`<seq> <ms> <unix ms> <size>_<sha256>.spv new|known|again|failed` lines, one
"# start <unix s> pid <pid>" per process), or None without an index or a module line in its newest session."""
try:
size = os.path.getsize(index_path)
with open(index_path, "rb") as f:
if size > SHADER_INDEX_READ:
f.seek(size - SHADER_INDEX_READ)
text = f.read().decode("utf-8", "replace")
except OSError:
return None
lines = text.splitlines()
first = max((i for i, ln in enumerate(lines) if ln.startswith("# start")), default=0)
session = lines[first:]
names, seen = [], set()
for ln in reversed(session):
parts = ln.split()
if len(parts) >= 4 and _SPV_NAME.fullmatch(parts[3]) and parts[3] not in seen:
seen.add(parts[3])
names.append(parts[3])
if not names:
return None
body = "\n".join(session) + "\n"
if len(body) > SHADER_INDEX_BYTES:
body = f"[... first {len(body) - SHADER_INDEX_BYTES} characters cut ...]\n" + body[-SHADER_INDEX_BYTES:]
elif first > 0 or size > SHADER_INDEX_READ:
body = "[... earlier sessions left out ...]\n" + body
return body, names
def shader_dumps(files_dir, max_bytes=SHADER_DUMP_BYTES, session_bytes=SHADER_SESSION_BYTES):
"""{dir: {"index": index text, "modules": {name: base64}, "total": n, "skipped": n[, "session": n]}} for the
dump folders in a game's files dir. With an index.txt naming modules (vk_shader_dump): every module named in its
newest session (= process start), last used first, each <= SHADER_MODULE_BYTES, up to session_bytes in all
("session" = how many it names). A GPU hang can come seconds after the culprit was created, so "newest written"
missed it (GitHub #140). Without one (fp_spirv): the newest modules (by time written) up to max_bytes. Unreadable
files are skipped (the app writes them inside its container). The reply is one JSON line on the SSH channel's
stdout (~40 MB base64 at most): the PC reads it whole, which is fine at that size."""
out = {}
for d in SHADER_DUMP_DIRS:
path = os.path.join(files_dir, d)
try:
names = [n for n in os.listdir(path) if n.endswith(".spv")]
except OSError:
continue
res = {"total": len(names), "modules": {}, "skipped": 0}
session = shader_session(os.path.join(path, "index.txt"))
if session is not None:
res["index"], order = session
res["session"] = len(order)
budget, per_module = session_bytes, SHADER_MODULE_BYTES
else:
index = _tail(os.path.join(path, "index.txt"), 1 << 20)
if index is not None:
res["index"] = index
mods = []
for n in names:
try:
mods.append((os.path.getmtime(os.path.join(path, n)), n))
except OSError:
pass
mods.sort(reverse=True)
order = [n for _, n in mods[:SHADER_DUMP_FILES]]
budget, per_module = max_bytes, max_bytes
used = 0
for n in order:
try:
with open(os.path.join(path, n), "rb") as f:
data = f.read(min(per_module, budget - used) + 1)
except OSError:
res["skipped"] += 1
continue
if used + len(data) > budget or len(data) > per_module:
if session is None:
break
res["skipped"] += 1 # too big: a smaller, older one may still fit
continue
used += len(data)
res["modules"][n] = base64.b64encode(data).decode("ascii")
out[d] = res
return out
# arm64 syscall numbers seen in stuck threads (/proc/<tid>/syscall gives the number)
ARM64_SYSCALLS = {22: "epoll_pwait", 29: "ioctl", 56: "openat", 63: "read", 64: "write", 73: "ppoll",
98: "futex", 101: "nanosleep", 115: "clock_nanosleep", 203: "connect", 206: "sendto",
207: "recvfrom", 212: "recvmsg", 260: "wait4"}
TCP_STATES = {"01": "ESTABLISHED", "02": "SYN_SENT", "03": "SYN_RECV", "04": "FIN_WAIT1", "05": "FIN_WAIT2",
"06": "TIME_WAIT", "07": "CLOSE", "08": "CLOSE_WAIT", "09": "LAST_ACK", "0A": "LISTEN", "0B": "CLOSING"}
def _remote_kind(hexaddr):
"""/proc/net/tcp(6) remote address → loopback / local / internet (never the address itself: it would need
redacting, and what matters is whether the game waits on the internet)."""
import ipaddress
try:
raw = bytes.fromhex(hexaddr)
if len(raw) == 4:
ip = ipaddress.IPv4Address(raw[::-1])
else: # four little-endian 32-bit words
ip = ipaddress.IPv6Address(b"".join(raw[i:i + 4][::-1] for i in range(0, 16, 4)))
ip = ip.ipv4_mapped or ip
except ValueError:
return "?"
if ip.is_unspecified:
return "none"
if ip.is_loopback:
return "loopback"
return "local" if ip.is_private or ip.is_link_local else "internet"
def game_threads(pkg, proc=None, samples=3, gap=1.0, sleep=time.sleep):
"""What a running (e.g. stuck) Android game's threads are doing (GitHub #157): for every process of `pkg` (its
main process and `pkg:<name>` ones), each thread's state, CPU time used over the sampled seconds, kernel wait
channel and current syscall, plus its TCP sockets. Readable as steamos: the container's user namespace belongs to
steamos. None when the game isn't running."""
proc = proc or PROC
pids = []
for d in os.listdir(proc):
if not d.isdigit():
continue
cmd = (_rd(os.path.join(proc, d, "cmdline")) or "").split("\0")[0]
if cmd == pkg or cmd.startswith(pkg + ":"):
pids.append((int(d), cmd))
if not pids:
return None
def cpu(pid):
out = {}
for t in os.listdir(os.path.join(proc, str(pid), "task")) if os.path.isdir(os.path.join(proc, str(pid),
"task")) else []:
st = _rd(os.path.join(proc, str(pid), "task", t, "stat")) or ""
f = st[st.rfind(")") + 2:].split()
if len(f) > 12:
out[t] = (f[0], int(f[11]) + int(f[12]))
return out
first = {pid: cpu(pid) for pid, _ in sorted(pids)}
for _ in range(max(0, samples - 1)):
sleep(gap)
span = max(0, samples - 1) * gap
lines = [f"# threads of the running game {pkg}; cpu_ms = CPU time used in {span:g} s, wchan = kernel wait, "
"syscall = what the thread is blocked in"]
for pid, cmd in sorted(pids):
last = cpu(pid)
lines.append(f"\n## process {cmd} (pid {pid}, {len(last)} threads)")
lines.append(f"{'tid':>7} st {'cpu_ms':>7} {'comm':<16} {'wchan':<24} syscall")
rows = []
for tid, (state, ticks) in last.items():
used = (ticks - first.get(pid, {}).get(tid, (state, ticks))[1]) * 1000 // CLK_TCK
tdir = os.path.join(proc, str(pid), "task", tid)
comm = (_rd(os.path.join(tdir, "comm")) or "?").strip()
wchan = (_rd(os.path.join(tdir, "wchan")) or "?").strip() or "-"
sc = (_rd(os.path.join(tdir, "syscall")) or "?").split()
call = sc[0] if sc else "?"
if call.isdigit():
call = ARM64_SYSCALLS.get(int(call), "syscall " + call)
rows.append((-used, int(tid), f"{tid:>7} {state:>2} {used:>7} {comm:<16} {wchan:<24} {call}"))
lines += [r[2] for r in sorted(rows)]
socks = []
for name in ("tcp", "tcp6"):
for ln in (_rd(os.path.join(proc, str(pid), "net", name)) or "").splitlines()[1:]:
f = ln.split()
if len(f) > 3 and ":" in f[2]:
addr, port = f[2].rsplit(":", 1)
socks.append(f"{name:<4} {TCP_STATES.get(f[3], f[3]):<12} remote={_remote_kind(addr)}"
f":{int(port, 16)}")
if socks:
lines.append("# TCP sockets (remote = loopback / local network / internet, and port)")
lines += sorted(set(socks))
return "\n".join(lines) + "\n"
def cmd_collect_diag(args):
"""Everything useful for debugging without the game or the PC app: host runtime facts and, with a package, the
game's launcher, settings, deployment, logs (launch, Lepton logcat, Proton/Revive/Unreal) and its file listing.
Returns {"host": {...}, "files": {name: text}, "listing": ...}; the PC redacts and zips it."""
max_bytes = int(args.get("max_bytes", 2 << 20))
files, host = {}, {}
host["openxr_runtime"] = openxr_runtime()
layers = os.path.join(HOME, ".local/share/openxr/1/api_layers/explicit.d")
host["openxr_layers"] = sorted(os.listdir(layers)) if os.path.isdir(layers) else []
host["kernel_keys"] = key_usage()
host["containers_conf"] = _tail(CONTAINERS_CONF, 20000)
host["podman"] = run(["podman", "ps", "-a", "--filter", "name=lepton", "--format",
"{{.Names}} {{.Status}}"]).stdout[-20000:]
host["steam_running"] = run(["pgrep", "-x", "steam"]).returncode == 0
host["uptime"] = _tail("/proc/uptime", 200)
host["boot"] = boot_state()
# how the previous boot ended: errors and kernel (GPU/msm/kgsl, OOM, panic) warnings before a crash or reset; and
# this boot's kernel warnings (a GPU hang the Frame recovered from only ends the game: no reboot, no tombstone)
for name, boot, extra in (("previous-boot-errors.txt", "-1", ["-p", "err"]),
("previous-boot-kernel.txt", "-1", ["-k", "-p", "warning"]),
("this-boot-kernel.txt", "0", ["-k", "-p", "warning"])):
try:
text = run(["journalctl", "-b", boot, *extra, "-n", "400", "-q", "--no-pager"]).stdout[-max_bytes:]
except OSError:
text = ""
if text.strip():
files[name] = text
host["installed"] = [{k: g.get(k) for k in ("package", "title", "kind", "appid", "version", "agent_version")}
for g in cmd_list_installed({})["games"]]
try:
host["steam_library"], console = steam_library_report()
if console:
files["steam-console.txt"] = console[-max_bytes:]
except Exception as exc: # noqa: BLE001 (diagnostics must not fail on it)
host["steam_library"] = {"error": str(exc)}
out = {"agent_version": AGENT_VERSION, "host": host, "files": files}
pkg = args.get("package")
if not pkg:
# the OpenXR runtime's own log (XRService-<date>_<time>.log): the newest one
xr = [p for p in glob.glob(os.path.join(STEAM, "logs/XRService-*.log")) +
glob.glob(os.path.join(STEAM, "logs/XRService-*/XRService-*.log")) if os.path.isfile(p)]
for p in sorted(xr, key=os.path.getmtime, reverse=True)[:1]:
files[os.path.basename(p)] = _tail(p, max_bytes)
return out
pkg = check_pkg(pkg)
dep = deployment(pkg)
anchor = os.path.join(ANCHORS, pkg)
if not dep:
out["installed"] = False
return out
out["installed"] = True
base, appid = dep["base"], dep.get("appid")
dep = dict(dep)
dep.pop("files", None) # the install manifest is large; `listing` below has the real files
files["deployment.json"] = json.dumps(dep, indent=1)
for name, path in (("launch.sh", os.path.join(anchor, "launch.sh")),
("settings.conf", os.path.join(base, "settings.conf")),
("launch.log", os.path.join(base, "launch.log")),
("launch-test.log", os.path.join(base, "launch-test.log"))):
text = _tail(path, max_bytes)
if text is not None:
files[name] = text
for rel in PCVR_LOGS + (f"steam-{appid}.log",):
text = _tail(os.path.join(base, rel), max_bytes)
if text is not None:
files[os.path.basename(rel)] = text
if dep.get("kind") == "pcvr":
for i, part in enumerate(game_logs(base)):
files[f"game-log-{i}.txt"] = part[-max_bytes:]
logs = os.path.join(STEAM, "logs")
if dep.get("steam_gamepad"): # which controller layout Steam activated, and the pads the container got (#162)
text = _tail(os.path.join(base, "gamepad.log"), max_bytes)
if text is not None:
files["gamepad.log"] = text
text = _tail(os.path.join(logs, "controller.txt"), 4 * max_bytes)
if text:
keep = [ln for ln in text.splitlines() if re.search(
r"Queueing activation|warmed config|Opted-in Controller|Loaded Config|virtual gamepad|xinput slot", ln)]
files["steam-controller.txt"] = "\n".join(keep[-400:]) + "\n"
text = _tail(os.path.join(logs, f"lepton-steamlaunch-{appid}.log"), max_bytes) # Lepton's launcher log
if text is not None:
files[f"lepton-steamlaunch-{appid}.log"] = text
logcats = os.path.join(logs, "lepton-logcats")
# the container's logcat buffers: lepton-logcats/steamlaunch-<appid>/logcat-{main,crash,system,kernel,radio}.log
order = ("main", "crash", "system", "kernel", "radio")
cands = [p for p in glob.glob(os.path.join(logcats, f"steamlaunch-{appid}", "*")) if os.path.isfile(p)]
for p in sorted(cands, key=lambda p: next((i for i, k in enumerate(order) if k in os.path.basename(p)), 9)):
name = os.path.basename(p)
files[name if name.startswith("logcat") else "logcat-" + name] = _tail(p, max_bytes)
if dep.get("kind", "quest") == "quest":
try:
threads = game_threads(pkg, samples=int(args.get("thread_samples", 3)))
except OSError:
threads = None
if threads:
files["threads.txt"] = threads
shaders = shader_dumps(data_files_dir(base, pkg), int(args.get("shader_bytes", SHADER_DUMP_BYTES)),
int(args.get("shader_session_bytes", SHADER_SESSION_BYTES)))
if shaders:
out["shaders"] = shaders
try:
listing = cmd_list_files({"package": pkg, "limit": 20000})
out["listing"] = {"missing": listing["missing"], "truncated": listing["truncated"],
"roots": [{"name": r["name"], "files": r["files"]} for r in listing["roots"]]}
except AgentError:
pass
return out
AGENT_HOME = os.path.join(HOME, ".local/share/frameport")
def cmd_purge(args):
"""Remove everything FramePort put on this Frame: its games (keep_saves: leave Quest save data and PC VR Proton
prefixes), their Steam shortcuts + grid art, ~/Applications/quest-frame, and ~/.local/share/frameport (this agent,
Proton self-test prefix, timefix layer). Runs detached (Steam is closed while shortcuts.vdf changes); poll
purge_status. Proton/Lepton stay installed (they're Steam apps) and the podman keyring fix stays (harmless)."""
games = cmd_list_installed({})["games"]
if any((pcvr_pids(d["base"]) if d.get("kind") in ("pcvr", "linux") else container_running(d["appid"]))
for d in games):
raise AgentError("a FramePort game is running on the Frame; close it first")
status = os.path.join(HOME, ".cache/frameport-purge.json")
os.makedirs(os.path.dirname(status), exist_ok=True)
with open(status, "w") as f:
json.dump({"state": "running", "started": time.time()}, f)
payload = json.dumps({"keep_saves": bool(args.get("keep_saves", True)), "status": status})
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit=frameport-purge-{int(time.time())}",
"--setenv=HOME=" + HOME, sys.executable, os.path.abspath(__file__), "_purge_worker", payload])
return {"started": True, "games": len(games), "status": status}
def purge_worker(payload):
args = json.loads(payload)
keep = args["keep_saves"]
result = {"state": "done", "removed": [], "kept": [], "errors": []}
service = True
try:
games = cmd_list_installed({})["games"]
users = steam_users()
for d in games: # SteamVR overlay apps: SteamVR forgets their manifests (while it still runs)
if is_overlay(d) or os.path.exists(os.path.join(ANCHORS, d["package"], OVERLAY_MANIFEST)):
try:
unregister_overlay(d["package"])
result["removed"].append(f"SteamVR overlay: {d.get('title')}")
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{d.get('title')}: {exc}")
try: # the overlay autostart service runs this agent: it goes before AGENT_HOME does
if os.path.exists(VR_OVERLAY_UNIT_PATH):
user_systemctl("disable", "--now", VR_OVERLAY_UNIT + ".service")
os.remove(VR_OVERLAY_UNIT_PATH)
user_systemctl("daemon-reload")
result["removed"].append(VR_OVERLAY_UNIT_PATH)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"overlay autostart service: {exc}")
try:
if remove_library_hooks():
result["removed"].append(LIBRARY_HOOK)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"Steam library hook: {exc}")
try:
service = stop_steam()
except AgentError as exc:
result["errors"].append(str(exc))
service = True
for d in games: # devkit fallback entries (Steam is closed: removed from shortcuts.vdf)
try:
if devkit_unregister(d["package"], steam_running=False):
result["removed"].append(f"Steam devkit entry: {d.get('title')}")
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{d.get('title')}: {exc}")
for u in users:
vdf = os.path.join(STEAM, "userdata", u, "config/shortcuts.vdf")
grid = os.path.join(STEAM, "userdata", u, "config/grid")
for d in games:
try:
if remove_shortcut(vdf, f'"{os.path.join(ANCHORS, d["package"])}/launch.sh"'):
result["removed"].append(f"Steam shortcut: {d.get('title')}")
for art in grid_files(grid, d["appid"]):
os.remove(art)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{d.get('title')}: {exc}")
try: # shortcuts left from games without an install record (older versions, interrupted removals)
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
for sc in list(root.get("shortcuts", {}).values()):
exe = sc.get("Exe", "") if isinstance(sc, dict) else ""
if exe.startswith(f'"{ANCHORS}/') and remove_shortcut(vdf, exe):
result["removed"].append(f"Steam shortcut: {sc.get('AppName') or sc.get('appname')}")
for art in grid_files(grid, sc.get("appid", 0) & 0xFFFFFFFF):
os.remove(art)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"shortcuts: {exc}")
for d in games:
base = d["base"]
saves = [os.path.join(base, n) for n in ("lepton-data", "compatdata")]
if keep and any(os.path.exists(p) for p in saves):
for name in os.listdir(base) if os.path.isdir(base) else []:
p = os.path.join(base, name)
if p not in saves:
remove_tree(p)
result["kept"].append(base)
else:
remove_tree(base)
if not keep and os.path.lexists(base):
result["errors"].append(f"couldn't remove {base}")
result["removed"].append(d.get("title") or d["package"])
for path in remove_desktop_entries(): # Linux apps' Desktop Mode entries
result["removed"].append(path)
for d in games: # <drive>/FramePort folders left empty
if remove_empty_drive_dir(d["base"]):
result["removed"].append(os.path.dirname(d["base"].rstrip("/")))
if not keep or not result["kept"]:
remove_tree(ANCHORS)
else: # anchors hold only launchers/artwork; saves live in the bases
for d in games:
anchor = os.path.join(ANCHORS, d["package"])
if os.path.realpath(anchor) not in [os.path.realpath(k) for k in result["kept"]]:
remove_tree(anchor)
remove_tree(AGENT_HOME)
result["removed"].append(AGENT_HOME)
if os.path.exists(XR_LAYER_MANIFEST):
os.remove(XR_LAYER_MANIFEST)
result["removed"].append(XR_LAYER_MANIFEST)
for name in ("frameport-setup.sh", "frameport-setup.log"): # left by bootstrap.sh
p = os.path.join(HOME, ".cache", name)
if os.path.exists(p):
os.remove(p)
result["removed"].append(p)
except Exception as exc: # noqa: BLE001
result["state"] = "failed"
result["errors"].append(str(exc))
finally:
start_steam(service)
result["finished"] = time.time()
with open(args["status"], "w") as f:
json.dump(result, f)
def cmd_purge_status(args):
try:
return json.load(open(os.path.join(HOME, ".cache/frameport-purge.json")))
except (OSError, ValueError):
return {"state": "none"}
def cmd_cleanup(args):
"""Remove rollback copies (previous-game.apk, settings.conf.previous) and leftover uploads; optional extra paths
under HOME (e.g. an old manual-install folder). Saves and installed games are never touched."""
freed, removed = 0, []
for dep in cmd_list_installed({})["games"]:
base = dep["base"]
for name in (["previous-game.apk", "settings.conf.previous"] if args.get("rollback", True) else []) + \
["incoming", "incoming-artwork"]:
p = os.path.join(base, name)
if os.path.isdir(p):
freed += sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs)
remove_tree(p)
removed.append(p)
elif os.path.exists(p):
freed += os.path.getsize(p)
os.remove(p)
removed.append(p)
bases = [d["base"] for d in cmd_list_installed({})["games"]]
drive_dirs = [d["install_dir"] for d in list_drives() if not d["internal"]]
for extra in args.get("paths", []):
p = os.path.realpath(os.path.expanduser(extra))
first = os.path.relpath(p, HOME).split(os.sep)[0] if p.startswith(HOME + os.sep) else ""
# inside a drive's FramePort folder (leftovers of games on a microSD), never an installed game's files
on_drive = any(p != os.path.realpath(d) and _inside(p, os.path.realpath(d)) for d in drive_dirs) \
and not any(_inside(p, os.path.realpath(b)) or _inside(os.path.realpath(b), p) for b in bases)
if not on_drive and (not first or first.startswith(".") or p == ANCHORS or p.startswith(ANCHORS + os.sep)):
# only ordinary folders in the home folder: never dot folders (.ssh, .steam, .local, .config…)
raise AgentError(f"refusing to remove {extra}")
if os.path.exists(p):
freed += (sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs)
if os.path.isdir(p) else os.path.getsize(p))
remove_tree(p)
removed.append(p)
return {"removed": removed, "freed_bytes": freed}
# ------------------------------------------------------------------------------------------ virtual keyboard
# "Type on Frame": the PC's key presses become a real keyboard on the Frame (Linux uinput; the steamos user may open
# /dev/uinput, an ACL entry made for Steam Input, no root). A real input device reaches everything that has focus:
# Android windows (Lepton's wayland_keyboard), the Steam UI, the desktop, Proton games.
UINPUT = "/dev/uinput"
UI_SET_EVBIT, UI_SET_KEYBIT, UI_DEV_SETUP, UI_DEV_CREATE, UI_DEV_DESTROY = (0x40045564, 0x40045565, 0x405C5503,
0x5501, 0x5502)
EV_SYN, EV_KEY, SYN_REPORT = 0, 1, 0
KEY_LAST = 248 # KEY_ESC (1) .. KEY_MICMUTE (248): every key a PC keyboard sends
KEY_LEFTSHIFT = 42
def text_keys():
"""US layout: character -> (Linux key code, shift)."""
keys = {"\n": (28, False), "\t": (15, False), " ": (57, False), "\b": (14, False)}
for plain, shifted, first in (("1234567890-=", "!@#$%^&*()_+", 2), ("qwertyuiop[]", "QWERTYUIOP{}", 16),
("asdfghjkl;'`", 'ASDFGHJKL:"~', 30), ("\\zxcvbnm,./", "|ZXCVBNM<>?", 43)):
for i, (a, b) in enumerate(zip(plain, shifted, strict=True)):
keys[a] = (first + i, False)
keys[b] = (first + i, True)
return keys
TEXT_KEYS = text_keys()
class VirtualKeyboard:
"""A uinput keyboard. `fd`/`write` can be replaced in tests; the device goes away with close()."""
def __init__(self, fd=None, write=os.write, settle=0.8):
self.write, self.held = write, set()
self.fd = fd
if fd is None:
self.fd = os.open(UINPUT, os.O_WRONLY | os.O_NONBLOCK)
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
for code in range(1, KEY_LAST + 1):
fcntl.ioctl(self.fd, UI_SET_KEYBIT, code)
fcntl.ioctl(self.fd, UI_DEV_SETUP, struct.pack("HHHH80sI", 0x03, 0x1209, 0x4650, 1,
b"FramePort keyboard", 0))
fcntl.ioctl(self.fd, UI_DEV_CREATE)
time.sleep(settle) # let gamescope/libinput pick the new keyboard up before the first key
def _event(self, kind, code, value):
self.write(self.fd, struct.pack("llHHi", 0, 0, kind, code, value))
def key(self, code, value):
"""value 1 = down, 0 = up, 2 = autorepeat."""
if not 0 < int(code) <= KEY_LAST or value not in (0, 1, 2):
return
self._event(EV_KEY, int(code), value)
self._event(EV_SYN, SYN_REPORT, 0)
(self.held.add if value else self.held.discard)(int(code))
def type_text(self, text, delay=0.008):
"""Type characters of the US layout; returns the characters it couldn't type."""
skipped = ""
for ch in text.replace("\r\n", "\n"):
if ch not in TEXT_KEYS:
skipped += ch
continue
code, shift = TEXT_KEYS[ch]
if shift:
self.key(KEY_LEFTSHIFT, 1)
self.key(code, 1)
self.key(code, 0)
if shift:
self.key(KEY_LEFTSHIFT, 0)
time.sleep(delay)
return skipped
def close(self):
for code in list(self.held): # never leave a key stuck when the PC goes away mid-press
self.key(code, 0)
if self.fd is not None and isinstance(self.fd, int):
try:
fcntl.ioctl(self.fd, UI_DEV_DESTROY)
except OSError:
pass
os.close(self.fd)
self.fd = None
def keyboard_session(stdin, stdout, keyboard=None):
"""Long-lived: prints {"ready": true} once the virtual keyboard exists, then reads one JSON object per line:
{"k": code, "v": 1|0|2} (key down/up/repeat) or {"text": "..."}; ends (keyboard removed) at EOF."""
try:
kb = keyboard or VirtualKeyboard()
except OSError as exc:
stdout.write(json.dumps({"ready": False, "error": f"can't create a virtual keyboard: {exc}"}) + "\n")
stdout.flush()
return 1
stdout.write(json.dumps({"ready": True}) + "\n")
stdout.flush()
try:
for line in stdin:
try:
msg = json.loads(line)
except ValueError:
continue
if "k" in msg:
kb.key(msg["k"], msg.get("v", 1))
elif "text" in msg:
skipped = kb.type_text(str(msg["text"]))
stdout.write(json.dumps({"typed": True, "skipped": skipped}) + "\n")
stdout.flush()
except (OSError, ValueError):
pass
finally:
kb.close()
return 0
def steam_js(expression, timeout=5):
"""Evaluate JavaScript in Steam's UI (SharedJSContext) through its CEF devtools port (127.0.0.1:8080, SteamOS
starts Steam with -cef-enable-debugging); returns the value. A minimal websocket client (stdlib only)."""
import base64
import socket
import urllib.request
targets = json.load(urllib.request.urlopen("http://127.0.0.1:8080/json", timeout=timeout))
url = next(t["webSocketDebuggerUrl"] for t in targets if t.get("title") == "SharedJSContext")
path = url.split("127.0.0.1:8080", 1)[1]
with socket.create_connection(("127.0.0.1", 8080), timeout=timeout) as sock:
key = base64.b64encode(os.urandom(16)).decode()
sock.sendall((f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
head = b""
while b"\r\n\r\n" not in head:
chunk = sock.recv(1)
if not chunk:
raise AgentError("Steam's devtools closed the connection")
head += chunk
data = json.dumps({"id": 1, "method": "Runtime.evaluate",
"params": {"expression": expression, "awaitPromise": True,
"returnByValue": True}}).encode()
mask, n = os.urandom(4), len(data)
size = bytes([0x80 | n]) if n < 126 else bytes([0x80 | 126]) + struct.pack(">H", n)
sock.sendall(b"\x81" + size + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
def exact(k):
buf = b""
while len(buf) < k:
chunk = sock.recv(k - len(buf))
if not chunk:
raise AgentError("Steam's devtools closed the connection")
buf += chunk
return buf
while True:
_b1, b2 = exact(2)
k = b2 & 0x7F
k = struct.unpack(">H", exact(2))[0] if k == 126 else struct.unpack(">Q", exact(8))[0] if k == 127 else k
msg = json.loads(exact(k).decode("utf-8", "replace") or "{}")
if msg.get("id") == 1:
res = (msg.get("result") or {}).get("result") or {}
return res.get("value")
FIRST_FRAME_MARKERS = (b"FrameBridge: new layer:", b"FrameBridge: pacing:")
# SteamVR's dashboard UI logs a dashboard opened with the controller's button as "[ToggleDashboard]
# toggle_dashboard_action"; Steam's own start-up menu comes without it (onShowOverlayRequestFromSteam / frame menu)
USER_DASHBOARD_MARKER = b"toggle_dashboard_action"
def user_opened_dashboard(log, pos):
"""(opened, new position): whether the player opened the dashboard (controller button) since `pos` in SteamVR's
vrwebhelper_systemui.txt. A missing log answers no (the worker then behaves as before)."""
try:
with open(log, "rb") as f:
f.seek(0, 2)
end = f.tell()
if pos is None or pos > end: # first look, or the log was rotated: only what comes from now on
return False, end
f.seek(pos)
return USER_DASHBOARD_MARKER in f.read(), end
except OSError:
return False, pos
LOGCAT_EOF = "logcat: Unexpected EOF"
# Lepton's mirror fails in more ways than one: it ends ("Unexpected EOF"), can't read ("Logcat read failure"), or
# prints nothing at all after "Waiting for app" (seen on a first start after an APK change, 2026-10-14)
LOGCAT_FAILURES = (LOGCAT_EOF, "logcat: Logcat read failure")
LOGCAT_ROW = re.compile(r"\d\d-\d\d \d\d:\d\d:\d\d\.\d{3} +\d+ +\d+ [VDIWEF] ") # a logcat -v threadtime line
LEPTON_ROW = re.compile(r"\x1b\[[0-9;]*m\d\d-\d\d \d\d:\d\d:\d\d\.\d{3} ") # Lepton's mirror colours its lines
LOGCAT_SILENCE = 20.0 # seconds without a logcat line after "Waiting for app" = the mirror isn't working (a running
# game's container logs all the time: FrameBridge alone every 5 s)
LOGCAT_BUFFER = "16M" # the container's log buffer: a chatty game overwrote its own first minutes in the default one
def logcat_keeper(log, appid, parent, poll=2.0, restarts=5, popen=None, run=None, clock=None,
silence=LOGCAT_SILENCE):
"""Keep launch.log filling when Lepton's logcat mirror doesn't (LOGCAT_LINE): after "Waiting for app", a failure
line (LOGCAT_FAILURES) or `silence` seconds without a logcat line start our own reader of the container's logcat
(`logcat -T <epoch>`: from the launcher's start when the mirror never wrote a line, else from just before its
last one; nothing is lost while the buffer holds it); restarted from where it stopped if it ends while the game
runs (at most `restarts` times), stopped again when Lepton's own (coloured) lines come back. Enlarges the
container's log buffer once it has booted. Every decision is printed (the launcher sends it to
logcat-keeper.log). Ends with the launcher."""
popen = popen or subprocess.Popen
run = run or subprocess.run
clock = clock or time.time
container = f"lepton-steamlaunch-{appid}"
start = clock() # the keeper starts with the launcher
# (Steam's LD_PRELOAD overlay libraries only add "ld.so: ... cannot be preloaded" lines to the log)
env = {k: v for k, v in os.environ.items() if k != "LD_PRELOAD"}
def say(text):
print(f"{time.strftime('%H:%M:%S')} {text}", flush=True)
def note(text): # also into launch.log, where triage and the dashboard worker read
try:
with open(log, "a") as f:
f.write(f"FramePort: {text}\n")
except OSError:
pass
def alive():
try:
os.kill(int(parent), 0)
return True
except (OSError, ValueError):
return False
pos, booted, buffer_tries, waiting_since = 0, False, 0, None
lepton_rows, last_row, failure, proc, started = False, None, None, None, 0
while alive():
try:
with open(log, "rb") as f:
f.seek(pos)
chunk = f.read().decode("utf-8", "replace")
pos = f.tell()
except OSError:
chunk = ""
if not booted and "Boot complete!" in chunk:
booted = True
if booted and buffer_tries < 5:
buffer_tries += 1
try:
r = run(["podman", "exec", container, "logcat", "-G", LOGCAT_BUFFER], capture_output=True,
text=True, timeout=15, env=env)
ok = r.returncode == 0
say(f"log buffer {LOGCAT_BUFFER}: {'set' if ok else (r.stdout + r.stderr).strip()[:200]}")
except (OSError, subprocess.SubprocessError) as exc:
ok = False
say(f"log buffer {LOGCAT_BUFFER}: {exc}")
if ok:
buffer_tries = 5
if waiting_since is None:
i = chunk.find("Waiting for app")
if i >= 0:
waiting_since, chunk = clock(), chunk[i:]
say("Lepton is waiting for the app; watching its logcat mirror")
if waiting_since is not None and chunk:
ours = proc is not None and proc.poll() is None
if ours and LEPTON_ROW.search(chunk):
proc.terminate()
proc, failure, lepton_rows, last_row = None, None, True, clock()
say("Lepton's mirror is writing again: stopped our reader")
note("Lepton's logcat is back; stopped FramePort's reader")
elif not ours:
if any(m in chunk for m in LOGCAT_FAILURES):
failure = next(m for m in LOGCAT_FAILURES if m in chunk)
elif LOGCAT_ROW.search(chunk):
lepton_rows, last_row = True, clock()
if waiting_since is not None and (proc is None or proc.poll() is not None):
if proc is not None: # our reader ended while the game runs: go on from where it stopped
say(f"our reader ended ({proc.poll()})")
proc, lepton_rows, last_row = None, True, clock() # (go on from about now)
failure = failure or "FramePort's reader ended"
quiet = clock() - (last_row or waiting_since)
after = "" if lepton_rows else " after the app started"
reason = failure or (None if quiet < silence else f"no logcat line for {quiet:.0f} s{after}")
if reason and started < restarts:
started += 1
# nothing came through yet: everything since the start; else from just before the last line seen
since = start if not lepton_rows else last_row - poll - 1
say(f"reading {container}'s logcat ourselves since {since:.3f} ({started}): {reason}")
note(f"Lepton's logcat isn't coming through ({reason}); reading {container}'s logcat ({started})")
out = open(log, "ab")
try:
proc = popen(["podman", "exec", container, "logcat", "-v", "threadtime", "-T", f"{since:.3f}"],
stdout=out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, env=env)
except OSError as exc:
say(f"could not start logcat: {exc}")
proc = None
finally:
out.close()
# what our reader writes is ours: only Lepton's coloured lines count from here on
try:
pos = os.path.getsize(log)
except OSError:
pass
time.sleep(poll)
if proc is not None and proc.poll() is None:
proc.terminate()
say("launcher ended")
def dashboard_worker(log, parent, wait_start=240, window=120, poll=0.5, ui_log=None, max_hides=10):
"""Close SteamVR's dashboard (Steam's "Resume game" frame menu) that opens when the game submits its first VR
frame: watch from FrameBridge's first "new layer:" line (the first submitted frame; Steam showed the menu ~0.3 s
later with ITR2), checking every `poll` s for `window` s after it (at most `max_hides`; agent 59: 120 s / 10, the
menu came back after the first 30 s in the owner's sessions). Waiting for the first
"pacing:" summary (agent 44-51) was ~8 s too late: the owner had pressed Resume by then. Stops for good once the
player opens the dashboard with the controller (agent 52 closed it 60 ms after each press: the game had paused
for it and stayed paused). Ends with the launcher."""
if ui_log is None:
ui_log = os.path.join(STEAM, "logs/vrwebhelper_systemui.txt")
_, ui_pos = user_opened_dashboard(ui_log, None)
def alive():
try:
os.kill(int(parent), 0)
return True
except (OSError, ValueError):
return False
deadline, pos, tail = time.time() + wait_start, 0, b""
while time.time() < deadline and alive():
try:
with open(log, "rb") as f:
f.seek(pos)
chunk = f.read()
pos += len(chunk)
text = tail + chunk
if any(m in text for m in FIRST_FRAME_MARKERS):
break
tail = text[-64:] # a marker split across two reads
except OSError:
pass
time.sleep(poll)
else:
print("no VR frames logged; dashboard left as it is")
return
print(f"{time.strftime('%H:%M:%S')} first VR frame")
hidden, end = 0, time.time() + window
while time.time() < end and hidden < max_hides and alive():
user, ui_pos = user_opened_dashboard(ui_log, ui_pos)
if user: # the player wants the dashboard (or the game's menu button opened it): never close it on them
print(f"{time.strftime('%H:%M:%S')} dashboard opened with the controller: leaving it to the player")
return
try:
if steam_js("SteamClient.OpenVR.VROverlay.IsDashboardVisible()"):
steam_js("SteamClient.OpenVR.VROverlay.HideDashboard()")
hidden += 1
print(f"{time.strftime('%H:%M:%S')} dashboard hidden")
except Exception as exc: # noqa: BLE001 (no devtools port, Steam restarting: leave it)
print(f"steam ui: {exc}")
return
time.sleep(poll)
def not_started(text, waited, grace=30):
"""Lepton's container didn't come up: "is not a running context" with no "Boot complete!" after it for `grace`
seconds. The first start after an APK change prints that message while it waits for the boot and then boots
normally (installing the new APK): agent 53 and older stopped those starts after a few seconds."""
i = text.rfind("is not a running context")
return i >= 0 and "Boot complete!" not in text[i:] and waited >= grace
# ------------------------------------------------------------------------------------------ monitor
# The GUI's Monitor tab: `_monitor` streams one JSON sample per tick (CPU/GPU/memory/temperatures/power/battery, the
# running FramePort games with fps, processes) and takes control lines (interval, process filter, end a process or a
# game) until stdin closes. Sources (dev Frame, SteamOS 0.4.3, kernel 6.18, 2026-10-07): GPU busy = summed DRM fdinfo
# `drm-engine-gpu` ns of every render-node fd (msm), GPU clock from devfreq 3d00000.gpu, 48 thermal zones grouped by
# type, max34417 power monitors (µW; vph = the whole system), the max1720x battery gauge, FrameBridge's `pacing:` lines
# for fps. Cheap by design: a naive sample (fd scan of ~520 processes) cost 43 ms CPU, so kernel threads are skipped
# after their first sighting and render fds are cached per process.
PROC = "/proc"
SYS = "/sys"
MON_INTERVALS = (0.1, 0.25, 0.5, 1, 2, 5) # seconds between samples; processes are scanned every 2 s at any of them
MON_DEFAULT_INTERVAL = 0.5
SCAN_SECONDS = 2.0
MON_FILTERS = ("game", "steam", "all")
# what a client can ask for ({"modules": [...]}, agent v73); without that message every module is collected
MON_MODULES = ("games", "procs", "cpu", "gpu", "mem", "temps", "power", "battery", "net", "disk")
MON_PROC_LIMIT = 150
MON_CONTEXT = 3 # "game" filter: the busiest other processes, shown for context
# processes never signalled: the session, SSH and system plumbing (killing them logs the user out or drops FramePort)
MON_NEVER = ("systemd", "sshd", "sshd-session", "sshd-auth", "dbus-daemon", "dbus-broker", "dbus-broker-lau",
"(sd-pam)", "login", "agetty")
# processes that end the headset session, Steam or the desktop when killed: the GUI asks again ("force")
MON_CRITICAL = ("steam", "steamwebhelper", "reaper", "vrserver", "vrcompositor", "vrmonitor", "vrwebhelper",
"vrdashboard", "vrstartup", "XRServiceLoopTh", "XRService", "gamescope", "gamescope-wl", "Xwayland",
"plasmashell", "kwin_wayland", "kwin_x11", "pipewire", "pipewire-pulse", "wireplumber", "V4L2Cam",
"steamos-session", "gamescope-sessi")
# processes whose command line can name a game (only these are read: cmdlines of ~500 processes cost too much)
MON_ROOT_COMMS = ("conmon", "launch.sh", "reaper", "bash", "sh")
MON_ROOT_PREFIXES = ("python", "proton", "pv-", "wine", "steam-runtime")
AGENT_FILES = {os.path.abspath(__file__), os.path.realpath(__file__)}
STEAM_GAME = "steam:" # monitor: a game Steam started that FramePort didn't install ("steam:<appid>", e.g. a Lepton
# game from Steam's store); its title comes from the appmanifest
REAPER_APPID = re.compile(r"\bAppId=(\d+)")
MON_GROUPS = ( # (group, comm prefixes), first match wins; games are found by their process tree
("steamvr", ("vr", "XRService", "V4L2Cam", "eyetracking", "proxmicmute", "systemlayer")),
("steam", ("steam", "reaper", "fossilize")),
("desktop", ("gamescope", "Xwayland", "plasmashell", "kwin", "xdg-desktop", "pipewire", "wireplumber",
"kded", "ksmserver")),
)
TEMP_GROUPS = ( # (group, zone type prefixes): the hottest zone of each group is shown
("CPU", ("cpu",)), ("GPU", ("gpuss",)), ("Memory", ("ddr",)), ("Battery", ("max1720x",)), ("NPU", ("nsp",)),
("Power ICs", ("pm8",)), ("Modem", ("modem",)), ("Camera", ("camera", "video")), ("Storage", ("ufs",)),
)
RAIL_GROUPS = {"vph": "system", "gfx": "gpu", "apc0": "cpu", "apc1": "cpu", "apc2": "cpu", "nsp1": "npu",
"nsp2": "npu"}
PACING_RE = re.compile(rb"FrameBridge: pacing: ([0-9.]+) fps(?:, displayTime vs predicted: avg ([0-9.]+) ms)?")
CLK_TCK = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100
def _rd(path):
try:
with open(path) as f:
return f.read()
except (OSError, ValueError):
return ""
def _num(text, default=None):
try:
return int(text.strip())
except (ValueError, AttributeError):
return default
def monitor_plan(modules=None):
"""The collectors one tick runs for the requested modules (None, "all" or anything unusable = all of them).
mem includes PSI, temps the zone detail and the fan. The process scan ("scan") runs for games and procs and also
for gpu: GPU busy is summed from the render fds the scan finds; "gpu_ns" (per-process GPU time) for the same."""
if modules is None or modules == "all" or not isinstance(modules, (list, tuple, set, frozenset)):
wanted = set(MON_MODULES)
else:
wanted = {m for m in modules if m in MON_MODULES}
plan = set(wanted)
if wanted & {"games", "procs", "gpu"}:
plan |= {"scan", "gpu_ns"}
return frozenset(plan)
def read_cpu_times():
"""[total, core0, core1, …] as (busy, all) jiffies from /proc/stat."""
out = []
for line in _rd(f"{PROC}/stat").splitlines():
if not line.startswith("cpu"):
break
f = [int(x) for x in line.split()[1:9]]
idle = f[3] + f[4] # idle + iowait
out.append((sum(f) - idle, sum(f)))
return out
def cpu_percent(prev, cur):
"""Busy % per entry of read_cpu_times() between two readings (0 when nothing elapsed)."""
res = []
for (b0, a0), (b1, a1) in zip(prev, cur, strict=False):
res.append(round(100.0 * (b1 - b0) / (a1 - a0), 1) if a1 > a0 else 0.0)
return res
def read_clusters():
"""[{"cpus": [0, 1], "max_mhz": 2265, "policy": path}] per cpufreq policy."""
out = []
policies = glob.glob(f"{SYS}/devices/system/cpu/cpufreq/policy*")
for p in sorted(policies, key=lambda s: _num(s.rsplit("policy", 1)[1], 0)):
cpus = [int(x) for x in _rd(f"{p}/related_cpus").split() if x.isdigit()]
out.append({"cpus": cpus, "max_mhz": (_num(_rd(f"{p}/cpuinfo_max_freq"), 0)) // 1000, "policy": p})
return out
def find_gpu_devfreq():
for d in sorted(glob.glob(f"{SYS}/class/devfreq/*")):
if "gpu" in os.path.basename(d) or "gpu" in _rd(f"{d}/name"):
return d
return None
def read_meminfo():
m = {}
for line in _rd(f"{PROC}/meminfo").splitlines():
k, _, v = line.partition(":")
if k in ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree"):
m[k] = _num(v.split()[0], 0) * 1024
return {"total": m.get("MemTotal", 0), "avail": m.get("MemAvailable", 0), "swap_total": m.get("SwapTotal", 0),
"swap_free": m.get("SwapFree", 0)}
def read_psi():
""""some" avg10 of /proc/pressure/{cpu,memory,io}: % of time something waited on it in the last 10 s."""
out = {}
for k in ("cpu", "memory", "io"):
m = re.search(r"^some avg10=([0-9.]+)", _rd(f"{PROC}/pressure/{k}"), re.M)
if m:
out[k] = float(m.group(1))
return out
def temp_zones():
"""[(group, zone name, temp path)] for every thermal zone in a known group."""
out = []
for z in sorted(glob.glob(f"{SYS}/class/thermal/thermal_zone*"), key=lambda s: _num(s.rsplit("zone", 1)[1], 0)):
name = _rd(f"{z}/type").strip()
group = next((g for g, prefixes in TEMP_GROUPS if name.startswith(prefixes)), None)
if group:
out.append((group, name.replace("-thermal", ""), f"{z}/temp"))
return out
def read_temps(zones):
"""({group: hottest °C}, {group: {zone: °C}})"""
groups, detail = {}, {}
for group, name, path in zones:
v = _num(_rd(path))
if v is None or v <= -40000:
continue
c = round(v / 1000.0, 1)
detail.setdefault(group, {})[name] = c
groups[group] = max(groups.get(group, c), c)
return groups, detail
def power_rails():
"""[(label, power*_input path)] of the max34417 power monitors (µW) that RAIL_GROUPS names."""
out = []
for h in sorted(glob.glob(f"{SYS}/class/hwmon/hwmon*")):
for lab in sorted(glob.glob(f"{h}/power*_label")):
label = _rd(lab).strip()
if label in RAIL_GROUPS: # only the rails the Monitor shows: each read is an I2C transfer
out.append((label, lab[:-len("_label")] + "_input"))
return out
FAST_RAILS = ("vph", "gfx") # read every tick; the others (CPU clusters, NPU) every 5 s
class PowerReader:
"""{"system", "cpu", "gpu", "npu": W, "rails": {label: W}} from the power monitors. Every read is an I2C transfer
(~0.5 ms of kernel time each, more under load), so only the system and GPU rails are read every tick."""
def __init__(self, rails):
self.rails, self.watts, self.slow_at = rails, {}, -1e9
def read(self, now):
slow = now - self.slow_at >= 5
if slow:
self.slow_at = now
for label, path in self.rails:
if slow or label in FAST_RAILS:
uw = _num(_rd(path))
if uw is not None:
self.watts[label] = uw / 1e6
out = {"rails": {k: round(w, 3) for k, w in self.watts.items()}}
for label, w in self.watts.items():
group = RAIL_GROUPS.get(label)
if group:
out[group] = round(out.get(group, 0.0) + w, 3)
return out
def find_fan():
for f in sorted(glob.glob(f"{SYS}/class/hwmon/hwmon*/fan1_input")):
return f
return None
class BatteryReader:
"""battery_state() fields + watts (negative = draining), seconds to empty/full, health, cycles, °C. The gauge sits
on I2C (~0.7 ms of kernel time per file on the Frame), so the paths are found once, current and voltage are read
every 2 s and everything else every 10 s."""
FAST = (("current", "current_now"), ("voltage", "voltage_now"))
SLOW = (("empty_s", "time_to_empty_now"), ("full_s", "time_to_full_now"), ("cycles", "cycle_count"),
("full_uah", "charge_full"), ("design_uah", "charge_full_design"), ("temp", "temp"))
def __init__(self):
self.dir, self.chargers, self.slow, self.slow_at = None, [], {}, -1e9
self.fast, self.fast_at = {}, -1e9
try:
names = sorted(os.listdir(POWER_SUPPLY))
except OSError:
names = []
for name in names:
d = os.path.join(POWER_SUPPLY, name)
kind = _rd(f"{d}/type").strip()
if kind == "Battery" and self.dir is None and _rd(f"{d}/capacity").strip().isdigit():
self.dir = d
elif kind in CHARGER_TYPES:
self.chargers.append(f"{d}/online")
def read(self, now):
if not self.dir:
return None
if now - self.fast_at >= 2:
self.fast = {k: _num(_rd(f"{self.dir}/{f}")) for k, f in self.FAST}
self.fast_at = now
if now - self.slow_at >= 10:
self.slow = {k: _num(_rd(f"{self.dir}/{f}")) for k, f in self.SLOW}
if self.slow.get("temp") is not None:
self.slow["temp"] = self.slow["temp"] / 10.0
self.slow["health"] = _rd(f"{self.dir}/health").strip() or None
self.slow["percent"] = _num(_rd(f"{self.dir}/capacity"))
self.slow["status"] = _rd(f"{self.dir}/status").strip()
self.slow["_charger"] = any(_rd(c).strip() == "1" for c in self.chargers)
self.slow_at = now
status = self.slow.get("status") or ""
b = {"percent": self.slow.get("percent"), "status": status,
"plugged": bool(self.slow.get("_charger")) or status in ("Charging", "Full"),
"draining": status == "Discharging"}
cur, volt = self.fast.get("current"), self.fast.get("voltage")
if cur is not None and volt is not None:
b["watts"] = round(cur * volt / 1e12, 2)
b.update({k: x for k, x in self.slow.items() if x is not None and k not in b and not k.startswith("_")})
return b
def read_net():
"""{iface: (rx bytes, tx bytes)} for the Frame's links."""
out = {}
for line in _rd(f"{PROC}/net/dev").splitlines()[2:]:
name, _, rest = line.partition(":")
name = name.strip()
if name.startswith(("wlan", "usb", "eth", "enp")):
f = rest.split()
out[name] = (int(f[0]), int(f[8]))
return out
def proc_stat(pid):
"""(comm, ppid, utime+stime jiffies, start jiffies, rss pages) from /proc/<pid>/stat, or None."""
s = _rd(f"{PROC}/{pid}/stat")
r = s.rfind(")")
if r < 0:
return None
comm = s[s.find("(") + 1:r]
f = s[r + 2:].split()
try:
return comm, int(f[1]), int(f[11]) + int(f[12]), int(f[19]), int(f[21])
except (IndexError, ValueError):
return None
def drm_fds(pid):
"""File descriptors of a process that are GPU render nodes (/dev/dri/renderD*; Lepton games see /dev/kgsl-3d0)."""
out = []
try:
fds = os.listdir(f"{PROC}/{pid}/fd")
except OSError:
return out
for fd in fds:
try:
t = os.readlink(f"{PROC}/{pid}/fd/{fd}")
except OSError:
continue
if t.startswith(("/dev/dri/render", "/dev/kgsl")):
out.append(fd)
return out
def drm_engine_ns(pid, fds):
"""{fd: (`drm-engine-gpu` ns, `drm-cycles-gpu`)} of the process's render fds (several fds = several DRM
clients); None if gone."""
out = {}
for fd in fds:
text = _rd(f"{PROC}/{pid}/fdinfo/{fd}")
i = text.find("drm-engine-gpu:")
if i < 0:
continue
j = text.find("drm-cycles-gpu:")
out[fd] = (_num(text[i + 15:].split(None, 1)[0], 0),
_num(text[j + 15:].split(None, 1)[0], 0) if j >= 0 else None)
return out or None
def gpu_busy_ns(prev, cur, dt, hz):
"""GPU time (ns) one render fd used between two samples. msm sometimes reports garbage engine time (SteamVR's
compositor near 2**64, rising ~1000x faster than real time: the Monitor showed an idle Frame at 100 %), so a
delta longer than the interval is replaced by cycles / GPU clock."""
ns, cycles = cur[0] - prev[0], None if cur[1] is None or prev[1] is None else cur[1] - prev[1]
if 0 <= ns <= dt * 1.05e9:
return ns
if cycles is not None and cycles >= 0 and hz:
return min(cycles / hz * 1e9, dt * 1e9)
return 0
def cgroup_dir(pid):
"""The cgroup v2 directory of a process that has memory/cpu stats (walks up from a leaf without them)."""
for line in _rd(f"{PROC}/{pid}/cgroup").splitlines():
if line.startswith("0::"):
d = f"{SYS}/fs/cgroup" + line[3:].strip()
while len(d) > len(f"{SYS}/fs/cgroup") and not os.path.exists(f"{d}/memory.current"):
d = os.path.dirname(d)
return d if os.path.exists(f"{d}/memory.current") else None
return None
def cgroup_usage(d):
"""(cpu usage µs, memory bytes) of a cgroup directory."""
m = re.search(r"^usage_usec (\d+)", _rd(f"{d}/cpu.stat"), re.M)
return (int(m.group(1)) if m else None), _num(_rd(f"{d}/memory.current"))
def monitor_group(comm):
for group, prefixes in MON_GROUPS:
if comm.startswith(prefixes):
return group
return "other"
class Monitor:
"""Sampling state: previous counters for deltas, caches (kernel threads, render fds, zones, rails, games)."""
def __init__(self, clock=time.monotonic, uid=None):
self.clock = clock
self.uid = os.getuid() if uid is None else uid
self.ncpu = max(1, len(read_cpu_times()) - 1)
self.clusters = read_clusters()
self.gpu = find_gpu_devfreq()
self.zones = temp_zones()
self.rails = power_rails()
self.fan = find_fan()
self.battery = BatteryReader()
self.power = PowerReader(self.rails)
self.page = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096
self.btime = next((int(x.split()[1]) for x in _rd(f"{PROC}/stat").splitlines() if x.startswith("btime ")), 0)
self.filter = "game"
self.plan = monitor_plan() # collectors per tick (set_modules)
self.kthreads = set()
self.drm = {} # pid -> (fds, time of the fd scan)
self.gpu_ns = {} # pid -> {fd: (drm-engine ns, drm-cycles)} of the last sample
self.gpu_pct = {} # pid -> GPU % in the last tick
self.gpu_acc = {} # pid -> GPU ns since the last process scan (the table shows the average over it)
self.scan_at = -1e9
self.scan_prev = None # time of the previous process scan
self.proc_prev = {} # pid -> (cpu jiffies, time)
self.cpu_prev = None
self.net_prev = None
self.cg_prev = {} # cgroup dir -> (usage µs, time)
self.logs = {} # launch.log path -> [offset, fps, avg ms, time of the line]
self.deps = []
self.deps_at = -1e9
self.steam_titles = {}
self.tick = 0
self.last = None # time of the previous sample
self.disk_at = -1e9
self.zone_detail_at = -1e9
self.temps, self.zone_detail, self.temps_at = None, {}, -1e9
self.procs = [] # last scan (all, unfiltered), for kill checks
self.games = []
self.roots = {}
self.root_cache = {} # (pid, start, comm) -> (package, kind) | None
self.names = {} # (pid, start, comm) -> full name of a process whose comm was truncated
def static(self):
gpu_max = _num(_rd(f"{self.gpu}/max_freq"), 0) // 1000000 if self.gpu else None
return {"agent": AGENT_VERSION, "cores": self.ncpu, "gpu_max_mhz": gpu_max,
"clusters": [{"cpus": c["cpus"], "max_mhz": c["max_mhz"]} for c in self.clusters],
"mem_total": read_meminfo()["total"], "rails": [r[0] for r in self.rails],
"temp_groups": sorted({z[0] for z in self.zones}, key=[g for g, _ in TEMP_GROUPS].index),
"fan": bool(self.fan), "intervals": list(MON_INTERVALS), "filters": list(MON_FILTERS),
"modules": list(MON_MODULES)}
def set_modules(self, modules):
"""Collect only these modules from the next tick on (None/"all" = everything)."""
plan = monitor_plan(modules)
if ("scan" in plan and "scan" not in self.plan) or ("procs" in plan and "procs" not in self.plan):
self.scan_at = -1e9 # processes wanted (again): scan at once
self.plan = plan
# -------------------------------------------------------------------- games
def deployments(self, now):
if now - self.deps_at > 30:
self.deps = cmd_list_installed({})["games"]
self.deps_at = now
return self.deps
def fps(self, base, now):
"""Latest FrameBridge pacing of a game's launch.log (read incrementally): (fps, avg ms) or (None, None)."""
path = os.path.join(base, "launch.log")
st = self.logs.setdefault(path, [None, None, None, 0.0])
try:
size = os.path.getsize(path)
except OSError:
return None, None
if st[0] is None or size < st[0]: # first look or a new launch truncated it: only the last 64 KiB
st[0] = max(0, size - 65536)
if size > st[0]:
with open(path, "rb") as f:
f.seek(st[0])
data = f.read(min(size - st[0], 1 << 20))
st[0] += len(data)
for m in PACING_RE.finditer(data):
st[1] = float(m.group(1))
st[2] = float(m.group(2)) if m.group(2) else None
st[3] = now
if st[1] is None or now - st[3] > 15: # pacing comes every ~5 s; older = loading or gone
return None, None
return st[1], st[2]
# ---------------------------------------------------------------- processes
def scan(self, now, wall):
"""Every process worth showing: uid 1000 + everything inside a game container. Groups by process tree."""
raw = {}
try:
names = os.listdir(PROC)
except OSError:
names = []
for name in names:
if not name.isdigit():
continue
pid = int(name)
if pid in self.kthreads:
continue
st = proc_stat(pid)
if st is None:
continue
if st[1] == 2 or pid == 2: # kernel threads: never shown, skipped from now on
self.kthreads.add(pid)
continue
try:
uid = os.stat(f"{PROC}/{pid}").st_uid
except OSError:
continue
raw[pid] = (st, uid)
self.kthreads &= {int(n) for n in names if n.isdigit()}
deps = self.deployments(now)
by_appid = {str(d.get("appid")): d for d in deps}
roots = {} # pid -> (package, "container" | "launcher"): a game container's conmon, its launch.sh/reaper,
# Proton processes started in its folder (their whole process trees are the game)
bases = [(d["base"].rstrip("/") + "/", d["package"]) for d in deps if d.get("kind") in ("pcvr", "linux")]
anchors = [(os.path.join(ANCHORS, d["package"], "launch.sh"), d["package"]) for d in deps]
for pid, ((comm, _ppid, _cpu, start, _rss), uid) in raw.items():
if uid != self.uid or not (comm in MON_ROOT_COMMS or comm.startswith(MON_ROOT_PREFIXES)):
continue
key = (pid, start, comm)
if key not in self.root_cache:
cmd = _rd(f"{PROC}/{pid}/cmdline").replace("\0", " ")
root = None
if comm == "conmon":
m = re.search(r"lepton-steamlaunch-(\d+)", cmd)
if m and m.group(1) in by_appid:
root = (by_appid[m.group(1)]["package"], "container")
elif not any(a in cmd for a in AGENT_FILES):
# the agent's own workers name a game's launch.sh too (uninstall's shortcut removal while Steam
# restarts): they aren't the game (helpers launch.sh starts still count, as its children)
pkg = next((k for path, k in anchors if path in cmd), None) or \
next((k for base, k in bases if base in cmd), None)
root = (pkg, "launcher") if pkg else None
m = REAPER_APPID.search(cmd) if comm == "reaper" and not pkg else None
# any other game Steam runs (its reaper); not SteamVR itself, not a devkit entry of ours
if m and m.group(1) not in by_appid and m.group(1) != STEAMVR_APPID and "/devkit-game/" not in cmd:
root = (STEAM_GAME + m.group(1), "steam")
self.root_cache[key] = root
if self.root_cache[key]:
roots[pid] = self.root_cache[key]
children = {}
for pid, ((_comm, ppid, *_), _uid) in raw.items():
children.setdefault(ppid, []).append(pid)
game_of = {}
for root, (pkg, _kind) in roots.items():
stack = [root]
while stack:
p = stack.pop()
if p in game_of:
continue
game_of[p] = pkg
stack.extend(children.get(p, ()))
out = []
for pid, ((comm, ppid, cpu, start, rss), uid) in raw.items():
pkg = game_of.get(pid)
if uid != self.uid and not pkg:
continue
prev = self.proc_prev.get(pid)
cpu_pct = 0.0
if prev and now > prev[1]:
cpu_pct = 100.0 * (cpu - prev[0]) / CLK_TCK / (now - prev[1]) / self.ncpu
self.proc_prev[pid] = (cpu, now)
name = comm
if len(comm) == 15: # truncated by the kernel (Android shows "lus4xvrplayerov"): the command line's name
key = (pid, start, comm)
if key not in self.names:
arg0 = _rd(f"{PROC}/{pid}/cmdline").split("\0", 1)[0]
self.names[key] = os.path.basename(arg0) if comm in arg0 else comm
name = self.names[key] or comm
out.append({"pid": pid, "ppid": ppid, "name": name, "group": f"game:{pkg}" if pkg else monitor_group(comm),
"game": pkg, "cpu": round(max(cpu_pct, 0.0), 1), "rss": rss * self.page,
"age": max(0, int(wall - (self.btime + start / CLK_TCK))) if self.btime else None,
"uid": uid, "critical": comm in MON_CRITICAL, "locked": comm in MON_NEVER})
# render fds: new processes now; a busy game process without any every 4 s for its first 2 min (Android
# games open the GPU a while after starting; a game container has ~90 mostly idle processes), others
# every 60 s (listing ~300 fds of a steamwebhelper costs)
fds = self.drm.get(pid)
young_game = pkg and (p_age := out[-1]["age"]) is not None and p_age < 120 and out[-1]["cpu"] >= 1.0
if fds is None or now - fds[1] > (4 if young_game and not fds[0] else 60):
self.drm[pid] = (drm_fds(pid), now)
alive = set(raw)
for d in (self.proc_prev, self.drm, self.gpu_ns, self.gpu_pct):
for pid in [p for p in d if p not in alive]:
del d[pid]
for cache in (self.root_cache, self.names):
for key in [k for k in cache if k[0] not in alive]:
del cache[key]
self.procs = out
self.roots = roots
return out
def gpu_sample(self, now, dt):
"""GPU % per process (from the cached render fds) and the summed busy %."""
total = 0.0
hz = _num(_rd(f"{self.gpu}/cur_freq"), 0) if self.gpu else 0
for pid, (fds, _t) in list(self.drm.items()):
if not fds:
continue
cur = drm_engine_ns(pid, fds)
if cur is None:
continue
prev = self.gpu_ns.get(pid)
self.gpu_ns[pid] = cur
if prev is not None and dt > 0:
used = sum(gpu_busy_ns(prev[fd], c, dt, hz) for fd, c in cur.items() if fd in prev)
pct = min(100.0, used / 1e7 / dt)
self.gpu_pct[pid] = pct
self.gpu_acc[pid] = self.gpu_acc.get(pid, 0) + used
total += pct
return min(100.0, total)
def games_sample(self, now):
"""Running FramePort games: title, kind, start time, CPU/memory of the container's cgroup, GPU %, fps."""
deps = {d["package"]: d for d in self.deployments(now)}
out = []
for pkg in sorted({v[0] for v in self.roots.values()}):
d = deps.get(pkg) or (self.steam_game(pkg[len(STEAM_GAME):]) if pkg.startswith(STEAM_GAME) else {})
pids = [p["pid"] for p in self.procs if p["game"] == pkg]
cpu = sum(p["cpu"] for p in self.procs if p["game"] == pkg)
rss = sum(p["rss"] for p in self.procs if p["game"] == pkg)
mem = None
conmon = [pid for pid, (k, kind) in self.roots.items() if k == pkg and kind == "container"]
kids = [p["pid"] for p in self.procs if p["ppid"] in conmon]
cg = cgroup_dir(kids[0]) if kids else None
if cg and cg != cgroup_dir(conmon[0]): # the container's own cgroup (conmon's is the user's)
usage, mem = cgroup_usage(cg)
prev = self.cg_prev.get(cg)
self.cg_prev[cg] = (usage, now)
if usage is not None and prev and prev[0] is not None and now > prev[1]:
cpu = 100.0 * (usage - prev[0]) / 1e6 / (now - prev[1]) / self.ncpu
ages = [p["age"] for p in self.procs if p["game"] == pkg and p["age"] is not None]
fps, ms = self.fps(d["base"], now) if d.get("base") else (None, None)
out.append({"package": pkg, "title": d.get("title") or pkg, "kind": d.get("kind", "quest"),
"appid": d.get("appid"), "elapsed": max(ages) if ages else None,
"cpu": round(max(cpu, 0.0), 1), "mem": mem if mem is not None else rss,
"gpu": round(sum(self.gpu_pct.get(p, 0.0) for p in pids), 1),
"fps": fps, "frame_ms": ms, "processes": len(pids), "overlay": is_overlay(d)})
# the game in front first: overlay apps (they run next to games) last, then the most recently started
out.sort(key=lambda g: (g["overlay"], g["elapsed"] is None, g["elapsed"] or 0))
self.games = out
return out
def steam_game(self, appid):
"""{title, kind, appid} of a Steam game FramePort didn't install (cached: appmanifest/shortcuts reads)."""
if appid not in self.steam_titles:
app = find_app_id(appid)
name = (app or {}).get("name")
if not name:
for user in steam_users():
name = shortcut_names(user).get(appid)
if name:
break
self.steam_titles[appid] = name or f"App {appid}"
return {"title": self.steam_titles[appid], "kind": "steam", "appid": appid}
def filtered(self, now=None):
procs = self.procs
span = (now - self.scan_prev) if now is not None and self.scan_prev is not None else 0.0
for p in procs: # GPU % averaged over the time since the previous scan (per tick it's noisy at 0.1 s)
acc = self.gpu_acc.get(p["pid"], 0)
p["gpu"] = round(min(100.0, acc / 1e7 / span), 1) if span > 0 else \
round(self.gpu_pct.get(p["pid"], 0.0), 1)
self.gpu_acc = {}
if self.filter == "game":
shown = [p for p in procs if p["game"]]
others = sorted((p for p in procs if not p["game"]), key=lambda p: (-p["cpu"], -p["rss"]))
shown += [dict(p, context=True) for p in others[:MON_CONTEXT]]
elif self.filter == "steam":
shown = [p for p in procs if p["group"] in ("steam", "steamvr", "desktop")]
else:
shown = list(procs)
shown.sort(key=lambda p: (-p["cpu"], -p["gpu"], -p["rss"]))
return shown[:MON_PROC_LIMIT]
# ------------------------------------------------------------------- sample
def sample(self, wall=None):
t0 = time.process_time()
now = self.clock()
wall = time.time() if wall is None else wall
dt = now - self.last if self.last is not None else 0.0
self.last = now
out = {"t": round(wall, 3), "dt": round(dt, 3)}
plan = self.plan
if "cpu" in plan:
cur = read_cpu_times()
if self.cpu_prev:
pct = cpu_percent(self.cpu_prev, cur)
out["cpu"] = {"total": pct[0] if pct else 0.0, "cores": pct[1:]}
else:
out["cpu"] = {"total": 0.0, "cores": [0.0] * self.ncpu}
self.cpu_prev = cur
out["cpu"]["mhz"] = [_num(_rd(f"{c['policy']}/scaling_cur_freq"), 0) // 1000 for c in self.clusters]
else:
self.cpu_prev = None # no average over the time it was off
if "mem" in plan:
out["mem"] = read_meminfo()
out["psi"] = read_psi()
if "temps" in plan:
if now - self.temps_at >= 2 or self.temps is None: # 48 sensor files (~3 ms at idle clocks); heat is slow
self.temps, self.zone_detail = read_temps(self.zones)
self.temps_at = now
temps, detail = self.temps, self.zone_detail
out["temps"] = temps
if now - self.zone_detail_at >= 5:
out["zones"] = detail
self.zone_detail_at = now
if self.fan:
out["fan"] = _num(_rd(self.fan))
if "power" in plan:
out["power"] = self.power.read(now)
if "battery" in plan:
out["battery"] = self.battery.read(now)
if "net" in plan:
net = read_net()
if self.net_prev and dt > 0:
prev = self.net_prev
out["net"] = {k: [max(0, int((v[0] - prev[k][0]) / dt)), max(0, int((v[1] - prev[k][1]) / dt))]
for k, v in net.items() if k in prev}
self.net_prev = net
else:
self.net_prev = None # a delta over one tick's dt needs the previous tick's counters
if "disk" in plan and now - self.disk_at >= 30:
try:
st = os.statvfs(HOME)
out["disk"] = {"free": st.f_bavail * st.f_frsize, "total": st.f_blocks * st.f_frsize}
except OSError:
pass
self.disk_at = now
scan = "scan" in plan and (now - self.scan_at >= SCAN_SECONDS or not self.procs)
if scan:
self.scan(now, wall)
self.scan_at = now
if "gpu_ns" in plan:
busy = self.gpu_sample(now, dt)
else:
busy = None
self.gpu_ns, self.gpu_pct, self.gpu_acc = {}, {}, {} # stale counters would spike on the next delta
if "gpu" in plan:
out["gpu"] = {"busy": round(busy, 1) if dt > 0 and busy is not None else None,
"mhz": _num(_rd(f"{self.gpu}/cur_freq"), 0) // 1000000 if self.gpu else None}
if "games" in plan:
out["games"] = self.games_sample(now)
if scan and "procs" in plan:
out["procs"] = self.filtered(now)
out["filter"] = self.filter
self.scan_prev = now
elif scan:
self.gpu_acc = {}
self.scan_prev = now
self.tick += 1
out["self_ms"] = round((time.process_time() - t0) * 1000, 2)
return out
# ------------------------------------------------------------------ actions
def ancestors(self):
pids, p = set(), os.getpid()
while p > 1:
pids.add(p)
st = proc_stat(p)
if not st:
break
p = st[1]
return pids
def kill(self, pid, sig="TERM", force=False, wait=3.0):
"""Signal one process the Monitor shows. TERM waits up to `wait` s and reports whether it ended."""
import signal
pid = int(pid)
st = proc_stat(pid)
if st is None:
return {"ended": True, "pid": pid, "note": "already gone"}
comm = st[0]
if pid <= 2 or pid in self.ancestors() or comm in MON_NEVER:
raise AgentError(f"{comm} ({pid}) is part of the system or of FramePort's connection; it can't be ended")
try:
uid = os.stat(f"{PROC}/{pid}").st_uid
except OSError:
return {"ended": True, "pid": pid, "note": "already gone"}
in_game = any(p["pid"] == pid and p["game"] for p in self.procs)
if uid != self.uid and not in_game:
raise AgentError(f"{comm} ({pid}) belongs to another user")
if comm in MON_CRITICAL and not force:
raise AgentError(f"CRITICAL: {comm}")
signum = signal.SIGKILL if sig == "KILL" else signal.SIGTERM
try:
os.kill(pid, signum)
except ProcessLookupError:
return {"ended": True, "pid": pid}
except PermissionError as exc:
raise AgentError(f"not allowed to end {comm} ({pid}): {exc}") from exc
end = time.monotonic() + wait
while time.monotonic() < end:
st = proc_stat(pid)
if st is None or _rd(f"{PROC}/{pid}/status").find("State:\tZ") >= 0:
return {"ended": True, "pid": pid, "name": comm}
time.sleep(0.1)
return {"ended": False, "pid": pid, "name": comm}
def steam_app_running(appid):
"""A Steam reaper runs this app id (a Steam game FramePort didn't install)."""
for name in os.listdir(PROC):
if name.isdigit() and _rd(f"{PROC}/{name}/comm").strip() == "reaper" and \
re.search(rf"\bAppId={appid}\b", _rd(f"{PROC}/{name}/cmdline").replace("\0", " ")):
return True
return False
def monitor_end_game(pkg, wait=6.0):
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs.
A Steam game FramePort didn't install ("steam:<appid>"): Steam's Exit game only."""
if str(pkg).startswith(STEAM_GAME):
appid = str(pkg)[len(STEAM_GAME):]
if not appid.isdigit():
raise AgentError(f"bad Steam app id {appid!r}")
if not steam_app_running(appid):
return {"ended": True, "via": None, "package": pkg, "note": "not running"}
steam_js(f"SteamClient.Apps.TerminateApp('{appid}', false)")
end = time.monotonic() + wait
while time.monotonic() < end and steam_app_running(appid):
time.sleep(0.5)
return {"ended": not steam_app_running(appid), "via": "steam", "package": pkg}
pkg = check_pkg(pkg)
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
def running():
if dep.get("kind") in ("pcvr", "linux"):
return bool(pcvr_pids(dep["base"]))
return container_running(dep["appid"])
if not running():
return {"ended": True, "via": None, "package": pkg, "note": "not running"}
via = None
devkit = devkit_gameid(pkg)
appid = (devkit_appid(devkit) if devkit else None) or dep.get("appid")
try:
steam_js(f"SteamClient.Apps.TerminateApp('{steam_gameid(appid)}', false)")
via = "steam"
end = time.monotonic() + wait
while time.monotonic() < end and running():
time.sleep(0.5)
except Exception: # noqa: BLE001 (Steam's UI unreachable: stop it directly)
pass
if running():
cmd_stop({"package": pkg})
via = "stop"
end = time.monotonic() + wait
while time.monotonic() < end and running():
time.sleep(0.5)
return {"ended": not running(), "via": via, "package": pkg}
def monitor_session(stdin, stdout, monitor=None, sleep=time.sleep, max_ticks=None):
"""Long-lived: prints {"ready": 1, "static": {...}}, then one sample per tick. Control lines on stdin:
{"interval": 1|2|5}, {"procs": "game"|"steam"|"all"}, {"pause": bool}, {"modules": [...]|"all"} (v73: only those
collectors run, see monitor_plan), {"id": n, "kill": pid, "sig": "TERM"|"KILL", "force": bool}, {"id": n,
"end_game": package} (each with an id gets {"reply": n, "ok": …}). Ends at EOF or when the SSH session (parent)
is gone."""
import threading
mon = monitor or Monitor()
lock = threading.Lock()
state = {"interval": MON_DEFAULT_INTERVAL, "stop": False, "pause": False}
wake = threading.Event()
def emit(obj):
with lock:
stdout.write(json.dumps(obj, separators=(",", ":")) + "\n")
stdout.flush()
def act(msg):
try:
if "kill" in msg:
res = mon.kill(msg["kill"], msg.get("sig", "TERM"), bool(msg.get("force")))
else:
res = monitor_end_game(msg["end_game"])
emit({"reply": msg.get("id"), "ok": True, "result": res})
except AgentError as exc:
emit({"reply": msg.get("id"), "ok": False, "error": str(exc)})
except Exception as exc: # noqa: BLE001
emit({"reply": msg.get("id"), "ok": False, "error": f"{type(exc).__name__}: {exc}"})
wake.set() # a fresh sample shows the result
def reader():
try:
for line in stdin:
try:
msg = json.loads(line)
except ValueError:
continue
if not isinstance(msg, dict):
continue
if msg.get("interval") in MON_INTERVALS:
state["interval"] = float(msg["interval"])
if msg.get("procs") in MON_FILTERS:
mon.filter = msg["procs"]
mon.scan_at = -1e9 # rescan now
if "pause" in msg:
state["pause"] = bool(msg["pause"])
if "modules" in msg and hasattr(mon, "set_modules"):
mon.set_modules(msg["modules"])
if "kill" in msg or "end_game" in msg:
threading.Thread(target=act, args=(msg,), daemon=True).start()
continue
wake.set()
except (OSError, ValueError):
pass
state["stop"] = True
wake.set()
emit({"ready": 1, "static": mon.static()})
threading.Thread(target=reader, daemon=True).start()
ticks = 0
parent = os.getppid()
try:
while not state["stop"]:
if not state["pause"]:
emit(mon.sample())
ticks += 1
if max_ticks is not None and ticks >= max_ticks:
break
wake.clear()
if sleep is time.sleep:
wake.wait(state["interval"])
else:
sleep(state["interval"])
if os.getppid() != parent: # SSH session gone without closing stdin
break
except (OSError, ValueError, BrokenPipeError):
pass
return 0
# ------------------------------------------------------------------------------------------ save backups (#352)
# Steam doesn't sync Quest games' saves: the PC app backs them up and restores them. The game's two storage roots (its
# internal /data/data/<pkg> folder and its /sdcard/Android/data/<pkg> folder) go into one tar.gz streamed over the SSH
# exec channel: `_backup_saves '<json>'` = archive on stdout, one result JSON line on stderr; `_restore_saves '<json>'`
# = archive on stdin, one result JSON line on stdout. Both refuse while the game runs and hold its launch lock. Files
# Lepton's containers own are read and written in podman's user namespace (`podman unshare`, where the steamos user
# owns every mapped id). Caches, shader/program binaries, IL2CPP copies, FrameBridge's own files, videos/OBBs and
# anything over SAVES_MAX_FILE stay out; the manifest (last member) lists files, sizes, sha256 and what was left out.
SAVES_MANIFEST = "frameport-saves.json"
SAVES_ROLLBACK = "saves-before-restore.tar.gz"
SAVES_FORMAT = 1
SAVES_MAX_FILE = 64 << 20 # larger files are left out of a backup (the manifest's "skipped", reason "size")
SAVES_MAX_TOTAL = 4 << 30 # a bigger backup is refused (that's game content, not saves)
SAVES_RESTORE_MAX_FILE = 1 << 30 # restore (untrusted input): one member at most this big ...
SAVES_RESTORE_MAX_TOTAL = 8 << 30 # ... all of them together at most this big
SAVES_RESTORE_MAX_MEMBERS = 200000
SAVES_MANIFEST_MAX = 32 << 20
# folder names (any depth, case-insensitive) that only hold what a game rebuilds by itself (Android's own backup
# leaves out cache/code_cache/no_backup too)
SAVES_SKIP_DIRS = {"cache", "code_cache", "code cache", "no_backup", "il2cpp", "unityshadercache", "gpucache",
"shadercache", "grshadercache", "dawncache", "dawngraphitecache", "dawnwebgpucache",
"programbinarycache", "vulkanprogrambinarycache", "pipelinecaches", "fp_vk_shaders", "fp_spirv",
"cachestorage", "scriptcache"}
SAVES_SKIP_UNDER_SAVED = {"logs", "crashes"} # Unreal's Saved/Logs and Saved/Crashes
SAVES_SKIP_EXT = (".obb", ".apk", ".foz", ".mp4", ".m4v", ".mkv", ".webm", ".mov")
SAVES_SKIP_PREFIX = ("fb_snap_", "framebridge") # FrameBridge's snapshots, config and state (FramePort writes them)
def saves_roots(base, pkg):
"""{"internal": .../internal/<pkg> (= /data/data/<pkg>), "external": .../external/Android/data/<pkg>}."""
data = os.path.join(base, "lepton-data")
return {"internal": os.path.join(data, "internal", pkg),
"external": os.path.join(data, "external", "Android", "data", pkg)}
def _saves_skip_dir(rel_parts, name):
low = name.lower()
if low in SAVES_SKIP_DIRS:
return True
return low in SAVES_SKIP_UNDER_SAVED and bool(rel_parts) and rel_parts[-1].lower() == "saved"
def _saves_skip_file(name):
low = name.lower()
return low.endswith(SAVES_SKIP_EXT) or low.startswith(SAVES_SKIP_PREFIX)
def collect_saves(roots, max_file=SAVES_MAX_FILE):
"""What a backup holds: {"dirs": [(arc, path, stat)], "files": [(arc, path, stat)], "skipped": [{path, size,
reason}]}, sorted. Symlinks (Lepton's links), devices and pipes are never followed or included."""
import stat as st_
out = {"dirs": [], "files": [], "skipped": []}
for key in sorted(roots):
root = roots[key]
try:
rst = os.lstat(root)
except OSError:
continue
if not st_.S_ISDIR(rst.st_mode):
out["skipped"].append({"path": key, "size": 0, "reason": "not a folder"})
continue
out["dirs"].append((key, root, rst))
for cur, dirs, files in os.walk(root, topdown=True, followlinks=False):
rel = os.path.relpath(cur, root)
parts = [] if rel == "." else rel.split(os.sep)
keep = []
for d in sorted(dirs):
p = os.path.join(cur, d)
arc = "/".join([key, *parts, d])
try:
s = os.lstat(p)
except OSError:
continue
if st_.S_ISLNK(s.st_mode):
out["skipped"].append({"path": arc, "size": 0, "reason": "link"})
elif _saves_skip_dir(parts, d):
out["skipped"].append({"path": arc + "/", "size": 0, "reason": "cache"})
else:
keep.append(d)
out["dirs"].append((arc, p, s))
dirs[:] = keep
for f in sorted(files):
p = os.path.join(cur, f)
arc = "/".join([key, *parts, f])
try:
s = os.lstat(p)
except OSError:
continue
if not st_.S_ISREG(s.st_mode):
out["skipped"].append({"path": arc, "size": 0,
"reason": "link" if st_.S_ISLNK(s.st_mode) else "special"})
elif _saves_skip_file(f):
out["skipped"].append({"path": arc, "size": s.st_size, "reason": "not a save"})
elif s.st_size > max_file:
out["skipped"].append({"path": arc, "size": s.st_size, "reason": "size"})
elif not os.access(p, os.R_OK):
out["skipped"].append({"path": arc, "size": s.st_size, "reason": "unreadable"})
else:
out["files"].append((arc, p, s))
return out
class _HashingReader:
"""A file read for the archive, hashed on the way (tarfile reads exactly the member's size)."""
def __init__(self, f):
self.f, self.h = f, hashlib.sha256()
def read(self, n=-1):
data = self.f.read(n)
self.h.update(data)
return data
def _saves_tarinfo(arc, s, is_dir):
import tarfile
ti = tarfile.TarInfo(arc)
ti.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE
ti.mode = s.st_mode & 0o7777
ti.uid, ti.gid = s.st_uid, s.st_gid
ti.uname = ti.gname = ""
ti.mtime = int(s.st_mtime)
ti.size = 0 if is_dir else s.st_size
return ti
def _dep_version(dep):
"""The installed build's game version (deployments record the APK's version as source_version)."""
return str((dep or {}).get("version") or (dep or {}).get("source_version") or "")
def write_saves_tar(out, pkg, roots, dep=None, max_file=SAVES_MAX_FILE, max_total=SAVES_MAX_TOTAL,
automatic=False):
"""Stream the game's saves as tar.gz to `out` (a binary file object). Returns the manifest (also the archive's
last member, frameport-saves.json)."""
import gzip
import io
import tarfile
found = collect_saves(roots, max_file)
total = sum(s.st_size for _a, _p, s in found["files"])
if total > max_total:
raise AgentError(f"{pkg}'s saves are {total / 2**30:.1f} GiB, more than the {max_total / 2**30:.0f} GiB a "
f"save backup takes (that's game content, not saves)")
manifest = {"format": SAVES_FORMAT, "package": pkg, "title": (dep or {}).get("title") or pkg,
"version": _dep_version(dep), "created": time.time(), "agent": AGENT_VERSION,
"automatic": automatic, "roots": sorted(roots), "max_file": max_file,
"files": [], "dirs": [], "skipped": found["skipped"]}
gz = gzip.GzipFile(fileobj=out, mode="wb", compresslevel=6)
with tarfile.open(fileobj=gz, mode="w|", format=tarfile.PAX_FORMAT) as tar:
for arc, _p, s in found["dirs"]:
tar.addfile(_saves_tarinfo(arc, s, True))
manifest["dirs"].append({"path": arc, "mode": s.st_mode & 0o7777, "uid": s.st_uid, "gid": s.st_gid})
for arc, p, s in found["files"]:
with open(p, "rb") as f:
reader = _HashingReader(f)
tar.addfile(_saves_tarinfo(arc, s, False), reader)
manifest["files"].append({"path": arc, "size": s.st_size, "sha256": reader.h.hexdigest(),
"mode": s.st_mode & 0o7777, "uid": s.st_uid, "gid": s.st_gid,
"mtime": int(s.st_mtime)})
raw = json.dumps(manifest, indent=1).encode()
ti = tarfile.TarInfo(SAVES_MANIFEST)
ti.size, ti.mtime, ti.mode = len(raw), int(manifest["created"]), 0o644
tar.addfile(ti, io.BytesIO(raw))
gz.close()
return manifest
def saves_summary(manifest):
return {"package": manifest.get("package"), "files": len(manifest.get("files") or []),
"bytes": sum(int(f.get("size") or 0) for f in manifest.get("files") or []),
"skipped": [s for s in manifest.get("skipped") or [] if s.get("reason") in ("size", "unreadable")],
"created": manifest.get("created"), "version": manifest.get("version")}
def _saves_member_name(name):
"""A member's path as parts, or None when it isn't confined to internal/ or external/ (absolute, '..', NUL,
backslashes, overlong)."""
if not isinstance(name, str) or not name or "\0" in name or "\\" in name or name.startswith("/"):
return None
parts = [p for p in name.split("/") if p not in ("", ".")]
if not parts or parts[0] not in ("internal", "external") or ".." in parts:
return None
if len(name) > 4096 or any(len(p) > 255 for p in parts):
return None
return parts
def stage_saves_archive(inp, staging, max_file=SAVES_RESTORE_MAX_FILE, max_total=SAVES_RESTORE_MAX_TOTAL,
max_members=SAVES_RESTORE_MAX_MEMBERS):
"""Read an untrusted save archive from `inp` into `staging` (flat numbered files, never the members' names).
Returns (entries {parts tuple: {kind, mode, mtime, staged, size, sha256}}, manifest). Refuses links, devices, paths
outside the two roots, duplicates, oversize members and files that don't match the manifest."""
import tarfile
entries, manifest, total, count = {}, None, 0, 0
try:
tar = tarfile.open(fileobj=inp, mode="r|gz")
except (tarfile.TarError, OSError, EOFError) as exc:
raise AgentError(f"not a save backup (unreadable archive: {exc})") from None
try:
for m in tar:
count += 1
if count > max_members:
raise AgentError("the backup has too many files")
if m.name == SAVES_MANIFEST:
if not m.isreg() or m.size > SAVES_MANIFEST_MAX or manifest is not None:
raise AgentError("the backup's manifest is invalid")
try:
manifest = json.loads(tar.extractfile(m).read().decode("utf-8"))
except ValueError:
raise AgentError("the backup's manifest is invalid") from None
continue
parts = _saves_member_name(m.name)
if parts is None:
raise AgentError(f"the backup has a file outside the game's save folders: {m.name[:200]!r}")
key = tuple(parts)
if key in entries:
raise AgentError(f"the backup has {m.name[:200]!r} twice")
if m.isdir():
entries[key] = {"kind": "dir", "mode": m.mode & 0o7777, "mtime": int(m.mtime or 0)}
continue
if not m.isreg():
raise AgentError(f"the backup has a link or special file: {m.name[:200]!r}")
if len(parts) < 2:
raise AgentError(f"the backup has a file in place of a save folder: {m.name[:200]!r}")
if m.size > max_file:
raise AgentError(f"{m.name[:200]!r} in the backup is too big ({m.size} bytes)")
total += m.size
if total > max_total:
raise AgentError("the backup is too big to restore")
staged = os.path.join(staging, f"{len(entries):08d}")
src = tar.extractfile(m)
h = hashlib.sha256()
with open(staged, "wb") as f:
left = m.size
while left:
chunk = src.read(min(left, 1 << 20))
if not chunk:
raise AgentError("the backup is cut off")
h.update(chunk)
f.write(chunk)
left -= len(chunk)
entries[key] = {"kind": "file", "mode": m.mode & 0o7777, "mtime": int(m.mtime or 0), "staged": staged,
"size": m.size, "sha256": h.hexdigest()}
except (tarfile.TarError, EOFError, zlib.error, OSError) as exc:
raise AgentError(f"the backup is damaged or cut off ({exc})") from None
finally:
try:
tar.close()
except Exception: # noqa: BLE001
pass
if not isinstance(manifest, dict) or manifest.get("format") != SAVES_FORMAT:
raise AgentError("not a FramePort save backup (no manifest)")
listed = {}
for f in manifest.get("files") or []:
parts = _saves_member_name(str((f or {}).get("path", "")))
if parts is not None:
listed[tuple(parts)] = f
for key, e in entries.items():
if e["kind"] != "file":
continue
f = listed.get(key)
if not f or f.get("sha256") != e["sha256"] or f.get("size") != e["size"]:
raise AgentError(f"the backup is damaged: {'/'.join(key)[:200]} doesn't match its manifest")
return entries, manifest
def _saves_dest(roots, parts):
return os.path.join(roots[parts[0]], *parts[1:])
def check_saves_destinations(roots, entries):
"""No restored path may pass through or replace a link, or swap a file for a folder (Lepton keeps links in a
game's storage: writing through one would land outside it)."""
import stat as st_
for key, e in entries.items():
for i in range(1, len(key) + 1):
sub = key[:i]
path = _saves_dest(roots, sub)
try:
s = os.lstat(path)
except FileNotFoundError:
break # the rest gets created
if st_.S_ISLNK(s.st_mode):
raise AgentError(f"won't restore through the link {'/'.join(sub)}")
want_dir = i < len(key) or e["kind"] == "dir"
if want_dir and not st_.S_ISDIR(s.st_mode):
raise AgentError(f"{'/'.join(sub)} is a file on the Frame but a folder in the backup")
if not want_dir and not st_.S_ISREG(s.st_mode):
raise AgentError(f"{'/'.join(sub)} is a folder on the Frame but a file in the backup")
def _saves_parent_stat(path):
"""stat of path's nearest existing folder."""
p = path
while True:
try:
return os.stat(p)
except OSError:
parent = os.path.dirname(p)
if parent == p:
raise
p = parent
def _saves_chown(path, uid, gid):
try:
s = os.lstat(path)
if (s.st_uid, s.st_gid) != (uid, gid):
os.chown(path, uid, gid)
except OSError:
pass # (outside podman's namespace only our own ids can be set)
def _saves_mkdir(path, mode=None):
"""Create a missing folder (and its parents) owned like its parent folder; mode None = the parent's mode."""
if os.path.isdir(path):
return
parent = os.path.dirname(path)
_saves_mkdir(parent)
ps = os.stat(parent)
os.mkdir(path)
os.chmod(path, ((ps.st_mode if mode is None else mode) & 0o2777) | 0o700)
_saves_chown(path, ps.st_uid, ps.st_gid)
def apply_staged_saves(roots, entries, manifest):
"""Put staged files in place: existing files keep their owner and mode, new ones get their folder's owner and the
backup's mode (at least u+rw); each file is swapped in with one rename. Save files the backup doesn't have go
(the same rules as a backup decide what's a save; files too big for the backup stay). Returns counts."""
import stat as st_
restored = removed = 0
for key in sorted((k for k, e in entries.items() if e["kind"] == "dir"), key=len):
_saves_mkdir(_saves_dest(roots, key), entries[key]["mode"])
for root in roots.values():
_saves_mkdir(root)
for key, e in sorted(entries.items()):
if e["kind"] != "file":
continue
dest = _saves_dest(roots, key)
_saves_mkdir(os.path.dirname(dest))
try:
s = os.lstat(dest)
uid, gid, mode = s.st_uid, s.st_gid, st_.S_IMODE(s.st_mode)
except FileNotFoundError:
ps = _saves_parent_stat(os.path.dirname(dest))
uid, gid, mode = ps.st_uid, ps.st_gid, (e["mode"] & 0o777) | 0o600
staged = e["staged"]
os.chmod(staged, mode)
_saves_chown(staged, uid, gid)
if e.get("mtime"):
os.utime(staged, (e["mtime"], e["mtime"]))
os.replace(staged, dest)
restored += 1
keep = {"/".join(k) for k, e in entries.items() if e["kind"] == "file"}
max_file = manifest.get("max_file")
current = collect_saves(roots, max_file if isinstance(max_file, int) and max_file > 0 else SAVES_MAX_FILE)
for arc, path, _s in current["files"]:
if arc not in keep:
try:
os.remove(path)
removed += 1
except OSError:
pass
return {"restored": restored, "removed": removed}
def saves_context(args):
"""(pkg, dep, roots) of an installed Quest game (its roots are real folders, or missing)."""
pkg = check_pkg(args.get("package"))
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
if dep.get("kind") in ("pcvr", "linux"):
raise AgentError("save backups are for Quest (Android) games only")
base = dep["base"]
if not os.path.isdir(os.path.join(base, "lepton-app")) and not os.path.isdir(os.path.join(base, "lepton-data")):
raise AgentError(f"{pkg}'s files are missing at {base} (storage not mounted?)")
roots = saves_roots(base, pkg)
data = os.path.realpath(os.path.join(base, "lepton-data"))
for r in roots.values():
if os.path.islink(r) or (os.path.lexists(r) and not _inside(os.path.realpath(r), data)):
raise AgentError(f"{r} is a link: refusing")
return pkg, dep, roots
class _SavesLock:
"""The game's launch lock (launch.sh holds it while the game runs): a backup or restore never overlaps a play
session, and a Play meanwhile is ignored like a double launch."""
def __init__(self, dep):
self.path, self.fd = os.path.join(dep["base"], ".launch.lock"), None
def __enter__(self):
if fcntl is None:
return self
self.fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o644)
try:
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(self.fd)
self.fd = None
raise AgentError("the game is running") from None
return self
def __exit__(self, *exc):
if self.fd is not None:
os.close(self.fd)
self.fd = None
def saves_running(dep):
return bool(dep.get("appid")) and container_running(dep["appid"])
def _saves_in_namespace(op, args):
"""Run op again inside podman's user namespace (stdio inherited); None = run it in this process."""
owner = as_owner()
if not owner or os.environ.get("FRAMEPORT_SAVES_NS") == "1":
return None
env = dict(os.environ, FRAMEPORT_SAVES_NS="1")
return subprocess.run(owner + [sys.executable or "python3", os.path.abspath(__file__), "_saves_ns", op,
json.dumps(args)], env=env).returncode
def saves_backup_session(args, out, err):
"""`_backup_saves`: the archive on `out`, one JSON line (ok + summary, or error) on `err`; exit 0 = complete."""
def emit(obj):
err.write(json.dumps(obj) + "\n")
err.flush()
def here():
pkg, dep, roots = saves_context(args)
manifest = write_saves_tar(out, pkg, roots, dep, int(args.get("max_file") or SAVES_MAX_FILE),
int(args.get("max_total") or SAVES_MAX_TOTAL))
out.flush()
emit({"ok": True, "result": saves_summary(manifest)})
return 0
try:
return _saves_run("backup", args, here)
except AgentError as exc:
emit({"ok": False, "error": str(exc)})
return 1
except Exception as exc: # noqa: BLE001
emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
return 1
def saves_restore_session(args, inp, out):
"""`_restore_saves`: the archive on `inp` (or the rollback copy with args.rollback), one JSON line on `out`."""
def emit(obj):
out.write(json.dumps(obj) + "\n")
out.flush()
def here():
emit({"ok": True, "result": restore_saves(args, inp)})
return 0
try:
return _saves_run("restore", args, here)
except AgentError as exc:
emit({"ok": False, "error": str(exc)})
return 1
except Exception as exc: # noqa: BLE001
emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
return 1
def _saves_run(op, args, here):
"""Outside podman's namespace: check the game, take its launch lock, then run `op` in the namespace (stdio
inherited). Inside it (FRAMEPORT_SAVES_NS=1), or without podman (tests): run `here` in this process."""
if os.environ.get("FRAMEPORT_SAVES_NS") == "1":
return here()
_pkg, dep, _roots = saves_context(args)
if saves_running(dep):
raise AgentError("the game is running")
with _SavesLock(dep):
code = _saves_in_namespace(op, args)
return here() if code is None else code
def restore_saves(args, inp):
"""Stage (and check) the whole archive first, keep the current saves as <base>/saves-before-restore.tar.gz (one
copy), then swap the files in. A failure while swapping puts the rollback copy back. args.rollback restores that
copy instead of `inp` (the current saves become the new rollback copy: undo of the undo)."""
pkg, dep, roots = saves_context(args)
base = dep["base"]
rollback = os.path.join(base, SAVES_ROLLBACK)
staging = tempfile.mkdtemp(prefix=".saves-restore-", dir=base)
limits = (SAVES_RESTORE_MAX_FILE, SAVES_RESTORE_MAX_TOTAL, SAVES_RESTORE_MAX_MEMBERS)
try:
if args.get("rollback"):
if not os.path.isfile(rollback):
raise AgentError("there's no copy of the saves from before the last restore")
with open(rollback, "rb") as f:
entries, manifest = stage_saves_archive(f, staging, *limits)
else:
entries, manifest = stage_saves_archive(inp, staging, *limits)
if manifest.get("package") != pkg:
raise AgentError(f"this backup is of {manifest.get('package')}, not {pkg}")
check_saves_destinations(roots, entries)
tmp = rollback + ".tmp"
with open(tmp, "wb") as f:
write_saves_tar(f, pkg, roots, dep, SAVES_MAX_FILE, 1 << 62)
os.replace(tmp, rollback)
try:
counts = apply_staged_saves(roots, entries, manifest)
except Exception as exc: # noqa: BLE001
undo = os.path.join(staging, "undo")
os.mkdir(undo)
try:
with open(rollback, "rb") as f:
back, back_manifest = stage_saves_archive(f, undo)
apply_staged_saves(roots, back, back_manifest)
state = "the saves from before were put back"
except Exception as again: # noqa: BLE001
state = f"putting the saves from before back failed too ({again}); they're in {rollback}"
raise AgentError(f"restoring failed ({exc}); {state}") from exc
return {**counts, "package": pkg, "rollback": rollback, "rollback_size": os.path.getsize(rollback),
"backup_created": manifest.get("created"), "backup_version": manifest.get("version")}
finally:
shutil.rmtree(staging, ignore_errors=True)
def _saves_info(found):
return {"files": len(found["files"]), "bytes": sum(s.st_size for _a, _p, s in found["files"]),
"skipped": [s for s in found["skipped"] if s["reason"] in ("size", "unreadable")]}
def cmd_saves_info(args):
"""What a save backup of the game would hold now (files, bytes, large files left out), whether the game runs, and
the rollback copy of the last restore."""
pkg, dep, roots = saves_context(args)
max_file = int(args.get("max_file") or SAVES_MAX_FILE)
owner = as_owner()
if owner: # the app's files may belong to mapped ids: count them in podman's namespace
p = run(owner + [sys.executable or "python3", os.path.abspath(__file__), "_saves_ns", "info",
json.dumps(args)], env=dict(os.environ, FRAMEPORT_SAVES_NS="1"))
try:
info = json.loads(p.stdout.strip().splitlines()[-1])
except (ValueError, IndexError):
raise AgentError(f"couldn't read {pkg}'s saves: {p.stderr.strip()[-300:]}") from None
else:
info = _saves_info(collect_saves(roots, max_file))
rollback = os.path.join(dep["base"], SAVES_ROLLBACK)
info.update(package=pkg, running=saves_running(dep), version=_dep_version(dep),
rollback={"size": os.path.getsize(rollback), "time": os.path.getmtime(rollback)}
if os.path.isfile(rollback) else None)
return info
def cmd_saves_rollback_delete(args):
"""Remove the copy of the saves from before the last restore."""
_pkg, dep, _roots = saves_context(args)
path = os.path.join(dep["base"], SAVES_ROLLBACK)
if os.path.isfile(path):
os.remove(path)
return {"removed": True}
return {"removed": False}
def saves_ns_main(op, raw):
"""`_saves_ns <op> '<json>'`: the part of a save command that runs inside `podman unshare`."""
os.environ["FRAMEPORT_SAVES_NS"] = "1"
args = json.loads(raw)
if op == "backup":
return saves_backup_session(args, sys.stdout.buffer, sys.stderr)
if op == "restore":
return saves_restore_session(args, sys.stdin.buffer, sys.stdout)
if op == "snapshot":
print(json.dumps(create_save_snapshot(args["package"])))
return 0
if op == "info":
try:
_pkg, _dep, roots = saves_context(args)
print(json.dumps(_saves_info(collect_saves(roots, int(args.get("max_file") or SAVES_MAX_FILE)))))
return 0
except AgentError as exc:
print(str(exc), file=sys.stderr)
return 1
return 2
SNAPSHOT_ROOT = os.path.expanduser("~/.local/share/frameport/save-snapshots")
SNAPSHOT_NAME = re.compile(r"^[0-9]+-[a-f0-9]{64}\.tar\.gz$")
def cmd_save_snapshot_settings(args):
"""Global opt-in, read at every game exit, including when the PC is disconnected."""
os.makedirs(SNAPSHOT_ROOT, exist_ok=True)
flag = os.path.join(SNAPSHOT_ROOT, "enabled")
if args.get("enabled") and not os.path.isfile(flag):
with open(flag + ".tmp", "w") as f:
f.write("1\n")
os.replace(flag + ".tmp", flag)
elif not args.get("enabled") and os.path.exists(flag):
os.remove(flag)
return {"enabled": os.path.isfile(flag)}
def snapshot_files(pkg):
folder = os.path.join(SNAPSHOT_ROOT, check_pkg(pkg))
return sorted((p for p in glob.glob(os.path.join(folder, "*.tar.gz"))
if SNAPSHOT_NAME.fullmatch(os.path.basename(p)) and not os.path.islink(p)), reverse=True)
def cmd_save_snapshots(args):
out = []
for folder in sorted(glob.glob(os.path.join(SNAPSHOT_ROOT, "*"))):
pkg = os.path.basename(folder)
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+", pkg) or os.path.islink(folder):
continue
for path in reversed(snapshot_files(pkg)): # oldest first: PC retention keeps the newest
name = os.path.basename(path)
out.append({"package": pkg, "snapshot": name, "sha256": name.split("-", 1)[1][:-7]})
return {"snapshots": out}
def create_save_snapshot(pkg):
"""Called in the user namespace with the launch lock held; publish complete archives only."""
pkg, dep, roots = saves_context({"package": pkg})
if saves_running(dep):
raise AgentError("the game is running")
folder = os.path.join(SNAPSHOT_ROOT, pkg)
os.makedirs(folder, exist_ok=True)
found = collect_saves(roots)
if sum(st.st_size for _arc, _path, st in found["files"]) > SAVES_MAX_TOTAL:
raise AgentError("saves exceed the snapshot size limit")
entries = []
for arc, path, st in found["files"]:
digest = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
digest.update(chunk)
entries.append((arc, st.st_size, digest.hexdigest()))
fingerprint = hashlib.sha256(json.dumps(entries).encode()).hexdigest()
previous = snapshot_files(pkg)
if previous:
try:
with open(previous[0] + ".json") as f:
if json.load(f).get("fingerprint") == fingerprint:
return {"created": False, "reason": "unchanged"}
except (OSError, ValueError):
pass # damaged metadata must not prevent a new backup
part = os.path.join(folder, "snapshot.part")
try:
with open(part, "wb") as f:
write_saves_tar(f, pkg, roots, dep, automatic=True)
digest = hashlib.sha256()
with open(part, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
digest.update(chunk)
name = f"{time.time_ns()}-{digest.hexdigest()}.tar.gz"
dest = os.path.join(folder, name)
with open(dest + ".json", "w") as f:
json.dump({"fingerprint": fingerprint}, f)
os.replace(part, dest)
for old in snapshot_files(pkg)[3:]:
os.remove(old)
if os.path.exists(old + ".json"):
os.remove(old + ".json")
return {"created": True, "snapshot": name}
finally:
if os.path.exists(part):
os.remove(part)
def snapshot_close(pkg):
if not os.path.isfile(os.path.join(SNAPSHOT_ROOT, "enabled")):
return 0
_pkg, dep, _roots = saves_context({"package": pkg})
# Never trust an environment variable claiming the game is stopped: verify the inherited lock itself.
lock = os.stat(os.path.join(dep["base"], ".launch.lock"))
inherited = os.fstat(9)
if (lock.st_dev, lock.st_ino) != (inherited.st_dev, inherited.st_ino) or fcntl is None:
raise AgentError("snapshot needs the launcher's lock")
fcntl.flock(9, fcntl.LOCK_EX | fcntl.LOCK_NB)
owner = as_owner()
if owner:
return subprocess.run(owner + [sys.executable or "python3", os.path.abspath(__file__),
"_saves_ns", "snapshot", json.dumps({"package": pkg})],
pass_fds=(9,)).returncode
print(json.dumps(create_save_snapshot(pkg)))
return 0
def fetch_save_snapshot(args, out, err):
try:
pkg, name = check_pkg(args.get("package")), args.get("snapshot") or ""
if not SNAPSHOT_NAME.fullmatch(name):
raise AgentError("invalid save snapshot")
path = os.path.join(SNAPSHOT_ROOT, pkg, name)
if path not in snapshot_files(pkg) or os.path.islink(os.path.dirname(path)):
raise AgentError("save snapshot no longer available")
with open(path, "rb") as f:
shutil.copyfileobj(f, out, 1 << 16)
out.flush()
err.write(json.dumps({"ok": True}) + "\n")
return 0
except (OSError, AgentError) as exc:
err.write(json.dumps({"ok": False, "error": str(exc)}) + "\n")
return 1
COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
def main():
if len(sys.argv) == 3 and sys.argv[1] == "_openxr_manifest":
dep = deployment(check_pkg(sys.argv[2])) or {}
if dep.get("static_openxr_loader"):
write_openxr_runtime_manifest(dep["base"], True)
return
if len(sys.argv) >= 3 and sys.argv[1] == "_snapshot_close":
return snapshot_close(sys.argv[2])
if len(sys.argv) >= 3 and sys.argv[1] == "_fetch_save_snapshot":
return fetch_save_snapshot(json.loads(sys.argv[2]), sys.stdout.buffer, sys.stderr)
if len(sys.argv) >= 2 and sys.argv[1] == "_keyboard":
return keyboard_session(sys.stdin, sys.stdout)
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_watch":
vr_overlay_watch()
return
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_round":
print(json.dumps(overlay_autostart_round()), flush=True)
return
if len(sys.argv) >= 3 and sys.argv[1] == "_backup_saves":
return saves_backup_session(json.loads(sys.argv[2]), sys.stdout.buffer, sys.stderr)
if len(sys.argv) >= 3 and sys.argv[1] == "_restore_saves":
return saves_restore_session(json.loads(sys.argv[2]), sys.stdin.buffer, sys.stdout)
if len(sys.argv) >= 4 and sys.argv[1] == "_saves_ns":
return saves_ns_main(sys.argv[2], sys.argv[3])
if len(sys.argv) >= 2 and sys.argv[1] == "_monitor":
return monitor_session(sys.stdin, sys.stdout)
if len(sys.argv) >= 3 and sys.argv[1] == "_shortcuts_worker":
shortcuts_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_purge_worker":
purge_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_room_capture":
room_capture_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_move_worker":
move_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_xr_probe":
xr_probe(sys.argv[2])
return 0
if len(sys.argv) >= 5 and sys.argv[1] == "_logcat_keeper":
logcat_keeper(sys.argv[2], sys.argv[3], sys.argv[4])
return 0
if len(sys.argv) >= 4 and sys.argv[1] == "_gamepad_links":
gamepad_links(sys.argv[2], sys.argv[3])
return 0
if len(sys.argv) >= 5 and sys.argv[1] == "_restart_requested":
return 0 if restart_requested(sys.argv[2], sys.argv[3], sys.argv[4]) else 1
if len(sys.argv) >= 4 and sys.argv[1] == "_dashboard_worker":
dashboard_worker(sys.argv[2], sys.argv[3])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_scan_watch":
scan_watch_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_room_sync":
print(json.dumps(room_sync_worker(sys.argv[2])))
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_config_sync":
config_sync_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_refresh_rate":
refresh_rate_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_tools_worker":
tools_worker(sys.argv[2])
return 0
if len(sys.argv) != 2 or sys.argv[1] not in COMMANDS:
print(json.dumps({"ok": False, "error": f"usage: frameport_agent.py <{'|'.join(sorted(COMMANDS))}>"}))
return 2
if sys.argv[1] == "library_pending" and hasattr(signal, "alarm"):
signal.alarm(LIBRARY_HOOK_SECONDS + 5) # Steam's ExecStartPre: own deadline too, should timeout(1) be missing
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
try:
args = json.loads(raw) if raw.strip() else {}
result = COMMANDS[sys.argv[1]](args)
print(json.dumps({"ok": True, "result": result}))
return 0
except AgentError as exc:
print(json.dumps({"ok": False, "error": str(exc)}))
return 1
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"}))
return 1
if __name__ == "__main__":
sys.exit(main())