Add unified APK source search and source management

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
saphidandGPT-6 Astra committed 2026-09-28 21:04:48 +10:00
1 parent 268afccf05
commit f1b12a6eb6
8 files changed
+676 -7

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
"""Opt-in local UI fixtures: FRAME_APK_SEARCH_DEMO=1. Never downloads."""
from apk_sources import SourceError
KIND = 'demo'
def sources():
return [{'id': 'demo-' + key, 'kind': KIND, 'name': name + ' (demo)', 'enabled': True,
'builtin': True, 'trust': trust, 'url': 'https://example.invalid'}
for key, name, trust in [('fdroid', 'F-Droid', 'community'),
('sidequest', 'SideQuest', 'official'), ('itch', 'itch.io', 'community')]]
def search(source, query, limit=50):
if source['id'] == 'demo-itch':
raise SourceError('Demo: source temporarily unavailable')
entries = [{'id': 'brush', 'package': 'org.demo.brush', 'name': 'Open Brush',
'summary': 'Paint in VR — demonstration listing only', 'version': '2.32',
'version_code': 232, 'min_sdk': 29, 'abis': ['arm64-v8a'], 'vr': True,
'engine': 'Unity OpenXR', 'size': 125000000, 'updated': '2026-09-25',
'verified': source['id'] == 'demo-fdroid', 'free': True, 'downloadable': True},
{'id': 'radio', 'package': 'org.demo.radio', 'name': 'Pocket Radio',
'summary': 'Listen in a flat Android panel', 'version': '1.0', 'version_code': 1,
'min_sdk': 24, 'abis': [], 'vr': False, 'size': 3000000, 'free': True,
'downloadable': False, 'page': 'https://example.invalid/radio'}]
return [e for e in entries if query.lower() in e['name'].lower()][:limit]
def details(source, entry_id):
return next(e for e in search(source, '') if e['id'] == entry_id)
def download(source, entry_id, version_code=None):
raise SourceError('Demo sources cannot download or install apps')
+226
View File
@@ -0,0 +1,226 @@
"""Parallel APK search and source selection. No device access during searches."""
import importlib
import inspect
import json
import os
import pkgutil
import re
import threading
import time
import unicodedata
import frame_host
from apk_sources import SourceError
_lock = threading.RLock()
_running = {}
_status = {}
TIMEOUT = 12
def modules():
import apk_sources
found, errors = [], []
for item in pkgutil.iter_modules(apk_sources.__path__):
if item.name == 'search' or item.name.startswith('_'):
continue
try:
module = importlib.import_module('apk_sources.' + item.name)
if getattr(module, 'KIND', None):
found.append(module)
except Exception as e:
errors.append({'id': item.name, 'name': item.name, 'enabled': False,
'trust': 'unknown', 'status': 'error', 'error': str(e)})
if os.environ.get('FRAME_APK_SEARCH_DEMO') == '1':
found.append(importlib.import_module('apk_sources._demo'))
return found, errors
def settings_path():
return frame_host.data_dir('apk-sources', 'enabled.json')
def overrides():
try:
return json.loads(settings_path().read_text())
except FileNotFoundError:
return {}
def registry():
result = []
mods, errors = modules()
with _lock:
enabled = overrides()
for module in mods:
try:
for source in module.sources():
source = dict(source)
source['enabled'] = enabled.get(source['id'], source.get('enabled', True))
source.update(_status.get(source['id'], {'status': 'not searched'}))
result.append((module, source))
except Exception as e:
errors.append({'id': module.KIND, 'name': module.KIND, 'enabled': False,
'trust': 'unknown', 'status': 'error', 'error': str(e)})
return result, errors
def sources():
items, errors = registry()
return [s for _, s in items] + errors
def resolve(source_id):
for module, source in registry()[0]:
if source['id'] == source_id:
return module, source
raise SourceError('Unknown source')
def set_enabled(source_id, enabled):
module, source = resolve(source_id)
with _lock:
if hasattr(module, 'set_enabled'):
module.set_enabled(source_id, enabled)
values = overrides()
values[source_id] = enabled
path = settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix('.tmp')
tmp.write_text(json.dumps(values))
tmp.replace(path)
return {'message': source['name'] + (' enabled' if enabled else ' disabled')}
def manage_repo(action, **kwargs):
mods, _ = modules()
module = next((m for m in mods if m.KIND == 'fdroid'), None)
if module is None or not hasattr(module, action):
raise SourceError('User repositories are not available in this build')
return getattr(module, action)(**kwargs)
def normalise(name):
return ' '.join(re.findall(r'\w+', unicodedata.normalize('NFKC', name or '').casefold()))
def fit(entry):
sdk, abis = entry.get('min_sdk'), entry.get('abis')
reasons = []
blocked = (sdk is not None and sdk > 30) or (abis is not None and bool(abis) and 'arm64-v8a' not in abis)
if sdk is not None and sdk > 30:
reasons.append('Needs Android API %s; Lepton supports 30' % sdk)
if abis and 'arm64-v8a' not in abis:
reasons.append('No arm64-v8a build')
known = sdk is not None and abis is not None
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
if 'vrapi' in hints:
reasons.append('Legacy VrApi requires a translator')
if 'godot' in hints:
reasons.append('Older Godot builds can crash on the missing clipboard service')
if 'openxr' in hints:
reasons.append('OpenXR candidate; required extensions still need checking')
if entry.get('vr'):
reasons.append('VR runtime compatibility is not guaranteed')
return {'installable': False if blocked else True if known else None,
'verdict': "Won't install" if blocked else 'Installable' if known else 'Compatibility unknown',
'reasons': reasons}
def offer_rank(entry):
compatible = entry['fit']['installable'] is True
return (entry.get('downloadable') is True and entry['fit']['installable'] is not False,
entry.get('verified') is True, compatible,
str(entry.get('updated') or '') if compatible else '',
(entry.get('version_code') or 0) if compatible else 0,
entry.get('trust') == 'official')
def group(entries, query='', vr=None, installable=False):
groups = {}
for entry in entries:
entry = dict(entry, fit=fit(entry))
if vr is not None and entry.get('vr') is not vr:
continue
if installable and entry['fit']['installable'] is not True:
continue
key = ('package', entry['package']) if entry.get('package') else ('name', normalise(entry.get('name')))
if not key[1]:
key = ('id', entry['source'], entry['id'])
groups.setdefault(key, []).append(entry)
result = []
for offers in groups.values():
offers.sort(key=offer_rank, reverse=True)
best = offers[0]
result.append({'name': best.get('name'), 'package': best.get('package'),
'summary': best.get('summary'), 'offers': offers})
q = normalise(query)
result.sort(key=lambda a: (not any(normalise(o.get('name')) == q for o in a['offers']),
not any(o['fit']['installable'] is True for o in a['offers']),
not any(normalise(o.get('name')).startswith(q) for o in a['offers']),
normalise(a['name'])))
return result
def _launch(module, source, query, limit):
key = source['id']
with _lock:
old = _running.get(key)
if old and not old['event'].is_set():
return old if old['query'] == query else None
task = {'event': threading.Event(), 'query': query, 'started': time.monotonic()}
_running[key] = task
def run():
try:
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
for e in module.search(source, query, limit=limit) if e.get('free') is True]
except Exception as e:
task['error'] = str(e)
finally:
task['event'].set()
threading.Thread(target=run, daemon=True).start()
return task
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
items, errors = registry()
if source and source not in [s['id'] for _, s in items]:
raise SourceError('Unknown source')
tasks = [(s, _launch(m, s, query, limit)) for m, s in items
if s['enabled'] and (not source or s['id'] == source)]
entries, statuses = [], list(errors)
for s, task in tasks:
status = {'id': s['id'], 'name': s['name']}
if task is None or not task['event'].wait(max(0, task['started'] + timeout - time.monotonic())):
status.update(status='timed out')
elif 'error' in task:
status.update(status='error', error=task['error'])
else:
status.update(status='ok')
entries.extend(task['entries'])
statuses.append(status)
with _lock:
_status[s['id']] = {k: v for k, v in status.items() if k not in ('id', 'name')}
return {'apps': group(entries, query, vr, installable), 'sources': statuses}
def install(source_id, entry_id, version_code=None):
import frame_android
module, source = resolve(source_id)
if not source['enabled']:
raise SourceError('This source is disabled')
entry = module.details(source, entry_id)
if entry.get('free') is not True or entry.get('downloadable') is not True:
raise SourceError('This app must be obtained from its developer page')
downloaded = module.download(source, entry_id, version_code=version_code)
obb = downloaded.get('obb') or entry.get('obb') or []
if obb and not hasattr(frame_android, 'install_obb'):
raise SourceError('This app needs OBB data; this build cannot install it yet')
kwargs = {'name': entry.get('name'), 'icon_png': downloaded.get('icon_png') or entry.get('icon_png'),
'source': source['name']}
if 'artwork' in inspect.signature(frame_android.install).parameters:
kwargs['artwork'] = downloaded.get('artwork') or entry.get('artwork')
result = frame_android.install(downloaded['apk'], **kwargs)
if obb:
frame_android.install_obb(result['package'], obb)
return result
+90 -6
View File
@@ -70,6 +70,15 @@
body.mobile .desk-only { display: none; }
.source-repo-form { display:grid; gap:10px; margin-top:14px; }
.source-repo-form input { display:block; width:100%; margin-top:5px; }
.source-offer { border-top:1px solid var(--line); padding-top:10px; margin-top:10px; }
#searchGrid .source-offer select { display:block; max-width:100%; width:100%; margin-top:5px; }
#searchFilters [aria-pressed=true], #sourceFilters [aria-pressed=true] { box-shadow:inset 0 0 0 1px var(--link); color:var(--link); }
#searchGrid h3, #searchGrid p { margin:4px 0; }
#searchGrid .row { margin-top:0; }
#searchGrid .source-offer { margin-top:auto; }
#sourceStatus { margin-bottom:10px; }
/* ---- pages: one per tab; the header nav switches between them ---- */
.page { display: flex; flex-direction: column; gap: 26px; }
.page:not(.on) { display: none; }
@@ -121,7 +130,7 @@
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
.seg button.on { background: var(--btn-hi); color: var(--bright); }
input[type=text], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
input[type=text], input[type=search], input[type=url], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 9px 11px; font: inherit; }
textarea { resize: vertical; min-height: 76px; }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
@@ -282,8 +291,8 @@
.and-btns { flex-basis: 100%; padding-left: 46px; }
.and-icon { width: 36px; height: 36px; border-radius: 8px; flex: none; background: rgba(0,0,0,.25); object-fit: cover; }
.cat-tools { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.cat-tools input[type=text] { flex: 1 1 240px; width: auto; }
.cat-tools select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
.cat-tools input[type=text], .cat-tools input[type=search] { flex: 1 1 240px; width: auto; }
.cat-tools select, .source-offer select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 9px 10px; font: inherit; font-size: 13px; max-width: 220px; }
.vchips { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 14px; }
.vchip { height: 26px; font-size: 12px; padding: 0 10px; opacity: .55; }
@@ -357,7 +366,7 @@
button { height: 38px; }
button.small { height: 32px; }
.actions button { height: 46px; }
.cat-tools select { max-width: none; flex: 1 1 100%; }
.cat-tools select, .source-offer select { max-width: none; flex: 1 1 100%; }
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
body.typing nav { display: none; }
}
@@ -575,7 +584,22 @@
</div>
</div>
<div class="panel">
<div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
<div class="shelf-head"><h2>Find apps</h2><span class="count" id="searchCount"></span></div>
<form id="sourceSearch" class="cat-tools"><input id="sourceQ" type="search" aria-label="Search every APK source" placeholder="Search every APK source…"><button class="small">Search</button></form>
<div class="vchips" id="searchFilters"><button class="chip" data-filter="vr" aria-pressed="false">VR</button><button class="chip" data-filter="flat" aria-pressed="false">Flat</button><button class="chip" data-filter="installable" aria-pressed="false">Installable</button></div>
<div class="vchips" id="sourceFilters"></div>
<div class="hint" id="sourceStatus" role="status" aria-live="polite"></div>
<div class="cat-grid" id="searchGrid"></div>
<details style="margin-top:20px" id="sourcesPanel"><summary>Sources</summary>
<div class="list" id="sourcesList"></div>
<form id="addSource" class="source-repo-form">
<label>Repository URL<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label>
<label>Fingerprint (optional)<input name="fingerprint" type="text" autocomplete="off"></label>
<button class="small">Add repository</button>
</form><div class="hint" id="sourceError" role="status"></div>
</details>
<div class="hint">Installable means Android 11 and arm64 requirements match. It does not guarantee the app runs. Unknown facts stay unknown.</div>
<div hidden> <div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
<div class="cat-tools">
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
<select id="catCat" aria-label="Category"><option value="">All categories</option></select>
@@ -584,7 +608,7 @@
<div class="cat-grid" id="catGrid"><div class="sub">Loading the catalogue…</div></div>
<div class="row" style="margin-top:12px"><button class="small" id="catMore" hidden>Show more</button></div>
<div class="hint">Verdicts come from checks of each APK against what Lepton supports (Android 11,
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div>
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div></div>
</div>
</div>
</section>
@@ -1780,6 +1804,66 @@ document.body.addEventListener("click", async e => {
});
loadOwned();
// ---- All APK sources ----
const sourceState = { vr: null, installable: false, source: "", apps: [], request: 0 };
function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } }
async function loadSources() {
try {
const { sources } = await api("/api/sources");
$("sourceFilters").innerHTML = `<button class="chip" data-source="" aria-pressed="${!sourceState.source}">All sources</button>` + sources.filter(s => s.enabled).map(s => `<button class="chip" data-source="${esc(s.id)}" aria-pressed="${sourceState.source === s.id}">${esc(s.name)}</button>`).join("");
$("sourcesList").innerHTML = sources.map(s => `<div class="item"><label class="grow"><input type="checkbox" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}> ${esc(s.name)} <span class="sub">${esc(s.trust || "unknown")} · ${esc(s.status || "not searched")}</span></label>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}">Remove</button>` : ""}</div>`).join("") || `<div class="sub">No source modules are available in this build.</div>`;
} catch (e) { $("sourceError").textContent = e.message; }
}
async function searchSources() {
const request = ++sourceState.request;
$("sourceStatus").textContent = "Searching enabled sources…";
const params = new URLSearchParams({ q: $("sourceQ").value, source: sourceState.source, installable: String(sourceState.installable) });
if (sourceState.vr !== null) params.set("vr", String(sourceState.vr));
try {
const result = await api("/api/search?" + params);
if (request !== sourceState.request) return;
sourceState.apps = result.apps;
$("searchCount").textContent = `${result.apps.length} apps`;
$("sourceStatus").textContent = result.sources.map(s => `${s.name}: ${s.status === "error" ? s.error : s.status}`).join(" · ") || "No enabled sources";
$("searchGrid").innerHTML = result.apps.map((a, i) => `<article class="cat-card"><h3>${esc(a.name || "Unnamed app")}</h3><div class="sub">${esc(a.package || "Package unknown")}</div><p>${esc(a.summary || "")}</p><div class="row">${[...new Set(a.offers.map(o => o.source_name))].map(n => `<span class="tag">${esc(n)}</span>`).join(" ")}</div><div class="source-offer"><label>Get from <select aria-label="Source for ${esc(a.name)}" data-offer="${i}">${a.offers.map((o, j) => `<option value="${j}">${esc(o.source_name)} · ${esc(o.version || "Version unknown")} · ${o.verified === true ? "Verified" : "Unverified"}</option>`).join("")}</select></label><div id="offerInfo${i}"></div></div></article>`).join("") || `<div class="sub">No matching apps. Try another search or enable more sources.</div>`;
result.apps.forEach((_, i) => renderOffer(i, 0));
await loadSources();
} catch (e) { if (request === sourceState.request) $("sourceStatus").textContent = e.message; }
}
function renderOffer(i, j) {
const o = sourceState.apps[i].offers[j], page = sourcePage(o.page), busy = installing.has(`${o.source}:${o.id}`);
$("offerInfo" + i).innerHTML = `<p class="sub">${esc(o.fit.verdict)} · API ${esc(o.min_sdk ?? "unknown")} · ${esc(o.abis == null ? "ABI unknown" : o.abis.join(", ") || "No native code")}<br>Version code ${esc(o.version_code ?? "unknown")} · ${o.size == null ? "Size unknown" : (o.size / 1048576).toFixed(1) + " MB"} · Updated ${esc(o.updated || "unknown")}</p><p class="hint">${o.fit.reasons.map(esc).join(" · ")}</p><div class="row">${o.downloadable && o.fit.installable !== false ? `<button class="small action" data-source-install="${i}" data-choice="${j}" ${busy ? "disabled" : ""}>${busy ? "Downloading and installing…" : "Install"}</button>` : ""}${page ? `<a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Developer page</a>` : ""}</div>`;
}
$("sourceSearch").onsubmit = e => { e.preventDefault(); searchSources(); };
$("searchFilters").onclick = e => {
const b = e.target.closest("[data-filter]"); if (!b) return;
if (b.dataset.filter === "installable") sourceState.installable = !sourceState.installable;
else { const value = b.dataset.filter === "vr"; sourceState.vr = sourceState.vr === value ? null : value; }
$("searchFilters").querySelectorAll("button").forEach(b => b.setAttribute("aria-pressed", b.dataset.filter === "installable" ? sourceState.installable : sourceState.vr === (b.dataset.filter === "vr")));
searchSources();
};
$("sourceFilters").onclick = e => { const b = e.target.closest("[data-source]"); if (b) { sourceState.source = b.dataset.source; searchSources(); } };
$("searchGrid").onchange = e => { if (e.target.matches("[data-offer]")) renderOffer(+e.target.dataset.offer, +e.target.value); };
$("searchGrid").onclick = async e => {
const b = e.target.closest("[data-source-install]"); if (!b) return;
const o = sourceState.apps[+b.dataset.sourceInstall].offers[+b.dataset.choice];
if (installing.has(`${o.source}:${o.id}`)) return;
b.disabled = true; b.textContent = "Downloading and installing…";
const result = await runJob(`Install ${o.name}`, `${o.source}:${o.id}`, () => api("/api/sources/install", {source: o.source, id: o.id, version_code: o.version_code ?? null}));
b.disabled = false; b.textContent = result ? "Installed" : "Retry install";
if (result) loadAndroid();
if (!b.isConnected) searchSources();
};
async function changeSource(body) {
$("sourceError").textContent = "Saving…";
try { const result = await api("/api/sources", body); $("sourceError").textContent = result.message; sourceState.source = ""; await loadSources(); await searchSources(); }
catch (e) { $("sourceError").textContent = e.message; await loadSources(); }
}
$("sourcesList").onchange = e => { if (e.target.matches("[data-enable]")) changeSource({action: "enable", source: e.target.dataset.enable, enabled: e.target.checked}); };
$("sourcesList").onclick = e => { const b = e.target.closest("[data-remove]"); if (b) changeSource({action: "remove", source: b.dataset.remove}); };
$("addSource").onsubmit = e => { e.preventDefault(); const data = new FormData(e.target); changeSource({action: "add", url: data.get("url"), fingerprint: data.get("fingerprint")}); };
loadSources().then(searchSources);
// ---- Android apps (own Lepton instance each) + catalogue ----
const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might work", unlikely: "Probably crashes", no: "Won't work" };
const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] };
+65 -1
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
from apk_sources import search as apk_search, SourceError # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_host # noqa: E402
@@ -1227,7 +1228,66 @@ def _sweep_one(prefix, d):
pass
POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
def source_text(body, key, optional=False):
value = body.get(key)
if optional and value in (None, ''):
return None
if not isinstance(value, str) or not value.strip() or len(value) > 2000:
raise Failure('Provide a valid ' + key, 400)
return value.strip()
def source_search(query):
args = parse_qs(query)
q = args.get('q', [''])[0]
vr = args.get('vr', [''])[0]
installable = args.get('installable', [''])[0]
if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'):
raise Failure('Invalid search filters', 400)
try:
return apk_search.search(q, vr=None if not vr else vr in ('true', '1'),
source=args.get('source', [None])[0], installable=installable in ('true', '1'))
except SourceError as e:
raise Failure(str(e), 400)
def source_install(body):
source, entry = source_text(body, 'source'), source_text(body, 'id')
version = body.get('version_code')
if version is not None and (type(version) is not int or version < 0):
raise Failure('version_code must be a non-negative integer', 400)
try:
_, selected = apk_search.resolve(source)
if not selected['enabled']:
raise SourceError('This source is disabled')
except SourceError as e:
raise Failure(str(e), 400)
return start_job('Install ' + entry, lambda: apk_search.install(source, entry, version))
def source_manage(body):
action = body.get('action')
try:
if action == 'enable':
if type(body.get('enabled')) is not bool:
raise Failure('enabled must be true or false', 400)
return apk_search.set_enabled(source_text(body, 'source'), body['enabled'])
if action == 'add':
url = source_text(body, 'url')
if urlparse(url).scheme != 'https' or not urlparse(url).hostname or urlparse(url).username:
raise Failure('Use an HTTPS repository URL without credentials', 400)
apk_search.manage_repo('add_repo', url=url, fingerprint=source_text(body, 'fingerprint', True),
name=source_text(body, 'name', True))
return {'message': 'Repository added'}
if action == 'remove':
apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source'))
return {'message': 'Repository removed'}
raise Failure('Unknown source action', 400)
except SourceError as e:
raise Failure(str(e), 400)
POST = {"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel}
@@ -1335,6 +1395,10 @@ 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/sources":
self.send_json({"sources": apk_search.sources()})
elif path == "/api/search":
self.send_json(source_search(url.query))
elif path == "/api/apk-versions":
self.send_json(apk_versions(url.query))
elif path == "/api/android":