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)

This commit is contained in:
spoopyghosty0 committed 2026-10-04 23:08:41 -04:00
1 parent cf1a2c9181
commit 208d7bb4dd
3 files changed
+146 -4

No files matched your search

+1
View File
@@ -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 <id>] … 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=<file>&gameid=<id>&directory=~/devkit-game` written to ~/.steam/steam.pipe; answer file / `.error`; `~/devkit-game/<id>/launch.sh` = link to the game's launch.sh, `<id>-argv.json` ["launch.sh"] (relative to the folder), `<id>-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: <id>" (OpenVR 0, but VR works: owner-verified 2026-10-04), art copied to grid/<appid>*. 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 `<base>/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 → `<base>/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.<shortcut appid>`, 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.
+102 -4
View File
@@ -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
+43
View File
@@ -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"')