diff --git a/CHANGELOG.md b/CHANGELOG.md index aad1cfb..635e830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Show saved Android compatibility reports on store search cards and app details. + Reports refresh in the background, so a stalled database or credential lookup + cannot delay search. Warnings allow installation; build compatibility blockers + still apply. Public copies use this computer's reports, not private shared ratings. + - Fix experimental PC screen capture on Windows builds whose streaming libraries lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual windows are hidden with an explanation instead of failing when shown. A Windows diff --git a/docs/apks.md b/docs/apks.md index 966483e..3c20f22 100644 --- a/docs/apks.md +++ b/docs/apks.md @@ -190,6 +190,35 @@ IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on th Frame as cards, with Play and a menu for Test, Report and Remove; the display settings and recent reports are below it. +Store search cards and app details also show **Reported not working** or +**Reported issues** when saved compatibility reports say so. Details include +the report date, version and notes. These are advisories: Install remains +available unless the selected build has an existing compatibility blocker +(for example, no arm64-v8a build or Android API above 30). Reports never clear +those blockers. Apps reported not working are excluded from Home's featured +app and recommendation shelves; search and category results still show them. +An unrated install failure caused by the wrong APK architecture or Android API +is ignored, using the same rules as the catalogue. A person's rating takes +priority over automated results, with the latest rating winning. + +Reports warm in a background worker at server start. Search and details read +only the last complete in-memory snapshot and schedule a single background +refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or +the report database. During initial loading they show the usual build verdict; +after a refresh, the next search or detail request sees the reports. Failed +refreshes keep the previous snapshot and retry after another 60 seconds. + +**Availability:** public copies benefit from their own saved reports, including +reports for apps outside the bundled catalogue, across every source with the +same package name. This does not give public users the private shared ratings. +The checked-in `apk-catalog/site/apps.js` contains predictions and no tested +report verdicts, so it cannot supply a useful public report snapshot. No private +reports, free-text notes or credentials are exported or published. The existing +maintainer loader still uses its configured database access in the background; +the search feature adds no sharing, endpoint or network destination. Tests leave +the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in +server subprocesses) and use synthetic reports to exercise refresh behavior. + The store groups apps by what a headset is good for, not by F-Droid's list: VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise diff --git a/tests/page/store_detail.mjs b/tests/page/store_detail.mjs index 6de1043..77505aa 100644 --- a/tests/page/store_detail.mjs +++ b/tests/page/store_detail.mjs @@ -13,13 +13,13 @@ function grab(name) { if (start < 0) fail(`no top-level ${name} in index.html`); let depth = 0; for (let i = start; i < lines.length; i++) { - for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0; + for (const c of lines[i]) depth += c === "{" || c === "[" ? 1 : c === "}" || c === "]" ? -1 : 0; if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n"; } fail(`unbalanced ${name}`); } const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage", - "GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody", + "GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeReportDetails", "storePickEligible", "STORE_CATS", "storeCat", "storeCard", "storeRow", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody", "updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"]; const code = names.map(grab).join(""); @@ -34,7 +34,7 @@ function page(api = async () => ({}), androidApps = []) { const document = { activeElement: null, querySelector: () => null }; const console = { error() {} }; const calls = []; - const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp };")( + const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp, storeCard, storeRow, storePickEligible };")( $, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps, async (title, key, work) => work(), () => {}, async (title, work) => work(), { open() { fail("an in-app installation opened an external page"); } }, 0); @@ -114,4 +114,31 @@ for (const [job, expected] of [[null, 1], [{ done: false }, 0], if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer"); if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app"); } + +// Report warnings appear on real cards, shelves and details; they are advisory. +for (const [kind, tone, label] of [['no', 'warning', 'Reported not working'], ['maybe', 'issues', 'Reported issues']]) { + const p = page(); + const o = {...offer, verdict: {tone, label: label+' on the Frame'}, + compat_report: {verdict: kind, lines: ['Reported notes', '2 reports, 0 working']}}; + const app = {name: o.name, offers: [o]}; + p.sourceState.apps = [app]; + p.sourceState.detail = {app, choice: 0}; + const card = p.storeCard(app), shelf = p.storeRow('VR picks', 'Synthetic fixtures', [app]); + if (!card.includes('>'+label+'') || !shelf.includes('>'+label+'')) fail('warning missing on card or shelf'); + if (!p.renderAppDetail()) fail('report detail did not render'); + const html = p.els.detailBody.innerHTML; + if (!html.includes('Saved compatibility reports') || !html.includes('Reported <unsafe> notes')) fail('report evidence missing or unescaped'); + if (html.includes('Reported ')) fail('report notes injected HTML'); + if (p.els.detailInstall.disabled || p.els.detailInstall.textContent !== 'Install on the Frame') fail('report disabled installation'); + if (kind==='no' && p.storePickEligible(o)) fail('broken report is eligible for featured/VR picks'); + // A positive report cannot remove a hard blocker. Negative evidence stays visible too. + for (const reportKind of [kind, 'works']) { + const blocked = {...o, verdict: {tone: 'blocked', label: 'No arm64 build'}, compat_report: {...o.compat_report, verdict: reportKind}}; + app.offers = [blocked]; + if (!p.renderAppDetail() || !p.els.detailInstall.disabled) fail('report overrode hard blocker'); + if (p.storePickEligible(blocked)) fail('blocked app eligible for picks'); + if (reportKind!=='works' && !p.storeCard(app).includes('>'+label+'')) fail('hard blocker hid report warning'); + } +} + console.log("ok"); diff --git a/tests/sandbox.py b/tests/sandbox.py index dd30577..b43f53c 100644 --- a/tests/sandbox.py +++ b/tests/sandbox.py @@ -22,6 +22,7 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir os.environ["FRAME_CONTROL_TELEMETRY"] = "0" # A maintainer's machine holds the database key; send anything that slips through nowhere. os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9" +os.environ["FRAME_COMPAT_SEARCH_REPORTS"] = "0" # server subprocesses never start real report readers # A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins, # its lock file) and the other per-user folders some modules use. A server started diff --git a/tests/test_apk_artwork.py b/tests/test_apk_artwork.py index 92e233b..12dd216 100644 --- a/tests/test_apk_artwork.py +++ b/tests/test_apk_artwork.py @@ -17,6 +17,9 @@ PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').rea class ArtworkTests(unittest.TestCase): def setUp(self): + p = patch.object(search, 'compat_reports', None) + p.start() + self.addCleanup(p.stop) with _images._lock: _images._urls.clear() _images._tokens.clear() diff --git a/tests/test_apk_compat_reports.py b/tests/test_apk_compat_reports.py new file mode 100644 index 0000000..acf6fae --- /dev/null +++ b/tests/test_apk_compat_reports.py @@ -0,0 +1,169 @@ +"""Search report snapshots: synthetic evidence only, no credentials or services.""" +import sandbox # noqa: F401 +import sys +import json +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import frame_catalog +import frame_compat_db as db +from apk_sources import search +from test_apk_search import SettingsTest, fake, ENTRIES + + +class ReportTests(SettingsTest): + def hook(self, rows): + by = frame_catalog.reports.by_package(rows) + snapshot = {pkg: v for pkg, rs in by.items() if (v := frame_catalog.reports.verdict(rs))} + p = patch.object(search, 'compat_reports', lambda: snapshot) + p.start() + self.addCleanup(p.stop) + + def test_search_and_details_share_catalogue_rules(self): + pkg = ENTRIES[0]['package'] + self.hook([dict(package=pkg, date='2026-10-01', result='runs'), + dict(package=pkg, date='2026-10-02', rating='broken', notes='Crashes '), + dict(package=pkg, date='2026-10-03', result='runs')]) + mod = fake() + with patch.object(search, 'modules', return_value=([mod], [])): + offer = search.search(timeout=.01)['apps'][0]['offers'][0] + detail = search.details('one', ENTRIES[0]['id']) + self.assertEqual(detail['compat_report']['verdict'], 'no') + self.assertEqual(offer['compat_report'], detail['compat_report']) + self.assertEqual(detail['verdict']['tone'], 'warning') + self.assertIn('Reported not working', detail['verdict']['label']) + + def test_warnings_never_change_installability_or_hard_blockers(self): + entry = dict(ENTRIES[0], min_sdk=23, abis=['arm64-v8a']) + for kind, tone in [('works', 'works'), ('issues', 'issues'), ('broken', 'warning')]: + self.hook([dict(package=entry['package'], date='2026-10-01', rating=kind)]) + decorated = search.decorate(entry) + self.assertEqual(decorated['verdict']['tone'], tone) + self.assertTrue(decorated['fit']['installable']) + for blocker in [dict(min_sdk=31), dict(abis=['x86_64']), + dict(requires_meta_services=True), dict(engine='VrApi')]: + blocked = search.decorate(dict(entry, **blocker)) + self.assertEqual(blocked['verdict']['tone'], 'blocked') + self.assertIsNotNone(blocked['compat_report']) + + def test_wrong_file_failures_do_not_warn_but_real_failures_do(self): + entry = dict(ENTRIES[0], min_sdk=23, abis=[]) + for notes in ['no arm64-v8a build', 'needs Android API 32']: + self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes=notes)]) + self.assertIsNone(search.decorate(entry)['compat_report']) + self.hook([dict(package=entry['package'], date='2026-10-01', result='crashes')]) + self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning') + self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes='installer crashed')]) + self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning') + + def test_report_warning_does_not_prevent_install(self): + self.hook([dict(package=ENTRIES[0]['package'], date='2026-10-01', rating='broken')]) + mod = fake() + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(frame_catalog.frame_android, 'install', return_value={'package': ENTRIES[0]['package']}) as install: + result = search.install('one', ENTRIES[0]['id']) + self.assertEqual(result['package'], ENTRIES[0]['package']) + install.assert_called_once() + + def test_stalled_loader_does_not_block_search_or_details(self): + entered, release, finished = threading.Event(), threading.Event(), threading.Event() + main = threading.get_ident() + + def loader(): + self.assertNotEqual(threading.get_ident(), main) + entered.set() + try: + if not release.wait(2): + raise TimeoutError('test stalled') + return [] + finally: + finished.set() + + cache = search.ReportCache(loader, frame_catalog.reports.verdict) + from apk_sources import _web + with patch.object(search, 'compat_reports', cache.read), \ + patch.object(search, 'modules', return_value=([fake()], [])), \ + patch.object(_web, 'prune'), \ + patch.object(db, 'key', side_effect=AssertionError('credentials on request')), \ + patch.object(db._opener, 'open', side_effect=AssertionError('network on request')), \ + patch.object(db.subprocess, 'run', side_effect=AssertionError('subprocess on request')): + try: + search.warm() # production server startup, before the first request + self.assertTrue(entered.wait(1)) + start = time.monotonic() + result = search.search(timeout=.01) + detail = search.details('one', ENTRIES[0]['id']) + self.assertLess(time.monotonic() - start, .2) + self.assertTrue(result['apps']) + self.assertIsNone(detail['compat_report']) + finally: + release.set() + self.assertTrue(finished.wait(1)) + + +class CacheTests(unittest.TestCase): + def wait_idle(self, cache): + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + with cache._lock: + if not cache._running: + return + time.sleep(.001) + self.fail('report worker did not finish') + + def test_public_copy_reads_its_local_mirror_and_outbox(self): + with tempfile.TemporaryDirectory() as tmp: + mirror, outbox = Path(tmp) / 'mirror.json', Path(tmp) / 'outbox.jsonl' + mirror.write_text(json.dumps({'reports': [dict(package='org.example.app', id='old', + date='2026-10-01', result='runs')]})) + outbox.write_text(json.dumps(dict(package='org.example.app', id='new', + date='2026-10-02', rating='broken')) + '\n') + with patch.object(db, 'MIRROR', str(mirror)), patch.object(db, 'OUTBOX', str(outbox)), \ + patch.object(db, '_mem', dict(at=0, reports=None, source=None)), \ + patch.object(db, 'shared', return_value=False), \ + patch.object(db, 'fetch_all', side_effect=AssertionError('private database read')): + cache = search.ReportCache(db.load, frame_catalog.reports.verdict) + cache.read() + self.wait_idle(cache) + self.assertEqual(cache.read()['org.example.app'][0], 'no') + + def test_ttl_refresh_is_single_flight_and_keeps_last_good_snapshot(self): + loader = Mock(return_value=[dict(package='org.example.app', date='2026-10-01', rating='issues')]) + cache = search.ReportCache(loader, frame_catalog.reports.verdict, ttl=60) + cache.read() + self.wait_idle(cache) + first = cache.read() + self.assertEqual(first['org.example.app'][0], 'maybe') + self.assertEqual(loader.call_count, 1) + entered, release = threading.Event(), threading.Event() + + def stalled(): + entered.set() + release.wait(2) + raise OSError('offline') + + loader.side_effect = stalled + cache._at -= 61 + try: + self.assertIs(cache.read(), first) + self.assertTrue(entered.wait(1)) + for _ in range(10): + self.assertIs(cache.read(), first) + self.assertEqual(loader.call_count, 2) + finally: + release.set() + self.wait_idle(cache) + self.assertIs(cache.read(), first) + self.assertEqual(loader.call_count, 2) # failure also has a retry TTL + loader.side_effect = None + loader.return_value = [dict(package='org.example.app', date='2026-10-02', rating='works')] + cache._at -= 61 + cache.read() + self.wait_idle(cache) + self.assertEqual(cache.read()['org.example.app'][0], 'works') + self.assertEqual(first['org.example.app'][0], 'maybe') # published snapshots never mutated diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py index bc3fae0..5705962 100644 --- a/tests/test_apk_search.py +++ b/tests/test_apk_search.py @@ -27,6 +27,9 @@ def fake(source_id='one', fn=None): class SettingsTest(unittest.TestCase): def setUp(self): + p = patch.object(search, 'compat_reports', None) + p.start() + self.addCleanup(p.stop) self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json') diff --git a/ui/apk_sources/search.py b/ui/apk_sources/search.py index 650d2ce..ace7110 100644 --- a/ui/apk_sources/search.py +++ b/ui/apk_sources/search.py @@ -2,7 +2,7 @@ Optional store metadata: images {icon, banner, screenshots}, developer, description, popularity, open_source, requires_meta_services, frame_tested. -Only an explicit frame_tested=True produces a working-on-Frame verdict. +An explicit frame_tested=True or a saved working report produces a working verdict. """ import importlib import inspect @@ -24,6 +24,46 @@ _game_data = {} # package -> downloaded OBB paths waiting for "Add game data" _status = {} TIMEOUT = 12 BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping +compat_reports = None # optional memory-only snapshot hook; enabled at server start, not import + + +class ReportCache: + """Stale-while-refresh reports. Never hold the snapshot lock while loading. + + The loader may block on disk, credentials or the database. Only its daemon + worker calls it; readers keep the last complete snapshot, even after errors. + """ + def __init__(self, loader, report_verdict, ttl=60): + self.loader, self.report_verdict, self.ttl = loader, report_verdict, ttl + self._lock = threading.Lock() + self._snapshot, self._at, self._running = {}, None, False + + def read(self): + with self._lock: + snapshot = self._snapshot + if not self._running and (self._at is None or time.monotonic() - self._at >= self.ttl): + self._running = True + threading.Thread(target=self._refresh, daemon=True).start() + return snapshot + + def _refresh(self): + try: + by = {} + for report in self.loader(): + if report.get('package'): + by.setdefault(report['package'], []).append(report) + snapshot = {pkg: v for pkg, rows in by.items() if (v := self.report_verdict(rows))} + with self._lock: + self._snapshot = snapshot + except Exception: + pass # optional evidence must never break search or discard a good snapshot + finally: + with self._lock: + self._at, self._running = time.monotonic(), False + + +def _reports(): + return compat_reports() if compat_reports else {} def modules(): @@ -149,7 +189,7 @@ def fit(entry): 'reasons': reasons} -def verdict(entry): +def verdict(entry, report=None): compatibility = fit(entry) hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower() if entry.get('requires_meta_services') is True: @@ -160,6 +200,12 @@ def verdict(entry): return {'label': "This version isn't made for the Frame", 'tone': 'blocked'} if 'vrapi' in hints: return {'label': "Made for older Quest headsets; won't run on the Frame", 'tone': 'blocked'} + if report: + kind, _ = report + return {'label': {'works': 'Reported working on the Frame', + 'maybe': 'Reported issues on the Frame', + 'no': 'Reported not working on the Frame'}[kind], + 'tone': {'works': 'works', 'maybe': 'issues', 'no': 'warning'}[kind]} if entry.get('frame_tested') is True: return {'label': 'Works on the Frame', 'tone': 'works'} if compatibility['installable'] is True: @@ -167,10 +213,13 @@ def verdict(entry): return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'} -def decorate(entry, shots=12): +def decorate(entry, shots=12, reports=None): """shots: screenshots to offer; a listing card shows three, the detail page all of them.""" from apk_sources import _images - return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots), + report = (reports if reports is not None else _reports()).get(entry.get('package')) + evidence = {'verdict': report[0], 'lines': list(report[1])} if report else None + return dict(entry, fit=fit(entry), verdict=verdict(entry, report), compat_report=evidence, + artwork=_images.artwork(entry, shots), **categories.classify(entry)) @@ -191,8 +240,9 @@ def offer_rank(entry): def group(entries, query='', vr=None, installable=False, category=None, phone=False): groups = {} + reports = _reports() # one complete snapshot for every offer in this response for entry in entries: - entry = decorate(entry, shots=3) + entry = decorate(entry, shots=3, reports=reports) if not categories.matches(entry, category, phone): continue if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat @@ -310,6 +360,7 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l def warm(): """Start every enabled source's index download in the background (server start, new repo).""" + _reports() # warm independently of potentially slow source indexes from apk_sources import _web _web.prune() items, _ = registry() diff --git a/ui/index.html b/ui/index.html index 8dc40fa..4557017 100644 --- a/ui/index.html +++ b/ui/index.html @@ -627,6 +627,8 @@ .runs { font-size:11px; font-weight:500; padding:3px 8px; border-radius:10px; white-space:nowrap; } .runs.works { color:#c6efb0; background:#70bb5426; } .runs.ready { color:#b7d8ec; background:#6ec9ff1a; } .runs.unknown { color:#a8b5c2; background:#ffffff0d; } .runs.blocked { color:#f1c28a; background:#d9a23a22; } + .runs.warning, .runs.issues { color:#f1c28a; background:#d9a23a22; white-space:normal; } + .store-compat-badges { display:flex; flex-wrap:wrap; gap:4px; } .store-hero > .store-art { position:absolute; inset:0; } .store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; } .hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; } @@ -773,6 +775,7 @@ .detail-buy .store-primary { width:100%; min-height:46px; border-radius:7px; margin:8px 0; font-size:15px; } .detail-verdict { color:#b6d6a8; font-size:12px; line-height:1.55; margin:0 0 15px; } .detail-verdict.blocked { color:#e3b77a; } + .detail-verdict.warning, .detail-verdict.issues { color:#e3b77a; } .detail-verdict.unknown { color:#adbac7; } .detail-price { font-size:23px; color:white; font-weight:600; } .detail-small { font-size:11px; color:var(--muted); margin:4px 0 18px; } @@ -4240,10 +4243,16 @@ async function loadSources() { } catch (e) { $("sourceError").textContent = e.message; } } // Keep installation confidence explicit, including apps that have not been checked yet. -const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"] }; +const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"], warning: ["Reported not working", "warning"], issues: ["Reported issues", "issues"] }; function storeRuns(o) { const [text, cls] = RUNS[o.verdict?.tone] || RUNS.unknown; - return `${text}`; + const report=o.compat_report, extra=o.verdict?.tone==='blocked' && ['no','maybe'].includes(report?.verdict); + return `${text}${extra ? `${report.verdict==='no' ? 'Reported not working' : 'Reported issues'}` : ''}`; +} +function storePickEligible(o) { return o.verdict?.tone !== 'blocked' && o.compat_report?.verdict !== 'no'; } +function storeReportDetails(o) { + const report=o.compat_report; + return report ? `
Saved compatibility reports${report.lines.map(line=>`

${esc(line)}

`).join('')}

