Merge pull request #11 from saphid/apk-alternatives

Offer older APK versions that fit when Lepton refuses an app
This commit is contained in:
Alex Southwell authored and GitHub committed 2026-09-28 17:38:35 +10:00
commit dcf9689f64
11 files changed
+639 -16

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
.DS_Store
__pycache__/
apk-catalog/data/cache/
apk-catalog/data/index-v2.json*
apk-catalog/data/index-v2*.json*
compat-db/.env.lakebed.server
compat-db/.lakebed/
tests/smoke/results/
+27
View File
@@ -46,6 +46,33 @@ 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 and
IzzyOnDroid. It shows at most eight version names, newest first, preferring an
arm64-only build, and says how many compatible builds it found in total.
Each index is reduced to its compatible builds once a day and cached (about
16 MB). The first lookup takes about 30 s and 100 MB of memory; later ones are
instant.
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
+4 -2
View File
@@ -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('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
@@ -48,7 +48,8 @@ def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign
none = 0xffffffff
chunks = (pool(strings) + resmap
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
(2, none, frame_apk.T_REF, version_ref)])
(2, none, frame_apk.T_REF, version_ref),
(11, none, frame_apk.T_INT_DEC, 210)])
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
@@ -108,6 +109,7 @@ class ApkInfo(unittest.TestCase):
self.assertEqual(info['package'], 'com.example.demo')
self.assertEqual(info['label'], 'App label') # the default, not French
self.assertEqual(info['version'], '2.1')
self.assertEqual(info['version_code'], 210)
self.assertEqual(info['min_sdk'], 26)
self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64'])
self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon
+260
View File
@@ -0,0 +1,260 @@
"""Offline version lookup with small index-v2 fixtures."""
import io
import json
import os
import sys
import tempfile
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_apk_versions as versions
import frame_catalog
import frame_android
def build(code, sdk=30, abis=None):
return {'manifest': {'versionName': str(code), 'versionCode': code,
'usesSdk': {'minSdkVersion': sdk}, 'nativecode': abis or []},
'file': {'name': f'/example_{code}.apk', 'sha256': str(code).zfill(64)}}
class VersionsTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
data = os.path.join(self.tmp.name, 'data')
os.mkdir(data)
for name, builds in [('index-v2.json', [build(5, 33), build(4, abis=['x86_64']),
build(3, abis=['arm64-v8a', 'x86_64']), build(2)]),
('index-v2.archive.json', [build(1, 21), build(2)]),
('index-v2.izzy.json', [])]:
with open(os.path.join(data, name), 'w') as f:
json.dump({'packages': {'org.example.app': {'versions': {str(i): b for i, b in enumerate(builds)}}}}, f)
self.enter_patch(patch.object(frame_catalog, 'CATALOG', self.tmp.name))
self.enter_patch(patch.dict(os.environ, {'FRAME_CONTROL_APP': ''}))
self.network = self.enter_patch(patch.object(frame_catalog.urllib.request, 'urlopen', side_effect=AssertionError('network used')))
def enter_patch(self, p):
result = p.start()
self.addCleanup(p.stop)
return result
def test_filter_order_archive_and_dedup(self):
result = versions.alternatives('org.example.app')
self.assertEqual([v['version_code'] for v in result['versions']], [3, 2, 1])
self.assertEqual(result['versions'][-1]['source'], 'F-Droid archive')
self.assertEqual(result['versions'][-1]['url'], 'https://f-droid.org/archive/example_1.apk')
self.assertEqual(result['errors'], [])
self.network.assert_not_called()
def test_current_version(self):
result = versions.alternatives('org.example.app', 3)
self.assertEqual([v['version_code'] for v in result['versions']], [2, 1])
def test_fallback(self):
result = versions.alternatives('com.missing.app')
self.assertEqual(result['versions'], [])
self.assertEqual([v['source'] for v in result['links']], ['APKMirror', 'APKPure', 'Uptodown', 'F-Droid', 'GitHub'])
self.assertTrue(all('com.missing.app' in v['url'] for v in result['links']))
self.assertIn('Android 11', result['note'])
self.assertIn('arm64-v8a', result['note'])
def test_failed_indexes_keep_search_links(self):
with patch.object(frame_catalog, 'load_index', side_effect=OSError('offline')):
result = versions.alternatives('org.example.app')
self.assertEqual(len(result['errors']), 3)
self.assertEqual(len(result['links']), 5)
def test_no_compatible_versions(self):
with patch.object(frame_catalog, 'load_index', return_value={}):
result = versions.alternatives('org.example.app')
self.assertEqual(result['versions'], [])
self.assertEqual(len(result['links']), 5)
def test_reduction_memory_cache_and_refresh(self):
repo = versions.REPOS[0][1]
index = frame_catalog.load_index(repo)
self.assertEqual([v['version_code'] for v in index['org.example.app']], [3, 2])
self.assertEqual(set(index['org.example.app'][0]),
{'version', 'version_code', 'min_sdk', 'abis', 'name', 'sha256'})
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
self.assertTrue(os.path.exists(raw)) # the catalogue build reads it
os.utime(raw, ns=(1, 1))
with patch.object(frame_catalog.json, 'load', side_effect=AssertionError('reparsed')):
self.assertIs(frame_catalog.load_index(repo), index)
path = raw + '.installable-v1'
with open(path, 'w') as f:
json.dump({}, f)
os.utime(path, ns=(1, 1))
self.assertEqual(frame_catalog.load_index(repo, cached_only=True), {})
payload = json.dumps({'packages': {'org.example.app': {'versions': {'x': build(9)}}}}).encode()
with patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(payload)) as fetch:
self.assertEqual(frame_catalog.load_index(repo)['org.example.app'][0]['version_code'], 9)
fetch.assert_called_once()
self.assertTrue(os.path.exists(raw))
def test_malformed_entries_are_skipped(self):
raw = os.path.join(self.tmp.name, 'odd.json')
with open(raw, 'w') as f:
json.dump({'packages': {'a.b': {'versions': {'x': {'manifest': {}}, 'y': None, 'z': build(4)}},
'c.d': {'versions': None}, 'e.f': []}}, f)
self.assertEqual([v['version_code'] for v in frame_catalog._reduce_index(raw)['a.b']], [4])
def test_one_failing_repo_keeps_the_others(self):
real = frame_catalog.load_index
def load(repo, cached_only=False):
if 'izzy' in repo:
raise KeyError('file')
return real(repo, cached_only=cached_only)
with patch.object(frame_catalog, 'load_index', side_effect=load):
result = versions.alternatives('org.example.app')
self.assertEqual([v['version_code'] for v in result['versions']], [3, 2, 1])
self.assertEqual(len(result['errors']), 1)
def test_newer_raw_index_outdates_reduced_copy(self):
repo = versions.REPOS[0][1]
frame_catalog.load_index(repo)
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
with open(raw, 'w') as f:
json.dump({'packages': {'org.example.app': {'versions': {'x': build(8)}}}}, f)
os.utime(raw, ns=(time.time_ns() + 10**9,) * 2)
self.assertEqual(frame_catalog.load_index(repo)['org.example.app'][0]['version_code'], 8)
def test_concurrent_requests_share_download(self):
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
os.remove(raw)
payload = json.dumps({'packages': {'org.example.app': {'versions': {'x': build(7)}}}}).encode()
with patch.object(frame_catalog.urllib.request, 'urlopen', side_effect=lambda *a, **k: io.BytesIO(payload)) as fetch:
with ThreadPoolExecutor(max_workers=4) as pool:
indexes = list(pool.map(frame_catalog.load_index, [versions.REPOS[0][1]] * 4))
fetch.assert_called_once()
self.assertTrue(all(index is indexes[0] for index in indexes))
def test_failed_refresh_preserves_cache(self):
repo = versions.REPOS[0][1]
index = frame_catalog.load_index(repo)
path = os.path.join(self.tmp.name, 'data', 'index-v2.json.installable-v1')
os.utime(path, ns=(1, 1))
os.utime(os.path.join(self.tmp.name, 'data', 'index-v2.json'), ns=(1, 1))
with patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(b'{')):
with self.assertRaises(ValueError):
frame_catalog.load_index(repo)
self.assertEqual(frame_catalog.load_index(repo, cached_only=True), index)
self.assertFalse(any(name.endswith('.part') for name in os.listdir(os.path.dirname(path))))
def test_stream_boundaries_and_invalid_index(self):
raw = os.path.join(self.tmp.name, 'stream.json')
with open(raw, 'w') as f:
json.dump({'repo': {'description': 'é' * 70000}, 'packages': {
'org.example.app': {'metadata': {'text': 'escaped " packages { }' * 6000},
'versions': {'x': build(7)}}}, 'tail': {}}, f)
self.assertEqual(frame_catalog._reduce_index(raw)['org.example.app'][0]['version_code'], 7)
for invalid in ('{}', '{"packages": []}', '{"packages": {', '{"packages": {}} trailing'):
with open(raw, 'w') as f:
f.write(invalid)
with self.assertRaises(ValueError):
frame_catalog._reduce_index(raw)
def test_cap_and_preferred_build(self):
records = [dict(version=str(i), version_code=i, min_sdk=21, abis=[],
name='/app_%s.apk' % i, sha256=str(i)) for i in range(20)]
records += [dict(records[-1], version_code=21, abis=['arm64-v8a'], name='/arm.apk'),
dict(records[-1], version_code=22, abis=['arm64-v8a', 'x86_64'], name='/all.apk')]
with patch.object(frame_catalog, 'load_index', return_value={'org.example.app': records}):
result = versions.alternatives('org.example.app')
self.assertEqual(result['total'], 22)
self.assertEqual(len(result['versions']), 8)
self.assertEqual(len({v['version'] for v in result['versions']}), 8)
self.assertEqual([v['version_code'] for v in result['versions']], [21, 18, 17, 16, 15, 14, 13, 12])
def test_android_names_and_verdict(self):
for sdk, name in [(23, 'Android 6.0'), (30, 'Android 11'), (32, 'Android 12L'), (33, 'Android 13'), (99, 'Android API 99')]:
self.assertEqual(versions.android_name(sdk), name)
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5.0',
'version_code': 50, 'min_sdk': 33, 'abis': ['arm64-v8a']}
description = versions.describe(info)
for text in ['org.example.app', '5.0', 'code 50', 'Android 13', 'arm64-v8a', 'cannot install']:
self.assertIn(text, description)
info.update(min_sdk=30, abis=[])
self.assertIn('can install', versions.describe(info))
info['abis'] = ['armeabi-v7a']
self.assertIn('no arm64-v8a build', versions.describe(info))
def test_install_resolves_index_hash(self):
versions.alternatives('org.example.app')
with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \
patch.object(frame_catalog, 'fetch_apk', return_value='/tmp/example.apk') as fetch, \
patch.object(frame_android, 'apk_info', return_value={'package': 'org.example.app', 'version_code': 1}), \
patch.object(frame_android, 'install', return_value={'label': 'Example'}) as install:
versions.install('org.example.app', 'https://f-droid.org/archive/example_1.apk')
self.assertEqual(fetch.call_args[0][0]['h'], str(1).zfill(64))
install.assert_called_once_with('/tmp/example.apk', source='F-Droid archive')
with self.assertRaises(frame_android.FrameError):
versions.install('org.example.app', 'https://evil.example/app.apk')
def test_install_checks_identity_without_network_refresh(self):
versions.alternatives('org.example.app')
for name in ('index-v2.json', 'index-v2.archive.json'):
os.utime(os.path.join(self.tmp.name, 'data', name + '.installable-v1'), ns=(1, 1))
for info in ({'package': 'wrong.package', 'version_code': 1},
{'package': 'org.example.app', 'version_code': 99}):
with patch.object(frame_catalog, 'fetch_apk', return_value='/tmp/example.apk'), \
patch.object(frame_android, 'apk_info', return_value=info), \
patch.object(frame_android, 'install') as install:
with self.assertRaises(frame_android.FrameError):
versions.install('org.example.app', 'https://f-droid.org/archive/example_1.apk')
install.assert_not_called()
self.network.assert_not_called()
class UploadVersionsTest(unittest.TestCase):
def test_endpoint_validation(self):
import server
for query in ('', 'package=', 'package=foo', 'package=a..b', 'package=a.1b',
'package=a.b/path', 'package=a.b&package=c.d', 'package=a.b&code=-1',
'package=a.b&code=x', 'package=a.b&code=', 'package=a.b&code=1&code=2'):
handler = object.__new__(server.Handler)
handler.path = '/api/apk-versions?' + query
with patch.object(handler, 'local_request', return_value=True), \
patch.object(handler, 'send_json') as reply, \
patch.object(versions, 'alternatives') as lookup:
handler.do_GET()
self.assertEqual(reply.call_args[0][1], 400, query)
lookup.assert_not_called()
handler.path = '/api/apk-versions?package=org.example_app.demo&code=123'
with patch.object(handler, 'local_request', return_value=True), \
patch.object(handler, 'send_json') as reply, \
patch.object(versions, 'alternatives', return_value={'total': 0}) as lookup:
handler.do_GET()
lookup.assert_called_once_with('org.example_app.demo', 123)
reply.assert_called_once_with({'total': 0})
def test_blocked_uploads_do_not_lookup_before_reply(self):
import server
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5',
'version_code': 5, 'min_sdk': 33, 'abis': [], 'icon_png': None}
for mode in ('apkinfo', 'apk'):
handler = object.__new__(server.Handler)
handler.headers = {'X-Filename': 'app.apk', 'X-Mode': mode, 'Content-Length': '1'}
handler.rfile = io.BytesIO(b'x')
with patch.object(frame_android, 'apk_info', return_value=dict(info)), \
patch.object(versions, 'alternatives', side_effect=AssertionError('lookup during upload')) as lookup, \
patch.object(server, 'ensure_master') as ssh:
if mode == 'apkinfo':
reply = handler.upload()
self.assertNotIn('alternatives', reply['apk'])
self.assertIn('API 33', reply['apk']['blocker'])
else:
with self.assertRaises(server.Failure) as error:
handler.upload()
self.assertEqual(error.exception.status, 400)
self.assertEqual(error.exception.apk['package'], info['package'])
lookup.assert_not_called()
ssh.assert_not_called()
if __name__ == '__main__':
unittest.main()
+17
View File
@@ -212,6 +212,23 @@ class ServerGuards(unittest.TestCase):
self.assertNotEqual(status, 200, body)
self.assertNotIn("job", body)
def test_android_install_of_another_version_runs_as_a_job(self):
pkg = "org.example.frame_control.not_in_any_repo"
sys.path.insert(0, str(ROOT / "ui"))
import frame_apk_versions
# The job must fail on the cached lookup, before any download: nothing is cached for this package.
self.assertEqual(frame_apk_versions._versions(pkg, cached_only=True)[0], [])
status, started = self.post("/api/android", {"action": "install", "package": pkg,
"url": f"https://f-droid.org/repo/{pkg}_1.apk"})
self.assertEqual(status, 200, started)
for _ in range(200):
job = json.loads(self.request("GET", f"/api/job?id={started['job']}", headers={"X-Frame-UI": "1"})[2])
if job["done"]:
break
time.sleep(0.05)
self.assertTrue(job["done"])
self.assertIn("no longer available", job["error"])
def test_unknown_routes(self):
self.assertEqual(self.request("GET", "/nope")[0], 404)
self.assertEqual(self.post("/api/nope", {})[0], 404)
+10 -2
View File
@@ -6,7 +6,7 @@ apps, the lepton-show-flatscreen marker; plus a non-Steam shortcut, so it shows
in the Steam library and gets its own SteamVR panel. Nothing goes through
Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
Python stdlib only. CLI: python3 ui/frame_android.py {info APK|versions APK-or-PKG|install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
"""
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
@@ -276,7 +276,15 @@ def probe(pkg, wait=20):
def main():
cmd, *args = sys.argv[1:] or ['help']
try:
if cmd == 'install':
if cmd in ('info', 'versions'):
import frame_apk_versions
if cmd == 'info':
print(frame_apk_versions.describe(apk_info(args[0])))
return
info = apk_info(args[0]) if os.path.isfile(args[0]) or args[0].lower().endswith('.apk') else None
r = frame_apk_versions.alternatives(
info['package'] if info else args[0], info.get('version_code') if info else None)
elif cmd == 'install':
r = install(args[0], flatscreen='--vr' not in args)
elif cmd == 'list':
r = list_apps()
+1
View File
@@ -229,6 +229,7 @@ def apk_info(path):
min_sdk = sdk.get('minSdkVersion')
info = {
'package': package,
'version_code': manifest.get('versionCode', (None, None))[1],
'version': _text(manifest.get('versionName'), res) or '',
'label': _text(app.get('label'), res) or package,
'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}),
+90
View File
@@ -0,0 +1,90 @@
"""Explain APK requirements and find installable versions in F-Droid's indexes."""
from urllib.parse import quote, urlencode
import frame_android
import frame_catalog
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/'),
('IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/'))
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 _versions(package, cached_only=False):
versions, errors, seen = [], [], set()
for source, repo in REPOS:
try:
index = frame_catalog.load_index(repo, cached_only=cached_only)
except Exception as e: # one bad repo (dropped download, odd index) mustn't hide the others
errors.append(f'Could not check {source}: {e}')
continue
for v in index.get(package, []):
url = repo + v['name'].lstrip('/')
key = (v['version_code'], v.get('sha256') or url)
if key in seen:
continue
seen.add(key)
versions.append(dict(v, url=url, source=source))
return versions, errors
def alternatives(package, current_version_code=None):
"""At most eight releases, preferring arm64-only builds over universal builds."""
versions, errors = _versions(package)
versions = [v for v in versions if v['version_code'] != current_version_code]
total = len(versions)
versions.sort(key=lambda v: (v['abis'] == ['arm64-v8a'], v['version_code']), reverse=True)
releases = {}
for v in versions:
releases.setdefault(v['version'], v)
versions = sorted(releases.values(), key=lambda v: v['version_code'], reverse=True)[:8]
return {'package': package, 'versions': versions, 'total': total,
'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.
records, _ = _versions(package, cached_only=True)
version = next((v for v in records 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'])
+139 -2
View File
@@ -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')
@@ -17,12 +17,149 @@ import frame_compat_db as compat_db # noqa: E402
# 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'))
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
# 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')
+58 -3
View File
@@ -254,10 +254,10 @@
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
.and-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; }
#repDlg, #titleDlg, #wiDlg, #pwDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
#repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
@@ -656,6 +656,16 @@
<span class="last" id="lastLog">Ready</span>
<span class="sub" id="drawerHint">Show ▴</span>
</div>
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
<h2 id="apkAltTitle">Try another APK version</h2>
<p id="apkAltReason"></p>
<div class="list" id="apkAltVersions"></div>
<p class="sub" id="apkAltNote"></p>
<div id="apkAltLinks"></div>
<p class="sub" id="apkAltErrors"></p>
<div class="actions"><button id="apkAltClose">Close</button></div>
</dialog>
<dialog id="repDlg" aria-labelledby="repTitle">
<form method="dialog" id="repForm">
<h2 id="repTitle">Report an APK</h2>
@@ -1410,12 +1420,56 @@ 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.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk);
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
};
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
xhr.send(file);
});
}
let apkLookup = 0;
async function checkApkAlternatives(apk) {
const lookup = ++apkLookup;
$("apkAltReason").textContent = apk.blocker;
$("apkAltVersions").textContent = "Checking F-Droid for older versions…";
$("apkAltVersions").onclick = null;
for (const id of ["apkAltNote", "apkAltErrors", "apkAltLinks"]) $(id).textContent = "";
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
const query = new URLSearchParams({package: apk.package});
if (apk.version_code != null) query.set("code", apk.version_code);
try {
const result = await api(`/api/apk-versions?${query}`);
if (lookup === apkLookup && $("apkAltDlg").open) showApkAlternatives(apk.blocker, result);
} catch (e) {
if (lookup === apkLookup) $("apkAltVersions").textContent = e.message;
}
}
function showApkAlternatives(reason, result) {
$("apkAltReason").textContent = reason;
$("apkAltNote").textContent = `${result.versions.length} of ${result.total} compatible versions. ${result.note}`;
$("apkAltErrors").textContent = result.errors.join(" · ");
$("apkAltLinks").innerHTML = result.links.map(l => `<a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.source)}</a>`).join(" · ");
$("apkAltVersions").innerHTML = result.versions.length ? result.versions.map((v, i) =>
`<div class="item"><div class="grow"><div class="t">${esc(v.version || "?")} <span class="sub">code ${esc(v.version_code)}</span></div>
<div class="s">${esc(v.source)} · minimum API ${esc(v.min_sdk)} · ${esc(v.abis.join(", ") || "no native code")}</div></div>
<button class="small" data-version="${i}" ${v.sha256 ? "" : "disabled"}>Install</button></div>`).join("") :
`<p>No compatible version found in F-Droid. Try the searches below.</p>`;
$("apkAltVersions").onclick = async e => {
const b = e.target.closest("[data-version]"); if (!b) return;
const v = result.versions[+b.dataset.version];
if (installing.has(result.package)) return;
b.disabled = true; b.textContent = "Installing…";
const res = await runJob(`Install ${result.package} ${v.version}`, result.package, () => api("/api/android", {
action: "install", package: result.package, url: v.url
}));
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
await loadAndroid();
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
};
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()) {
@@ -1996,6 +2050,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.blocker) checkApkAlternatives(apk);
$("repFileNote").textContent = apk.blocker ? `Note: ${apk.blocker}` : `${apk.package} ${apk.version}`;
} catch (e) { $("repFileNote").textContent = e.message; }
$("repFile").value = "";
+32 -6
View File
@@ -37,6 +37,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
@@ -91,9 +92,10 @@ exit 1
class Failure(Exception):
def __init__(self, message, status=502):
def __init__(self, message, status=502, apk=None):
super().__init__(message)
self.status = status
self.apk = apk
# What ssh prints when it never reached the Frame, and what to tell the user
@@ -561,16 +563,28 @@ def open_thing(body):
raise Failure("unknown target", 400)
def apk_versions(query):
args = parse_qs(query, keep_blank_values=True)
packages, codes = args.get('package', []), args.get('code', [])
if len(packages) != 1 or not frame_android.PKG_RE.match(packages[0]):
raise Failure('invalid Android package id', 400)
if codes and (len(codes) != 1 or not re.fullmatch(r'[0-9]{1,19}', codes[0])):
raise Failure('invalid version code', 400)
return frame_apk_versions.alternatives(packages[0], int(codes[0]) if codes else None)
def android(body):
"""Android apps, each in its own persistent Lepton instance (frame_android.py)."""
action, pkg = body.get("action"), str(body.get("package", ""))
ensure_master()
try:
if action == "install":
frame_catalog.app(pkg) # an unknown package fails now, not in the background
url = body.get("url")
if not url:
frame_catalog.app(pkg) # an unknown package fails now, not in the background
def work():
m = frame_catalog.install(pkg)
m = frame_apk_versions.install(pkg, url) if 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}
return start_job(f"Install {pkg}", work)
@@ -1303,8 +1317,10 @@ class Handler(BaseHTTPRequestHandler):
def send_json(self, obj, status=200):
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
def send_error_json(self, message, status):
def send_error_json(self, message, status, apk=None):
body, offline_status = error_body(message)
if apk is not None:
body["apk"] = apk
self.send_json(body, offline_status or status)
def do_GET(self):
@@ -1319,6 +1335,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
"computer": "Mac" if frame_host.MAC else "PC"})
elif path == "/api/apk-versions":
self.send_json(apk_versions(url.query))
elif path == "/api/android":
ensure_master()
self.send_json({"apps": frame_android.list_apps()})
@@ -1358,7 +1376,7 @@ class Handler(BaseHTTPRequestHandler):
else:
self.send_json({"error": "not found"}, 404)
except Failure as e:
self.send_error_json(str(e), e.status)
self.send_error_json(str(e), e.status, e.apk)
except frame_android.FrameError as e:
self.send_error_json(str(e), 502)
except Exception as e:
@@ -1384,7 +1402,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_error_json(str(e), e.status)
self.send_error_json(str(e), e.status, e.apk)
except (ValueError, TypeError) as e:
self.send_json({"error": f"bad request: {e}"}, 400)
except frame_android.FrameError as e:
@@ -1489,6 +1507,14 @@ class Handler(BaseHTTPRequestHandler):
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, {"package": info["package"], "version_code": info.get("version_code"), "blocker": str(e)})
ensure_master()
try:
m = frame_android.install(str(dest), source=name)