diff --git a/.gitignore b/.gitignore index ad5b005..eebdda6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ apk-catalog/data/cache/ apk-catalog/data/index-v2.json* compat-db/.env.lakebed.server compat-db/.lakebed/ +apk-catalog/data/index-v2.archive.json* diff --git a/docs/apks.md b/docs/apks.md index 050e58f..4a7428d 100644 --- a/docs/apks.md +++ b/docs/apks.md @@ -46,6 +46,28 @@ Lepton Development must be installed once. Over SSH, `ssh frame 'steam steam://install/3056000'` queues it, but the install still needs to be confirmed or started in the headset. +## When an app needs a newer Android + +Lepton is Android 11 (API 30), with arm64-v8a only. If Frame Control refuses +an APK, it shows compatible versions from F-Droid's main and archive repos. +Choose **Install** to download a listed version, verify its SHA-256 against +the index, and install it as its own app. + +You can also inspect a file or look up a package from the command line: + +```sh +python3 ui/frame_android.py info some-app.apk +python3 ui/frame_android.py versions some-app.apk +python3 ui/frame_android.py versions org.example.app +``` + +The search links open APKMirror, APKPure, Uptodown, F-Droid and GitHub. Pick a +version whose minimum is Android 11 or lower and that has an arm64-v8a build +(or no native code). Frame Control does not fetch APKs from those search sites. +Older versions may lack fixes, and being installable does not guarantee an +app will run: see the missing services below. Android may refuse a downgrade +or an update signed by a different publisher; removing the app deletes its data. + ## Installed apps disappear when Lepton Development closes (verified 2026-09-25) Lepton Development runs in a throwaway "dev" context. When it exits for any diff --git a/tests/test_frame_apk.py b/tests/test_frame_apk.py index 40119d4..1af14f9 100644 --- a/tests/test_frame_apk.py +++ b/tests/test_frame_apk.py @@ -35,7 +35,7 @@ def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign foreign_label adds a non-android `label` attribute after android:label. """ strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk', - 'application', package, 'junk', 'label'] # the second 'label' has no android id + 'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c) resmap = struct.pack('= 2}), diff --git a/ui/frame_apk_versions.py b/ui/frame_apk_versions.py new file mode 100644 index 0000000..8a8559f --- /dev/null +++ b/ui/frame_apk_versions.py @@ -0,0 +1,86 @@ +"""Explain APK requirements and find installable versions in F-Droid's indexes.""" +from urllib.parse import quote, urlencode + +import frame_android +import frame_catalog +from pick import installable + +ANDROID = dict(enumerate([ + '1.0', '1.1', '1.5', '1.6', '2.0', '2.0.1', '2.1', '2.2', '2.3', '2.3.3', + '3.0', '3.1', '3.2', '4.0', '4.0.3', '4.1', '4.2', '4.3', '4.4', '4.4W', + '5.0', '5.1', '6.0', '7.0', '7.1', '8.0', '8.1', '9', '10', '11', '12', + '12L', '13', '14', '15', '16', +], 1)) +REPOS = (('F-Droid', 'https://f-droid.org/repo/'), + ('F-Droid archive', 'https://f-droid.org/archive/')) +NOTE = ('Pick a version whose minimum is Android 11 or lower and that has an ' + 'arm64-v8a build (or no native code). Installable does not mean every feature works.') + + +def android_name(sdk): + return 'Android ' + ANDROID[sdk] if sdk in ANDROID else f'Android API {sdk}' + + +def describe(info): + sdk = info.get('min_sdk') + minimum = f'{android_name(sdk)} (API {sdk})' if sdk else 'not specified' + try: + frame_android.check_installable(info) + verdict = 'Lepton can install this APK. Features may still need services Lepton lacks.' + except frame_android.FrameError as e: + verdict = f'Lepton cannot install this APK: {e}' + return (f"{info['package']} · {info.get('version') or '?'} " + f"(code {info.get('version_code') if info.get('version_code') is not None else '?'})\n" + f"Minimum: {minimum}\nABIs: {', '.join(info['abis']) or 'no native code'}\n{verdict}") + + +def search_links(package): + q = quote(package, safe='') + return [{'source': name, 'url': url} for name, url in ( + ('APKMirror', 'https://www.apkmirror.com/?' + urlencode({'post_type': 'app_release', 's': package})), + ('APKPure', 'https://apkpure.com/search?q=' + q), + ('Uptodown', 'https://en.uptodown.com/android/search/' + q), + ('F-Droid', 'https://search.f-droid.org/?q=' + q), + ('GitHub', 'https://github.com/search?type=repositories&q=' + q), + )] + + +def alternatives(package, current_version_code=None): + """All compatible builds, ordered by version code; keep per-ABI builds distinct.""" + versions, errors, seen = [], [], set() + for source, repo in REPOS: + try: + index = frame_catalog.load_index(repo) + except (OSError, ValueError) as e: + errors.append(f'Could not check {source}: {e}') + continue + for v in index.get('packages', {}).get(package, {}).get('versions', {}).values(): + m, file = v['manifest'], v['file'] + code = m.get('versionCode', 0) + if not installable(v): + continue + url = repo + file['name'].lstrip('/') + key = (code, file.get('sha256') or url) + if code == current_version_code or key in seen: + continue + seen.add(key) + versions.append({'version': m.get('versionName', ''), 'version_code': code, + 'min_sdk': m.get('usesSdk', {}).get('minSdkVersion', 1), + 'abis': m.get('nativecode') or [], 'url': url, + 'source': source, 'sha256': file.get('sha256')}) + versions.sort(key=lambda v: v['version_code'], reverse=True) + return {'package': package, 'versions': versions, 'links': search_links(package), + 'note': NOTE, 'errors': errors} + + +def install(package, url): + # Resolve the selection again: the client cannot supply a trusted hash or arbitrary URL. + result = alternatives(package) + version = next((v for v in result['versions'] if v['url'] == url), None) + if not version: + raise frame_android.FrameError('That version is no longer available; check the APK again') + apk = frame_catalog.fetch_apk({'a': version['url'], 'h': version['sha256'], 'n': package}) + info = frame_android.apk_info(apk) + if info['package'] != package or info.get('version_code') != version['version_code']: + raise frame_android.FrameError('The downloaded APK does not match the selected version') + return frame_android.install(apk, source=version['source']) diff --git a/ui/frame_catalog.py b/ui/frame_catalog.py index 28c0161..3a3db39 100644 --- a/ui/frame_catalog.py +++ b/ui/frame_catalog.py @@ -2,7 +2,7 @@ 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 +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') @@ -23,6 +23,36 @@ _cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None} _env = {} +_index_lock = threading.Lock() + + +def load_index(repo): + """Share the catalogue's index files; installed apps use its writable cache.""" + 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-v2.json' if repo == APK_HOSTS[0] else 'index-v2.archive.json' + path = os.path.join(directory, filename) + with _index_lock: + if os.path.exists(path) and time.time() - os.path.getmtime(path) < 86400: + with open(path) as f: + return json.load(f) + os.makedirs(directory, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=filename, suffix='.part', dir=directory) + try: + with os.fdopen(fd, 'wb') as f, urllib.request.urlopen(repo + 'index-v2.json', timeout=30) as r: + shutil.copyfileobj(r, f, 1 << 20) + with open(tmp) as f: + index = json.load(f) + if not isinstance(index.get('packages'), dict): + raise ValueError('invalid F-Droid index') + os.replace(tmp, path) + 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') diff --git a/ui/index.html b/ui/index.html index 475ea72..4f8e733 100644 --- a/ui/index.html +++ b/ui/index.html @@ -552,6 +552,16 @@ Ready Show ▴ + +

