Files
saphidandClaude Opus 5.5 d4486a7681 Frame Control: Mac app, web UI, Android and Steam tooling
Package the Frame Control web UI as an installable Electron Mac app and
bring in the tooling built alongside it.

- app/: Electron wrapper that starts ui/server.py on a free loopback port,
  hardened window (sandbox, no navigation, runAsNode fuse off), login-shell
  PATH so Homebrew tools work from Finder, first-run offer to run
  connect.sh, ad-hoc signed DMG/zip via electron-builder.
- ui/: headset view (OpenVR screenshots), device status, library, Steam
  "Get games" (owned games, install, store search), Android apps as
  persistent Lepton instances with a rated F-Droid catalogue and a private
  compatibility database, Android display controls over ADB, file and
  clipboard transfer, Flatpaks, remote and power actions.
- apk-catalog/, compat-db/, frame/: catalogue build pipeline, Lakebed
  capsule for compatibility reports, Frame-side launchers.
- tests/ and CI: server guard and validation tests plus Steam helper tests,
  run on Python 3.9 with script and app syntax checks.
- Docs: README leads with the Mac app; new Android, panels, Steam games and
  field-notes docs; security notes on LAN-exposed ADB ports.

Screenshot values for the headset's IP and Wi-Fi name are placeholders.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 22:21:20 +10:00

109 lines
4.1 KiB
Python

"""Runs ON the Steam Frame (piped over SSH as `python3 -`); captures the headset view.
Asks SteamVR for a stereo screenshot through OpenVR's IVRScreenshots API (ctypes,
so nothing to build or install). The result is a side-by-side PNG of both eyes
with everything composited: the room, floating panels, dashboard and
controllers. Verified 2026-09-25 (SteamOS 0.3.0, SteamVR 2.17.10): 1920x1080,
960x1080 per eye.
Prints one JSON line: {"path": ...} or {"error": ...}. The caller copies the PNG
back and deletes it.
"""
import ctypes as C
import json
import os
import sys
import time
LIB = "/opt/steamvr/bin/linuxarm64/libopenvr_api.so"
OUT_DIR = "/tmp/frame-vrcap"
APP_OVERLAY = 2 # doesn't take focus from whatever is running
SCREENSHOT_STEREO = 2
ERRORS = {1: "request failed", 100: "incompatible version", 101: "not found",
102: "buffer too small", 108: "screenshot already in progress"}
Err, Handle = C.c_int, C.c_uint32
class ScreenshotsFnTable(C.Structure):
# openvr_capi.h, VR_IVRScreenshots_FnTable (IVRScreenshots_001).
_fields_ = [
("RequestScreenshot", C.CFUNCTYPE(Err, C.POINTER(Handle), C.c_int, C.c_char_p, C.c_char_p)),
("HookScreenshot", C.CFUNCTYPE(Err, C.POINTER(C.c_int), C.c_int)),
("GetScreenshotPropertyType", C.CFUNCTYPE(C.c_int, Handle, C.POINTER(Err))),
("GetScreenshotPropertyFilename", C.CFUNCTYPE(C.c_uint32, Handle, C.c_int, C.c_char_p,
C.c_uint32, C.POINTER(Err))),
("UpdateScreenshotProgress", C.CFUNCTYPE(Err, Handle, C.c_float)),
("TakeStereoScreenshot", C.CFUNCTYPE(Err, C.POINTER(Handle), C.c_char_p, C.c_char_p)),
("SubmitScreenshot", C.CFUNCTYPE(Err, Handle, C.c_int, C.c_char_p, C.c_char_p)),
]
def done(**result):
print(json.dumps(result))
sys.exit(0 if "path" in result else 1)
def settled(path, wait=0.1):
"""True once the file exists and its size has stopped changing."""
try:
size = os.path.getsize(path)
time.sleep(wait)
return size > 0 and size == os.path.getsize(path)
except OSError:
return False
def capture(vr):
err = Err(0)
ptr = vr.VR_GetGenericInterface(b"FnTable:IVRScreenshots_001", C.byref(err))
if err.value or not ptr:
done(error=f"SteamVR screenshots unavailable ({err.value})")
shots = C.cast(ptr, C.POINTER(ScreenshotsFnTable)).contents
os.makedirs(OUT_DIR, mode=0o700, exist_ok=True)
for name in os.listdir(OUT_DIR): # leftovers from interrupted captures
p = os.path.join(OUT_DIR, name)
try:
# Older than the server's 15 s `timeout`, so no capture still owns it.
if time.time() - os.path.getmtime(p) > 20:
os.remove(p)
except OSError:
pass
base = os.path.join(OUT_DIR, f"shot-{os.getpid()}")
handle = Handle(0)
rc = shots.RequestScreenshot(C.byref(handle), SCREENSHOT_STEREO,
(base + "-preview").encode(), (base + "-vr").encode())
if rc:
done(error=f"SteamVR screenshot: {ERRORS.get(rc, rc)}")
# The compositor appends .png. The preview (left eye only) is written last.
stereo, preview = base + "-vr.png", base + "-preview.png"
deadline = time.time() + 8
while time.time() < deadline:
if os.path.exists(preview) and settled(stereo):
os.remove(preview)
return stereo
time.sleep(0.05)
for p in (stereo, preview):
if os.path.exists(p):
os.remove(p)
done(error="SteamVR didn't write the screenshot in time")
def main():
vr = C.CDLL(LIB)
vr.VR_InitInternal2.restype = C.c_uint32
vr.VR_InitInternal2.argtypes = [C.POINTER(Err), C.c_int, C.c_char_p]
vr.VR_GetGenericInterface.restype = C.c_void_p
vr.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(Err)]
err = Err(0)
vr.VR_InitInternal2(C.byref(err), APP_OVERLAY, None)
if err.value:
done(error=f"Can't reach SteamVR (init error {err.value}). Is SteamVR running?")
try:
done(path=capture(vr))
finally:
vr.VR_ShutdownInternal()
main()