diff --git a/.claude/NOTES-apk-search.md b/.claude/NOTES-apk-search.md new file mode 100644 index 0000000..aa57ce3 --- /dev/null +++ b/.claude/NOTES-apk-search.md @@ -0,0 +1,64 @@ +# APK search + +- Scope: dynamic source aggregator, additive server APIs, Android search view; no device calls. +- Source metadata stays unknown when missing. Unknown-package names only group with other unknown packages (avoids ambiguous package attribution). +- Queries use daemon workers with at most one in-flight request per source; deadlines don't wait for stuck workers. +- Independent review delegated to parent per task brief; no workers launched. + +## Implementation + +- `ui/apk_sources/search.py`: discovers KIND modules, parallel daemon queries (12 s deadline), bounded per-source workers, grouped offers, fit/rank/filter logic, persisted source enablement, repo adapter and install adapter. +- `ui/server.py`: GET `/api/sources`, GET `/api/search?q=&vr=true|false&installable=true|false&source=`, POST `/api/sources/install` and POST `/api/sources` with actions `add`, `remove`, `enable`. +- `ui/index.html`: unified Find apps in Android tab, source/VR/Flat/Installable chips, offer picker, metadata, jobs, Sources panel. Old catalogue DOM remains hidden for existing report/icon code; only the unified search is visible. +- `_demo.py` is discovered only with `FRAME_APK_SEARCH_DEMO=1`; it cannot download. `tests/search_preview.py` serves the real UI/search endpoints and rejects all device endpoints and writes. + +## Evidence + +- `python3 -m unittest discover -s tests`: 179 tests passed on Python 3.9.6, exit 0. Log `/tmp/apk-search-suite.log`. +- `node --check /tmp/apk-search-evidence/ui.js`: exit 0; script extracted from index.html. +- `git diff --check`: exit 0. +- Python 3.9 grammar parse: search.py, _demo.py, server.py, test_apk_search.py and search_preview.py passed; actual suite interpreter also Python 3.9.6. +- Started `FRAME_APK_SEARCH_DEMO=1 python3 tests/search_preview.py` locally; used T3 preview at http://127.0.0.1:8795/#android (1280x800). +- Browser assertions: two grouped apps, source picker updates install choice, VR excludes flat, Flat excludes VR, source filter keeps one offer, search query narrows results, pending install disables its button after re-render. Passed. +- Inspected screenshots: `/tmp/apk-search-evidence/search.png`, `/tmp/apk-search-evidence/sources.png`. Other panels show intentional device-access errors in the preview. +- Tests cover dynamic module discovery, grouping (including ambiguous names), ranking, fit, timeout/failure isolation, persistent enablement, endpoint validation, repo callbacks, background install with mocked frame_android.install, conditional artwork, and OBB support/refusal. + +## Parent integration / unverified + +- No source modules from sibling branches are present yet. Real network listings/downloads, signatures, merged repo persistence and physical installation were not exercised. No SSH/device installation performed. +- Assumes future `frame_android.install_obb(package, paths)`; reconcile with vr-library worker's actual signature. Artwork is passed only when install explicitly declares that parameter. OBB-required apps fail before APK installation when helper is absent. +- `app/package.json` currently copies ui with only `*.py` / `*.html`; parent must include `apk_sources/**/*.py` in packaged resources when integrating source workers. Kept package config outside this worker's scope. +- No cross-provider reviewer launched: task explicitly forbids delegation and assigns integration/review to the parent. +- Source metadata unknowns do not imply compatibility or verification. Unknown-package entries only group with other unknown-package entries with the same normalized name. + +## Store redesign (follow-up) + +- Read `/tmp/vrapk/p5-redesign.md` and common ground rules in full. Consulted Human Interface Craft, especially purpose/information, hierarchy, progressive disclosure and familiar navigation. +- Discover now owns the Android tab's full width; device controls/reports remain under On your Frame. Artwork cards, a featured app, browse rows, debounced search, friendly filters/count/empty state and skeletons replace the technical listing. +- Details use a native dialog with banner/icon, creator, description, screenshots with keyboard/arrow controls, badges, primary installation action and friendly source choices. Technical identifiers and compatibility facts are collapsed. +- Sources use a separate native settings dialog with aligned switches, source initials, descriptions, trust labels and repository form. A failed source produces a quiet human-readable notice. +- `_images.py` registers only source-entry artwork, returns opaque handles, permits HTTP(S) raster images only, rejects private/link-local/reserved IPs (including redirected targets), connects to the validated IP with TLS hostname checking, limits images to 8 MiB and bounds its memory cache to 64 MiB / registry to 4096 handles. Browser requests are same-origin `/source-image/`; arbitrary URLs are never accepted by the HTTP endpoint. +- New GET `/api/sources/details?source=&id=` supplies richer details. Search decorates offers with `artwork` and a plain-language `verdict`. +- Optional source metadata is documented in search.py: `images: {icon, banner, screenshots}`, developer, description, popularity, open_source, requires_meta_services, frame_tested. Only explicit `frame_tested=True` earns Works on the Frame. Installability alone says Ready to try. No fabricated popularity labels when a source provides no ranking data. +- Background jobs accept an optional progress reporter; source installs report Downloading and Installing. The UI displays a percentage only if supplied by a job. Current shared download() interface provides no byte progress, so real sources show truthful indeterminate stages, not fabricated percentages. +- Demo listings include real recorded public artwork plus clearly documented illustrative listing metadata. Preview installer alone simulates percentages and completion; it never calls frame_android.install or SSH. Repo toggles in preview use an isolated temporary settings file. + +### Visual review and evidence + +- Iteration 1: inspected browse and detail screenshots; found too much vertical space above the app rows and description. Reduced hero/banner heights and header spacing. +- Iteration 2: inspected wide and narrow layouts; moved desktop search beside the heading, placed a full popular row before the short VR row, compacted the disconnected-headset notice for browsing, aligned settings switches, preserved radio focus after detail refresh and improved progress text contrast. +- Final visual inspection: 1440x1050 desktop and 390x844 narrow viewport, two narrow columns (172px each), document width 380px inside a 390px viewport (no horizontal overflow). +- Final PNGs in `/tmp/apk-search-evidence/v2/`: browse-home.png, search-results.png, app-details.png, install-progress.png, sources-sheet.png, empty-state.png, narrow-window.png. Iteration screenshots retained there too. +- Browser checks via T3 preview at http://127.0.0.1:8795/#android: six grouped apps; no package/API/ABI/engine/demo jargon in browse; typing `world` yields three apps after debounce; app details contain three screenshots and two source choices; source selection updates installation target and keeps keyboard focus; disabling itch.io removes its notice and persists for subsequent searches; simulated install displays Downloading 43%, then completion and Open in Steam; empty search renders illustrated recovery; narrow two-column layout has no horizontal overflow. +- `FRAME_APK_SEARCH_DEMO=1 python3 tests/search_preview.py` started the local server used above. Real device endpoints rejected. +- `python3 -m unittest discover -s tests`: 188 tests passed on Python 3.9.6, exit 0. Final log `/tmp/apk-search-evidence/v2/unittest.log`. +- `node --check /tmp/apk-search-evidence/v2/ui.js`: exit 0 (inline script extracted from final index.html). +- Python 3.9 grammar parse: seven changed/new Python files passed. `git diff --check`: exit 0. + +### Still unverified / parent integration + +- Real source modules, real repository add/remove writes, real downloads and physical headset launch/install are not tested. No SSH or device installation performed. +- Image fetch transport tested with mocks; preview serves recorded images through the actual HTTP image endpoint/cache. Live asset files were fetched separately to create fixtures; live proxy TLS/redirect behavior across provider CDNs remains unverified. +- Percentages in install-progress.png are explicitly simulated by the preview harness. Real provider byte-progress integration needs an extension to the shared download interface; stage reporting works now. +- Source workers must provide `images` and creator/description metadata for rich listings; fallback gradients/initials work without them. Frame-tested/popularity signals must be backed by source evidence, not inferred from installability. +- The earlier packaging (`apk_sources/**/*.py`) and install_obb signature integration notes still apply. No Electron build or cross-provider review run; parent owns integration/review and this brief prohibits delegation. diff --git a/tests/fixtures/apk-search/artwork/README.md b/tests/fixtures/apk-search/artwork/README.md new file mode 100644 index 0000000..6c67814 --- /dev/null +++ b/tests/fixtures/apk-search/artwork/README.md @@ -0,0 +1,19 @@ +# Store preview artwork + +Recorded public artwork for offline UI verification, fetched 2026-09-28. +`urls.json` records each original URL. No unit test downloads these files. + +- Open Brush banner, icon and screenshots: Icosa Foundation's public + `icosa-foundation/openbrush.app` website assets. Artwork remains credited to + its creators; used here to preview the Open Brush listing. +- Mindustry, AntennaPod and NewPipe icons/screenshots: their public F-Droid + listings. Corresponding projects use GPL licences; these images represent + those same apps in the store preview. +- Luanti, SuperTuxKart and other social previews: public GitHub-generated + repository preview images. Project names/logos belong to their owners. + +`../store.json` contains illustrative listing metadata, including mock package +names, popularity, dates, version/size and compatibility fields. It is not a +catalogue or evidence that a particular release works on the Frame. `_demo.py` +is opt-in and cannot download APKs. `tests/search_preview.py` preloads these +recordings into the image cache and simulates installation without a headset. diff --git a/tests/fixtures/apk-search/artwork/brush-banner.jpg b/tests/fixtures/apk-search/artwork/brush-banner.jpg new file mode 100644 index 0000000..b7bd790 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-banner.jpg differ diff --git a/tests/fixtures/apk-search/artwork/brush-icon.png b/tests/fixtures/apk-search/artwork/brush-icon.png new file mode 100644 index 0000000..32cfb0a Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/brush-shot1.png b/tests/fixtures/apk-search/artwork/brush-shot1.png new file mode 100644 index 0000000..3cd6b99 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-shot1.png differ diff --git a/tests/fixtures/apk-search/artwork/brush-shot2.webp b/tests/fixtures/apk-search/artwork/brush-shot2.webp new file mode 100644 index 0000000..c22ad8c Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-shot2.webp differ diff --git a/tests/fixtures/apk-search/artwork/brush-shot3.webp b/tests/fixtures/apk-search/artwork/brush-shot3.webp new file mode 100644 index 0000000..13b255e Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-shot3.webp differ diff --git a/tests/fixtures/apk-search/artwork/kart.png b/tests/fixtures/apk-search/artwork/kart.png new file mode 100644 index 0000000..6d90ef5 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/kart.png differ diff --git a/tests/fixtures/apk-search/artwork/luanti-icon.png b/tests/fixtures/apk-search/artwork/luanti-icon.png new file mode 100644 index 0000000..47d7345 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/luanti-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/luanti.png b/tests/fixtures/apk-search/artwork/luanti.png new file mode 100644 index 0000000..c8fa35d Binary files /dev/null and b/tests/fixtures/apk-search/artwork/luanti.png differ diff --git a/tests/fixtures/apk-search/artwork/mindustry-icon.png b/tests/fixtures/apk-search/artwork/mindustry-icon.png new file mode 100644 index 0000000..db8b4f2 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/mindustry-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/mindustry-shot.png b/tests/fixtures/apk-search/artwork/mindustry-shot.png new file mode 100644 index 0000000..b9c5874 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/mindustry-shot.png differ diff --git a/tests/fixtures/apk-search/artwork/newpipe-icon.png b/tests/fixtures/apk-search/artwork/newpipe-icon.png new file mode 100644 index 0000000..561ea34 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/newpipe-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/newpipe-shot.png b/tests/fixtures/apk-search/artwork/newpipe-shot.png new file mode 100644 index 0000000..3ecd0de Binary files /dev/null and b/tests/fixtures/apk-search/artwork/newpipe-shot.png differ diff --git a/tests/fixtures/apk-search/artwork/pod-icon.png b/tests/fixtures/apk-search/artwork/pod-icon.png new file mode 100644 index 0000000..9b54286 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/pod-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/pod-shot.png b/tests/fixtures/apk-search/artwork/pod-shot.png new file mode 100644 index 0000000..a189a10 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/pod-shot.png differ diff --git a/tests/fixtures/apk-search/artwork/urls.json b/tests/fixtures/apk-search/artwork/urls.json new file mode 100644 index 0000000..7574e46 --- /dev/null +++ b/tests/fixtures/apk-search/artwork/urls.json @@ -0,0 +1,16 @@ +{ + "brush-banner.jpg": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg", + "brush-icon.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png", + "brush-shot1.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png", + "brush-shot2.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp", + "brush-shot3.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp", + "luanti.png": "https://opengraph.githubassets.com/1/luanti-org/luanti", + "kart.png": "https://opengraph.githubassets.com/1/supertuxkart/stk-code", + "luanti-icon.png": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png", + "pod-icon.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png", + "mindustry-icon.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png", + "mindustry-shot.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png", + "pod-shot.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png", + "newpipe-icon.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png", + "newpipe-shot.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png" +} diff --git a/tests/fixtures/apk-search/entries.json b/tests/fixtures/apk-search/entries.json new file mode 100644 index 0000000..1b1f236 --- /dev/null +++ b/tests/fixtures/apk-search/entries.json @@ -0,0 +1,7 @@ +[ + {"source":"one","id":"brush","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":true,"version":"1","version_code":1,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2025-01-01"}, + {"source":"two","id":"brush2","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":false,"version":"2","version_code":2,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2026-01-01"}, + {"source":"one","id":"other","package":"org.other","name":"Open Brush","free":true,"downloadable":true,"min_sdk":31,"abis":["arm64-v8a"],"vr":true}, + {"source":"one","id":"unknown","package":null,"name":"Pocket Radio!","free":true,"downloadable":false,"vr":false}, + {"source":"two","id":"unknown2","package":null,"name":"pocket radio","free":true,"downloadable":false,"vr":false} +] diff --git a/tests/fixtures/apk-search/store.json b/tests/fixtures/apk-search/store.json new file mode 100644 index 0000000..26bf886 --- /dev/null +++ b/tests/fixtures/apk-search/store.json @@ -0,0 +1,182 @@ +[ + { + "id": "brush", + "package": "org.preview.brush", + "name": "Open Brush", + "summary": "Make the world your canvas. Paint, sculpt and create in a space without limits.", + "description": "Your imagination deserves more room. Open Brush turns the space around you into a canvas, with expressive brushes, vivid colors and light you can paint with.\n\nCreate something small, build something extraordinary, or just enjoy making your first mark in VR. This community-led painting app is free and open source.", + "developer": "Icosa Foundation", + "license": "Apache-2.0", + "free": true, + "downloadable": true, + "version": "2.32.29", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": true, + "updated": "2026-09-27", + "size": 85000000, + "popularity": 100, + "images": { + "banner": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg", + "icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png", + "screenshots": [ + "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png", + "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp", + "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp" + ] + }, + "engine": "Unity OpenXR", + "frame_tested": true, + "icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png", + "page": "https://openbrush.app" + }, + { + "id": "mindustry", + "package": "org.preview.mindustry", + "name": "Mindustry", + "summary": "Build a factory. Defend your world.", + "description": "Build a factory. Defend your world.", + "developer": "Anuken", + "license": "GPL-3.0", + "free": true, + "downloadable": true, + "version": "1.2", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-26", + "size": 85000000, + "popularity": 92, + "images": { + "banner": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png", + "icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png", + "screenshots": [ + "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png" + ] + }, + "icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png" + }, + { + "id": "luanti", + "package": "org.preview.luanti", + "name": "Luanti", + "summary": "A world of blocks. Endless possibilities.", + "description": "A world of blocks. Endless possibilities.", + "developer": "Luanti contributors", + "license": "LGPL-2.1", + "free": true, + "downloadable": true, + "version": "1.3", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-25", + "size": 85000000, + "popularity": 84, + "images": { + "banner": "https://opengraph.githubassets.com/1/luanti-org/luanti", + "icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png", + "screenshots": [ + "https://opengraph.githubassets.com/1/luanti-org/luanti" + ] + }, + "icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png" + }, + { + "id": "pod", + "package": "org.preview.pod", + "name": "AntennaPod", + "summary": "Your favorite stories, wherever you listen.", + "description": "Your favorite stories, wherever you listen.", + "developer": "AntennaPod contributors", + "license": "GPL-3.0", + "free": true, + "downloadable": true, + "version": "1.4", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-24", + "size": 85000000, + "popularity": 76, + "images": { + "banner": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png", + "icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png", + "screenshots": [ + "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png" + ] + }, + "icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png" + }, + { + "id": "newpipe", + "package": "org.preview.newpipe", + "name": "NewPipe", + "summary": "Your videos and music, without the distractions.", + "description": "Your videos and music, without the distractions.", + "developer": "Team NewPipe", + "license": "GPL-3.0", + "free": true, + "downloadable": true, + "version": "1.5", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-23", + "size": 85000000, + "popularity": 68, + "images": { + "banner": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png", + "icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png", + "screenshots": [ + "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png" + ] + }, + "icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png" + }, + { + "id": "kart", + "package": "org.preview.kart", + "name": "SuperTuxKart", + "summary": "A little friendly competition. A lot of colorful chaos.", + "description": "A little friendly competition. A lot of colorful chaos.", + "developer": "SuperTuxKart Team", + "license": "GPL-3.0", + "free": true, + "downloadable": false, + "version": "1.6", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-22", + "size": 85000000, + "popularity": 60, + "images": { + "banner": "https://opengraph.githubassets.com/1/supertuxkart/stk-code", + "icon": null, + "screenshots": [ + "https://opengraph.githubassets.com/1/supertuxkart/stk-code" + ] + }, + "icon": null, + "page": "https://supertuxkart.net" + } +] diff --git a/tests/search_preview.py b/tests/search_preview.py new file mode 100644 index 0000000..cdd6bec --- /dev/null +++ b/tests/search_preview.py @@ -0,0 +1,58 @@ +"""Local store preview. No device access; installation progress is simulated. + +FRAME_APK_SEARCH_DEMO=1 python3 tests/search_preview.py +""" +import json +import os +from pathlib import Path +import sys +import tempfile +import time +from urllib.parse import urlparse +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import server +from apk_sources import _demo, _images, search + + +class Preview(server.Handler): + def do_GET(self): + path = urlparse(self.path).path + if path == '/api/android': + self.send_json({'apps': []}) + return + if path == '/api/android/reports': + self.send_json({'reports': [], 'shared': False}) + return + if path not in ('/', '/index.html', '/api/search', '/api/sources', '/api/sources/details', '/api/job', '/api/host') and not path.startswith('/source-image/'): + self.send_json({'error': 'Headset disconnected', 'offline': True}, 503) + return + super().do_GET() + + def do_POST(self): + if not self.local_request(): + return + if urlparse(self.path).path == '/api/sources/install': + body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0)))) + def work(report): + for percent in (12, 28, 43, 67, 89): + report('Downloading', percent) + time.sleep(2) + report('Installing', None) + time.sleep(3) + return {'package': 'org.preview.' + body['id'], 'message': 'Preview installation complete'} + self.send_json(server.start_job('Preview installation', work, progress=True)) + return + if urlparse(self.path).path == '/api/sources': + super().do_POST() + return + self.send_json({'error': 'Device access disabled in store preview'}, 403) + + +if __name__ == '__main__': + if os.environ.get('FRAME_APK_SEARCH_DEMO') != '1': + sys.exit('Set FRAME_APK_SEARCH_DEMO=1') + for name, url in json.loads((_demo.FIXTURES / 'artwork' / 'urls.json').read_text()).items(): + _images.remember(url, (_demo.FIXTURES / 'artwork' / name).read_bytes()) + with tempfile.TemporaryDirectory(prefix='frame-store-preview-') as tmp: + search.settings_path = lambda: Path(tmp) / 'enabled.json' + server.ThreadingHTTPServer(('127.0.0.1', 8795), Preview).serve_forever() diff --git a/tests/test_apk_artwork.py b/tests/test_apk_artwork.py new file mode 100644 index 0000000..b036f90 --- /dev/null +++ b/tests/test_apk_artwork.py @@ -0,0 +1,101 @@ +import http.client +import json +from pathlib import Path +import socket +import sys +import threading +import unittest +from unittest.mock import patch, Mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +from apk_sources import _images, search, SourceError +import server + +PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').read_bytes() + + +class ArtworkTests(unittest.TestCase): + def setUp(self): + with _images._lock: + _images._urls.clear() + _images._cache.clear() + + def test_only_registered_source_images_are_fetchable(self): + with patch.object(_images, 'fetch') as fetch: + with self.assertRaisesRegex(SourceError, 'Unknown artwork'): + _images.image('https://example.com/arbitrary.png') + fetch.assert_not_called() + entry = {'images': {'icon': 'https://example.com/icon.png', 'banner': 'file:///tmp/private', + 'screenshots': ['https://example.com/shot.png', 'javascript:alert(1)']}} + art = _images.artwork(entry) + self.assertTrue(art['icon'].startswith('/source-image/')) + self.assertIsNone(art['banner']) + self.assertEqual(len(art['screenshots']), 1) + with patch.object(_images, 'fetch', return_value=(PNG, 'image/png')) as fetch: + self.assertEqual(_images.image(art['icon'].split('/')[-1]), (PNG, 'image/png')) + _images.image(art['icon'].split('/')[-1]) + fetch.assert_called_once_with('https://example.com/icon.png') + + def test_rejects_credentials_ports_and_non_http(self): + for url in ['file:///tmp/a.png', 'data:image/png;base64,AAAA', 'http://user:pass@example.com/a.png', + 'http://example.com:22/a.png', 'https://example.com:bad/a.png', '//example.com/a.png']: + self.assertIsNone(_images.register(url), url) + + def test_blocks_private_loopback_and_mixed_dns_answers(self): + for ip in ['127.0.0.1', '10.0.0.1', '169.254.169.254', '::1', '192.168.1.1']: + with patch.object(socket, 'getaddrinfo', return_value=[(2,1,6,'',(ip,443))]), \ + patch.object(socket, 'create_connection') as connect: + with self.assertRaisesRegex(SourceError, 'Private network'): + _images.fetch('https://example.com/private.png') + connect.assert_not_called() + + def test_redirect_to_private_network_is_rejected(self): + response = Mock(status=302) + response.getheader.return_value = 'http://127.0.0.1/secret' + conn = Mock() + conn.getresponse.return_value = response + public = [(2,1,6,'',('93.184.216.34',80))] + private = [(2,1,6,'',('127.0.0.1',80))] + with patch.object(socket, 'getaddrinfo', side_effect=[public,private]), \ + patch.object(socket, 'create_connection') as connect, \ + patch.object(http.client, 'HTTPConnection', return_value=conn): + with self.assertRaisesRegex(SourceError, 'Private network'): + _images.fetch('http://example.com/a.png') + connect.assert_called_once_with(('93.184.216.34',80),timeout=10) + + def test_non_images_and_oversized_images_are_rejected(self): + with self.assertRaises(SourceError): + _images.remember('https://example.com/a.svg', b'') + with self.assertRaisesRegex(SourceError, 'too large'): + _images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE) + + def test_handles_are_bounded(self): + for i in range(4100): + _images.register('https://example.com/%d.png' % i) + self.assertEqual(len(_images._urls),4096) + + def test_plain_language_verdict_is_evidence_based(self): + self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame') + self.assertEqual(search.verdict({'min_sdk':24,'abis':[]})['label'], 'Ready to try on the Frame') + self.assertEqual(search.verdict({'min_sdk':24,'abis':[],'frame_tested':True})['label'], 'Works on the Frame') + self.assertIn('newer Android', search.verdict({'min_sdk':31})['label']) + self.assertIn('Meta Quest services', search.verdict({'requires_meta_services':True})['label']) + self.assertEqual(search.verdict({'engine':'VrApi'})['tone'],'blocked') + self.assertNotEqual(search.verdict({'frame_tested':True,'min_sdk':31})['tone'],'works') + + def test_image_endpoint_does_not_allow_arbitrary_urls(self): + httpd = server.ThreadingHTTPServer(('127.0.0.1',0),server.Handler) + threading.Thread(target=httpd.serve_forever,daemon=True).start() + try: + path = _images.register('https://example.com/app.png') + _images.remember('https://example.com/app.png',PNG) + for url, expected in [(path,200),('/source-image/unknown',404)]: + c=http.client.HTTPConnection('127.0.0.1',httpd.server_port) + c.request('GET',url) + r=c.getresponse();data=r.read();c.close() + self.assertEqual(r.status,expected) + if expected==200: + self.assertEqual(data,PNG) + self.assertEqual(r.getheader('Content-Type'),'image/png') + finally: + httpd.shutdown();httpd.server_close() diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py new file mode 100644 index 0000000..d5c1389 --- /dev/null +++ b/tests/test_apk_search.py @@ -0,0 +1,207 @@ +import http.client +import json +from pathlib import Path +import sys +import tempfile +import threading +import time +import types +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +from apk_sources import search, SourceError +import server + +ENTRIES = json.loads((Path(__file__).parent / 'fixtures/apk-search/entries.json').read_text()) + + +def fake(source_id='one', fn=None): + return types.SimpleNamespace(KIND=source_id, sources=lambda: [dict(id=source_id, name=source_id, + enabled=True, trust='official', builtin=True)], + search=fn or (lambda s, q, limit=50: [e for e in ENTRIES if e['source'] == source_id]), + details=lambda s, i: ENTRIES[0], + download=Mock(return_value={'apk': '/fake.apk', 'obb': []})) + + +class SettingsTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json') + p.start() + self.addCleanup(p.stop) + search._running.clear() + search._status.clear() + + +class SearchTests(SettingsTest): + def test_group_does_not_merge_distinct_or_unknown_packages(self): + result = search.group(ENTRIES) + self.assertEqual(len(result), 3) + self.assertEqual(sorted(len(a['offers']) for a in result), [1, 2, 2]) + same_name = dict(ENTRIES[0], package=None) + self.assertEqual(len(search.group(ENTRIES[:1] + [same_name])), 2) + + def test_rank_exact_and_installable_and_verified(self): + result = search.group(ENTRIES, 'Open Brush') + self.assertEqual(result[0]['package'], 'org.brush') + self.assertEqual(result[0]['offers'][0]['source'], 'one') + self.assertEqual(result[1]['package'], 'org.other') + self.assertEqual(len(search.group(ENTRIES, vr=False)), 1) + self.assertEqual(len(search.group(ENTRIES, installable=True)), 1) + + def test_fit_unknown_and_native_free_and_vr_hints(self): + self.assertIsNone(search.fit({})['installable']) + self.assertTrue(search.fit({'min_sdk': 23, 'abis': []})['installable']) + self.assertFalse(search.fit({'min_sdk': 23, 'abis': ['x86']})['installable']) + self.assertIn('Legacy VrApi', search.fit({'engine': 'VrApi'})['reasons'][0]) + + def test_timeout_and_failure_leave_other_results(self): + release = threading.Event() + calls = [] + def slow(s, q, limit=50): + calls.append(q) + release.wait(2) + return [] + mods = [fake(), fake('slow', slow), fake('broken', Mock(side_effect=SourceError('offline')))] + try: + with patch.object(search, 'modules', return_value=(mods, [])): + started = time.monotonic() + result = search.search(timeout=.03) + self.assertLess(time.monotonic() - started, .3) + self.assertTrue(result['apps']) + self.assertEqual([s['status'] for s in result['sources']], ['ok', 'timed out', 'error']) + search.search('other', timeout=.03) + self.assertEqual(calls, ['']) + finally: + release.set() + + def test_disable_persists_and_prevents_queries_and_installs(self): + mod = fake() + with patch.object(search, 'modules', return_value=([mod], [])): + search.set_enabled('one', False) + self.assertFalse(search.sources()[0]['enabled']) + self.assertEqual(search.search()['apps'], []) + with self.assertRaisesRegex(SourceError, 'disabled'): + search.install('one', 'brush') + + def test_install_passes_metadata_artwork_and_obb(self): + mod = fake() + mod.download.return_value.update(obb=['main.obb'], artwork={'hero': '/hero.png'}, icon_png=b'png') + def install(apk, name=None, icon_png=None, source=None, artwork=None): + self.assertEqual((apk, name, icon_png, source, artwork), + ('/fake.apk', 'Open Brush', b'png', 'one', {'hero': '/hero.png'})) + return {'package': 'org.brush'} + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(server.frame_android, 'install_obb', create=True) as obb: + # An actual function exposes the future signature for inspection. + with patch.object(server.frame_android, 'install', install): + search.install('one', 'brush', 1) + obb.assert_called_once_with('org.brush', ['main.obb']) + mod.download.assert_called_once_with(mod.sources()[0] | {'status': 'not searched'}, 'brush', version_code=1) + + def test_discovery_and_demo_are_opt_in(self): + module = fake() + with patch.object(search.pkgutil, 'iter_modules', return_value=[types.SimpleNamespace(name='example')]), \ + patch.object(search.importlib, 'import_module', return_value=module), \ + patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}): + self.assertEqual(search.modules(), ([module], [])) + with patch.object(search.pkgutil, 'iter_modules', return_value=[]), \ + patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}): + self.assertEqual(search.modules(), ([], [])) + + def test_newest_compatible_then_official_offer(self): + first = dict(ENTRIES[0], verified=False, trust='community') + newer = dict(first, source='new', version_code=2, updated='2026-01-01') + official = dict(newer, source='official', trust='official') + incompatible = dict(newer, source='blocked', verified=True, min_sdk=40) + offers = search.group([first, incompatible, newer, official])[0]['offers'] + self.assertEqual([e['source'] for e in offers], ['official', 'new', 'one', 'blocked']) + + def test_missing_obb_support_stops_before_install(self): + mod = fake() + mod.download.return_value['obb'] = ['main.obb'] + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(server.frame_android, 'install') as install, \ + patch.dict(server.frame_android.__dict__): + server.frame_android.__dict__.pop('install_obb', None) + with self.assertRaisesRegex(SourceError, 'OBB'): + search.install('one', 'brush') + install.assert_not_called() + + def test_listing_cannot_download(self): + mod = fake() + mod.details = lambda s, i: dict(ENTRIES[0], downloadable=False) + with patch.object(search, 'modules', return_value=([mod], [])): + with self.assertRaisesRegex(SourceError, 'developer page'): + search.install('one', 'brush') + mod.download.assert_not_called() + + +class EndpointTests(SettingsTest): + def setUp(self): + super().setUp() + self.mod = fake() + p = patch.object(search, 'modules', return_value=([self.mod], [])) + p.start() + self.addCleanup(p.stop) + self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler) + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + self.addCleanup(self.httpd.server_close) + self.addCleanup(self.httpd.shutdown) + + def request(self, method, path, body=None): + c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port) + c.request(method, path, json.dumps(body) if body is not None else None, + {'X-Frame-UI': '1', 'Content-Type': 'application/json'}) + r = c.getresponse() + result = r.status, json.loads(r.read()) + c.close() + return result + + def test_http_search_and_validation(self): + self.assertEqual(self.request('GET', '/api/sources')[1]['sources'][0]['id'], 'one') + self.assertTrue(self.request('GET', '/api/search?q=Brush&vr=true')[1]['apps']) + self.assertEqual(self.request('GET', '/api/search?vr=invalid')[0], 400) + self.assertEqual(self.request('GET', '/api/search?source=missing')[0], 400) + for body in ({'source': 'one'}, {'source': 'one', 'id': 'brush', 'version_code': True}): + self.assertEqual(self.request('POST', '/api/sources/install', body)[0], 400) + + def test_http_install_background_job(self): + with patch.object(server.frame_android, 'install', return_value={'package': 'org.brush'}) as install: + status, reply = self.request('POST', '/api/sources/install', {'source': 'one', 'id': 'brush'}) + self.assertEqual(status, 200) + for _ in range(100): + job = self.request('GET', '/api/job?id=' + reply['job'])[1] + if job['done']: + break + time.sleep(.01) + self.assertTrue(job['done']) + self.assertIsNone(job['error']) + install.assert_called_once_with('/fake.apk', name='Open Brush', icon_png=None, source='one') + + def test_details_endpoint_and_real_install_stages(self): + code, entry = self.request('GET', '/api/sources/details?source=one&id=brush') + self.assertEqual(code, 200) + self.assertEqual(entry['name'], 'Open Brush') + self.assertEqual(entry['verdict']['label'], 'Ready to try on the Frame') + self.assertIn('artwork', entry) + self.assertEqual(self.request('GET', '/api/sources/details?source=one')[0], 400) + stages = [] + with patch.object(server.frame_android, 'install', return_value={'package':'org.brush'}): + search.install('one', 'brush', progress=lambda stage, percent: stages.append((stage,percent))) + self.assertEqual(stages, [('Downloading',None),('Installing',None)]) + + def test_http_repository_management(self): + self.assertEqual(self.request('POST', '/api/sources', {'action': 'enable', 'source': 'one', 'enabled': False})[0], 200) + code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'}) + self.assertEqual(code, 400) + self.assertIn('not available', reply['error']) + mod = fake('fdroid') + mod.add_repo, mod.remove_repo, mod.set_enabled = Mock(), Mock(), Mock() + with patch.object(search, 'modules', return_value=([mod], [])): + self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})[0], 200) + mod.add_repo.assert_called_once_with(url='https://repo.example/repo', fingerprint=None, name=None) + self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200) + mod.remove_repo.assert_called_once_with(source_id='fdroid') diff --git a/ui/apk_sources/_demo.py b/ui/apk_sources/_demo.py new file mode 100644 index 0000000..b83760a --- /dev/null +++ b/ui/apk_sources/_demo.py @@ -0,0 +1,34 @@ +"""Opt-in local store fixtures: FRAME_APK_SEARCH_DEMO=1. Never downloads.""" +import json +from pathlib import Path +from apk_sources import SourceError + +KIND = 'demo' +FIXTURES = Path(__file__).resolve().parents[2] / 'tests' / 'fixtures' / 'apk-search' + + +def sources(): + return [{'id': 'demo-' + key, 'kind': KIND, 'name': name, 'enabled': True, + 'builtin': True, 'trust': trust, 'url': 'https://example.invalid'} + for key, name, trust in [('github', 'GitHub', 'official'), ('fdroid', 'F-Droid', 'community'), + ('sidequest', 'SideQuest', 'official'), ('itch', 'itch.io', 'community')]] + + +def search(source, query, limit=50): + if source['id'] == 'demo-itch': + raise SourceError('Source temporarily unavailable') + entries = json.loads((FIXTURES / 'store.json').read_text()) + if source['id'] == 'demo-sidequest': + entries = [e for e in entries if e['vr']] + if source['id'] == 'demo-fdroid': + entries = [e for e in entries if not e['vr']] + return [dict(e, verified=source['id'] in ('demo-github', 'demo-fdroid')) for e in entries + if query.lower() in (e['name'] + ' ' + e['summary']).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('Preview sources cannot download or install apps') diff --git a/ui/apk_sources/_images.py b/ui/apk_sources/_images.py new file mode 100644 index 0000000..df71483 --- /dev/null +++ b/ui/apk_sources/_images.py @@ -0,0 +1,122 @@ +"""Bounded artwork cache. Only source-provided URLs get opaque image handles.""" +from collections import OrderedDict +import http.client +import ipaddress +import secrets +import socket +import ssl +import threading +from urllib.parse import urljoin, urlsplit + +from apk_sources import SourceError + +_lock = threading.Lock() +_urls = OrderedDict() +_cache = OrderedDict() +MAX_IMAGE = 8 * 1024 * 1024 +MAX_CACHE = 64 * 1024 * 1024 + + +def valid_url(url): + try: + p = urlsplit(url) + return (p.scheme in ('https', 'http') and bool(p.hostname) and not p.username and not p.password + and p.port in (None, 80, 443) and len(url) <= 4096) + except (ValueError, TypeError): + return False + + +def register(url): + if not isinstance(url, str) or not valid_url(url): + return None + with _lock: + for token, known in _urls.items(): + if known == url: + _urls.move_to_end(token) + return '/source-image/' + token + token = secrets.token_urlsafe(24) + _urls[token] = url + while len(_urls) > 4096: + _urls.popitem(last=False) + return '/source-image/' + token + + +def artwork(entry): + images = entry.get('images') or {} + if not isinstance(images, dict): + images = {} + return {'icon': register(images.get('icon') or entry.get('icon')), + 'banner': register(images.get('banner')), + 'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]} + + +def image_type(data): + if data.startswith(b'\x89PNG\r\n\x1a\n'): + return 'image/png' + if data.startswith(b'\xff\xd8\xff'): + return 'image/jpeg' + if data.startswith((b'GIF87a', b'GIF89a')): + return 'image/gif' + if data[:4] == b'RIFF' and data[8:12] == b'WEBP': + return 'image/webp' + raise SourceError('Artwork is not a supported image') + + +def fetch(url, redirects=3): + if not valid_url(url): + raise SourceError('Artwork URL is not allowed') + p = urlsplit(url) + port = p.port or (443 if p.scheme == 'https' else 80) + addresses = socket.getaddrinfo(p.hostname, port, type=socket.SOCK_STREAM) + if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses): + raise SourceError('Private network artwork is not allowed') + # Connect to the checked IP, never resolve again between validation and use. + sock = socket.create_connection((addresses[0][4][0], port), timeout=10) + conn = http.client.HTTPConnection(p.hostname, port, timeout=10) + try: + if p.scheme == 'https': + sock = ssl.create_default_context().wrap_socket(sock, server_hostname=p.hostname) + conn.sock = sock + path = p.path or '/' + if p.query: + path += '?' + p.query + conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', 'Accept': 'image/png,image/jpeg,image/webp,image/gif'}) + response = conn.getresponse() + if response.status in (301, 302, 303, 307, 308) and redirects: + target = urljoin(url, response.getheader('Location', '')) + conn.close() + return fetch(target, redirects - 1) + if response.status != 200: + raise SourceError('Artwork is unavailable') + data = response.read(MAX_IMAGE + 1) + if len(data) > MAX_IMAGE: + raise SourceError('Artwork is too large') + return data, image_type(data) + finally: + conn.close() + sock.close() + + +def remember(url, data): + value = (data, image_type(data)) + if len(data) > MAX_IMAGE: + raise SourceError('Artwork is too large') + with _lock: + _cache[url] = value + _cache.move_to_end(url) + while sum(len(v[0]) for v in _cache.values()) > MAX_CACHE: + _cache.popitem(last=False) + return value + + +def image(token): + with _lock: + url = _urls.get(token) + if not url: + raise SourceError('Unknown artwork') + cached = _cache.get(url) + if cached: + _cache.move_to_end(url) + return cached + data, _ = fetch(url) + return remember(url, data) diff --git a/ui/apk_sources/search.py b/ui/apk_sources/search.py new file mode 100644 index 0000000..d1f3d18 --- /dev/null +++ b/ui/apk_sources/search.py @@ -0,0 +1,264 @@ +"""Parallel APK search and source selection. No device access during searches. + +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. +""" +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 verdict(entry): + 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: + return {'label': "Needs Meta Quest services; won't run", 'tone': 'blocked'} + if entry.get('min_sdk') is not None and entry['min_sdk'] > 30: + return {'label': 'Might not work: needs a newer Android than the Frame has', 'tone': 'blocked'} + if compatibility['installable'] is False: + 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 entry.get('frame_tested') is True: + return {'label': 'Works on the Frame', 'tone': 'works'} + if compatibility['installable'] is True: + return {'label': 'Ready to try on the Frame', 'tone': 'ready'} + return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'} + + +def decorate(entry): + from apk_sources import _images + return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry)) + + +def details(source_id, entry_id): + module, source = resolve(source_id) + return decorate(dict(module.details(source, entry_id), source=source_id, + source_name=source['name'], trust=source.get('trust'))) + + +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 = decorate(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, progress=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') + if progress: + progress('Downloading', None) + 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') + if progress: + progress('Installing', None) + result = frame_android.install(downloaded['apk'], **kwargs) + if obb: + frame_android.install_obb(result['package'], obb) + return result diff --git a/ui/index.html b/ui/index.html index 073e2b7..60796f6 100644 --- a/ui/index.html +++ b/ui/index.html @@ -121,7 +121,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 +282,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,10 +357,159 @@ 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; } } + /* ---- Discover: artwork first, details when you need them ---- */ + .sr-only { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; } + #androidLibrary { grid-template-columns:1fr; } + #androidLibrary .and-col { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr)); align-items:start; } + .store-tabs { display:flex; align-items:center; gap:8px; margin-bottom:20px; border-bottom:1px solid #ffffff12; padding-bottom:14px; } + .store-tabs > button { background:transparent; color:var(--muted); border-radius:6px; } + .store-tabs > button.on { color:var(--bright); background:#ffffff0e; } + .store-heading h1 { font-size:clamp(28px,3.2vw,38px); letter-spacing:-1.2px; line-height:1.15; margin:8px 0 10px; font-weight:700; color:var(--bright); } + .store-heading p, .sources-intro p { color:#a8b5c2; margin:0 0 24px; font-size:15px; } + .store-eyebrow { color:var(--link); font-size:11px; text-transform:uppercase; font-weight:700; letter-spacing:1.8px; } + .store-toolbar { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,400px); align-items:center; gap:32px; } + .store-toolbar .store-heading p { margin-bottom:0; } + @media(max-width:900px) { .store-toolbar { display:block; } .store-toolbar .store-heading p { margin-bottom:20px; } } + .store-search { display:flex; align-items:center; gap:12px; border:1px solid #ffffff12; background:#0e1723a6; border-radius:8px; padding:6px 8px 6px 16px; } + .store-search svg { width:21px; height:21px; color:var(--muted); flex:none; } + .store-search input[type=search] { background:transparent; padding:9px 0; font-size:15px; border:0; box-shadow:none; min-width:0; } + .store-search:focus-within { border-color:var(--link); } + .store-search button { border-radius:5px; } + .store-filters { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:16px 0 24px; } + .store-chips { display:flex; flex-wrap:wrap; gap:7px; } + .store-chips button { border-radius:20px; font-size:12px; height:34px; padding:0 14px; background:#ffffff07; color:#b5c0cb; border:1px solid #ffffff0c; } + .store-chips button[aria-pressed=true] { background:#235779; border-color:#5287a5; color:white; } + .store-source-select select { max-width:180px; padding:8px 26px 8px 10px; font:inherit; font-size:12px; border:0; border-radius:5px; color:var(--text); background:#1d2b3b; } + .store-notice { color:#a8b5c2; font-size:12px; margin:12px 0; } + body:has(.page.on #appStore:not([hidden])) #offline { padding:10px 16px; background:#ffffff06; box-shadow:inset 3px 0 0 var(--dim); } + body:has(.page.on #appStore:not([hidden])) #offline .s { display:none; } + .store-result-count { font-size:13px; color:var(--muted); margin-bottom:14px; } + .store-result-count:empty { display:none; } + .store-hero { position:relative; display:block; width:100%; height:270px; border:0; padding:0; border-radius:12px; overflow:hidden; text-align:left; margin:0 0 32px; isolation:isolate; } + .store-art { position:relative; display:block; overflow:hidden; background:radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%),transparent 70%),linear-gradient(140deg,hsl(var(--hue) 35% 18%),#101827); } + .store-art > img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; } + .art-initial { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:70px; font-weight:700; color:#ffffff45; letter-spacing:-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; } + .hero-copy .store-eyebrow { color:#c2e7ff; } + .hero-copy strong { color:white; font-size:38px; letter-spacing:-.8px; line-height:1.1; } + .hero-copy > span:not(.store-eyebrow):not(.hero-cta) { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; } + .hero-cta { color:white; display:inline-flex; align-items:center; background:#ffffff20; border:1px solid #ffffff40; border-radius:6px; padding:10px 18px; font-size:13px; margin-top:6px; } + .store-row { margin-bottom:34px; } + .store-row-head { display:flex; align-items:baseline; gap:12px; margin-bottom:14px; } + .store-row-head h2 { text-transform:none; letter-spacing:-.3px; font-size:21px; font-weight:600; color:var(--bright); margin:0; } + .store-row-head p { color:var(--muted); font-size:12px; margin:0; } + .store-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(225px,1fr)); gap:18px; } + .store-card { display:block; width:100%; height:auto; min-width:0; padding:0; border:1px solid #ffffff0b; border-radius:9px; overflow:hidden; text-align:left; background:#1b2938; color:var(--text); transition:transform .18s,box-shadow .18s; font-weight:400; } + .store-card:hover { transform:translateY(-4px); background:#223448; box-shadow:0 12px 28px #0004; } + .store-card .store-art { aspect-ratio:16/9; width:100%; } + .store-card-content { padding:15px 14px; display:block; } + .store-card-title { display:flex; align-items:center; gap:9px; margin-bottom:9px; min-width:0; } + .store-icon { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:8px; overflow:hidden; flex:none; background:hsl(var(--hue) 35% 28%); color:#fff; font-size:17px; position:relative; } + .store-icon img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; } + .store-card-title strong { font-size:15px; color:var(--bright); text-overflow:ellipsis; overflow:hidden; white-space:nowrap; } + .store-summary { display:block; color:#a8b5c2; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + .store-card-foot { display:flex; justify-content:space-between; align-items:center; gap:6px; margin-top:16px; font-size:11px; color:#a8b5c2; } + .store-card-foot b { color:#dbeecf; font-weight:500; } + .store-badges { display:flex; flex-wrap:wrap; gap:7px; } + .store-badge { font-size:11px; color:#b7d8ec; background:#6ec9ff14; padding:4px 9px; border-radius:5px; } + .store-badge.works { color:#b8e2ab; background:#70bb5418; } + .store-empty { display:flex; flex-direction:column; align-items:center; text-align:center; padding:58px 20px 70px; } + .store-empty svg { width:110px; height:90px; color:var(--link); margin-bottom:14px; } + .store-empty h2 { font-size:23px; color:var(--bright); margin:8px 0; text-transform:none; letter-spacing:0; } + .store-empty p { max-width:390px; color:var(--muted); margin:0 0 22px; } + .store-skeleton { aspect-ratio:1.2; background:linear-gradient(110deg,#233446 30%,#30455b 45%,#233446 60%); background-size:250% 100%; border-radius:9px; animation:store-shimmer 1.8s ease infinite; } + @keyframes store-shimmer { to { background-position:-150% 0; } } + .store-dialog { padding:0; border:1px solid #ffffff1a; border-radius:14px; background:#172331; color:var(--text); width:min(980px,calc(100vw - 40px)); max-width:none; max-height:calc(100dvh - 48px); overflow:auto; box-shadow:0 30px 100px #0009; } + .store-dialog::backdrop { background:#030a13c9; backdrop-filter:blur(7px); } + .store-close { position:sticky; float:right; top:14px; margin:14px 14px -54px 0; z-index:6; height:36px; width:36px; padding:0; border-radius:50%; background:#09131dcc; color:white; font-size:25px; line-height:1; border:1px solid #ffffff30; } + .detail-banner { height:195px; } + .detail-banner::after { content:""; position:absolute; inset:0; z-index:2; background:linear-gradient(0deg,#172331,transparent 70%); } + .detail-content { padding:0 30px 30px; position:relative; } + .detail-heading { display:flex; gap:16px; align-items:center; margin-top:-24px; position:relative; z-index:3; } + .detail-heading .store-icon { width:70px; height:70px; border-radius:15px; box-shadow:0 3px 18px #0004; font-size:32px; } + .detail-heading h1 { font-size:30px; color:white; letter-spacing:-.7px; margin:0 0 4px; } + .detail-heading p { margin:0; color:#a8b5c2; font-size:13px; } + .detail-content > .store-badges { margin:20px 0; } + .detail-layout { display:grid; grid-template-columns:minmax(0,1fr) 290px; gap:30px; margin-top:22px; } + .detail-layout h2 { text-transform:none; letter-spacing:0; font-size:17px; color:white; margin:0 0 14px; } + .detail-description { color:#b5c2ce; font-size:14px; line-height:1.75; white-space:pre-line; } + .detail-gallery { display:flex; gap:10px; overflow-x:auto; scroll-snap-type:x mandatory; margin:0 0 10px; } + .detail-gallery .store-art { flex:0 0 86%; aspect-ratio:16/9; border-radius:7px; scroll-snap-align:start; } + .detail-gallery .store-art img { object-fit:contain; background:#0b1420; } + .gallery-controls { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; font-size:11px; color:var(--muted); } + .gallery-controls button { height:30px; width:34px; padding:0; border-radius:5px; } + .detail-buy { align-self:start; background:#0e1926; padding:18px; border:1px solid #ffffff0a; border-radius:10px; } + .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.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; } + .offer-choice { display:flex; align-items:flex-start; gap:9px; padding:11px 9px; margin-top:8px; border:1px solid #ffffff12; border-radius:7px; cursor:pointer; } + .offer-choice:has(input:checked) { background:#1e3c52; border-color:#5386a6; } + .offer-choice input { margin:4px 0 0; accent-color:var(--blue); } + .offer-choice strong { font-size:12px; display:block; color:var(--bright); } + .offer-choice small { display:block; font-size:10px; color:#a8bbc9; margin-top:4px; } + .offer-mark { display:inline-flex; justify-content:center; align-items:center; height:26px; width:26px; flex:none; border-radius:6px; background:hsl(var(--hue) 30% 30%); color:white; font-size:11px; } + .detail-technical { border-top:1px solid #ffffff14; margin-top:25px; padding-top:16px; font-size:12px; color:var(--muted); } + .detail-technical summary { cursor:pointer; } + .detail-technical dl { display:grid; grid-template-columns:110px minmax(0,1fr); gap:6px; } + .detail-technical dd { margin:0; overflow-wrap:anywhere; } + .detail-buy .store-primary:disabled { opacity:1; color:#d6e8f7; background:#274f71; cursor:default; } + #detailProgress::-webkit-progress-bar { background:#283b4d; border-radius:3px; } + #detailProgress::-webkit-progress-value { background:var(--blue); border-radius:3px; } + #detailProgress { height:4px; width:100%; accent-color:var(--blue); display:block; margin-bottom:10px; } + .sources-dialog { width:min(560px,calc(100vw - 32px)); padding:30px; } + .sources-dialog .store-close { margin:-16px -16px -20px 0; top:0; } + .sources-intro h1 { font-size:28px; color:white; letter-spacing:-.7px; margin:9px 0; } + .source-setting { display:flex; align-items:center; gap:13px; padding:17px 0; border-top:1px solid #ffffff0c; } + .source-setting .offer-mark { width:42px; height:42px; font-size:16px; border-radius:10px; } + .source-setting .grow { flex:1; min-width:0; } + .store-dialog a { color:var(--link); } + .source-setting strong { display:block; font-size:14px; color:white; } + .source-setting p { color:#a3b4c3; font-size:12px; margin:3px 0; } + .source-setting small { color:var(--muted); font-size:10px; } + .source-toggle { appearance:none; width:38px; height:23px; flex:none; border-radius:15px; background:#465463; position:relative; cursor:pointer; margin:0; } + .source-toggle::after { content:""; position:absolute; top:3px; left:3px; width:17px; height:17px; background:#fff; border-radius:50%; transition:transform .15s; } + .source-toggle:checked { background:#208dca; } + .source-toggle:checked::after { transform:translateX(15px); } + .source-repo-form { display:grid; gap:12px; border-top:1px solid #ffffff14; margin-top:10px; padding-top:24px; } + .source-repo-form h2 { font-size:19px; letter-spacing:0; text-transform:none; color:white; margin:0; } + .source-repo-form p { color:var(--muted); font-size:12px; margin:0; } + .source-repo-form label { font-size:12px; } + .source-repo-form input { margin-top:7px; display:block; border-radius:6px; } + .source-repo-form details { font-size:11px; color:var(--muted); } + .source-repo-form button { justify-self:start; border-radius:6px; } + #appStore :focus-visible, .store-dialog :focus-visible, .store-tabs :focus-visible { outline:2px solid var(--link); outline-offset:4px; } + .store-search input:focus-visible { outline:0!important; } + @media(min-width:1400px) { .store-grid { grid-template-columns:repeat(5,minmax(0,1fr)); } } + @media(max-width:1000px) { .store-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } .detail-layout { grid-template-columns:minmax(0,1fr) 260px; gap:20px; } } + @media(max-width:680px) { + .store-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } + .store-tabs { margin-bottom:24px; gap:2px; } .store-tabs button { padding:0 10px; font-size:12px; } + .store-heading p { font-size:13px; max-width:320px; } + .store-filters { align-items:flex-start; flex-direction:column; gap:10px; margin-bottom:18px; } + .store-chips { gap:5px; } .store-chips button { font-size:11px; padding:0 10px; } + .store-hero { height:285px; border-radius:9px; margin-bottom:26px; } + .hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; } + .hero-copy > span:not(.store-eyebrow):not(.hero-cta) { font-size:13px; max-width:260px; } + .store-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; } + .store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; } + .store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; } + .store-card-foot { font-size:10px; margin-top:12px; } .store-summary { font-size:11px; } + .store-dialog { width:calc(100vw - 16px); max-height:calc(100dvh - 20px); border-radius:10px; } + .detail-banner { height:190px; } .detail-content { padding:0 18px 24px; } .detail-heading h1 { font-size:24px; } + .detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; } + .sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; } + } + @media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } } + @@ -521,9 +670,16 @@
-

