mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 10:00:34 +02:00
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:
1 parent
6ccf562756
commit
d4486a7681
56 files changed
+19298
-11
No files matched your search
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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(),
|
||||
}))
|
||||
@@ -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()
|
||||
@@ -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)]
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Executable
+785
@@ -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()
|
||||
Reference in new issue
Block a user