Files
saphidandClaude Opus 5.5 a1fa4ce140 Fix the cross-provider review's findings on APK alternatives
One malformed or unreachable repo no longer hides the others; skip bad
index entries; a refreshed raw index outdates its reduced copy; style the
dialog like the others; validate package ids with PKG_RE.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:15:11 +10:00

295 lines
12 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, json, 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_host # noqa: E402
import frame_compat_db as compat_db # noqa: E402
# Inside the installed app the catalogue folder is read-only, so downloads go to
# the per-user cache (FRAME_CONTROL_APP is set by app/main.js).
CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG
else os.path.join(CATALOG, 'data', 'cache'))
# Repo base URL -> local name of its index; every APK download must come from one of these.
INDEX_FILES = {'https://f-droid.org/repo/': 'index-v2.json',
'https://f-droid.org/archive/': 'index-v2.archive.json',
'https://apt.izzysoft.de/fdroid/repo/': 'index-v2.izzy.json'}
APK_HOSTS = tuple(INDEX_FILES)
_lock = threading.Lock()
_cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None}
_env = {}
_index_lock = threading.Lock()
_indexes = {}
class _IndexReader:
"""Decode one object member at a time; never retain the whole raw index."""
def __init__(self, stream):
self.stream, self.buffer = stream, ''
self.decoder = json.JSONDecoder()
def fill(self):
chunk = self.stream.read(1 << 16)
if not chunk:
raise ValueError('incomplete F-Droid index')
self.buffer += chunk
def peek(self):
self.buffer = self.buffer.lstrip()
while not self.buffer:
self.fill()
self.buffer = self.buffer.lstrip()
return self.buffer[0]
def expect(self, char):
if self.peek() != char:
raise ValueError('invalid F-Droid index')
self.buffer = self.buffer[1:]
def value(self):
self.peek()
while True:
try:
value, end = self.decoder.raw_decode(self.buffer)
self.buffer = self.buffer[end:]
return value
except json.JSONDecodeError:
self.fill()
def members(self):
self.expect('{')
if self.peek() != '}':
while True:
key = self.value()
if not isinstance(key, str):
raise ValueError('invalid F-Droid index key')
self.expect(':')
yield key
if self.peek() == '}':
break
self.expect(',')
self.expect('}')
def _reduce_index(path):
from pick import installable
packages = {}
found = False
with open(path, encoding='utf-8') as f:
reader = _IndexReader(f)
for key in reader.members():
if key != 'packages':
reader.value()
continue
found = True
for package in reader.members():
records, entry = [], reader.value()
versions = entry.get('versions') if isinstance(entry, dict) else None
for v in (versions.values() if isinstance(versions, dict) else ()):
# Skip malformed entries rather than losing the whole repo.
if not (isinstance(v, dict) and isinstance(v.get('manifest'), dict)
and isinstance(v.get('file'), dict) and v['file'].get('name')):
continue
if not installable(v):
continue
m, file = v['manifest'], v['file']
records.append({'version': m.get('versionName', ''),
'version_code': m.get('versionCode', 0),
'min_sdk': m.get('usesSdk', {}).get('minSdkVersion', 1),
'abis': m.get('nativecode') or [],
'name': file['name'], 'sha256': file.get('sha256')})
if records:
packages[package] = records
if reader.buffer.strip() or f.read().strip():
raise ValueError('trailing data in F-Droid index')
if not found:
raise ValueError('invalid F-Droid index')
return packages
def load_index(repo, cached_only=False):
"""Compact installable records by package, cached on disk and by mtime in memory."""
if repo not in APK_HOSTS:
raise ValueError('unexpected index URL')
directory = CACHE if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG else os.path.join(CATALOG, 'data')
filename = INDEX_FILES[repo]
raw = os.path.join(directory, filename)
path = raw + '.installable-v1'
with _index_lock:
mtime = os.stat(path).st_mtime_ns if os.path.exists(path) else None
# A newer raw index (the catalogue script refreshed it) outdates the reduced copy.
newer_raw = mtime is not None and os.path.exists(raw) and os.stat(raw).st_mtime_ns > mtime
if mtime is not None and (cached_only or (time.time() - mtime / 1e9 < 86400 and not newer_raw)):
cached = _indexes.get(path)
if cached is None or cached[0] != mtime:
with open(path) as f:
cached = (mtime, json.load(f))
_indexes[path] = cached
return cached[1]
if cached_only:
return {}
os.makedirs(directory, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=filename, suffix='.part', dir=directory)
os.close(fd)
try:
if os.path.exists(raw) and time.time() - os.path.getmtime(raw) < 86400:
index = _reduce_index(raw)
refreshed = os.stat(raw).st_mtime_ns
else:
with open(tmp, 'wb') as f, urllib.request.urlopen(repo + 'index-v2.json', timeout=30) as r:
shutil.copyfileobj(r, f, 1 << 20)
index = _reduce_index(tmp)
refreshed = time.time_ns()
with open(tmp, 'w') as f:
json.dump(index, f, separators=(',', ':'))
os.utime(tmp, ns=(refreshed, refreshed))
os.replace(tmp, path)
_indexes[path] = (os.stat(path).st_mtime_ns, index)
return index
finally:
if os.path.exists(tmp):
os.remove(tmp)
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