Files
saphid--frame-control/ui/frame_catalog.py
T
saphidandClaude Opus 5.5 d4486a7681 Frame Control: Mac app, web UI, Android and Steam tooling
Package the Frame Control web UI as an installable Electron Mac app and
bring in the tooling built alongside it.

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

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

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

155 lines
6.4 KiB
Python

"""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