Find installable APK alternatives in F-Droid main and archive

This commit is contained in:
saphid committed 2026-09-28 14:16:43 +10:00
1 parent 9110557e23
commit fbe7ba9575
10 files changed
+331 -9

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ apk-catalog/data/cache/
apk-catalog/data/index-v2.json* apk-catalog/data/index-v2.json*
compat-db/.env.lakebed.server compat-db/.env.lakebed.server
compat-db/.lakebed/ compat-db/.lakebed/
apk-catalog/data/index-v2.archive.json*
+22
View File
@@ -46,6 +46,28 @@ Lepton Development must be installed once. Over SSH,
`ssh frame 'steam steam://install/3056000'` queues it, but the install still `ssh frame 'steam steam://install/3056000'` queues it, but the install still
needs to be confirmed or started in the headset. 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) ## Installed apps disappear when Lepton Development closes (verified 2026-09-25)
Lepton Development runs in a throwaway "dev" context. When it exits for any 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. foreign_label adds a non-android `label` attribute after android:label.
""" """
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk', 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('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap 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 none = 0xffffffff
chunks = (pool(strings) + resmap chunks = (pool(strings) + resmap
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8), + 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(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)] + 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 []))) + ([(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['package'], 'com.example.demo')
self.assertEqual(info['label'], 'App label') # the default, not French self.assertEqual(info['label'], 'App label') # the default, not French
self.assertEqual(info['version'], '2.1') self.assertEqual(info['version'], '2.1')
self.assertEqual(info['version_code'], 210)
self.assertEqual(info['min_sdk'], 26) self.assertEqual(info['min_sdk'], 26)
self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64']) self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64'])
self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon
+126
View File
@@ -0,0 +1,126 @@
"""Offline version lookup with small index-v2 fixtures."""
import io
import json
import os
import sys
import tempfile
import unittest
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)])]:
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']), 2)
self.assertEqual(len(result['links']), 5)
def test_no_compatible_versions(self):
with patch.object(frame_catalog, 'load_index', return_value={
'packages': {'org.example.app': {'versions': {'x': build(10, 33)}}}}):
result = versions.alternatives('org.example.app')
self.assertEqual(result['versions'], [])
self.assertEqual(len(result['links']), 5)
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):
with 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')
class UploadVersionsTest(unittest.TestCase):
def test_blocked_uploads_return_alternatives_before_ssh(self):
import server
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5',
'version_code': 5, 'min_sdk': 33, 'abis': [], 'icon_png': None}
result = {'package': info['package'], 'versions': [], 'links': versions.search_links(info['package'])}
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', return_value=result) as lookup, \
patch.object(server, 'ensure_master') as ssh:
if mode == 'apkinfo':
reply = handler.upload()
self.assertEqual(reply['apk']['alternatives'], result)
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.alternatives, result)
lookup.assert_called_once_with('org.example.app', 5)
ssh.assert_not_called()
if __name__ == '__main__':
unittest.main()
+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 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. 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 import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
@@ -276,7 +276,15 @@ def probe(pkg, wait=20):
def main(): def main():
cmd, *args = sys.argv[1:] or ['help'] cmd, *args = sys.argv[1:] or ['help']
try: 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) r = install(args[0], flatscreen='--vr' not in args)
elif cmd == 'list': elif cmd == 'list':
r = list_apps() r = list_apps()
+1
View File
@@ -229,6 +229,7 @@ def apk_info(path):
min_sdk = sdk.get('minSdkVersion') min_sdk = sdk.get('minSdkVersion')
info = { info = {
'package': package, 'package': package,
'version_code': manifest.get('versionCode', (None, None))[1],
'version': _text(manifest.get('versionName'), res) or '', 'version': _text(manifest.get('versionName'), res) or '',
'label': _text(app.get('label'), res) or package, '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}), 'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}),
+86
View File
@@ -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'])
+31 -1
View File
@@ -2,7 +2,7 @@
list, verified downloads, installs into per-app Lepton instances, and list, verified downloads, installs into per-app Lepton instances, and
compatibility reports. Python stdlib only. 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__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOG = os.path.join(ROOT, 'apk-catalog') CATALOG = os.path.join(ROOT, 'apk-catalog')
@@ -23,6 +23,36 @@ _cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None}
_env = {} _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(): def catalog():
"""Rated apps with the database's reports applied.""" """Rated apps with the database's reports applied."""
path = os.path.join(CATALOG, 'site', 'apps.js') path = os.path.join(CATALOG, 'site', 'apps.js')
+34
View File
@@ -552,6 +552,16 @@
<span class="last" id="lastLog">Ready</span> <span class="last" id="lastLog">Ready</span>
<span class="sub" id="drawerHint">Show ▴</span> <span class="sub" id="drawerHint">Show ▴</span>
</div> </div>
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
<h2 id="apkAltTitle">Try another APK version</h2>
<p id="apkAltReason"></p>
<div 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"> <dialog id="repDlg" aria-labelledby="repTitle">
<form method="dialog" id="repForm"> <form method="dialog" id="repForm">
<h2 id="repTitle">Report an APK</h2> <h2 id="repTitle">Report an APK</h2>
@@ -1144,12 +1154,35 @@ function upload(file, mode) {
xhr.onload = () => { xhr.onload = () => {
$("prog").style.display = "none"; $("prog").style.display = "none";
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; } 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.status < 300 ? resolve(data) : reject(new Error(data.error));
}; };
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); }; xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
xhr.send(file); 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 => `<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];
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; const TITLE_EXT = /\.(zip|exe)$/i;
async function sendFiles(files, dirs = new Set()) { async function sendFiles(files, dirs = new Set()) {
for (const [i, f] of files.entries()) { for (const [i, f] of files.entries()) {
@@ -1707,6 +1740,7 @@ $("repFile").onchange = async () => {
const { apk } = await upload(file, "apkinfo"); const { apk } = await upload(file, "apkinfo");
$("repPkg").value = apk.package; $("repVer").value = apk.version; $("repLabel").value = apk.label; $("repPkg").value = apk.package; $("repVer").value = apk.version; $("repLabel").value = apk.label;
if (!$("repSrc").value) $("repSrc").value = file.name; 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}`; $("repFileNote").textContent = apk.blocker ? `Note: ${apk.blocker}` : `${apk.package} ${apk.version}`;
} catch (e) { $("repFileNote").textContent = e.message; } } catch (e) { $("repFileNote").textContent = e.message; }
$("repFile").value = ""; $("repFile").value = "";
+16 -4
View File
@@ -34,6 +34,7 @@ from urllib.parse import parse_qs, unquote, urlparse
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_android # noqa: E402 import frame_android # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402 import frame_catalog # noqa: E402
import frame_host # noqa: E402 import frame_host # noqa: E402
import frame_store # noqa: E402 import frame_store # noqa: E402
@@ -80,9 +81,10 @@ exit 1
class Failure(Exception): class Failure(Exception):
def __init__(self, message, status=502): def __init__(self, message, status=502, alternatives=None):
super().__init__(message) super().__init__(message)
self.status = status self.status = status
self.alternatives = alternatives
_master_lock = threading.Lock() _master_lock = threading.Lock()
@@ -449,7 +451,8 @@ def android(body):
ensure_master() ensure_master()
try: try:
if action == "install": 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} 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"): if action in ("launch", "stop"):
m = getattr(frame_android, action)(pkg) m = getattr(frame_android, action)(pkg)
@@ -1204,7 +1207,7 @@ class Handler(BaseHTTPRequestHandler):
else: else:
self.send_json({"error": "not found"}, 404) self.send_json({"error": "not found"}, 404)
except Failure as e: 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: except frame_android.FrameError as e:
self.send_json({"error": str(e)}, 502) self.send_json({"error": str(e)}, 502)
except Exception as e: except Exception as e:
@@ -1230,7 +1233,7 @@ class Handler(BaseHTTPRequestHandler):
raise Failure("request body must be a JSON object", 400) raise Failure("request body must be a JSON object", 400)
self.send_json(handler(body)) self.send_json(handler(body))
except Failure as e: 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: except (ValueError, TypeError) as e:
self.send_json({"error": f"bad request: {e}"}, 400) self.send_json({"error": f"bad request: {e}"}, 400)
except frame_android.FrameError as e: except frame_android.FrameError as e:
@@ -1330,11 +1333,20 @@ class Handler(BaseHTTPRequestHandler):
info["blocker"] = None info["blocker"] = None
except frame_android.FrameError as e: except frame_android.FrameError as e:
info["blocker"] = str(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} return {"message": f"Read {info['label']} {info['version']}", "apk": info}
if mode == "title": if mode == "title":
keep = True # stage_title owns tmp now, and removes it on failure keep = True # stage_title owns tmp now, and removes it on failure
return stage_title(str(dest), temp_dir=str(tmp)) return stage_title(str(dest), temp_dir=str(tmp))
if mode == "apk": 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() ensure_master()
try: try:
m = frame_android.install(str(dest), source=name) m = frame_android.install(str(dest), source=name)