Try another APK version

+

+
+

+ +

+
+
+

Report an APK

@@ -1144,12 +1154,35 @@ function upload(file, mode) { xhr.onload = () => { $("prog").style.display = "none"; let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; } + if (data.alternatives) showApkAlternatives(data.error, data.alternatives); xhr.status < 300 ? resolve(data) : reject(new Error(data.error)); }; xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); }; xhr.send(file); }); } +function showApkAlternatives(reason, result) { + $("apkAltReason").textContent = reason; + $("apkAltNote").textContent = result.note; + $("apkAltErrors").textContent = result.errors.join(" · "); + $("apkAltLinks").innerHTML = result.links.map(l => `${esc(l.source)}`).join(" · "); + $("apkAltVersions").innerHTML = result.versions.length ? result.versions.map((v, i) => + `
${esc(v.version || "?")} code ${esc(v.version_code)}
+
${esc(v.source)} · minimum API ${esc(v.min_sdk)} · ${esc(v.abis.join(", ") || "no native code")}
+
`).join("") : + `

No compatible version found in F-Droid. Try the searches below.

`; + $("apkAltVersions").onclick = async e => { + const b = e.target.closest("[data-version]"); if (!b) return; + const v = result.versions[+b.dataset.version]; + const res = await act(`Install ${result.package} ${v.version}`, () => api("/api/android", { + action: "install", package: result.package, url: v.url + }), b); + if (res) { $("apkAltDlg").close(); loadAndroid(); } + }; + if (!$("apkAltDlg").open) $("apkAltDlg").showModal(); +} +$("apkAltClose").onclick = () => $("apkAltDlg").close(); + const TITLE_EXT = /\.(zip|exe)$/i; async function sendFiles(files, dirs = new Set()) { for (const [i, f] of files.entries()) { @@ -1707,6 +1740,7 @@ $("repFile").onchange = async () => { const { apk } = await upload(file, "apkinfo"); $("repPkg").value = apk.package; $("repVer").value = apk.version; $("repLabel").value = apk.label; if (!$("repSrc").value) $("repSrc").value = file.name; + if (apk.alternatives) showApkAlternatives(apk.blocker, apk.alternatives); $("repFileNote").textContent = apk.blocker ? `Note: ${apk.blocker}` : `${apk.package} ${apk.version}`; } catch (e) { $("repFileNote").textContent = e.message; } $("repFile").value = ""; diff --git a/ui/server.py b/ui/server.py index 05661e3..be078a3 100755 --- a/ui/server.py +++ b/ui/server.py @@ -34,6 +34,7 @@ from urllib.parse import parse_qs, unquote, urlparse sys.path.insert(0, str(Path(__file__).resolve().parent)) import frame_android # noqa: E402 +import frame_apk_versions # noqa: E402 import frame_catalog # noqa: E402 import frame_host # noqa: E402 import frame_store # noqa: E402 @@ -80,9 +81,10 @@ exit 1 class Failure(Exception): - def __init__(self, message, status=502): + def __init__(self, message, status=502, alternatives=None): super().__init__(message) self.status = status + self.alternatives = alternatives _master_lock = threading.Lock() @@ -449,7 +451,8 @@ def android(body): ensure_master() try: if action == "install": - m = frame_catalog.install(pkg) + m = (frame_apk_versions.install(pkg, body["url"]) if body.get("url") + else frame_catalog.install(pkg)) return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m} if action in ("launch", "stop"): m = getattr(frame_android, action)(pkg) @@ -1204,7 +1207,7 @@ class Handler(BaseHTTPRequestHandler): else: self.send_json({"error": "not found"}, 404) except Failure as e: - self.send_json({"error": str(e)}, e.status) + self.send_json({"error": str(e), **({"alternatives": e.alternatives} if e.alternatives is not None else {})}, e.status) except frame_android.FrameError as e: self.send_json({"error": str(e)}, 502) except Exception as e: @@ -1230,7 +1233,7 @@ class Handler(BaseHTTPRequestHandler): raise Failure("request body must be a JSON object", 400) self.send_json(handler(body)) except Failure as e: - self.send_json({"error": str(e)}, e.status) + self.send_json({"error": str(e), **({"alternatives": e.alternatives} if e.alternatives is not None else {})}, e.status) except (ValueError, TypeError) as e: self.send_json({"error": f"bad request: {e}"}, 400) except frame_android.FrameError as e: @@ -1330,11 +1333,20 @@ class Handler(BaseHTTPRequestHandler): info["blocker"] = None except frame_android.FrameError as e: info["blocker"] = str(e) + info["alternatives"] = frame_apk_versions.alternatives(info["package"], info.get("version_code")) return {"message": f"Read {info['label']} {info['version']}", "apk": info} if mode == "title": keep = True # stage_title owns tmp now, and removes it on failure return stage_title(str(dest), temp_dir=str(tmp)) if mode == "apk": + try: + info = frame_android.apk_info(str(dest)) + except frame_android.FrameError as e: + raise Failure(str(e), 400) + try: + frame_android.check_installable(info) + except frame_android.FrameError as e: + raise Failure(str(e), 400, frame_apk_versions.alternatives(info["package"], info.get("version_code"))) ensure_master() try: m = frame_android.install(str(dest), source=name)