From 208d7bb4dd918bd4c235ddb2364d78aa9fcabe9f Mon Sep 17 00:00:00 2001 From: spoopyghosty0 <336754034+spoopyghosty0@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:08:41 -0400 Subject: [PATCH] Close SteamVR's dashboard once a FramePort game shows its first VR frames (it was open at every start, also over Lepton's 2D launcher): launch.sh starts the agent's dashboard helper, which uses Steam's UI (CEF devtools); existing launchers get it in place (agent v44) --- CLAUDE.md | 1 + agent/frameport_agent.py | 106 +++++++++++++++++++++++++++++++++++++-- tests/test_agent.py | 43 ++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0481cbb..307903e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -499,6 +499,7 @@ picker at all: a FramePort fix would be an injected picker activity. Generated c Steam placeholder set (no Steam art at all) → fixed in artwork/steam.py. **Steam library failures (GitHub #21/#30):** on some Frames Steam never loads FramePort's shortcuts.vdf entries (same SteamOS build and arm64 beta client as the dev Frame; IDs correct; file written before Steam starts): Play → console_log `GameAction [AppID ] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` (= "Game configuration unavailable", Steam treats the id as a store app). Cause unknown; agent v42/43 diagnostics: `launch` returns `steam` (started / error + code / silent from console_log), `collect_diag` adds `steam_library` (real Steam dir, beta, Steam start vs vdf write time, entries per account, devkit games) + `steam-console.txt` (incl. Steam's `logs/shortcuts.previous.txt` if present; steamclient.so has "LoadShortcuts: rejecting attempt to load shortcuts: invalid account ID"). **Fallback (agent v43):** when Play gets AppError_9 the agent registers the game through Steam's devkit interface (what Valve's Devkit Management Tool does, MIT: `devkit-1 steam://devkit-1/<~/.steam/steam.token>/create-shortcut?response=&gameid=&directory=~/devkit-game` written to ~/.steam/steam.pipe; answer file / `.error`; `~/devkit-game//launch.sh` = link to the game's launch.sh, `-argv.json` ["launch.sh"] (relative to the folder), `-settings.json` {steam_play 0, compat_tool ""}). Steam adds it live (no restart), picks its own appid (read back from shortcuts.vdf by DevkitGameID), shows it as a normal non-Steam game named "Devkit Game: " (OpenVR 0, but VR works: owner-verified 2026-10-04), art copied to grid/*. Ids: letter first, then letters/digits/_ only (spaces, '-', leading digit → "missing/invalid arguments"). Later Plays use the devkit entry; uninstall (`devkit_unregister`, `delete-shortcut` live) and purge remove it. Verified on the dev Frame: register/launch/unregister; the AppError_9 trigger itself only in unit tests. **Exit game (GitHub #36, 2026-10-04):** `Apps.TerminateApp` (what Exit game calls; reachable for tests through Steam's CEF devtools on 127.0.0.1:8080, SharedJSContext, `Runtime.evaluate` — a stdlib websocket client is enough) closed both a normal shortcut and a devkit entry on the dev Frame within 1-3 s. SIGTERM to Steam's `reaper` alone left launch.sh + Lepton (setsid) + the container running (an earlier headset launch ran on for 87 min); SIGTERM to launch.sh cleans up in ~4 s. Agent v44: launch.sh's 2 s loop ends the game when its parent (the reaper) is gone; `upgrade_launchers` (from ensure_host_fixes) adds that to existing launchers in place. Which in-headset exit path fails is still unknown. Screenshots on the Frame: all under SteamVR's appid 250820 (`760/remote/250820/screenshots`, screenshots.vdf has creation time, no game). +**SteamVR dashboard at game start (2026-10-04):** the dashboard (Resume game / controller / VR options) was open whenever a FramePort game started (also over Lepton's 2D launcher). Steam's UI exposes `SteamClient.OpenVR.VROverlay.{IsDashboardVisible,HideDashboard,ShowDashboard}` (CEF devtools, `steam_js` in the agent). Agent v44: launch.sh starts `_dashboard_worker` (waits for FrameBridge's first `pacing:` line in launch.log, then hides a visible dashboard for 25 s, at most 3 times; log `/dashboard.log`; opt out with env FRAMEPORT_KEEP_DASHBOARD=1). Verified on the dev Frame (dashboard shown → 4XVR launched via Steam → hidden ~5 s after the first frames); not yet seen in the headset. **Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Files: `install/files.py`, `frameport frame send|storage`, GUI Files tab (formerly Frame → Send files). **SteamVR per-app settings (2026-09-30):** editing steamvr.vrsettings while SteamVR runs is lost; the web API (127.0.0.1:27062 /app/setsettings) needs `x-steamvr-secret`. `native/vrsettings` = `fp_vrsettings.exe` (freestanding, OpenVR `FnTable:IVRSettings_003` as a Utility app, loads SteamVR's bin/win64/openvr_api.dll) sets them live and SteamVR persists them: section `steam.app.`, keys `preferredRefreshRate` (float) and `motionSmoothingOverride` (0 global, 1 on, 2 off, 3 always). Steam Link (vrlink) lists the Frame's rates 72/80/90/96/108/120/144 in vrserver.txt and follows the per-app preference ("host preferred N Hz"; whether the key is honoured is unverified in-headset yet). Judder metric: vrcompositor.txt session summary dropped + "Timed out. N total" (Stormland: 0 dropped but 313 timeouts in 2 min); fpsVR (`%LOCALAPPDATA%\fpsVR\*.json`, 0.1 ms histograms) gives p99 CPU/GPU ms. `pcvr.steamvr_tuning` (default on, PC only) applies on Play: highest rate whose budget ≥ p99×1.05, at least one step down, smoothing on. diff --git a/agent/frameport_agent.py b/agent/frameport_agent.py index a31a598..842eaf1 100644 --- a/agent/frameport_agent.py +++ b/agent/frameport_agent.py @@ -641,7 +641,7 @@ def ensure_host_fixes(): except Exception: # noqa: BLE001 upgraded = [] if upgraded: - changed.append(f"launchers: exit watchdog ({len(upgraded)})") + changed.append(f"launchers: exit watchdog, dashboard ({len(upgraded)})") return changed @@ -1632,6 +1632,7 @@ trap 'exit 130' INT trap 'exit 143' TERM setsid {lepton_q} start >"$app_dir/launch.log" 2>&1 & child=$! +{dashboard} wait "$child" """) @@ -1640,11 +1641,20 @@ wait "$child" # 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 &') 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 ) & permfix=$!") +def dashboard_line(): + return DASHBOARD_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__))) + + def upgrade_launchers(): """Give launchers written by older agents the parent watchdog (in place: a new file, so a running launcher keeps reading the old one). Returns the packages changed.""" @@ -1654,11 +1664,16 @@ def upgrade_launchers(): text = open(path).read() except OSError: continue - if OLD_WATCHDOG not in text or "parent=$PPID" in text: + new = text + 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 new == text: continue tmp = path + ".tmp" with open(tmp, "w") as f: - f.write(text.replace(OLD_WATCHDOG, WATCHDOG, 1)) + f.write(new) os.chmod(tmp, os.stat(path).st_mode) os.replace(tmp, path) changed.append(os.path.basename(os.path.dirname(path))) @@ -1669,7 +1684,8 @@ def write_launcher(anchor, base, pkg, title, appid, lepton, env): 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, base_q=shlex.quote(base), appid=appid, - lepton_q=shlex.quote(lepton), extra_env=extra, watchdog=WATCHDOG) + lepton_q=shlex.quote(lepton), extra_env=extra, watchdog=WATCHDOG, + dashboard=dashboard_line()) path = os.path.join(anchor, "launch.sh") with open(path + ".tmp", "w") as f: f.write(text) @@ -3011,6 +3027,85 @@ def keyboard_session(stdin, stdout, keyboard=None): 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") + + +def dashboard_worker(log, parent, wait_start=240, window=25): + """Close SteamVR's dashboard once the game's first VR frames are logged (FrameBridge's "pacing:" line), checking + for `window` seconds after that (Steam can open it a moment later; at most 3 times). Ends with the launcher.""" + def alive(): + try: + os.kill(int(parent), 0) + return True + except (OSError, ValueError): + return False + deadline = time.time() + wait_start + while time.time() < deadline and alive(): + try: + with open(log, "rb") as f: + if b"FrameBridge: pacing:" in f.read(): + break + except OSError: + pass + time.sleep(1) + else: + print("no VR frames logged; dashboard left as it is") + return + hidden, end = 0, time.time() + window + while time.time() < end and hidden < 3 and alive(): + 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(1) + + COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")} @@ -3026,6 +3121,9 @@ def main(): if len(sys.argv) >= 3 and sys.argv[1] == "_xr_probe": xr_probe(sys.argv[2]) return 0 + 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] == "_tools_worker": tools_worker(sys.argv[2]) return 0 diff --git a/tests/test_agent.py b/tests/test_agent.py index 212a1e7..6c756f5 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1118,3 +1118,46 @@ def test_upgrade_launchers_adds_watchdog(monkeypatch, tmp_path): text = (anchor / "launch.sh").read_text() assert "parent=$PPID" in text and a.OLD_WATCHDOG not in text and os.access(anchor / "launch.sh", os.X_OK) assert a.upgrade_launchers() == [] # once only + + +def test_dashboard_worker_hides_dashboard_after_first_frames(monkeypatch, tmp_path): + a = load_agent(monkeypatch, tmp_path) + monkeypatch.setattr(a.time, "sleep", lambda s: None) + log = tmp_path / "launch.log" + log.write_text("Lepton starting\n") + calls, visible = [], [True, True, False, True] + + def js(expr, timeout=5): + calls.append(expr) + if expr.endswith("IsDashboardVisible()"): + if len(calls) == 1: # the first frames arrive while it waits + pass + return visible.pop(0) if visible else False + return None + monkeypatch.setattr(a, "steam_js", js) + clock = iter(range(0, 10000)) + monkeypatch.setattr(a.time, "time", lambda: next(clock)) + log.write_text("Lepton starting\nI FrameBridge: pacing: 72.0 fps\n") + a.dashboard_worker(str(log), os.getpid(), wait_start=10, window=8) + assert calls.count("SteamClient.OpenVR.VROverlay.HideDashboard()") == 3 # at most three times + + +def test_dashboard_worker_waits_for_vr_frames(monkeypatch, tmp_path): + a = load_agent(monkeypatch, tmp_path) + monkeypatch.setattr(a.time, "sleep", lambda s: None) + clock = iter(range(0, 10000)) + monkeypatch.setattr(a.time, "time", lambda: next(clock)) + monkeypatch.setattr(a, "steam_js", lambda *x, **k: pytest.fail("no frames yet: must not touch Steam")) + log = tmp_path / "launch.log" + log.write_text("2D app, no FrameBridge\n") + a.dashboard_worker(str(log), os.getpid(), wait_start=5) + + +def test_upgrade_launchers_adds_dashboard_helper(monkeypatch, tmp_path): + a = load_agent(monkeypatch, tmp_path) + anchor = tmp_path / "Applications/quest-frame/com.x.y" + anchor.mkdir(parents=True) + (anchor / "launch.sh").write_text(f'{a.OLD_WATCHDOG}\nsetsid lepton start &\nchild=$!\nwait "$child"\n') + assert a.upgrade_launchers() == ["com.x.y"] + text = (anchor / "launch.sh").read_text() + assert "_dashboard_worker" in text and text.index("_dashboard_worker") < text.index('wait "$child"')