Reports can cover other versions or runtimes. You can still try installing unless this build has a compatibility blocker.

` : ''; } const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app"; function storeCard(app) { @@ -4271,7 +4280,7 @@ function renderStore() { if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; } if (!browsing) { $("searchGrid").innerHTML=`
${apps.map(storeCard).join("")}
`; return; } // Home: one featured VR app, then a shelf per category; each app appears once, blocked ones not at all. - const ok=apps.filter(a=>a.offers[0].verdict?.tone !== 'blocked'), shown=new Set(); + const ok=apps.filter(a=>storePickEligible(a.offers[0])), shown=new Set(); const day=Math.floor(Date.now()/864e5), heroes=ok.filter(a=>a.offers[0].vr && a.offers[0].artwork?.banner); const featured=heroes[day % (heroes.length || 1)] || ok.find(a=>a.offers[0].artwork?.banner) || ok[0]; if (!featured) { $("searchGrid").innerHTML=storeEmpty(); return; } // nothing here runs on the Frame @@ -4327,7 +4336,7 @@ function renderAppDetail() { function renderAppDetailBody() { const restoreOfferFocus=document.activeElement?.name==='storeOffer'; const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page); - $("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`
${storeIcon(o)}

${esc(o.name)}

${esc(o.developer || o.author || 'From '+storeName(o.source_name))}

