Redesign APK discovery as an artwork-led app store

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
saphidandGPT-6 Astra committed 2026-09-28 21:47:38 +10:00
1 parent f1b12a6eb6
commit 7844577be8
26 files changed
+884 -101

No files matched your search

+32
View File
@@ -30,3 +30,35 @@
- `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. - `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. - 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. - 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/<opaque handle>`; 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.
+19
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

+16
View File
@@ -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"
}
+182
View File
@@ -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"
}
]
+35 -4
View File
@@ -1,27 +1,58 @@
"""Local demo server for UI checks; rejects every device endpoint. """Local store preview. No device access; installation progress is simulated.
FRAME_APK_SEARCH_DEMO=1 python3 tests/search_preview.py FRAME_APK_SEARCH_DEMO=1 python3 tests/search_preview.py
""" """
import json
import os import os
from pathlib import Path from pathlib import Path
import sys import sys
import tempfile
import time
from urllib.parse import urlparse from urllib.parse import urlparse
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import server import server
from apk_sources import _demo, _images, search
class Preview(server.Handler): class Preview(server.Handler):
def do_GET(self): def do_GET(self):
if urlparse(self.path).path not in ('/', '/index.html', '/api/search', '/api/sources', '/api/job', '/api/host'): path = urlparse(self.path).path
self.send_json({'error': 'Device access disabled in search preview'}, 503) 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 return
super().do_GET() super().do_GET()
def do_POST(self): def do_POST(self):
self.send_json({'error': 'Writes disabled in search preview'}, 403) 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 __name__ == '__main__':
if os.environ.get('FRAME_APK_SEARCH_DEMO') != '1': if os.environ.get('FRAME_APK_SEARCH_DEMO') != '1':
sys.exit('Set 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() server.ThreadingHTTPServer(('127.0.0.1', 8795), Preview).serve_forever()
+101
View File
@@ -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'<svg onload="evil()"/>')
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()
+12
View File
@@ -181,6 +181,18 @@ class EndpointTests(SettingsTest):
self.assertIsNone(job['error']) self.assertIsNone(job['error'])
install.assert_called_once_with('/fake.apk', name='Open Brush', icon_png=None, source='one') 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): def test_http_repository_management(self):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'enable', 'source': 'one', 'enabled': False})[0], 200) 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'}) code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})
+15 -15
View File
@@ -1,29 +1,29 @@
"""Opt-in local UI fixtures: FRAME_APK_SEARCH_DEMO=1. Never downloads.""" """Opt-in local store fixtures: FRAME_APK_SEARCH_DEMO=1. Never downloads."""
import json
from pathlib import Path
from apk_sources import SourceError from apk_sources import SourceError
KIND = 'demo' KIND = 'demo'
FIXTURES = Path(__file__).resolve().parents[2] / 'tests' / 'fixtures' / 'apk-search'
def sources(): def sources():
return [{'id': 'demo-' + key, 'kind': KIND, 'name': name + ' (demo)', 'enabled': True, return [{'id': 'demo-' + key, 'kind': KIND, 'name': name, 'enabled': True,
'builtin': True, 'trust': trust, 'url': 'https://example.invalid'} 'builtin': True, 'trust': trust, 'url': 'https://example.invalid'}
for key, name, trust in [('fdroid', 'F-Droid', 'community'), for key, name, trust in [('github', 'GitHub', 'official'), ('fdroid', 'F-Droid', 'community'),
('sidequest', 'SideQuest', 'official'), ('itch', 'itch.io', 'community')]] ('sidequest', 'SideQuest', 'official'), ('itch', 'itch.io', 'community')]]
def search(source, query, limit=50): def search(source, query, limit=50):
if source['id'] == 'demo-itch': if source['id'] == 'demo-itch':
raise SourceError('Demo: source temporarily unavailable') raise SourceError('Source temporarily unavailable')
entries = [{'id': 'brush', 'package': 'org.demo.brush', 'name': 'Open Brush', entries = json.loads((FIXTURES / 'store.json').read_text())
'summary': 'Paint in VR — demonstration listing only', 'version': '2.32', if source['id'] == 'demo-sidequest':
'version_code': 232, 'min_sdk': 29, 'abis': ['arm64-v8a'], 'vr': True, entries = [e for e in entries if e['vr']]
'engine': 'Unity OpenXR', 'size': 125000000, 'updated': '2026-09-25', if source['id'] == 'demo-fdroid':
'verified': source['id'] == 'demo-fdroid', 'free': True, 'downloadable': True}, entries = [e for e in entries if not e['vr']]
{'id': 'radio', 'package': 'org.demo.radio', 'name': 'Pocket Radio', return [dict(e, verified=source['id'] in ('demo-github', 'demo-fdroid')) for e in entries
'summary': 'Listen in a flat Android panel', 'version': '1.0', 'version_code': 1, if query.lower() in (e['name'] + ' ' + e['summary']).lower()][:limit]
'min_sdk': 24, 'abis': [], 'vr': False, 'size': 3000000, 'free': True,
'downloadable': False, 'page': 'https://example.invalid/radio'}]
return [e for e in entries if query.lower() in e['name'].lower()][:limit]
def details(source, entry_id): def details(source, entry_id):
@@ -31,4 +31,4 @@ def details(source, entry_id):
def download(source, entry_id, version_code=None): def download(source, entry_id, version_code=None):
raise SourceError('Demo sources cannot download or install apps') raise SourceError('Preview sources cannot download or install apps')
+122
View File
@@ -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)
+41 -3
View File
@@ -1,4 +1,9 @@
"""Parallel APK search and source selection. No device access during searches.""" """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 importlib
import inspect import inspect
import json import json
@@ -127,6 +132,35 @@ def fit(entry):
'reasons': reasons} '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): def offer_rank(entry):
compatible = entry['fit']['installable'] is True compatible = entry['fit']['installable'] is True
return (entry.get('downloadable') is True and entry['fit']['installable'] is not False, return (entry.get('downloadable') is True and entry['fit']['installable'] is not False,
@@ -139,7 +173,7 @@ def offer_rank(entry):
def group(entries, query='', vr=None, installable=False): def group(entries, query='', vr=None, installable=False):
groups = {} groups = {}
for entry in entries: for entry in entries:
entry = dict(entry, fit=fit(entry)) entry = decorate(entry)
if vr is not None and entry.get('vr') is not vr: if vr is not None and entry.get('vr') is not vr:
continue continue
if installable and entry['fit']['installable'] is not True: if installable and entry['fit']['installable'] is not True:
@@ -204,7 +238,7 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
return {'apps': group(entries, query, vr, installable), 'sources': statuses} return {'apps': group(entries, query, vr, installable), 'sources': statuses}
def install(source_id, entry_id, version_code=None): def install(source_id, entry_id, version_code=None, progress=None):
import frame_android import frame_android
module, source = resolve(source_id) module, source = resolve(source_id)
if not source['enabled']: if not source['enabled']:
@@ -212,6 +246,8 @@ def install(source_id, entry_id, version_code=None):
entry = module.details(source, entry_id) entry = module.details(source, entry_id)
if entry.get('free') is not True or entry.get('downloadable') is not True: 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') 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) downloaded = module.download(source, entry_id, version_code=version_code)
obb = downloaded.get('obb') or entry.get('obb') or [] obb = downloaded.get('obb') or entry.get('obb') or []
if obb and not hasattr(frame_android, 'install_obb'): if obb and not hasattr(frame_android, 'install_obb'):
@@ -220,6 +256,8 @@ def install(source_id, entry_id, version_code=None):
'source': source['name']} 'source': source['name']}
if 'artwork' in inspect.signature(frame_android.install).parameters: if 'artwork' in inspect.signature(frame_android.install).parameters:
kwargs['artwork'] = downloaded.get('artwork') or entry.get('artwork') kwargs['artwork'] = downloaded.get('artwork') or entry.get('artwork')
if progress:
progress('Installing', None)
result = frame_android.install(downloaded['apk'], **kwargs) result = frame_android.install(downloaded['apk'], **kwargs)
if obb: if obb:
frame_android.install_obb(result['package'], obb) frame_android.install_obb(result['package'], obb)
+289 -76
View File
@@ -70,15 +70,6 @@
body.mobile .desk-only { display: none; } body.mobile .desk-only { display: none; }
.source-repo-form { display:grid; gap:10px; margin-top:14px; }
.source-repo-form input { display:block; width:100%; margin-top:5px; }
.source-offer { border-top:1px solid var(--line); padding-top:10px; margin-top:10px; }
#searchGrid .source-offer select { display:block; max-width:100%; width:100%; margin-top:5px; }
#searchFilters [aria-pressed=true], #sourceFilters [aria-pressed=true] { box-shadow:inset 0 0 0 1px var(--link); color:var(--link); }
#searchGrid h3, #searchGrid p { margin:4px 0; }
#searchGrid .row { margin-top:0; }
#searchGrid .source-offer { margin-top:auto; }
#sourceStatus { margin-bottom:10px; }
/* ---- pages: one per tab; the header nav switches between them ---- */ /* ---- pages: one per tab; the header nav switches between them ---- */
.page { display: flex; flex-direction: column; gap: 26px; } .page { display: flex; flex-direction: column; gap: 26px; }
.page:not(.on) { display: none; } .page:not(.on) { display: none; }
@@ -370,6 +361,155 @@
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */ /* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
body.typing nav { display: none; } 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; } }
</style> </style>
</head> </head>
<body> <body>
@@ -530,9 +670,16 @@
<div class="page" data-page="android"> <div class="page" data-page="android">
<section id="android"> <section id="android">
<div class="shelf-head"><h2>Android apps</h2><span class="count" id="andCount"></span><span class="spacer"></span> <div class="store-tabs" aria-label="Android sections"><button class="on" data-store-tab="browse" aria-pressed="true">Discover</button><button data-store-tab="library" aria-pressed="false">On your Frame <span id="andCount"></span></button><span class="spacer"></span><button id="sourcesOpen" class="small">Sources <span aria-hidden="true">⚙</span></button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
<button class="action small" id="repNew" title="Report how any APK worked on the Frame">+ Report an APK</button></div> <section id="appStore" aria-label="Discover apps">
<div class="and-grid"> <div class="store-toolbar"><div class="store-heading"><div><div class="store-eyebrow">A little more possibility</div><h1>Find your next favorite.</h1><p>Great games and useful apps. All in one place, all free to explore.</p></div></div>
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form></div>
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">For you</button><button data-filter="vr" aria-pressed="false">VR experiences</button><button data-filter="flat" aria-pressed="false">Flat-screen apps</button><button data-filter="installable" aria-pressed="false">Fits your Frame</button></div><label class="store-source-select"><span class="sr-only">Filter by source</span><select id="sourceFilter"><option value="">All sources</option></select></label></div>
<div id="sourceStatus" class="store-notice" role="status" aria-live="polite" hidden></div>
<div id="searchCount" class="store-result-count" aria-live="polite"></div>
<div id="searchGrid" aria-busy="false"></div>
</section>
<div class="and-grid" id="androidLibrary" hidden>
<div class="and-col"> <div class="and-col">
<div class="panel"> <div class="panel">
<div class="shelf-head"><h2>On the Frame</h2></div> <div class="shelf-head"><h2>On the Frame</h2></div>
@@ -583,22 +730,7 @@
reported, including ones not on F-Droid.</div> reported, including ones not on F-Droid.</div>
</div> </div>
</div> </div>
<div class="panel"> <div hidden>
<div class="shelf-head"><h2>Find apps</h2><span class="count" id="searchCount"></span></div>
<form id="sourceSearch" class="cat-tools"><input id="sourceQ" type="search" aria-label="Search every APK source" placeholder="Search every APK source…"><button class="small">Search</button></form>
<div class="vchips" id="searchFilters"><button class="chip" data-filter="vr" aria-pressed="false">VR</button><button class="chip" data-filter="flat" aria-pressed="false">Flat</button><button class="chip" data-filter="installable" aria-pressed="false">Installable</button></div>
<div class="vchips" id="sourceFilters"></div>
<div class="hint" id="sourceStatus" role="status" aria-live="polite"></div>
<div class="cat-grid" id="searchGrid"></div>
<details style="margin-top:20px" id="sourcesPanel"><summary>Sources</summary>
<div class="list" id="sourcesList"></div>
<form id="addSource" class="source-repo-form">
<label>Repository URL<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label>
<label>Fingerprint (optional)<input name="fingerprint" type="text" autocomplete="off"></label>
<button class="small">Add repository</button>
</form><div class="hint" id="sourceError" role="status"></div>
</details>
<div class="hint">Installable means Android 11 and arm64 requirements match. It does not guarantee the app runs. Unknown facts stay unknown.</div>
<div hidden> <div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div> <div hidden> <div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
<div class="cat-tools"> <div class="cat-tools">
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…"> <input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
@@ -686,6 +818,18 @@
<span class="last" id="lastLog">Ready</span> <span class="last" id="lastLog">Ready</span>
<span class="sub" id="drawerHint">Show ▴</span> <span class="sub" id="drawerHint">Show ▴</span>
</div> </div>
<dialog id="appDetail" class="store-dialog" aria-labelledby="detailTitle">
<button class="store-close" data-close="appDetail" aria-label="Close app details">×</button>
<div id="detailBody"></div>
</dialog>
<dialog id="sourcesDialog" class="store-dialog sources-dialog" aria-labelledby="sourcesTitle">
<button class="store-close" data-close="sourcesDialog" aria-label="Close source settings">×</button>
<div class="sources-intro"><div class="store-eyebrow">Your app shelves</div><h1 id="sourcesTitle">A world of apps.</h1><p>Choose where you discover games and apps. We bring them together for you.</p></div>
<div id="sourcesList"></div>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><button class="action">Add source</button></form>
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
</dialog>
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle"> <dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
<h2 id="apkAltTitle">Try another APK version</h2> <h2 id="apkAltTitle">Try another APK version</h2>
<p id="apkAltReason"></p> <p id="apkAltReason"></p>
@@ -1324,7 +1468,7 @@ function showJobs() {
$("jobs").hidden = !jobsRunning; $("jobs").hidden = !jobsRunning;
$("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`; $("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(); installing.add(key); jobsRunning++; showJobs();
log(label + "…"); log(label + "…");
try { try {
@@ -1334,6 +1478,7 @@ async function runJob(label, key, start) {
let j; let j;
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; } 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; } catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
onProgress(j);
if (!j.done) continue; if (!j.done) continue;
if (j.error) throw new Error(j.error); if (j.error) throw new Error(j.error);
log(j.message, "ok"); toast(j.message); log(j.message, "ok"); toast(j.message);
@@ -1804,64 +1949,132 @@ document.body.addEventListener("click", async e => {
}); });
loadOwned(); loadOwned();
// ---- All APK sources ---- // ---- Discover apps ----
const sourceState = { vr: null, installable: false, source: "", apps: [], request: 0 }; 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(/&nbsp;/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 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 || "") ? `<img src="${esc(path)}" alt="${esc(alt)}" loading="${eager ? "eager" : "lazy"}" decoding="async">` : ""; }
function storeArt(o, cls="", eager=false, path=null, alt=null) {
const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0];
return `<span class="store-art ${cls}" style="--hue:${storeHue(o.name)}"><span class="art-initial" aria-hidden="true">${esc(storeInitial(o.name))}</span>${storeImage(image, alt || `${o.name} artwork`, eager)}</span>`;
}
function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue(o.name)}" aria-hidden="true">${esc(storeInitial(o.name))}${storeImage(o.artwork?.icon, "")}</span>`; }
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 `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
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() { async function loadSources() {
try { try {
const { sources } = await api("/api/sources"); const {sources} = await api("/api/sources"); sourceState.sources = sources;
$("sourceFilters").innerHTML = `<button class="chip" data-source="" aria-pressed="${!sourceState.source}">All sources</button>` + sources.filter(s => s.enabled).map(s => `<button class="chip" data-source="${esc(s.id)}" aria-pressed="${sourceState.source === s.id}">${esc(s.name)}</button>`).join(""); $("sourceFilter").innerHTML = `<option value="">All sources</option>` + sources.filter(s=>s.enabled).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
$("sourcesList").innerHTML = sources.map(s => `<div class="item"><label class="grow"><input type="checkbox" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}> ${esc(s.name)} <span class="sub">${esc(s.trust || "unknown")} · ${esc(s.status || "not searched")}</span></label>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}">Remove</button>` : ""}</div>`).join("") || `<div class="sub">No source modules are available in this build.</div>`; $("sourceFilter").value = sourceState.source;
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</small>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
} catch (e) { $("sourceError").textContent = e.message; } } catch (e) { $("sourceError").textContent = e.message; }
} }
function storeCard(app) {
const i=sourceState.apps.indexOf(app), o=app.offers[0];
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span>${o.vr ? "VR experience" : o.vr === false ? "Flat-screen app" : "Android app"}</span><b>Free</b></span></span></button>`;
}
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p></div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
function storeEmpty() {
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>A little further to explore.</h2><p>No apps found this time. Try a different name, clear your filters, or explore another source.</p><button class="action" id="storeReset">Explore all apps</button></div>`;
}
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=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; 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=`<button class="store-hero" data-app="${apps.indexOf(featured)}" aria-label="Explore ${esc(featured.name)}">${storeArt(o,"",true)}<span class="hero-copy"><span class="store-eyebrow">Step into something new</span><strong>${esc(featured.name)}</strong><span>${esc(storeText(featured.summary))}</span><span class="hero-cta">Explore ${esc(featured.name)} <span aria-hidden="true">&nbsp; ↗</span></span></span></button>` + 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() { async function searchSources() {
const request = ++sourceState.request; const request=++sourceState.request;
$("sourceStatus").textContent = "Searching enabled sources…"; $("searchGrid").setAttribute("aria-busy","true");
const params = new URLSearchParams({ q: $("sourceQ").value, source: sourceState.source, installable: String(sourceState.installable) }); $("searchGrid").innerHTML=`<div class="store-grid" role="status" aria-label="Finding apps">${Array.from({length:8},()=>'<div class="store-skeleton" aria-hidden="true"></div>').join("")}</div>`;
if (sourceState.vr !== null) params.set("vr", String(sourceState.vr)); 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 { try {
const result = await api("/api/search?" + params); const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
if (request !== sourceState.request) return; sourceState.apps=result.apps;
sourceState.apps = result.apps; const unavailable=result.sources.filter(s=>s.status==='error'||s.status==='timed out').map(s=>storeName(s.name));
$("searchCount").textContent = `${result.apps.length} apps`; $("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").textContent = result.sources.map(s => `${s.name}: ${s.status === "error" ? s.error : s.status}`).join(" · ") || "No enabled sources"; $("sourceStatus").hidden=!unavailable.length;
$("searchGrid").innerHTML = result.apps.map((a, i) => `<article class="cat-card"><h3>${esc(a.name || "Unnamed app")}</h3><div class="sub">${esc(a.package || "Package unknown")}</div><p>${esc(a.summary || "")}</p><div class="row">${[...new Set(a.offers.map(o => o.source_name))].map(n => `<span class="tag">${esc(n)}</span>`).join(" ")}</div><div class="source-offer"><label>Get from <select aria-label="Source for ${esc(a.name)}" data-offer="${i}">${a.offers.map((o, j) => `<option value="${j}">${esc(o.source_name)} · ${esc(o.version || "Version unknown")} · ${o.verified === true ? "Verified" : "Unverified"}</option>`).join("")}</select></label><div id="offerInfo${i}"></div></div></article>`).join("") || `<div class="sub">No matching apps. Try another search or enable more sources.</div>`; renderStore();await loadSources();
result.apps.forEach((_, i) => renderOffer(i, 0)); } 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."; } }
await loadSources(); finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
} catch (e) { if (request === sourceState.request) $("sourceStatus").textContent = e.message; }
} }
function renderOffer(i, j) { function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
const o = sourceState.apps[i].offers[j], page = sourcePage(o.page), busy = installing.has(`${o.source}:${o.id}`); function detailBadges(o) { return `<span class="store-badge">${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}</span>${o.verdict?.tone==='works' ? '<span class="store-badge works">✓ Works on Frame</span>' : ''}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
$("offerInfo" + i).innerHTML = `<p class="sub">${esc(o.fit.verdict)} · API ${esc(o.min_sdk ?? "unknown")} · ${esc(o.abis == null ? "ABI unknown" : o.abis.join(", ") || "No native code")}<br>Version code ${esc(o.version_code ?? "unknown")} · ${o.size == null ? "Size unknown" : (o.size / 1048576).toFixed(1) + " MB"} · Updated ${esc(o.updated || "unknown")}</p><p class="hint">${o.fit.reasons.map(esc).join(" · ")}</p><div class="row">${o.downloadable && o.fit.installable !== false ? `<button class="small action" data-source-install="${i}" data-choice="${j}" ${busy ? "disabled" : ""}>${busy ? "Downloading and installing…" : "Install"}</button>` : ""}${page ? `<a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Developer page</a>` : ""}</div>`; 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)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Meet the creators ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
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();
} }
$("sourceSearch").onsubmit = e => { e.preventDefault(); searchSources(); };
$("searchFilters").onclick = e => {
const b = e.target.closest("[data-filter]"); if (!b) return;
if (b.dataset.filter === "installable") sourceState.installable = !sourceState.installable;
else { const value = b.dataset.filter === "vr"; sourceState.vr = sourceState.vr === value ? null : value; }
$("searchFilters").querySelectorAll("button").forEach(b => b.setAttribute("aria-pressed", b.dataset.filter === "installable" ? sourceState.installable : sourceState.vr === (b.dataset.filter === "vr")));
searchSources();
};
$("sourceFilters").onclick = e => { const b = e.target.closest("[data-source]"); if (b) { sourceState.source = b.dataset.source; searchSources(); } };
$("searchGrid").onchange = e => { if (e.target.matches("[data-offer]")) renderOffer(+e.target.dataset.offer, +e.target.value); };
$("searchGrid").onclick = async e => {
const b = e.target.closest("[data-source-install]"); if (!b) return;
const o = sourceState.apps[+b.dataset.sourceInstall].offers[+b.dataset.choice];
if (installing.has(`${o.source}:${o.id}`)) return;
b.disabled = true; b.textContent = "Downloading and installing…";
const result = await runJob(`Install ${o.name}`, `${o.source}:${o.id}`, () => api("/api/sources/install", {source: o.source, id: o.id, version_code: o.version_code ?? null}));
b.disabled = false; b.textContent = result ? "Installed" : "Retry install";
if (result) loadAndroid();
if (!b.isConnected) searchSources();
};
async function changeSource(body) { async function changeSource(body) {
$("sourceError").textContent = "Saving…"; $("sourceError").textContent="Saving your sources…";
try { const result = await api("/api/sources", body); $("sourceError").textContent = result.message; sourceState.source = ""; await loadSources(); await searchSources(); } 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(); } catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
} }
$("sourcesList").onchange = e => { if (e.target.matches("[data-enable]")) changeSource({action: "enable", source: e.target.dataset.enable, enabled: e.target.checked}); }; let storeSearchTimer;
$("sourcesList").onclick = e => { const b = e.target.closest("[data-remove]"); if (b) changeSource({action: "remove", source: b.dataset.remove}); }; $("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);};
$("addSource").onsubmit = e => { e.preventDefault(); const data = new FormData(e.target); changeSource({action: "add", url: data.get("url"), fingerprint: data.get("fingerprint")}); }; $("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); loadSources().then(searchSources);
// ---- Android apps (own Lepton instance each) + catalogue ---- // ---- Android apps (own Lepton instance each) + catalogue ----
+20 -3
View File
@@ -147,7 +147,7 @@ _jobs_lock = threading.Lock()
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"} _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".""" """Run work() in the background. It returns a dict with a "message"."""
now = time.time() now = time.time()
with _jobs_lock: with _jobs_lock:
@@ -156,10 +156,14 @@ def start_job(label, work):
job = secrets.token_hex(8) job = secrets.token_hex(8)
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now} _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(): def run():
fields = {} fields = {}
try: try:
result = work() result = work(report) if progress else work()
fields = {"message": result.get("message") or f"{label}: done", "result": result} fields = {"message": result.get("message") or f"{label}: done", "result": result}
except (Failure, frame_android.FrameError) as e: except (Failure, frame_android.FrameError) as e:
fields = {"error": unreachable(str(e)) or str(e)} fields = {"error": unreachable(str(e)) or str(e)}
@@ -1262,7 +1266,7 @@ def source_install(body):
raise SourceError('This source is disabled') raise SourceError('This source is disabled')
except SourceError as e: except SourceError as e:
raise Failure(str(e), 400) raise Failure(str(e), 400)
return start_job('Install ' + entry, lambda: apk_search.install(source, entry, version)) return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
def source_manage(body): def source_manage(body):
@@ -1395,6 +1399,19 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER, {"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
"computer": "Mac" if frame_host.MAC else "PC"}) "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": elif path == "/api/sources":
self.send_json({"sources": apk_search.sources()}) self.send_json({"sources": apk_search.sources()})
elif path == "/api/search": elif path == "/api/search":