Android apps

-
-
+
+
+
A little more possibility

Find your next favorite.

Great games and useful apps. All in one place, all free to explore.

+
+
+ +
+
+
+ -
-

Find apps

+
@@ -662,6 +818,18 @@ Ready Show ▴
+ + +
+
+ + +
Your app shelves

A world of apps.

Choose where you discover games and apps. We bring them together for you.

+
+

Add a source

Have a favorite F-Droid repository? Add its address to browse its apps here.

Verify this source (optional)
+

+
+

Try another APK version

@@ -1300,7 +1468,7 @@ function showJobs() { $("jobs").hidden = !jobsRunning; $("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`; } -async function runJob(label, key, start) { +async function runJob(label, key, start, onProgress = () => {}) { installing.add(key); jobsRunning++; showJobs(); log(label + "…"); try { @@ -1310,6 +1478,7 @@ async function runJob(label, key, start) { let j; try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; } catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; } + onProgress(j); if (!j.done) continue; if (j.error) throw new Error(j.error); log(j.message, "ok"); toast(j.message); @@ -1780,6 +1949,134 @@ document.body.addEventListener("click", async e => { }); loadOwned(); +// ---- Discover apps ---- +const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() }; +const storeName = s => String(s || "").replace(/ \(demo\)$/i, ""); +const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360; +const storeInitial = name => [...String(name || "?")][0].toUpperCase(); +const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/ /g, " "); +const storeOpenSource = o => o.open_source === true || /^(MIT|Apache|[AL]?GPL|BSD|MPL|ISC|CC0|Unlicense)/i.test(o.license || ""); +function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } } +function storeImage(path, alt, eager=false) { return /^\/source-image\/[\w-]+$/.test(path || "") ? `${esc(alt)}` : ""; } +function storeArt(o, cls="", eager=false, path=null, alt=null) { + const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0]; + return `${storeImage(image, alt || `${o.name} artwork`, eager)}`; +} +function storeIcon(o) { return ``; } +document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true); +function sourceMark(s) { const name=storeName(s); return ``; } +function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); } +async function loadSources() { + try { + const {sources} = await api("/api/sources"); sourceState.sources = sources; + $("sourceFilter").innerHTML = `` + sources.filter(s=>s.enabled).map(s=>``).join(""); + $("sourceFilter").value = sourceState.source; + $("sourcesList").innerHTML = sources.map(s=>`
${sourceMark(s.name)}
${esc(storeName(s.name))}

${esc(sourceDescription(s))}

${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}${s.builtin === false ? `` : ""}
`).join("") || `

Your sources will appear here when they are available.

`; + } catch (e) { $("sourceError").textContent = e.message; } +} +function storeCard(app) { + const i=sourceState.apps.indexOf(app), o=app.offers[0]; + return ``; +} +function storeRow(title, subtitle, apps) { return apps.length ? `

${esc(title)}

${esc(subtitle)}

${apps.slice(0,5).map(storeCard).join("")}
` : ""; } +function storeEmpty() { + return `

A little further to explore.

No apps found this time. Try a different name, clear your filters, or explore another source.

`; +} +function renderStore() { + const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable && !sourceState.source; + $("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`; + if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; } + if (!browsing) { $("searchGrid").innerHTML=`
${apps.map(storeCard).join("")}
`; return; } + const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0]; + const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works')); + const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked')); + const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0)); + const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||''))); + $("searchGrid").innerHTML=`` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent); +} +function storeChips() { $("searchFilters").querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",b.dataset.filter==='all' ? sourceState.vr===null && !sourceState.installable : b.dataset.filter==='installable' ? sourceState.installable : sourceState.vr===(b.dataset.filter==='vr'))); } +async function searchSources() { + const request=++sourceState.request; + $("searchGrid").setAttribute("aria-busy","true"); + $("searchGrid").innerHTML=`
${Array.from({length:8},()=>'').join("")}
`; + 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; + const unavailable=result.sources.filter(s=>s.status==='error'||s.status==='timed out').map(s=>storeName(s.name)); + $("sourceStatus").textContent=unavailable.length ? `${unavailable.join(" and ")} ${unavailable.length===1 ? "isn't" : "aren't"} responding right now. You can still explore the other sources.` : ""; + $("sourceStatus").hidden=!unavailable.length; + renderStore();await loadSources(); + } catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML=storeEmpty();$("sourceStatus").hidden=false;$("sourceStatus").textContent="We couldn't reach the app shelves. Try again in a moment."; } } + finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); } +} +function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; } +function detailBadges(o) { return `${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}${o.verdict?.tone==='works' ? '✓ Works on Frame' : ''}Free${storeOpenSource(o) ? 'Open source' : ''}`; } +function renderDetail() { + 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 ? `

Meet the creators ↗

` : ''}
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(); +} +function updateInstall() { + const o=currentOffer();if(!o || !$("detailInstall"))return; + const job=sourceState.jobs.get(`${o.source}:${o.id}`), blocked=o.verdict?.tone==='blocked', page=sourcePage(o.page), b=$("detailInstall"); + b.disabled=!!(job && !job.done) || (blocked && !job?.result) || (!o.downloadable && !page); + b.textContent=job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on Frame') : 'Get from the creator ↗'; + $("detailProgress").hidden=!job || job.done; + if(job?.percent!=null)$("detailProgress").value=job.percent;else $("detailProgress").removeAttribute("value"); + $("detailInstallNote").textContent=job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Keep exploring. We’ll take care of the installation.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.'); + $("detailOffers").querySelectorAll('input').forEach(input=>input.disabled=!!job&&!job.done); +} +async function openStoreApp(index) { + const app=sourceState.apps[index];if(!app)return; + sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0}; + renderDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0; + await loadOfferDetails(); +} +async function loadOfferDetails() { + const d=sourceState.detail,o=currentOffer(), request=++sourceState.detailRequest; + try { + const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id})); + if(request!==sourceState.detailRequest || d!==sourceState.detail)return; + const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]}; + d.app.offers[d.choice]={...o,...detail,artwork};renderDetail(); + } catch(e) { if(d===sourceState.detail)$("detailInstallNote").textContent="Some app details couldn't be refreshed. You can still view this listing."; } +} +async function installStoreApp() { + const o=currentOffer(), key=`${o.source}:${o.id}`, existing=sourceState.jobs.get(key); + if(existing?.result) { await act(`Open ${o.name}`,()=>api('/api/android',{action:'launch',package:existing.result.package}),$("detailInstall"));return; } + if(!o.downloadable) { const url=sourcePage(o.page);if(url)window.open(url,'_blank','noopener,noreferrer');return; } + if(existing&&!existing.done)return; + const state={stage:'Downloading',percent:null,done:false};sourceState.jobs.set(key,state);updateInstall(); + const result=await runJob(`Install ${o.name}`,key,()=>api('/api/sources/install',{source:o.source,id:o.id,version_code:o.version_code??null}),j=>{Object.assign(state,{stage:j.stage||'Downloading',percent:j.percent??null,error:j.error});updateInstall();}); + Object.assign(state,{done:true,result,error:result?null:state.error||"We couldn't finish the installation. Try again."});updateInstall(); + if(result)loadAndroid(); +} +async function changeSource(body) { + $("sourceError").textContent="Saving your sources…"; + 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(); } +} +let storeSearchTimer; +$("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);}; +$("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer);searchSources();}; +$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();}; +$("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();}; +$("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}}; +$("detailBody").onclick=e=>{if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});}; +$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderDetail();loadOfferDetails();}}; +$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();}; +$("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 f=new FormData(e.target);changeSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});}; +document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close()); +$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;}); +document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();}); +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: [] }; diff --git a/ui/server.py b/ui/server.py index 8000468..ab635f6 100755 --- a/ui/server.py +++ b/ui/server.py @@ -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 @@ -146,7 +147,7 @@ _jobs_lock = threading.Lock() _jobs = {} # id -> {"label", "done", "error", "message", "result", "time"} -def start_job(label, work): +def start_job(label, work, progress=False): """Run work() in the background. It returns a dict with a "message".""" now = time.time() with _jobs_lock: @@ -155,10 +156,14 @@ def start_job(label, work): job = secrets.token_hex(8) _jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now} + def report(stage, percent=None): + with _jobs_lock: + _jobs[job].update(stage=stage, percent=percent) + def run(): fields = {} try: - result = work() + result = work(report) if progress else work() fields = {"message": result.get("message") or f"{label}: done", "result": result} except (Failure, frame_android.FrameError) as e: fields = {"error": unreachable(str(e)) or str(e)} @@ -1227,7 +1232,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 report: apk_search.install(source, entry, version, report), progress=True) + + +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 +1399,23 @@ 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.startswith("/source-image/"): + from apk_sources import _images + try: + self.send_bytes(*_images.image(path.rsplit("/", 1)[-1])) + except Exception: + self.send_json({"error": "Artwork unavailable"}, 404) + elif path == "/api/sources/details": + args = parse_qs(url.query) + try: + self.send_json(apk_search.details(source_text({k: v[0] for k, v in args.items()}, "source"), + source_text({k: v[0] for k, v in args.items()}, "id"))) + except SourceError as e: + raise Failure(str(e), 400) + 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":