mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-14 14:01:26 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baaddd7622 | ||
|
|
07fc7ca3ff | ||
|
|
e885e9adba | ||
|
|
7aa7c55be9 | ||
|
|
2554309c7d | ||
|
|
adc1e44cf1 | ||
|
|
7f46cd54a4 |
No files matched your search
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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**) |
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Executable
+116
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
@@ -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
@@ -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"> ↗</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"> ›</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
@@ -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
@@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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(/ /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>
|
||||
Reference in new issue
Block a user