mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Add unified APK source search and source management
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
1 parent
268afccf05
commit
f1b12a6eb6
8 files changed
+676
-7
No files matched your search
@@ -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')
|
||||
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user