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

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

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

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

124 lines
4.6 KiB
Python

"""Scan F-Droid APKs (latest version per app) for Steam Frame / Lepton signals.
Reads only the zip central directory and the resources.arsc key-string pool
through HTTP range requests. Output: one JSON line per package (resumable).
"""
import json, os, struct, sys, zlib, threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import zipcd
from pick import pick_version
REPO = 'https://f-droid.org/repo'
HERE = os.path.dirname(os.path.abspath(__file__))
IDX = os.path.join(HERE, 'data', 'index-v2.json')
OUT = os.path.join(HERE, 'data', 'scan.jsonl')
KEYS = {
'compose': [b'compose_view_saveable_id_tag', b'wrapped_composition_tag',
b'androidx_compose_ui_view_compositionlocal_map'],
'gms': [b'common_google_play_services_unknown_issue',
b'common_google_play_services_install_title'],
'firebase': [b'google_app_id', b'gcm_defaultSenderId'],
}
LIBS = {
'unity': 'libunity.so', 'flutter': 'libflutter.so', 'reactnative': 'libreactnative',
'hermes': 'libhermes', 'godot': 'libgodot_android.so', 'gdx': 'libgdx.so',
'sdl': 'libSDL2.so', 'unreal': 'libUE4.so', 'unreal5': 'libUnreal.so',
'xamarin': 'libmonodroid.so', 'qt': 'libQt5Core', 'qt6': 'libQt6Core',
'cocos': 'libcocos', 'love': 'liblove.so', 'renpy': 'librenpy',
'kivy': 'libpython', 'gomobile': 'libgojni.so',
}
def arsc_keys(url, entries):
comp, csize, lho = entries['resources.arsc']
h = zipcd.rng(url, lho, lho + 29)
nl, el = struct.unpack('<HH', h[26:30])
base = lho + 30 + nl + el
if comp == 8:
blob = zlib.decompress(zipcd.rng(url, base, base + csize - 1), -15)
read = lambda o, n: blob[o:o + n]
elif comp == 0:
read = lambda o, n: zipcd.rng(url, base + o, base + o + n - 1)
else:
raise ValueError(f'arsc compression {comp}')
th = read(0, 12)
if struct.unpack('<H', th[:2])[0] != 2:
raise ValueError('not a ResTable')
off = struct.unpack('<H', th[2:4])[0]
pools = []
total = struct.unpack('<I', th[4:8])[0]
while off < total:
ch = read(off, 8)
ctype, chdr, csz = struct.unpack('<HHI', ch)
if ctype == 0x0200: # package
ph = read(off, 288)
key_off = struct.unpack('<I', ph[8 + 4 + 256 + 8:8 + 4 + 256 + 12])[0]
kh = read(off + key_off, 8)
ksz = struct.unpack('<I', kh[4:8])[0]
pools.append(read(off + key_off, ksz))
if csz <= 0:
break
off += csz
return b''.join(pools)
def scan(pkg, meta, ver):
f = ver['file']
url = REPO + f['name']
m = ver['manifest']
r = {'pkg': pkg, 'vc': m.get('versionCode'), 'vn': m.get('versionName'),
'apk': url, 'size': f.get('size')}
try:
names, entries, _ = zipcd.list_names(url, f['size'])
libs = {n for n in names if n.startswith('lib/')}
r['frameworks'] = sorted(k for k, s in LIBS.items() if any(s in n for n in libs))
r['abis'] = sorted({n.split('/')[1] for n in libs if n.count('/') >= 2})
r['metainf_compose'] = any(n.startswith('META-INF/androidx.compose.ui') for n in names)
r['assets_bin_data'] = any(n.startswith('assets/bin/Data/') for n in names)
if 'resources.arsc' in entries:
kp = arsc_keys(url, entries)
for k, pats in KEYS.items():
r[k] = any(p in kp or p.decode().encode('utf-16-le') in kp for p in pats)
else:
r['no_arsc'] = True
except Exception as e: # keep going; record the failure
r['error'] = f'{type(e).__name__}: {e}'[:200]
return r
def main():
idx = json.load(open(IDX))
done = set()
if os.path.exists(OUT):
for line in open(OUT):
try:
r = json.loads(line)
done.add((r['pkg'], r.get('vc')))
except Exception:
pass
jobs = []
for pkg, p in idx['packages'].items():
if not p.get('versions'):
continue
ver = pick_version(p)
if (pkg, ver['manifest'].get('versionCode')) not in done:
jobs.append((pkg, p['metadata'], ver))
print(f'{len(done)} done, {len(jobs)} to scan', flush=True)
lock = threading.Lock()
n = 0
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '12'))) as ex:
futs = [ex.submit(scan, *j) for j in jobs]
for fu in as_completed(futs):
r = fu.result()
with lock:
out.write(json.dumps(r) + '\n'); out.flush()
n += 1
if n % 100 == 0:
print(f'{n}/{len(jobs)}', flush=True)
print('finished', flush=True)
if __name__ == '__main__':
main()