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>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-25 22:21:20 +10:00
1 parent 6ccf562756
commit d4486a7681
56 files changed
+19298 -11

No files matched your search

+316
View File
@@ -0,0 +1,316 @@
"""Android apps on the Frame, each in its own persistent Lepton instance.
Every APK gets ~/Applications/Android/<package>/ on the Frame with app.apk,
launch.sh (frame/android/lepton-app.sh), instance.id, meta.json and, for 2D
apps, the lepton-show-flatscreen marker; plus a non-Steam shortcut, so it shows
in the Steam library and gets its own SteamVR panel. Nothing goes through
Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
"""
import glob, json, os, re, shlex, subprocess, sys, threading, time, zipfile, zlib
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
APPS_DIR = 'Applications/Android' # relative to the Frame's $HOME
COMPAT = '.local/share/Steam/steamapps/compatdata'
SHADERS = '.local/share/Steam/steamapps/shadercache'
LAUNCHER = os.path.join(ROOT, 'frame', 'android', 'lepton-app.sh')
SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
class FrameError(RuntimeError):
pass
def ssh(cmd, input=None, timeout=120):
try:
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], input=input, capture_output=True,
timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired:
raise FrameError(f'timed out talking to {FRAME}')
if p.returncode != 0:
raise FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
return p.stdout
def shortcut_tool(*args, timeout=60):
with open(SHORTCUTS) as f:
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
timeout=timeout).strip()
def instance_id(pkg):
# Stable per package, well above real Steam app ids, below 2^32. T3 Code's
# hand-picked 2873873873 sits outside this range.
return 2800000000 + zlib.crc32(pkg.encode()) % 70000000
def game_id(shortcut_appid):
return (int(shortcut_appid) << 32) | 0x02000000
def aapt2():
found = sorted(glob.glob(os.path.expanduser('~/.homebrew/share/android-commandlinetools/build-tools/*/aapt2'))
+ glob.glob('/opt/homebrew/share/android-commandlinetools/build-tools/*/aapt2')
+ glob.glob(os.path.expanduser('~/Library/Android/sdk/build-tools/*/aapt2')))
return found[-1] if found else None
def apk_info(path):
"""Package, label, version, native ABIs and the best PNG icon inside the APK."""
tool = aapt2()
if not tool:
raise FrameError('aapt2 not found: brew install --cask android-commandlinetools, then '
'sdkmanager "build-tools;36.0.0"')
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
if not m:
raise FrameError(f'not a readable APK: {os.path.basename(path)}')
label = re.search(r"application-label(?:-en(?:-US)?)?:'([^']*)'", out) or \
re.search(r"application: label='([^']*)'", out)
icons = re.findall(r"application-icon-(\d+):'([^']+)'", out)
abis = re.search(r"native-code: (.*)", out)
sdk = re.search(r"(?:minSdkVersion|sdkVersion):'(\d+)'", out)
info = {'package': m[1], 'version': m[2], 'label': (label[1] if label else '') or m[1],
'abis': re.findall(r"'([^']+)'", abis[1]) if abis else [],
'min_sdk': int(sdk[1]) if sdk else None, 'icon_png': None}
try:
z = zipfile.ZipFile(path)
except (zipfile.BadZipFile, OSError) as e:
raise FrameError(f'not a readable APK: {e}')
with z:
names = set(z.namelist())
for _, icon in sorted(icons, key=lambda d: -int(d[0])):
if icon.endswith('.png') and icon in names:
info['icon_png'] = z.read(icon)
break
else: # adaptive icons are XML; fall back to the largest launcher PNG
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
key=lambda n: z.getinfo(n).file_size)
if pngs:
info['icon_png'] = z.read(pngs[-1])
return info
def check_installable(info):
if info['min_sdk'] and info['min_sdk'] > 30:
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
if info['abis'] and 'arm64-v8a' not in info['abis']:
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only")
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
def _rsync(src, dest, *extra, timeout=600):
try:
subprocess.run(['rsync', '-a', *extra, '-e', 'ssh ' + ' '.join(SSH_OPTS), src, f'{FRAME}:{dest}'],
check=True, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {os.path.basename(src)} to the Frame timed out')
except subprocess.CalledProcessError as e:
raise FrameError(f'copying {os.path.basename(src)} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
def _shortcut_ids():
try:
return {int(x['appid']) for x in json.loads(shortcut_tool('list'))}
except (ValueError, TypeError, KeyError) as e:
raise FrameError(f'could not read the Steam shortcut list: {e}')
def _write_meta(d, meta):
# Write then rename, so a dropped connection can't leave torn JSON behind.
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
info = apk_info(apk_path)
if icon_png:
info['icon_png'] = icon_png
check_installable(info)
pkg = info['package']
if not PKG_RE.match(pkg):
raise FrameError(f'unexpected package name {pkg!r}')
with _install_lock:
return _install(apk_path, info, pkg, flatscreen, name, source)
def _install(apk_path, info, pkg, flatscreen, name, source):
iid = instance_id(pkg)
d = f'{APPS_DIR}/{pkg}'
existing = read_meta(pkg)
ok = False
try:
ssh(f'mkdir -p {d}')
_rsync(apk_path, f'{d}/app.apk.part')
_rsync(LAUNCHER, f'{d}/launch.sh', '--chmod=u+x', timeout=120)
icon = ''
if info['icon_png']:
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
icon = f'$HOME/{d}/icon.png'
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
home = ssh('echo $HOME').strip()
shortcut = _int((existing or {}).get('shortcut'))
if not shortcut or shortcut not in _shortcut_ids():
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
icon.replace('$HOME', home))
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
if not shortcut:
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
'source': source or os.path.basename(apk_path)}
_write_meta(d, meta)
ok = True
return meta
finally:
if not ok and not existing:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
ssh(f'rm -rf {d}', timeout=30)
except FrameError:
pass
def _int(v):
try:
n = int(v)
return n if n > 0 else None
except (TypeError, ValueError):
return None
def read_meta(pkg):
try:
m = json.loads(ssh(f'cat {APPS_DIR}/{pkg}/meta.json 2>/dev/null || true') or 'null')
except (ValueError, FrameError):
return None
return _clean_meta(m)
def _clean_meta(m):
"""A usable meta dict with integer ids, or None if it's missing what we need."""
if not isinstance(m, dict) or not PKG_RE.match(str(m.get('package', ''))):
return None
iid, shortcut = _int(m.get('instance')), _int(m.get('shortcut'))
if not iid:
return None
m.update(instance=iid, shortcut=shortcut, game_id=game_id(shortcut) if shortcut else None,
label=str(m.get('label') or m['package']), version=str(m.get('version') or ''))
return m
def running_instances():
"""Lepton container name -> adb port, for the instances that are running now."""
out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true')
return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2)
def list_apps():
out = ssh(f'for f in {APPS_DIR}/*/meta.json; do [ -f "$f" ] && cat "$f" && echo; echo "@@"; done 2>/dev/null || true')
running = running_instances()
apps = []
for chunk in out.split('@@'):
chunk = chunk.strip()
if not chunk:
continue
try:
m = _clean_meta(json.loads(chunk))
except ValueError:
continue
if m:
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
apps.append(m)
return sorted(apps, key=lambda m: m['label'].lower())
def _meta_or_fail(pkg):
if not PKG_RE.match(pkg or ''):
raise FrameError(f'bad package name {pkg!r}')
m = read_meta(pkg)
if not m:
raise FrameError(f'{pkg} is not installed')
return m
def launch(pkg):
m = _meta_or_fail(pkg)
if not m['game_id']:
raise FrameError(f"{m['label']} has no Steam shortcut; reinstall it")
ssh(f"steam steam://rungameid/{int(m['game_id'])} >/dev/null 2>&1 &")
return m
def stop(pkg):
m = _meta_or_fail(pkg)
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
return m
def remove(pkg, keep_data=False):
m = _meta_or_fail(pkg)
stop(pkg)
if m['shortcut']:
try:
shortcut_tool('remove', str(int(m['shortcut'])))
except FrameError:
pass # already gone from Steam
iid = int(m['instance'])
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
return m
def probe(pkg, wait=20):
"""Launch the app's instance and report whether it stays up (for compat reports)."""
m = _meta_or_fail(pkg)
ctr = f"lepton-steamlaunch-{int(m['instance'])}"
launch(pkg)
t0 = time.time()
while time.time() - t0 < 90 and ctr not in running_instances():
time.sleep(3)
if ctr not in running_instances():
return {'package': pkg, 'version': m['version'], 'result': 'instance_failed',
'detail': 'Lepton instance did not start within 90 s'}
# Android boots inside the container; then give the app time to crash, or not.
time.sleep(wait)
sh = f'podman exec {ctr} /system/bin/sh -c'
alive = ssh(f"{sh} 'pidof {pkg}' 2>/dev/null || true").strip()
crash = ssh(f"{sh} 'logcat -d -b crash' 2>/dev/null | tail -n 60 || true")
reason = next((l.split('AndroidRuntime: ', 1)[1] for l in crash.splitlines()
if 'AndroidRuntime: ' in l and ('Exception' in l or 'Error' in l)), '')
if not reason and 'Fatal signal' in crash:
reason = next(l[l.find('Fatal signal'):] for l in crash.splitlines() if 'Fatal signal' in l)
if 'ClipboardManager' in reason:
reason = 'no clipboard service: ' + reason
return {'package': pkg, 'version': m['version'], 'result': 'runs' if alive else 'crashes',
'detail': reason[:300], 'seconds': wait,
'container_up': ctr in running_instances()}
def main():
cmd, *args = sys.argv[1:] or ['help']
try:
if cmd == 'install':
r = install(args[0], flatscreen='--vr' not in args)
elif cmd == 'list':
r = list_apps()
elif cmd in ('launch', 'stop', 'probe'):
r = globals()[cmd](args[0])
elif cmd == 'remove':
r = remove(args[0], keep_data='--keep-data' in args)
else:
sys.exit(__doc__)
except FrameError as e:
sys.exit(f'error: {e}')
print(json.dumps(r, indent=1))
if __name__ == '__main__':
main()
+154
View File
@@ -0,0 +1,154 @@
"""Frame Control's side of the APK catalogue (../apk-catalog): the rated F-Droid
list, verified downloads, installs into per-app Lepton instances, and
compatibility reports. Python stdlib only.
"""
import hashlib, os, shutil, sys, tempfile, threading, time, urllib.error, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOG = os.path.join(ROOT, 'apk-catalog')
sys.path.insert(0, CATALOG)
import build as catalog_build # noqa: E402
import reports # noqa: E402
import frame_android # noqa: E402
import frame_compat_db as compat_db # noqa: E402
CACHE = (os.path.expanduser('~/Library/Caches/Frame Control/apk') if '.app/Contents/Resources' in CATALOG
else os.path.join(CATALOG, 'data', 'cache'))
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
_lock = threading.Lock()
_cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None}
_env = {}
def catalog():
"""Rated apps with the database's reports applied."""
path = os.path.join(CATALOG, 'site', 'apps.js')
reps = compat_db.load()
sig = (len(reps), max((r.get('date') or '' for r in reps), default=''))
with _lock:
mtime = os.path.getmtime(path)
if _cache['mtime'] != mtime:
_cache.update(mtime=mtime, sig=None, apps=catalog_build.load_catalog())
if _cache['sig'] != sig:
by = reports.by_package(reps)
for a in _cache['apps']:
catalog_build.finalize(a, by.get(a['p']))
_cache['apps'].sort(key=lambda a: (catalog_build.RANK[a['r']], a['n'].lower()))
_cache.update(sig=sig, by_pkg={a['p']: a for a in _cache['apps']})
return _cache['apps']
def app(pkg):
catalog()
a = _cache['by_pkg'].get(pkg)
if not a:
raise frame_android.FrameError(f'{pkg} is not in the catalogue')
return a
def _sha256(path):
h = hashlib.sha256()
with open(path, 'rb') as f:
for chunk in iter(lambda: f.read(1 << 20), b''):
h.update(chunk)
return h.hexdigest()
def fetch_apk(a):
"""Download (or reuse) the APK and verify it against the F-Droid index's SHA-256."""
if not a['a'].startswith(APK_HOSTS):
raise frame_android.FrameError('unexpected APK URL')
if not a.get('h'):
raise frame_android.FrameError(f"{a['n']}: the F-Droid index has no SHA-256 for this APK, so it can't be verified")
os.makedirs(CACHE, exist_ok=True)
path = os.path.join(CACHE, os.path.basename(a['a']))
if os.path.exists(path) and _sha256(path) == a['h']:
return path
# Own temp file per download: concurrent downloads can't clobber or delete each other's.
fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + '.', suffix='.part', dir=CACHE)
os.close(fd)
try:
with urllib.request.urlopen(a['a'], timeout=60) as r, open(tmp, 'wb') as f:
shutil.copyfileobj(r, f, 1 << 20)
if _sha256(tmp) != a['h']:
raise frame_android.FrameError('SHA-256 mismatch against the F-Droid index; download discarded')
os.replace(tmp, path) # only a verified file ever reaches the cache name
return path
except (urllib.error.URLError, TimeoutError, OSError) as e:
raise frame_android.FrameError(f"couldn't download {a['n']}: {e}")
finally:
if os.path.exists(tmp):
os.remove(tmp)
def fetch_icon(a):
if not a.get('i', '').startswith('https://f-droid.org/repo/'):
return None
try:
with urllib.request.urlopen(a['i'], timeout=15) as r:
data = r.read(2_000_000)
return data if data[:8] == b'\x89PNG\r\n\x1a\n' else None
except Exception:
return None
def install(pkg):
a = app(pkg)
if a['r'] == 'no' and not a['t']:
raise frame_android.FrameError(f"{a['n']} can't run on the Frame: {a['why'][0]}")
return frame_android.install(fetch_apk(a), name=a['n'], icon_png=fetch_icon(a), source='F-Droid')
def environment():
"""SteamOS and Lepton build ids, recorded with every report."""
if not _env:
out = frame_android.ssh('. /etc/os-release; echo "$BUILD_ID"; '
'sed -n \'s/.*"buildid"[[:space:]]*"\\([0-9]*\\)".*/\\1/p\' '
'~/.local/share/Steam/steamapps/appmanifest_3056000.acf')
lines = out.split()
_env.update(steamos=lines[0] if lines else None, lepton=lines[1] if len(lines) > 1 else None)
return _env
RUNTIMES = ('instance', 'lepton-dev', 'other')
def add_report(pkg, version, result=None, rating=None, notes='', via='user', runtime='instance',
label=None, source=None):
"""One report for any APK (F-Droid or not): did it work, and how was it run."""
if not frame_android.PKG_RE.match(pkg or '') or len(pkg) > 200:
raise ValueError('a report needs a valid package name, e.g. org.example.app')
if rating not in (None, 'works', 'issues', 'broken'):
raise ValueError('rating must be works, issues or broken')
if result not in (None, 'runs', 'crashes', 'install_failed', 'instance_failed'):
raise ValueError('bad result')
if not rating and not result:
raise ValueError('say whether it worked')
if runtime not in RUNTIMES:
raise ValueError(f"runtime must be one of {', '.join(RUNTIMES)}")
try:
env = environment()
except frame_android.FrameError:
env = {'steamos': None, 'lepton': None} # Frame asleep: still record the report
clean = lambda v, n: (str(v).strip()[:n] or None) if v not in (None, '') else None
return compat_db.add({'package': pkg, 'version': clean(version, 80), 'result': result, 'rating': rating,
'notes': clean(notes, 1000), 'via': via, 'runtime': runtime,
'label': clean(label, 120), 'source': clean(source, 300),
'date': time.strftime('%Y-%m-%dT%H:%M:%S'), **env})
def recent_reports(limit=200):
"""Newest first, with a display name from the report or the catalogue."""
catalog()
names = {p: a['n'] for p, a in (_cache['by_pkg'] or {}).items()}
out = []
for r in sorted(compat_db.load(), key=lambda r: r.get('date') or '', reverse=True)[:limit]:
out.append({**r, 'name': r.get('label') or names.get(r['package']) or r['package'],
'inCatalog': r['package'] in names})
return out
def probe_and_report(pkg):
r = frame_android.probe(pkg)
add_report(pkg, r.get('version'), result=r['result'], notes=r.get('detail', ''), via='probe')
return r
+240
View File
@@ -0,0 +1,240 @@
"""Frame Control's compatibility database: a private Lakebed capsule
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
write, using a key kept in the macOS Keychain (service frame-control-compat-db,
account app-key).
New reports go to a local outbox first and are sent from there, so nothing is
lost offline. A mirror of every report is kept for offline reads. Both live in
~/Library/Application Support/Frame Control/compat-db/. Python stdlib only.
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
(import restores a backup; reports already in the database are skipped.)
"""
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
KEYCHAIN = ('frame-control-compat-db', 'app-key')
STATE = os.path.expanduser('~/Library/Application Support/Frame Control/compat-db')
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
MIRROR = os.path.join(STATE, 'compat-mirror.json')
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
'label', 'source')
TTL = 60 # seconds a fetched copy is reused
_lock = threading.Lock()
_mem = {'at': 0, 'reports': None, 'source': None}
class DBError(RuntimeError):
pass
def key():
k = os.environ.get('FRAME_CONTROL_KEY')
if k:
return k
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
capture_output=True, text=True)
if p.returncode != 0 or not p.stdout.strip():
raise DBError('No compatibility-database key in the Keychain '
f'(service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
return p.stdout.strip()
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Never follow redirects: urllib would copy the key header to the new host."""
def redirect_request(self, *args, **kwargs):
return None
_opener = urllib.request.build_opener(_NoRedirect)
def _request(path, body=None, timeout=20):
req = urllib.request.Request(URL + path, method='POST' if body is not None else 'GET',
data=json.dumps(body).encode() if body is not None else None,
headers={'x-frame-control-key': key(), 'content-type': 'application/json',
'user-agent': 'FrameControl/1'})
try:
with _opener.open(req, timeout=timeout) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
raise DBError(f'compatibility database said HTTP {e.code}')
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
raise DBError(f"can't reach the compatibility database: {e}")
def _from_row(row):
r = {k: row.get(k) for k in FIELDS if k != 'date'}
r['date'] = row.get('reportedAt')
r['id'] = row.get('clientId') or row.get('id')
return r
def fetch_all():
"""Every report from the database (paged), deduplicated."""
seen, out, since = set(), [], ''
for _ in range(200):
page = _request('/v1/reports?since=' + urllib.parse.quote(since))
for row in page.get('reports', []):
if row.get('id') and row['id'] not in seen:
seen.add(row['id'])
out.append(_from_row(row))
if not page.get('next') or page['next'] == since:
break
since = page['next']
return out
RESULTS = ('runs', 'crashes', 'install_failed', 'instance_failed')
RATINGS = ('works', 'issues', 'broken')
def problem(r):
"""Why the server would reject this report, or None. Mirrors compat-db/server/index.ts."""
if not isinstance(r, dict):
return 'not an object'
for k in ('package', 'id', 'date'):
if not r.get(k) or not isinstance(r[k], str):
return f'missing {k}'
if r.get('result') not in (None, '', *RESULTS):
return f"bad result {r['result']!r}"
if r.get('rating') not in (None, '', *RATINGS):
return f"bad rating {r['rating']!r}"
return None
def _quarantine(lines, why):
"""Keep what can't be sent, with the reason, instead of dropping it."""
os.makedirs(STATE, exist_ok=True)
with open(OUTBOX + '.rejected', 'a') as f:
for line in lines:
f.write(json.dumps({'why': why, 'at': time.strftime('%Y-%m-%dT%H:%M:%S'), 'line': line}) + '\n')
def _outbox():
"""Queued reports. Unreadable or invalid lines move to the .rejected file."""
if not os.path.exists(OUTBOX):
return []
good, bad = [], []
with open(OUTBOX) as f:
for line in f:
if not line.strip():
continue
try:
r = json.loads(line)
except ValueError:
bad.append((line.rstrip('\n'), 'unreadable JSON'))
continue
why = problem(r)
(bad.append((line.rstrip('\n'), why)) if why else good.append(r))
if bad:
for line, why in bad:
_quarantine([line], why)
_write_outbox(good)
return good
def _write_outbox(rows):
with open(OUTBOX + '.tmp', 'w') as f:
f.writelines(json.dumps(r, ensure_ascii=False) + '\n' for r in rows)
os.replace(OUTBOX + '.tmp', OUTBOX)
def flush():
"""Send queued reports. Sent ones leave the outbox; ones the server rejects go to
the .rejected file; on a network error the rest stay queued. Returns how many are left."""
with _lock:
pending = _outbox()
while pending:
batch = pending[:100]
res = _request('/v1/reports', {'reports': [{**r, 'clientId': r['id']} for r in batch]})
rejected = set(res.get('rejected') or [])
if rejected:
_quarantine([json.dumps(r) for r in batch if r['id'] in rejected], 'rejected by the server')
pending = pending[100:]
_write_outbox(pending)
return len(pending)
def _read_mirror():
try:
with open(MIRROR) as f:
return [r for r in json.load(f).get('reports', []) if isinstance(r, dict) and r.get('package')]
except (OSError, ValueError, AttributeError):
return []
def load():
"""All reports: the database (cached for TTL s), else the offline mirror; plus unsent ones."""
now = time.time()
if _mem['reports'] is None or now - _mem['at'] > TTL:
try:
try:
flush()
except Exception:
pass # sending can fail for any reason; reading must still work
reports, source = fetch_all(), 'lakebed'
os.makedirs(STATE, exist_ok=True)
with open(MIRROR + '.tmp', 'w') as f:
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
os.replace(MIRROR + '.tmp', MIRROR)
except DBError:
reports, source = _read_mirror(), 'mirror'
_mem.update(at=now, reports=reports, source=source)
sent = {r.get('id') for r in _mem['reports']}
return _mem['reports'] + [r for r in _outbox() if r['id'] not in sent]
def add(report):
"""Validate, queue, then try to send. Never raises once the report is queued."""
r = {k: report.get(k) for k in FIELDS}
r['id'] = report.get('id') or str(uuid.uuid4())
why = problem(r)
if why:
raise ValueError(f'report not saved: {why}')
os.makedirs(STATE, exist_ok=True)
with _lock, open(OUTBOX, 'a') as f:
f.write(json.dumps(r, ensure_ascii=False) + '\n')
try:
flush()
_mem['at'] = 0 # refetch on next load
except Exception:
pass # stays queued; load() shows it and a later call sends it
return r
def main():
cmd, *args = sys.argv[1:] or ['count']
try:
if cmd == 'count':
print(len(fetch_all()))
elif cmd == 'export':
reports = fetch_all()
with open(args[0], 'w') as f:
json.dump({'exported': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'source': URL,
'count': len(reports), 'reports': reports}, f, indent=1)
print(f'{len(reports)} reports -> {args[0]}')
elif cmd == 'import':
with open(args[0]) as f:
backup = json.load(f)
rows = [{**{k: r.get(k) for k in FIELDS}, 'id': r.get('id')} for r in backup['reports']]
bad = [(r, problem(r)) for r in rows if problem(r)]
ok = [r for r in rows if not problem(r)]
for r, why in bad:
print(f"skipped {r.get('package')!r}: {why}", file=sys.stderr)
os.makedirs(STATE, exist_ok=True)
with _lock, open(OUTBOX, 'a') as f:
f.writelines(json.dumps(r) + '\n' for r in ok)
left = flush()
print(f'{len(ok)} reports sent, {len(bad)} invalid skipped, {left} still queued; '
'reports already in the database were not duplicated')
elif cmd == 'flush':
print(f'{flush()} still queued')
else:
sys.exit(__doc__)
except DBError as e:
sys.exit(f'error: {e}')
if __name__ == '__main__':
main()
+182
View File
@@ -0,0 +1,182 @@
"""Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object.
Read-only. Every probe is best effort: a missing tool or file gives null, not an
error. Paths verified on SteamOS 0.3.0 (vr), build 20260922.
"""
import glob
import json
import os
import re
import socket
import subprocess
import time
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
# Runtimes and compatibility tools that show up as "apps" in steamapps/.
TOOL_NAME = re.compile(r"^(Steam Linux Runtime|Proton|Steamworks Common|FEX$|Lepton Development$)")
def read(path):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def run(*cmd):
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=2).stdout.strip()
except (OSError, subprocess.TimeoutExpired):
return ""
def num(path, scale=1.0):
v = read(path)
try:
return int(v) * scale
except (TypeError, ValueError):
return None
def battery():
for d in glob.glob("/sys/class/power_supply/*"):
if read(d + "/type") != "Battery":
continue
cap = read(d + "/capacity")
# max1720x reports microvolts/microamps; current is positive while charging.
volts, amps = num(d + "/voltage_now", 1e-6), num(d + "/current_now", 1e-6)
return {"percent": int(cap) if cap and cap.isdigit() else None,
"status": read(d + "/status"),
"watts": round(volts * amps, 2) if volts is not None and amps is not None else None,
"timeToFull": num(d + "/time_to_full_now"),
"timeToEmpty": num(d + "/time_to_empty_now"),
"tempC": num(d + "/temp", 0.1),
"health": read(d + "/health")}
return None
def power_source():
"""The plugged-in charger, if any: {'type': 'C PD [PD_PPS]', 'watts': 20.0}."""
for d in glob.glob("/sys/class/power_supply/*"):
if read(d + "/type") == "USB" and read(d + "/online") == "1":
volts, amps = num(d + "/voltage_now", 1e-6), num(d + "/current_now", 1e-6)
return {"type": read(d + "/usb_type"),
"watts": round(volts * amps, 1) if volts and amps else None}
for d in glob.glob("/sys/class/power_supply/*"):
if read(d + "/type") != "Battery" and read(d + "/online") == "1":
return {"type": None, "watts": None}
return None
def disk(path):
try:
s = os.statvfs(path)
except OSError:
return None
return {"total": s.f_blocks * s.f_frsize, "free": s.f_bavail * s.f_frsize}
def memory():
info = {}
for line in (read("/proc/meminfo") or "").splitlines():
k, _, v = line.partition(":")
info[k] = int(v.split()[0]) * 1024 if v.split() else 0
if "MemTotal" not in info:
return None
return {"total": info["MemTotal"], "available": info.get("MemAvailable", 0)}
def max_temp():
temps = []
for z in glob.glob("/sys/class/thermal/thermal_zone*/temp"):
t = read(z)
if t and t.lstrip("-").isdigit():
temps.append(int(t) / 1000)
return max(temps) if temps else None
def wifi():
for line in run("nmcli", "-t", "-f", "active,ssid,signal", "dev", "wifi").splitlines():
# nmcli escapes ':' inside fields as '\:'.
parts = re.split(r"(?<!\\):", line)
if len(parts) >= 3 and parts[0] == "yes":
return {"ssid": parts[1].replace("\\:", ":"),
"signal": int(parts[2]) if parts[2].isdigit() else None}
return None
def ip_addr():
m = re.search(r"\s(\d+\.\d+\.\d+\.\d+)/", run("ip", "-4", "-brief", "addr", "show", "scope", "global"))
return m.group(1) if m else None
def os_release():
out = {}
for line in (read("/etc/os-release") or "").splitlines():
k, _, v = line.partition("=")
out[k] = v.strip('"')
return {"version": out.get("VERSION_ID"), "build": out.get("BUILD_ID"),
"variant": out.get("VARIANT_ID")}
def volume():
m = re.search(r"Volume:\s*([\d.]+)(.*)", run("wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"))
if not m:
return None
return {"level": float(m.group(1)), "muted": "MUTED" in m.group(2)}
def process_names():
return set(run("ps", "-e", "-o", "comm=").split()) # xrdp runs as root
def port_listening(port):
return f":{port} " in run("ss", "-ltn")
def games():
out = []
for f in glob.glob(os.path.join(STEAM, "steamapps/appmanifest_*.acf")):
text = read(f) or ""
fields = dict(re.findall(r'^\s*"(appid|name|SizeOnDisk)"\s+"([^"]*)"', text, re.M))
if fields.get("appid", "").isdigit() and not TOOL_NAME.match(fields.get("name", "")):
out.append({"appid": fields["appid"], "name": fields.get("name", fields["appid"]),
"size": int(fields.get("SizeOnDisk", "0")) if fields.get("SizeOnDisk", "").isdigit() else 0})
return sorted(out, key=lambda g: g["name"].lower())
def flatpaks():
out = []
for line in run("flatpak", "list", "--app", "--columns=application,name,version,installation").splitlines():
p = line.split("\t")
if len(p) == 4:
out.append({"id": p[0], "name": p[1], "version": p[2], "installation": p[3]})
return out
uptime = read("/proc/uptime")
procs = process_names()
print(json.dumps({
"time": time.time(),
"hostname": socket.gethostname(),
"os": os_release(),
"uptime": float(uptime.split()[0]) if uptime else None,
"battery": battery(),
"power": power_source(),
"disk": {"root": disk("/"), "home": disk("/home")},
"memory": memory(),
"temp": max_temp(),
"wifi": wifi(),
"ip": ip_addr(),
"volume": volume(),
"services": {
"steamvr": "vrserver" in procs,
"desktop": "plasmashell" in procs,
"lepton": port_listening(5555),
"rdp": "xrdp" in procs,
},
"games": games(),
"flatpaks": flatpaks(),
}))
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""Runs ON the Frame (piped over SSH): read and drive the Steam client.
The Frame's Steam client starts with -cef-enable-debugging, so its UI's
JavaScript context ("SharedJSContext") answers the Chrome DevTools protocol on
127.0.0.1:8080. That context holds the library (appStore), downloads
(downloadsStore) and the SteamClient API. This file is a stdlib-only WebSocket
client for it, plus the few actions Frame Control needs.
Usage: python3 - owned # owned games + download status, JSON
python3 - install APPID # start an install; reports the wizard state
python3 - store APPID # open the app's store page in the headset
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
"""
import base64
import json
import os
import socket
import struct
import subprocess
import sys
import time
import urllib.request
CDP = "http://127.0.0.1:8080/json"
# EInstallMgrState values from the client's own JS (steamui, 2026-09).
STATE = {0: "none", 1: "setup", 2: "waiting for license", 3: "free license", 4: "CD key",
5: "waiting for app info", 6: "password", 7: "config", 8: "EULA", 9: "creating apps",
10: "reading media", 11: "change media", 12: "legacy CD keys", 13: "signup",
14: "complete", 15: "failed", 16: "canceled"}
# States where Steam is waiting for someone to answer a dialog in the headset.
NEEDS_HEADSET = {3, 4, 6, 8, 11, 13}
BUSY = {1, 2, 5, 9, 10, 12}
class Fail(Exception):
pass
class Page:
"""Minimal CDP-over-WebSocket client (text frames, no extensions)."""
def __init__(self, title="SharedJSContext"):
try:
pages = json.load(urllib.request.urlopen(CDP, timeout=5))
except OSError as e:
raise Fail(f"Steam's UI isn't answering on {CDP} ({e}); is Steam running?")
url = next((p["webSocketDebuggerUrl"] for p in pages if p.get("title") == title), None)
if not url:
raise Fail(f"no {title} page; Steam may still be starting")
hostport, path = url.split("://", 1)[1].split("/", 1)
host, port = hostport.rsplit(":", 1)
self.sock = socket.create_connection((host, int(port)), timeout=30)
key = base64.b64encode(os.urandom(16)).decode()
self.sock.sendall((f"GET /{path} HTTP/1.1\r\nHost: {hostport}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n").encode())
head = b""
while b"\r\n\r\n" not in head:
chunk = self.sock.recv(4096)
if not chunk:
raise Fail("DevTools closed the connection during the handshake")
head += chunk
head, self.buf = head.split(b"\r\n\r\n", 1)
if b" 101 " not in head.split(b"\r\n")[0]:
raise Fail("DevTools refused the WebSocket upgrade")
self.next_id = 0
def _send(self, text):
data, mask = text.encode(), os.urandom(4)
n = len(data)
if n < 126:
head = struct.pack(">BB", 0x81, 0x80 | n)
elif n < 1 << 16:
head = struct.pack(">BBH", 0x81, 0x80 | 126, n)
else:
head = struct.pack(">BBQ", 0x81, 0x80 | 127, n)
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
def _take(self, n):
while len(self.buf) < n:
chunk = self.sock.recv(1 << 16)
if not chunk:
raise Fail("DevTools closed the connection")
self.buf += chunk
out, self.buf = self.buf[:n], self.buf[n:]
return out
def _recv(self):
message = b""
while True:
b0, b1 = self._take(2)
n = b1 & 0x7F
if n == 126:
n = struct.unpack(">H", self._take(2))[0]
elif n == 127:
n = struct.unpack(">Q", self._take(8))[0]
payload = self._take(n)
if b0 & 0x0F in (0x1, 0x0): # text or continuation; skip ping/pong/close
message += payload
if b0 & 0x80:
return message.decode()
def eval(self, expr):
"""Evaluate JS (awaiting promises) and return its JSON-serialisable value."""
self.next_id += 1
self._send(json.dumps({"id": self.next_id, "method": "Runtime.evaluate", "params": {
"expression": expr, "awaitPromise": True, "returnByValue": True}}))
while True:
msg = json.loads(self._recv())
if msg.get("id") != self.next_id:
continue # events
if "error" in msg: # protocol error, e.g. the page is reloading
raise Fail(f"DevTools: {msg['error'].get('message', msg['error'])}")
res = msg.get("result", {})
if "exceptionDetails" in res:
d = res["exceptionDetails"]
raise Fail(d.get("exception", {}).get("description") or d.get("text") or "JS error")
return res.get("result", {}).get("value")
# steam_hw_compat_category_packed: 2 bits per device; the client reads the
# Frame's rating as `packed >> 8 & 3` (0 unknown, 1 unsupported, 2 playable, 3 verified).
OWNED_JS = r"""
(async () => {
const country = await SteamClient.User.GetIPCountry().catch(() => null);
const dl = new Map(downloadsStore.m_DownloadOverview || []).get("0") || null;
const games = appStore.allApps.filter(a => a.app_type == 1).map(a => {
const c = a.local_per_client_data || {};
return { id: a.appid, name: a.display_name, sort: a.sort_as || a.display_name,
installed: !!c.installed, status: c.display_status ?? null, pct: c.status_percentage ?? null,
frame: (a.steam_hw_compat_category_packed >> 8) & 3, deck: a.steam_hw_compat_category_packed & 3,
vr: !!a.vr_supported, vrOnly: !!a.vr_only, size: Number(a.size_on_disk || 0),
minutes: a.minutes_playtime_forever || 0, lastPlayed: a.rt_last_time_played || 0 };
});
return { games, country, download: dl && dl.update_appid ? {
appid: dl.update_appid, state: dl.update_state, paused: dl.paused, install: dl.update_is_install,
percent: dl.overall_percent_complete, eta: dl.overall_estimated_time_remaining_sec,
bps: dl.update_network_bytes_per_second } : null };
})()
"""
WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({
state: i.eInstallState, app: i.currentAppID, need: i.nDiskSpaceRequired, free: i.nDiskSpaceAvailable,
error: i.eAppError, detail: i.errorDetail }))"""
def steam_url(url):
"""Hand a steam:// URL to the running client (the `steam` wrapper forwards it)."""
subprocess.Popen(["steam", url], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
def owned():
return Page().eval(OWNED_JS)
def install(appid):
page = Page()
app = page.eval(f"(a => a && {{name: a.display_name, installed: !!a.local_per_client_data?.installed}})"
f"(appStore.GetAppOverviewByAppID({appid}))")
if app and app["installed"]:
return {"state": "installed", "message": f"{app['name']} is already installed"}
name = app["name"] if app else str(appid)
# The URL goes through Steam's own handler, which marks the install as
# expected and skips the options dialog when there is one library folder
# and enough space (verified 2026-09-25). A bare OpenInstallWizard doesn't.
steam_url(f"steam://install/{appid}")
w = None
deadline = time.time() + 12
while time.time() < deadline:
time.sleep(0.4)
w = page.eval(WIZARD_JS)
if w["app"] == appid and w["state"] not in BUSY:
break
if not w or w["app"] != appid:
# Steam didn't open a wizard for this app in time, or another app's is open.
if not app:
return {"state": "unknown", "message": f"Asked Steam to install {appid}. It isn't in this account's "
"library, so Steam may be showing a store or license dialog in the headset"}
other = f" Another install dialog (app {w['app']}) is open in the headset." if w and w["app"] else ""
return {"state": "unknown", "message": f"Asked Steam to install {name}, but it didn't start within 12 s.{other}"}
s = w["state"]
# Steam stops at the options dialog when it wants to show a compatibility
# note or the disk settings. Accept its defaults (the default library
# folder) when the game fits, as the headset's own Install button does.
if s == 7 and 0 < w["need"] < w["free"]:
page.eval("SteamClient.Installs.ContinueInstall()")
time.sleep(1.5)
w = page.eval(WIZARD_JS)
s = 14 if w["state"] in (0, 14) else w["state"]
label = STATE.get(s, str(s))
if s == 14:
return {"state": "downloading", "message": f"{name} is queued to download on the Frame"}
if s == 7:
return {"state": "headset", "message": f"Steam is showing install options for {name} in the headset "
f"(needs {w['need'] / 1e9:.1f} GB, {w['free'] / 1e9:.0f} GB free)"}
if s in NEEDS_HEADSET:
return {"state": "headset", "message": f"Steam needs you to accept the {label} for {name} in the headset"}
if s == 15:
raise Fail(f"Steam couldn't install {name}: {w.get('detail') or 'error ' + str(w.get('error'))}")
if not app:
return {"state": "unknown", "message": f"Asked Steam to install {appid}. It isn't in this account's "
"library, so Steam may be showing a store or license dialog in the headset"}
return {"state": label, "message": f"Asked Steam to install {name} (wizard: {label})"}
def store(appid):
steam_url(f"steam://store/{appid}")
return {"message": "Opened the store page in the headset"}
def main():
try:
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "owned":
out = owned()
elif cmd in ("install", "store") and len(sys.argv) == 3 and sys.argv[2].isdigit():
out = (install if cmd == "install" else store)(int(sys.argv[2]))
else:
raise Fail("usage: owned | install APPID | store APPID")
except (Fail, OSError) as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
except Exception as e: # keep the one-JSON-object contract for the server
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
sys.exit(1)
print(json.dumps(out))
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
"""Mac side: search the Steam store and look up each result's Steam Frame rating.
Uses the store's public endpoints (no key, no login):
api/storesearch name search, price in the IP's currency
saleaction/ajaxgetdeckappcompatibilityreport per-app Deck/SteamOS/Machine/Frame ratings;
`frame_resolved_category` is the Frame's
Buying happens on the store page, signed in as the user; nothing here buys.
"""
import json
import threading
import time
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
STORE = "https://store.steampowered.com"
UA = {"User-Agent": "FrameControl/1 (+local)"}
COMPAT_TTL = 24 * 3600
_compat = {} # appid -> (time, category)
_lock = threading.Lock()
def _get(path, params, timeout=10):
url = f"{STORE}/{path}?{urllib.parse.urlencode(params)}"
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout) as r:
return json.load(r)
def frame_rating(appid):
"""0 unknown, 1 unsupported, 2 playable, 3 verified (same scale as the client's)."""
with _lock:
hit = _compat.get(appid)
if hit and time.time() - hit[0] < COMPAT_TTL:
return hit[1]
try:
r = _get("saleaction/ajaxgetdeckappcompatibilityreport", {"nAppID": appid, "l": "english"})
cat = int((r.get("results") or {}).get("frame_resolved_category") or 0)
cat = cat if 0 <= cat <= 3 else 0
except (OSError, ValueError, TypeError, AttributeError):
return 0 # not cached, so the next search retries
with _lock:
_compat[appid] = (time.time(), cat)
return cat
def search(term, cc):
"""cc: two-letter store country; storesearch returns nothing without one."""
term = term.strip()[:100]
if not term:
return []
items = _get("api/storesearch", {"term": term, "l": "english", "cc": cc}).get("items") or []
apps = [i for i in items if i.get("type") == "app" and str(i.get("id", "")).isdigit()]
with ThreadPoolExecutor(8) as pool:
ratings = list(pool.map(lambda i: frame_rating(int(i["id"])), apps))
return [{"id": int(i["id"]), "name": i.get("name", ""), "price": i.get("price"),
"image": i.get("tiny_image"), "metascore": i.get("metascore") or None, "frame": f}
for i, f in zip(apps, ratings)]
+108
View File
@@ -0,0 +1,108 @@
"""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()
+1396
View File
File diff suppressed because it is too large. Load diff
Executable
+785
View File
@@ -0,0 +1,785 @@
#!/usr/bin/env python3
"""Frame Control: a small local web UI for managing the Steam Frame from the Mac.
Stdlib only. Listens on 127.0.0.1 and talks to the headset through the `frame`
SSH alias set up by scripts/connect.sh, reusing the scripts in ../scripts.
Usage: ui/server.py [--port 47810] (normally started by scripts/frame-ui.sh)
Env: FRAME_ALIAS (default frame)
"""
import argparse
import json
import os
import re
import shlex
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
import frame_android
import frame_catalog
import frame_store
HERE = Path(__file__).resolve().parent
SCRIPTS = HERE.parent / "scripts"
FRAME = os.environ.get("FRAME_ALIAS", "frame")
# Reuse one SSH connection for the frequent status/screenshot calls. /tmp, not
# $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
CONTROL = f"/tmp/frame-ui-{os.getuid()}-%C"
MUX = ["ssh", "-o", "BatchMode=yes", "-o", f"ControlPath={CONTROL}"]
# Commands use the master when it's up and connect directly when it isn't.
SSH = [*MUX, "-o", "ControlMaster=no", "-o", "ConnectTimeout=5"]
# Android helpers share the multiplexed connection when it's up.
frame_android.SSH_OPTS = SSH[1:]
APPID = re.compile(r"^\d{1,10}$")
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
MAX_UPLOAD = 8 * 1024**3
MAX_JSON = 1024**2
# gamescope writes the PNG asynchronously; wait until its size stops changing.
SCREENSHOT = r"""
set -eu
f=$(mktemp /tmp/frame-ui-XXXXXX.png)
trap 'rm -f "$f"' EXIT
XDG_RUNTIME_DIR=/run/user/$(id -u) WAYLAND_DISPLAY=gamescope-0 gamescopectl screenshot "$f" >/dev/null 2>&1
last=-1
for i in $(seq 1 50); do
sleep 0.1
size=$(stat -c %s "$f" 2>/dev/null || echo 0)
if [ "$size" -gt 0 ] && [ "$size" = "$last" ]; then cat "$f"; exit 0; fi
last=$size
done
echo "gamescope did not write a screenshot" >&2
exit 1
"""
class Failure(Exception):
def __init__(self, message, status=502):
super().__init__(message)
self.status = status
_master_lock = threading.Lock()
_master = None
def ensure_master():
"""Start the shared SSH connection if it isn't up (one attempt at a time).
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
"""
global _master
def up():
try:
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True,
timeout=5).returncode == 0
except subprocess.TimeoutExpired:
return False
with _master_lock:
if up() or (_master and _master.poll() is None):
return
# Keepalives make a dead link (Frame asleep, off Wi-Fi) exit within ~10s,
# so the next request starts a fresh master.
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=2", "-N", FRAME],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
for _ in range(60):
if up() or _master.poll() is not None:
return
time.sleep(0.05)
def ssh(remote, *, stdin=None, timeout=30, text=True):
try:
ensure_master()
r = subprocess.run([*SSH, FRAME, remote], input=stdin, capture_output=True,
text=text, errors="replace" if text else None, timeout=timeout)
except subprocess.TimeoutExpired:
raise Failure(f"Timed out talking to {FRAME}")
if r.returncode != 0:
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
raise failure
return r.stdout
def script(name, *args, stdin=None, timeout=900):
"""Run one of ../scripts and return its combined output."""
try:
r = subprocess.run([str(SCRIPTS / name), *args], input=stdin, text=True, timeout=timeout,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
env={**os.environ, "FRAME_ALIAS": FRAME})
except subprocess.TimeoutExpired:
raise Failure(f"{name} timed out")
out = strip_ansi(r.stdout).strip()
if r.returncode != 0:
raise Failure(out or f"{name} exited {r.returncode}")
return out
def strip_ansi(s):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
def terminal(command):
"""Open Terminal.app running `command` (for anything needing a password)."""
as_str = command.replace("\\", "\\\\").replace('"', '\\"')
r = subprocess.run(["osascript", "-e", 'tell application "Terminal"',
"-e", f'do script "{as_str}"', "-e", "activate", "-e", "end tell"],
capture_output=True, text=True)
if r.returncode != 0:
# Usually macOS Automation consent for Terminal was denied.
raise Failure(f"Couldn't open Terminal: {r.stderr.strip()}", 500)
def open_app(name, fallback_url):
if subprocess.run(["open", "-a", name], capture_output=True).returncode == 0:
return f"Opened {name}"
subprocess.run(["open", fallback_url])
return f"{name} isn't installed; opened its download page"
# ---- actions ---------------------------------------------------------------
def status(_body):
return json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20))
def headset_view():
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
# `timeout`: VR_Init can block if SteamVR is restarting.
out = ssh("timeout 15 python3 -", stdin=(HERE / "frame_vrshot.py").read_text(), timeout=30)
# SteamVR prints its own notices (e.g. about vrwebhelper) on stdout too, so
# take the last line that is our result object.
result = {"error": out.strip() or "no output"}
for line in out.splitlines():
try:
obj = json.loads(line)
except ValueError:
continue
if isinstance(obj, dict) and ("path" in obj or "error" in obj):
result = obj
if "error" in result:
raise Failure(str(result["error"]))
path = str(result["path"])
if not re.fullmatch(r"/tmp/frame-vrcap/shot-\d+-vr\.png", path):
raise Failure(f"unexpected capture path {path!r}")
# Captures show whatever was on screen, so delete even if the copy fails.
try:
return ssh(f"cat {path}; rc=$?; rm -f {path}; exit $rc", timeout=20, text=False)
finally:
try:
ssh(f"rm -f {path}", timeout=10)
except Failure:
pass # frame_vrshot.py sweeps leftovers on the next capture
def launch(body):
appid = str(body.get("appid", ""))
if not APPID.match(appid):
raise Failure("bad appid", 400)
ssh(f"steam steam://rungameid/{appid} >/dev/null 2>&1 &")
return {"message": f"Launching {appid}"}
def steam_frame(*args, timeout=40):
"""Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON."""
try:
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout)
except Failure as e:
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh()
# reports stderr instead if there was any, so look in both.
for line in reversed([*getattr(e, "stdout", "").splitlines(), *str(e).splitlines()]):
try:
raise Failure(json.loads(line)["error"]) from None
except (ValueError, KeyError, TypeError):
continue
raise
return json.loads(out)
def steam(body):
"""Steam games: install an owned game, or open its store page in the headset."""
appid, action = str(body.get("appid", "")), body.get("action")
if not APPID.match(appid):
raise Failure("bad appid", 400)
if action not in ("install", "store"):
raise Failure("action must be install or store", 400)
return steam_frame(action, appid)
def steam_search(query):
q = parse_qs(query)
cc = (q.get("cc") or [""])[0].upper()
if not re.fullmatch(r"[A-Z]{2}", cc):
raise Failure("cc must be a two-letter country code", 400)
try:
return {"results": frame_store.search((q.get("q") or [""])[0], cc)}
except (OSError, ValueError, TypeError, AttributeError) as e:
raise Failure(f"Steam store search failed: {e}")
def set_volume(body):
if "muted" in body:
ssh(f"wpctl set-mute @DEFAULT_AUDIO_SINK@ {1 if body['muted'] else 0}")
if "level" in body:
level = float(body["level"])
if not 0 <= level <= 1:
raise Failure("level must be 0..1", 400)
ssh(f"wpctl set-volume @DEFAULT_AUDIO_SINK@ {level:.2f}")
return {"message": "Volume updated"}
def clipboard(body):
if body.get("fromMac"):
return {"message": script("paste-to-frame.sh", timeout=30)}
text = body.get("text")
if not isinstance(text, str) or not text:
raise Failure("nothing to send", 400)
return {"message": script("paste-to-frame.sh", "-", stdin=text, timeout=30)}
def flatpak(body):
app, action = str(body.get("id", "")), body.get("action")
if not FLATPAK_ID.match(app):
raise Failure("bad Flatpak app ID", 400)
if action == "install":
return {"message": script("install-apps.sh", app)}
if action == "uninstall":
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
raise Failure("action must be install or uninstall", 400)
def open_thing(body):
what = body.get("what")
alias = shlex.quote(FRAME)
if what == "terminal":
terminal(f"ssh {alias}")
return {"message": "Opened an SSH session in Terminal"}
if what in ("reboot", "poweroff", "suspend"):
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
terminal(f"ssh -t {alias} sudo systemctl {what}")
return {"message": f"Confirm with the Developer Mode password in Terminal to {what}"}
if what == "steamlink":
return {"message": open_app("Steam Link", "https://store.steampowered.com/remoteplay")}
if what == "rdp":
return {"message": open_app("Windows App", "https://apps.apple.com/app/windows-app/id1295203466")}
if what == "sftp":
terminal(f"sftp {alias}")
return {"message": "Opened an SFTP session in Terminal"}
raise Failure("unknown target", 400)
def android(body):
"""Android apps, each in its own persistent Lepton instance (frame_android.py)."""
action, pkg = body.get("action"), str(body.get("package", ""))
ensure_master()
try:
if action == "install":
m = frame_catalog.install(pkg)
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m}
if action in ("launch", "stop"):
m = getattr(frame_android, action)(pkg)
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
if action == "remove":
m = frame_android.remove(pkg, keep_data=bool(body.get("keepData")))
return {"message": f"Removed {m['label']}"}
if action == "probe":
r = frame_catalog.probe_and_report(pkg)
word = {"runs": "runs", "crashes": "crashed", "instance_failed": "didn't start"}.get(r["result"], r["result"])
return {"message": f"{pkg} {word}" + (f": {r['detail']}" if r.get("detail") else ""), "probe": r}
if action == "report":
# Any APK, not only catalogue or installed ones: package, did it work, how it was run.
r = frame_catalog.add_report(pkg, body.get("version"), rating=body.get("rating"),
notes=str(body.get("notes") or ""),
runtime=body.get("runtime") or "instance",
label=body.get("label"), source=body.get("source"))
name = r.get("label") or pkg
return {"message": f"Saved your report for {name}", "report": r}
except frame_android.FrameError as e:
raise Failure(str(e))
raise Failure("unknown action", 400)
# ---- Android display (wm size / wm density / font_scale over ADB) -----------
#
# Each running Lepton instance listens for ADB on the Frame (5555 is Lepton
# Development; own-instance apps get the next free port). ADB goes through a
# dedicated SSH forward that lives only for the request, like
# scripts/install-apk.sh, and is always torn down with an adb disconnect.
ADB_PORTS = range(5555, 5600)
SIZE_RE = re.compile(r"^(\d{3,4})x(\d{3,4})$")
DENSITY_RANGE = (120, 640)
WIDTH_RANGE, HEIGHT_RANGE = (640, 3840), (360, 2160)
FONT_RANGE = (0.5, 2.0)
KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"}
# One ADB session at a time: requests are rare, and it keeps adb's state simple.
_adb_lock = threading.Lock()
_live_tunnels = set() # ssh processes to kill if the server stops mid-request
def adb_path():
for cand in (os.environ.get("ADB"), shutil.which("adb"), "/opt/homebrew/bin/adb",
str(Path.home() / ".homebrew/bin/adb"), "/usr/local/bin/adb"):
if cand and os.access(cand, os.X_OK):
return cand
raise Failure("adb missing on the Mac: brew install android-platform-tools", 500)
def adb(adb_bin, *args, timeout=20):
try:
r = subprocess.run([adb_bin, *args], capture_output=True, text=True,
errors="replace", timeout=timeout)
except subprocess.TimeoutExpired:
raise Failure(f"adb {' '.join(args[-2:])} timed out")
out = (r.stdout + r.stderr).strip()
if r.returncode != 0:
raise Failure(out or f"adb exited {r.returncode}")
return out
def free_local_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class AdbTunnel:
"""SSH forwards from Mac loopback to Frame ADB ports, plus adb connections.
`with AdbTunnel([5555, 5557]) as t: t.shell(5555, "wm size")`. On exit it
disconnects adb and kills the ssh process, whatever happened inside.
"""
def __init__(self, ports):
self.remote = list(ports)
self.local = {}
self.proc = None
self.adb = adb_path()
def __enter__(self):
if not _adb_lock.acquire(timeout=60):
raise Failure("another Android display request is still running; try again", 503)
try:
self._open()
except BaseException:
self.__exit__(None, None, None)
raise
return self
def _open(self):
try:
self._forward()
except Failure:
# A local port picked by free_local_port() can be taken before ssh
# binds it (ExitOnForwardFailure turns that into an error): retry once.
self._stop_ssh()
self._forward()
self.failed = {}
for p in self.remote:
out = adb(self.adb, "connect", self.serial(p), timeout=15)
# adb connect exits 0 even when it fails; check what it says. Keep
# going so one stuck port doesn't hide the healthy instances.
if "connected to" not in out:
self.failed[p] = f"adb couldn't connect to Frame port {p}: {out}"
def _forward(self):
self.local = {p: free_local_port() for p in self.remote}
fwd = [a for p, lp in self.local.items() for a in ("-L", f"127.0.0.1:{lp}:127.0.0.1:{p}")]
# Its own connection (ControlPath=none), so killing it drops the forwards.
self.proc = _proc = subprocess.Popen(
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none",
"-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-N", *fwd, FRAME],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
_live_tunnels.add(_proc)
deadline = time.time() + 12
pending = set(self.local.values())
while pending:
if self.proc.poll() is not None:
err = self.proc.stderr.read().decode(errors="replace").strip()
raise Failure(f"ADB tunnel failed: {err or 'ssh exited ' + str(self.proc.returncode)}")
if time.time() > deadline:
raise Failure("ADB tunnel didn't come up within 12s")
for lp in list(pending):
try:
socket.create_connection(("127.0.0.1", lp), timeout=0.5).close()
pending.discard(lp)
except OSError:
pass
if pending:
time.sleep(0.1)
def serial(self, port):
return f"127.0.0.1:{self.local[port]}"
def shell(self, port, command, timeout=20):
if port in getattr(self, "failed", {}):
raise Failure(self.failed[port])
return adb(self.adb, "-s", self.serial(port), "shell", command, timeout=timeout)
def _stop_ssh(self):
proc, self.proc = self.proc, None
if not proc:
return
try:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(5)
except OSError:
pass
finally:
_live_tunnels.discard(proc)
if proc.stderr:
proc.stderr.close()
def __exit__(self, *exc):
try:
# Tunnel first: it's what could outlive us. `adb disconnect` only
# talks to the local adb server, so it works without the tunnel.
self._stop_ssh()
for p in self.local:
try:
subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, timeout=10)
except (subprocess.TimeoutExpired, OSError):
pass
finally:
_adb_lock.release()
return False
DISPLAY_READ = "echo @@pkgs; pm list packages -3; echo @@size; wm size; echo @@density; wm density; " \
"echo @@font; settings get system font_scale"
def parse_display(out):
sec, parts = None, {}
for line in out.splitlines():
line = line.strip()
if line.startswith("@@"):
sec = line[2:]
parts[sec] = []
elif sec and line:
parts[sec].append(line)
def pick(lines, key):
for line in lines:
if line.lower().startswith(key) and ":" in line:
return line.split(":", 1)[1].strip()
return None
size, density = parts.get("size", []), parts.get("density", [])
font = (parts.get("font") or ["null"])[0]
try:
font_scale = None if font == "null" else float(font)
except ValueError:
font_scale = None
phys_d, over_d = pick(density, "physical density"), pick(density, "override density")
return {
"packages": sorted(l.split(":", 1)[1] for l in parts.get("pkgs", []) if l.startswith("package:")),
"physicalSize": pick(size, "physical size"),
"overrideSize": pick(size, "override size"),
"physicalDensity": int(phys_d) if phys_d and phys_d.isdigit() else None,
"overrideDensity": int(over_d) if over_d and over_d.isdigit() else None,
# null means never set, which Android treats as 1.0.
"fontScale": font_scale,
}
def lepton_ports():
"""Frame ADB ports (5555-5599) that are listening now, with container names and app labels."""
out = ssh("ss -ltnH; echo @@podman; podman ps --format '{{.Names}} {{.Labels.adb_port}}' 2>/dev/null; "
f"echo @@meta; for f in {frame_android.APPS_DIR}/*/meta.json; do [ -f \"$f\" ] && cat \"$f\" && echo @@m; done; true",
timeout=20)
listen, _, rest = out.partition("@@podman")
podman, _, meta = rest.partition("@@meta")
ports = set()
for line in listen.splitlines():
cols = line.split()
if len(cols) >= 4:
port = cols[3].rsplit(":", 1)[-1]
if port.isdigit() and int(port) in ADB_PORTS:
ports.add(int(port))
containers = {}
for line in podman.splitlines():
cols = line.split()
if len(cols) == 2 and cols[1].isdigit():
containers[int(cols[1])] = cols[0]
labels = dict(KNOWN_LABELS)
for chunk in meta.split("@@m"):
try:
m = json.loads(chunk)
labels[str(m["package"])] = str(m["label"])
except (ValueError, KeyError, TypeError):
pass
return sorted(ports), containers, labels
def android_displays():
ports, containers, labels = lepton_ports()
if not ports:
return {"instances": []}
instances = []
with AdbTunnel(ports) as t:
for p in ports:
item = {"port": p, "container": containers.get(p)}
try:
item.update(parse_display(t.shell(p, DISPLAY_READ)))
except Failure as e:
item["error"] = str(e)
item["labels"] = {pkg: labels[pkg] for pkg in item.get("packages", []) if pkg in labels}
instances.append(item)
return {"instances": instances}
def android_display(body):
port = body.get("port")
if type(port) is not int or port not in ADB_PORTS:
raise Failure(f"port must be an integer {ADB_PORTS.start}-{ADB_PORTS.stop - 1}", 400)
cmds = []
size = body.get("size")
if size is not None:
if size == "reset":
cmds.append("wm size reset")
else:
m = SIZE_RE.fullmatch(size) if isinstance(size, str) else None
if not m:
raise Failure("size must be WIDTHxHEIGHT (e.g. 2560x1440) or \"reset\"", 400)
w, h = int(m[1]), int(m[2])
if not (WIDTH_RANGE[0] <= w <= WIDTH_RANGE[1] and HEIGHT_RANGE[0] <= h <= HEIGHT_RANGE[1]):
raise Failure(f"size must be {WIDTH_RANGE[0]}-{WIDTH_RANGE[1]} wide and "
f"{HEIGHT_RANGE[0]}-{HEIGHT_RANGE[1]} high", 400)
cmds.append(f"wm size {w}x{h}")
density = body.get("density")
if density is not None:
if density == "reset":
cmds.append("wm density reset")
elif type(density) is int and DENSITY_RANGE[0] <= density <= DENSITY_RANGE[1]:
cmds.append(f"wm density {density}")
else:
raise Failure(f"density must be an integer {DENSITY_RANGE[0]}-{DENSITY_RANGE[1]} or \"reset\"", 400)
font = body.get("fontScale")
if font is not None:
if font == "reset":
# Applying a config change (e.g. the wm resets just before) writes
# font_scale=1.0 back asynchronously, so delete again once it settles.
cmds.append("settings delete system font_scale; sleep 1; settings delete system font_scale")
elif type(font) in (int, float) and FONT_RANGE[0] <= font <= FONT_RANGE[1]:
cmds.append(f"settings put system font_scale {round(float(font), 3):g}")
else:
raise Failure(f"fontScale must be a number {FONT_RANGE[0]}-{FONT_RANGE[1]} or \"reset\"", 400)
if not cmds:
raise Failure("nothing to change: give density, size or fontScale", 400)
ports, _, _ = lepton_ports()
if port not in ports:
raise Failure(f"no Lepton instance is listening on Frame port {port}", 404)
with AdbTunnel([port]) as t:
for c in cmds:
out = t.shell(port, c)
# wm prints usage or an exception on failure but may still exit 0.
if re.search(r"exception|error|usage", out, re.I):
raise Failure(f"{c}: {out}")
now = parse_display(t.shell(port, DISPLAY_READ))
now["port"] = port
return {"message": f"Port {port}: " + "; ".join(c.split(";")[0] for c in cmds), "display": now}
POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing}
# ---- HTTP ------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
server_version = "FrameControl/1"
timeout = 60 # per socket operation, so a stalled client can't hold a thread
def log_message(self, fmt, *args):
sys.stderr.write("%s %s\n" % (self.command, fmt % args))
def local_request(self):
# Blocks DNS rebinding (Host) and cross-site form posts (custom header
# forces a CORS preflight, which this server never approves).
host = (self.headers.get("Host") or "").rsplit(":", 1)[0]
if host not in ("127.0.0.1", "localhost"):
self.send_json({"error": "forbidden host"}, 403)
return False
# All of /api/*, not just POST: an <img> on any website could otherwise
# trigger a headset capture and display it.
api = urlparse(self.path).path.startswith("/api/")
if (self.command == "POST" or api) and self.headers.get("X-Frame-UI") != "1":
self.send_json({"error": "missing X-Frame-UI header"}, 403)
return False
return True
def send_bytes(self, data, ctype, status=200, headers=()):
self.send_response(status)
self.send_header("Content-Type", ctype)
for name, value in headers:
self.send_header(name, value)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
# Nobody may frame the UI (clickjacking).
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
self.end_headers()
self.wfile.write(data)
def send_json(self, obj, status=200):
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
def do_GET(self):
if not self.local_request():
return
url = urlparse(self.path)
path = url.path
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/android":
ensure_master()
self.send_json({"apps": frame_android.list_apps()})
elif path == "/api/android/displays":
self.send_json(android_displays())
elif path == "/api/android/reports":
self.send_json({"reports": frame_catalog.recent_reports()})
elif path == "/api/android/catalog":
self.send_json({"apps": frame_catalog.catalog()})
elif path == "/api/status":
self.send_json(status({}))
elif path == "/api/steam/owned":
self.send_json(steam_frame("owned"))
elif path == "/api/steam/search":
self.send_json(steam_search(url.query))
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
elif path == "/api/screenshot":
self.send_bytes(ssh(SCREENSHOT, timeout=20, text=False), "image/png",
headers=[("X-Capture-Source", "gamescope")])
else:
self.send_json({"error": "not found"}, 404)
except Failure as e:
self.send_json({"error": str(e)}, e.status)
except Exception as e:
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
def do_POST(self):
if not self.local_request():
return
path = urlparse(self.path).path
try:
if path == "/api/upload":
self.send_json(self.upload())
return
handler = POST.get(path)
if not handler:
self.send_json({"error": "not found"}, 404)
return
length = int(self.headers.get("Content-Length") or 0)
if not 0 <= length <= MAX_JSON:
raise Failure("request body too large", 413)
body = json.loads(self.rfile.read(length) or b"{}")
if not isinstance(body, dict):
raise Failure("request body must be a JSON object", 400)
self.send_json(handler(body))
except Failure as e:
self.send_json({"error": str(e)}, e.status)
except (ValueError, TypeError) as e:
self.send_json({"error": f"bad request: {e}"}, 400)
except Exception as e:
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
def upload(self):
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
mode = self.headers.get("X-Mode", "push")
length = int(self.headers.get("Content-Length") or 0)
if not name or name.startswith("."):
raise Failure("missing filename", 400)
if length <= 0 or length > MAX_UPLOAD:
raise Failure("empty or too-large upload", 400)
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
raise Failure("APK install needs a .apk file", 400)
tmp = Path(tempfile.mkdtemp(prefix="frame-ui-"))
try:
dest = tmp / name
with open(dest, "wb") as f:
remaining = length
while remaining:
chunk = self.rfile.read(min(remaining, 1 << 20))
if not chunk:
raise Failure("upload interrupted", 400)
f.write(chunk)
remaining -= len(chunk)
if mode == "apkinfo":
# Read an APK for a report without installing it.
try:
info = frame_android.apk_info(str(dest))
except frame_android.FrameError as e:
raise Failure(str(e), 400)
info.pop("icon_png", None)
try:
frame_android.check_installable(info)
info["blocker"] = None
except frame_android.FrameError as e:
info["blocker"] = str(e)
return {"message": f"Read {info['label']} {info['version']}", "apk": info}
if mode == "apk":
ensure_master()
try:
m = frame_android.install(str(dest), source=name)
except frame_android.FrameError as e:
raise Failure(str(e), 400)
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
return {"message": script("push.sh", str(dest))}
finally:
shutil.rmtree(tmp, ignore_errors=True)
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
args = ap.parse_args()
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
# The master was started with -N, so it stays up until told to exit.
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
if _master and _master.poll() is None:
_master.terminate()
for proc in list(_live_tunnels): # ADB forwards of requests cut off mid-way
if proc.poll() is None:
proc.terminate()
if __name__ == "__main__":
main()