Compare commits

..
Author SHA1 Message Date
SAPHID f7f694956d fix(ui): reconcile PR 91 with current main 2026-10-18 00:38:06 +00:00
SAPHID 0931ff57d5 merge: preserve itch purchases alongside scoped source logins 2026-10-18 00:02:27 +00:00
saphidandClaude Opus 5.5 0f8aaa393b Store: show owned itch.io games under either VR filter
itch.io's game records don't say which games are VR, so owned purchases
came back with VR unknown and the "VR experiences" filter hid them (found
in a live test: an owned Quest game vanished). Unknown still counts as flat
for every other source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:08:19 +11:00
saphidandClaude Opus 5.5 382fb0f906 itch.io purchases: find overlapping key matches so a partial match can't hide the real start
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:44:38 +11:00
saphidandClaude Opus 5.5 76e90af5b7 itch.io purchases: redact the key when it's percent-encoded twice
_clean (and so _carries_key) now also matches each key character as %25XX,
hex in either case and for either letter case of the character, keeping
the 8+ character starts. Tests: a fully double-encoded key, recased forms,
a start of it, a mix of literal, single and double encoding, and in a
storage URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:20:54 +11:00
saphidandClaude Opus 5.5 5456f2f6f6 itch.io purchases: redact the key when it's percent-encoded after a letter-case change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:48:54 +11:00
saphidandClaude Opus 5.5 2cfb61a208 itch.io purchases: round-4 review fixes for per-hop key checks, redaction, IPv4 and the connect watchdog
- public_apk takes a check(url) callback run on every hop, before any lookup
  or connection; the itch.io download passes its key refusal, so a later
  redirect carrying the key (or ?api_key=) is refused too.
- One redaction function removes the key and its 8+ character starts in any
  letter case, with each character literal or percent-encoded (either hex
  case); used for every itch.io message, keeping error type and retry_after.
- IPv4 is an allow-list too: the whole special-purpose blocks (incl.
  192.0.0.0/24, which Python 3.9 partly calls global) are refused besides
  requiring is_global.
- No watchdog thread: connect and TLS handshake each get what is left of the
  shared deadline as their socket timeout, then the normal read timeout, so
  nothing can shut a connection after it has been handed over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:40:54 +11:00
saphidandClaude Opus 5.5 c6e4362c66 itch.io purchases: round-3 review fixes for IPv6, keys in storage URLs, HTTP errors and connect time
- public_address treats IPv6 as an allow-list: global unicast (2000::/3)
  only, minus 6to4, Teredo, documentation, ORCHID and benchmarking blocks.
  Site-local fec0::/10 (is_global on Python 3.9) and everything else outside
  2000::/3 is refused.
- A storage URL from itch.io (Location or JSON url) that contains the API
  key, or 8+ characters of it in any case or encoding, or an api_key
  parameter, is refused before any connection. Storage-side errors lose the
  key and keep their type and retry_after.
- http.client errors (BadStatusLine, IncompleteRead, RemoteDisconnected)
  around every api.itch.io open and read become readable, key-free
  SourceErrors raised from None.
- Connecting shares one monotonic deadline (20 s, or the caller's timeout if
  shorter) across all addresses and both connect and TLS handshake, with a
  watchdog for a stalled handshake; reads keep their own timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:28:15 +11:00
saphidandClaude Opus 5.5 88d61cb3b6 itch.io purchases: round-2 review fixes for addresses, revocation races and key errors
- public_address tests each property explicitly (reserved, private,
  loopback, link-local, multicast, unspecified) as Python 3.9's is_global
  passes 4000::1 and 64:ff9b:1::/48. IPv6 forms carrying IPv4 (mapped,
  compatible, NAT64 incl. local-use, 6to4, Teredo) are refused outright.
- Every checked address is tried in turn (10 s each to connect and
  handshake), each pinned to its own address with the same TLS host name.
- _use reads the key and its connection generation together, and deletes
  only if both still match: an older refusal can't undo a reconnect, even
  with the same key.
- Key errors are judged on itch.io's own words before redaction, and any
  "invalid key" error outside /profile is confirmed with /profile before
  the key is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:11:57 +11:00
saphidandClaude Opus 5.5 2d63dacdf9 itch.io purchases: review fixes for redirects, key redaction, races and revoked keys
- Storage downloads no longer go through a "any host name" redirect policy.
  _web.public_apk resolves each hop, refuses loopback, private, link-local,
  shared, reserved and multicast addresses (odd IPv4 spellings and IPv4
  mapped into IPv6 included), connects to the checked address, and checks
  again on every redirect. No credentials are sent there. _web.ANY and the
  extra open_url/apk parameters are gone; _web.apk's streaming is shared as
  save_apk.
- api.itch.io redirects are no longer followed with the key: the download
  step reads a 30x Location, a JSON body's url, or takes the file itself,
  since the live response is unverified.
- Error text loses the key, and any start of it from 8 characters, before
  joining and truncating.
- Saving and deleting the key are serialised; a connect whose /profile check
  finishes after a disconnect (or another connect) does not save. The key
  file is written through a unique mkstemp file.
- 401/403 from api.itch.io deletes the key only once /profile confirms the
  key itself is refused, including at download time with cached metadata.
  Storage-host 403s are download errors, not rate limits.
- Docs list which parts are verified against the public reference and which
  are assumed or unknown without a live account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:59:15 +11:00
saphidandClaude Opus 5.5 adefdf4398 Store: install the Android builds of games you own on itch.io
Opt-in: paste an itch.io API key under My itch.io purchases in the store's
sources dialog. It is checked with GET /profile, saved 0600 in the app data
dir (itch-account.json), and only ever sent to api.itch.io as a Bearer
header. A new "itch.io (my purchases)" source lists owned Android games
(/profile/owned-keys, all pages), labelled "Owned on itch.io"; installs
pick the best Android upload (/games/ID/uploads) and download it through
/uploads/ID/download into the normal APK install path. A refused key is
never saved, or is deleted if itch.io later revokes it; rate limits and
network errors stay per-source and readable. The key is kept out of URLs,
errors, logs, API responses and the page.

Tests use recorded JSON and a fake HTTPS layer under urllib's real opener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:50:30 +11:00
saphidandClaude Opus 5.5 2c3f8b3e0c APK sources: optional request headers and a redirect host policy for downloads
_web.open_url/apk take headers and the hosts a download may redirect to;
_web.ANY allows any HTTPS host name (no IP literals, credentials or other
ports). Authorization is still dropped on every redirect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:50:23 +11:00
31 changed files with 1836 additions and 1595 deletions

No files matched your search

