mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
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>
155 lines
6.4 KiB
Python
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
|