Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 baaddd7622 Headset store: pending game data belongs to the installed package, in every view, and goes when the app does
From review round 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:48:00 +11:00
saphidandClaude Opus 5.5 07fc7ca3ff Headset store review fixes: ship ui/apk_sources in the Frame bundle; live installed state; GitHub installs remembered; game data its own job; no stuck loading; lookup waits count against the budget
The iPhone app's Frame bundle (also used by store-on-frame.sh) left out
ui/apk_sources, so ui/server.py couldn't start from it; a test now runs
the server's imports from an extracted bundle.

From review round 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:42:09 +11:00
saphidandClaude Opus 5.5 e885e9adba APK Store in the headset on the new store; images load first time; phone layout
- ui/store.html, scripts/store-on-frame.sh and the /store route (built in
  another thread and left uncommitted in the main checkout) join this branch,
  rebuilt on /api/search: the same sources, categories, phone-only rule and
  artwork as the Store, as a 1280x800 laser panel. A category rail with
  Installed, eight art cards a page, a featured VR app on Home, a detail
  sheet with screenshots, install progress and game data, the thumbstick
  turning pages and the on-screen keyboard.
- Artwork: the proxy allowed four name lookups at once and failed the rest
  immediately, so a page of cards lost most of its images until the retry.
  Public answers are now kept for five minutes and a lookup waits for a slot
  within its own budget.
- Phones: one-line shelf headings, a shorter store header, wider shelf
  cards, and a two-column library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:31:34 +11:00
saphidandClaude Opus 5.5 7aa7c55be9 Store categories: "does not require root" isn't a root tool
From review round 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:08:05 +11:00
saphidandClaude Opus 5.5 2554309c7d Store categories: searches always find phone-only apps; messengers and broad-only sources escape a phone side; name-only phone words; hardware the Frame lacks
From review round 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:01:44 +11:00
saphidandClaude Opus 5.5 adc1e44cf1 Store categories: keep useful apps with a phone side, ignore "no root", map IzzyOnDroid's Office; keep sidebar focus; never feature an app that won't run
From review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:47:10 +11:00
saphidandClaude Opus 5.5 7f46cd54a4 Android tab: a Store with Frame categories and a card Library
The store browsed F-Droid alphabetically, so it opened on proxies, port
checkers and root tools. It now groups apps by what a headset is good for
(VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web &
chat, Tools) from F-Droid's own categories, hides phone-only apps unless
asked, and browses each source's best-illustrated, newest apps first.

- Store: a category sidebar (chips on narrow windows), one shelf per
  category on the home page with no repeats, a featured VR app, and cards
  that show a banner, phone screenshots side by side, or the icon over its
  own blur instead of a letter, plus whether it fits the Frame.
- Library: its own view with art cards, Play, and a menu for Test, Report
  and Remove; filters for VR, flat and running apps; display settings and
  reports below. Artwork comes from the saved source indexes.
- Images: listings register three screenshots per app instead of twelve,
  handles are indexed by URL, and the bound is 16,384, so a large browse no
  longer forgets its own images before the page loads them.