${detailBadges(o)}
${images.length ? `` : ''}

About this app

${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
${page ? `

The developer's page ↗

` : ''}
Technical details
Package
${esc(o.package||"Unknown")}
Version
${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})
Android API
${esc(o.min_sdk??"Unknown")}
Architecture
${esc(o.abis?.join(', ')||"Unknown / no native code")}
Updated
${esc(o.updated||"Unknown")}
Compatibility
${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}
`; + $("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`
${storeIcon(o)}

${esc(o.name)}

${esc(o.developer || o.author || 'From '+storeName(o.source_name))}

${detailBadges(o)}
${images.length ? `` : ''}

About this app

${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
${page ? `

The developer's page ↗

` : ''}
Technical details
Package
${esc(o.package||"Unknown")}
Version
${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})
Android API
${esc(o.min_sdk??"Unknown")}
Architecture
${esc(o.abis?.join(', ')||"Unknown / no native code")}
Updated
${esc(o.updated||"Unknown")}
Compatibility
${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}
`; updateInstall(); if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus(); } diff --git a/ui/server.py b/ui/server.py index bfef68f..22035c1 100755 --- a/ui/server.py +++ b/ui/server.py @@ -3593,6 +3593,9 @@ def main(): args = ap.parse_args() httpd = LoopbackServer(("127.0.0.1", args.port), Handler) sweep_tmp() + if os.environ.get('FRAME_COMPAT_SEARCH_REPORTS') != '0': + apk_search.compat_reports = apk_search.ReportCache( + frame_compat_db.load, frame_catalog.reports.verdict, ttl=frame_compat_db.TTL).read threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search frame_telemetry.start() frame_contact.start()