-1
View File
@@ -22,7 +22,6 @@ 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` |
+82
View File
@@ -135,6 +135,88 @@ is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
Removing a repository clears its state.
## My itch.io purchases
The store can also install the Android (Quest) builds of games you **own** on
itch.io: bought, claimed free, from a bundle or a gift. It is opt-in. In the
store, open the sources dialog and use **My itch.io purchases**: create an API
key on itch.io (account settings › API keys) and paste it there. Frame Control
checks it with itch.io (`GET /profile`) before saving it; a wrong or revoked key
is reported and nothing is saved. Once connected, a source called
**itch.io (my purchases)** appears, and its games are marked **Owned on
itch.io** instead of Free. **Disconnect itch.io** deletes the key file.
How it works, using the documented
[server-side API](https://itch.io/docs/api/serverside) with
`Authorization: Bearer <key>`:
- `GET https://api.itch.io/profile/owned-keys` lists your download keys, page by
page until an empty page (500 per page, at most 40 pages). Games whose
`traits` don't include `p_android` are left out; one entry per game, with the
newest key.
- Opening a game asks `GET /games/<game_id>/uploads?download_key_id=<key id>`
for its files. Android uploads are those marked `p_android` (in `traits`, or
as a `p_android`/`platforms.android` flag) or named `.apk`. `.apk` files come
first, then full games before demos, newest first. Soundtracks, pre-orders and
externally hosted files are skipped.
- Installing asks `GET /uploads/<upload_id>/download?download_key_id=<key id>`.
The answer accepted is a redirect to itch.io's storage host, a JSON body
with a `url` field naming it, or the file itself. Frame Control follows the
first two without the key, to HTTPS on port 443 only, and only to host names
whose every address is public. For IPv4 that means outside every special
block (`0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`,
`192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`,
`198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`), however written. IPv6 is
an allow-list: global unicast (`2000::/3`) only, minus 6to4, Teredo,
documentation, ORCHID and benchmarking blocks, so mapped or NAT64 IPv4,
site-local, unique-local and the rest are all refused. It connects only to
the addresses it checked, trying each in turn within one 20-second budget
for connecting and the TLS handshake, and checks again on each further
redirect. A storage URL, first or after any redirect, that contains the API
key (or 8+ characters of it, in any case, plain or percent-encoded) or an
`api_key` parameter is refused before it is looked up, so the key never
reaches the storage server. The APK then takes
the normal install path, with the same ABI, OpenXR layer and Meta-services
checks and warnings as any other store download.
The key is stored in `frame_host.data_dir('itch-account.json')`
(`~/Library/Application Support/Frame Control/itch-account.json` on macOS),
written through a new 0600 temporary file and moved into place. It is only
sent to `https://api.itch.io`, in the `Authorization` header, never in a URL;
Frame Control does not follow api.itch.io's redirects with it. The key, or any
part of it from 8 characters, never appears in the page, API responses, errors
or logs; the page clears the field as soon as it is sent. A disconnect made
while a key is still being checked wins: that key is not saved.
Owned-game lists are kept in memory for ten minutes and never written to the
disk cache.
When api.itch.io answers 401 or 403, Frame Control asks `GET /profile` with the
same key. If that is refused too (or itch.io says "invalid key"), the key is
deleted and the store says to connect again; otherwise only that game or
download is refused, and the key stays. Rate limits (429) and network errors
only fail that source's results, with a readable message. A storage host's
refusal is reported as a download error, not as a rate limit.
### What is verified and what isn't
Not tested against a live itch.io account at all: everything below was tested
with recorded and hand-made responses (`tests/test_itch_purchases.py`) only.
| Part | Status |
|---|---|
| Bearer key, `GET /profile`, `/profile/owned-keys` paging and its fields, the `errors` array | Documented in the public reference (checked 2026-10-13) |
| `GET /games/<id>/uploads` and `download_key_id` | Not in the public reference; paths and parameter from itch.io's app client, [go-itchio](https://github.com/itchio/go-itchio) |
| Upload fields (`filename`, `display_name`, `size`, `storage`, `type`, `traits`, `updated_at`, `demo`, `preorder`) | Assumed from go-itchio and the API's snake_case convention |
| The `p_android` trait, on games and uploads | Assumed from itch.io's `p_windows`/`p_linux`/`p_osx` naming |
| What `/uploads/<id>/download` returns | Unknown: go-itchio only builds the URL and hands it to a downloader, so a redirect, a JSON `url` and the file are all accepted |
| Whether that endpoint accepts the key as a Bearer header | Unknown: go-itchio puts it in the query string, which Frame Control refuses to do |
| itch.io's storage host names and addresses | Unknown; any public HTTPS host is allowed |
Limits: a game whose page doesn't mark Android, or whose Android build is
inside a `.zip`, won't install. Android builds have no version codes on
itch.io, so only the newest upload can be installed. Owning a game doesn't make
it run on the Frame: Quest builds that need Meta's platform services won't.
## Publish your own repository
Only publish free APKs you own or have the developer's permission to distribute.
+2
View File
@@ -73,6 +73,8 @@ does not suppress the other at the aggregator level. Queries filter the current
feed window locally: this is not an exhaustive historical itch search. Only
explicit zero-price Android entries are returned. Covers are exposed in
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
Games the user owns on itch.io are a separate, opt-in source that downloads
through their own API key: see [My itch.io purchases](apk-repos.md#my-itchio-purchases).
Curated GitHub entries include publisher artwork and plain-language summaries.
Repository image URLs are pinned to inspected commits. Open Brush screenshots
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
-57
View File
@@ -183,63 +183,6 @@ See
[compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md).
## Store and Library in Frame Control
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
*Social Network* is Web & chat).
Apps built around a phone are hidden from browsing unless **Show phone-only
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
a category that only makes sense in a phone's own system (launchers, keyboards,
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
fitness, alarms, system tools) unless a useful category says otherwise
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
part (a widget, a launcher, a battery tool), and apps that need hardware the
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
Without a search, each source offers its best 300 for the chosen category:
apps with a banner first, then with screenshots, then the newest.
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
The **APK Store** panel is the Store above, drawn for the laser: the same
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
its own floating panel:
```sh
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
scripts/store-on-frame.sh close
```
`open` copies the same bundle the iPhone app uses
(`ios/scripts/make_frame_bundle.py`) to
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
all run on the Frame, so the Mac can go away after `open`.
The page has a category rail (Home, the store's categories, Installed), eight
art cards a page, and a featured VR app on Home. It turns pages instead of
scrolling, because a tagged panel gets mouse clicks and a drag would select
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
the real sources): Home, categories, Installed, the detail sheet. **Still to
check in the headset:** that the panel opens and takes laser clicks, that an
install completes when run on the Frame, and that **Open** brings up the app's
panel.
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
+1 -1
View File
@@ -139,7 +139,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 the **Library** before
sources and artwork. Android display links reveal **On your Frame** before
scrolling to the form. Mac streaming remains searchable on all platforms, with
its action shown only when the server is on macOS.
-1
View File
@@ -89,7 +89,6 @@ controls to place each panel. See [docs/panels.md](panels.md).
| Script | Runs on | Purpose |
|---|---|---|
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
| `scripts/store-on-frame.sh` | Mac → Frame | Open the APK Store (Frame Control's Android store) as a panel in the headset; installs run on the Frame (**inferred**; see [docs/apks.md](apks.md)) |
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
+1 -1
View File
@@ -18,7 +18,7 @@ import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
-116
View File
@@ -1,116 +0,0 @@
#!/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
+5
View File
@@ -0,0 +1,5 @@
Hand-made itch.io API responses for tests/test_itch_purchases.py; no real account or
network was used. owned-keys-*.json follow the documented /profile/owned-keys sample
(https://itch.io/docs/api/serverside). uploads.json follows the shape the itch.io app's
client library (github.com/itchio/go-itchio) reads from /games/ID/uploads, with the
`traits` array it handles; `p_android` there is assumed, not documented.
+22
View File
@@ -0,0 +1,22 @@
{
"page": 1,
"per_page": 500,
"owned_keys": [
{"id": 9001, "game_id": 341190, "purchase_id": 3923754, "downloads": 6,
"created_at": "2026-03-10T23:46:36Z", "updated_at": "2026-03-10T23:46:36Z",
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
"cover_url": "https://img.itch.zone/aW1nLzE2OTA3NTIuanBn/315x250%23c/Ihj7Wa.jpg",
"short_text": "Grow plants in zero gravity.", "type": "default", "classification": "game",
"min_price": 1299, "traits": ["p_windows", "p_android", "can_be_bought"],
"created_at": "2025-12-04T16:18:04Z", "published_at": "2026-01-03T17:28:47Z",
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio",
"url": "https://maker.itch.io"}}},
{"id": 9002, "game_id": 3, "purchase_id": null, "downloads": 0,
"created_at": "2026-02-01T10:00:00Z", "updated_at": "2026-02-01T10:00:00Z",
"game": {"id": 3, "title": "Desk Only", "url": "https://leafo.itch.io/x-moon",
"cover_url": "https://img.itch.zone/aW1hZ2UvMy8xODM3LnBuZw==/315x250%23/y2uYQI.png",
"type": "default", "classification": "game", "min_price": 0,
"traits": ["p_windows", "p_linux", "p_osx"], "published_at": "2013-03-03T23:02:14Z",
"user": {"id": 1, "username": "leafo", "display_name": "leafo", "url": "https://leafo.itch.io"}}}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"page": 2,
"per_page": 500,
"owned_keys": [
{"id": 8001, "game_id": 341190, "purchase_id": null, "downloads": 1,
"created_at": "2025-06-01T10:00:00Z", "updated_at": "2025-06-01T10:00:00Z",
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
"type": "default", "classification": "game", "min_price": 1299,
"traits": ["p_windows", "p_android", "can_be_bought"],
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio"}}},
{"id": 8002, "game_id": 77, "purchase_id": null, "downloads": 2,
"created_at": "2025-05-01T10:00:00Z", "updated_at": "2025-05-01T10:00:00Z",
"game": {"id": 77, "title": "Free Quest Toy", "url": "https://toys.itch.io/quest-toy",
"cover_url": "http://img.example/not-itch.png",
"type": "default", "classification": "game", "min_price": 0,
"traits": ["p_android"], "published_at": "2025-04-01T00:00:00Z",
"user": {"id": 5, "username": "toys"}}}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"uploads": [
{"id": 501, "game_id": 341190, "filename": "OrbitGarden-win64.zip", "display_name": "Windows",
"size": 734003200, "storage": "hosted", "type": "default", "traits": ["p_windows"],
"created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-02T10:00:00Z"},
{"id": 502, "game_id": 341190, "filename": "OrbitGarden-Quest-old.apk", "display_name": "Quest 1.0",
"size": 104857600, "storage": "hosted", "type": "default", "traits": ["p_android"],
"created_at": "2026-01-01T10:00:00Z", "updated_at": "2026-01-05T10:00:00Z"},
{"id": 503, "game_id": 341190, "filename": "OrbitGarden-Quest.apk", "display_name": "Quest 1.2",
"size": 115343360, "storage": "hosted", "type": "default", "traits": ["p_android"],
"created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-03T10:00:00Z"},
{"id": 504, "game_id": 341190, "filename": "OrbitGarden-demo.apk", "display_name": "Quest demo",
"size": 52428800, "storage": "hosted", "type": "default", "traits": ["p_android", "demo"],
"created_at": "2026-05-01T10:00:00Z", "updated_at": "2026-05-01T10:00:00Z"},
{"id": 505, "game_id": 341190, "filename": "OrbitGarden-android-bundle.zip", "display_name": "Android (zip)",
"size": 120000000, "storage": "hosted", "type": "default", "traits": ["p_android"],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
{"id": 506, "game_id": 341190, "filename": "Soundtrack.apk", "display_name": "Soundtrack",
"size": 1000, "storage": "hosted", "type": "soundtrack", "traits": [],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
{"id": 507, "game_id": 341190, "filename": "mirror.apk", "display_name": "Mirror",
"size": 1000, "storage": "external", "host": "example.com", "type": "default", "traits": ["p_android"],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
{"id": 508, "game_id": 341190, "filename": "OrbitGarden-mac.dmg", "display_name": "macOS",
"size": 1000, "storage": "hosted", "type": "default", "traits": ["p_osx"],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"}
]
}
+12 -1
View File
@@ -19,7 +19,7 @@ function grab(name) {
fail(`unbalanced ${name}`);
}
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "storePrice", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
const code = names.map(grab).join("");
@@ -56,6 +56,17 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
if (html.includes('role="alert"')) fail("a good listing showed the error state");
}
// A game the user owns on itch.io says so instead of "Free".
{
const p = page();
p.sourceState.detail = { app: { name: "Orbit Garden", offers: [{ ...offer, source: "itch-owned", source_name: "itch.io (my purchases)",
owned: true, owned_label: "Owned on itch.io", free: false }] }, choice: 0 };
if (p.renderAppDetail() !== true) fail("an owned listing reported a render failure");
const html = p.els.detailBody.innerHTML;
if (!html.includes('<div class="detail-price">Owned on itch.io</div>')) fail("owned listing has no Owned on itch.io label");
if (html.includes("No purchase needed")) fail("owned listing claims no purchase is needed");
}
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
{
const p = page();
+3 -9
View File
@@ -19,9 +19,7 @@ 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:
@@ -73,13 +71,9 @@ class ArtworkTests(unittest.TestCase):
_images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE)
def test_handles_are_bounded(self):
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'))
for i in range(4100):
_images.register('https://example.com/%d.png' % i)
self.assertEqual(len(_images._urls),4096)
def test_plain_language_verdict_is_evidence_based(self):
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
+1 -1
View File
@@ -122,7 +122,7 @@ class SearchTests(SettingsTest):
break
time.sleep(.01)
self.assertTrue(arrived[1]['event'].wait(2))
self.assertEqual(arrived[1]['query'][:2], ('second', 50))
self.assertEqual(arrived[1]['query'], ('second', 50))
def test_set_enabled_does_not_hold_search_lock_in_source(self):
free = []
-14
View File
@@ -63,20 +63,6 @@ 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',
-3
View File
@@ -141,9 +141,6 @@ def wait_for_idle_resolvers(timeout=10):
class ArtworkTests(unittest.TestCase):
def setUp(self):
wait_for_idle_resolvers()
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
+862
View File
@@ -0,0 +1,862 @@
"""My itch.io purchases: the account key, owned games and their Android downloads.
Offline: a fake HTTPS layer under urllib's real opener (so redirects and header
handling are the real ones) serves the recorded JSON in fixtures/itch_account.
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import contextlib, email.message, http.client, io, ipaddress, json, os, socket, sys, tempfile, threading, time
import unittest
import urllib.error, urllib.parse, urllib.request, zipfile
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, SourceLimited, _itch_account as account, _web, itch, search
import server
FIX = Path(__file__).parent / 'fixtures' / 'itch_account'
KEY = 'TESTKEYabcdefghijklmnopqrstuvwxyz0123456789'
OTHER = 'OTHERKEY0123456789abcdefghijklmnopqrstuv'
def apk_bytes():
out = io.BytesIO()
with zipfile.ZipFile(out, 'w') as z:
z.writestr('AndroidManifest.xml', b'\x03\x00\x08\x00')
z.writestr('classes.dex', b'dex\n035\x00')
return out.getvalue()
class Reply(io.BytesIO):
"""Just enough of http.client.HTTPResponse for urllib's handlers."""
def __init__(self, url, code=200, body=b'', headers=None):
super().__init__(body if isinstance(body, bytes) else json.dumps(body).encode())
self.url, self.code, self.status, self.msg, self.reason = url, code, code, 'fake', 'fake'
self.headers = email.message.Message()
for k, v in (headers or {}).items():
self.headers[k] = v
def info(self):
return self.headers
def geturl(self):
return self.url
def getcode(self):
return self.code
def getheader(self, name, default=None):
return self.headers.get(name, default)
class FakeConnection:
"""_web._connect's http.client connection, answered from the same routes."""
def __init__(self, web, host):
self.web, self.host, self.reply = web, host, None
def request(self, method, path, headers=None):
req = urllib.request.Request('https://' + self.host + path, headers=headers or {})
self.reply = self.web(None, req)
def getresponse(self):
return self.reply
def close(self):
pass
# What the fake resolver answers: the storage host is public; the rest are traps.
ADDRESSES = {'storage.itch-cdn.example': '151.101.1.1', 'other-cdn.example': '151.101.2.2',
'localhost.': '127.0.0.1', 'frame.local': '192.168.1.20', '127.0.0x0.1': '127.0.0.1',
'intranet.example': '10.0.0.5', 'mapped.example': '::ffff:127.0.0.1', 'cgnat.example': '100.64.0.1',
'mixed.example': ('151.101.1.1', '10.0.0.5'), 'dual.example': ('2a04:4e42::81', '151.101.1.1'),
'nat64.example': '64:ff9b:1::a00:1', 'sitelocal.example': 'fec0::1',
'many.example': tuple('151.101.%d.1' % n for n in range(1, 9))}
REAL_CONNECT = _web._connect
class FakeItch:
"""Routes https requests by host and path; records every request with its headers."""
def __init__(self):
self.calls, self.routes = [], {}
def route(self, host_path, reply):
self.routes[host_path] = reply
def __call__(self, handler, req):
url = urllib.parse.urlsplit(req.full_url)
headers = {k.lower(): v for k, v in req.header_items()}
self.calls.append((req.full_url, headers))
reply = self.routes.get(url.hostname + url.path)
if reply is None:
return Reply(req.full_url, 404, {'errors': ['not found']})
if callable(reply):
reply = reply(req, urllib.parse.parse_qs(url.query))
if isinstance(reply, Exception):
raise reply
if isinstance(reply, Reply):
return reply
return Reply(req.full_url, 200, reply)
def urls(self, host_path):
return [u for u, _ in self.calls if urllib.parse.urlsplit(u).hostname + urllib.parse.urlsplit(u).path == host_path]
def owned_pages(req, query):
page = query['page'][0]
return {'1': json.loads((FIX / 'owned-keys-1.json').read_text()),
'2': json.loads((FIX / 'owned-keys-2.json').read_text())}.get(page, {'page': int(page), 'owned_keys': []})
class ItchPurchases(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
for target, value in [(account.frame_host, 'data_dir'), (search.frame_host, 'data_dir')]:
p = patch.object(target, value, lambda *parts: root.joinpath('data', *parts))
p.start()
self.addCleanup(p.stop)
(root / 'cache').mkdir()
for p in (patch.object(_web, 'cache', return_value=str(root / 'cache')),
patch.object(_web.frame_host, 'cache_dir', lambda *parts: root.joinpath('cache', *parts))):
p.start()
self.addCleanup(p.stop)
self.web = FakeItch()
self.web.route('api.itch.io/profile', {'user': {'username': 'player', 'id': 1}})
self.web.route('api.itch.io/profile/owned-keys', owned_pages)
self.web.route('api.itch.io/games/341190/uploads', json.loads((FIX / 'uploads.json').read_text()))
p = patch('urllib.request.HTTPSHandler.https_open', autospec=True, side_effect=self.web)
p.start()
self.addCleanup(p.stop)
p = patch('urllib.request.HTTPHandler.http_open', side_effect=AssertionError('plain HTTP in test'))
p.start()
self.addCleanup(p.stop)
real_lookup = _web.socket.getaddrinfo
def lookup(host, port, *args, **kwargs):
if host not in ADDRESSES:
try:
ipaddress.ip_address(host) # IP literals (and the in-process test server) need no DNS
return real_lookup(host, port, *args, **kwargs)
except ValueError:
pass
if host == 'localhost':
return real_lookup(host, port, *args, **kwargs)
raise _web.socket.gaierror('unknown test host ' + host)
found = ADDRESSES[host]
return [(10 if ':' in a else 2, 1, 6, '', (a, port)) for a in ([found] if isinstance(found, str) else found)]
self.connected, self.unreachable = [], set()
def connect(host, address, timeout, deadline):
self.connected.append((host, address))
if address[0] in self.unreachable:
raise OSError('Network is unreachable')
self.assertLessEqual(deadline - time.monotonic(), _web.CONNECT_BUDGET)
return FakeConnection(self.web, host)
for p in (patch.object(_web.socket, 'getaddrinfo', lookup), patch.object(_web, '_connect', connect)):
p.start()
self.addCleanup(p.stop)
account._forget()
self.addCleanup(account._forget)
_web._limited.clear()
self.addCleanup(_web._limited.clear)
search._status.clear()
def owned_source(self):
return next(s for s in itch.sources() if s['id'] == itch.OWNED)
def connect(self):
self.assertTrue(account.connect(KEY)['itch']['connected'])
# -- the key ---------------------------------------------------------------
def test_connect_checks_the_key_then_saves_it_0600(self):
result = account.connect(' ' + KEY + '\n')
self.assertEqual(result['message'], 'Connected to itch.io as player')
self.assertEqual(result['itch'], {'connected': True, 'username': 'player'})
self.assertNotIn(KEY, json.dumps(result))
url, headers = self.web.calls[0]
self.assertEqual(url, 'https://api.itch.io/profile')
self.assertEqual(headers['authorization'], 'Bearer ' + KEY)
self.assertEqual(json.loads(account.path().read_text())['key'], KEY)
if os.name == 'posix':
self.assertEqual(os.stat(account.path()).st_mode & 0o777, 0o600)
self.assertEqual([p.name for p in account.path().parent.iterdir()], ['itch-account.json']) # no .tmp left
self.assertNotIn(KEY, json.dumps(account.status()))
def test_reconnecting_rewrites_the_file_0600_even_if_it_was_wider(self):
self.connect()
os.chmod(account.path(), 0o644)
account.connect(OTHER)
self.assertEqual(json.loads(account.path().read_text())['key'], OTHER)
if os.name == 'posix':
self.assertEqual(os.stat(account.path()).st_mode & 0o777, 0o600)
def test_bad_or_revoked_key_stays_disconnected(self):
for reply in (Reply('https://api.itch.io/profile', 401, {'errors': ['invalid key']}),
Reply('https://api.itch.io/profile', 403, b'Forbidden'),
{'errors': ['invalid key']}):
self.web.route('api.itch.io/profile', reply)
with self.assertRaisesRegex(account.BadKey, 'did not accept the API key') as caught:
account.connect(KEY)
self.assertNotIn(KEY, str(caught.exception))
self.assertFalse(account.path().exists())
self.assertEqual(account.status(), {'connected': False, 'username': None})
self.assertEqual([s['id'] for s in itch.sources()], ['itch', 'itch-quest'])
with self.assertRaisesRegex(account.BadKey, 'does not look like'):
account.connect('short')
with self.assertRaisesRegex(account.BadKey, 'does not look like'):
account.connect(KEY + '\r\nX-Injected: 1')
self.assertEqual(len(self.web.calls), 3) # malformed keys never reach itch.io
def test_a_refused_request_with_a_working_key_keeps_the_key(self):
self.connect()
self.web.route('api.itch.io/games/341190/uploads', Reply('https://api.itch.io/games/341190/uploads', 403, b''))
with self.assertRaisesRegex(SourceError, r'refused this request \(HTTP 403\)') as caught:
itch.details(self.owned_source(), '341190')
self.assertNotIsInstance(caught.exception, account.BadKey)
self.assertTrue(account.connected())
def test_a_key_revoked_later_is_removed_with_a_clear_message(self):
self.connect()
self.web.route('api.itch.io/profile/owned-keys', Reply('https://api.itch.io/profile/owned-keys', 401, b''))
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', 401, b''))
with self.assertRaisesRegex(account.BadKey, 'no longer accepts the saved API key'):
itch.search(self.owned_source(), '')
self.assertFalse(account.path().exists())
self.assertNotIn(itch.OWNED, [s['id'] for s in itch.sources()])
def test_disconnect_deletes_the_key_file(self):
self.connect()
self.assertIn(itch.OWNED, [s['id'] for s in itch.sources()])
result = account.disconnect()
self.assertFalse(account.path().exists())
self.assertEqual(result['itch'], {'connected': False, 'username': None})
self.assertNotIn(itch.OWNED, [s['id'] for s in itch.sources()])
with self.assertRaisesRegex(SourceError, 'Connect your itch.io account first'):
account.owned_keys()
account.disconnect() # already gone: fine
# -- owned games -------------------------------------------------------------
def test_owned_keys_follow_pages_until_an_empty_one(self):
self.connect()
keys = account.owned_keys()
self.assertEqual([k['id'] for k in keys], [9001, 9002, 8001, 8002])
pages = self.web.urls('api.itch.io/profile/owned-keys')
self.assertEqual([urllib.parse.parse_qs(urllib.parse.urlsplit(u).query) for u in pages],
[{'page': [str(n)], 'per_page': ['500']} for n in (1, 2, 3)])
self.assertTrue(all('Bearer ' + KEY == h['authorization'] for _, h in self.web.calls))
self.assertTrue(all(KEY not in u for u, _ in self.web.calls))
account.owned_keys() # cached in memory, not on disk
self.assertEqual(len(self.web.urls('api.itch.io/profile/owned-keys')), 3)
self.assertFalse(any(KEY in p.read_text(errors='ignore') or b'Orbit' in p.read_bytes()
for p in Path(_web.cache()).iterdir()))
def test_page_cap_stops_a_server_that_never_ends(self):
self.connect()
self.web.route('api.itch.io/profile/owned-keys', lambda req, q: {'owned_keys': [{'id': int(q['page'][0])}]})
with patch.object(account, 'PAGES', 3):
self.assertEqual(len(account.owned_keys()), 3)
def test_search_lists_owned_android_games_labelled(self):
self.connect()
entries = itch.search(self.owned_source(), '')
self.assertEqual([(e['id'], e['name']) for e in entries], [('341190', 'Orbit Garden VR'), ('77', 'Free Quest Toy')])
orbit, toy = entries
self.assertEqual(orbit['download_key_id'], 9001) # the newest key for a game owned twice
self.assertTrue(orbit['owned'])
self.assertEqual(orbit['owned_label'], 'Owned on itch.io')
self.assertFalse(orbit['free'])
self.assertTrue(toy['free'])
self.assertIsNone(toy['icon']) # not an itch.io image host
self.assertEqual(orbit['page'], 'https://maker.itch.io/orbit-garden')
self.assertEqual(itch.search(self.owned_source(), 'zero gravity')[0]['id'], '341190')
# Through the store's search, owned (paid) games pass the free-only filter.
result = search.search('orbit', source=itch.OWNED)
self.assertEqual(result['sources'][0]['status'], 'ok')
offer = result['apps'][0]['offers'][0]
self.assertEqual((offer['source'], offer['source_name'], offer['owned_label']),
(itch.OWNED, 'itch.io (my purchases)', 'Owned on itch.io'))
self.assertNotIn(KEY, json.dumps(result))
def test_owned_games_show_under_either_vr_choice(self):
# itch.io doesn't say which purchases are VR, so neither filter hides them; other
# games of unknown kind still count as flat.
self.connect()
both = ['Free Quest Toy', 'Orbit Garden VR']
for vr in (True, False):
names = sorted(a['name'] for a in search.search('', vr=vr, source=itch.OWNED)['apps'])
self.assertEqual(names, both)
flat = {'source': 'x', 'id': '1', 'name': 'Flat', 'summary': '', 'vr': None}
self.assertEqual(search.group([flat], vr=True), [])
self.assertEqual(len(search.group([flat], vr=False)), 1)
self.assertEqual(search.group([dict(flat, owned=True, vr=False)], vr=True), [])
def test_only_the_account_source_may_mark_paid_games_owned(self):
entry = {'free': False, 'owned': True}
self.assertFalse(search.obtainable({'id': 'fdroid'}, entry))
self.assertTrue(search.obtainable({'id': itch.OWNED, 'owned': True}, entry))
self.assertTrue(search.obtainable({'id': 'fdroid'}, {'free': True}))
def test_android_upload_selection(self):
uploads = json.loads((FIX / 'uploads.json').read_text())['uploads']
# .apk files before other Android files, full games before demos, newest first; never
# soundtracks, externally hosted files or other platforms.
self.assertEqual([u['id'] for u in account.android_uploads(uploads)], [503, 502, 504, 505])
self.assertTrue(account.is_android({'filename': 'Game.APK'}))
self.assertTrue(account.is_android({'filename': 'game.zip', 'p_android': True}))
self.assertTrue(account.is_android({'filename': 'game.zip', 'platforms': {'android': 'all'}}))
self.assertFalse(account.is_android({'filename': 'game.zip', 'traits': ['p_windows']}))
self.assertEqual(account.android_uploads({'not': 'a list'}), [])
def test_details_offer_the_android_uploads(self):
self.connect()
entry = itch.details(self.owned_source(), '341190')
self.assertTrue(entry['downloadable'])
self.assertEqual([v['upload_id'] for v in entry['versions']], [503, 502, 504, 505])
self.assertEqual((entry['version'], entry['size'], entry['updated']), ('Quest 1.2', 115343360, '2026-04-03'))
self.assertTrue(entry['versions'][2]['demo'])
url = self.web.urls('api.itch.io/games/341190/uploads')[0]
self.assertEqual(url, 'https://api.itch.io/games/341190/uploads?download_key_id=9001')
with self.assertRaisesRegex(SourceError, 'not among'):
itch.details(self.owned_source(), '3') # owned, but not for Android
self.web.route('api.itch.io/games/77/uploads', {'uploads': []})
self.assertFalse(itch.details(self.owned_source(), '77')['downloadable'])
with self.assertRaisesRegex(SourceError, 'no Android download'):
itch.download(self.owned_source(), '77')
def test_download_url_building(self):
self.assertEqual(account.download_url(503, 9001),
'https://api.itch.io/uploads/503/download?download_key_id=9001')
for bad in [(0, 1), (1, None), ('5', 1), (True, 1), (-2, 1)]:
with self.assertRaises(SourceError):
account.download_url(*bad)
# -- downloads -----------------------------------------------------------------
def storage(self, location='https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?sig=abc'):
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 302, b'', {'Location': location}))
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk', Reply(location, 200, apk_bytes()))
def test_download_sends_the_key_to_itch_only_and_strips_it_on_redirect(self):
self.connect()
self.storage()
result = itch.download(self.owned_source(), '341190')
self.assertTrue(os.path.isfile(result['apk']))
self.assertFalse(result['verified'])
api, cdn = self.web.calls[-2:]
self.assertEqual(api[0], 'https://api.itch.io/uploads/503/download?download_key_id=9001')
self.assertEqual(api[1]['authorization'], 'Bearer ' + KEY)
self.assertTrue(cdn[0].startswith('https://storage.itch-cdn.example/'))
self.assertNotIn('authorization', cdn[1])
# Connected to the address that was checked, not to a fresh lookup.
self.assertEqual(self.connected, [('storage.itch-cdn.example', ('151.101.1.1', 443))])
self.assertTrue(all(KEY not in u for u, _ in self.web.calls))
with self.assertRaisesRegex(SourceError, 'version codes'):
itch.download(self.owned_source(), '341190', version_code=3)
def test_download_also_accepts_a_json_url_or_the_file_itself(self):
self.connect()
self.storage()
location = 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?sig=abc'
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200, {'url': location},
{'Content-Type': 'application/json'}))
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertNotIn('authorization', self.web.calls[-1][1])
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200, apk_bytes(),
{'Content-Type': 'application/octet-stream'}))
self.connected.clear()
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertEqual(self.connected, []) # api.itch.io served it; no storage host involved
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200, {'nothing': 1},
{'Content-Type': 'application/json'}))
with self.assertRaisesRegex(SourceError, 'did not say where'):
itch.download(self.owned_source(), '341190')
def test_storage_redirects_are_checked_on_every_hop(self):
self.connect()
self.storage('https://other-cdn.example/hop')
self.web.route('other-cdn.example/hop', Reply('https://other-cdn.example/hop', 302, b'',
{'Location': 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk'}))
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertEqual([h for h, _ in self.connected], ['other-cdn.example', 'storage.itch-cdn.example'])
self.web.route('other-cdn.example/hop', Reply('https://other-cdn.example/hop', 302, b'',
{'Location': 'https://intranet.example/a.apk'}))
with self.assertRaisesRegex(SourceError, 'private network address'):
itch.download(self.owned_source(), '341190')
def test_redirects_to_plain_http_or_private_addresses_are_refused(self):
self.connect()
for location in ('http://storage.itch-cdn.example/a.apk', 'https://user:pw@cdn.example/a.apk',
'https://cdn.example:8443/a.apk'):
self.storage(location)
with self.assertRaisesRegex(SourceError, 'unexpected download URL'):
itch.download(self.owned_source(), '341190')
for location in ('https://localhost./a.apk', 'https://frame.local/a.apk', 'https://127.0.0x0.1/a.apk',
'https://intranet.example/a.apk', 'https://mapped.example/a.apk',
'https://cgnat.example/a.apk', 'https://mixed.example/a.apk',
'https://127.0.0.1/a.apk', 'https://[::1]/a.apk', 'https://169.254.169.254/a.apk',
'https://nat64.example/a.apk', 'https://[4000::1]/a.apk', 'https://[64:ff9b:1::a00:1]/a.apk',
'https://[fec0::1]/a.apk', 'https://sitelocal.example/a.apk'):
self.storage(location)
with self.assertRaisesRegex(SourceError, 'private network address'):
itch.download(self.owned_source(), '341190')
self.assertEqual(self.connected, [])
self.assertEqual({urllib.parse.urlsplit(u).hostname for u, _ in self.web.calls}, {'api.itch.io'})
def test_public_address_rules(self):
for ip in ('151.101.1.1', '8.8.8.8', '2a04:4e42::81', '2001:4860:4860::8888'):
self.assertTrue(_web.public_address(ip), ip)
for ip in ('127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.0.1', '169.254.169.254', '100.64.0.1',
'0.0.0.0', '224.0.0.1', '255.255.255.255', '240.0.0.1', '::1', '::', 'fe80::1%en0', 'fc00::1',
'ff02::1', 'not an ip',
'192.0.0.8', '192.0.0.11', '192.0.0.255', '192.0.0.170', '192.0.0.9', '192.0.0.1', # 3.9: global
'192.0.2.1', '192.88.99.1', '198.18.0.1', '198.19.255.255', '198.51.100.7', '203.0.113.9',
'0.1.2.3', '10.255.255.255', '172.31.0.1', '239.1.1.1', '254.0.0.1',
'4000::1', '100::1', 'f000::1', # reserved: Python 3.9's is_global says True for 4000::1
'::ffff:10.0.0.1', '::ffff:127.0.0.1', '::ffff:8.8.8.8', # IPv4-mapped
'::a00:1', '::8.8.8.8', # IPv4-compatible
'64:ff9b::a00:1', '64:ff9b::808:808', # NAT64 well-known prefix
'64:ff9b:1::a00:1', '64:ff9b:1::808:808', # NAT64 local-use: 3.9's is_global says True
'2002:a00:1::1', '2002:808:808::1', # 6to4
'2001:0:4136:e378:8000:63bf:3fff:fdd2', # Teredo
'fec0::1', 'feff::1', # site-local: Python 3.9's is_global says True
'fc00::1', 'fd12:3456::1', 'fe80::1', 'ff00::1', 'ff0e::1', '8000::1', 'c000::1',
'2001:db8::1', '3fff::1', # documentation
'2001:10::1', '2001:20::1', '2001:2::1'): # ORCHID, ORCHIDv2, benchmarking
self.assertFalse(_web.public_address(ip), ip)
self.assertEqual(_web.resolve_public('storage.itch-cdn.example'), [('151.101.1.1', 443)])
self.assertEqual(_web.resolve_public('dual.example'), [('2a04:4e42::81', 443), ('151.101.1.1', 443)])
def test_a_storage_url_carrying_the_key_is_refused(self):
self.connect()
for location in ('https://storage.itch-cdn.example/a.apk?api_key=' + KEY,
'https://storage.itch-cdn.example/a.apk?API_KEY=someone-elses',
'https://storage.itch-cdn.example/a.apk?apikey=x',
'https://storage.itch-cdn.example/' + KEY + '/a.apk',
'https://storage.itch-cdn.example/a.apk#' + KEY[:10],
'https://storage.itch-cdn.example/a.apk?x=' + urllib.parse.quote('%' + KEY),
'https://' + KEY.lower() + '.itch-cdn.example/a.apk'):
self.storage(location)
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused') as caught:
itch.download(self.owned_source(), '341190')
self.assertNotIn(KEY.lower(), str(caught.exception).lower())
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200,
{'url': 'https://storage.itch-cdn.example/a.apk?api_key=' + KEY},
{'Content-Type': 'application/json'}))
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused'):
itch.download(self.owned_source(), '341190')
self.assertEqual(self.connected, [])
# A storage URL's own signature or token parameters are fine.
self.storage('https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?token=abc&Signature=x')
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
def test_a_later_redirect_carrying_the_key_is_refused_before_connecting(self):
self.connect()
bad = 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?api_key=' + KEY
for hops in (['https://other-cdn.example/hop1'], # 2nd hop carries the key
['https://other-cdn.example/hop1', 'https://other-cdn.example/hop2']): # 3rd hop
self.connected.clear()
self.storage(hops[0])
for here, there in zip(hops, hops[1:] + [bad]):
path = urllib.parse.urlsplit(here).path
self.web.route('other-cdn.example' + path, Reply(here, 302, b'', {'Location': there}))
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused') as caught:
itch.download(self.owned_source(), '341190')
self.assertNotIn(KEY, str(caught.exception))
self.assertEqual([h for h, _ in self.connected], ['other-cdn.example'] * len(hops))
self.assertFalse(any(KEY in u for u, _ in self.web.calls))
# The check runs on every hop before the name is even looked up.
seen = []
with patch.object(_web, 'resolve_public', side_effect=AssertionError('looked up')):
with self.assertRaisesRegex(SourceError, 'no thanks'):
_web.public_apk('https://storage.itch-cdn.example/a.apk', 'itch.io',
check=lambda u: seen.append(u) or (_ for _ in ()).throw(SourceError('no thanks')))
self.assertEqual(seen, ['https://storage.itch-cdn.example/a.apk'])
def test_a_partial_match_before_the_key_cannot_hide_its_start(self):
key = 'abcdefghABCDEFGHi1234567890'
for text in (key[:8].upper() + key.upper(), key[:9] + key, 'xx' + key[:8] + key + 'yy'):
out = account._clean(text, key)
self.assertNotIn('1234567890', out, (text, out))
import time
t0 = time.monotonic(); account._clean(('abcdefgh' * 12500)[:100000], key)
self.assertLess(time.monotonic() - t0, 2.0)
def test_redaction_catches_every_spelling_of_the_key(self):
hexed = ''.join('%%%02X' % ord(c) for c in KEY)
for spelling in (KEY, KEY.lower(), KEY.upper(), hexed, hexed.lower(),
KEY[:5] + hexed[15:30] + KEY[10:], # partly encoded
KEY[:9], hexed[:30].lower(), # starts of it, 8+ characters
''.join('%%%02x' % ord(c) for c in KEY.lower()), # encoded after a case change
''.join('%%%02X' % ord(c) for c in KEY.swapcase()),
'%%%02x' % ord(KEY[0].swapcase()) + KEY[1:]): # one encoded, recased letter
cleaned = account._clean('before ' + spelling + ' after', KEY)
self.assertEqual(cleaned, 'before [API key] after', spelling)
double = ''.join('%%25%02X' % ord(c) for c in KEY) # percent-encoded twice
for spelling in (double, double.lower(), double.upper(), double[:5 * 9], # full, hex recased, 9-char start
''.join('%%25%02x' % ord(c) for c in KEY.lower()), # the key recased, then double-encoded
KEY[:4] + double[20:45] + hexed[27:33] + KEY[11:]): # literal, double and single mixed
self.assertEqual(account._clean('before ' + spelling + ' after', KEY), 'before [API key] after', spelling)
self.assertEqual(account._clean(double[:5 * 7], KEY), double[:5 * 7]) # 7 characters: kept
self.assertTrue(account._carries_key('https://cdn.example/a?x=' + double, KEY))
self.assertTrue(account._carries_key('https://cdn.example/' + double[:5 * 8].lower() + '/a', KEY))
self.assertEqual(account._clean('TESTKEY is short', KEY), 'TESTKEY is short') # 7 characters: kept
self.assertEqual(account._clean(KEY + KEY.lower(), KEY), '[API key][API key]')
for url in ('https://cdn.example/a?x=' + hexed, 'https://cdn.example/a?x=' + hexed.lower(),
'https://cdn.example/' + KEY.upper() + '/a', 'https://cdn.example/a?x=' + urllib.parse.quote_plus(KEY)):
self.assertTrue(account._carries_key(url, KEY), url)
self.assertFalse(account._carries_key('https://cdn.example/a?token=abc&Signature=x', KEY))
# Storage errors carrying an encoded or lowercased key lose it, keeping their kind.
self.connect()
self.storage()
with patch.object(_web, 'public_apk', side_effect=SourceLimited('slow ' + hexed.lower(), 42)):
with self.assertRaises(SourceLimited) as caught:
itch.download(self.owned_source(), '341190')
self.assertEqual((str(caught.exception), caught.exception.retry_after), ('slow [API key]', 42))
with patch.object(_web, 'public_apk', side_effect=SourceError('host ' + KEY.lower() + '.cdn')):
with self.assertRaisesRegex(SourceError, r'^host \[API key\]\.cdn$'):
itch.download(self.owned_source(), '341190')
def test_storage_errors_lose_the_key_but_keep_their_kind(self):
self.connect()
self.storage()
with patch.object(_web, 'public_apk', side_effect=SourceLimited('slow down ' + KEY, 90)):
with self.assertRaises(SourceLimited) as caught:
itch.download(self.owned_source(), '341190')
self.assertEqual((str(caught.exception), caught.exception.retry_after), ('slow down [API key]', 90))
with patch.object(_web, 'public_apk', side_effect=SourceError('Could not find the download server ' + KEY)):
with self.assertRaisesRegex(SourceError, r'^Could not find the download server \[API key\]$') as caught:
itch.download(self.owned_source(), '341190')
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(caught.exception.__suppress_context__)
def test_broken_http_responses_are_readable_and_lose_the_key(self):
self.connect()
class Truncated(Reply):
def read(self, *args):
raise http.client.IncompleteRead(b'{"owned_keys": [' + KEY.encode(), 1000)
cases = [http.client.BadStatusLine('HTTP/1.1 ' + KEY + ' oops'),
lambda req, q: Truncated(req.full_url, 200, b''),
http.client.RemoteDisconnected('closed after ' + KEY)]
for case in cases:
account._forget()
self.web.route('api.itch.io/profile/owned-keys', case)
with self.assertRaisesRegex(SourceError, '^Could not reach itch.io: ') as caught:
account.owned_keys()
self.assertNotIn(KEY, str(caught.exception))
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(caught.exception.__suppress_context__)
self.assertTrue(account.connected())
# The same on the download request, for each kind of answer it reads.
for case in (http.client.BadStatusLine('HTTP/1.1 ' + KEY),
lambda req, q: Truncated(req.full_url, 200, b'', {'Content-Type': 'application/json'}),
lambda req, q: Truncated(req.full_url, 200, b'', {'Content-Type': 'application/octet-stream'})):
self.web.route('api.itch.io/uploads/503/download', case)
with self.assertRaises(SourceError) as caught:
account.download(503, 9001)
self.assertNotIn(KEY, str(caught.exception))
self.assertRegex(str(caught.exception), '^Could not (reach itch.io|download APK): ')
def test_connecting_shares_one_deadline_across_addresses(self):
clock, deadlines = [1000.0], []
def slow(host, address, timeout, deadline):
deadlines.append(deadline)
clock[0] += 7 # each address hangs for 7 s before failing
raise socket.timeout('timed out')
with patch.object(_web.time, 'monotonic', lambda: clock[0]), patch.object(_web, '_connect', slow):
with self.assertRaises(OSError):
_web._public_connection('many.example', 60)
self.assertEqual(len(set(deadlines)), 1) # one deadline for every attempt
self.assertEqual(deadlines[0], 1000.0 + _web.CONNECT_BUDGET)
self.assertEqual(len(deadlines), 3) # 8 addresses, but only 21 s of trying, not 56
self.assertLessEqual(clock[0] - 1000.0, _web.CONNECT_BUDGET + 7)
deadlines.clear()
clock[0] = 1000.0
with patch.object(_web.time, 'monotonic', lambda: clock[0]), patch.object(_web, '_connect', slow):
with self.assertRaises(OSError):
_web._public_connection('many.example', 5) # the caller's shorter timeout wins
self.assertEqual(deadlines, [1005.0])
def test_a_stalled_handshake_stops_at_the_deadline(self):
# A local server that accepts and then says nothing: the TLS handshake never completes.
server = socket.socket()
server.bind(('127.0.0.1', 0))
server.listen(1)
self.addCleanup(server.close)
timeouts = []
real_settimeout = socket.socket.settimeout
def settimeout(sock, value):
timeouts.append(value)
return real_settimeout(sock, value)
start = time.monotonic()
# No thread is left behind to shut the connection later: the deadline is only socket timeouts.
with patch.object(_web.threading, 'Timer', side_effect=AssertionError('watchdog thread')), \
patch.object(socket.socket, 'settimeout', settimeout):
with self.assertRaises(OSError):
REAL_CONNECT('storage.itch-cdn.example', server.getsockname(), 60, start + 0.5)
self.assertLess(time.monotonic() - start, 3)
self.assertTrue(timeouts and all(0 < t <= 0.5 for t in timeouts), timeouts) # what's left, each step
with patch.object(_web.socket, 'create_connection', side_effect=AssertionError('connected')):
with self.assertRaises(socket.timeout): # a deadline already gone: no connection attempt at all
REAL_CONNECT('storage.itch-cdn.example', server.getsockname(), 60, time.monotonic() - 1)
def test_an_unreachable_address_falls_back_to_the_next_checked_one(self):
self.connect()
self.storage('https://dual.example/upload/503/OrbitGarden-Quest.apk')
self.web.route('dual.example/upload/503/OrbitGarden-Quest.apk', Reply('https://dual.example/', 200, apk_bytes()))
self.unreachable.add('2a04:4e42::81')
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertEqual(self.connected, [('dual.example', ('2a04:4e42::81', 443)), ('dual.example', ('151.101.1.1', 443))])
self.unreachable.add('151.101.1.1')
with self.assertRaisesRegex(SourceError, '^Could not download APK: Network is unreachable$'):
itch.download(self.owned_source(), '341190')
def test_storage_refusals_are_download_errors_not_rate_limits(self):
self.connect()
self.storage()
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk',
Reply('https://storage.itch-cdn.example/x', 403, b'denied'))
with self.assertRaisesRegex(SourceError, r"itch.io's download server refused the file \(HTTP 403\)") as caught:
itch.download(self.owned_source(), '341190')
self.assertNotIsInstance(caught.exception, SourceLimited)
self.assertTrue(account.connected())
self.assertEqual(_web.wait_time('https://api.itch.io/'), 0)
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk',
Reply('https://storage.itch-cdn.example/x', 429, b'', {'Retry-After': '60'}))
with self.assertRaises(SourceLimited):
itch.download(self.owned_source(), '341190')
def test_a_key_revoked_after_details_were_cached_is_removed_at_download(self):
self.connect()
itch.details(self.owned_source(), '341190') # uploads and owned keys now cached
for code in (401, 403):
self.connect()
self.web.route('api.itch.io/uploads/503/download', Reply('https://api.itch.io/uploads/503/download', code, b''))
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', code, b''))
with self.assertRaisesRegex(account.BadKey, 'no longer accepts the saved API key'):
account.download(503, 9001)
self.assertFalse(account.path().exists())
self.web.route('api.itch.io/profile', {'user': {'username': 'player'}})
# The key still works, only this download is refused: keep the key, say so.
self.connect()
self.web.route('api.itch.io/uploads/503/download', Reply('https://api.itch.io/uploads/503/download', 403, b''))
with self.assertRaisesRegex(SourceError, r'refused this request \(HTTP 403\)') as caught:
account.download(503, 9001)
self.assertNotIsInstance(caught.exception, (account.BadKey, SourceLimited))
self.assertTrue(account.connected())
def test_a_disconnect_during_the_key_check_wins(self):
checking, release = threading.Event(), threading.Event()
def slow_profile(req, query):
checking.set()
release.wait(5)
return {'user': {'username': 'player'}}
self.web.route('api.itch.io/profile', slow_profile)
outcome = {}
def run():
try:
outcome['result'] = account.connect(KEY)
except SourceError as e:
outcome['error'] = e
worker = threading.Thread(target=run)
worker.start()
self.assertTrue(checking.wait(5))
account.disconnect()
release.set()
worker.join(5)
self.assertRegex(str(outcome.get('error')), 'changed while the key was being checked; nothing was saved')
self.assertFalse(account.path().exists())
self.assertFalse(account.connected())
def test_an_old_refusal_does_not_undo_a_reconnect_with_the_same_key(self):
self.connect()
listing, release, profile_ok = threading.Event(), threading.Event(), [True]
def slow_owned(req, query):
listing.set()
release.wait(5)
return Reply(req.full_url, 401, b'')
self.web.route('api.itch.io/profile/owned-keys', slow_owned)
self.web.route('api.itch.io/profile', lambda req, q: {'user': {'username': 'player'}} if profile_ok[0]
else Reply(req.full_url, 401, b''))
outcome = {}
def run():
try:
account.owned_keys()
except SourceError as e:
outcome['error'] = e
worker = threading.Thread(target=run)
worker.start()
self.assertTrue(listing.wait(5))
account.disconnect()
account.connect(KEY) # same key, connected again while the old listing waits
profile_ok[0] = False # the old listing's confirmation now sees a refusal
release.set()
worker.join(5)
self.assertIsInstance(outcome.get('error'), account.BadKey)
self.assertTrue(account.path().exists()) # the fresh connection survives
self.assertEqual(json.loads(account.path().read_text())['key'], KEY)
def test_redacted_text_is_not_mistaken_for_a_key_error(self):
self.connect()
before = len(self.web.urls('api.itch.io/profile'))
self.web.route('api.itch.io/profile/owned-keys', {'errors': ['Invalid request: ' + KEY]})
with self.assertRaisesRegex(SourceError, r'^itch.io: Invalid request: \[API key\]$') as caught:
account.owned_keys()
self.assertNotIsInstance(caught.exception, account.BadKey)
self.assertTrue(account.connected())
self.assertEqual(len(self.web.urls('api.itch.io/profile')), before) # not even a key check needed
def test_an_invalid_key_error_elsewhere_is_confirmed_before_deleting(self):
self.connect()
self.web.route('api.itch.io/profile/owned-keys', {'errors': ['invalid key']})
with self.assertRaisesRegex(SourceError, '^itch.io: invalid key$') as caught:
account.owned_keys() # /profile still accepts the key: keep it
self.assertNotIsInstance(caught.exception, account.BadKey)
self.assertTrue(account.connected())
account._forget()
self.web.route('api.itch.io/profile', {'errors': ['invalid key']})
with self.assertRaisesRegex(account.BadKey, 'no longer accepts'):
account.owned_keys()
self.assertFalse(account.connected())
def test_concurrent_connects_never_share_a_temporary_file(self):
made = []
real = account.tempfile.mkstemp
def mkstemp(*args, **kwargs):
fd, name = real(*args, **kwargs)
made.append(name)
if os.name == 'posix':
self.assertEqual(os.stat(name).st_mode & 0o777, 0o600)
return fd, name
with patch.object(account.tempfile, 'mkstemp', mkstemp):
workers = [threading.Thread(target=lambda k=k: self.swallow(account.connect, k)) for k in (KEY, OTHER) * 3]
for w in workers:
w.start()
for w in workers:
w.join(5)
self.assertEqual(len(made), len(set(made)))
self.assertIn(json.loads(account.path().read_text())['key'], (KEY, OTHER))
self.assertEqual([p.name for p in account.path().parent.iterdir()], ['itch-account.json'])
@staticmethod
def swallow(fn, *args):
try:
fn(*args)
except SourceError:
pass # a connect overtaken by another one refuses to save: fine here
def test_existing_hosts_still_strip_authorization_on_redirect(self):
req = urllib.request.Request('https://api.github.com/a', headers={'Authorization': 'Bearer x'})
nxt = _web.Redirect(('api.github.com',)).redirect_request(req, None, 302, 'Found', {}, 'https://api.github.com/b')
self.assertFalse(nxt.has_header('Authorization'))
with self.assertRaises(SourceError):
_web.Redirect(('api.github.com',)).redirect_request(req, None, 302, 'Found', {}, 'https://evil.example/b')
def test_install_uses_the_normal_apk_install_path(self):
import frame_android
self.connect()
self.storage()
calls = []
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
with zipfile.ZipFile(apk_path) as z:
self.assertIn('AndroidManifest.xml', z.namelist())
calls.append((name, source))
return {'package': 'com.maker.orbit', 'message': 'Installed'}
with patch.object(frame_android, 'install', install):
result = search.install(itch.OWNED, '341190')
self.assertEqual(calls, [('Orbit Garden VR', 'itch.io (my purchases)')])
self.assertEqual(result['package'], 'com.maker.orbit')
# -- errors never carry the key ------------------------------------------------
def test_errors_are_readable_and_never_contain_the_key(self):
self.connect()
cases = [
(Reply('https://api.itch.io/profile/owned-keys', 429, b'', {'Retry-After': '120'}),
SourceLimited, '^itch.io is limiting requests; try again in 2 minutes$'),
(Reply('https://api.itch.io/profile/owned-keys', 503, b''), SourceError, 'HTTP error 503; try again later'),
(urllib.error.URLError('connect to api.itch.io with ' + KEY + ' failed'), SourceError,
r'^Could not reach itch.io: connect to api.itch.io with \[API key\] failed$'),
(OSError('timed out'), SourceError, '^Could not reach itch.io: timed out$'),
(Reply('https://api.itch.io/profile/owned-keys', 200, b'<html>'), SourceError, 'invalid response'),
({'errors': ['echo ' + KEY]}, SourceError, r'^itch.io: echo \[API key\]$'),
# The key straddles the 200-character cut: no piece of it may survive.
({'errors': ['x' * 180, KEY]}, SourceError, r'^itch.io: x{180}; \[API key\]$'),
({'errors': ['x' * 190 + KEY]}, SourceError, r'^itch.io: x{190}\[API key\]$'),
]
self.assertEqual(account._clean('cut at ' + KEY[:9], KEY), 'cut at [API key]') # a truncated key's start
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
for reply, kind, message in cases:
account._forget()
_web._limited.clear()
self.web.route('api.itch.io/profile/owned-keys', reply)
with self.assertRaisesRegex(kind, message) as caught:
account.owned_keys()
self.assertNotIn(KEY, str(caught.exception))
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(account.connected()) # not the key's fault: stays connected
# The store's search reports the failure as a non-fatal source status.
account._forget()
result = search.search('', source=itch.OWNED)
self.assertEqual(result['sources'][0]['status'], 'error')
self.assertNotIn(KEY, json.dumps(result) + stderr.getvalue())
def test_server_api_connects_and_never_echoes_the_key(self):
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
self.addCleanup(httpd.server_close)
self.addCleanup(httpd.shutdown)
def request(method, path, body=None):
c = http.client.HTTPConnection('127.0.0.1', httpd.server_port)
c.request(method, path, json.dumps(body) if body is not None else None,
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
r = c.getresponse()
out = r.status, r.read().decode()
c.close()
return out
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', 401, b''))
bad = request('POST', '/api/sources', {'action': 'itch-connect', 'key': KEY})
self.web.route('api.itch.io/profile', {'user': {'username': 'player'}})
good = request('POST', '/api/sources', {'action': 'itch-connect', 'key': KEY})
listed = request('GET', '/api/sources')
empty = request('POST', '/api/sources', {'action': 'itch-connect', 'key': ' '})
gone = request('POST', '/api/sources', {'action': 'itch-disconnect'})
self.assertEqual(bad[0], 400)
self.assertIn('did not accept the API key', json.loads(bad[1])['error'])
self.assertEqual(good[0], 200)
self.assertEqual(json.loads(good[1])['itch'], {'connected': True, 'username': 'player'})
self.assertEqual(json.loads(listed[1])['itch'], {'connected': True, 'username': 'player'})
self.assertIn(itch.OWNED, [s['id'] for s in json.loads(listed[1])['sources']])
self.assertEqual(empty[0], 400)
self.assertEqual(json.loads(gone[1])['itch'], {'connected': False, 'username': None})
self.assertFalse(account.path().exists())
for text in (bad[1], good[1], listed[1], empty[1], gone[1], stderr.getvalue()):
self.assertNotIn(KEY, text)
if __name__ == '__main__':
unittest.main()
-6
View File
@@ -88,12 +88,6 @@ 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"):
-360
View File
@@ -1,360 +0,0 @@
"""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):
# Sixty milliseconds pass while a slot is busy. A still-running lookup
# gets only the remaining forty, regardless of the test host's load.
with patch.object(_images, '_resolvers') as slots, \
patch.object(_images.time, 'monotonic', side_effect=[100, 100.06]), \
patch.object(_images.threading, 'Thread') as worker:
slots.acquire.return_value = True
with self.assertRaises(SourceError):
_images._resolve('slow.example', 443, 0.1)
slots.acquire.assert_called_once_with(timeout=0.1)
self.assertAlmostEqual(worker.return_value.join.call_args.args[0], 0.04)
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()
+3 -1
View File
@@ -32,7 +32,9 @@ Entry dict (missing facts are None, never guessed)
Rules
-----
- Only sources that distribute APKs with the developer's consent: free listings,
never paid apps re-hosted, no licence or entitlement workarounds.
never paid apps re-hosted, no licence or entitlement workarounds. The one paid
exception is a store's own download of a game the user bought there: a source
marked 'owned': True whose entries carry 'owned': True (itch.io purchases).
- Honour each site's terms and robots rules; if automated download isn't allowed,
return entries with 'downloadable': False and a 'page' link instead.
- Cache indexes under frame_host.cache_dir('apk-sources'); send a clear
-116
View File
@@ -1,116 +0,0 @@
"""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
+11 -38
View File
@@ -12,9 +12,7 @@ from urllib.parse import urljoin, urlsplit
from apk_sources import SourceError, _credentials
_lock = threading.Lock()
_urls = OrderedDict() # token -> URL, oldest first
_tokens = {} # URL -> token
MAX_TOKENS = 16384
_urls = OrderedDict()
_cache = OrderedDict()
MAX_IMAGE = 8 * 1024 * 1024
MAX_CACHE = 64 * 1024 * 1024
@@ -33,24 +31,24 @@ def register(url):
if not isinstance(url, str) or not valid_url(url):
return None
with _lock:
token = _tokens.get(url)
if token in _urls:
_urls.move_to_end(token)
return '/source-image/' + token
for token, known in _urls.items():
if known == url:
_urls.move_to_end(token)
return '/source-image/' + token
token = secrets.token_urlsafe(24)
_urls[token], _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)
_urls[token] = url
while len(_urls) > 4096:
_urls.popitem(last=False)
return '/source-image/' + token
def artwork(entry, shots=12):
def artwork(entry):
images = entry.get('images') or {}
if not isinstance(images, dict):
images = {}
return {'icon': register(images.get('icon') or entry.get('icon')),
'banner': register(images.get('banner')),
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:min(shots, 12)]) if path]}
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]}
def image_type(data):
@@ -73,37 +71,12 @@ 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.
# 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))):
if not _resolvers.acquire(blocking=False):
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():
+394
View File
@@ -0,0 +1,394 @@
"""The user's own itch.io account: an API key, the games it owns and their Android uploads.
Opt-in (store > sources > Connect itch.io). The key is kept in
frame_host.data_dir('itch-account.json') with mode 0600 and is only ever sent to
https://api.itch.io, in an Authorization header. Downloads end on itch.io's storage
host, fetched without the key from a checked public address (_web.public_apk).
The key never goes into a URL, an error, a log line or an API response. Owned-key
listings stay in memory, never in the disk cache.
API: https://itch.io/docs/api/serverside. See docs/apk-repos.md.
"""
import hashlib, http.client, json, os, re, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
import frame_host
from . import SourceError, SourceLimited, _web
API = 'https://api.itch.io'
HOSTS = ('api.itch.io',)
NAME = 'itch.io'
TTL = 600 # owned keys and uploads are re-read after ten minutes
PER_PAGE = 500 # the documented maximum for /profile/owned-keys
PAGES = 40 # stop after 20,000 owned keys
KEY = re.compile(r'[A-Za-z0-9_.-]{16,256}')
BAD_KEY = ('itch.io did not accept the API key. Create one in itch.io settings > '
'API keys and connect again')
REVOKED = ('itch.io no longer accepts the saved API key, so it was removed. '
'Create a new one in itch.io settings > API keys and connect again')
REDIRECTS = (301, 302, 303, 307, 308)
# Everything a connection or response can raise. http.client's errors (IncompleteRead,
# BadStatusLine...) are not OSErrors, and BadStatusLine repeats what the server sent.
NETWORK = (OSError, ValueError, http.client.HTTPException)
_lock = threading.Lock() # the cache
_cache = {} # (generation, key digest, what) -> (time, value)
_account = threading.Lock() # saving and deleting the key file
_generation = [0] # bumped by every save and delete: a connect that started earlier doesn't save
class BadKey(SourceError):
"""itch.io refused the key: wrong, revoked or deleted."""
def path():
return frame_host.data_dir('itch-account.json')
def _load():
try:
data = json.loads(path().read_text())
except (OSError, ValueError):
return None
if not isinstance(data, dict) or not isinstance(data.get('key'), str) or not KEY.fullmatch(data['key']):
return None
return data
def status():
"""{'connected', 'username'}: what the page may know. Never the key; no network."""
data = _load()
return {'connected': bool(data), 'username': data.get('username') if data else None}
def connected():
return _load() is not None
def _key():
data = _load()
if not data:
raise SourceError('Connect your itch.io account first (store > sources > Connect itch.io)')
return data['key']
def _save(key, username):
"""Write the key file through a fresh 0600 temporary file (mkstemp), then move it into place."""
target = path()
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(target.parent), prefix='.itch-account-', suffix='.tmp')
try:
with os.fdopen(fd, 'w') as f:
os.chmod(tmp, 0o600) # mkstemp already does on POSIX; say so for every platform
json.dump({'key': key, 'username': username}, f)
os.replace(tmp, str(target))
finally:
if os.path.exists(tmp):
os.remove(tmp)
def _forget():
with _lock:
_cache.clear()
def _clean(message, key):
"""message without the key, or any start of it (8+ characters, as truncation leaves), in
any letter case, with any of its characters written literally, percent-encoded (%4B or
%4b) or percent-encoded twice (%254B, %254b). The only redaction used here: every message
that might hold the key goes through it. (The key's characters are letters, digits and
_.-, so +/space encoding can't change it.)"""
if not key:
return message
# Each character: itself, or the percent-encoding (once or twice) of either of its letter cases.
tokens = ['(?:%s)' % '|'.join([re.escape(c)] + ['%%(?:25)?%02x' % ord(v) for v in sorted({c.lower(), c.upper()})])
for c in key]
start, rest = re.compile(''.join(tokens[:8]), re.I), [re.compile(t, re.I) for t in tokens[8:]]
spans, pos = [], 0
while True:
found = start.search(message, pos)
if not found:
break
end = found.end()
for token in rest: # as much more of the key as follows
more = token.match(message, end)
if not more:
break
end = more.end()
spans.append((found.start(), end))
# Look again from the next character, not the end: a partial match just before the
# real key can overlap its start. Overlapping spans merge below.
pos = found.start() + 1
out, pos = [], 0
for a, b in spans: # in order of start; only overlapping spans merge
if out and a < pos:
pos = max(pos, b)
continue
out += [message[pos:a], '[API key]']
pos = b
return ''.join(out) + message[pos:]
class _Stay(urllib.request.HTTPRedirectHandler):
"""Don't follow: the caller decides where a redirect may go, and never sends the key there."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _open(url, key, accept='application/json'):
"""GET url on api.itch.io with the key. Redirects come back as HTTPError 30x."""
_web.checked_url(url, HOSTS)
wait = _web.wait_time(url)
if wait:
raise _web.limited_error(NAME, wait)
return urllib.request.build_opener(_Stay()).open(urllib.request.Request(
url, headers={'User-Agent': _web.UA, 'Authorization': 'Bearer ' + key, 'Accept': accept}), timeout=60)
def _refused(code, key, api_path, message=None):
"""A refusal that may be about the key (401/403, or an "invalid key" error): BadKey only
once /profile confirms the key itself is refused; otherwise it's this request (a game or
upload this purchase doesn't cover), and the key stays."""
if api_path != '/profile':
_get('/profile', key) # raises BadKey if the key is the problem
raise SourceError(message or 'itch.io refused this request (HTTP %d); your purchase may not include it' % code)
raise BadKey(BAD_KEY)
def _network_error(e, key):
reason = getattr(e, 'reason', None) or e
return SourceError(_clean('Could not reach itch.io: ' + str(reason), key))
BAD_KEY_TEXT = re.compile(r'\b(invalid|revoked|expired)\b.*\bkey\b|\bkey\b.*\b(invalid|revoked|expired)\b', re.I)
def _checked(raw, key, api_path):
"""Decoded JSON object from api.itch.io, or SourceError/BadKey from its errors array."""
if len(raw) > 8 * 1024 * 1024:
raise SourceError('itch.io response is too large')
try:
data = json.loads(raw)
except ValueError:
raise SourceError('itch.io returned an invalid response') from None
if not isinstance(data, dict):
raise SourceError('itch.io returned an invalid response')
errors = data.get('errors')
if errors: # documented: errors is an array of strings
messages = [str(m) for m in errors if isinstance(m, (str, int))]
# Judged on itch.io's own words: redacted text says "[API key]", which would look like a key error.
about_key = any(BAD_KEY_TEXT.search(m) and 'download key' not in m.lower() for m in messages)
# Each message loses the key before joining and shortening, so no part of it survives the cut.
text = _clean('; '.join(_clean(m, key) for m in messages)[:200], key)
if about_key:
_refused(None, key, api_path, 'itch.io: ' + text)
raise SourceError('itch.io: ' + (text or 'request refused'))
return data
def _get(api_path, key, params=None):
"""JSON from api.itch.io; SourceError with a readable message (never the key) on failure."""
url = API + api_path + ('?' + urllib.parse.urlencode(params) if params else '')
try:
with _open(url, key) as r:
raw = r.read(8 * 1024 * 1024 + 1)
except urllib.error.HTTPError as e:
e.close()
if e.code == 429:
raise _web.limited_error(NAME, _web.throttle(url, e.headers)) from None
if e.code in (401, 403):
_refused(e.code, key, api_path)
raise SourceError('itch.io returned HTTP error %d; try again later' % e.code) from None
except SourceError:
raise
except NETWORK as e:
raise _network_error(e, key) from None
return _checked(raw, key, api_path)
def _cached(key, what, load):
slot = (_generation[0], hashlib.sha256(key.encode()).hexdigest(), what)
with _lock:
hit = _cache.get(slot)
if hit and time.time() - hit[0] < TTL:
return hit[1]
value = load()
with _lock:
_cache[slot] = (time.time(), value)
return value
def _use(call):
"""Run call(key); a key itch.io now refuses is removed, so the account shows disconnected."""
with _account: # the key and the connection it belongs to, read together
key, generation = _key(), _generation[0]
try:
return call(key)
except BadKey:
with _account:
# Only the connection this call started with: a reconnect meanwhile (even with
# the same key) has been checked since, and is not undone by this older verdict.
if _generation[0] == generation and (_load() or {}).get('key') == key:
_delete()
raise BadKey(REVOKED) from None
def connect(key):
"""Check key with itch.io (GET /profile), then save it. A refused key is never saved, and
nor is one whose check finished after a disconnect or another connect."""
key = (key or '').strip() if isinstance(key, str) else ''
if not KEY.fullmatch(key):
raise BadKey('That does not look like an itch.io API key. Copy it from itch.io settings > API keys')
with _account:
started = _generation[0]
user = _get('/profile', key).get('user') # the network, outside the lock
if not isinstance(user, dict):
raise SourceError('itch.io returned an invalid profile')
username = user.get('username') if isinstance(user.get('username'), str) else None
with _account:
if _generation[0] != started:
raise SourceError('The itch.io connection changed while the key was being checked; '
'nothing was saved. Try again')
_generation[0] += 1
_save(key, username)
_forget()
return {'message': 'Connected to itch.io' + (' as ' + username if username else ''), 'itch': status()}
def _delete():
"""Remove the key file; call with _account held."""
_generation[0] += 1
try:
os.remove(str(path()))
except FileNotFoundError:
pass
_forget()
def disconnect():
with _account:
_delete()
return {'message': 'Disconnected from itch.io; the API key was deleted from this computer', 'itch': status()}
def owned_keys():
"""Every download key the account owns, newest first (GET /profile/owned-keys, all pages)."""
def load(key):
keys = []
for page in range(1, PAGES + 1):
batch = _get('/profile/owned-keys', key, {'page': page, 'per_page': PER_PAGE}).get('owned_keys')
# Documented: a page can hold fewer than per_page even when more follow; an empty one ends it.
if not batch or not isinstance(batch, list):
break
keys.extend(k for k in batch if isinstance(k, dict))
return keys
return _use(lambda key: _cached(key, 'owned', lambda: load(key)))
def _number(value):
return value if type(value) is int and value > 0 else None
def is_android(upload):
"""An upload marked for Android, or an .apk file. The marker's field name is not in the
public reference: itch.io's traits use p_<platform> (p_windows...), so p_android is assumed."""
traits = upload.get('traits') if isinstance(upload.get('traits'), list) else []
platforms = upload.get('platforms') if isinstance(upload.get('platforms'), dict) else {}
filename = str(upload.get('filename') or '').lower()
return 'p_android' in traits or upload.get('p_android') is True or 'android' in platforms \
or filename.endswith('.apk')
def android_uploads(uploads):
"""Installable Android uploads, best first: .apk files, then full games, then newest."""
chosen = []
for u in uploads if isinstance(uploads, list) else []:
if not isinstance(u, dict) or not _number(u.get('id')) or not is_android(u):
continue
traits = u.get('traits') if isinstance(u.get('traits'), list) else []
if u.get('type') not in (None, 'default') or u.get('preorder') is True or 'preorder' in traits:
continue # soundtracks, books, placeholders
if u.get('storage') == 'external':
continue # hosted on another site: not something itch.io can hand us
chosen.append(u)
chosen.sort(key=lambda u: str(u.get('updated_at') or u.get('created_at') or ''), reverse=True)
chosen.sort(key=lambda u: (not str(u.get('filename') or '').lower().endswith('.apk'),
u.get('demo') is True or 'demo' in (u.get('traits') or [])))
return chosen
def uploads(game_id, download_key_id):
"""The game's uploads this download key may fetch (GET /games/ID/uploads)."""
game_id, download_key_id = _number(game_id), _number(download_key_id)
if not game_id or not download_key_id:
raise SourceError('Invalid itch.io game')
return _use(lambda key: _cached(key, ('uploads', game_id, download_key_id), lambda: _get(
'/games/%d/uploads' % game_id, key, {'download_key_id': download_key_id}).get('uploads') or []))
def download_url(upload_id, download_key_id):
upload_id, download_key_id = _number(upload_id), _number(download_key_id)
if not upload_id or not download_key_id:
raise SourceError('Invalid itch.io upload')
return API + '/uploads/%d/download?' % upload_id + urllib.parse.urlencode({'download_key_id': download_key_id})
def _fetch(url, key):
"""Ask api.itch.io for the file. Not checked against a live account, so this accepts each
plausible answer: a redirect to storage, JSON naming the storage URL, or the file itself."""
try:
r = _open(url, key, 'application/vnd.android.package-archive, application/json;q=0.5, */*;q=0.1')
except urllib.error.HTTPError as e:
e.close()
if e.code in REDIRECTS:
target = e.headers.get('Location')
elif e.code == 429:
raise _web.limited_error(NAME, _web.throttle(url, e.headers)) from None
elif e.code in (401, 403):
_refused(e.code, key, '/uploads')
else:
raise SourceError('itch.io returned HTTP error %d for the download; try again later' % e.code) from None
except SourceError:
raise
except NETWORK as e:
raise _network_error(e, key) from None
else:
with r:
if 'json' not in (r.headers.get('Content-Type') or '').lower():
try:
return _web.save_apk(r) # served by api.itch.io itself
except NETWORK + (zipfile.BadZipFile,) as e:
raise SourceError(_clean('Could not download APK: ' + str(e), key)) from None
try:
raw = r.read(1024 * 1024 + 1)
except NETWORK as e:
raise _network_error(e, key) from None
target = _checked(raw, key, '/uploads').get('url')
if not isinstance(target, str) or not target:
raise SourceError('itch.io did not say where to download the file')
def refuse(hop): # on the first URL and on every redirect after it, before any lookup
if _carries_key(hop, key):
raise SourceError('itch.io pointed the download at a URL carrying your API key; refused, '
'so the key is not sent to the storage server')
try:
return _web.public_apk(urllib.parse.urljoin(url, target), NAME, check=refuse) # no key sent
except SourceLimited as e:
raise SourceLimited(_clean(str(e), key), e.retry_after) from None
except SourceError as e:
raise type(e)(_clean(str(e), key)) from None
def _carries_key(url, key):
"""True if url holds the key (or 8+ characters of its start), however cased or encoded, or
an itch.io API-key parameter: go-itchio puts the key in ?api_key=, and storage must never
get it. Other parameters (signatures, tokens) are the storage URL's own and are kept."""
for form in (url, urllib.parse.unquote(url), urllib.parse.unquote_plus(url)):
if _clean(form, key) != form:
return True
names = {name.lower() for name, _ in urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query,
keep_blank_values=True)}
return bool(names & {'api_key', 'apikey', 'api-key'})
def download(upload_id, download_key_id):
"""The APK: the key goes to api.itch.io only, never to the storage host."""
url = download_url(upload_id, download_key_id)
return _use(lambda key: _fetch(url, key))
+159 -11
View File
@@ -1,5 +1,6 @@
"""Small HTTPS cache and APK downloader for public publisher sources."""
import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
import hashlib, http.client, ipaddress, os, shutil, socket, ssl, tempfile, threading, time
import urllib.error, urllib.parse, urllib.request, zipfile
from email.utils import parsedate_to_datetime
import frame_host
@@ -211,19 +212,16 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
raise SourceError('Could not read source: ' + str(e)) from e
def apk(url, hosts, digest=None, name=None):
def save_apk(response, digest=None):
"""Stream response (anything with read(n)) into the cache as a checked APK."""
tmp = None
name = name or _host(url)
wait = wait_time(url)
if wait:
raise limited_error(name, wait)
try:
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
h = hashlib.sha256()
with os.fdopen(fd, 'wb') as f, open_url(url, hosts) as r:
with os.fdopen(fd, 'wb') as f:
size = 0
while True:
chunk = r.read(1 << 20)
chunk = response.read(1 << 20)
if not chunk:
break
size += len(chunk)
@@ -240,12 +238,162 @@ def apk(url, hosts, digest=None, name=None):
path = os.path.join(cache(), actual + '.apk')
os.replace(tmp, path)
return {'apk': path, 'obb': [], 'sha256': actual, 'verified': bool(digest)}
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
def apk(url, hosts, digest=None, name=None):
name = name or _host(url)
wait = wait_time(url)
if wait:
raise limited_error(name, wait)
try:
with open_url(url, hosts) as r:
return save_apk(r, digest)
except urllib.error.HTTPError as e:
if e.code in (403, 429):
raise limited_error(name, throttle(url, e.headers)) from e
raise SourceError('Could not download APK: HTTP error ' + str(e.code)) from e
except (OSError, ValueError, zipfile.BadZipFile) as e:
raise SourceError('Could not download APK: ' + str(e)) from e
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
# IPv6 is an allow-list: only global unicast (2000::/3), which rules out everything else at
# once (loopback, unspecified, mapped/compatible IPv4, NAT64 64:ff9b::, site-local fec0::,
# unique-local fc00::, link-local, multicast, reserved). Inside it, these special blocks are
# refused too: 6to4 and Teredo carry IPv4 that may be private; the rest aren't the internet.
_GLOBAL6 = ipaddress.ip_network('2000::/3')
_SPECIAL6 = tuple(ipaddress.ip_network(n) for n in (
'2001::/32', # Teredo
'2001:2::/48', # benchmarking
'2001:10::/28', # ORCHID
'2001:20::/28', # ORCHIDv2
'2001:db8::/32', # documentation
'2002::/16', # 6to4
'3fff::/20')) # documentation (2024)
# IPv4 blocks that are never the public internet, listed in full rather than trusting
# is_global (Python 3.9 calls 192.0.0.8, .11 and .255 global).
_SPECIAL4 = tuple(ipaddress.ip_network(n) for n in (
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
'192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15',
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32'))
def public_address(ip):
"""True for an address on the public internet: never loopback, private, link-local,
site-local, shared (CGNAT), reserved, unspecified or multicast, nor an IPv6 wrapper
around IPv4. Python 3.9's is_global alone lets several of these through."""
try:
a = ipaddress.ip_address(ip.split('%', 1)[0])
except ValueError:
return False
if a.version == 6 and (a not in _GLOBAL6 or any(a in n for n in _SPECIAL6)):
return False
if a.version == 4 and any(a in n for n in _SPECIAL4):
return False
return a.is_global and not (a.is_reserved or a.is_private or a.is_loopback or a.is_link_local
or a.is_multicast or a.is_unspecified)
def resolve_public(host):
"""host's addresses for port 443, if every address it has is public; else SourceError."""
try:
addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
except (OSError, UnicodeError) as e:
raise SourceError('Could not find the download server ' + host) from e
if not addresses or not all(public_address(a[4][0]) for a in addresses):
raise SourceError('The download server is on a private network address; refused')
found = []
for a in addresses:
if a[4][:2] not in found:
found.append(a[4][:2])
return found
CONNECT_BUDGET = 20 # seconds for connecting, all addresses and TLS handshakes together
def _public_connection(host, timeout, budget=CONNECT_BUDGET):
"""A verified TLS connection to host:443, made to an address checked to be public.
Each checked address is tried in turn, all within one deadline (budget, or timeout if
shorter) covering both connecting and the handshake. The one connected to is always one
that was checked, so a second lookup can't swap in another. timeout is for reads after."""
deadline = time.monotonic() + min(budget, timeout)
error = None
for address in resolve_public(host):
if time.monotonic() >= deadline:
break
try:
return _connect(host, address, timeout, deadline)
except OSError as e: # unreachable, refused, or its TLS failed: try the next one
error = e
raise error or socket.timeout('Timed out connecting to ' + host)
def _connect(host, address, timeout, deadline):
"""TLS to exactly address, verified as host, connected and handshaken before deadline
(a time.monotonic() value); reads then get timeout seconds each."""
def remaining():
left = deadline - time.monotonic()
if left <= 0:
raise socket.timeout('Timed out connecting to ' + host)
return left
sock = socket.create_connection(address, timeout=remaining())
try:
# Each step gets what is left of the deadline as its socket timeout. A handshake
# read as several receives could in theory run past it by one timeout; no thread
# is kept to cut it short, as one could fire after the connection was handed over.
sock.settimeout(remaining())
sock = ssl.create_default_context().wrap_socket(sock, server_hostname=host,
do_handshake_on_connect=False)
sock.settimeout(remaining())
sock.do_handshake()
sock.settimeout(timeout)
except BaseException:
sock.close()
raise
conn = http.client.HTTPConnection(host, 443, timeout=timeout)
conn.sock = sock
return conn
def public_apk(url, name, redirects=3, timeout=60, check=None):
"""Download an APK from a public HTTPS host that no source lists in advance (itch.io's
storage). No credentials are sent. Every hop, redirects included, is re-checked, and
check(url), if given, runs on each hop before any lookup or connection (it raises)."""
for _ in range(redirects + 1):
if check:
check(url)
try:
p = urllib.parse.urlsplit(url)
port = p.port
except (ValueError, TypeError) as e:
raise SourceError(name + ' returned an invalid download URL') from e
if p.scheme != 'https' or not p.hostname or p.username or p.password or port not in (None, 443):
raise SourceError(name + ' returned an unexpected download URL')
wait = wait_time(url)
if wait:
raise limited_error(name, wait)
try:
conn = _public_connection(p.hostname, timeout)
except OSError as e:
raise SourceError('Could not download APK: ' + str(e)) from e
try:
conn.request('GET', (p.path or '/') + ('?' + p.query if p.query else ''), headers={'User-Agent': UA})
r = conn.getresponse()
if r.status in (301, 302, 303, 307, 308):
url = urllib.parse.urljoin(url, r.getheader('Location') or '')
continue
if r.status == 429:
raise limited_error(name, throttle(url, r.headers))
if r.status != 200:
raise SourceError("%s's download server refused the file (HTTP %d); try again later"
% (name, r.status))
return save_apk(r)
except (OSError, ValueError, http.client.HTTPException, zipfile.BadZipFile) as e:
raise SourceError('Could not download APK: ' + str(e)) from e
finally:
conn.close()
raise SourceError(name + ' redirected the download too many times')
+4 -29
View File
@@ -26,7 +26,7 @@ from frame_apk_sign import _der_parts, _cert_key, der
from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
CACHE_VERSION = 3 # 3: categories
CACHE_VERSION = 2
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 = {}
@@ -474,7 +474,6 @@ 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
@@ -493,7 +492,6 @@ 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():
@@ -686,39 +684,16 @@ def set_enabled(source_id, enabled):
_write(_storage(), settings)
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)."""
def search(source, query, limit=50):
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)
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)
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()))
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')
+97 -15
View File
@@ -1,17 +1,35 @@
"""Free Android VR RSS listings. Download pages stay in the publisher's UI."""
"""Free Android VR RSS listings, plus the Android builds of games the user owns.
The free feeds' download pages stay in the publisher's UI. Owned games appear only
after the user connects their itch.io account with an API key (_itch_account.py);
they download through itch.io's own API, with the download key of that purchase.
"""
import html, re, urllib.parse, xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from . import SourceError, _web
from . import SourceError, _itch_account, _web
KIND = 'itch'
FEEDS = ('openxr', 'oculus-quest')
OWNED = 'itch-owned'
OWNED_LABEL = 'Owned on itch.io'
def sources():
return [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
found = [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
if _itch_account.connected():
found.append({'id': OWNED, 'kind': KIND, 'name': 'itch.io (my purchases)',
'url': 'https://itch.io/my-purchases', 'owned': True,
'description': 'Android builds of games you own on itch.io',
'builtin': True, 'enabled': True, 'trust': 'user'})
return found
def _itch_page(url):
p = urllib.parse.urlsplit(url if isinstance(url, str) else '')
return p.scheme == 'https' and (p.hostname or '').endswith('.itch.io') and not p.username and ':' not in p.netloc
def _parse(source, data):
@@ -22,8 +40,7 @@ def _parse(source, data):
entries = []
for item in root.findall('./channel/item'):
page = item.findtext('link') or ''
p = urllib.parse.urlsplit(page)
if p.scheme != 'https' or not (p.hostname or '').endswith('.itch.io') or p.username or ':' in p.netloc:
if not _itch_page(page):
continue
if item.findtext('price') != '$0.00' or item.findtext('platforms/android') != 'yes':
continue
@@ -45,23 +62,81 @@ def _parse(source, data):
return entries
def _owned_entry(source, key):
"""Entry for one owned download key, or None if its game isn't for Android."""
game = key.get('game') if isinstance(key.get('game'), dict) else {}
game_id = key.get('game_id') or game.get('id')
if type(game_id) is not int or type(key.get('id')) is not int:
return None
traits = game.get('traits')
if isinstance(traits, list) and 'p_android' not in traits:
return None # the game's page offers no Android download
cover = game.get('cover_url') if isinstance(game.get('cover_url'), str) else None
if cover and not cover.startswith('https://img.itch.zone/'):
cover = None
user = game.get('user') if isinstance(game.get('user'), dict) else {}
price = game.get('min_price')
return {'source': source['id'], 'id': str(game_id), 'package': None,
'name': str(game.get('title') or 'itch.io game ' + str(game_id)),
'summary': str(game.get('short_text') or OWNED_LABEL),
'developer': user.get('display_name') or user.get('username'),
'icon': cover, 'images': {'icon': cover, 'banner': cover, 'screenshots': []},
'page': game['url'] if _itch_page(game.get('url')) else None,
'version': None, 'version_code': None, 'min_sdk': None, 'abis': None,
'vr': None, 'size': None, 'free': price == 0 if type(price) is int else None,
'owned': True, 'owned_label': OWNED_LABEL, 'license': None,
'updated': str(game.get('published_at') or '')[:10] or None,
'downloadable': None, 'download_key_id': key['id']}
def _owned(source):
entries = {}
for key in _itch_account.owned_keys():
entry = _owned_entry(source, key)
if entry:
entries.setdefault(entry['id'], entry) # keys come newest first; one entry per game
return entries
def search(source, query, limit=50):
limit = max(0, min(int(limit), 100))
if not limit:
return []
entries = {}
tag = source.get('tag', 'openxr')
if tag not in FEEDS:
raise SourceError('Unsupported itch.io feed')
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
entries.setdefault(entry['id'], entry)
if source.get('owned'):
entries = _owned(source)
else:
entries = {}
tag = source.get('tag', 'openxr')
if tag not in FEEDS:
raise SourceError('Unsupported itch.io feed')
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
entries.setdefault(entry['id'], entry)
words = query.lower().split()
return [e for e in entries.values() if all(w in (e['name'] + ' ' + e['summary']).lower()
for w in words)][:limit]
def _owned_details(source, entry_id):
entry = _owned(source).get(str(entry_id))
if not entry:
raise SourceError('This game is not among the Android games you own on itch.io')
uploads = _itch_account.uploads(int(entry['id']), entry['download_key_id'])
versions = [{'version': str(u.get('display_name') or u.get('filename') or u['id']), 'version_code': None,
'upload_id': u['id'], 'name': u.get('filename'), 'min_sdk': None,
'demo': u.get('demo') is True or 'demo' in (u.get('traits') or []),
'size': u.get('size') if type(u.get('size')) is int else None,
'updated': str(u.get('updated_at') or u.get('created_at') or '')[:10] or None}
for u in _itch_account.android_uploads(uploads)]
entry = dict(entry, versions=versions, downloadable=bool(versions))
if versions:
entry.update({k: versions[0][k] for k in ('version', 'size', 'updated')})
return entry
def details(source, entry_id):
if source.get('owned'):
return _owned_details(source, entry_id)
entry = next((e for e in search(source, '', 100) if e['id'] == entry_id), None)
if not entry:
raise SourceError('Game is not in the current free Android VR feed; open its publisher page')
@@ -69,4 +144,11 @@ def details(source, entry_id):
def download(source, entry_id, version_code=None):
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
if not source.get('owned'):
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
if version_code is not None:
raise SourceError('itch.io does not publish Android version codes; download the latest upload')
entry = _owned_details(source, entry_id)
if not entry['versions']:
raise SourceError('This game has no Android download on itch.io')
return _itch_account.download(entry['versions'][0]['upload_id'], entry['download_key_id'])
+24 -60
View File
@@ -15,7 +15,7 @@ import time
import unicodedata
import frame_host
from apk_sources import SourceError, SourceLimited, _categories as categories
from apk_sources import SourceError, SourceLimited
_lock = threading.RLock()
_running = {}
@@ -23,7 +23,6 @@ _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():
@@ -167,11 +166,9 @@ def verdict(entry):
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
def decorate(entry, shots=12):
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
def decorate(entry):
from apk_sources import _images
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
**categories.classify(entry))
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry))
def details(source_id, entry_id):
@@ -189,13 +186,14 @@ def offer_rank(entry):
entry.get('trust') == 'official')
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
def group(entries, query='', vr=None, installable=False):
groups = {}
for entry in entries:
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
entry = decorate(entry)
# Unknown counts as flat, except the user's own purchases (itch.io doesn't say which are
# VR): those show under either choice rather than vanish from one.
if vr is not None and (entry.get('vr') is True) != vr \
and not (entry.get('owned') is True and entry.get('vr') is None):
continue
if installable and entry['fit']['installable'] is not True:
continue
@@ -208,7 +206,7 @@ def group(entries, query='', vr=None, installable=False, category=None, phone=Fa
offers.sort(key=offer_rank, reverse=True)
best = offers[0]
result.append({'name': best.get('name'), 'package': best.get('package'),
'summary': best.get('summary'), 'category': best.get('category'), 'offers': offers})
'summary': best.get('summary'), 'offers': offers})
q = normalise(query)
def browse(a): # a headset store: VR first, then apps with artwork, newest first
@@ -228,10 +226,15 @@ def group(entries, query='', vr=None, installable=False, category=None, phone=Fa
return result
def _launch(module, source, query, limit, category=None, phone=False):
def obtainable(source, entry):
"""Free, or a game the user owns, listed by their own account's source (itch.io purchases)."""
return entry.get('free') is True or (source.get('owned') is True and entry.get('owned') is True)
def _launch(module, source, query, limit):
"""One search per source at a time; the newest different query runs next."""
key = source['id']
task = {'event': threading.Event(), 'query': (query, limit, category or None, bool(phone)), 'started': time.monotonic(),
task = {'event': threading.Event(), 'query': (query, limit), 'started': time.monotonic(),
'job': (module, source)}
with _lock:
old = _running.get(key)
@@ -250,17 +253,13 @@ def _launch(module, source, query, limit, category=None, phone=False):
def _start(key, task):
module, source = task['job']
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)
query, limit = task['query']
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, **kwargs) if e.get('free') is True]
for e in module.search(source, query, limit=limit) if obtainable(source, e)]
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
except Exception as e:
task['error'] = str(e)
@@ -276,20 +275,14 @@ def _start(key, task):
threading.Thread(target=run, daemon=True).start()
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50, category=None, phone=False):
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
items, errors = registry()
if source and source not in [s['id'] for _, s in items]:
raise SourceError('Unknown source')
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, category, phone)) for m, s in chosen if not s.get('page_only')]
tasks = [(s, _launch(m, s, query, limit)) 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']}
@@ -304,8 +297,7 @@ 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, category, phone), 'sources': statuses, 'elsewhere': elsewhere,
'categories': [{'id': c, 'label': label} for c, label in categories.CATEGORIES]}
return {'apps': group(entries, query, vr, installable), 'sources': statuses, 'elsewhere': elsewhere}
def warm():
@@ -315,35 +307,7 @@ def warm():
items, _ = registry()
for m, s in items:
if s['enabled'] and not s.get('page_only'):
_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
_launch(m, s, '', 50) # same as the first browse, so that search reuses it
def install(source_id, entry_id, version_code=None, progress=None):
@@ -352,7 +316,7 @@ def install(source_id, entry_id, version_code=None, progress=None):
if not source['enabled']:
raise SourceError('This source is disabled')
entry = module.details(source, entry_id)
if entry.get('free') is not True or entry.get('downloadable') is not True:
if not obtainable(source, entry) or entry.get('downloadable') is not True:
raise SourceError('This app must be obtained from its developer page')
if progress:
progress('Downloading', None)
+94 -250
View File
@@ -645,96 +645,6 @@
.store-card-foot { display:flex; justify-content:space-between; align-items:center; gap:6px; margin-top:16px; font-size:11px; color:#a8b5c2; }
.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; }
@@ -818,14 +728,7 @@
.store-hero { height:285px; border-radius:9px; margin-bottom:26px; }
.hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; }
.hero-copy .hero-sum { font-size:13px; max-width:260px; }
.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-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; }
.store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; }
.store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; }
.store-card-foot { font-size:10px; margin-top:12px; } .store-summary { font-size:11px; }
@@ -1183,33 +1086,24 @@
<div class="page" data-page="android">
<section id="android">
<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>
<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" title="Choose where Discover looks for apps">Sources…</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><h1>Apps and games for the Frame</h1><p>Free Android apps and VR games from open app stores and developers' own pages, each marked with whether it runs on the Frame. Steam games are in <a href="#games">Games</a>.</p></div></div>
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form></div>
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">All</button><button data-filter="vr" aria-pressed="false">VR</button><button data-filter="flat" aria-pressed="false">Flat-screen</button><button data-filter="installable" aria-pressed="false">Installs on the Frame</button></div></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>
</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">
<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>
<section class="panel" id="installQueue" hidden>
<div class="shelf-head"><h2>Install queue</h2><span class="count" id="queueCount"></span></div>
<div class="list" id="queueList"></div>
@@ -1537,6 +1431,7 @@
<div class="sources-intro"><h1 id="sourcesTitle">Sources</h1><p>Where Discover looks for apps. Turn a source off to hide its apps.</p></div>
<div id="sourcesList"></div>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Add an F-Droid repository by its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><details><summary>This repository needs a login (optional)</summary><label>Username<input name="username" type="text" autocomplete="off" autocapitalize="none" spellcheck="false"></label><label>Password<input name="password" type="password" autocomplete="new-password"></label><p>Kept on this computer, apart from the address, and only sent to this repository over HTTPS.</p></details><button class="action">Add source</button></form>
<form id="itchAccount" class="source-repo-form"><h2>My itch.io purchases</h2><p id="itchAccountNote">Install the Android builds of games you own on itch.io. Create an API key in itch.io settings › API keys and paste it here. It stays on this computer and is only sent to itch.io.</p><label id="itchKeyField">itch.io API key<input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="300" placeholder="Paste your API key"></label><button class="action" id="itchConnect">Connect itch.io</button><button type="button" class="small" id="itchDisconnect" hidden>Disconnect itch.io</button></form>
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
</dialog>
@@ -3613,22 +3508,7 @@ $("modRemove").onclick = () => modAction("uninstall", "Remove UEVR");
$("modClose").onclick = () => $("modDlg").close();
// ---- Discover apps ----
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 sourceState = { vr: null, installable: false, apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
const storeInitial = name => [...String(name || "?")][0].toUpperCase(); // the small icon's stand-in
@@ -3636,37 +3516,25 @@ 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">` : ""; }
// Artwork: the banner or first screenshot. Under it (and instead of it, when there's none, or it
// doesn't load) the app's icon on a blurred wash of itself, or failing that a VR or app glyph.
const GLYPH_VR = `<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><rect x="5" y="15" width="38" height="20" rx="8"/><circle cx="17" cy="25" r="3.5"/><circle cx="31" cy="25" r="3.5"/></svg>`;
const GLYPH_APP = `<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><rect x="8" y="8" width="13" height="13" rx="3.5"/><rect x="27" y="8" width="13" height="13" rx="3.5"/><rect x="8" y="27" width="13" height="13" rx="3.5"/><rect x="27" y="27" width="13" height="13" rx="3.5"/></svg>`;
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`, fallback = `<span class="art-glyph">${o.vr ? GLYPH_VR : GLYPH_APP}</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)}">${fallback}${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)}">${fallback}<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];
const under = !path && valid(art.icon)
? `<img class="art-wash" src="${esc(art.icon)}" alt="" loading="lazy" decoding="async"><img class="art-icon" src="${esc(art.icon)}" alt="" loading="lazy" decoding="async">`
: fallback;
return `<span class="store-art ${cls}${image ? "" : " no-image"}" style="--hue:${storeHue(o.name)}">${under}${storeImage(image, label, eager)}${extra}</span>`;
function storeArt(o, cls="", eager=false, path=null, alt=null) {
const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0];
const icon = !path && /^\/source-image\/[\w-]+$/.test(o.artwork?.icon || "") ? o.artwork.icon : "";
const under = path ? "" : icon
? `<img class="art-wash" src="${esc(icon)}" alt="" loading="lazy" decoding="async"><img class="art-icon" src="${esc(icon)}" alt="" loading="lazy" decoding="async">`
: `<span class="art-glyph">${o.vr ? GLYPH_VR : GLYPH_APP}</span>`;
return `<span class="store-art ${cls}${image ? "" : " no-image"}" style="--hue:${storeHue(o.name)}">${under}${storeImage(image, alt || `${o.name} artwork`, eager)}</span>`;
}
function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue(o.name)}" aria-hidden="true">${esc(storeInitial(o.name))}${storeImage(o.artwork?.icon, "")}</span>`; }
document.addEventListener("error", e => {
if (!e.target.matches?.(".store-art img, .store-icon img")) 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(".store-art"); img.remove();
if (art && !art.querySelector("img")) {
art.classList.remove("icon-art", "shots");
if (!art.querySelector(".art-glyph")) art.insertAdjacentHTML("afterbegin", `<span class="art-glyph">${GLYPH_APP}</span>`);
}
const art = e.target.closest(".store-art");
e.target.remove();
// Nothing left to show: the glyph rather than an empty wash.
if (art && !art.querySelector("img") && !art.querySelector(".art-glyph")) art.insertAdjacentHTML("afterbegin", `<span class="art-glyph">${GLYPH_APP}</span>`);
}, 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) {
@@ -3682,13 +3550,24 @@ function sourceToken(s) {
}
async function loadSources() {
try {
const {sources} = await api("/api/sources"); sourceState.sources = sources;
$("sourceFilter").innerHTML = `<option value="">All sources</option>` + sources.filter(s=>s.enabled && !s.page_only).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
$("sourceFilter").value = sourceState.source;
const {sources, itch} = await api("/api/sources"); sourceState.sources = sources; showItchAccount(itch);
$("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")}${s.login ? " · Uses a login" : ""}</small>${sourceToken(s)}${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div>${s.page_only ? `<a class="source-web" href="${esc(sourcePage(s.url))}" target="_blank" rel="noopener" title="Its apps can't be searched from here">Website ↗</a>` : `<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; }
}
// Keep installation confidence explicit, including apps that have not been checked yet.
// Owned on itch.io (the user's own purchases) or free: what the store shows instead of a price.
const storePrice = o => o.owned ? (o.owned_label || "Owned") : "Free";
function showItchAccount(itch) {
const on = !!itch?.connected;
$("itchAccountNote").textContent = on ? `Connected${itch.username ? " as " + itch.username : ""}. Android builds of games you own appear in the store, marked Owned on itch.io. Disconnecting deletes the API key from this computer.` : "Install the Android builds of games you own on itch.io. Create an API key in itch.io settings › API keys and paste it here. It stays on this computer and is only sent to itch.io.";
$("itchKeyField").hidden = on; $("itchConnect").hidden = on; $("itchDisconnect").hidden = !on;
$("itchAccount").elements.key.required = !on;
}
async function itchAccount(body) {
$("sourceError").textContent = body.action === 'itch-connect' ? "Checking the key with itch.io…" : "Disconnecting…";
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;showItchAccount(result.itch);await loadSources();await searchSources(); }
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
}
// Will it run? Short enough for a card; the full sentence is in its tooltip and the details.
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"] };
function storeRuns(o) {
const [text, cls] = RUNS[o.verdict?.tone] || RUNS.unknown;
@@ -3696,48 +3575,42 @@ function storeRuns(o) {
}
const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app";
function storeCard(app) {
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="${esc(app.name)}: ${esc(o.verdict?.label || "not yet checked on the Frame")}">${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) || "From " + storeName(o.source_name))}</span><span class="store-card-foot"><span class="store-cat">${esc(o.phone_only ? "Phone app" : c ? storeCat(c)[1] : storeKind(o))}</span>${storeRuns(o)}</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>` : "";
const i=sourceState.apps.indexOf(app), o=app.offers[0], owned=app.offers.find(x=>x.owned);
return `<button class="store-card" data-app="${i}" aria-label="${esc(app.name)}: ${esc(o.verdict?.label || "not yet checked on the Frame")}">${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) || "From " + storeName(o.source_name))}</span>${owned ? `<span class="store-summary">${esc(storePrice(owned))}</span>` : ""}<span class="store-card-foot"><span>${storeKind(o)}</span>${storeRuns(o)}</span></span></button>`;
}
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2>${subtitle ? `<p>${esc(subtitle)}</p>` : ""}</div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
function storeEmpty(message) {
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>${message ? "Couldn't load apps" : "A little further to explore."}</h2><p>${message ? esc(message) : "No apps found this time. Try a different name, another category, or turn on phone-only apps."}</p><button class="action" id="storeReset">${message ? "Try again" : "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>`;
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>${message ? "Couldn't load apps" : "No apps found"}</h2><p>${message ? esc(message) : "Try another name, or clear the filters."}</p><button class="action" id="storeReset">${message ? "Try again" : "Clear filters"}</button></div>`;
}
function renderStore() {
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]}` : ""}`;
const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable;
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`;
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
// 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))));
const canInstall=o.downloadable && o.verdict?.tone !== 'blocked';
$("searchGrid").innerHTML=`<div class="store-hero" data-app="${apps.indexOf(featured)}">${storeArt(o,"",true)}<div 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 class="hero-sum">${esc(storeText(featured.summary))}</span><span class="hero-runs">${storeRuns(o)}</span><span class="hero-actions">${canInstall ? `<button class="action" data-hero-install="${apps.indexOf(featured)}">Install on the Frame</button>` : ""}<button class="hero-details" data-app="${apps.indexOf(featured)}">Details</button></span></div></div>` + rows + storeRow("Recently updated","Fresh from their creators",recent);
// The featured app: something that runs, with real artwork, and preferably VR. It isn't repeated below.
const runs=a=>a.offers[0].verdict?.tone!=='blocked', art=a=>a.offers[0].artwork?.banner;
const featured=apps.find(a=>a.offers[0].vr && art(a) && runs(a)) || apps.find(a=>art(a) && runs(a)) || apps.find(runs) || apps[0], o=featured.offers[0];
const rest=apps.filter(a=>a!==featured);
const tested=rest.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works'));
const vr=tested.length ? tested : rest.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked'));
// Rows don't repeat each other either: each takes the next apps not shown yet.
const shown=new Set(), take=list=>{ const out=list.filter(a=>!shown.has(a)).slice(0,5); out.forEach(a=>shown.add(a)); return out; };
const vrRow=take(vr);
const likely={works:0,ready:1,unknown:2,blocked:3}, rank=a=>likely[a.offers[0].verdict?.tone] ?? 2;
const popular=take([...rest].sort((a,b)=>rank(a)-rank(b) || (b.offers[0].popularity||0)-(a.offers[0].popularity||0)));
const recent=take(rest.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||''))));
const canInstall=o.downloadable && runs(featured);
$("searchGrid").innerHTML=`<div class="store-hero" data-app="${apps.indexOf(featured)}">${storeArt(o,"",true)}<div class="hero-copy"><span class="store-eyebrow">Featured${o.vr ? " in VR" : ""}</span><strong>${esc(featured.name)}</strong><span class="hero-sum">${esc(storeText(featured.summary))}</span><span class="hero-runs">${storeRuns(o)}</span><span class="hero-actions">${canInstall ? `<button class="action" data-hero-install="${apps.indexOf(featured)}">Install on the Frame</button>` : ""}<button class="hero-details" data-app="${apps.indexOf(featured)}">Details</button></span></div></div>` +
storeRow(tested.length ? "VR that works on the Frame" : "VR games and experiences", tested.length ? "Checked on a Frame" : "Made for headsets; most aren't checked on the Frame yet", vrRow) +
storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular" : "More to try", "", popular) +
storeRow("Recently updated", "New versions from their developers", recent);
}
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"}); }
function storeChips() { $("searchFilters").querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",b.dataset.filter==='all' ? sourceState.vr===null && !sourceState.installable : b.dataset.filter==='installable' ? sourceState.installable : sourceState.vr===(b.dataset.filter==='vr'))); }
async function searchSources(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),category:sourceState.category,phone:String(sourceState.phone)});
const params=new URLSearchParams({q:$("sourceQ").value,installable:String(sourceState.installable)});
if(sourceState.vr!==null)params.set("vr",String(sourceState.vr));
try {
const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
@@ -3759,7 +3632,7 @@ async function searchSources(quiet) {
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
}
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
function detailBadges(o) { return `<span class="store-badge">${storeKind(o)}</span>${storeRuns(o)}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
function detailBadges(o) { return `<span class="store-badge">${storeKind(o)}</span>${storeRuns(o)}<span class="store-badge">${esc(storePrice(o))}</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
// The store's app page. (Not renderDetail: the Devices page declares that further down,
// and a later function declaration silently replaces an earlier one; issue #68.)
// True when the page rendered; false when it shows the error instead.
@@ -3775,7 +3648,7 @@ function renderAppDetail() {
function renderAppDetailBody() {
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">${esc(storePrice(o))}</div><p class="detail-small">${o.owned ? "Downloads with your itch.io account." : "No purchase needed."}</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
updateInstall();
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
}
@@ -3836,11 +3709,8 @@ async function changeSource(body) {
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();};
$("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 h=e.target.closest('[data-hero-install]');if(h){openStoreApp(+h.dataset.heroInstall,true);return;}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();}};
$("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 h=e.target.closest('[data-hero-install]');if(h){openStoreApp(+h.dataset.heroInstall,true);return;}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;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();};
@@ -3852,7 +3722,7 @@ async function addSource(body) {
let error="";
const result=await runJob('Add repository',`repo:${body.url}`,()=>api('/api/sources',body).catch(e=>{error=e.message;throw e;}),j=>{error=j.error||"";});
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
if(result){$("addSource").reset();await loadSources();await searchSources();}
}
// A login typed into the address moves into the login fields, on the page too, before anything
// is sent: the address is shown and logged, the login never. It stays there if adding fails.
@@ -3897,6 +3767,9 @@ $("addSource").onsubmit=e=>{
const f=new FormData(e.target), url=f.get('url'), username=f.get('username'), password=f.get('password');
addSource({action:'add',url,fingerprint:f.get('fingerprint'),...(username ? {username,password} : {})});
};
// The key goes to the server once and leaves the page straight away: the field is cleared before the reply.
$("itchAccount").onsubmit=e=>{e.preventDefault();const input=e.target.elements.key, key=input.value.trim();input.value='';if(key)itchAccount({action:'itch-connect',key});};
$("itchDisconnect").onclick=()=>itchAccount({action:'itch-disconnect'});
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
function showAndroidTab(tab) {
@@ -3912,7 +3785,6 @@ 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 ----
@@ -3920,56 +3792,28 @@ 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); }
if (metaLib.connected) renderMetaApps(); // "On the Frame" marks in My Meta library
$("andCount").textContent = androidApps.length ? String(androidApps.length) : "";
renderLibrary();
$("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>`;
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>`;
+12 -15
View File
@@ -46,6 +46,7 @@ import frame_assistant # noqa: E402
import frame_android # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
from apk_sources import fdroid as apk_fdroid # noqa: E402
from apk_sources import _itch_account as itch_account # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_devices # noqa: E402
@@ -2429,14 +2430,11 @@ def source_search(query):
q = args.get('q', [''])[0]
vr = args.get('vr', [''])[0]
installable = args.get('installable', [''])[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):
if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'):
raise Failure('Invalid search filters', 400)
try:
return apk_search.search(q, vr=None if not vr else vr in ('true', '1'),
source=args.get('source', [None])[0], installable=installable in ('true', '1'),
category=category or None, phone=phone in ('true', '1'))
source=args.get('source', [None])[0], installable=installable in ('true', '1'))
except SourceError as e:
raise Failure(str(e), 400)
@@ -2496,6 +2494,13 @@ def source_manage(body):
if action == 'remove':
apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source'))
return {'message': 'Repository removed'}
if action == 'itch-connect': # the key is checked with itch.io, saved 0600, never echoed
key = body.get('key')
if not isinstance(key, str) or not key.strip() or len(key) > 300:
raise Failure('Paste your itch.io API key', 400)
return itch_account.connect(key)
if action == 'itch-disconnect':
return itch_account.disconnect()
raise Failure('Unknown source action', 400)
except SourceError as e:
raise Failure(str(e), 400)
@@ -2680,9 +2685,6 @@ 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":
@@ -2723,7 +2725,7 @@ class Handler(BaseHTTPRequestHandler):
except SourceError as e:
raise Failure(str(e), 400)
elif path == "/api/sources":
self.send_json({"sources": apk_search.sources()})
self.send_json({"sources": apk_search.sources(), "itch": itch_account.status()})
elif path == "/api/search":
self.send_json(source_search(url.query))
elif path == "/api/apk-versions":
@@ -2732,12 +2734,7 @@ class Handler(BaseHTTPRequestHandler):
ensure_master()
apps = frame_android.list_apps()
backfill_art(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]})
self.send_json({"apps": apps})
elif path == "/api/quest":
self.send_json(quest_status(url.query))
elif path == "/api/meta":
-489
View File
@@ -1,489 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APK Store</title>
<!-- Frame Control's Android store as a panel in the headset (docs/apks.md →
"The store in the headset"). Served by ui/server.py running on the Frame
(FRAME_LOCAL=1), opened by scripts/store-on-frame.sh at 1280×800. The same
sources, categories and artwork as the Store in Frame Control (/api/search,
ui/apk_sources/_categories.py), drawn for the laser: big targets, pages
instead of drag-scrolling (the panel gets mouse clicks, so a drag would
select text), the thumbstick (a mouse wheel) turns pages, and an on-screen
keyboard for search. -->
<style>
:root {
--bg: #0f1722; --rail: #131d2a; --card: #1b2938; --card-hi: #23364a; --line: #ffffff14;
--text: #e3e8ee; --bright: #fff; --dim: #93a4ba; --blue: #1a9fff; --link: #66c0f4;
--good: #8ed36b; --bad: #ef7a64;
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
--play: linear-gradient(to right, #75b022 5%, #588a1b 95%);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; height: 100%; background: radial-gradient(ellipse at 70% -20%, #1d3a5a 0%, transparent 60%), var(--bg);
color: var(--text); font: 19px/1.35 "Motiva Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
overflow: hidden; user-select: none; -webkit-user-select: none; }
button { font: inherit; color: inherit; background: #ffffff0d; border: 1px solid var(--line); border-radius: 12px;
min-height: 52px; padding: 0 20px; cursor: pointer; }
button:hover { background: #ffffff1c; }
button:disabled { opacity: .45; cursor: default; }
button.primary { background: var(--action); border: 0; font-weight: 600; color: #fff; }
button.play { background: var(--play); border: 0; font-weight: 600; color: #fff; }
button.danger { color: var(--bad); }
svg { width: 22px; height: 22px; flex: none; }
.app { display: grid; grid-template-columns: 236px 1fr; height: 100%; }
/* Rail */
.rail { background: #0c131ccc; border-right: 1px solid var(--line); padding: 18px 12px; display: flex; flex-direction: column; gap: 3px; }
.rail h1 { font-size: 22px; margin: 2px 10px 12px; display: flex; align-items: center; gap: 10px; color: var(--bright); }
.rail h1 svg { width: 28px; height: 28px; color: var(--blue); }
.rail button { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 47px; text-align: left; border: 0;
background: transparent; color: #b5c0cb; font-size: 17px; font-weight: 500; padding: 0 12px; border-radius: 10px; }
.rail button:hover { background: #ffffff10; color: var(--bright); }
.rail button[aria-pressed="true"] { background: #1a9fff2e; color: var(--bright); box-shadow: inset 4px 0 0 var(--blue); }
.rail .sep { height: 1px; background: var(--line); margin: 6px 8px; }
.rail .n { margin-left: auto; font-size: 13px; color: #c6e2f5; background: #1a9fff2e; border-radius: 10px; padding: 1px 8px; }
.rail .n:empty { display: none; }
.rail label { margin-top: auto; display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 15px; color: var(--dim); cursor: pointer; }
.rail input { width: 22px; height: 22px; accent-color: var(--blue); margin: 0; }
/* Main */
main { display: flex; flex-direction: column; min-width: 0; padding: 18px 22px; gap: 12px; }
.bar { display: flex; gap: 12px; align-items: center; }
.search { flex: 1; display: flex; align-items: center; gap: 12px; background: #0c1520; border: 1px solid var(--line);
border-radius: 12px; min-height: 54px; padding: 0 18px; color: var(--dim); }
.search input { flex: 1; background: none; border: 0; color: var(--text); font: inherit; outline: none; min-width: 0; }
.search input::placeholder { color: var(--dim); }
.heading { display: flex; align-items: baseline; gap: 14px; min-height: 30px; }
.heading b { color: var(--bright); font-size: 25px; letter-spacing: -.4px; }
.heading span { color: var(--dim); font-size: 16px; }
.heading .note { margin-left: auto; font-size: 14px; color: var(--dim); max-width: 520px; text-align: right; }
.content { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
.grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 14px; }
.grid.one-row { grid-template-rows: minmax(0, 1fr); }
.card { display: flex; flex-direction: column; padding: 0; overflow: hidden; text-align: left; min-height: 0; border-radius: 14px;
background: var(--card); border: 1px solid var(--line); }
.card:hover { background: var(--card-hi); border-color: #66c0f480; }
.card .art { flex: 0 0 52%; }
.card .body { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px 12px; min-height: 0; flex: 1; width: 100%; }
.card .title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.card .title strong { font-size: 17px; color: var(--bright); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.card .sum { color: var(--dim); font-size: 14px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 13px; color: var(--dim); }
/* Artwork: a banner, else screenshots side by side, else the icon over its own blur, else an initial. */
.art { position: relative; display: block; overflow: hidden; width: 100%;
background: radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%), transparent 70%), linear-gradient(140deg, hsl(var(--hue) 35% 18%), #101827); }
.art > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.art .initial { position: absolute; inset: 0; display: grid; place-items: center; font-size: 64px; font-weight: 700; color: #ffffff45; }
.art.shots { display: flex; gap: 6px; justify-content: center; padding: 8px 8px 0; }
.art.shots > img { position: relative; inset: auto; width: auto; max-width: 31%; height: 100%; object-position: top; border-radius: 6px 6px 0 0; box-shadow: 0 4px 14px #0008; }
.art.iconart > .backdrop { position: absolute; inset: -30%; width: 160%; height: 160%; object-fit: cover; filter: blur(28px) saturate(1.4) brightness(.55); }
.art.iconart > .mark { position: absolute; left: 50%; top: 50%; width: 36%; height: auto; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 22%; box-shadow: 0 8px 24px #0009; object-fit: cover; }
.art.shots .initial, .art.iconart .initial { display: none; }
.art .vr { position: absolute; z-index: 2; top: 8px; left: 8px; }
.icon { position: relative; display: inline-grid; place-items: center; width: 38px; height: 38px; border-radius: 9px; overflow: hidden; flex: none;
background: hsl(var(--hue) 35% 28%); color: #fff; font-size: 18px; font-weight: 600; }
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.vr { font-size: 12px; font-weight: 700; letter-spacing: .8px; color: #0e141b; background: #c2e7ff; border-radius: 5px; padding: 2px 7px; }
.fit { font-size: 12px; font-weight: 600; border-radius: 5px; padding: 2px 8px; white-space: nowrap; }
.fit.works { color: #c3efb2; background: #59bf4029; } .fit.ready { color: #bfe3fa; background: #1a9fff26; } .fit.blocked { color: #f3b2a6; background: #d9412629; }
.tag { font-size: 12px; font-weight: 600; color: #d6f5c8; background: #2d5a1f; border-radius: 10px; padding: 2px 9px; white-space: nowrap; }
/* Home */
.hero { position: relative; flex: 0 0 42%; padding: 0; border: 0; border-radius: 16px; overflow: hidden; text-align: left; isolation: isolate; }
.hero .art { position: absolute; inset: 0; height: 100%; }
.hero::after { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(90deg, #081421f0 0%, #08142180 50%, #08142100 85%); }
.hero .copy { position: relative; z-index: 2; padding: 26px 30px; max-width: 560px; display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.hero .eyebrow { display: flex; gap: 10px; align-items: center; font-size: 13px; font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase; color: #c2e7ff; }
.hero strong { font-size: 38px; color: #fff; line-height: 1.05; letter-spacing: -.8px; }
.hero .blurb { color: #e0e8ef; font-size: 17px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.hero .cta { margin-top: 4px; background: #ffffff22; border: 1px solid #ffffff44; border-radius: 10px; padding: 9px 18px; font-size: 16px; color: #fff; }
/* Pager */
.pager { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.pager button { min-width: 180px; }
.dots { display: flex; gap: 8px; align-items: center; color: var(--dim); font-size: 15px; }
.empty { grid-column: 1 / -1; grid-row: 1 / -1; display: grid; place-items: center; align-content: center; gap: 14px; color: var(--dim); text-align: center; padding: 30px; }
.empty h2 { margin: 0; color: var(--bright); font-size: 24px; }
.skeleton { border-radius: 14px; background: linear-gradient(100deg, #1b2938 30%, #24364a 50%, #1b2938 70%); background-size: 300% 100%; animation: shimmer 1.4s infinite; }
@keyframes shimmer { to { background-position: -150% 0; } }
/* Detail sheet and keyboard sit over everything. */
.overlay { position: fixed; inset: 0; background: #000b; display: grid; place-items: center; z-index: 10; }
.sheet { background: #16212e; border: 1px solid var(--line); border-radius: 22px; width: 1000px; max-height: 740px; overflow: hidden;
display: flex; flex-direction: column; box-shadow: 0 30px 80px #000c; }
.sheet > .art { flex: 0 0 250px; }
.sheet .gallery { flex: 0 0 250px; display: flex; gap: 10px; padding: 14px 14px 0; background: #0c131c; justify-content: center; overflow: hidden; }
.sheet .gallery img { height: 100%; width: auto; max-width: 32%; object-fit: cover; object-position: top; border-radius: 10px 10px 0 0; }
.sheet .inner { padding: 20px 28px 22px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; }
.sheet .top { display: flex; gap: 18px; align-items: center; }
.sheet .top .icon { width: 72px; height: 72px; border-radius: 16px; font-size: 32px; }
.sheet h2 { margin: 0; font-size: 30px; color: var(--bright); line-height: 1.1; }
.sheet .meta { color: var(--dim); font-size: 16px; }
.sheet .text { font-size: 17px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.sheet .why { background: #ffffff0a; border-radius: 12px; padding: 10px 16px; font-size: 15px; color: #c5d0db; }
.sheet .why ul { margin: 4px 0 0; padding-left: 22px; }
.sheet .acts { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; align-items: center; }
.sheet .acts .close { margin-left: auto; }
.progress { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 6px; font-size: 15px; color: var(--dim); }
.progress .track { height: 8px; border-radius: 4px; background: #ffffff14; overflow: hidden; }
.progress .fill { height: 100%; background: var(--blue); width: 30%; transition: width .3s; }
.progress.busy .fill { animation: busy 1.2s ease-in-out infinite; }
@keyframes busy { from { margin-left: -30%; } to { margin-left: 100%; } }
.kbd { position: fixed; left: 0; right: 0; bottom: 0; background: #131d2af5; border-top: 1px solid var(--line);
padding: 12px; display: grid; gap: 8px; z-index: 20; box-shadow: 0 -20px 50px #0009; }
.kbd .row { display: flex; gap: 8px; justify-content: center; }
.kbd button { min-width: 76px; min-height: 60px; padding: 0 10px; font-size: 21px; }
.kbd .wide { min-width: 320px; }
.toast { position: fixed; left: 50%; top: 18px; transform: translateX(-50%); background: #0d2236; border: 1px solid var(--blue);
border-radius: 14px; padding: 12px 20px; z-index: 30; max-width: 900px; box-shadow: 0 10px 30px #0008; }
.toast.bad { background: #3a1416; border-color: var(--bad); }
</style>
</head>
<body>
<div class="app">
<div class="rail" role="navigation" aria-label="Store">
<h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 7h16l-1.5 12.5a1 1 0 0 1-1 .9H6.5a1 1 0 0 1-1-.9z"/><path d="M8.5 9V6a3.5 3.5 0 0 1 7 0v3"/></svg>APK Store</h1>
<div id="cats"></div>
<div class="sep"></div>
<button data-view="installed" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3.5" y="4" width="7" height="7" rx="1.5"/><rect x="13.5" y="4" width="7" height="7" rx="1.5"/><rect x="3.5" y="14" width="7" height="7" rx="1.5"/><rect x="13.5" y="14" width="7" height="7" rx="1.5"/></svg>Installed<span class="n" id="instN"></span></button>
<label title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="phone"> Show phone-only apps</label>
</div>
<main>
<div class="bar">
<label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="q" placeholder="Search games and apps" autocomplete="off" spellcheck="false" aria-label="Search games and apps"></label>
<button id="kbdBtn" title="On-screen keyboard">⌨︎ Keyboard</button>
</div>
<div class="heading"><b id="title">Home</b><span id="count"></span><span class="note" id="note"></span></div>
<div class="content" id="content"></div>
<div class="pager">
<button id="prev">◀ Previous</button>
<span class="dots" id="page"></span>
<button id="next">Next ▶</button>
</div>
</main>
</div>
<div class="overlay" id="detail" hidden><div class="sheet" id="sheet"></div></div>
<div class="kbd" id="kbd" hidden></div>
<div class="toast" id="toast" hidden></div>
<script>
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
const $ = id => document.getElementById(id);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const PER_PAGE = 8;
// Same ids and order as STORE_CATS in index.html and ui/apk_sources/_categories.py.
const CATS = [
["", "Home", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
["vr", "VR", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
["games", "Games", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/>'],
["emulators", "Emulators", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4M8 10.5h3M9.5 9v3"/>'],
["watch", "Watch & listen", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z"/><path d="M8 20h8"/>'],
["read", "Read & learn", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
["create", "Create", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
["work", "Work", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
["web", "Web & chat", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
["tools", "Tools", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
];
const catLabel = id => (CATS.find(c => c[0] === id) || [])[1] || "";
const FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Won't run" };
// view: "" (home), a category id, or "installed"; jobs: offer key -> {stage, percent, done, result, error}
// installedFrom: offer key -> package it installed as (GitHub listings have no package);
// needsData: installed packages still waiting for their game data (any view, while installed)
const st = { view: "", q: "", phone: false, page: 0, apps: [], installed: new Map(), jobs: new Map(), installedFrom: new Map(),
needsData: new Set(), request: 0, loading: false, retry: 0 };
async function api(path, body) {
const opts = body === undefined ? { headers: { "X-Frame-UI": UI_KEY } } : {
method: "POST", headers: { "Content-Type": "application/json", "X-Frame-UI": UI_KEY }, body: JSON.stringify(body) };
const r = await fetch(path, opts);
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
return j;
}
let toastTimer;
function toast(msg, bad = false) {
const t = $("toast");
t.textContent = msg; t.className = "toast" + (bad ? " bad" : ""); t.hidden = false;
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.hidden = true, bad ? 8000 : 4500);
}
// ---- Artwork (only the server's /source-image/ handles are ever used as an image) ----
const hue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
const initial = name => [...String(name || "?")][0].toUpperCase();
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
const img = (u, cls = "") => valid(u) ? `<img${cls ? ` class="${cls}"` : ""} src="${esc(u)}" alt="" decoding="async">` : "";
function art(name, a = {}, extra = "") {
const base = `style="--hue:${hue(name)}"`, ini = `<span class="initial" aria-hidden="true">${esc(initial(name))}</span>`;
if (valid(a.banner)) return `<span class="art" ${base}>${ini}${img(a.banner)}${extra}</span>`;
const shots = (a.screenshots || []).filter(valid).slice(0, 3);
if (shots.length) return `<span class="art shots" ${base}>${ini}${shots.map(u => img(u)).join("")}${extra}</span>`;
if (valid(a.icon)) return `<span class="art iconart" ${base}>${ini}${img(a.icon, "backdrop")}${img(a.icon, "mark")}${extra}</span>`;
return `<span class="art" ${base}>${ini}${extra}</span>`;
}
const icon = (name, a = {}) => `<span class="icon" style="--hue:${hue(name)}" aria-hidden="true">${esc(initial(name))}${img(a.icon)}</span>`;
document.addEventListener("error", e => {
const el = e.target;
if (!el.matches?.(".art img, .icon img")) return;
if (!el.dataset.retried) { el.dataset.retried = "1"; setTimeout(() => { el.src = el.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return; }
const box = el.closest(".shots, .iconart"); el.remove();
if (box && !box.querySelector("img")) box.classList.remove("shots", "iconart");
}, true);
const text = s => String(s || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ");
// ---- Data ----
async function loadInstalled() {
try {
st.installed = new Map((await api("/api/android")).apps.map(a => [a.package, a]));
for (const pkg of st.needsData) if (!st.installed.has(pkg)) { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); } // removed
} catch (e) { toast(`Couldn't list installed apps: ${e.message}`, true); }
$("instN").textContent = st.installed.size || "";
}
async function search(quiet) {
const request = ++st.request;
clearTimeout(st.retry);
if (st.view === "installed") { st.loading = false; st.page = 0; return render(); } // a pending search is now stale
st.loading = !quiet; if (!quiet) render();
const params = new URLSearchParams({ q: st.q, category: st.view, phone: String(st.phone) });
try {
const result = await api("/api/search?" + params);
if (request !== st.request) return;
st.apps = result.apps; st.loading = false;
const loading = result.sources.filter(s => s.status === "loading").map(s => s.name);
$("note").textContent = loading.length ? `Still fetching ${loading.join(" and ")}; more apps will appear.` : "";
if (loading.length) st.retry = setTimeout(() => request === st.request && search(true), 6000);
if (!quiet) st.page = 0;
render();
} catch (e) {
if (request !== st.request) return;
st.loading = false; st.apps = []; render(`Couldn't reach the app shelves: ${e.message}`);
}
}
const offerKey = app => `${app.offers[0].source}:${app.offers[0].id}`;
// Always the latest state: an Installed card's own copy goes stale after Stop or Remove.
function installedOf(app) {
const pkg = app.installed?.package || app.package || st.installedFrom.get(offerKey(app));
return pkg ? st.installed.get(pkg) : undefined;
}
// ---- Pages ----
function list() {
if (st.view === "installed") {
const q = st.q.toLowerCase();
return [...st.installed.values()].filter(m => !q || `${m.label} ${m.package}`.toLowerCase().includes(q))
.map(m => ({ installed: m, name: m.label || m.package, package: m.package, summary: m.version ? `v${String(m.version).replace(/^v/i, "")}` : "",
offers: [{ name: m.label, artwork: m.store_art || {}, vr: m.vr && !m.flatscreen }] }));
}
return st.home ? st.home.rest : st.apps;
}
function card(app, i) {
const o = app.offers[0], m = installedOf(app), job = st.jobs.get(offerKey(app));
const state = m ? `<span class="tag">${m.running ? "Running" : "Installed"}</span>`
: job && !job.done ? `<span class="tag">Installing…</span>`
: FIT[o.verdict?.tone] ? `<span class="fit ${o.verdict.tone}">${FIT[o.verdict.tone]}</span>` : "";
const kind = o.phone_only ? "Phone app" : catLabel(app.category || o.category) || (m ? (o.vr ? "VR app" : "Android app") : "Android app");
return `<button class="card" data-i="${i}" aria-label="${esc(app.name)}">${art(app.name, o.artwork, o.vr ? '<span class="vr">VR</span>' : "")}
<span class="body"><span class="title">${icon(app.name, o.artwork)}<strong>${esc(app.name)}</strong></span>
<span class="sum">${esc(text(app.summary))}</span><span class="foot"><span>${esc(kind)}</span>${state}</span></span></button>`;
}
function render(error) {
const home = st.view === "" && !st.q;
$("title").textContent = st.q ? `Results for “${st.q}”` : st.view === "installed" ? "Installed" : st.view ? catLabel(st.view) : "Home";
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.view === st.view)));
const content = $("content");
if (st.loading) {
content.innerHTML = `<div class="grid">${Array.from({ length: PER_PAGE }, () => '<div class="skeleton"></div>').join("")}</div>`;
$("count").textContent = ""; $("page").textContent = ""; $("prev").disabled = $("next").disabled = true; return;
}
// Home: a featured VR app and four picks, then pages of one app from each category in turn.
st.home = null;
if (home && st.apps.length) {
const ok = st.apps.filter(a => a.offers[0].verdict?.tone !== "blocked");
const heroes = ok.filter(a => a.offers[0].vr && valid(a.offers[0].artwork?.banner));
const featured = heroes[Math.floor(Date.now() / 864e5) % (heroes.length || 1)] || ok.find(a => valid(a.offers[0].artwork?.banner));
const queues = CATS.slice(1).map(([id]) => ok.filter(a => a.category === id && a !== featured));
const mixed = [];
for (let i = 0; queues.some(q => q[i]); i++) queues.forEach(q => q[i] && mixed.push(q[i]));
st.home = { featured, rest: mixed.slice(0, 4 + 5 * PER_PAGE) }; // six pages of picks; the categories have the rest
}
const items = list();
const first = st.home?.featured ? 4 : PER_PAGE; // the first home page shares its space with the banner
const pages = Math.max(1, items.length <= first ? 1 : 1 + Math.ceil((items.length - first) / PER_PAGE));
st.page = Math.min(st.page, pages - 1);
const start = st.page === 0 ? 0 : first + (st.page - 1) * PER_PAGE;
const shown = items.slice(start, start + (st.page === 0 ? first : PER_PAGE));
$("count").textContent = home ? "" : `${items.length.toLocaleString()} app${items.length === 1 ? "" : "s"}`;
$("page").textContent = `Page ${st.page + 1} of ${pages}`;
$("prev").disabled = st.page === 0; $("next").disabled = st.page >= pages - 1;
const empty = error ? `<div class="empty"><h2>No connection</h2>${esc(error)}</div>`
: st.view === "installed" && !st.q ? `<div class="empty"><h2>Nothing installed yet</h2>Pick an app in the store and press Install.<button class="primary" data-view="">Browse the store</button></div>`
: `<div class="empty"><h2>No apps here</h2>Try another category${st.phone ? "" : ", or show phone-only apps"}.</div>`;
let html = "";
if (st.page === 0 && st.home?.featured) {
const f = st.home.featured, o = f.offers[0];
html += `<button class="hero" data-feature="1" aria-label="${esc(f.name)}">${art(f.name, o.artwork)}<span class="copy">
<span class="eyebrow">${o.vr ? '<span class="vr">VR</span>' : ""}Featured</span><strong>${esc(f.name)}</strong>
<span class="blurb">${esc(text(f.summary))}</span><span class="cta">See ${esc(f.name)} ›</span></span></button>`;
}
html += `<div class="grid${st.page === 0 && st.home?.featured ? " one-row" : ""}">${shown.length ? shown.map((a, i) => card(a, start + i)).join("") : empty}</div>`;
content.innerHTML = html;
}
function turn(by) {
if (by < 0 ? $("prev").disabled : $("next").disabled) return;
st.page += by; render();
}
// ---- Detail sheet ----
let current = null; // {app, installed}
const ver = v => String(v || "").replace(/^v/i, "");
const size = n => n >= 1e9 ? (n / 1e9).toFixed(1) + " GB" : n >= 1e6 ? Math.round(n / 1e6) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
function openDetail(app) {
current = app; drawDetail(); $("detail").hidden = false;
const o = app.offers[0];
if (o.source && o.id && !app.detailed) { // the full entry has all the screenshots
api(`/api/sources/details?source=${encodeURIComponent(o.source)}&id=${encodeURIComponent(o.id)}`)
.then(full => { if (current === app) { app.offers[0] = { ...o, ...full }; app.detailed = true; drawDetail(); } }).catch(() => {});
}
}
function drawDetail() {
const app = current; if (!app) return;
const o = app.offers[0], m = installedOf(app), key = offerKey(app), job = st.jobs.get(key), data = m && st.jobs.get("data:" + m.package);
if (app.installed && !m) return closeDetail(); // removed
const shots = (o.artwork?.screenshots || []).filter(valid);
const top = valid(o.artwork?.banner) ? art(app.name, { banner: o.artwork.banner })
: shots.length ? `<div class="gallery">${shots.slice(0, 3).map(u => img(u)).join("")}</div>` : art(app.name, o.artwork || {});
const meta = [o.developer, o.source_name, o.version ? `v${ver(o.version)}` : m?.version ? `v${ver(m.version)}` : "", o.size ? size(o.size) : ""].filter(Boolean).join(" · ");
const reasons = (o.fit?.reasons || []).filter(Boolean);
let acts;
const progress = j => {
const pct = typeof j.percent === "number" ? Math.max(3, Math.min(100, j.percent)) : null;
return `<div class="progress${pct === null ? " busy" : ""}"><span>${esc(j.stage || "Downloading")}${pct === null ? "…" : ` ${Math.round(pct)}%`}</span><div class="track"><div class="fill" style="${pct === null ? "" : `width:${pct}%`}"></div></div></div>`;
};
const gameData = !(m && st.needsData.has(m.package)) ? "" : data && !data.done ? progress(data)
: `<button class="primary" data-act="game-data">Add game data</button><span class="meta">Open it once on the Frame first.</span>`;
if (job && !job.done) {
acts = progress(job);
} else if (m) {
acts = gameData + (m.running ? `<button data-act="stop">■ Stop</button>` : `<button class="play" data-act="launch">▶ Open</button>`)
+ `<button class="danger" data-act="remove">Remove</button>`;
} else if (!o.source) {
acts = "";
} else if (o.downloadable === false) {
acts = `<button disabled>Get it from the developer's page</button>`;
} else if (o.verdict?.tone === "blocked") {
acts = `<button disabled>Won't run on the Frame</button>`;
} else {
acts = `<button class="primary" data-act="install">Install</button>`;
}
const blurb = o.description || o.summary || (app.installed ? "" : app.summary); // an Installed card's summary is its version, already in meta
const failed = data?.error || job?.error;
const note = failed ? `<div class="meta" style="color:var(--bad)">${esc(failed)}</div>` : "";
$("sheet").innerHTML = `${top}<div class="inner">
<div class="top">${icon(app.name, o.artwork)}<div><h2>${esc(app.name)}</h2><div class="meta">${esc(meta)}</div></div>
${o.vr ? '<span class="vr" style="margin-left:auto">VR</span>' : ""}</div>
${blurb ? `<div class="text">${esc(text(blurb))}</div>` : ""}
${o.verdict ? `<div class="why"><b>${esc(o.verdict.label)}</b>${reasons.length ? `<ul>${reasons.slice(0, 3).map(r => `<li>${esc(r)}</li>`).join("")}</ul>` : ""}</div>` : ""}
${note}<div class="acts">${acts}<button class="close" data-act="close">Close</button></div></div>`;
}
function closeDetail() { current = null; $("detail").hidden = true; }
async function runJob(key, start) {
const job = { stage: "Downloading", percent: null, done: false };
st.jobs.set(key, job); drawDetail(); render();
try {
const { job: id } = await start();
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(id)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
Object.assign(job, { stage: j.stage || job.stage, percent: j.percent ?? null });
if (!j.done) { drawDetail(); continue; }
if (j.error) throw new Error(j.error);
toast(j.message); job.result = j.result || {};
break;
}
} catch (e) { job.error = e.message; toast(e.message, true); }
finally {
job.done = true;
if (!job.error) st.jobs.delete(key); // a failure stays, so the sheet can say why
await loadInstalled(); render(); drawDetail();
}
return job;
}
$("sheet").onclick = async e => {
const b = e.target.closest("[data-act]"); if (!b || !current) return;
const app = current, o = app.offers[0], m = installedOf(app), act = b.dataset.act, key = offerKey(app);
if (act === "close") return closeDetail();
if (act === "install") {
toast(`Installing ${app.name}. The first time takes a minute…`);
const job = await runJob(key, () => api("/api/sources/install", { source: o.source, id: o.id, version_code: o.version_code ?? null }));
if (job.result?.package) {
st.installedFrom.set(key, job.result.package);
if (job.result.game_data) st.needsData.add(job.result.package);
}
return render(), drawDetail();
}
if (act === "game-data" && m) { // its own job, by package: a failed copy keeps the pending data, so it can be tried again
const job = await runJob("data:" + m.package, () => api("/api/sources", { action: "game-data", package: m.package }));
if (!job.error) st.needsData.delete(m.package);
return render(), drawDetail();
}
const pkg = m?.package; if (!pkg) return;
if (act === "remove" && b.dataset.sure !== "1") {
b.dataset.sure = "1"; b.textContent = "Remove it and its data?"; return; // no confirm(): a separate window the laser has to find
}
b.disabled = true;
try {
const r = await api("/api/android", { action: act, package: pkg });
toast(r.message);
if (act === "remove") { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); }
if (act === "launch") closeDetail();
} catch (err) { toast(err.message, true); }
await loadInstalled(); render();
if (current === app) drawDetail();
};
$("detail").onclick = e => { if (e.target === $("detail")) closeDetail(); };
// ---- Navigation ----
$("cats").innerHTML = CATS.map(([id, label, path]) => `<button data-view="${id}" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${path}</svg>${esc(label)}</button>`).join("");
document.addEventListener("click", e => {
const v = e.target.closest("[data-view]");
if (v && !e.target.closest("#sheet")) {
st.view = v.dataset.view; st.q = ""; $("q").value = "";
if (st.view === "installed") loadInstalled().then(() => search()); else search();
return;
}
if (e.target.closest("[data-feature]")) return openDetail(st.home.featured);
const c = e.target.closest(".card[data-i]");
if (c) openDetail(list()[+c.dataset.i]);
});
$("phone").onchange = e => { st.phone = e.target.checked; search(); };
$("prev").onclick = () => turn(-1);
$("next").onclick = () => turn(1);
// The thumbstick arrives as a mouse wheel: one notch, one page.
let wheelAt = 0;
document.querySelector("main").addEventListener("wheel", e => {
e.preventDefault();
if (!$("detail").hidden || Date.now() - wheelAt < 350 || Math.abs(e.deltaY) < 4) return;
wheelAt = Date.now(); turn(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
let qTimer;
$("q").oninput = e => { clearTimeout(qTimer); qTimer = setTimeout(() => { st.q = e.target.value.trim(); search(); }, 250); };
document.addEventListener("keydown", e => { if (e.key === "Escape") { closeDetail(); $("kbd").hidden = true; } });
// ---- On-screen keyboard (a text field on a panel may not bring up Steam's) ----
const ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
$("kbd").innerHTML = ROWS.map(r => `<div class="row">${[...r].map(k => `<button data-k="${k}">${k}</button>`).join("")}</div>`).join("")
+ `<div class="row"><button data-k="clear">Clear</button><button class="wide" data-k=" ">space</button><button data-k="back">⌫</button><button data-k="done" class="primary">Done</button></div>`;
$("kbd").onclick = e => {
const b = e.target.closest("[data-k]"); if (!b) return;
const k = b.dataset.k, q = $("q");
if (k === "done") { $("kbd").hidden = true; return; }
q.value = k === "clear" ? "" : k === "back" ? q.value.slice(0, -1) : q.value + k;
q.dispatchEvent(new Event("input"));
};
$("kbdBtn").onclick = () => $("kbd").hidden = !$("kbd").hidden;
loadInstalled().then(() => search());
</script>
</body>
</html>