- F-Droid cache version 3 keeps each app's categories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:37:27 +11:00
19 changed files with 1556 additions and 90 deletions

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ desktop or panels.
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
+57
View File
@@ -177,6 +177,63 @@ See
[compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md).
## Store and Library in Frame Control
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
*Social Network* is Web & chat).
Apps built around a phone are hidden from browsing unless **Show phone-only
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
a category that only makes sense in a phone's own system (launchers, keyboards,
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
fitness, alarms, system tools) unless a useful category says otherwise
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
part (a widget, a launcher, a battery tool), and apps that need hardware the
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
Without a search, each source offers its best 300 for the chosen category:
apps with a banner first, then with screenshots, then the newest.
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
The **APK Store** panel is the Store above, drawn for the laser: the same
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
its own floating panel:
```sh
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
scripts/store-on-frame.sh close
```
`open` copies the same bundle the iPhone app uses
(`ios/scripts/make_frame_bundle.py`) to
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
all run on the Frame, so the Mac can go away after `open`.
The page has a category rail (Home, the store's categories, Installed), eight
art cards a page, and a featured VR app on Home. It turns pages instead of
scrolling, because a tagged panel gets mouse clicks and a drag would select
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
the real sources): Home, categories, Installed, the detail sheet. **Still to
check in the headset:** that the panel opens and takes laser clicks, that an
install completes when run on the Frame, and that **Open** brings up the app's
panel.
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
+1 -1
View File
@@ -133,7 +133,7 @@ rejects invalid types, out-of-range levels and invalid mute states before SSH.
Other settings link to the existing forms and their validation, progress,
confirmations and reset behavior: per-instance Android resolution/density/text,
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
sources and artwork. Android display links reveal **On your Frame** before
sources and artwork. Android display links reveal the **Library** before
scrolling to the form. Mac streaming remains searchable on all platforms, with
its action shown only when the server is on macOS.
+1
View File
@@ -89,6 +89,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
| Script | Runs on | Purpose |
|---|---|---|
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
| `scripts/store-on-frame.sh` | Mac → Frame | Open the APK Store (Frame Control's Android store) as a panel in the headset; installs run on the Frame (**inferred**; see [docs/apks.md](apks.md)) |
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
+1 -1
View File
@@ -18,7 +18,7 @@ import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env zsh
# Mac-side: the APK Store in the headset. Frame Control's Android catalogue
# (the same rated F-Droid list, Install, Open and Remove) as its own floating
# panel on the Steam Frame, so you can browse and install apps from inside.
# See docs/apks.md → "The store in the headset".
#
# Frame Control's server runs on the Frame itself (FRAME_LOCAL=1, as the iPhone
# app runs it) on the Frame's loopback, and Chromium opens ui/store.html as a
# panel. Installs then download and verify the APK on the Frame; the Mac isn't
# needed after `open`.
#
# Usage:
# scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
# scripts/store-on-frame.sh close # close the panel and stop the server
# scripts/store-on-frame.sh status
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
HERE=${0:A:h}
ROOT=${HERE:h}
PORT=47812 # the Frame's 127.0.0.1 only
APPID=2001947812 # the panel's id (panel-on-frame.sh's range)
# Not ~/.cache/frame-control: the iPhone app prunes other versions there.
REMOTE=.local/share/frame-control/store-server
cmd=${1:-}
# Same bundle the iPhone app copies (ios/scripts/make_frame_bundle.py), so the
# server on the Frame has exactly the files the desktop app ships.
deploy() {
local tmp version
tmp=$(mktemp -d)
trap "rm -rf ${(q)tmp}" EXIT
version=$(python3 "$ROOT/ios/scripts/make_frame_bundle.py" "$tmp/bundle.tar.gz")
if ! ssh "$FRAME_ALIAS" "test -f $REMOTE/$version/ui/server.py"; then
print -u2 "==> Copying Frame Control ($version) to the Frame"
ssh "$FRAME_ALIAS" "set -e; mkdir -p $REMOTE; cd $REMOTE; rm -rf $version.tmp; mkdir $version.tmp
tar xzf - -C $version.tmp; rm -rf $version; mv $version.tmp $version" <"$tmp/bundle.tar.gz"
fi
# Older versions, unless a server is still running from one.
ssh "$FRAME_ALIAS" "cd $REMOTE && for d in */; do d=\${d%/}; [ \"\$d\" = $version ] && continue
pgrep -f \"\$PWD/\$d/\" >/dev/null && continue; rm -rf -- \"\$d\"; done"
print -r -- "$version"
}
# Runs on the Frame. Args: dir port appid key
OPEN=$(cat <<'EOF'
set -u
dir=$HOME/$1 port=$2 appid=$3 key=$4
export DISPLAY=:0 LC_ALL=C.UTF-8
unset WAYLAND_DISPLAY
xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1 || { echo "The headset isn't showing anything (gamescope's :0 isn't up). Wake it and try again." >&2; exit 2; }
if [ -x "$HOME/chromium-xr/chrome" ]; then
# Steam's overlay library crashes Chromium's zygote (docs/webxr-chromium.md).
browser=(env LD_PRELOAD= "$HOME/chromium-xr/chrome" --user-data-dir="$HOME/.local/share/frame-control/store")
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
browser=(flatpak run org.chromium.Chromium --user-data-dir="$HOME/.var/app/org.chromium.Chromium/frame-store")
else
echo "The store needs a Chromium on the Frame: Chromium XR (docs/webxr-chromium.md) or the org.chromium.Chromium Flatpak (scripts/install-apps.sh)." >&2
exit 3
fi
# A fresh server each time, so it runs this version with this key.
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null
# Match the browser by its profile flag and the server by its script path: this
# launcher's own command line holds the bundle path, so a looser pattern kills it.
pkill -f '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) ' || true
pkill -f '[s]tore-server/[^/ ]*/ui/server[.]py' || true
sleep 0.5
cd "$dir" || exit 1
FRAME_LOCAL=1 FRAME_UI_KEY=$key FRAME_DEVICE=headset setsid nohup python3 -I -u -B "$dir/ui/server.py" --port "$port" \
>/tmp/frame-store-server.log 2>&1 </dev/null &
echo $! > /tmp/frame-store-server.pid
up=
for _ in $(seq 1 40); do
curl -s -o /dev/null "http://127.0.0.1:$port/store" && { up=1; break; }
sleep 0.25
done
[ -n "$up" ] || { echo "Frame Control's server didn't start on the Frame:" >&2; tail -n 15 /tmp/frame-store-server.log >&2; exit 1; }
setsid nohup "${browser[@]}" --ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check \
--password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars \
--disable-features=Translate,MediaRouter --window-size=1280,800 \
"--app=http://127.0.0.1:$port/store?key=$key" >/tmp/frame-store.log 2>&1 </dev/null &
# Tag the window so it's its own panel (docs/panels.md).
for _ in $(seq 1 60); do
sleep 0.5
for win in $(xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}'); do
xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "APK Store" || continue
if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
echo "panel valve.steam.desktopgame.$appid window $win"
exit 0
fi
done
done
echo "Chromium started, but its window didn't appear within 30 s:" >&2
tail -n 15 /tmp/frame-store.log >&2
exit 1
EOF
)
case "$cmd" in
open)
version=$(deploy | tail -n 1)
key=$(openssl rand -hex 16)
ssh "$FRAME_ALIAS" bash -s -- "$REMOTE/$version" "$PORT" "$APPID" "$key" <<<"$OPEN"
;;
close)
ssh "$FRAME_ALIAS" 'pkill -f "[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) " || true
pkill -f "[s]tore-server/[^/ ]*/ui/server[.]py" || true
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null; rm -f /tmp/frame-store-server.pid; true'
;;
status)
ssh "$FRAME_ALIAS" "ls $REMOTE 2>/dev/null; curl -s -o /dev/null http://127.0.0.1:$PORT/store && echo 'server up' || echo 'server down'
pgrep -af '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) |[s]tore-server/[^/ ]*/ui/server[.]py' | head -n 1 | cut -c1-120"
;;
*) sed -n '2,15p' "$0"; exit 2 ;;
esac
+9 -3
View File
@@ -19,7 +19,9 @@ class ArtworkTests(unittest.TestCase):
def setUp(self):
with _images._lock:
_images._urls.clear()
_images._tokens.clear()
_images._cache.clear()
_images._dns.clear()
def test_only_registered_source_images_are_fetchable(self):
with patch.object(_images, 'fetch') as fetch:
@@ -71,9 +73,13 @@ class ArtworkTests(unittest.TestCase):
_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)
with patch.object(_images, 'MAX_TOKENS', 50):
first = _images.register('https://example.com/0.png')
for i in range(1, 60):
_images.register('https://example.com/%d.png' % i)
self.assertEqual((len(_images._urls), len(_images._tokens)), (50, 50))
self.assertNotEqual(_images.register('https://example.com/0.png'), first) # forgotten, so a new handle
self.assertEqual(_images.register('https://example.com/59.png'), _images.register('https://example.com/59.png'))
def test_plain_language_verdict_is_evidence_based(self):
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
+1 -1
View File
@@ -122,7 +122,7 @@ class SearchTests(SettingsTest):
break
time.sleep(.01)
self.assertTrue(arrived[1]['event'].wait(2))
self.assertEqual(arrived[1]['query'], ('second', 50))
self.assertEqual(arrived[1]['query'][:2], ('second', 50))
def test_set_enabled_does_not_hold_search_lock_in_source(self):
free = []
+14
View File
@@ -63,6 +63,20 @@ class Coverage(unittest.TestCase):
self.assertIn('*.js', ui['filter'])
self.assertIn('*.css', ui['filter'])
def test_bundle_runs_the_server_on_its_own(self):
# The iPhone app and scripts/store-on-frame.sh start ui/server.py from this bundle alone.
import io, tarfile, tempfile
spec = importlib.util.spec_from_file_location('frame_bundle_run', ROOT / 'ios/scripts/make_frame_bundle.py')
bundle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bundle)
with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=io.BytesIO(bundle.build()), mode='r:gz') as tar:
tar.extractall(tmp)
result = subprocess.run([sys.executable, '-I', '-B', '-c',
'import sys; sys.path.insert(0, "ui"); import server, apk_sources.search; '
'apk_sources.search.modules()[1] and sys.exit("source modules failed: %r" % apk_sources.search.modules()[1])'],
cwd=tmp, capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr[-2000:])
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
# Run the real aggregator with every external probe stubbed.
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
+5
View File
@@ -122,6 +122,11 @@ FIXTURES = ROOT / 'tests/fixtures/library'
class ArtworkTests(unittest.TestCase):
def setUp(self):
from apk_sources import _images
_images._dns.clear() # each test answers lookups its own way
self.addCleanup(_images._dns.clear)
def test_source_inputs_and_url(self):
from apk_sources import _images
data = (FIXTURES / 'icon.png').read_bytes()
+6
View File
@@ -88,6 +88,12 @@ class ServerGuards(unittest.TestCase):
self.assertIn(mime, headers['Content-Type'])
self.assertIn(marker, payload)
def test_store_page_served_with_anti_framing_headers(self):
status, headers, payload = self.request("GET", "/store")
self.assertEqual(status, 200)
self.assertEqual(headers["X-Frame-Options"], "DENY")
self.assertIn(b"<title>APK Store</title>", payload)
def test_foreign_host_rejected(self):
# DNS rebinding: a hostile name pointed at 127.0.0.1.
for path in ("/", "/api/status"):
+361
View File
@@ -0,0 +1,361 @@
"""The store's Frame categories (ui/apk_sources/_categories.py): which apps go where, which are
phone-only and hidden, and how the browse, search and library artwork use them. Offline."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, _categories, _images, fdroid, search # noqa: E402
import test_fdroid_sources as fixtures # noqa: E402 (a module, so its tests don't run twice)
FIXTURES, PIN, URL = fixtures.FIXTURES, fixtures.PIN, fixtures.URL
def kind(**entry):
return _categories.classify(dict({'name': 'App', 'summary': ''}, **entry))
class Classify(unittest.TestCase):
def test_vr_first_whatever_else_it_is(self):
self.assertEqual(kind(vr=True, categories=['Phone & SMS']), {'category': 'vr', 'phone_only': False})
def test_fine_categories_decide(self):
self.assertEqual(kind(categories=['Games', 'Puzzle Game'])['category'], 'games')
self.assertEqual(kind(categories=['Games', 'Emulator'])['category'], 'emulators')
# A chat app is in Multimedia too; the finer Social Network wins.
self.assertEqual(kind(categories=['Multimedia', 'Internet', 'Social Network'])['category'], 'web')
self.assertEqual(kind(categories=['Reading', 'Ebook Reader'])['category'], 'read')
self.assertEqual(kind(categories=['Multimedia', 'Podcast'])['category'], 'watch')
def test_broad_categories_alone(self):
self.assertEqual(kind(categories=['Multimedia']), {'category': 'watch', 'phone_only': False})
self.assertEqual(kind(categories=['Writing']), {'category': 'work', 'phone_only': False})
def test_phone_only(self):
for categories in (['System', 'Launcher'], ['Internet', 'VPN & Proxy'], ['Theming', 'Wallpaper'],
['Sports & Health'], ['Connectivity'], ['Time', 'Alarm Clock'], ['Habit Tracker']):
with self.subTest(categories=categories):
self.assertTrue(kind(categories=categories)['phone_only'])
self.assertTrue(kind(name='[ROOT] Night Light', categories=['Development'])['phone_only'])
self.assertTrue(kind(name='5G Proxy Client', summary='Routes traffic')['phone_only'])
def test_games_and_media_keep_phone_words(self):
# 'Battery' in a game's name or a widget in a music player doesn't hide a game or a player.
self.assertFalse(kind(name='Widget Wars', categories=['Strategy Game'])['phone_only'])
self.assertFalse(kind(name='Music', summary='with a home screen widget', categories=['Local Media Player'])['phone_only'])
def test_emulators_by_name_without_categories(self):
self.assertEqual(kind(name='RetroArch', summary='Frontend for emulators')['category'], 'emulators')
def test_unknown_is_neither_hidden_nor_guessed(self):
self.assertEqual(kind(name='Thing'), {'category': None, 'phone_only': False})
self.assertEqual(kind(categories=['Something New']), {'category': None, 'phone_only': False})
def test_matches(self):
phone = {'category': 'tools', 'phone_only': True}
self.assertFalse(_categories.matches(phone))
self.assertTrue(_categories.matches(phone, phone=True))
self.assertTrue(_categories.matches({'category': 'games', 'phone_only': False}, 'games'))
self.assertFalse(_categories.matches({'category': 'watch', 'phone_only': False}, 'games'))
def test_every_store_category_has_a_label(self):
self.assertEqual({c for c, _ in _categories.CATEGORIES}, _categories.IDS)
targets = set(_categories.FINE) | {c for c in _categories.BROAD.values() if c}
self.assertLessEqual(targets, _categories.IDS)
self.assertLessEqual(set(_categories.PRIORITY), _categories.IDS)
class FDroidCategories(unittest.TestCase):
setUp, fetch, add = fixtures.Repositories.setUp, fixtures.Repositories.fetch, fixtures.Repositories.add
def index(self, version, categories):
raw = json.loads((FIXTURES / ('artwork-' + version + '.json')).read_text())
if version == 'v2':
raw['packages']['org.example.app']['metadata']['categories'] = categories
else:
raw['apps'][0]['categories'] = categories
path = self.root / ('index-' + version + '.json')
path.write_text(json.dumps(raw))
if version == 'v1':
fdroid._v1(path.read_bytes(), path)
return path
def test_categories_survive_v1_and_v2(self):
source = self.add()
for version in ('v1', 'v2'):
with self.subTest(version=version):
apps = fdroid._reduce(self.index(version, ['Games', 'Puzzle Game', 7]), source)
self.assertEqual(apps['org.example.app']['categories'], ['Games', 'Puzzle Game'])
def save(self, source, apps):
fdroid._write(fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json'),
{'version': fdroid.CACHE_VERSION, 'url': URL, 'fingerprint': PIN, 'apps': apps})
def app(self, pkg, name, updated, categories=(), banner=None):
return {'source': 'x', 'id': pkg, 'package': pkg, 'name': name, 'summary': '', 'updated': updated,
'categories': list(categories), 'images': {'icon': None, 'banner': banner, 'screenshots': []}}
def test_browse_shows_artwork_then_newest_and_filters_before_the_limit(self):
source = self.add()
self.save(source, {a['id']: a for a in (
self.app('a.old', 'Aaa', '2020-01-01', ['Puzzle Game']),
self.app('b.new', 'Bbb', '2026-01-01', ['Puzzle Game']),
self.app('c.art', 'Ccc', '2019-01-01', ['Puzzle Game'], banner=URL + 'b.png'),
self.app('d.sms', 'Ddd', '2026-02-01', ['Phone & SMS']))})
self.assertEqual([a['id'] for a in fdroid.search(source, '', limit=10)], ['c.art', 'd.sms', 'b.new', 'a.old'])
keep = lambda e: _categories.matches(dict(e, **_categories.classify(e)))
self.assertEqual([a['id'] for a in fdroid.search(source, '', limit=1, keep=keep)], ['c.art'])
self.assertEqual([a['id'] for a in fdroid.search(source, 'a', limit=10)], ['a.old', 'c.art']) # a query sorts by name
def test_lookup_reads_the_saved_index_only(self):
source = self.add()
self.assertEqual(fdroid.lookup(dict(source, id='never-loaded'), ['org.example.app']), {})
self.save(source, {'a.b': self.app('a.b', 'A', None)})
before = self.fetch_mock.call_count
self.assertEqual(list(fdroid.lookup(source, ['a.b', 'missing'])), ['a.b'])
self.assertEqual(self.fetch_mock.call_count, before)
self.assertEqual(fdroid.lookup(dict(source, enabled=False), ['a.b']), {})
def fake_source(entries, kind_='fake', lookup=None):
mod = types.SimpleNamespace(KIND=kind_)
mod.sources = lambda: [{'id': kind_, 'kind': kind_, 'name': kind_.title(), 'url': 'https://example.org/',
'builtin': True, 'enabled': True, 'trust': 'community'}]
mod.search = lambda source, query, limit=50: [dict(e) for e in entries][:limit]
mod.details = lambda source, entry_id: next(dict(e) for e in entries if e['id'] == entry_id)
if lookup is not None:
mod.lookup = lookup
return mod
ENTRY = {'source': 'fake', 'package': None, 'summary': '', 'free': True, 'downloadable': True,
'min_sdk': 24, 'abis': ['arm64-v8a'], 'vr': None}
class Search(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
p = patch.object(search, 'settings_path', return_value=Path(tmp.name) / 'sources.json')
p.start()
self.addCleanup(p.stop)
for state in (search._running, search._pending, search._status):
state.clear()
self.addCleanup(state.clear)
def run_search(self, entries, **kwargs):
with patch.object(search, 'modules', return_value=([fake_source(entries)], [])):
return search.search(**kwargs)
def test_category_and_phone_filters(self):
entries = [dict(ENTRY, id='g', name='Game', categories=['Puzzle Game']),
dict(ENTRY, id='s', name='Texts', categories=['Phone & SMS']),
dict(ENTRY, id='v', name='Video', categories=['Local Media Player'])]
result = self.run_search(entries)
self.assertEqual(sorted(a['name'] for a in result['apps']), ['Game', 'Video'])
self.assertEqual({a['name']: a['category'] for a in result['apps']}, {'Game': 'games', 'Video': 'watch'})
self.assertIn({'id': 'emulators', 'label': 'Emulators'}, result['categories'])
self.assertEqual([a['name'] for a in self.run_search(entries, category='games')['apps']], ['Game'])
self.assertEqual(len(self.run_search(entries, phone=True)['apps']), 3)
with self.assertRaisesRegex(SourceError, 'Unknown category'):
self.run_search(entries, category='phones')
def test_listing_offers_three_screenshots_details_all(self):
shots = ['https://example.org/%d.png' % i for i in range(8)]
entry = dict(ENTRY, id='g', name='Game', images={'icon': None, 'banner': None, 'screenshots': shots})
listed = self.run_search([entry])['apps'][0]['offers'][0]
self.assertEqual(len(listed['artwork']['screenshots']), 3)
self.assertEqual(len(search.decorate(entry)['artwork']['screenshots']), 8)
def test_browse_asks_sources_for_more_than_a_search(self):
seen = []
mod = fake_source([])
mod.search = lambda source, query, limit=50: seen.append((query, limit)) or []
with patch.object(search, 'modules', return_value=([mod], [])):
search.search('')
search.search('chess')
self.assertEqual(seen, [('', search.BROWSE_LIMIT), ('chess', 50)])
class LibraryArtwork(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
p = patch.object(search, 'settings_path', return_value=Path(tmp.name) / 'sources.json')
p.start()
self.addCleanup(p.stop)
def test_by_package_from_saved_indexes_then_by_name_for_curated_apps(self):
icon = {'images': {'icon': 'https://example.org/icon.png', 'banner': None, 'screenshots': []}}
repo = fake_source([], 'fdroid', lookup=lambda source, packages: {
p: dict(icon, name='x') for p in packages if p == 'org.antennapod'})
github = fake_source([dict(icon, id='icosa/open-brush', name='Open Brush')], 'github')
bare = fake_source([dict(icon, id='x', name='Elsewhere', package='org.elsewhere')], 'itch')
apps = [{'package': 'org.antennapod', 'label': 'AntennaPod'}, {'package': 'com.Icosa.OpenBrush', 'label': 'Open Brush'},
{'package': 'org.elsewhere', 'label': 'Elsewhere'}, {'package': 'org.none', 'label': 'None'}]
with patch.object(search, 'modules', return_value=([repo, github, bare], [])):
found = search.artwork_for(apps)
self.assertEqual(sorted(found), ['com.Icosa.OpenBrush', 'org.antennapod'])
self.assertTrue(found['org.antennapod']['icon'].startswith('/source-image/'))
def test_a_failing_source_is_skipped(self):
def broken(source, packages):
raise SourceError('no')
with patch.object(search, 'modules', return_value=([fake_source([], 'fdroid', lookup=broken)], [])):
self.assertEqual(search.artwork_for([{'package': 'a.b', 'label': 'A'}]), {})
class ImageHandles(unittest.TestCase):
def test_same_url_same_handle_without_scanning(self):
with _images._lock:
_images._urls.clear()
_images._tokens.clear()
a = _images.register('https://example.org/a.png')
self.assertEqual(_images.register('https://example.org/a.png'), a)
self.assertNotEqual(_images.register('https://example.org/b.png'), a)
class Route(unittest.TestCase):
def test_search_filters_are_checked(self):
import server
with patch.object(server.apk_search, 'search', return_value={'apps': []}) as run:
server.source_search('q=&category=games&phone=1')
self.assertEqual(run.call_args.kwargs['category'], 'games')
self.assertIs(run.call_args.kwargs['phone'], True)
server.source_search('q=chess')
self.assertEqual((run.call_args.kwargs['category'], run.call_args.kwargs['phone']), (None, False))
for bad in ('phone=maybe', 'category=' + 'x' * 41):
with self.subTest(bad=bad), self.assertRaises(server.Failure):
server.source_search(bad)
class ReviewCases(unittest.TestCase):
"""Real index entries (October 2026) that the first rules got wrong."""
def test_useful_apps_with_a_phone_side_stay(self):
for categories, category in ((['Connectivity', 'Email', 'Internet', 'Writing'], 'web'), # Thunderbird
(['Connectivity', 'Internet', 'Multimedia', 'Podcast'], 'watch'), # FocusPodcast
(['Contact', 'Email'], 'web'),
(['Cloud Storage & File Sync', 'File Manager', 'System'], 'tools')):
with self.subTest(categories=categories):
self.assertEqual(kind(categories=categories), {'category': category, 'phone_only': False})
def test_phone_side_alone_or_with_a_broad_category_is_hidden(self):
for categories in (['Internet', 'VPN & Proxy'], ['App Manager', 'Development', 'System'], ['Navigation']):
with self.subTest(categories=categories):
self.assertTrue(kind(categories=categories)['phone_only'])
self.assertTrue(kind(categories=['Launcher', 'Games'])['phone_only']) # system ones always
def test_no_root_needed_is_not_a_root_tool(self):
for summary in ('Linux on Android, no root required', 'Linux Without Root Access', 'SSH server without root',
'Works on non-rooted phones', 'Rootless and simple'):
with self.subTest(summary=summary):
self.assertFalse(kind(summary=summary, categories=['Development'])['phone_only'])
self.assertTrue(kind(summary='Needs root to tune the kernel', categories=['Development'])['phone_only'])
def test_izzyondroid_names(self):
self.assertEqual(kind(categories=['Office'])['category'], 'work')
self.assertTrue(kind(categories=['Xposed'])['phone_only'])
class ReviewCasesTwo(unittest.TestCase):
"""Round two of real entries (October 2026)."""
def test_messengers_with_phone_or_push_stay(self):
for categories in (['Internet', 'Messaging', 'Phone & SMS'], ['Internet', 'Messaging', 'Push'],
['Connectivity', 'Messaging', 'Phone & SMS', 'Voice & Video Chat']):
with self.subTest(categories=categories):
self.assertEqual(kind(categories=categories), {'category': 'web', 'phone_only': False})
def test_broad_only_sources_can_escape_a_phone_side(self):
self.assertEqual(kind(categories=['System', 'Development']), {'category': 'tools', 'phone_only': False})
self.assertEqual(kind(categories=['Connectivity', 'Internet']), {'category': 'web', 'phone_only': False})
self.assertTrue(kind(categories=['VPN & Proxy', 'Internet'])['phone_only']) # it has finer ones, none useful
def test_summary_features_dont_make_a_phone_app(self):
self.assertFalse(kind(name='OSS Weather', summary='Forecasts with home screen widgets', categories=['Weather'])['phone_only'])
self.assertFalse(kind(name='Digital Calculator', summary='Hardware keyboard support', categories=['Calculator', 'System'])['phone_only'])
self.assertTrue(kind(name='Clock Widget', categories=['Clock'])['phone_only']) # but the name says what it is
def test_saying_root_isnt_needed(self):
for summary in ('VNC server app that does not require root privileges', "Backups that don't need root access",
"It doesn't need root", 'Linux without root', 'Works on non-rooted phones', 'Rootless'):
with self.subTest(summary=summary):
self.assertFalse(kind(summary=summary, categories=['Remote Access'])['phone_only'])
for summary in ('Requires root', 'Needs root access to change the kernel', 'For rooted devices'):
with self.subTest(summary=summary):
self.assertTrue(kind(summary=summary, categories=['Remote Access'])['phone_only'])
def test_hardware_the_frame_lacks(self):
self.assertTrue(kind(name='IR Blaster Remote', categories=['Multimedia', 'Remote Controller', 'System'])['phone_only'])
self.assertTrue(kind(name='Wear Sync for Breezy', summary='Weather on your Wear OS watch', categories=['Connectivity', 'Weather'])['phone_only'])
class SearchFindsPhoneApps(unittest.TestCase):
setUp, run_search = Search.setUp, Search.run_search
def test_a_search_finds_what_browsing_hides(self):
entries = [dict(ENTRY, id='s', name='Texts', categories=['Phone & SMS'])]
self.assertEqual(self.run_search(entries)['apps'], [])
found = self.run_search(entries, query='texts')['apps']
self.assertEqual([(a['name'], a['offers'][0]['phone_only']) for a in found], [('Texts', True)])
class ArtworkLookups(unittest.TestCase):
"""A page of cards asks for dozens of images at once; they mustn't fail for want of a lookup slot."""
def setUp(self):
_images._dns.clear()
self.addCleanup(_images._dns.clear)
def test_public_answers_are_kept_private_ones_are_not(self):
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
with patch.object(_images.socket, 'getaddrinfo', return_value=public) as lookup:
for _ in range(3):
self.assertEqual(_images._resolve('example.org', 443, 5), public)
self.assertEqual(lookup.call_count, 1)
private = [(2, 1, 6, '', ('10.0.0.5', 443))]
with patch.object(_images.socket, 'getaddrinfo', return_value=private) as lookup:
_images._resolve('inside.example', 443, 5)
_images._resolve('inside.example', 443, 5)
self.assertEqual(lookup.call_count, 2)
def test_waiting_for_a_slot_counts_against_the_budget(self):
import threading, time
gate = threading.Event()
for _ in range(4): # every slot busy for the whole test
_images._resolvers.acquire()
threading.Timer(0.06, lambda: [_images._resolvers.release() for _ in range(4)]).start()
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(2) and []):
start = time.monotonic()
with self.assertRaises(SourceError):
_images._resolve('slow.example', 443, 0.1)
self.assertLess(time.monotonic() - start, 0.16)
gate.set()
def test_many_at_once_wait_for_a_slot(self):
import threading, time
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
def slow(*a, **k):
time.sleep(0.05)
return public
errors = []
def one(i):
try:
_images._resolve('host%d.example' % i, 443, 5)
except SourceError as e:
errors.append(e)
with patch.object(_images.socket, 'getaddrinfo', side_effect=slow):
threads = [threading.Thread(target=one, args=(i,)) for i in range(12)]
[t.start() for t in threads]
[t.join() for t in threads]
self.assertEqual(errors, [])
if __name__ == '__main__':
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
"""Store categories that make sense on a Steam Frame, and which apps only make sense on a phone.
Sources describe apps in their own terms (F-Droid's categories, a VR flag). The store groups
them for a headset instead: VR first, then games and the flat apps worth having in a panel
(video, reading, making things). Apps built around a phone's hardware or system (calls and
SMS, launchers, widgets, keyboards, fitness, root tools) are marked phone_only; the store
hides them unless asked, since Lepton can run them but they do nothing useful on the Frame.
"""
import re
# (id, label), in the order the store shows them.
CATEGORIES = (
('vr', 'VR'),
('games', 'Games'),
('emulators', 'Emulators'),
('watch', 'Watch & listen'),
('read', 'Read & learn'),
('create', 'Create'),
('work', 'Work'),
('web', 'Web & chat'),
('tools', 'Tools'),
)
IDS = {c for c, _ in CATEGORIES}
# F-Droid's category names -> a store category; None = only useful on a phone.
# F-Droid has broad categories ('Multimedia') and, since 2025, finer ones ('Podcast'); an app
# usually has both. A finer one decides, since 'Social Network' + 'Multimedia' is a chat app.
BROAD = {
'Games': 'games', 'Multimedia': 'watch', 'Reading': 'read', 'Science & Education': 'read',
'Graphics': 'create', 'Writing': 'work', 'Money': 'work', 'Time': 'work', 'Internet': 'web',
'Development': 'tools', 'Security': 'tools',
'Office': 'work', 'Food': 'read', # IzzyOnDroid's own names
'Connectivity': None, 'Navigation': None, 'Phone & SMS': None, 'Sports & Health': None,
'System': None, 'Theming': None,
}
FINE = {
'emulators': ('Emulator',),
'games': ('Action Game', 'Board Game', 'Card Game', 'Casual Game', 'Dice', 'Educational Game', 'Party Game',
'Platformer Game', 'Puzzle Game', 'Role-Playing Game', 'Shooter Game', 'Sport Game',
'Strategy Game', 'Visual Novel', 'Word Game'),
'watch': ('Local Media Player', 'Online Media Player', 'Podcast', 'Radio', 'Cast', 'Lyrics', 'Gallery',
'Ambient Sound'),
'read': ('Ebook Reader', 'Audiobook', 'News', 'Translation & Dictionary', 'Religion', 'Recipe Manager'),
'create': ('Draw', 'Music Practice Tool', 'Recorder'),
'web': ('Browser', 'Email', 'Messaging', 'Social Network', 'Forum', 'Voice & Video Chat', 'AI Chat',
'Bookmark'),
'work': ('Note', 'Task', 'Text Editor', 'Calendar & Agenda', 'Calculator', 'Unit Convertor',
'Finance Manager', 'Time Tracker', 'Schedule', 'Inventory'),
'tools': ('Code & Forge', 'Password & 2FA', 'File Manager', 'File Encryption & Vault', 'Text Encryption',
'Remote Access', 'Remote Controller', 'Cloud Storage & File Sync', 'Network Analyzer',
'File Transfer', 'Text to Speech', 'Speech Recognizer', 'Download', 'Game Helper', 'Weather'),
}
FINE_TARGET = {name: target for target, names in FINE.items() for name in names}
# Only make sense as part of a phone's own system: any one of these makes an app phone-only.
PHONE_SYSTEM = {
'Launcher', 'Keyboard & IME', 'Wallpaper', 'Icon Pack', 'Flashlight', 'Battery', 'Notification', 'Volume',
'Theming', 'Emergency Action', 'Xposed', 'Automation',
}
# Phone things that are often a side of a useful app (Thunderbird is in Contact, FluffyChat in
# Phone & SMS, Nextcloud in Connectivity, a file manager in System): phone-only unless a useful
# category says otherwise. A finer one when the source has them; a broad one when it only has
# broad ones (IzzyOnDroid often does).
PHONE_SIDE = {
'Phone & SMS', 'Push', 'Contact', 'Camera', 'OCR', 'Location Tracker & Sharer', 'Navigation',
'Public Transport', 'Workout', 'Diet', 'Medication', 'Health Manager', 'Alarm Clock', 'Pass Wallet',
'Wallet', 'VPN & Proxy', 'DNS & Hosts', 'Firewall', 'App Manager', 'App Store & Updater', 'System',
'Connectivity',
}
PHONE_CATEGORIES = PHONE_SYSTEM | PHONE_SIDE
# Fine categories that aren't useful on a headset either, but don't make an app phone-only on their own.
PHONE_LEANING = {'Clock', 'Timer', 'Stopwatch', 'Habit Tracker', 'Shopping List', 'Market & Price',
'Meditation', 'Mental Health', 'Sports & Health'}
# When an app has several, the one that best describes it on a headset wins.
PRIORITY = ('emulators', 'games', 'watch', 'read', 'create', 'web', 'work', 'tools')
EMULATOR = re.compile(r'\bemulat(or|ion|es)\b|\b(retroarch|ppsspp|dolphin|dosbox|scummvm|mame|mupen64|melonds|citra|duckstation|flycast)\b', re.I)
# What an app is, from its name: these are phone parts, whatever the categories say.
PHONE_NAME = re.compile(r'\b(launcher|home ?screen|widgets?|keyboard|wallpapers?|ringtones?|dialer|sms|mms|'
r'caller id|lock ?screen|status ?bar|step counter|pedometer|5g|4g|lte|hotspot|tethering|'
r'magisk|xposed|battery)\b|\[root\]', re.I)
# "Doesn't need root" isn't a root tool; taken out before HARDWARE looks for "needs root".
NOT_ROOT = re.compile(r"\b(no|non|without|not|never|doesn.?t (need|require)|does not (need|require)|don.?t (need|require))[- ]+(the )?root(ed|ing)?\b"
r"|\broot(less|[- ]free| (is )?not (required|needed))\b", re.I)
# Needs hardware or a device the Frame doesn't have, from the name or summary.
HARDWARE = re.compile(r'\b(infra-?red|ir (blaster|remote|transmitter)|wear ?os|smart ?watch(es)?|nfc|sim card|'
r'(requires?|needs?|for) (a )?root(ed)?( (access|device|phone))?)\b', re.I)
def classify(entry):
"""{'category': one of IDS or None, 'phone_only': bool} for a store entry."""
if entry.get('vr') is True:
return {'category': 'vr', 'phone_only': False}
name, summary = str(entry.get('name') or ''), str(entry.get('summary') or '')
names = [c for c in entry.get('categories') or () if isinstance(c, str)]
fine = {FINE_TARGET[c] for c in names if c in FINE_TARGET}
broad = {BROAD[c] for c in names if BROAD.get(c)}
category = next((c for c in PRIORITY if c in fine), None) or next((c for c in PRIORITY if c in broad), None)
if category is None and EMULATOR.search(name + ' ' + summary):
category = 'emulators'
only_broad = all(c in BROAD for c in names) # this source has no finer categories for it
useful = fine or (broad if only_broad else set())
phone = any(c in PHONE_SYSTEM for c in names) or (not useful and any(c in PHONE_SIDE for c in names))
if names and category is None and all(c in PHONE_CATEGORIES or c in PHONE_LEANING or BROAD.get(c, 1) is None
for c in names):
phone = True
if category not in ('games', 'emulators', 'watch', 'read', 'create'):
phone = phone or bool(PHONE_NAME.search(name)) or bool(HARDWARE.search(NOT_ROOT.sub(' ', name + ' ' + summary)))
return {'category': category, 'phone_only': phone}
def matches(entry, category=None, phone=False):
"""Whether a classified entry belongs in a browse for this category ('' or None = any). A search
shows phone-only apps too (search passes phone=True), so a wrong guess never hides an app."""
if entry.get('phone_only') and not phone:
return False
return not category or entry.get('category') == category
+38 -11
View File
@@ -12,7 +12,9 @@ from urllib.parse import urljoin, urlsplit
from apk_sources import SourceError
_lock = threading.Lock()
_urls = OrderedDict()
_urls = OrderedDict() # token -> URL, oldest first
_tokens = {} # URL -> token
MAX_TOKENS = 16384
_cache = OrderedDict()
MAX_IMAGE = 8 * 1024 * 1024
MAX_CACHE = 64 * 1024 * 1024
@@ -31,24 +33,24 @@ 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 = _tokens.get(url)
if token in _urls:
_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)
_urls[token], _tokens[url] = url, token
while len(_urls) > MAX_TOKENS: # a store browse lists a few thousand images
_tokens.pop(_urls.popitem(last=False)[1], None)
return '/source-image/' + token
def artwork(entry):
def artwork(entry, shots=12):
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]}
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:min(shots, 12)]) if path]}
def image_type(data):
@@ -71,12 +73,37 @@ def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
_resolvers = threading.BoundedSemaphore(4)
# A page of store cards asks for dozens of images from the same few hosts at once: their public
# addresses are kept for a few minutes, so most requests don't need a lookup (or a slot) at all.
_dns = {} # (host, port) -> (addresses, expires)
DNS_TTL = 300
def _resolve(host, port, timeout):
with _lock:
known = _dns.get((host, port))
if known and known[1] > time.monotonic():
return known[0]
found = _lookup(host, port, timeout)
try:
public = bool(found) and all(ipaddress.ip_address(a[4][0]).is_global for a in found)
except (TypeError, ValueError, IndexError):
public = False
if public: # private answers are refused by the caller, and never kept
with _lock:
_dns[(host, port)] = (found, time.monotonic() + DNS_TTL)
while len(_dns) > 256:
_dns.pop(next(iter(_dns)))
return found
def _lookup(host, port, timeout):
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
if not _resolvers.acquire(blocking=False):
# A request waits for a free slot within its own budget rather than failing at once.
started = time.monotonic()
if not _resolvers.acquire(timeout=max(0, min(timeout, 10))):
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
timeout -= time.monotonic() - started # the wait for a slot comes out of the same budget
found = {}
def run():
+29 -4
View File
@@ -26,7 +26,7 @@ from frame_apk_sign import _der_parts, _cert_key, der
from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
CACHE_VERSION = 2
CACHE_VERSION = 3 # 3: categories
TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
_LOCK = threading.RLock() # settings only; never held while downloading
_load_locks = {}
@@ -432,6 +432,7 @@ def _reduce(path, source):
name=_text(meta.get('name')) or pkg, summary=_summary(meta.get('summary')),
icon=images['icon'], images=images, developer=_text(meta.get('authorName')) or None,
page=meta.get('webSite') or source['url'], vr=None, free=True,
categories=[c for c in meta.get('categories') or [] if isinstance(c, str)][:8],
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
return result
@@ -450,6 +451,7 @@ def _v1(content, path):
localized = app.get('localized', {})
meta = {k: _text({locale: fields[k] for locale, fields in localized.items() if fields.get(k)}) or app.get(k)
for k in ('name', 'summary', 'license', 'webSite', 'authorName')}
meta['categories'] = app.get('categories') or []
for field in ('icon', 'featureGraphic', 'phoneScreenshots', 'sevenInchScreenshots'):
images = {}
for locale, fields in localized.items():
@@ -625,16 +627,39 @@ def set_enabled(source_id, enabled):
_write(_storage(), settings)
def search(source, query, limit=50):
def _showcase(a):
"""Browse order without a query: apps with real artwork first, then the most recently updated."""
images = a.get('images') or {}
return (not images.get('banner'), not images.get('screenshots'), not images.get('icon'),
''.join(chr(0x10ffff - ord(c)) for c in str(a.get('updated') or '')))
def search(source, query, limit=50, keep=None):
"""keep: optional filter applied before the limit (the store's category browse)."""
if not source.get('enabled', True):
return []
apps, _ = _load(source)
words = query.casefold().split()
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)
and (keep is None or keep(a))]
if words:
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
else:
found.sort(key=_showcase)
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
def lookup(source, packages):
"""Entries for these packages from the saved index only (never downloads); missing ones are left out."""
if not source.get('enabled', True):
return {}
found = _cached(source, frame_host.cache_dir('apk-sources', source['id'] + '.json'))
if not found:
return {}
apps = found[0]
return {p: {k: v for k, v in apps[p].items() if k != 'versions'} for p in packages if p in apps}
def details(source, entry_id):
if not source.get('enabled', True):
raise SourceError('repository is disabled')
+58 -14
View File
@@ -15,7 +15,7 @@ import time
import unicodedata
import frame_host
from apk_sources import SourceError, SourceLimited
from apk_sources import SourceError, SourceLimited, _categories as categories
_lock = threading.RLock()
_running = {}
@@ -23,6 +23,7 @@ _pending = {} # source id -> newest query waiting for the running one
_game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
_status = {}
TIMEOUT = 12
BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping
def modules():
@@ -158,9 +159,11 @@ def verdict(entry):
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
def decorate(entry):
def decorate(entry, shots=12):
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
from apk_sources import _images
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry))
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
**categories.classify(entry))
def details(source_id, entry_id):
@@ -178,10 +181,12 @@ def offer_rank(entry):
entry.get('trust') == 'official')
def group(entries, query='', vr=None, installable=False):
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
groups = {}
for entry in entries:
entry = decorate(entry)
entry = decorate(entry, shots=3)
if not categories.matches(entry, category, phone):
continue
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
continue
if installable and entry['fit']['installable'] is not True:
@@ -195,7 +200,7 @@ def group(entries, query='', vr=None, installable=False):
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})
'summary': best.get('summary'), 'category': best.get('category'), 'offers': offers})
q = normalise(query)
def browse(a): # a headset store: VR first, then apps with artwork, newest first
@@ -215,10 +220,10 @@ def group(entries, query='', vr=None, installable=False):
return result
def _launch(module, source, query, limit):
def _launch(module, source, query, limit, category=None, phone=False):
"""One search per source at a time; the newest different query runs next."""
key = source['id']
task = {'event': threading.Event(), 'query': (query, limit), 'started': time.monotonic(),
task = {'event': threading.Event(), 'query': (query, limit, category or None, bool(phone)), 'started': time.monotonic(),
'job': (module, source)}
with _lock:
old = _running.get(key)
@@ -237,13 +242,17 @@ def _launch(module, source, query, limit):
def _start(key, task):
module, source = task['job']
query, limit = task['query']
query, limit, category, phone = task['query']
kwargs = {}
if (category or not phone) and 'keep' in inspect.signature(module.search).parameters:
# Large sources filter before their limit, so a category browse isn't cut to the first few hundred.
kwargs['keep'] = lambda e: categories.matches(dict(e, **categories.classify(e)), category, phone)
def run():
queued = None
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]
for e in module.search(source, query, limit=limit, **kwargs) if e.get('free') is True]
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
except Exception as e:
task['error'] = str(e)
@@ -259,14 +268,20 @@ def _start(key, task):
threading.Thread(target=run, daemon=True).start()
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50, category=None, phone=False):
items, errors = registry()
if source and source not in [s['id'] for _, s in items]:
raise SourceError('Unknown source')
if category and category not in categories.IDS:
raise SourceError('Unknown category')
if not query.strip():
limit = max(limit, BROWSE_LIMIT)
else:
phone = True # the phone-only guess hides apps from browsing, never from a search for them
chosen = [(m, s) for m, s in items if s['enabled'] and (not source or s['id'] == source)]
# Page-only sources (SideQuest) can't be searched; offer a link to browse them instead.
elsewhere = [{'name': s['name'], 'url': s['url']} for m, s in chosen if s.get('page_only')]
tasks = [(s, _launch(m, s, query, limit)) for m, s in chosen if not s.get('page_only')]
tasks = [(s, _launch(m, s, query, limit, category, phone)) for m, s in chosen if not s.get('page_only')]
entries, statuses = [], list(errors)
for s, task in tasks:
status = {'id': s['id'], 'name': s['name']}
@@ -281,7 +296,8 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
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, 'elsewhere': elsewhere}
return {'apps': group(entries, query, vr, installable, category, phone), 'sources': statuses, 'elsewhere': elsewhere,
'categories': [{'id': c, 'label': label} for c, label in categories.CATEGORIES]}
def warm():
@@ -291,7 +307,35 @@ def warm():
items, _ = registry()
for m, s in items:
if s['enabled'] and not s.get('page_only'):
_launch(m, s, '', 50) # same as the first browse, so that search reuses it
_launch(m, s, '', BROWSE_LIMIT) # same as the first browse, so that search reuses it
def artwork_for(apps):
"""Store artwork for installed apps ({'package', 'label'} each), from saved indexes only:
by package where a source has one, else by name (curated GitHub apps). {package: artwork}."""
from apk_sources import _images
items, _ = registry()
wanted = {a['package']: normalise(a.get('label')) for a in apps if a.get('package')}
found = {}
for module, source in items:
if not source['enabled'] or source.get('page_only'):
continue
try:
if hasattr(module, 'lookup'):
hits = module.lookup(source, [p for p in wanted if p not in found])
elif module.KIND == 'github': # a small local list; its entries have names, not packages
names = {n: p for p, n in wanted.items() if n and p not in found}
hits = {names[normalise(e['name'])]: e for e in module.search(source, '', limit=100)
if normalise(e.get('name')) in names}
else:
continue
except (SourceError, OSError, ValueError, KeyError, TypeError):
continue
for package, entry in hits.items():
art = _images.artwork(entry)
if art['icon'] or art['banner'] or art['screenshots']:
found.setdefault(package, art)
return found
def install(source_id, entry_id, version_code=None, progress=None):
+239 -52
View File
@@ -558,6 +558,95 @@
.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; }
/* ---- Android: Store and Library ---- */
.store-tabs { border-bottom:0; padding-bottom:0; margin-bottom:26px; }
.store-switch { display:inline-flex; padding:4px; gap:4px; border-radius:12px; background:#0e1723; border:1px solid #ffffff10; }
.store-switch > button { display:inline-flex; align-items:center; gap:9px; height:42px; padding:0 22px; border-radius:9px; font-size:15px; font-weight:600; background:transparent; color:var(--muted); border:0; }
.store-switch > button svg { width:19px; height:19px; }
.store-switch > button.on { background:linear-gradient(180deg,#2a4763,#203a52); color:var(--bright); box-shadow:0 2px 10px #0006, inset 0 1px 0 #ffffff14; }
.store-switch > button:not(.on):hover { color:var(--text); background:#ffffff08; }
#andCount { font-size:12px; font-weight:600; color:#c6e2f5; background:#1a9fff2e; border-radius:10px; padding:1px 8px; }
#andCount:empty { display:none; }
.store-layout { display:grid; grid-template-columns:200px minmax(0,1fr); gap:32px; align-items:start; }
.store-side { position:sticky; top:88px; display:flex; flex-direction:column; gap:18px; }
.store-cats { display:flex; flex-direction:column; gap:2px; }
.store-cats button { display:flex; align-items:center; gap:11px; width:100%; height:38px; padding:0 12px; border-radius:8px; background:transparent; border:0; color:#b5c0cb; font-size:14px; font-weight:500; text-align:left; }
.store-cats button svg { width:18px; height:18px; flex:none; opacity:.85; }
.store-cats button:hover { background:#ffffff09; color:var(--bright); }
.store-cats button[aria-pressed=true] { background:#1a9fff26; color:var(--bright); box-shadow:inset 3px 0 0 var(--blue); }
.store-side-opts { display:flex; flex-direction:column; gap:10px; padding:14px 12px 0; border-top:1px solid #ffffff10; font-size:12px; color:#a8b5c2; }
.store-check { display:flex; align-items:center; gap:8px; cursor:pointer; }
.store-check input { accent-color:var(--blue); width:15px; height:15px; margin:0; }
.store-source-select { display:flex; flex-direction:column; gap:6px; }
.store-source-select select { max-width:none; width:100%; }
.store-main { min-width:0; }
.store-toolbar { margin-bottom:20px; }
@media(max-width:900px) {
.store-layout { display:block; }
.store-side { position:static; margin-bottom:18px; gap:12px; }
.store-cats { flex-direction:row; overflow-x:auto; scrollbar-width:none; gap:6px; }
.store-cats button { width:auto; flex:none; height:34px; border-radius:18px; background:#ffffff07; border:1px solid #ffffff0c; font-size:12px; }
.store-cats button[aria-pressed=true] { box-shadow:none; background:#235779; border-color:#5287a5; }
.store-side-opts { flex-direction:row; flex-wrap:wrap; align-items:center; gap:14px; border:0; padding:0; }
.store-source-select { flex-direction:row; align-items:center; }
}
.store-shelf { display:grid; grid-auto-flow:column; grid-auto-columns:minmax(220px,calc((100% - 54px)/4)); gap:18px; overflow-x:auto; scroll-snap-type:x mandatory; padding:4px 2px 14px; scrollbar-width:thin; }
.store-shelf > * { scroll-snap-align:start; }
.store-row-head .see-all { margin-left:auto; font-size:12px; height:28px; padding:0 12px; border-radius:14px; background:#ffffff0a; color:var(--link); border:0; }
.store-row-head .see-all:hover { background:#ffffff14; color:var(--bright); }
.store-card { display:flex; flex-direction:column; }
.store-card-content { display:flex; flex-direction:column; flex:1; min-width:0; width:100%; }
.store-card-title strong { min-width:0; }
.store-summary { white-space:normal; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; min-height:2.9em; line-height:1.45; }
.store-card-foot { margin-top:auto; padding-top:12px; }
.store-art.shots { display:flex; gap:6px; justify-content:center; padding:10px 10px 0; }
.store-art.shots > img { position:relative; inset:auto; width:auto; height:100%; max-width:31%; object-fit:cover; object-position:top; border-radius:6px 6px 0 0; box-shadow:0 4px 14px #0008; }
.store-art.icon-art > .art-backdrop { position:absolute; inset:-30%; width:160%; height:160%; object-fit:cover; filter:blur(28px) saturate(1.4) brightness(.55); }
.store-art.icon-art > .art-mark { position:absolute; z-index:2; left:50%; top:50%; width:34%; aspect-ratio:1; height:auto; transform:translate(-50%,-50%); border-radius:22%; box-shadow:0 8px 24px #0009; object-fit:cover; }
.store-art.icon-art .art-initial, .store-art.shots .art-initial { display:none; }
.store-cat { color:#a8b5c2; }
.store-fit { font-size:11px; font-weight:600; border-radius:4px; padding:2px 7px; }
.store-fit.works { color:#c3efb2; background:#59bf4029; }
.store-fit.ready { color:#bfe3fa; background:#1a9fff26; }
.store-fit.blocked { color:#f3b2a6; background:#d9412629; }
.store-vr { font-size:10px; font-weight:700; letter-spacing:.8px; color:#0e141b; background:#c2e7ff; border-radius:4px; padding:2px 6px; }
.store-art .store-vr { position:absolute; z-index:3; top:9px; left:9px; box-shadow:0 2px 8px #0007; }
.store-hero .hero-meta { display:flex; gap:8px; align-items:center; }
/* Library */
.lib-head { display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap; margin-bottom:22px; }
.lib-head h1 { font-size:clamp(26px,3vw,34px); letter-spacing:-1px; margin:6px 0 6px; color:var(--bright); font-weight:700; }
.lib-head p { color:#a8b5c2; margin:0; font-size:14px; max-width:620px; }
.lib-tools { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.lib-tools input[type=search] { width:220px; border-radius:8px; }
.lib-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:18px; margin-bottom:36px; }
.lib-card { position:relative; display:flex; flex-direction:column; border-radius:10px; background:#1b2938; border:1px solid #ffffff0b; transition:transform .18s,box-shadow .18s; }
.lib-card:hover { transform:translateY(-3px); box-shadow:0 12px 28px #0005; background:#203247; }
.lib-card .store-art { aspect-ratio:16/9; width:100%; border-radius:10px 10px 0 0; }
.lib-card:has(.lib-more[open]) { z-index:5; transform:none; }
.lib-card.running { border-color:#59bf4080; box-shadow:0 0 0 1px #59bf4040; }
.lib-running { position:absolute; z-index:3; top:9px; right:9px; font-size:11px; font-weight:600; color:#d6f5c8; background:#2d5a1fd9; border-radius:10px; padding:2px 9px; display:inline-flex; align-items:center; gap:6px; }
.lib-running::before { content:""; width:7px; height:7px; border-radius:50%; background:#8ef05f; box-shadow:0 0 6px #8ef05f; }
.lib-body { display:flex; gap:11px; align-items:center; padding:13px 14px 4px; min-width:0; }
.lib-body .store-icon { width:40px; height:40px; border-radius:10px; font-size:19px; }
.lib-body strong { display:block; color:var(--bright); font-size:15px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lib-body small { display:block; color:var(--muted); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lib-body > div { min-width:0; }
.lib-warn { margin:6px 14px 0; font-size:11px; color:#e8c27a; }
.lib-actions { display:flex; gap:8px; align-items:center; padding:12px 14px 14px; margin-top:auto; }
.lib-actions .play, .lib-actions .stop { flex:1; height:36px; font-size:14px; font-weight:600; border-radius:6px; }
.lib-more { position:relative; }
.lib-more > summary { list-style:none; display:flex; align-items:center; justify-content:center; width:38px; height:36px; border-radius:6px; background:var(--btn); color:var(--text); cursor:pointer; font-size:18px; line-height:1; }
.lib-more > summary::-webkit-details-marker { display:none; }
.lib-more > summary:hover, .lib-more[open] > summary { background:var(--btn-hi); }
.lib-menu { position:absolute; right:0; bottom:calc(100% + 6px); z-index:20; min-width:190px; padding:6px; border-radius:9px; background:#23364a; border:1px solid #ffffff1a; box-shadow:0 14px 34px #000a; display:flex; flex-direction:column; }
.lib-menu button { display:block; width:100%; text-align:left; height:auto; padding:8px 10px; border-radius:6px; background:transparent; font-size:13px; font-weight:400; color:var(--text); border:0; }
.lib-menu button small { display:block; color:var(--muted); font-size:11px; margin-top:2px; }
.lib-menu button:hover { background:#ffffff12; }
.lib-menu button.danger { color:#ff9c8a; }
.lib-empty { grid-column:1/-1; text-align:center; padding:48px 20px; border:1px dashed #ffffff1c; border-radius:12px; color:#a8b5c2; }
.lib-empty h2 { color:var(--bright); text-transform:none; letter-spacing:-.3px; font-size:20px; margin:0 0 8px; }
.lib-section { font-size:13px; text-transform:uppercase; letter-spacing:1.6px; color:var(--muted); margin:0 0 14px; font-weight:700; }
.lib-manage { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr)); gap:18px; align-items:start; }
.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; }
@@ -638,7 +727,14 @@
.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-row-head { display:flex; align-items:center; } .store-row-head h2 { font-size:18px; } .store-row-head p { display:none; }
.store-heading .store-eyebrow, .store-heading p { display:none; } .store-heading h1 { font-size:26px; margin:0 0 12px; }
.store-shelf { grid-auto-columns:minmax(200px,72%); gap:12px; }
.store-switch > button { height:38px; padding:0 14px; font-size:14px; }
.lib-head { margin-bottom:16px; } .lib-head p { font-size:13px; } .lib-tools, .lib-tools input[type=search] { width:100%; }
.lib-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.lib-body { padding:10px 10px 2px; } .lib-body .store-icon { width:30px; height:30px; font-size:14px; } .lib-body strong { font-size:13px; }
.lib-actions { padding:10px; gap:6px; } .lib-actions .play, .lib-actions .stop { height:34px; font-size:13px; }
.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; }
@@ -987,24 +1083,33 @@
<div class="page" data-page="android">
<section id="android">
<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>
<section id="appStore" aria-label="Discover apps">
<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>
<div class="store-tabs" aria-label="Android sections"><div class="store-switch"><button class="on" data-store-tab="browse" aria-pressed="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 7h16l-1.5 12.5a1 1 0 0 1-1 .9H6.5a1 1 0 0 1-1-.9z"/><path d="M8.5 9V6a3.5 3.5 0 0 1 7 0v3"/></svg>Store</button><button data-store-tab="library" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="3.5" y="4" width="7" height="7" rx="1.5"/><rect x="13.5" y="4" width="7" height="7" rx="1.5"/><rect x="3.5" y="14" width="7" height="7" rx="1.5"/><rect x="13.5" y="14" width="7" height="7" rx="1.5"/></svg>Library <span id="andCount"></span></button></div><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>
<section id="appStore" aria-label="Android store">
<div class="store-layout">
<div class="store-side" role="navigation" aria-label="Store categories">
<div id="storeCats" class="store-cats"></div>
<div class="store-side-opts">
<label class="store-check"><input type="checkbox" id="storeFits"> Only apps that fit the Frame</label>
<label class="store-check" title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="storePhone"> Show phone-only apps</label>
<label class="store-source-select"><span>Source</span><select id="sourceFilter"><option value="">All sources</option></select></label>
</div>
</div>
<div class="store-main">
<div class="store-toolbar"><div class="store-heading" id="storeHeading"><div class="store-eyebrow">Android apps for your Frame</div><h1>Store</h1><p>VR games, emulators, video and reading apps that work in a panel. Free, from open sources.</p></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="panel">
<div class="shelf-head"><h2>On the Frame</h2></div>
<div class="list" id="andApps"><div class="sub">Loading…</div></div>
<div class="hint">Each app runs in its own Lepton instance and keeps its data. It's in the Steam
library under its own name and opens in its own panel. Test launches it and records
whether it stays up; Report records how it worked for you.</div>
</div>
</div>
</section>
<div class="lib" id="androidLibrary" hidden>
<div class="lib-head"><div><div class="store-eyebrow">On your Frame</div><h1>Library</h1><p id="libSummary">Your Android apps. Each runs in its own Lepton instance, keeps its data, and is in the Steam library under its own name.</p></div>
<div class="lib-tools"><div class="store-chips" id="libFilters"><button data-lib="all" aria-pressed="true">All</button><button data-lib="vr" aria-pressed="false">VR</button><button data-lib="flat" aria-pressed="false">Flat</button><button data-lib="running" aria-pressed="false">Running</button></div>
<input id="libQ" type="search" aria-label="Filter your apps" placeholder="Filter your apps"></div></div>
<div class="lib-grid" id="andApps"><div class="sub">Loading…</div></div>
<h2 class="lib-section">Display and reports</h2>
<div class="lib-manage">
<section class="panel disp" id="display">
<div class="shelf-head"><h2>Android display</h2><span class="count">Lepton</span><span class="spacer"></span>
<button class="small" id="dispReload" title="Read the current values again">Refresh</button></div>
@@ -3199,7 +3304,22 @@ 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 sourceState = { vr: null, installable: false, source: "", category: "", phone: false, apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
// The store's categories (ui/apk_sources/_categories.py): what a Frame is good for, not F-Droid's phone-centred list.
const STORE_CATS = [
["", "Home", "Android apps for your Frame", "VR games, emulators, video and reading apps that work in a panel. Free, from open sources.", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
["vr", "VR", "Made for headsets", "OpenXR games and experiences that fill the room, not a panel.", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
["games", "Games", "Play in a panel", "Puzzle, strategy, card and action games that play well on a big floating screen.", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/><circle cx="15.5" cy="12" r=".6" fill="currentColor"/><circle cx="17" cy="13.6" r=".6" fill="currentColor"/>'],
["emulators", "Emulators", "Classic games, big screen", "RetroArch, Dolphin, PPSSPP and friends. Bring your own games.", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4"/><path d="M8 10.5h3M9.5 9v3"/><circle cx="15" cy="10.5" r=".8" fill="currentColor"/>'],
["watch", "Watch & listen", "Your own cinema", "Video players, streaming front-ends, music and podcasts.", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z" fill="currentColor"/><path d="M8 20h8"/>'],
["read", "Read & learn", "Books, news and more", "E-book and comic readers, news, dictionaries and learning apps.", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
["create", "Create", "Make something", "Drawing, photo editing, music and recording.", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
["work", "Work", "Get things done", "Notes, tasks, documents, calendars and calculators.", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
["web", "Web & chat", "Stay connected", "Browsers, email, messaging and social apps.", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
["tools", "Tools", "Handy utilities", "File managers, passwords, remote access and developer tools.", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
];
const storeCat = id => STORE_CATS.find(c => c[0] === (id || "")) || STORE_CATS[0];
const catIcon = id => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${storeCat(id)[4]}</svg>`;
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();
@@ -3207,12 +3327,30 @@ const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/&
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 || "") ? `<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 storeArt(o, cls="", eager=false, path=null, alt=null, extra="") {
// Best artwork first: a banner, else phone-style screenshots side by side, else the icon over its own blur.
const art = o.artwork || {}, label = alt || `${o.name} artwork`, initial = `<span class="art-initial" aria-hidden="true">${esc(storeInitial(o.name))}</span>`;
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
if (!path && !valid(art.banner) && (art.screenshots || []).some(valid)) {
const shots = art.screenshots.filter(valid).slice(0, 3);
return `<span class="store-art shots ${cls}" style="--hue:${storeHue(o.name)}" role="img" aria-label="${esc(label)}">${initial}${shots.map(u => storeImage(u, "", eager)).join("")}${extra}</span>`;
}
if (!path && !valid(art.banner) && valid(art.icon)) {
return `<span class="store-art icon-art ${cls}" style="--hue:${storeHue(o.name)}" role="img" aria-label="${esc(label)}">${initial}<img class="art-backdrop" src="${esc(art.icon)}" alt="" loading="${eager ? "eager" : "lazy"}" decoding="async"><img class="art-mark" src="${esc(art.icon)}" alt="" loading="${eager ? "eager" : "lazy"}" decoding="async">${extra}</span>`;
}
const image = path || art.banner || art.screenshots?.[0];
return `<span class="store-art ${cls}" style="--hue:${storeHue(o.name)}">${initial}${storeImage(image, label, eager)}${extra}</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);
document.addEventListener("error", e => {
if (!e.target.matches?.(".store-art img, .store-icon img")) return;
const img = e.target;
if (!img.dataset.retried) { // the proxy fetches from the source on first use, which can time out once
img.dataset.retried = "1"; setTimeout(() => { img.src = img.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return;
}
const art = img.closest(".icon-art, .shots"); img.remove();
if (art && !art.querySelector("img")) art.classList.remove("icon-art", "shots"); // show the initial again
}, 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() {
@@ -3223,32 +3361,50 @@ async function loadSources() {
$("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; }
}
const STORE_FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Won't run" };
function storeFit(o) { const t = o.verdict?.tone; return STORE_FIT[t] ? `<span class="store-fit ${t}" title="${esc(o.verdict.label)}">${STORE_FIT[t]}</span>` : ""; }
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>`;
const i=sourceState.apps.indexOf(app), o=app.offers[0], c=app.category || o.category;
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o,"",false,null,null,o.vr ? '<span class="store-vr">VR</span>' : "")}<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 class="store-cat">${esc(o.phone_only ? "Phone app" : c ? storeCat(c)[1] : o.vr === false ? "Flat-screen app" : "Android app")}</span>${storeFit(o) || "<b>Free</b>"}</span></span></button>`;
}
function storeRow(title, subtitle, apps, category) {
return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p>${category !== undefined ? `<button class="see-all" data-cat="${esc(category)}">See all ›</button>` : ""}</div><div class="store-shelf">${apps.slice(0,12).map(storeCard).join("")}</div></section>` : "";
}
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>`;
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, another category, or turn on phone-only apps.</p><button class="action" id="storeReset">Back to the store</button></div>`;
}
function renderCats() {
if (!$("storeCats").children.length) // built once, so a keyboard user keeps focus on the category they chose
$("storeCats").innerHTML = STORE_CATS.map(([id, label]) => `<button data-cat="${id}" aria-pressed="false">${catIcon(id)}<span>${esc(label)}</span></button>`).join("");
$("storeCats").querySelectorAll("[data-cat]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.cat === sourceState.category)));
const [, label, eyebrow, blurb] = storeCat(sourceState.category);
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Store")}</h1><p>${esc(blurb)}</p>`;
}
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()}”` : ""}`;
const apps=sourceState.apps, q=$("sourceQ").value.trim(), browsing=!q && !sourceState.category && !sourceState.installable && !sourceState.source;
renderCats();
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${q ? ` for “${q}”` : ""}${sourceState.category ? ` in ${storeCat(sourceState.category)[1]}` : ""}`;
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);
// Home: one featured VR app, then a shelf per category; each app appears once, blocked ones not at all.
const ok=apps.filter(a=>a.offers[0].verdict?.tone !== 'blocked'), shown=new Set();
const day=Math.floor(Date.now()/864e5), heroes=ok.filter(a=>a.offers[0].vr && a.offers[0].artwork?.banner);
const featured=heroes[day % (heroes.length || 1)] || ok.find(a=>a.offers[0].artwork?.banner) || ok[0];
if (!featured) { $("searchGrid").innerHTML=storeEmpty(); return; } // nothing here runs on the Frame
const o=featured.offers[0];
shown.add(featured);
const take=(list)=>{ const out=list.filter(a=>!shown.has(a)).slice(0,12); out.forEach(a=>shown.add(a)); return out; };
const rows=STORE_CATS.slice(1).map(([id,label,eyebrow])=>storeRow(label,eyebrow,take(ok.filter(a=>a.category===id)),id)).join("");
const recent=take([...ok].filter(a=>a.offers[0].updated && a.offers[0].artwork?.icon).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="hero-meta">${o.vr ? '<span class="store-vr">VR</span>' : ""}<span class="store-eyebrow">Featured</span></span><strong>${esc(featured.name)}</strong><span>${esc(storeText(featured.summary))}</span><span class="hero-cta">See ${esc(featured.name)} <span aria-hidden="true">&nbsp;›</span></span></span></button>` + rows + 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'))); }
function storeChips() { renderCats(); $("storeFits").checked = sourceState.installable; $("storePhone").checked = sourceState.phone; }
function storeCategory(id) { sourceState.category = id || ""; $("sourceQ").value = ""; storeChips(); searchSources(); document.getElementById("android")?.scrollIntoView({block:"start", behavior:"smooth"}); }
async function searchSources(quiet) {
const request=++sourceState.request;
$("searchGrid").setAttribute("aria-busy","true");
if(!quiet)$("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>`;
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable)});
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable),category:sourceState.category,phone:String(sourceState.phone)});
if(sourceState.vr!==null)params.set("vr",String(sourceState.vr));
try {
const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
@@ -3341,8 +3497,10 @@ 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();}};
$("storeCats").onclick=e=>{const b=e.target.closest('[data-cat]');if(b)storeCategory(b.dataset.cat);};
$("storeFits").onchange=e=>{sourceState.installable=e.target.checked;searchSources();};
$("storePhone").onchange=e=>{sourceState.phone=e.target.checked;searchSources();};
$("searchGrid").onclick=e=>{const c=e.target.closest('[data-cat]');if(c)return storeCategory(c.dataset.cat);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.phone=false;sourceState.category='';sourceState.source='';$('sourceFilter').value='';storeChips();searchSources();}};
$("detailBody").onclick=e=>{if(e.target.closest('[data-close]'))return $("appDetail").close();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;renderAppDetail();loadOfferDetails();}};
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
@@ -3371,6 +3529,7 @@ document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick =
showAndroidTab(button.dataset.storeTab);
if (button.dataset.storeTab === 'library') loadAndroid();
});
storeChips();
loadSources().then(()=>searchSources());
// ---- Android apps (own Lepton instance each) + catalogue ----
@@ -3378,27 +3537,55 @@ const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might w
const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] };
let androidApps = [];
const libState = { filter: "all", q: "" };
function libCard(a) {
const pkg = esc(a.package), name = a.label || a.package;
const o = { name, artwork: a.store_art || {} }; // the store's artwork for it, when a source lists it
const kind = a.vr && !a.flatscreen ? '<span class="store-vr">VR</span>' : "";
const version = String(a.version || "").replace(/^v/i, "");
const from = a.source && !/\.apk$/i.test(a.source) ? ` · ${a.source}` : "";
const issues = (a.vr_issues || []).filter(n => !/\(good\)\.?$/.test(n)); // frame_apk_vr.py also notes good news
const warn = issues.length ? `<div class="lib-warn" title="${esc(issues.join(" "))}">${esc(issues[0])}</div>` : "";
return `<article class="lib-card${a.running ? " running" : ""}" data-pkg="${pkg}">
${storeArt(o, "", false, null, `${name} artwork`, kind + (a.running ? '<span class="lib-running">Running</span>' : ""))}
<div class="lib-body">${storeIcon(o)}<div><strong title="${esc(name)}">${esc(name)}</strong><small title="${pkg}">${version ? "v" + esc(version) : esc(a.package)}${esc(from)}</small></div></div>${warn}
<div class="lib-actions">
${a.running ? `<button class="stop" data-and="stop" data-pkg="${pkg}">■ Stop</button>`
: `<button class="play" data-and="launch" data-pkg="${pkg}">▶ Play</button>`}
<details class="lib-more"><summary aria-label="More for ${esc(name)}" title="More">⋯</summary><div class="lib-menu">
${a.art_missing ? `<button data-and="refresh-art" data-pkg="${pkg}" data-name="${esc(name)}">Add artwork<small>Its Steam library tile has none yet</small></button>` : ""}
<button data-and="probe" data-pkg="${pkg}">Test<small>Launch it and record whether it stays up</small></button>
<button data-and="rate" data-pkg="${pkg}" data-ver="${esc(a.version)}" data-name="${esc(name)}" data-src="${esc(a.source || "")}">Report how it worked<small>Goes to the shared compatibility list</small></button>
<button class="danger" data-and="remove" data-pkg="${pkg}" data-name="${esc(name)}">Remove from the Frame</button>
</div></details>
</div></article>`;
}
function renderLibrary() {
const q = libState.q.trim().toLowerCase(), f = libState.filter;
const shown = androidApps.filter(a => (f === "all" || (f === "vr" ? a.vr && !a.flatscreen : f === "flat" ? !(a.vr && !a.flatscreen) : a.running))
&& (!q || `${a.label} ${a.package}`.toLowerCase().includes(q)));
const vr = androidApps.filter(a => a.vr && !a.flatscreen).length, running = androidApps.filter(a => a.running).length;
$("libSummary").textContent = androidApps.length
? `${androidApps.length} ${androidApps.length === 1 ? "app" : "apps"} on your Frame${vr ? `, ${vr} in VR` : ""}${running ? `; ${running} running now` : ""}. Each runs in its own Lepton instance, keeps its data, and is in the Steam library under its own name.`
: "Your Android apps. Each runs in its own Lepton instance, keeps its data, and is in the Steam library under its own name.";
$("libFilters").querySelectorAll("[data-lib]").forEach(b => b.setAttribute("aria-pressed", b.dataset.lib === f));
$("andApps").innerHTML = shown.length ? shown.map(libCard).join("")
: androidApps.length ? `<div class="lib-empty"><h2>Nothing matches</h2><p>Try another filter.</p></div>`
: `<div class="lib-empty"><h2>No Android apps yet</h2><p>Find VR games, emulators and video apps in the store, or drop an .apk on Send to Frame.</p><button class="action" data-store-go="browse">Open the store</button></div>`;
}
$("libFilters").onclick = e => { const b = e.target.closest("[data-lib]"); if (b) { libState.filter = b.dataset.lib; renderLibrary(); } };
$("libQ").oninput = e => { libState.q = e.target.value; renderLibrary(); };
$("andApps").addEventListener("click", e => {
if (e.target.closest("[data-store-go]")) showAndroidTab("browse");
if (e.target.closest(".lib-menu button")) e.target.closest("details").open = false;
});
document.addEventListener("click", e => { document.querySelectorAll(".lib-more[open]").forEach(d => { if (!d.contains(e.target)) d.open = false; }); });
async function loadAndroid() {
try {
androidApps = (await api("/api/android")).apps;
} catch (e) { return failed($("andApps"), e); }
$("andCount").textContent = androidApps.length ? `${androidApps.length} installed` : "";
$("andApps").innerHTML = androidApps.length ? androidApps.map(a => {
const c = cat.apps && cat.apps.find(x => x.p === a.package);
const icon = c && c.i ? `<img class="and-icon" src="${esc(c.i)}" alt="">` : `<div class="and-icon"></div>`;
return `<div class="item and-item">${icon}
<div class="grow"><div class="t">${esc(a.label)}</div>
<div class="s">${esc(a.package)} · v${esc(String(a.version || "").replace(/^v/i, ""))}</div></div>
${a.running ? `<span class="tag">Running</span>` : ""}
<div class="row and-btns">
${a.running ? `<button class="small" data-and="stop" data-pkg="${esc(a.package)}">Stop</button>`
: `<button class="play small" data-and="launch" data-pkg="${esc(a.package)}">▶</button>`}
${a.art_missing ? `<button class="small" data-and="refresh-art" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
<button class="small" data-and="probe" data-pkg="${esc(a.package)}" title="Launch it and record whether it stays up">Test</button>
<button class="small" data-and="rate" data-pkg="${esc(a.package)}" data-ver="${esc(a.version)}" data-name="${esc(a.label)}" data-src="${esc(a.source || "")}" title="Report how it worked">Report</button>
<button class="small danger" data-and="remove" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}">Remove</button>
</div></div>`;
}).join("") : `<div class="sub">No Android apps yet. Install one from the catalogue, or drop an .apk on Send to Frame.</div>`;
$("andCount").textContent = androidApps.length ? String(androidApps.length) : "";
renderLibrary();
const installed = new Set(androidApps.map(a => a.package));
document.querySelectorAll('#catGrid [data-and="install"]').forEach(b => {
if (installed.has(b.dataset.pkg)) b.outerHTML = `<span class="tag">Installed</span>`;
+14 -3
View File
@@ -2179,11 +2179,14 @@ def source_search(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'):
category, phone = args.get('category', [''])[0], args.get('phone', [''])[0]
if (len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0')
or phone not in ('', 'true', 'false', '1', '0') or len(category) > 40):
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'))
source=args.get('source', [None])[0], installable=installable in ('true', '1'),
category=category or None, phone=phone in ('true', '1'))
except SourceError as e:
raise Failure(str(e), 400)
@@ -2415,6 +2418,9 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/store":
# The Android store as a headset panel (scripts/store-on-frame.sh).
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/settings/artwork":
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
@@ -2464,7 +2470,12 @@ class Handler(BaseHTTPRequestHandler):
ensure_master()
apps = frame_android.list_apps()
backfill_art(apps=apps)
self.send_json({"apps": apps})
try: # store artwork for the library's cards, from saved indexes only
art = apk_search.artwork_for(apps)
except Exception:
art = {}
self.send_json({"apps": [dict(a, store_art=art[a['package']]) if a.get('package') in art else a
for a in apps]})
elif path == "/api/titles":
ensure_master()
titles_list = frame_titles.list_titles()
+489
View File
@@ -0,0 +1,489 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APK Store</title>
<!-- Frame Control's Android store as a panel in the headset (docs/apks.md →
"The store in the headset"). Served by ui/server.py running on the Frame
(FRAME_LOCAL=1), opened by scripts/store-on-frame.sh at 1280×800. The same
sources, categories and artwork as the Store in Frame Control (/api/search,
ui/apk_sources/_categories.py), drawn for the laser: big targets, pages
instead of drag-scrolling (the panel gets mouse clicks, so a drag would
select text), the thumbstick (a mouse wheel) turns pages, and an on-screen
keyboard for search. -->
<style>
:root {
--bg: #0f1722; --rail: #131d2a; --card: #1b2938; --card-hi: #23364a; --line: #ffffff14;
--text: #e3e8ee; --bright: #fff; --dim: #93a4ba; --blue: #1a9fff; --link: #66c0f4;
--good: #8ed36b; --bad: #ef7a64;
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
--play: linear-gradient(to right, #75b022 5%, #588a1b 95%);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; height: 100%; background: radial-gradient(ellipse at 70% -20%, #1d3a5a 0%, transparent 60%), var(--bg);
color: var(--text); font: 19px/1.35 "Motiva Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
overflow: hidden; user-select: none; -webkit-user-select: none; }
button { font: inherit; color: inherit; background: #ffffff0d; border: 1px solid var(--line); border-radius: 12px;
min-height: 52px; padding: 0 20px; cursor: pointer; }
button:hover { background: #ffffff1c; }
button:disabled { opacity: .45; cursor: default; }
button.primary { background: var(--action); border: 0; font-weight: 600; color: #fff; }
button.play { background: var(--play); border: 0; font-weight: 600; color: #fff; }
button.danger { color: var(--bad); }
svg { width: 22px; height: 22px; flex: none; }
.app { display: grid; grid-template-columns: 236px 1fr; height: 100%; }
/* Rail */
.rail { background: #0c131ccc; border-right: 1px solid var(--line); padding: 18px 12px; display: flex; flex-direction: column; gap: 3px; }
.rail h1 { font-size: 22px; margin: 2px 10px 12px; display: flex; align-items: center; gap: 10px; color: var(--bright); }
.rail h1 svg { width: 28px; height: 28px; color: var(--blue); }
.rail button { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 47px; text-align: left; border: 0;
background: transparent; color: #b5c0cb; font-size: 17px; font-weight: 500; padding: 0 12px; border-radius: 10px; }
.rail button:hover { background: #ffffff10; color: var(--bright); }
.rail button[aria-pressed="true"] { background: #1a9fff2e; color: var(--bright); box-shadow: inset 4px 0 0 var(--blue); }
.rail .sep { height: 1px; background: var(--line); margin: 6px 8px; }
.rail .n { margin-left: auto; font-size: 13px; color: #c6e2f5; background: #1a9fff2e; border-radius: 10px; padding: 1px 8px; }
.rail .n:empty { display: none; }
.rail label { margin-top: auto; display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 15px; color: var(--dim); cursor: pointer; }
.rail input { width: 22px; height: 22px; accent-color: var(--blue); margin: 0; }
/* Main */
main { display: flex; flex-direction: column; min-width: 0; padding: 18px 22px; gap: 12px; }
.bar { display: flex; gap: 12px; align-items: center; }
.search { flex: 1; display: flex; align-items: center; gap: 12px; background: #0c1520; border: 1px solid var(--line);
border-radius: 12px; min-height: 54px; padding: 0 18px; color: var(--dim); }
.search input { flex: 1; background: none; border: 0; color: var(--text); font: inherit; outline: none; min-width: 0; }
.search input::placeholder { color: var(--dim); }
.heading { display: flex; align-items: baseline; gap: 14px; min-height: 30px; }
.heading b { color: var(--bright); font-size: 25px; letter-spacing: -.4px; }
.heading span { color: var(--dim); font-size: 16px; }
.heading .note { margin-left: auto; font-size: 14px; color: var(--dim); max-width: 520px; text-align: right; }
.content { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
.grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 14px; }
.grid.one-row { grid-template-rows: minmax(0, 1fr); }
.card { display: flex; flex-direction: column; padding: 0; overflow: hidden; text-align: left; min-height: 0; border-radius: 14px;
background: var(--card); border: 1px solid var(--line); }
.card:hover { background: var(--card-hi); border-color: #66c0f480; }
.card .art { flex: 0 0 52%; }
.card .body { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px 12px; min-height: 0; flex: 1; width: 100%; }
.card .title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.card .title strong { font-size: 17px; color: var(--bright); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.card .sum { color: var(--dim); font-size: 14px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 13px; color: var(--dim); }
/* Artwork: a banner, else screenshots side by side, else the icon over its own blur, else an initial. */
.art { position: relative; display: block; overflow: hidden; width: 100%;
background: radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%), transparent 70%), linear-gradient(140deg, hsl(var(--hue) 35% 18%), #101827); }
.art > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.art .initial { position: absolute; inset: 0; display: grid; place-items: center; font-size: 64px; font-weight: 700; color: #ffffff45; }
.art.shots { display: flex; gap: 6px; justify-content: center; padding: 8px 8px 0; }
.art.shots > img { position: relative; inset: auto; width: auto; max-width: 31%; height: 100%; object-position: top; border-radius: 6px 6px 0 0; box-shadow: 0 4px 14px #0008; }
.art.iconart > .backdrop { position: absolute; inset: -30%; width: 160%; height: 160%; object-fit: cover; filter: blur(28px) saturate(1.4) brightness(.55); }
.art.iconart > .mark { position: absolute; left: 50%; top: 50%; width: 36%; height: auto; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 22%; box-shadow: 0 8px 24px #0009; object-fit: cover; }
.art.shots .initial, .art.iconart .initial { display: none; }
.art .vr { position: absolute; z-index: 2; top: 8px; left: 8px; }
.icon { position: relative; display: inline-grid; place-items: center; width: 38px; height: 38px; border-radius: 9px; overflow: hidden; flex: none;
background: hsl(var(--hue) 35% 28%); color: #fff; font-size: 18px; font-weight: 600; }
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.vr { font-size: 12px; font-weight: 700; letter-spacing: .8px; color: #0e141b; background: #c2e7ff; border-radius: 5px; padding: 2px 7px; }
.fit { font-size: 12px; font-weight: 600; border-radius: 5px; padding: 2px 8px; white-space: nowrap; }
.fit.works { color: #c3efb2; background: #59bf4029; } .fit.ready { color: #bfe3fa; background: #1a9fff26; } .fit.blocked { color: #f3b2a6; background: #d9412629; }
.tag { font-size: 12px; font-weight: 600; color: #d6f5c8; background: #2d5a1f; border-radius: 10px; padding: 2px 9px; white-space: nowrap; }
/* Home */
.hero { position: relative; flex: 0 0 42%; padding: 0; border: 0; border-radius: 16px; overflow: hidden; text-align: left; isolation: isolate; }
.hero .art { position: absolute; inset: 0; height: 100%; }
.hero::after { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(90deg, #081421f0 0%, #08142180 50%, #08142100 85%); }
.hero .copy { position: relative; z-index: 2; padding: 26px 30px; max-width: 560px; display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.hero .eyebrow { display: flex; gap: 10px; align-items: center; font-size: 13px; font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase; color: #c2e7ff; }
.hero strong { font-size: 38px; color: #fff; line-height: 1.05; letter-spacing: -.8px; }
.hero .blurb { color: #e0e8ef; font-size: 17px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.hero .cta { margin-top: 4px; background: #ffffff22; border: 1px solid #ffffff44; border-radius: 10px; padding: 9px 18px; font-size: 16px; color: #fff; }
/* Pager */
.pager { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.pager button { min-width: 180px; }
.dots { display: flex; gap: 8px; align-items: center; color: var(--dim); font-size: 15px; }
.empty { grid-column: 1 / -1; grid-row: 1 / -1; display: grid; place-items: center; align-content: center; gap: 14px; color: var(--dim); text-align: center; padding: 30px; }
.empty h2 { margin: 0; color: var(--bright); font-size: 24px; }
.skeleton { border-radius: 14px; background: linear-gradient(100deg, #1b2938 30%, #24364a 50%, #1b2938 70%); background-size: 300% 100%; animation: shimmer 1.4s infinite; }
@keyframes shimmer { to { background-position: -150% 0; } }
/* Detail sheet and keyboard sit over everything. */
.overlay { position: fixed; inset: 0; background: #000b; display: grid; place-items: center; z-index: 10; }
.sheet { background: #16212e; border: 1px solid var(--line); border-radius: 22px; width: 1000px; max-height: 740px; overflow: hidden;
display: flex; flex-direction: column; box-shadow: 0 30px 80px #000c; }
.sheet > .art { flex: 0 0 250px; }
.sheet .gallery { flex: 0 0 250px; display: flex; gap: 10px; padding: 14px 14px 0; background: #0c131c; justify-content: center; overflow: hidden; }
.sheet .gallery img { height: 100%; width: auto; max-width: 32%; object-fit: cover; object-position: top; border-radius: 10px 10px 0 0; }
.sheet .inner { padding: 20px 28px 22px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; }
.sheet .top { display: flex; gap: 18px; align-items: center; }
.sheet .top .icon { width: 72px; height: 72px; border-radius: 16px; font-size: 32px; }
.sheet h2 { margin: 0; font-size: 30px; color: var(--bright); line-height: 1.1; }
.sheet .meta { color: var(--dim); font-size: 16px; }
.sheet .text { font-size: 17px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.sheet .why { background: #ffffff0a; border-radius: 12px; padding: 10px 16px; font-size: 15px; color: #c5d0db; }
.sheet .why ul { margin: 4px 0 0; padding-left: 22px; }
.sheet .acts { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; align-items: center; }
.sheet .acts .close { margin-left: auto; }
.progress { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 6px; font-size: 15px; color: var(--dim); }
.progress .track { height: 8px; border-radius: 4px; background: #ffffff14; overflow: hidden; }
.progress .fill { height: 100%; background: var(--blue); width: 30%; transition: width .3s; }
.progress.busy .fill { animation: busy 1.2s ease-in-out infinite; }
@keyframes busy { from { margin-left: -30%; } to { margin-left: 100%; } }
.kbd { position: fixed; left: 0; right: 0; bottom: 0; background: #131d2af5; border-top: 1px solid var(--line);
padding: 12px; display: grid; gap: 8px; z-index: 20; box-shadow: 0 -20px 50px #0009; }
.kbd .row { display: flex; gap: 8px; justify-content: center; }
.kbd button { min-width: 76px; min-height: 60px; padding: 0 10px; font-size: 21px; }
.kbd .wide { min-width: 320px; }
.toast { position: fixed; left: 50%; top: 18px; transform: translateX(-50%); background: #0d2236; border: 1px solid var(--blue);
border-radius: 14px; padding: 12px 20px; z-index: 30; max-width: 900px; box-shadow: 0 10px 30px #0008; }
.toast.bad { background: #3a1416; border-color: var(--bad); }
</style>
</head>
<body>
<div class="app">
<div class="rail" role="navigation" aria-label="Store">
<h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 7h16l-1.5 12.5a1 1 0 0 1-1 .9H6.5a1 1 0 0 1-1-.9z"/><path d="M8.5 9V6a3.5 3.5 0 0 1 7 0v3"/></svg>APK Store</h1>
<div id="cats"></div>
<div class="sep"></div>
<button data-view="installed" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3.5" y="4" width="7" height="7" rx="1.5"/><rect x="13.5" y="4" width="7" height="7" rx="1.5"/><rect x="3.5" y="14" width="7" height="7" rx="1.5"/><rect x="13.5" y="14" width="7" height="7" rx="1.5"/></svg>Installed<span class="n" id="instN"></span></button>
<label title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="phone"> Show phone-only apps</label>
</div>
<main>
<div class="bar">
<label class="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="q" placeholder="Search games and apps" autocomplete="off" spellcheck="false" aria-label="Search games and apps"></label>
<button id="kbdBtn" title="On-screen keyboard">⌨︎ Keyboard</button>
</div>
<div class="heading"><b id="title">Home</b><span id="count"></span><span class="note" id="note"></span></div>
<div class="content" id="content"></div>
<div class="pager">
<button id="prev">◀ Previous</button>
<span class="dots" id="page"></span>
<button id="next">Next ▶</button>
</div>
</main>
</div>
<div class="overlay" id="detail" hidden><div class="sheet" id="sheet"></div></div>
<div class="kbd" id="kbd" hidden></div>
<div class="toast" id="toast" hidden></div>
<script>
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
const $ = id => document.getElementById(id);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const PER_PAGE = 8;
// Same ids and order as STORE_CATS in index.html and ui/apk_sources/_categories.py.
const CATS = [
["", "Home", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
["vr", "VR", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
["games", "Games", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/>'],
["emulators", "Emulators", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4M8 10.5h3M9.5 9v3"/>'],
["watch", "Watch & listen", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z"/><path d="M8 20h8"/>'],
["read", "Read & learn", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
["create", "Create", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
["work", "Work", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
["web", "Web & chat", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
["tools", "Tools", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
];
const catLabel = id => (CATS.find(c => c[0] === id) || [])[1] || "";
const FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Won't run" };
// view: "" (home), a category id, or "installed"; jobs: offer key -> {stage, percent, done, result, error}
// installedFrom: offer key -> package it installed as (GitHub listings have no package);
// needsData: installed packages still waiting for their game data (any view, while installed)
const st = { view: "", q: "", phone: false, page: 0, apps: [], installed: new Map(), jobs: new Map(), installedFrom: new Map(),
needsData: new Set(), request: 0, loading: false, retry: 0 };
async function api(path, body) {
const opts = body === undefined ? { headers: { "X-Frame-UI": UI_KEY } } : {
method: "POST", headers: { "Content-Type": "application/json", "X-Frame-UI": UI_KEY }, body: JSON.stringify(body) };
const r = await fetch(path, opts);
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
return j;
}
let toastTimer;
function toast(msg, bad = false) {
const t = $("toast");
t.textContent = msg; t.className = "toast" + (bad ? " bad" : ""); t.hidden = false;
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.hidden = true, bad ? 8000 : 4500);
}
// ---- Artwork (only the server's /source-image/ handles are ever used as an image) ----
const hue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
const initial = name => [...String(name || "?")][0].toUpperCase();
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
const img = (u, cls = "") => valid(u) ? `<img${cls ? ` class="${cls}"` : ""} src="${esc(u)}" alt="" decoding="async">` : "";
function art(name, a = {}, extra = "") {
const base = `style="--hue:${hue(name)}"`, ini = `<span class="initial" aria-hidden="true">${esc(initial(name))}</span>`;
if (valid(a.banner)) return `<span class="art" ${base}>${ini}${img(a.banner)}${extra}</span>`;
const shots = (a.screenshots || []).filter(valid).slice(0, 3);
if (shots.length) return `<span class="art shots" ${base}>${ini}${shots.map(u => img(u)).join("")}${extra}</span>`;
if (valid(a.icon)) return `<span class="art iconart" ${base}>${ini}${img(a.icon, "backdrop")}${img(a.icon, "mark")}${extra}</span>`;
return `<span class="art" ${base}>${ini}${extra}</span>`;
}
const icon = (name, a = {}) => `<span class="icon" style="--hue:${hue(name)}" aria-hidden="true">${esc(initial(name))}${img(a.icon)}</span>`;
document.addEventListener("error", e => {
const el = e.target;
if (!el.matches?.(".art img, .icon img")) return;
if (!el.dataset.retried) { el.dataset.retried = "1"; setTimeout(() => { el.src = el.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return; }
const box = el.closest(".shots, .iconart"); el.remove();
if (box && !box.querySelector("img")) box.classList.remove("shots", "iconart");
}, true);
const text = s => String(s || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ");
// ---- Data ----
async function loadInstalled() {
try {
st.installed = new Map((await api("/api/android")).apps.map(a => [a.package, a]));
for (const pkg of st.needsData) if (!st.installed.has(pkg)) { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); } // removed
} catch (e) { toast(`Couldn't list installed apps: ${e.message}`, true); }
$("instN").textContent = st.installed.size || "";
}
async function search(quiet) {
const request = ++st.request;
clearTimeout(st.retry);
if (st.view === "installed") { st.loading = false; st.page = 0; return render(); } // a pending search is now stale
st.loading = !quiet; if (!quiet) render();
const params = new URLSearchParams({ q: st.q, category: st.view, phone: String(st.phone) });
try {
const result = await api("/api/search?" + params);
if (request !== st.request) return;
st.apps = result.apps; st.loading = false;
const loading = result.sources.filter(s => s.status === "loading").map(s => s.name);
$("note").textContent = loading.length ? `Still fetching ${loading.join(" and ")}; more apps will appear.` : "";
if (loading.length) st.retry = setTimeout(() => request === st.request && search(true), 6000);
if (!quiet) st.page = 0;
render();
} catch (e) {
if (request !== st.request) return;
st.loading = false; st.apps = []; render(`Couldn't reach the app shelves: ${e.message}`);
}
}
const offerKey = app => `${app.offers[0].source}:${app.offers[0].id}`;
// Always the latest state: an Installed card's own copy goes stale after Stop or Remove.
function installedOf(app) {
const pkg = app.installed?.package || app.package || st.installedFrom.get(offerKey(app));
return pkg ? st.installed.get(pkg) : undefined;
}
// ---- Pages ----
function list() {
if (st.view === "installed") {
const q = st.q.toLowerCase();
return [...st.installed.values()].filter(m => !q || `${m.label} ${m.package}`.toLowerCase().includes(q))
.map(m => ({ installed: m, name: m.label || m.package, package: m.package, summary: m.version ? `v${String(m.version).replace(/^v/i, "")}` : "",
offers: [{ name: m.label, artwork: m.store_art || {}, vr: m.vr && !m.flatscreen }] }));
}
return st.home ? st.home.rest : st.apps;
}
function card(app, i) {
const o = app.offers[0], m = installedOf(app), job = st.jobs.get(offerKey(app));
const state = m ? `<span class="tag">${m.running ? "Running" : "Installed"}</span>`
: job && !job.done ? `<span class="tag">Installing…</span>`
: FIT[o.verdict?.tone] ? `<span class="fit ${o.verdict.tone}">${FIT[o.verdict.tone]}</span>` : "";
const kind = o.phone_only ? "Phone app" : catLabel(app.category || o.category) || (m ? (o.vr ? "VR app" : "Android app") : "Android app");
return `<button class="card" data-i="${i}" aria-label="${esc(app.name)}">${art(app.name, o.artwork, o.vr ? '<span class="vr">VR</span>' : "")}
<span class="body"><span class="title">${icon(app.name, o.artwork)}<strong>${esc(app.name)}</strong></span>
<span class="sum">${esc(text(app.summary))}</span><span class="foot"><span>${esc(kind)}</span>${state}</span></span></button>`;
}
function render(error) {
const home = st.view === "" && !st.q;
$("title").textContent = st.q ? `Results for “${st.q}”` : st.view === "installed" ? "Installed" : st.view ? catLabel(st.view) : "Home";
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.view === st.view)));
const content = $("content");
if (st.loading) {
content.innerHTML = `<div class="grid">${Array.from({ length: PER_PAGE }, () => '<div class="skeleton"></div>').join("")}</div>`;
$("count").textContent = ""; $("page").textContent = ""; $("prev").disabled = $("next").disabled = true; return;
}
// Home: a featured VR app and four picks, then pages of one app from each category in turn.
st.home = null;
if (home && st.apps.length) {
const ok = st.apps.filter(a => a.offers[0].verdict?.tone !== "blocked");
const heroes = ok.filter(a => a.offers[0].vr && valid(a.offers[0].artwork?.banner));
const featured = heroes[Math.floor(Date.now() / 864e5) % (heroes.length || 1)] || ok.find(a => valid(a.offers[0].artwork?.banner));
const queues = CATS.slice(1).map(([id]) => ok.filter(a => a.category === id && a !== featured));
const mixed = [];
for (let i = 0; queues.some(q => q[i]); i++) queues.forEach(q => q[i] && mixed.push(q[i]));
st.home = { featured, rest: mixed.slice(0, 4 + 5 * PER_PAGE) }; // six pages of picks; the categories have the rest
}
const items = list();
const first = st.home?.featured ? 4 : PER_PAGE; // the first home page shares its space with the banner
const pages = Math.max(1, items.length <= first ? 1 : 1 + Math.ceil((items.length - first) / PER_PAGE));
st.page = Math.min(st.page, pages - 1);
const start = st.page === 0 ? 0 : first + (st.page - 1) * PER_PAGE;
const shown = items.slice(start, start + (st.page === 0 ? first : PER_PAGE));
$("count").textContent = home ? "" : `${items.length.toLocaleString()} app${items.length === 1 ? "" : "s"}`;
$("page").textContent = `Page ${st.page + 1} of ${pages}`;
$("prev").disabled = st.page === 0; $("next").disabled = st.page >= pages - 1;
const empty = error ? `<div class="empty"><h2>No connection</h2>${esc(error)}</div>`
: st.view === "installed" && !st.q ? `<div class="empty"><h2>Nothing installed yet</h2>Pick an app in the store and press Install.<button class="primary" data-view="">Browse the store</button></div>`
: `<div class="empty"><h2>No apps here</h2>Try another category${st.phone ? "" : ", or show phone-only apps"}.</div>`;
let html = "";
if (st.page === 0 && st.home?.featured) {
const f = st.home.featured, o = f.offers[0];
html += `<button class="hero" data-feature="1" aria-label="${esc(f.name)}">${art(f.name, o.artwork)}<span class="copy">
<span class="eyebrow">${o.vr ? '<span class="vr">VR</span>' : ""}Featured</span><strong>${esc(f.name)}</strong>
<span class="blurb">${esc(text(f.summary))}</span><span class="cta">See ${esc(f.name)} ›</span></span></button>`;
}
html += `<div class="grid${st.page === 0 && st.home?.featured ? " one-row" : ""}">${shown.length ? shown.map((a, i) => card(a, start + i)).join("") : empty}</div>`;
content.innerHTML = html;
}
function turn(by) {
if (by < 0 ? $("prev").disabled : $("next").disabled) return;
st.page += by; render();
}
// ---- Detail sheet ----
let current = null; // {app, installed}
const ver = v => String(v || "").replace(/^v/i, "");
const size = n => n >= 1e9 ? (n / 1e9).toFixed(1) + " GB" : n >= 1e6 ? Math.round(n / 1e6) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
function openDetail(app) {
current = app; drawDetail(); $("detail").hidden = false;
const o = app.offers[0];
if (o.source && o.id && !app.detailed) { // the full entry has all the screenshots
api(`/api/sources/details?source=${encodeURIComponent(o.source)}&id=${encodeURIComponent(o.id)}`)
.then(full => { if (current === app) { app.offers[0] = { ...o, ...full }; app.detailed = true; drawDetail(); } }).catch(() => {});
}
}
function drawDetail() {
const app = current; if (!app) return;
const o = app.offers[0], m = installedOf(app), key = offerKey(app), job = st.jobs.get(key), data = m && st.jobs.get("data:" + m.package);
if (app.installed && !m) return closeDetail(); // removed
const shots = (o.artwork?.screenshots || []).filter(valid);
const top = valid(o.artwork?.banner) ? art(app.name, { banner: o.artwork.banner })
: shots.length ? `<div class="gallery">${shots.slice(0, 3).map(u => img(u)).join("")}</div>` : art(app.name, o.artwork || {});
const meta = [o.developer, o.source_name, o.version ? `v${ver(o.version)}` : m?.version ? `v${ver(m.version)}` : "", o.size ? size(o.size) : ""].filter(Boolean).join(" · ");
const reasons = (o.fit?.reasons || []).filter(Boolean);
let acts;
const progress = j => {
const pct = typeof j.percent === "number" ? Math.max(3, Math.min(100, j.percent)) : null;
return `<div class="progress${pct === null ? " busy" : ""}"><span>${esc(j.stage || "Downloading")}${pct === null ? "…" : ` ${Math.round(pct)}%`}</span><div class="track"><div class="fill" style="${pct === null ? "" : `width:${pct}%`}"></div></div></div>`;
};
const gameData = !(m && st.needsData.has(m.package)) ? "" : data && !data.done ? progress(data)
: `<button class="primary" data-act="game-data">Add game data</button><span class="meta">Open it once on the Frame first.</span>`;
if (job && !job.done) {
acts = progress(job);
} else if (m) {
acts = gameData + (m.running ? `<button data-act="stop">■ Stop</button>` : `<button class="play" data-act="launch">▶ Open</button>`)
+ `<button class="danger" data-act="remove">Remove</button>`;
} else if (!o.source) {
acts = "";
} else if (o.downloadable === false) {
acts = `<button disabled>Get it from the developer's page</button>`;
} else if (o.verdict?.tone === "blocked") {
acts = `<button disabled>Won't run on the Frame</button>`;
} else {
acts = `<button class="primary" data-act="install">Install</button>`;
}
const blurb = o.description || o.summary || (app.installed ? "" : app.summary); // an Installed card's summary is its version, already in meta
const failed = data?.error || job?.error;
const note = failed ? `<div class="meta" style="color:var(--bad)">${esc(failed)}</div>` : "";
$("sheet").innerHTML = `${top}<div class="inner">
<div class="top">${icon(app.name, o.artwork)}<div><h2>${esc(app.name)}</h2><div class="meta">${esc(meta)}</div></div>
${o.vr ? '<span class="vr" style="margin-left:auto">VR</span>' : ""}</div>
${blurb ? `<div class="text">${esc(text(blurb))}</div>` : ""}
${o.verdict ? `<div class="why"><b>${esc(o.verdict.label)}</b>${reasons.length ? `<ul>${reasons.slice(0, 3).map(r => `<li>${esc(r)}</li>`).join("")}</ul>` : ""}</div>` : ""}
${note}<div class="acts">${acts}<button class="close" data-act="close">Close</button></div></div>`;
}
function closeDetail() { current = null; $("detail").hidden = true; }
async function runJob(key, start) {
const job = { stage: "Downloading", percent: null, done: false };
st.jobs.set(key, job); drawDetail(); render();
try {
const { job: id } = await start();
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(id)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
Object.assign(job, { stage: j.stage || job.stage, percent: j.percent ?? null });
if (!j.done) { drawDetail(); continue; }
if (j.error) throw new Error(j.error);
toast(j.message); job.result = j.result || {};
break;
}
} catch (e) { job.error = e.message; toast(e.message, true); }
finally {
job.done = true;
if (!job.error) st.jobs.delete(key); // a failure stays, so the sheet can say why
await loadInstalled(); render(); drawDetail();
}
return job;
}
$("sheet").onclick = async e => {
const b = e.target.closest("[data-act]"); if (!b || !current) return;
const app = current, o = app.offers[0], m = installedOf(app), act = b.dataset.act, key = offerKey(app);
if (act === "close") return closeDetail();
if (act === "install") {
toast(`Installing ${app.name}. The first time takes a minute…`);
const job = await runJob(key, () => api("/api/sources/install", { source: o.source, id: o.id, version_code: o.version_code ?? null }));
if (job.result?.package) {
st.installedFrom.set(key, job.result.package);
if (job.result.game_data) st.needsData.add(job.result.package);
}
return render(), drawDetail();
}
if (act === "game-data" && m) { // its own job, by package: a failed copy keeps the pending data, so it can be tried again
const job = await runJob("data:" + m.package, () => api("/api/sources", { action: "game-data", package: m.package }));
if (!job.error) st.needsData.delete(m.package);
return render(), drawDetail();
}
const pkg = m?.package; if (!pkg) return;
if (act === "remove" && b.dataset.sure !== "1") {
b.dataset.sure = "1"; b.textContent = "Remove it and its data?"; return; // no confirm(): a separate window the laser has to find
}
b.disabled = true;
try {
const r = await api("/api/android", { action: act, package: pkg });
toast(r.message);
if (act === "remove") { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); }
if (act === "launch") closeDetail();
} catch (err) { toast(err.message, true); }
await loadInstalled(); render();
if (current === app) drawDetail();
};
$("detail").onclick = e => { if (e.target === $("detail")) closeDetail(); };
// ---- Navigation ----
$("cats").innerHTML = CATS.map(([id, label, path]) => `<button data-view="${id}" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${path}</svg>${esc(label)}</button>`).join("");
document.addEventListener("click", e => {
const v = e.target.closest("[data-view]");
if (v && !e.target.closest("#sheet")) {
st.view = v.dataset.view; st.q = ""; $("q").value = "";
if (st.view === "installed") loadInstalled().then(() => search()); else search();
return;
}
if (e.target.closest("[data-feature]")) return openDetail(st.home.featured);
const c = e.target.closest(".card[data-i]");
if (c) openDetail(list()[+c.dataset.i]);
});
$("phone").onchange = e => { st.phone = e.target.checked; search(); };
$("prev").onclick = () => turn(-1);
$("next").onclick = () => turn(1);
// The thumbstick arrives as a mouse wheel: one notch, one page.
let wheelAt = 0;
document.querySelector("main").addEventListener("wheel", e => {
e.preventDefault();
if (!$("detail").hidden || Date.now() - wheelAt < 350 || Math.abs(e.deltaY) < 4) return;
wheelAt = Date.now(); turn(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
let qTimer;
$("q").oninput = e => { clearTimeout(qTimer); qTimer = setTimeout(() => { st.q = e.target.value.trim(); search(); }, 250); };
document.addEventListener("keydown", e => { if (e.key === "Escape") { closeDetail(); $("kbd").hidden = true; } });
// ---- On-screen keyboard (a text field on a panel may not bring up Steam's) ----
const ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
$("kbd").innerHTML = ROWS.map(r => `<div class="row">${[...r].map(k => `<button data-k="${k}">${k}</button>`).join("")}</div>`).join("")
+ `<div class="row"><button data-k="clear">Clear</button><button class="wide" data-k=" ">space</button><button data-k="back">⌫</button><button data-k="done" class="primary">Done</button></div>`;
$("kbd").onclick = e => {
const b = e.target.closest("[data-k]"); if (!b) return;
const k = b.dataset.k, q = $("q");
if (k === "done") { $("kbd").hidden = true; return; }
q.value = k === "clear" ? "" : k === "back" ? q.value.slice(0, -1) : q.value + k;
q.dispatchEvent(new Event("input"));
};
$("kbdBtn").onclick = () => $("kbd").hidden = !$("kbd").hidden;
loadInstalled().then(() => search());
</script>
</body>
</html>