mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
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>
295 lines
12 KiB
Python
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
|