Compare commits

..
Author SHA1 Message Date
SAPHID d546568a11 fix(store): preserve resolver cleanup and test lookup budgets deterministically 2026-10-17 23:58:05 +00:00
Alex Southwell 5b531c84fb Merge pull request #90 from saphid/feat/repo-auth
Store: repositories behind a login, and a GitHub token in source settings
2026-10-18 10:57:12 +11:00
SAPHID 57a9d8c717 chore: resolve conflicts with main 2026-10-17 22:10:19 +00:00
saphidandClaude Opus 5.5 baaddd7622 Headset store: pending game data belongs to the installed package, in every view, and goes when the app does
From review round 6.

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

From review round 5.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:37:27 +11:00
saphidandClaude Opus 5.5 4ecc03280e Source logins: refuse addresses with a backslash and a login; decode escapes in one pass
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:41:40 +11:00
saphidandClaude Opus 5.5 2f1aeaac72 Source logins: refuse an invalid address that holds a login; keep undecodable bytes as typed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:35:50 +11:00
saphidandClaude Opus 5.5 8347c2c3f7 Source logins: let the URL parser find a pasted login; text matching only as a fallback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:30:54 +11:00
saphidandClaude Opus 5.5 a94c45d014 Source logins: strip a pasted login even with tabs, newlines or backslashes in the address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:26:18 +11:00
saphidandClaude Opus 5.5 e20225604d Source logins: keep repository logins inside the repository directory
- Every request made with an F-Droid repository's login (entry.jar, index,
  APKs), redirects included, now uses the scoped check: same origin and
  inside the repository directory by whole plain path segments. A
  same-origin redirect elsewhere on the server gets no login.
- _child() refuses file names a server could normalise outside the
  repository: double-encoded dots, ';' path parameters, encoded slashes or
  backslashes (anything _credentials.path_segments rejects).
- Store page: moveLogin() strips the login from the address with plain
  text matching first, then decodes with a fallback to the text as typed,
  so '100%done', '%FF' or an address the URL parser rejects can no longer
  leave the password in the field.

Tests: outgoing headers for '%252e%252e/...' and '..;/...' payloads and a
same-origin redirect outside the repository carry no Authorization; the
page test covers undecodable and unparseable logins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:12:10 +11:00
saphidandClaude Opus 5.5 2d81c92114 Source logins: fix review findings (artwork scope, file lock, page, backoff)
- Artwork: a saved login is only sent for URLs under the repository by whole
  path segments. Paths with dot segments (plain or percent-encoded), encoded
  slashes/backslashes, double encoding or ';' get nothing, since a server
  could normalise them outside the repository. Checked again on every
  redirect hop; Handler(scoped=True) does the same for urllib callers.
- Credentials file: every read-modify-write holds an inter-process lock
  (source-credentials.lock, flock or msvcrt), so the CLI and the app can't
  undo each other's saves or bring back a removed login.
- Store page: a login typed into the address moves into the login fields,
  and out of the address field, before the add starts, and stays there if
  it fails.
- Rate-limit backoff is keyed per login (anonymous or each token), so saving
  a GitHub token after anonymous requests were limited is used at once.

Regression tests: outgoing artwork headers for traversal URLs and a
redirect to one, two processes churning the credentials file, anonymous
limit then token, and the page's login handling in node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:00:33 +11:00
saphidandClaude Opus 5.5 fe73906f1f Docs: repositories behind a login and GitHub tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:46:35 +11:00
saphidandClaude Opus 5.5 84912ea4e1 Store: repositories behind a login, and a GitHub token in source settings
F-Droid repositories can now be added with HTTP basic auth, either as
https://user:pass@host/fdroid/repo (as F-Droid's client accepts) or with
separate username/password fields (store form, server API, and the CLI's
--username/--password-stdin). The login is stripped from the saved URL and
kept in a new shared module, apk_sources/_credentials.py, which writes
source-credentials.json in the data folder with mode 0600.

A credential is only ever added per request to its exact origin (scheme,
host, port): redirects elsewhere get nothing and have Authorization
removed. HTTPS only. 401 (or 403 with a login) on add gives a "check the
username and password" error and saves nothing; signature, pin, TOFU,
hash and rollback checks are unchanged. Removing a repository deletes its
login.

GitHub gets a user-settable token (FRAME_GITHUB_TOKEN still wins), sent as
Bearer only to api.github.com. If GitHub answers 401, the request is
retried once without it and the rejection is reported in source settings.
The source list shows only whether a token is set, never its value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:46:35 +11:00
33 changed files with 2862 additions and 616 deletions

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ desktop or panels.
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
+1 -1
View File
@@ -73,7 +73,7 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
Browse the shots you take in the headset and save them to your Pictures folder.
**⌨️ Keyboard and trackpad**<br>
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one.
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
</td>
</tr>
+54 -1
View File
@@ -58,8 +58,61 @@ form shows the pinned one ("Trusted on first use: …") so you can compare it.
Re-adding an existing URL preserves its pin; changing it requires deliberately
removing and re-adding it.
### Repositories behind a login
Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own
client, the login can be part of the address
(`https://user:pass@example.org/fdroid/repo`), or given separately: in the
store's **Add a source** form under "This repository needs a login", in the
server API (`username` and `password` beside `url`), or on the command line,
where the password is read from standard input so it stays out of shell history
and `ps`:
```sh
printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin
```
How the login is kept and used:
- **Kept out of the address.** The `user:pass@` part is removed before anything
is saved, so the saved repository URL, its id, the source list, job results
and the page never contain it. The store only says "Uses a login".
- **Stored separately.** Logins live in
`frame_host.data_dir('source-credentials.json')`
(`~/Library/Application Support/Frame Control/source-credentials.json` on
macOS), written with mode 0600 and keyed by source id. They are stored in
plain text there, protected by file permissions only (on Windows,
the folder's normal ACLs). Removing the repository deletes its login, and
the file goes when the last login is removed. Each change holds a lock
file (`source-credentials.lock`), so the app and the command line can't undo
each other's saves or removals.
- **HTTPS only.** An `http://` address with a login is refused before any request.
- **Inside the repository only.** The `Authorization` header is decided
separately for every request, redirects included: index, `entry.jar`,
APKs and the store's artwork proxy. It is sent only when the request's
scheme, host and port match the repository's and its path is inside the
repository's directory by whole path segments. A redirect to another host
or port, or to another path on the same server, gets no login, and that
request's own `Authorization` header is removed. Paths with `.` or `..`
segments (plain or percent-encoded), encoded `/` or `\`, double encoding
or `;` get no login, since a server could normalise them to a path
outside the repository. Index file names like that are refused outright.
- **Never echoed.** Logins are not written to logs, error messages, API
responses or the cache. The CLI prints only the saved source.
- **Checked before saving.** As with any repository, the whole index must
download and verify before anything is saved. HTTP 401 (or 403 when a login
was sent) stops the add with "check the username and password" and saves
nothing: no repository, no login, no rollback state. A 403 with a login is
not treated as rate limiting.
- **Same verification.** The login changes only who may download. Signature,
fingerprint pin or TOFU, index hash and rollback checks apply exactly as for
any other repository.
Re-adding the same address with a new login replaces the saved one. Re-adding
it without a login keeps the saved one.
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`,
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
`sources`, `search`, `details`, and `download` from the shared source contract.
This change supplies the CLI and API; the integrated source-management UI is
+17 -4
View File
@@ -45,10 +45,23 @@ search. Results outside the curated list stay page-only, even if a repository
claims an open-source license. This prevents an arbitrary tagged mirror from
becoming a trusted downloader. Extend the curated JSON after provenance review.
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
quota. Tokens are sent only to `api.github.com`, never written to the cache,
never sent to asset hosts, and removed on redirects. The adapter does not
read `gh` credentials automatically. Metadata is cached for one hour under
For a higher API quota, add a GitHub access token in the store's source
settings (the GitHub row's token field) or through the server API
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still
works and wins over a saved token. A saved token is kept with repository
logins in `source-credentials.json` (mode 0600, see
[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list
reports only whether a token is set, where it came from and whether GitHub
rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to
`api.github.com`, never written to the cache, never sent to asset hosts (public
release downloads don't need them), and removed on redirects. If GitHub answers
401 to the token, the request is retried once without it, as Obtainium does.
The rejection is logged without the token and shown in source settings. The
token is not tried again until it is saved again or the app restarts. Rate-limit
backoff is kept per login (anonymous, or each token), so saving a token after
anonymous requests were limited lets the next request go out with it. The
adapter does not read `gh` credentials automatically. Metadata is cached for one hour under
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
fetches at most ten releases. Rate-limit errors are surfaced without retry
loops. Asset IDs and release tags are not Android version codes: metadata
+57
View File
@@ -183,6 +183,63 @@ See
[compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md).
## Store and Library in Frame Control
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
*Social Network* is Web & chat).
Apps built around a phone are hidden from browsing unless **Show phone-only
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
a category that only makes sense in a phone's own system (launchers, keyboards,
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
fitness, alarms, system tools) unless a useful category says otherwise
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
part (a widget, a launcher, a battery tool), and apps that need hardware the
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
Without a search, each source offers its best 300 for the chosen category:
apps with a banner first, then with screenshots, then the newest.
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
The **APK Store** panel is the Store above, drawn for the laser: the same
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
its own floating panel:
```sh
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
scripts/store-on-frame.sh close
```
`open` copies the same bundle the iPhone app uses
(`ios/scripts/make_frame_bundle.py`) to
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
all run on the Frame, so the Mac can go away after `open`.
The page has a category rail (Home, the store's categories, Installed), eight
art cards a page, and a featured VR app on Home. It turns pages instead of
scrolling, because a tagged panel gets mouse clicks and a drag would select
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
the real sources): Home, categories, Installed, the detail sheet. **Still to
check in the headset:** that the panel opens and takes laser clicks, that an
install completes when run on the Frame, and that **Open** brings up the app's
panel.
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
+1 -1
View File
@@ -133,7 +133,7 @@ rejects invalid types, out-of-range levels and invalid mute states before SSH.
Other settings link to the existing forms and their validation, progress,
confirmations and reset behavior: per-instance Android resolution/density/text,
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
sources and artwork. Android display links reveal **On your Frame** before
sources and artwork. Android display links reveal the **Library** before
scrolling to the form. Mac streaming remains searchable on all platforms, with
its action shown only when the server is on macOS.
+1
View File
@@ -89,6 +89,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
| Script | Runs on | Purpose |
|---|---|---|
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
| `scripts/store-on-frame.sh` | Mac → Frame | Open the APK Store (Frame Control's Android store) as a panel in the headset; installs run on the Frame (**inferred**; see [docs/apks.md](apks.md)) |
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
+4 -7
View File
@@ -151,13 +151,10 @@ two fingers to scroll, two-finger tap to right-click) plus a text field that
types on the Frame. On a computer, clicking the pad passes your mouse and
keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac).
Since Control (below), the pad goes through **gamescope's own input socket**
([`ui/frame_touch.py`](../ui/frame_touch.py)), so it reaches whichever panel has
focus, Steam's own menus included, and needs nothing installed on the Frame.
Only characters that aren't on a US keyboard (accents, emoji) go through
**KDE Connect** as below; it starts the first time you type one. (Before
Control, everything went through KDE Connect, which is why the notes below
describe it in detail.)
It goes through **KDE Connect**, the first-party route (KDE makes the Frame's
desktop): Frame Control's server runs [`ui/frame_input_agent.py`](../ui/frame_input_agent.py)
on the Frame, which talks KDE Connect's own LAN protocol to the Frame's
`kdeconnectd` as if it were a phone. KDE Connect does the typing and clicking.
**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
+1 -1
View File
@@ -18,7 +18,7 @@ import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env zsh
# Mac-side: the APK Store in the headset. Frame Control's Android catalogue
# (the same rated F-Droid list, Install, Open and Remove) as its own floating
# panel on the Steam Frame, so you can browse and install apps from inside.
# See docs/apks.md → "The store in the headset".
#
# Frame Control's server runs on the Frame itself (FRAME_LOCAL=1, as the iPhone
# app runs it) on the Frame's loopback, and Chromium opens ui/store.html as a
# panel. Installs then download and verify the APK on the Frame; the Mac isn't
# needed after `open`.
#
# Usage:
# scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
# scripts/store-on-frame.sh close # close the panel and stop the server
# scripts/store-on-frame.sh status
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
HERE=${0:A:h}
ROOT=${HERE:h}
PORT=47812 # the Frame's 127.0.0.1 only
APPID=2001947812 # the panel's id (panel-on-frame.sh's range)
# Not ~/.cache/frame-control: the iPhone app prunes other versions there.
REMOTE=.local/share/frame-control/store-server
cmd=${1:-}
# Same bundle the iPhone app copies (ios/scripts/make_frame_bundle.py), so the
# server on the Frame has exactly the files the desktop app ships.
deploy() {
local tmp version
tmp=$(mktemp -d)
trap "rm -rf ${(q)tmp}" EXIT
version=$(python3 "$ROOT/ios/scripts/make_frame_bundle.py" "$tmp/bundle.tar.gz")
if ! ssh "$FRAME_ALIAS" "test -f $REMOTE/$version/ui/server.py"; then
print -u2 "==> Copying Frame Control ($version) to the Frame"
ssh "$FRAME_ALIAS" "set -e; mkdir -p $REMOTE; cd $REMOTE; rm -rf $version.tmp; mkdir $version.tmp
tar xzf - -C $version.tmp; rm -rf $version; mv $version.tmp $version" <"$tmp/bundle.tar.gz"
fi
# Older versions, unless a server is still running from one.
ssh "$FRAME_ALIAS" "cd $REMOTE && for d in */; do d=\${d%/}; [ \"\$d\" = $version ] && continue
pgrep -f \"\$PWD/\$d/\" >/dev/null && continue; rm -rf -- \"\$d\"; done"
print -r -- "$version"
}
# Runs on the Frame. Args: dir port appid key
OPEN=$(cat <<'EOF'
set -u
dir=$HOME/$1 port=$2 appid=$3 key=$4
export DISPLAY=:0 LC_ALL=C.UTF-8
unset WAYLAND_DISPLAY
xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1 || { echo "The headset isn't showing anything (gamescope's :0 isn't up). Wake it and try again." >&2; exit 2; }
if [ -x "$HOME/chromium-xr/chrome" ]; then
# Steam's overlay library crashes Chromium's zygote (docs/webxr-chromium.md).
browser=(env LD_PRELOAD= "$HOME/chromium-xr/chrome" --user-data-dir="$HOME/.local/share/frame-control/store")
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
browser=(flatpak run org.chromium.Chromium --user-data-dir="$HOME/.var/app/org.chromium.Chromium/frame-store")
else
echo "The store needs a Chromium on the Frame: Chromium XR (docs/webxr-chromium.md) or the org.chromium.Chromium Flatpak (scripts/install-apps.sh)." >&2
exit 3
fi
# A fresh server each time, so it runs this version with this key.
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null
# Match the browser by its profile flag and the server by its script path: this
# launcher's own command line holds the bundle path, so a looser pattern kills it.
pkill -f '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) ' || true
pkill -f '[s]tore-server/[^/ ]*/ui/server[.]py' || true
sleep 0.5
cd "$dir" || exit 1
FRAME_LOCAL=1 FRAME_UI_KEY=$key FRAME_DEVICE=headset setsid nohup python3 -I -u -B "$dir/ui/server.py" --port "$port" \
>/tmp/frame-store-server.log 2>&1 </dev/null &
echo $! > /tmp/frame-store-server.pid
up=
for _ in $(seq 1 40); do
curl -s -o /dev/null "http://127.0.0.1:$port/store" && { up=1; break; }
sleep 0.25
done
[ -n "$up" ] || { echo "Frame Control's server didn't start on the Frame:" >&2; tail -n 15 /tmp/frame-store-server.log >&2; exit 1; }
setsid nohup "${browser[@]}" --ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check \
--password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars \
--disable-features=Translate,MediaRouter --window-size=1280,800 \
"--app=http://127.0.0.1:$port/store?key=$key" >/tmp/frame-store.log 2>&1 </dev/null &
# Tag the window so it's its own panel (docs/panels.md).
for _ in $(seq 1 60); do
sleep 0.5
for win in $(xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}'); do
xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "APK Store" || continue
if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
echo "panel valve.steam.desktopgame.$appid window $win"
exit 0
fi
done
done
echo "Chromium started, but its window didn't appear within 30 s:" >&2
tail -n 15 /tmp/frame-store.log >&2
exit 1
EOF
)
case "$cmd" in
open)
version=$(deploy | tail -n 1)
key=$(openssl rand -hex 16)
ssh "$FRAME_ALIAS" bash -s -- "$REMOTE/$version" "$PORT" "$APPID" "$key" <<<"$OPEN"
;;
close)
ssh "$FRAME_ALIAS" 'pkill -f "[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) " || true
pkill -f "[s]tore-server/[^/ ]*/ui/server[.]py" || true
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null; rm -f /tmp/frame-store-server.pid; true'
;;
status)
ssh "$FRAME_ALIAS" "ls $REMOTE 2>/dev/null; curl -s -o /dev/null http://127.0.0.1:$PORT/store && echo 'server up' || echo 'server down'
pgrep -af '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) |[s]tore-server/[^/ ]*/ui/server[.]py' | head -n 1 | cut -c1-120"
;;
*) sed -n '2,15p' "$0"; exit 2 ;;
esac
+79
View File
@@ -0,0 +1,79 @@
// The store's "Add a source" login handling and GitHub token field, run in node against
// the real functions from ui/index.html. A login typed into the address must leave the
// address field (into the login fields) before anything is sent, and stay out of it.
import { readFileSync } from "fs";
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const lines = src.split("\n");
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
function grab(name) {
const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
if (start < 0) fail(`no top-level ${name} in index.html`);
let depth = 0;
for (let i = start; i < lines.length; i++) {
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
}
fail(`unbalanced ${name}`);
}
const { moveLogin, sourceToken } = new Function(
"const esc = s => String(s).replace(/[&<>\"']/g, c => `&#${c.charCodeAt(0)};`); const storeName = s => s;\n" +
grab("decodeLenient") + grab("moveLogin") + grab("sourceToken") + "return { moveLogin, sourceToken };")();
function form(url, username = "", password = "") {
const details = { open: false };
const field = value => ({ value, closest: () => details });
return { details, elements: { url: field(url), username: field(username), password: field(password) } };
}
let f = form("https://alice:s3cr%40t@repo.example/fdroid/repo");
moveLogin(f);
if (f.elements.url.value !== "https://repo.example/fdroid/repo") fail("address still holds the login: " + f.elements.url.value);
if (f.elements.username.value !== "alice" || f.elements.password.value !== "s3cr@t") fail("login not moved into its fields");
if (!f.details.open) fail("login fields not shown");
f = form("https://alice:fromurl@repo.example/repo", "typed", "typedpw");
moveLogin(f);
if (f.elements.url.value.includes("fromurl") || f.elements.username.value !== "typed" || f.elements.password.value !== "typedpw")
fail("typed login replaced, or address kept the login");
// Undecodable logins and unusual spellings the URL parser accepts still lose their login; undecodable bytes stay as typed.
for (const [url, address, user, pass] of [
["https://alice:100%done@repo.example/private/repo", "https://repo.example/private/repo", "alice", "100%done"],
["https:\t//alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["ht\ntps://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["https:alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["\u0000https://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["https://alice:%FF@repo.example/private/repo", "https://repo.example/private/repo", "alice", "%FF"],
["https://al%20ice:p%40ss:word@repo.example/r", "https://repo.example/r", "al ice", "p@ss:word"],
["https://alice:p%FF\u00e4@repo.example/repo", "https://repo.example/repo", "alice", "p%FF\u00e4"],
["fdroidrepos://alice:pw@repo.example/repo?fingerprint=AB", "fdroidrepos://repo.example/repo?fingerprint=AB", "alice", "pw"],
["https://alice@repo.example/repo", "https://repo.example/repo", "alice", ""],
]) {
f = form(url);
moveLogin(f);
if (f.elements.url.value !== address) fail(`address kept the login: ${url} -> ${f.elements.url.value}`);
if (f.elements.username.value !== user || f.elements.password.value !== pass)
fail(`login for ${url}: ${f.elements.username.value} / ${f.elements.password.value}`);
}
// An address the parser rejects that may hold a login is refused and left as typed, never sent.
for (const url of ["https://alice\\name:pw@repo.example/repo", "https:\\\\alice:s3cret@repo.example/repo", "fdroidrepos://alice:p\\w@repo.example/repo", "https://alice:s3cret@repo example/repo", "https:alice:s3cret@repo example/repo", "https://repo.example:bad\\a@b"]) {
f = form(url);
if (moveLogin(f) !== false) fail("accepted an invalid address with a login: " + url);
if (f.elements.username.value || f.elements.password.value) fail("guessed a login from an invalid address: " + url);
}
for (const url of ["https://repo.example/repo", "not a url", "https://repo.example/a@b", "fdroidrepos:///a@b", "fdroidrepos:/a@b", "https://repo.example/r?x=a@b#c@d"]) {
f = form(url);
moveLogin(f);
if (f.elements.url.value !== url || f.elements.username.value) fail("changed an address without a login: " + url);
}
const html = sourceToken({ id: "github", name: "GitHub releases", token: { set: true, from: "saved", rejected: true } });
if (!html.includes("rejected") || !html.includes('data-clear-token="github"')) fail("token row: " + html);
if (sourceToken({ id: "fdroid", name: "F-Droid" }) !== "") fail("token field on a source without one");
const slow = "https://a:" + "%FF".repeat(4000) + "@repo.example/r", t0 = Date.now();
f = form(slow); moveLogin(f);
if (Date.now() - t0 > 200 || f.elements.password.value !== "%FF".repeat(4000)) fail("decoding malformed escapes is slow or wrong");
console.log("ok");
+9 -3
View File
@@ -19,7 +19,9 @@ class ArtworkTests(unittest.TestCase):
def setUp(self):
with _images._lock:
_images._urls.clear()
_images._tokens.clear()
_images._cache.clear()
_images._dns.clear()
def test_only_registered_source_images_are_fetchable(self):
with patch.object(_images, 'fetch') as fetch:
@@ -71,9 +73,13 @@ class ArtworkTests(unittest.TestCase):
_images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE)
def test_handles_are_bounded(self):
for i in range(4100):
_images.register('https://example.com/%d.png' % i)
self.assertEqual(len(_images._urls),4096)
with patch.object(_images, 'MAX_TOKENS', 50):
first = _images.register('https://example.com/0.png')
for i in range(1, 60):
_images.register('https://example.com/%d.png' % i)
self.assertEqual((len(_images._urls), len(_images._tokens)), (50, 50))
self.assertNotEqual(_images.register('https://example.com/0.png'), first) # forgotten, so a new handle
self.assertEqual(_images.register('https://example.com/59.png'), _images.register('https://example.com/59.png'))
def test_plain_language_verdict_is_evidence_based(self):
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
+2 -2
View File
@@ -122,7 +122,7 @@ class SearchTests(SettingsTest):
break
time.sleep(.01)
self.assertTrue(arrived[1]['event'].wait(2))
self.assertEqual(arrived[1]['query'], ('second', 50))
self.assertEqual(arrived[1]['query'][:2], ('second', 50))
def test_set_enabled_does_not_hold_search_lock_in_source(self):
free = []
@@ -325,7 +325,7 @@ class EndpointTests(SettingsTest):
mod.add_repo.side_effect = SourceError('repository fingerprint mismatch')
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'):
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://repo.example:x/'):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400)
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
mod.remove_repo.assert_called_once_with(source_id='fdroid')
+14
View File
@@ -63,6 +63,20 @@ class Coverage(unittest.TestCase):
self.assertIn('*.js', ui['filter'])
self.assertIn('*.css', ui['filter'])
def test_bundle_runs_the_server_on_its_own(self):
# The iPhone app and scripts/store-on-frame.sh start ui/server.py from this bundle alone.
import io, tarfile, tempfile
spec = importlib.util.spec_from_file_location('frame_bundle_run', ROOT / 'ios/scripts/make_frame_bundle.py')
bundle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bundle)
with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=io.BytesIO(bundle.build()), mode='r:gz') as tar:
tar.extractall(tmp)
result = subprocess.run([sys.executable, '-I', '-B', '-c',
'import sys; sys.path.insert(0, "ui"); import server, apk_sources.search; '
'apk_sources.search.modules()[1] and sys.exit("source modules failed: %r" % apk_sources.search.modules()[1])'],
cwd=tmp, capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr[-2000:])
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
# Run the real aggregator with every external probe stubbed.
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
+6 -6
View File
@@ -81,7 +81,7 @@ class Repositories(unittest.TestCase):
state.clear()
self.addCleanup(state.clear)
def fetch(self, url, path, maximum):
def fetch(self, url, path, maximum, auth=None):
name = url.rsplit('/', 1)[-1]
if self.v1 and name == 'entry.jar':
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
@@ -311,7 +311,7 @@ class Repositories(unittest.TestCase):
source = self.add()
other = dict(source, id='other-repo')
started, release = threading.Event(), threading.Event()
def fetch(url, path, maximum):
def fetch(url, path, maximum, auth=None):
if threading.current_thread().name == 'slow':
started.set()
release.wait(5)
@@ -355,7 +355,7 @@ class Repositories(unittest.TestCase):
self.files['entry.jar'], _ = entry_jar(50)
source = fdroid.add_repo(URL)
jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]}
def fetch(url, path, maximum):
def fetch(url, path, maximum, auth=None):
name = threading.current_thread().name
if name in jars and url.endswith('entry.jar'):
Path(path).write_bytes(jars[name])
@@ -401,7 +401,7 @@ class Repositories(unittest.TestCase):
source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None)
self.v1 = True
inner = []
def fetch(url, path, maximum):
def fetch(url, path, maximum, auth=None):
if url.endswith('index-v1.jar') and not inner:
inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now
self.v1 = False
@@ -471,7 +471,7 @@ class Repositories(unittest.TestCase):
source = self.add()
self.expire(source)
release = threading.Event()
def slow(url, path, maximum):
def slow(url, path, maximum, auth=None):
release.wait(5)
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = slow
@@ -528,7 +528,7 @@ class Repositories(unittest.TestCase):
self.expire(source)
before = self.fetch_mock.call_count
release = __import__('threading').Event()
def slow(url, path, maximum):
def slow(url, path, maximum, auth=None):
release.wait(5)
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = slow
+3
View File
@@ -141,6 +141,9 @@ 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
-255
View File
@@ -18,7 +18,6 @@ import tempfile
import threading
import time
import unittest
from unittest import mock
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -632,257 +631,3 @@ class PageQueue(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class PadEvents(unittest.TestCase):
"""The trackpad and key row go through gamescope; accents and emoji go the KDE Connect way."""
@classmethod
def setUpClass(cls):
import server
cls.pad = staticmethod(server.pad_events)
def test_moves_clicks_and_scroll(self):
out, extra = self.pad([{"dx": 3, "dy": -2}, {"singleclick": True}, {"singlehold": True},
{"singlerelease": True}, {"scroll": True, "dy": 1}, {"rightclick": True}])
self.assertEqual(out[0], {"dx": 3, "dy": -2})
self.assertEqual(out[1:3], [{"button": "left", "down": True}, {"button": "left", "down": False}])
self.assertEqual(out[3:5], [{"button": "left", "down": True}, {"button": "left", "down": False}])
self.assertEqual(out[5], {"scroll": [0, -15]}) # up = content follows the finger
self.assertEqual(out[6:], [{"button": "right", "down": True}, {"button": "right", "down": False}])
self.assertEqual(extra, [])
def test_keys_and_modifiers(self):
out, _ = self.pad([{"specialKey": 12}, {"specialKey": 1, "ctrl": True}, {"key": "c", "ctrl": True}])
self.assertEqual(out[:2], [{"key": 28, "down": True}, {"key": 28, "down": False}])
self.assertEqual([e["key"] for e in out[2:6]], [29, 14, 14, 29]) # Ctrl+Backspace
self.assertEqual([(e["key"], e["down"]) for e in out[6:]],
[(29, True), (46, True), (46, False), (29, False)]) # Ctrl+C
def test_text_stays_ascii_through_gamescope(self):
out, slow = self.pad([{"key": "Hi "}, {"key": "there"}])
self.assertEqual(out, [{"text": "Hi there"}])
self.assertEqual(slow, [])
def test_keyboard_goes_to_kde_whole_and_in_order(self):
events = [{"key": "a"}, {"key": "é"}, {"specialKey": 12}, {"key": "c", "ctrl": True}, {"dx": 2, "dy": 1}]
out, slow = self.pad(events, kde=True)
self.assertEqual(out, [{"dx": 2, "dy": 1}]) # the pointer still goes through gamescope
self.assertEqual(slow, events[:4]) # the keyboard as sent, in order
def test_shifted_key_with_modifier(self):
out, _ = self.pad([{"key": "A", "ctrl": True}])
self.assertEqual([e["key"] for e in out], [29, 42, 30, 30, 42, 29])
class PadDelivery(unittest.TestCase):
"""remote_input's promises: "sent" means the whole batch is taken; accents go once, in order."""
class Agent:
def __init__(self, sent=True, state="ready"):
self.sent, self.state, self.got = sent, state, []
def send(self, events):
if events and self.sent:
self.got.append(events)
return {"state": self.state, "sent": self.sent and bool(events)}
def setUp(self):
import server
self.s = server
self.saved = (server._touch, server._input, server.KDE_WAIT)
server._touch, server._input = self.Agent(), self.Agent()
self.reset()
def tearDown(self):
self.s._touch, self.s._input, self.s.KDE_WAIT = self.saved
self.settle()
self.reset()
def reset(self):
self.s._kde_queue.clear()
self.s._kde_until = self.s._typed_until = 0.0
self.s._kde_worker[0] = None
def settle(self):
worker = self.s._kde_worker[0]
if worker:
worker.join(5)
def typed(self):
return [e for batch in self.s._input.got for e in batch]
def test_accent_only_batch_is_acknowledged_and_typed_once(self):
status = self.s.remote_input({"events": [{"key": "é"}]})
self.assertTrue(status["sent"]) # so the page doesn't send it again
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}])
def test_mixed_text_stays_in_one_ordered_stream(self):
self.s.remote_input({"events": [{"key": "aéb"}, {"specialKey": 12}]})
self.settle()
self.assertEqual(self.typed(), [{"key": "aéb"}, {"specialKey": 12}])
self.assertEqual(self.s._touch.got, []) # none of it split off through gamescope
def test_batch_gamescope_didnt_take_is_not_queued(self):
self.s._touch = self.Agent(sent=False, state="starting")
status = self.s.remote_input({"events": [{"dx": 1, "dy": 1}, {"key": "é"}]})
self.assertFalse(status["sent"]) # the page tries the whole batch again...
self.assertEqual(self.s._kde_queue, []) # ...so the accent isn't queued twice
def test_waits_for_kde_connect_then_sends_without_more_input(self):
self.s._input = self.Agent(sent=False, state="starting")
self.s.remote_input({"events": [{"key": "é"}]})
time.sleep(0.2)
self.assertEqual(self.s._kde_queue, [{"key": "é"}])
self.s._input.sent = True
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}])
self.assertEqual(self.s._kde_queue, [])
def test_later_ascii_waits_behind_a_pending_accent(self):
self.s._input = self.Agent(sent=False, state="starting")
self.s.remote_input({"events": [{"key": "é"}]})
self.s.remote_input({"events": [{"key": "x"}]})
self.assertEqual(self.s._touch.got, []) # "x" can't overtake the "é"
self.assertEqual(self.s._kde_queue, [{"key": "é"}, {"key": "x"}])
self.s._input.sent = True
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
def test_queue_is_bounded_and_dropped_on_error(self):
self.s._input = self.Agent(sent=False, state="error")
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.settle()
self.assertEqual(self.s._kde_queue, [])
self.s._input = self.Agent(sent=False, state="starting")
self.s.KDE_WAIT = 0.3
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.assertLessEqual(len(self.s._kde_queue), self.s.KDE_QUEUE_LIMIT)
self.settle()
self.assertEqual(self.s._kde_queue, [])
def test_ascii_just_after_an_accent_stays_behind_it(self):
self.s.remote_input({"events": [{"key": "é"}]})
self.settle()
self.s.remote_input({"events": [{"key": "x"}]}) # KDE Connect may still be typing the é
self.settle()
self.assertEqual(self.s._touch.got, [])
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
def test_accent_just_after_gamescope_text_waits_for_it(self):
self.s.remote_input({"events": [{"key": "x" * 50}]})
self.assertEqual(self.s._touch.got, [[{"text": "x" * 50}]])
start = time.time()
self.s.remote_input({"events": [{"key": "é"}]})
self.settle()
self.assertGreater(time.time() - start, 0.6) # 50 keys at 16 ms, plus the margin
self.assertEqual(self.typed(), [{"key": "é"}])
def test_typing_time_counts_every_key_transition(self):
t = self.s.typing_seconds
self.assertAlmostEqual(t([{"text": "ab"}]), 4 * 0.008)
self.assertAlmostEqual(t([{"text": "A"}]), 4 * 0.008) # shift down, key down, key up, shift up
self.assertAlmostEqual(t([{"text": "x" * 500}]), 8.0) # a long paste
self.assertAlmostEqual(t([{"key": 29, "down": True}, {"dx": 3, "dy": 0}]), 0.008)
def test_the_margin_is_added_once_not_per_request(self):
for _ in range(10):
self.s.remote_input({"events": [{"key": "ab"}]})
# ten requests of 4 transitions each: the work queues up, with no margin added per request
self.assertLess(self.s._typed_until - time.time(), 10 * 4 * 0.008 + 0.05)
def test_trimming_while_sending_doesnt_drop_unsent_events(self):
slow = self.Agent()
gate = threading.Event()
send = slow.send
def held(events):
gate.wait(5)
return send(events)
slow.send = held
self.s._input = slow
self.s.remote_input({"events": [{"key": "é"}] * 150})
time.sleep(0.2) # the worker has taken its 150 and is mid-send
self.s.remote_input({"events": [{"key": "ü"}] * 150})
gate.set()
self.settle()
sent = self.typed()
self.assertEqual(sent[:150], [{"key": "é"}] * 150)
self.assertEqual(sent[150:], [{"key": "ü"}] * 150) # none of the new ones went missing
def test_input_queued_as_the_worker_finishes_is_still_sent(self):
for _ in range(40):
self.reset()
self.s._input.got.clear()
self.s.remote_input({"events": [{"key": "é"}]})
time.sleep(0.002)
self.s.remote_input({"events": [{"key": "ü"}]})
self.settle()
self.assertEqual(self.s._kde_queue, [])
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "ü"}])
class PadRoute(unittest.TestCase):
def setUp(self):
import server
self.s = server
self.agents = mock.patch.multiple(server, _touch=mock.Mock(), _input=mock.Mock())
self.agents.start()
self.addCleanup(self.agents.stop)
self.s._touch.send.return_value = {"state": "ready", "sent": True}
self.s._input.send.return_value = {"state": "ready", "sent": True}
self.s.reset_pad_route()
self.s._touch.reset_mock(); self.s._input.reset_mock()
self.addCleanup(self.s.reset_pad_route)
def switch(self):
with mock.patch.multiple(self.s, FRAME="frame-a", HOST_OPTS=[], MUX=[], SSH=[], macview=None), \
mock.patch.multiple(self.s.frame_android, FRAME="frame-a", SSH_OPTS=[]):
self.s.route("frame-b", [])
def test_switch_stops_both_input_agents_and_discards_old_queue(self):
self.s._kde_queue.append({"key": "old"})
self.s._kde_until = self.s._typed_until = time.time() + 60
cancel = self.s._kde_cancel
self.switch()
self.s._input.stop.assert_called_once()
self.s._touch.stop.assert_called_once()
self.assertEqual(self.s._kde_queue, [])
self.assertEqual((self.s._kde_until, self.s._typed_until), (0, 0))
self.assertTrue(cancel.is_set())
def test_switch_cancels_delayed_and_retrying_text_before_new_input(self):
for delaying in (True, False):
with self.subTest(delaying=delaying):
waiting, cancel = threading.Event(), threading.Event()
class ObservedCancel:
def wait(self, timeout):
waiting.set()
return cancel.wait(timeout)
def set(self):
cancel.set()
self.s.reset_pad_route()
self.s._kde_cancel = ObservedCancel()
self.s._typed_until = time.time() + 60 if delaying else 0
self.s._input.reset_mock()
self.s._input.send.return_value = {"state": "starting", "sent": False}
self.s.remote_input({"events": [{"key": "é"}]})
worker = self.s._kde_worker[0]
self.assertTrue(waiting.wait(1), "worker never reached its cancellable delay")
self.switch()
worker.join(1)
self.assertFalse(worker.is_alive())
attempts = self.s._input.send.call_count
self.assertEqual(attempts, 0 if delaying else 1)
self.s._input.send.return_value = {"state": "ready", "sent": True}
self.s.remote_input({"events": [{"key": "ü"}]})
next_worker = self.s._kde_worker[0]
if next_worker:
next_worker.join(1)
self.assertFalse(next_worker.is_alive())
self.assertEqual(self.s._input.send.call_count, attempts + 1)
self.assertEqual(self.s._input.send.call_args.args[0], [{"key": "ü"}])
self.assertEqual(self.s._kde_queue, [])
+6
View File
@@ -88,6 +88,12 @@ class ServerGuards(unittest.TestCase):
self.assertIn(mime, headers['Content-Type'])
self.assertIn(marker, payload)
def test_store_page_served_with_anti_framing_headers(self):
status, headers, payload = self.request("GET", "/store")
self.assertEqual(status, 200)
self.assertEqual(headers["X-Frame-Options"], "DENY")
self.assertIn(b"<title>APK Store</title>", payload)
def test_foreign_host_rejected(self):
# DNS rebinding: a hostile name pointed at 127.0.0.1.
for path in ("/", "/api/status"):
+566
View File
@@ -0,0 +1,566 @@
"""Repositories and sources behind a login: F-Droid basic auth and GitHub tokens.
A fake HTTPS layer stands in for the network (urllib's real redirect, error and
request processing still run), so these tests see every request and its headers.
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import base64
import contextlib
import http.client
import io
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import Mock, patch
import urllib.request
import urllib.response
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceAuthError, SourceError, SourceLimited, _credentials, _images, _web, fdroid, github, search
import server
FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
REPO = 'https://repo.example/fdroid/repo/'
USER, PASSWORD = 'alice', 's3cr@t-pässword'
TOKEN = 'ghp_frameControlTestToken0123456789'
SECRETS = (PASSWORD, TOKEN, base64.b64encode((USER + ':' + PASSWORD).encode()).decode())
REAL_BUILD_OPENER = urllib.request.build_opener
def basic(user, password):
return 'Basic ' + base64.b64encode((user + ':' + password).encode()).decode()
class FakeHTTPS(urllib.request.HTTPSHandler):
"""Answers https requests from route(url, authorization) -> (status, headers, body)."""
def __init__(self, route):
super().__init__()
self.route, self.seen = route, []
def https_open(self, req):
auth = req.get_header('Authorization')
self.seen.append((req.full_url, auth))
status, headers, body = self.route(req.full_url, auth)
message = http.client.HTTPMessage()
for key, value in headers.items():
message[key] = value
response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, status)
response.msg = 'fake'
return response
class Base(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)),
('cache_dir', lambda *p: self.root.joinpath('cache', *p))]:
mock = patch.object(fdroid.frame_host, name, value)
mock.start()
self.addCleanup(mock.stop)
self.web = FakeHTTPS(self.route)
mock = patch('urllib.request.build_opener', lambda *handlers: REAL_BUILD_OPENER(*handlers, self.web))
mock.start()
self.addCleanup(mock.stop)
mock = patch.dict(os.environ, {github.ENV: ''})
mock.start()
self.addCleanup(mock.stop)
for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing, github._rejected):
state.clear()
self.addCleanup(state.clear)
self.stderr = io.StringIO()
mock = patch('sys.stderr', self.stderr)
mock.start()
self.addCleanup(mock.stop)
def route(self, url, auth):
raise AssertionError('unexpected request ' + url)
def assertNoSecrets(self, *texts):
for text in texts:
for secret in SECRETS + (USER + ':',):
self.assertNotIn(secret, text)
def disk(self):
"""Everything saved under the data and cache folders except the credentials file."""
out = []
for path in self.root.rglob('*'):
if path.is_file() and path.name != 'source-credentials.json':
out.append(path.read_bytes().decode('utf-8', 'replace'))
return '\n'.join(out)
def credentials_file(self):
return self.root / 'data' / 'source-credentials.json'
class Repository(Base):
"""A repository at REPO that wants USER and PASSWORD."""
def setUp(self):
super().setUp()
self.login = basic(USER, PASSWORD)
self.redirect = None # file name -> absolute URL it redirects to
self.forbidden = 401
def serve(self, name):
return (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
def route(self, url, auth):
for mirror in ('https://mirror.example/fdroid/repo/', 'https://repo.example/elsewhere/'):
if url.startswith(mirror): # serves anyone; a login must never get here unless same-origin
return 200, {}, self.serve(url[len(mirror):])
self.assertTrue(url.startswith(REPO), url)
if auth != self.login:
return self.forbidden, {'WWW-Authenticate': 'Basic realm="repo"'}, b'no'
name = url[len(REPO):]
if self.redirect and name in self.redirect:
return 302, {'Location': self.redirect[name]}, b''
return 200, {}, self.serve(name)
def add(self, **kw):
return fdroid.add_repo('https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo', PIN, **kw)
class RepositoryLogin(Repository):
def test_add_search_download_with_login_in_the_url(self):
source = self.add()
self.assertEqual(source['url'], REPO)
self.assertEqual(source['fingerprint'], PIN)
saved = fdroid.user_repos()[0]
self.assertEqual(saved['url'], REPO)
self.assertNotIn('login', saved)
self.assertEqual(len(fdroid.search(source, 'example offline')), 1)
downloaded = fdroid.download(source, 'org.example.app', 1)
self.assertTrue(downloaded['verified'])
self.assertTrue(self.web.seen)
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
self.assertTrue(all('@' not in url for url, auth in self.web.seen)) # never in the URL
self.assertTrue(next(s for s in fdroid.sources() if s['id'] == source['id'])['login'])
self.assertNoSecrets(json.dumps(source), json.dumps(fdroid.sources()), self.disk(), self.stderr.getvalue())
# The settings file holds no part of the login; the credentials file holds it, privately.
settings = (self.root / 'data' / 'apk-repos.json').read_text()
self.assertNotIn(USER, settings)
self.assertTrue(self.credentials_file().is_file())
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
def test_login_given_apart_and_saved_login_reused_on_refresh(self):
source = fdroid.add_repo(REPO, PIN, username=USER, password=PASSWORD)
self.assertEqual(source['url'], REPO)
self.web.seen.clear()
fdroid._load(fdroid.user_repos()[0], force=True) # as a background refresh would
self.assertTrue(self.web.seen)
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
fdroid.add_repo(REPO) # re-adding without a login keeps the saved one
self.assertTrue(_credentials.has(source['id']))
def test_wrong_or_missing_login_saves_nothing(self):
for kw, message in [({'username': USER, 'password': 'wrong'}, 'check the username and password'),
({}, 'needs a username and password')]:
with self.subTest(kw=kw):
with self.assertRaisesRegex(SourceAuthError, message) as caught:
fdroid.add_repo(REPO, PIN, **kw)
self.assertNoSecrets(str(caught.exception), repr(caught.exception))
self.assertNotIn('wrong', str(caught.exception))
self.assertEqual(fdroid.user_repos(), [])
self.assertFalse(self.credentials_file().exists())
self.assertFalse((self.root / 'data' / 'apk-repo-state.json').exists())
self.assertEqual(_web._limited, {}) # an auth failure isn't a rate limit
def test_403_with_a_login_is_a_login_error_not_a_rate_limit(self):
self.forbidden = 403
with self.assertRaisesRegex(SourceAuthError, 'check the username and password'):
fdroid.add_repo(REPO, PIN, username=USER, password='wrong')
self.assertEqual(_web._limited, {})
self.assertEqual(fdroid.user_repos(), [])
def test_signature_checks_still_apply(self):
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
fdroid.add_repo(REPO, '0' * 64, username=USER, password=PASSWORD)
self.assertEqual(fdroid.user_repos(), [])
self.assertFalse(self.credentials_file().exists())
def test_cross_origin_redirect_never_gets_the_login(self):
self.redirect = {'entry.jar': 'https://mirror.example/fdroid/repo/entry.jar',
'index-v2.json': 'https://repo.example/elsewhere/index-v2.json'}
source = self.add()
self.assertEqual(source['fingerprint'], PIN) # the mirror's copy still had to verify
mirror = [auth for url, auth in self.web.seen if url.startswith('https://mirror.example/')]
same_origin = [auth for url, auth in self.web.seen if url.startswith('https://repo.example/elsewhere/')]
self.assertEqual(mirror, [None])
self.assertEqual(same_origin, [None]) # same origin but outside the repository: no login either
def test_traversal_names_are_refused_and_never_get_the_login(self):
for name in ('%252e%252e/%252e%252e/evil/collect.apk', '..;/..;/evil/collect.apk', 'a%2f..%2f..%2fevil.apk',
'a%5c..%5cevil.apk', '%2e%2e/evil.apk', 'x;y.apk', '100%25.apk'):
with self.subTest(name=name), self.assertRaisesRegex(SourceError, 'unsafe|outside'):
fdroid._child(REPO, name)
self.assertEqual(fdroid._child(REPO, '/app/en-US/phoneScreenshots/#0 a.png'),
REPO + 'app/en-US/phoneScreenshots/%230%20a.png')
# Even if such a URL reached the downloader directly, the login stays home.
login = _credentials.basic(REPO, USER, PASSWORD)
for url in (REPO + '%252e%252e/%252e%252e/evil/collect.apk', REPO + '..;/..;/evil/collect.apk'):
with self.subTest(url=url), tempfile.TemporaryDirectory() as tmp:
self.web.seen.clear()
with contextlib.suppress(SourceError): # the repository answers 401 without the login
fdroid._fetch(url, Path(tmp) / 'out', 1 << 20, auth=login)
self.assertEqual(self.web.seen, [(url, None)])
def test_same_origin_redirect_outside_the_repository_gets_no_login(self):
self.redirect = {'example1.apk': 'https://repo.example/elsewhere/example1.apk'}
source = self.add()
self.web.seen.clear()
self.assertTrue(fdroid.download(source, 'org.example.app', 1)['verified'])
self.assertEqual(self.web.seen, [(REPO + 'example1.apk', self.login),
('https://repo.example/elsewhere/example1.apk', None)])
def test_other_ports_and_http_never_get_the_login(self):
login = _credentials.basic(REPO, USER, PASSWORD)
for url in ('https://repo.example:8443/fdroid/repo/x', 'http://repo.example/fdroid/repo/x',
'https://other.example/fdroid/repo/x', 'https://repo.example.evil/x'):
self.assertFalse(login.matches(url), url)
self.assertTrue(login.matches('https://REPO.example:443/anything'))
self.assertNoSecrets(repr(login))
handler = _credentials.Handler(login)
req = urllib.request.Request('https://other.example/x', headers={'Authorization': 'Basic stale'})
self.assertIsNone(handler.https_request(req).get_header('Authorization'))
def test_http_and_bad_logins_are_refused_before_any_request(self):
for url, kw in [('http://alice:pw@repo.example/fdroid/repo', {}),
('http://repo.example/fdroid/repo', {'username': USER, 'password': PASSWORD}),
(REPO, {'username': 'a:b', 'password': PASSWORD}),
(REPO, {'username': USER, 'password': 'x\r\nX-Evil: 1'}),
(REPO, {'password': PASSWORD}),
('https://alice:pw@repo.example/fdroid/repo', {'username': 'bob'})]:
with self.subTest(url=url, kw=kw), self.assertRaises(SourceError) as caught:
fdroid.add_repo(url, PIN, **kw)
self.assertNotIn('pw', str(caught.exception).replace('password', ''))
self.assertNoSecrets(str(caught.exception))
self.assertEqual(self.web.seen, [])
def test_remove_deletes_the_login(self):
source = self.add()
other = _credentials.bearer('https://api.github.com/', TOKEN)
_credentials.put('github', other)
fdroid.remove_repo(source['id'])
self.assertFalse(_credentials.has(source['id']))
self.assertNotIn(PASSWORD, self.credentials_file().read_text())
_credentials.remove('github')
self.assertFalse(self.credentials_file().exists()) # nothing left: the file goes
def test_cli_reads_the_password_from_stdin(self):
out = io.StringIO()
argv = ['fdroid.py', 'add', REPO, '--fingerprint', PIN, '--username', USER, '--password-stdin']
with patch.object(sys, 'argv', argv), patch.object(sys, 'stdin', io.StringIO(PASSWORD + '\n')), \
contextlib.redirect_stdout(out):
fdroid.main()
self.assertEqual(json.loads(out.getvalue())['url'], REPO)
self.assertNoSecrets(out.getvalue())
self.assertTrue(_credentials.has(fdroid.user_repos()[0]['id']))
def test_artwork_from_the_repository_gets_the_login_and_only_there(self):
source = self.add()
self.assertEqual(_credentials.for_url(REPO + 'icons/a.png').header(), self.login)
self.assertTrue(_credentials.has(source['id']))
self.assertIsNone(_credentials.for_url('https://repo.example/other/a.png')) # outside the repository
self.assertIsNone(_credentials.for_url('https://cdn.example/fdroid/repo/a.png'))
class GitHubToken(Base):
def setUp(self):
super().setUp()
self.accept = {TOKEN}
self.releases = json.dumps([]).encode()
self.anonymous_limited = False
def route(self, url, auth):
if url.startswith('https://api.github.com/'):
if auth is not None and auth not in {'Bearer ' + t for t in self.accept}:
return 401, {}, b'{"message": "Bad credentials"}'
if auth is None and self.anonymous_limited:
return 403, {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(time.time()) + 1800)}, b'{}'
if url.endswith('/redirected'):
return 302, {'Location': 'https://github.com/landed'}, b''
return 200, {}, self.releases
if url == 'https://github.com/landed':
return 200, {}, b'{"ok": true}'
raise AssertionError('unexpected request ' + url)
def test_saved_token_goes_only_to_the_api(self):
github.set_token(TOKEN)
self.assertEqual(github._api('/repos/a/b/releases'), [])
self.assertEqual(self.web.seen, [('https://api.github.com/repos/a/b/releases', 'Bearer ' + TOKEN)])
auth = github._credential(github._token()[0])
self.assertEqual(_web.read('https://api.github.com/redirected', ('api.github.com', 'github.com'), auth=auth),
b'{"ok": true}')
self.assertEqual(self.web.seen[-2:], [('https://api.github.com/redirected', 'Bearer ' + TOKEN),
('https://github.com/landed', None)]) # redirect: no token
status = github.sources()[0]['token']
self.assertEqual(status, {'set': True, 'from': 'saved', 'rejected': False})
self.assertNoSecrets(json.dumps(github.sources()), self.disk(), self.stderr.getvalue())
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
def test_rejected_token_retries_once_without_it(self):
github.set_token(TOKEN)
self.accept = set()
self.assertEqual(github._api('/repos/a/b/releases'), [])
self.assertEqual([auth for _, auth in self.web.seen], ['Bearer ' + TOKEN, None])
self.assertTrue(github.token_status()['rejected'])
self.assertIn('rejected', self.stderr.getvalue())
self.assertNoSecrets(self.stderr.getvalue())
self.web.seen.clear()
github._api('/repos/a/b/other')
self.assertEqual([auth for _, auth in self.web.seen], [None]) # not tried again this run
github.set_token(TOKEN) # saving it again gives it a fresh chance
self.assertFalse(github.token_status()['rejected'])
def test_environment_variable_wins(self):
github.set_token(TOKEN)
env = 'ghp_fromTheEnvironment'
self.accept = {env}
with patch.dict(os.environ, {github.ENV: env}):
github._api('/repos/a/b/releases')
self.assertEqual(self.web.seen[-1][1], 'Bearer ' + env)
self.assertEqual(github.token_status(), {'set': True, 'from': 'env', 'rejected': False})
self.assertEqual(github.token_status()['from'], 'saved')
def test_clear_removes_it_from_disk(self):
github.set_token(TOKEN)
github.clear_token()
self.assertFalse(self.credentials_file().exists())
self.assertEqual(github.token_status(), {'set': False, 'from': None, 'rejected': False})
github._api('/repos/a/b/releases')
self.assertEqual(self.web.seen[-1][1], None)
def test_saving_a_token_escapes_the_anonymous_rate_limit(self):
self.anonymous_limited = True
with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'):
github._api('/repos/a/b/releases')
self.assertEqual(self.web.seen[-1][1], None)
github.set_token(TOKEN)
self.assertEqual(github._api('/repos/a/b/other'), []) # tried, with the token's own quota
self.assertEqual(self.web.seen[-1], ('https://api.github.com/repos/a/b/other', 'Bearer ' + TOKEN))
github.clear_token()
count = len(self.web.seen)
with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'):
github._api('/repos/a/b/third')
self.assertEqual(len(self.web.seen), count) # anonymous is still backing off: no request
def test_bad_tokens_are_refused(self):
for token in ('', ' ', 'two words', 'x\nAuthorization: y'):
with self.subTest(token=token), self.assertRaises(SourceError):
github.set_token(token)
self.assertFalse(self.credentials_file().exists())
PNG = b'\x89PNG\r\n\x1a\nfixture'
PRIVATE = 'https://repo.example/private/repo/'
TRAVERSALS = [PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', PRIVATE + '%2E%2E/x.png', PRIVATE + '../../evil/x.png',
PRIVATE + './icons/a.png', PRIVATE + '..%2f..%2fevil/x.png', PRIVATE + 'a%5c..%5c..%5cevil.png',
PRIVATE + 'a;b/../../evil.png', PRIVATE + '%252e%252e/%252e%252e/evil.png',
'https://repo.example/private/repository/x.png', 'https://repo.example/private/repo%2fx.png']
class ArtworkScope(Base):
"""The artwork proxy: actual outgoing headers, through _images.get's own HTTP code."""
def setUp(self):
super().setUp()
self.sent, self.redirects = [], {}
_credentials.put('fdroid-user-private', _credentials.basic(PRIVATE, USER, PASSWORD))
test = self
class Response:
def __init__(self, status, location=''):
self.status, self.location, self.body = status, location, PNG if status == 200 else b''
def getheader(self, name, default=''):
return self.location if name == 'Location' else default
def read1(self, n):
chunk, self.body = self.body[:n], self.body[n:]
return chunk
class Connection:
def __init__(self, host, port, timeout=None):
self.host, self.port = host, port
def request(self, method, path, headers):
url = 'https://%s%s' % (self.host, path)
test.sent.append((url, headers.get('Authorization')))
self.url = url
def getresponse(self):
if self.url in test.redirects:
return Response(302, test.redirects[self.url])
return Response(200)
def close(self):
pass
# A public address, no real socket or TLS, and Connection in place of http.client's.
for target, name, value in [(_images, '_resolve', lambda host, port, timeout: [(2, 1, 6, '', ('93.184.216.34', port))]),
(_images.socket, 'create_connection', lambda *a, **k: Mock()),
(_images.ssl, 'create_default_context', lambda: Mock()),
(_images.http.client, 'HTTPConnection', Connection)]:
mock = patch.object(target, name, value)
mock.start()
self.addCleanup(mock.stop)
def test_artwork_in_the_repository_gets_the_login(self):
self.assertEqual(_images.get(PRIVATE + 'icons/a%20b.png'), PNG)
self.assertEqual(self.sent, [(PRIVATE + 'icons/a%20b.png', basic(USER, PASSWORD))])
def test_paths_a_server_could_normalise_outside_the_repository_get_nothing(self):
for url in TRAVERSALS:
with self.subTest(url=url):
self.sent.clear()
self.assertEqual(_images.get(url), PNG)
self.assertEqual(self.sent, [(url, None)])
self.assertIsNone(_credentials.for_url(url))
def test_redirect_to_a_traversal_gets_nothing(self):
start = PRIVATE + 'icons/a.png'
self.redirects[start] = PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'
self.assertEqual(_images.get(start), PNG)
self.assertEqual(self.sent, [(start, basic(USER, PASSWORD)), (self.redirects[start], None)])
class ScopedHandler(Base):
"""Handler(scoped=True), for callers (artwork-like) that must stay inside a scope, redirects included."""
def route(self, url, auth):
if url == PRIVATE + 'icons/a.png':
return 302, {'Location': PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'}, b''
return 200, {}, PNG
def test_redirect_into_a_traversal_is_stripped(self):
login = _credentials.basic(PRIVATE, USER, PASSWORD)
opener = urllib.request.build_opener(_credentials.Handler(login, scoped=True))
with opener.open(PRIVATE + 'icons/a.png') as r:
self.assertEqual(r.read(), PNG)
self.assertEqual(self.web.seen, [(PRIVATE + 'icons/a.png', basic(USER, PASSWORD)),
(PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', None)])
for url in TRAVERSALS:
self.assertFalse(_credentials.in_scope(login, url), url)
self.assertTrue(_credentials.in_scope(login, PRIVATE + 'a/b%20c.png?x=1'))
self.assertTrue(_credentials.in_scope(login, PRIVATE.rstrip('/')))
CHURN = '''
import sys
sys.path.insert(0, sys.argv[1])
from apk_sources import _credentials
for i in range(int(sys.argv[3])):
key = sys.argv[2] + str(i)
_credentials.put(key, _credentials.basic('https://h.example/', 'u', 'p'))
if i % 2:
_credentials.remove(key)
'''
class TwoProcesses(Base):
def test_interleaved_saves_and_removals_are_not_lost(self):
ui = str(Path(__file__).resolve().parents[1] / 'ui')
env = dict(os.environ, FRAME_CONTROL_DATA_DIR=str(self.root / 'data'))
count = 60
procs = [subprocess.Popen([sys.executable, '-c', CHURN, ui, prefix, str(count)], env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE) for prefix in ('a', 'b')]
for p in procs:
out, err = p.communicate(timeout=120)
self.assertEqual(p.returncode, 0, err.decode())
saved = set(json.loads(self.credentials_file().read_text()))
self.assertEqual(saved, {p + str(i) for p in 'ab' for i in range(0, count, 2)})
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
@unittest.skipUnless(shutil.which('node'), 'needs node')
class PageLogin(unittest.TestCase):
def test_login_leaves_the_address_field_before_sending(self):
"""tests/page/source_login.mjs runs the real page functions from index.html."""
r = subprocess.run(['node', str(Path(__file__).parent / 'page' / 'source_login.mjs')],
capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
self.assertIn('ok', r.stdout)
class Endpoints(Repository):
"""The server API: logins in, never out, in responses or the server log."""
def setUp(self):
super().setUp()
mock = patch.object(search, 'modules', return_value=([fdroid, github], []))
mock.start()
self.addCleanup(mock.stop)
self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.addCleanup(self.httpd.server_close)
self.addCleanup(self.httpd.shutdown)
def request(self, method, path, body=None):
c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port)
c.request(method, path, json.dumps(body) if body is not None else None,
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
r = c.getresponse()
raw = r.read().decode()
c.close()
self.replies.append(raw)
return r.status, json.loads(raw)
def job(self, reply):
for _ in range(500):
job = self.request('GET', '/api/job?id=' + reply['job'])[1]
if job['done']:
return job
time.sleep(.01)
self.fail('job did not finish')
def test_add_with_login_and_token_settings(self):
self.replies = []
bad = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
'username': USER, 'password': 'wrong'})[1])
self.assertIn('check the username and password', bad['error'])
self.assertEqual(fdroid.user_repos(), [])
code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
'username': USER, 'password': PASSWORD})
self.assertEqual(code, 200)
job = self.job(reply)
self.assertIsNone(job['error'])
listed = self.request('GET', '/api/sources')[1]['sources']
repo = next(s for s in listed if s['id'] == job['result']['source']['id'])
self.assertEqual((repo['url'], repo['login']), (REPO, True))
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'github',
'token': TOKEN})[0], 200)
github_row = next(s for s in self.request('GET', '/api/sources')[1]['sources'] if s['id'] == 'github')
self.assertEqual(github_row['token'], {'set': True, 'from': 'saved', 'rejected': False})
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'fdroid',
'token': TOKEN})[0], 400)
self.assertEqual(self.request('POST', '/api/sources', {'action': 'clear-token', 'source': 'github'})[0], 200)
self.assertFalse(_credentials.has('github'))
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': repo['id']})[0], 200)
self.assertFalse(self.credentials_file().exists())
self.assertNoSecrets(*self.replies, self.stderr.getvalue(), self.disk())
def test_login_in_the_url_is_accepted(self):
self.replies = []
url = 'https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo'
job = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': url, 'fingerprint': PIN})[1])
self.assertIsNone(job['error'])
self.assertEqual(fdroid.user_repos()[0]['url'], REPO)
self.assertNoSecrets(*self.replies, self.stderr.getvalue())
if __name__ == '__main__':
unittest.main()
+360
View File
@@ -0,0 +1,360 @@
"""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()
-8
View File
@@ -150,14 +150,6 @@ class Apply(unittest.TestCase):
self.assertFalse(self.t.STALE[0])
self.assertEqual(self.said[-1][1].get("stale"), None)
def test_stale_survives_a_bare_release(self):
gs = FakeGamescope()
panel = self.t.apply(gs, {"button": "left", "down": True, "window": 99, "display": ":1"}, None)
self.assertTrue(self.t.STALE[0])
self.t.apply(gs, {"button": "left", "down": False}, panel) # the page's release names no panel
self.assertTrue(self.t.STALE[0])
self.assertEqual(self.said[-1][1].get("stale"), True)
def test_same_window_id_on_the_other_display_is_another_panel(self):
gs = FakeGamescope()
self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None)
+4
View File
@@ -45,6 +45,10 @@ class SourceError(Exception):
"""User-readable failure from a source (network, format, verification)."""
class SourceAuthError(SourceError):
"""The server refused our login (HTTP 401, or 403 when a login was sent)."""
class SourceLimited(SourceError):
"""The source's host asked us to slow down; retry_after is in seconds."""
+116
View File
@@ -0,0 +1,116 @@
"""Store categories that make sense on a Steam Frame, and which apps only make sense on a phone.
Sources describe apps in their own terms (F-Droid's categories, a VR flag). The store groups
them for a headset instead: VR first, then games and the flat apps worth having in a panel
(video, reading, making things). Apps built around a phone's hardware or system (calls and
SMS, launchers, widgets, keyboards, fitness, root tools) are marked phone_only; the store
hides them unless asked, since Lepton can run them but they do nothing useful on the Frame.
"""
import re
# (id, label), in the order the store shows them.
CATEGORIES = (
('vr', 'VR'),
('games', 'Games'),
('emulators', 'Emulators'),
('watch', 'Watch & listen'),
('read', 'Read & learn'),
('create', 'Create'),
('work', 'Work'),
('web', 'Web & chat'),
('tools', 'Tools'),
)
IDS = {c for c, _ in CATEGORIES}
# F-Droid's category names -> a store category; None = only useful on a phone.
# F-Droid has broad categories ('Multimedia') and, since 2025, finer ones ('Podcast'); an app
# usually has both. A finer one decides, since 'Social Network' + 'Multimedia' is a chat app.
BROAD = {
'Games': 'games', 'Multimedia': 'watch', 'Reading': 'read', 'Science & Education': 'read',
'Graphics': 'create', 'Writing': 'work', 'Money': 'work', 'Time': 'work', 'Internet': 'web',
'Development': 'tools', 'Security': 'tools',
'Office': 'work', 'Food': 'read', # IzzyOnDroid's own names
'Connectivity': None, 'Navigation': None, 'Phone & SMS': None, 'Sports & Health': None,
'System': None, 'Theming': None,
}
FINE = {
'emulators': ('Emulator',),
'games': ('Action Game', 'Board Game', 'Card Game', 'Casual Game', 'Dice', 'Educational Game', 'Party Game',
'Platformer Game', 'Puzzle Game', 'Role-Playing Game', 'Shooter Game', 'Sport Game',
'Strategy Game', 'Visual Novel', 'Word Game'),
'watch': ('Local Media Player', 'Online Media Player', 'Podcast', 'Radio', 'Cast', 'Lyrics', 'Gallery',
'Ambient Sound'),
'read': ('Ebook Reader', 'Audiobook', 'News', 'Translation & Dictionary', 'Religion', 'Recipe Manager'),
'create': ('Draw', 'Music Practice Tool', 'Recorder'),
'web': ('Browser', 'Email', 'Messaging', 'Social Network', 'Forum', 'Voice & Video Chat', 'AI Chat',
'Bookmark'),
'work': ('Note', 'Task', 'Text Editor', 'Calendar & Agenda', 'Calculator', 'Unit Convertor',
'Finance Manager', 'Time Tracker', 'Schedule', 'Inventory'),
'tools': ('Code & Forge', 'Password & 2FA', 'File Manager', 'File Encryption & Vault', 'Text Encryption',
'Remote Access', 'Remote Controller', 'Cloud Storage & File Sync', 'Network Analyzer',
'File Transfer', 'Text to Speech', 'Speech Recognizer', 'Download', 'Game Helper', 'Weather'),
}
FINE_TARGET = {name: target for target, names in FINE.items() for name in names}
# Only make sense as part of a phone's own system: any one of these makes an app phone-only.
PHONE_SYSTEM = {
'Launcher', 'Keyboard & IME', 'Wallpaper', 'Icon Pack', 'Flashlight', 'Battery', 'Notification', 'Volume',
'Theming', 'Emergency Action', 'Xposed', 'Automation',
}
# Phone things that are often a side of a useful app (Thunderbird is in Contact, FluffyChat in
# Phone & SMS, Nextcloud in Connectivity, a file manager in System): phone-only unless a useful
# category says otherwise. A finer one when the source has them; a broad one when it only has
# broad ones (IzzyOnDroid often does).
PHONE_SIDE = {
'Phone & SMS', 'Push', 'Contact', 'Camera', 'OCR', 'Location Tracker & Sharer', 'Navigation',
'Public Transport', 'Workout', 'Diet', 'Medication', 'Health Manager', 'Alarm Clock', 'Pass Wallet',
'Wallet', 'VPN & Proxy', 'DNS & Hosts', 'Firewall', 'App Manager', 'App Store & Updater', 'System',
'Connectivity',
}
PHONE_CATEGORIES = PHONE_SYSTEM | PHONE_SIDE
# Fine categories that aren't useful on a headset either, but don't make an app phone-only on their own.
PHONE_LEANING = {'Clock', 'Timer', 'Stopwatch', 'Habit Tracker', 'Shopping List', 'Market & Price',
'Meditation', 'Mental Health', 'Sports & Health'}
# When an app has several, the one that best describes it on a headset wins.
PRIORITY = ('emulators', 'games', 'watch', 'read', 'create', 'web', 'work', 'tools')
EMULATOR = re.compile(r'\bemulat(or|ion|es)\b|\b(retroarch|ppsspp|dolphin|dosbox|scummvm|mame|mupen64|melonds|citra|duckstation|flycast)\b', re.I)
# What an app is, from its name: these are phone parts, whatever the categories say.
PHONE_NAME = re.compile(r'\b(launcher|home ?screen|widgets?|keyboard|wallpapers?|ringtones?|dialer|sms|mms|'
r'caller id|lock ?screen|status ?bar|step counter|pedometer|5g|4g|lte|hotspot|tethering|'
r'magisk|xposed|battery)\b|\[root\]', re.I)
# "Doesn't need root" isn't a root tool; taken out before HARDWARE looks for "needs root".
NOT_ROOT = re.compile(r"\b(no|non|without|not|never|doesn.?t (need|require)|does not (need|require)|don.?t (need|require))[- ]+(the )?root(ed|ing)?\b"
r"|\broot(less|[- ]free| (is )?not (required|needed))\b", re.I)
# Needs hardware or a device the Frame doesn't have, from the name or summary.
HARDWARE = re.compile(r'\b(infra-?red|ir (blaster|remote|transmitter)|wear ?os|smart ?watch(es)?|nfc|sim card|'
r'(requires?|needs?|for) (a )?root(ed)?( (access|device|phone))?)\b', re.I)
def classify(entry):
"""{'category': one of IDS or None, 'phone_only': bool} for a store entry."""
if entry.get('vr') is True:
return {'category': 'vr', 'phone_only': False}
name, summary = str(entry.get('name') or ''), str(entry.get('summary') or '')
names = [c for c in entry.get('categories') or () if isinstance(c, str)]
fine = {FINE_TARGET[c] for c in names if c in FINE_TARGET}
broad = {BROAD[c] for c in names if BROAD.get(c)}
category = next((c for c in PRIORITY if c in fine), None) or next((c for c in PRIORITY if c in broad), None)
if category is None and EMULATOR.search(name + ' ' + summary):
category = 'emulators'
only_broad = all(c in BROAD for c in names) # this source has no finer categories for it
useful = fine or (broad if only_broad else set())
phone = any(c in PHONE_SYSTEM for c in names) or (not useful and any(c in PHONE_SIDE for c in names))
if names and category is None and all(c in PHONE_CATEGORIES or c in PHONE_LEANING or BROAD.get(c, 1) is None
for c in names):
phone = True
if category not in ('games', 'emulators', 'watch', 'read', 'create'):
phone = phone or bool(PHONE_NAME.search(name)) or bool(HARDWARE.search(NOT_ROOT.sub(' ', name + ' ' + summary)))
return {'category': category, 'phone_only': phone}
def matches(entry, category=None, phone=False):
"""Whether a classified entry belongs in a browse for this category ('' or None = any). A search
shows phone-only apps too (search passes phone=True), so a wrong guess never hides an app."""
if entry.get('phone_only') and not phone:
return False
return not category or entry.get('category') == category
+270
View File
@@ -0,0 +1,270 @@
"""Logins for sources behind one: HTTP basic auth and access tokens.
Saved apart from the source settings, in frame_host.data_dir('source-credentials.json')
(mode 0600), keyed by a caller-chosen name: an F-Droid source id, 'github'. Each
credential belongs to one exact origin (https, host, port) and is only ever sent
to a request for that origin: Handler adds it to each request, redirects
included, and removes any Authorization header from every other request.
Scoped use (for_url, and Handler(..., scoped=True), which F-Droid repositories and
artwork use) is stricter: the URL must also be under the credential's scope by whole
path segments, with no dot segments, encoded slashes or ';' a server could normalise
into a path outside it. Origin-only matching suits a host-wide token (GitHub's API).
Secrets leave this module only as an Authorization header value. Credential's
repr hides them, and nothing here puts them in an exception message.
Each change is a read-modify-write under an inter-process lock (the CLI and the
app may both save), so one process can't undo another's save or removal.
"""
import base64
import contextlib
import errno
import json
import os
import tempfile
import threading
import urllib.parse
import urllib.request
import frame_host
_lock = threading.RLock()
MAX = 4096 # longest username, password or token accepted
def _path():
return frame_host.data_dir('source-credentials.json')
@contextlib.contextmanager
def _changing():
"""This thread, then this process, holds the credentials file for a read-modify-write."""
path = _path().with_name('source-credentials.lock')
with _lock:
path.parent.mkdir(parents=True, exist_ok=True)
with open(str(path), 'a+b') as f:
if os.name == 'nt':
import msvcrt
while True:
try:
f.seek(0)
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
break
except OSError as e: # LK_LOCK gives up after ~10 s of contention; keep waiting
if e.errno not in (errno.EACCES, errno.EDEADLK):
raise
try:
yield
finally:
f.seek(0)
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def path_segments(path):
"""A URL path's decoded segments, or None if a server might read it as another path:
'.' or '..' (plain or percent-encoded), encoded '/' or '\\', '%' left after decoding
(double encoding), or ';' path parameters."""
if ';' in path or '\\' in path:
return None
parts = [urllib.parse.unquote(part) for part in path.split('/')]
if any(part in ('.', '..') or '/' in part or '\\' in part or '%' in part or ';' in part for part in parts):
return None
return parts
def in_scope(credential, url):
"""True if url is on credential's origin and under its scope by whole, plain path segments."""
if credential is None or not credential.matches(url):
return False
try:
path, scope = urllib.parse.urlsplit(url).path, urllib.parse.urlsplit(credential.scope).path
except ValueError:
return False
parts, base = path_segments(path), path_segments(scope)
if parts is None or base is None:
return False
if base and base[-1] == '':
base = base[:-1] # 'https://h/repo/' covers '/repo/x', not '/repository'
return parts[:len(base)] == base
def origin(url):
"""'https://host:port' for an https URL; ValueError for anything else."""
p = urllib.parse.urlsplit(url)
port = p.port # raises ValueError on a bad port
if p.scheme.lower() != 'https' or not p.hostname:
raise ValueError('credentials are only sent over HTTPS')
host = p.hostname.lower()
return 'https://%s:%d' % ('[%s]' % host if ':' in host else host, port or 443)
def _check(value, what, allow_empty=False):
if not isinstance(value, str) or (not value and not allow_empty) or len(value) > MAX:
raise ValueError('invalid ' + what)
if any(ord(c) < 32 or ord(c) == 127 for c in value):
raise ValueError(what + ' must not contain control characters')
return value
class Credential:
"""A username and password (basic) or a token (bearer) for one origin.
scope is the URL prefix it belongs under: in_scope checks it (by path segments),
matches() checks only the origin."""
__slots__ = ('origin', 'scope', 'username', '_secret')
def __init__(self, scope, secret, username=None):
self.origin = origin(scope)
self.scope = scope
if username is not None:
_check(username, 'username')
if ':' in username:
raise ValueError('username must not contain ":"')
_check(secret, 'password', allow_empty=True)
else:
_check(secret, 'token')
if any(c.isspace() for c in secret):
raise ValueError('token must not contain spaces')
self.username, self._secret = username, secret
@property
def kind(self):
return 'bearer' if self.username is None else 'basic'
def header(self):
if self.username is None:
return 'Bearer ' + self._secret
return 'Basic ' + base64.b64encode((self.username + ':' + self._secret).encode('utf-8')).decode('ascii')
def matches(self, url):
"""True only for a URL on exactly this credential's origin."""
try:
return origin(url) == self.origin
except ValueError:
return False
def same(self, other):
return isinstance(other, Credential) and (self.origin, self.username, self._secret) == (
other.origin, other.username, other._secret)
def __repr__(self):
return '<Credential %s for %s>' % (self.kind, self.origin)
def _json(self):
return {'scope': self.scope, 'username': self.username, 'secret': self._secret}
def basic(scope, username, password):
"""Basic auth for scope's origin, e.g. basic('https://example.org/fdroid/repo/', 'me', 'pw')."""
return Credential(scope, password, username)
def bearer(scope, token):
"""An access token for scope's origin, sent as 'Authorization: Bearer <token>'."""
return Credential(scope, token)
def _read():
try:
with open(str(_path()), encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError
return data
except FileNotFoundError:
return {}
except (OSError, ValueError):
return {} # unreadable: as if none were saved; the next save rewrites it
def _save(data):
path = _path()
if not data:
try:
os.remove(str(path))
except FileNotFoundError:
pass
return
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.credentials-', suffix='.part') # created 0600
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f, separators=(',', ':'))
os.chmod(tmp, 0o600)
os.replace(tmp, str(path))
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def get(key):
"""The saved Credential for key, or None."""
with _lock:
item = _read().get(key)
try:
return Credential(item['scope'], item['secret'], item.get('username'))
except (TypeError, KeyError, ValueError):
return None
def has(key):
return get(key) is not None
def put(key, credential):
if not isinstance(credential, Credential):
raise TypeError('put() needs a Credential')
with _changing():
data = _read()
data[key] = credential._json()
_save(data)
def remove(key):
"""Delete key's credential from disk; True if there was one."""
with _changing():
data = _read()
if key not in data:
return False
del data[key]
_save(data)
return True
def for_url(url):
"""A saved credential url is in the scope of (see in_scope), or None. Call it again for
each redirect hop: the answer depends only on the URL being requested."""
with _lock:
keys = list(_read())
for key in keys:
credential = get(key)
if in_scope(credential, url):
return credential
return None
class Handler(urllib.request.BaseHandler):
"""Adds the credential to requests for its origin (redirects included) and strips
Authorization from every other request, so a redirect elsewhere never gets it.
scoped=True also requires each request, redirects included, to be in_scope."""
handler_order = 900 # after the default processors
def __init__(self, credential, scoped=False):
self.credential, self.scoped = credential, scoped
def _request(self, req):
req.remove_header('Authorization')
allowed = in_scope if self.scoped else lambda c, url: c is not None and c.matches(url)
if allowed(self.credential, req.full_url):
req.add_unredirected_header('Authorization', self.credential.header())
return req
http_request = https_request = _request
+44 -13
View File
@@ -9,10 +9,12 @@ import threading
import time
from urllib.parse import urljoin, urlsplit
from apk_sources import SourceError
from apk_sources import SourceError, _credentials
_lock = threading.Lock()
_urls = OrderedDict()
_urls = OrderedDict() # token -> URL, oldest first
_tokens = {} # URL -> token
MAX_TOKENS = 16384
_cache = OrderedDict()
MAX_IMAGE = 8 * 1024 * 1024
MAX_CACHE = 64 * 1024 * 1024
@@ -31,24 +33,24 @@ def register(url):
if not isinstance(url, str) or not valid_url(url):
return None
with _lock:
for token, known in _urls.items():
if known == url:
_urls.move_to_end(token)
return '/source-image/' + token
token = _tokens.get(url)
if token in _urls:
_urls.move_to_end(token)
return '/source-image/' + token
token = secrets.token_urlsafe(24)
_urls[token] = url
while len(_urls) > 4096:
_urls.popitem(last=False)
_urls[token], _tokens[url] = url, token
while len(_urls) > MAX_TOKENS: # a store browse lists a few thousand images
_tokens.pop(_urls.popitem(last=False)[1], None)
return '/source-image/' + token
def artwork(entry):
def artwork(entry, shots=12):
images = entry.get('images') or {}
if not isinstance(images, dict):
images = {}
return {'icon': register(images.get('icon') or entry.get('icon')),
'banner': register(images.get('banner')),
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]}
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:min(shots, 12)]) if path]}
def image_type(data):
@@ -71,12 +73,37 @@ def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
_resolvers = threading.BoundedSemaphore(4)
# A page of store cards asks for dozens of images from the same few hosts at once: their public
# addresses are kept for a few minutes, so most requests don't need a lookup (or a slot) at all.
_dns = {} # (host, port) -> (addresses, expires)
DNS_TTL = 300
def _resolve(host, port, timeout):
with _lock:
known = _dns.get((host, port))
if known and known[1] > time.monotonic():
return known[0]
found = _lookup(host, port, timeout)
try:
public = bool(found) and all(ipaddress.ip_address(a[4][0]).is_global for a in found)
except (TypeError, ValueError, IndexError):
public = False
if public: # private answers are refused by the caller, and never kept
with _lock:
_dns[(host, port)] = (found, time.monotonic() + DNS_TTL)
while len(_dns) > 256:
_dns.pop(next(iter(_dns)))
return found
def _lookup(host, port, timeout):
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
if not _resolvers.acquire(blocking=False):
# A request waits for a free slot within its own budget rather than failing at once.
started = time.monotonic()
if not _resolvers.acquire(timeout=max(0, min(timeout, 10))):
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
timeout -= time.monotonic() - started # the wait for a slot comes out of the same budget
found = {}
def run():
@@ -141,7 +168,11 @@ def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
path = p.path or '/'
if p.query:
path += '?' + p.query
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
sent = {'User-Agent': 'FrameControl/0.3.1', **(headers or {})}
login = _credentials.for_url(url) # a repository behind a login: its own artwork only, per hop
if login:
sent['Authorization'] = login.header()
conn.request('GET', path, headers=sent)
live[0].settimeout(min(10, left()))
response = conn.getresponse()
if response.status in (301, 302, 303, 307, 308) and redirects:
+28 -13
View File
@@ -3,7 +3,7 @@ import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.pars
from email.utils import parsedate_to_datetime
import frame_host
from . import SourceError, SourceLimited
from . import SourceAuthError, SourceError, SourceLimited, _credentials
UA = 'FrameControl/0.1'
APK_CAP = 2 * 1024 ** 3 # cached APKs across all sources, least recently used go first
@@ -38,7 +38,7 @@ def release(path):
else:
_in_use.pop(path, None)
BACKOFF = 600 # seconds to leave a host alone after 403/429 without Retry-After
_limited = {} # host -> time.time() before which we don't contact it
_limited = {} # host (+ login identity) -> time.time() before which we don't ask it with that login
_limited_lock = threading.Lock()
@@ -46,8 +46,16 @@ def _host(url):
return (urllib.parse.urlsplit(url).hostname or '').lower()
def throttle(url, headers=None):
"""Remember that url's host asked us to back off; return the delay in seconds."""
def _who(url, auth=None):
"""Backoff key: the host, per login. A token has its own quota, so anonymous
rate limiting mustn't hold back requests made with one (or the reverse)."""
if auth is None:
return _host(url)
return _host(url) + ' ' + hashlib.sha256(auth.header().encode()).hexdigest()[:16]
def throttle(url, headers=None, auth=None):
"""Remember that url's host asked us (with this login) to back off; return the delay in seconds."""
headers = headers or {}
now, delay = time.time(), None
value = (headers.get('Retry-After') or '').strip()
@@ -62,14 +70,15 @@ def throttle(url, headers=None):
if delay is None and headers.get('X-RateLimit-Remaining') == '0' and reset.isdigit():
delay = int(reset) - now # GitHub
delay = min(max(delay if delay is not None else BACKOFF, 1), 6 * 3600)
key = _who(url, auth)
with _limited_lock:
_limited[_host(url)] = max(_limited.get(_host(url), 0), now + delay)
_limited[key] = max(_limited.get(key, 0), now + delay)
return delay
def wait_time(url):
def wait_time(url, auth=None):
with _limited_lock:
return max(0, _limited.get(_host(url), 0) - time.time())
return max(0, _limited.get(_who(url, auth), 0) - time.time())
def limited_error(name, seconds, hint=''):
@@ -136,6 +145,8 @@ def checked_url(url, hosts):
class Redirect(urllib.request.HTTPRedirectHandler):
"""Only to the allowed hosts, and never carrying an Authorization header along:
a credential passed to open_url() is added again, by its Handler, only on its origin."""
def __init__(self, hosts):
self.hosts = hosts
@@ -147,13 +158,15 @@ class Redirect(urllib.request.HTTPRedirectHandler):
return result
def open_url(url, hosts, headers=None):
def open_url(url, hosts, headers=None, auth=None):
"""auth: an _credentials.Credential, sent only to requests on its own origin."""
checked_url(url, hosts)
return urllib.request.build_opener(Redirect(hosts)).open(
handlers = [Redirect(hosts)] + ([_credentials.Handler(auth)] if auth else [])
return urllib.request.build_opener(*handlers).open(
urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60)
def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data')
name = name or _host(url) or 'The source'
@@ -165,13 +178,13 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
if os.path.isfile(path) and time.time() - os.path.getmtime(path) < ttl:
with open(path, 'rb') as f:
return f.read()
wait = wait_time(url)
wait = wait_time(url, auth)
if wait:
data = cached()
if data is None:
raise limited_error(name, wait, hint)
return data
with open_url(url, hosts, headers) as r:
with open_url(url, hosts, headers, auth) as r:
data = r.read(8 * 1024 * 1024 + 1)
if len(data) > 8 * 1024 * 1024:
raise SourceError('Source index is too large')
@@ -185,8 +198,10 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
os.remove(tmp)
return data
except urllib.error.HTTPError as e:
if e.code == 401: # not 403: GitHub also uses that for rate limits, token or not
raise SourceAuthError('%s refused the login (HTTP 401)' % name) from e
if e.code in (403, 429):
delay = throttle(url, e.headers)
delay = throttle(url, e.headers, auth)
data = cached()
if data is None:
raise limited_error(name, delay, hint) from e
+111 -20
View File
@@ -20,13 +20,13 @@ import zipfile
if __package__ in (None, ''):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from apk_sources import SourceError, SourceLimited, _web
from apk_sources import SourceAuthError, SourceError, SourceLimited, _credentials, _web
import frame_host
from frame_apk_sign import _der_parts, _cert_key, der
from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
CACHE_VERSION = 2
CACHE_VERSION = 3 # 3: categories
TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
_LOCK = threading.RLock() # settings only; never held while downloading
_load_locks = {}
@@ -71,6 +71,39 @@ def _url(url, fingerprint=None):
return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin
def _split_login(url, username=None, password=None):
"""(url without user:pass, credential or None). The address may carry the login, as
F-Droid's client accepts (https://user:pass@host/fdroid/repo), or it may be given apart."""
try:
p = urllib.parse.urlsplit(url)
p.port
except ValueError as e:
raise SourceError('invalid repository URL') from e
if '@' in p.netloc:
if p.username is None:
raise SourceError('invalid repository URL')
linked = urllib.parse.unquote(p.username), None if p.password is None else urllib.parse.unquote(p.password)
if username not in (None, '', linked[0]) or (linked[1] is not None and password not in (None, '', linked[1])):
raise SourceError('the address and the username/password fields disagree')
username = linked[0]
password = linked[1] if linked[1] is not None else password
url = urllib.parse.urlunsplit((p.scheme, p.netloc.rpartition('@')[2], p.path, p.query, p.fragment))
if username in (None, ''):
if password:
raise SourceError('a password needs a username')
return url, None
clean, _ = _url(url)
try:
return url, _credentials.basic(clean, username, password or '')
except ValueError as e:
raise SourceError('cannot use that login: ' + str(e)) from None
def _login(source):
"""The saved login for a user repository, or None."""
return None if source.get('builtin') else _credentials.get(source['id'])
def _child(base, name):
name = str(name).lstrip('/')
decoded = urllib.parse.unquote(name)
@@ -81,6 +114,9 @@ def _child(base, name):
url = urllib.parse.urljoin(base, urllib.parse.quote(decoded, safe='/')) # names may contain '#' or spaces
if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment:
raise SourceError('repository file is outside its repository')
# Nothing a server could normalise elsewhere: double-encoded dots, ';' parameters, encoded slashes.
if _credentials.path_segments(urllib.parse.urlsplit(url).path) is None:
raise SourceError('unsafe repository file name')
return url
@@ -91,14 +127,17 @@ class _HTTPSRedirect(urllib.request.HTTPRedirectHandler):
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _fetch(url, path, maximum):
def _fetch(url, path, maximum, auth=None):
"""auth: the repository's login, sent only inside the repository (its origin and directory,
by whole plain path segments), on every request including redirects."""
host = urllib.parse.urlsplit(url).hostname
wait = _web.wait_time(url)
wait = _web.wait_time(url, auth)
if wait:
raise _web.limited_error(host, wait)
request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'})
handlers = [_HTTPSRedirect()] + ([_credentials.Handler(auth, scoped=True)] if auth else [])
try:
with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f:
with urllib.request.build_opener(*handlers).open(request, timeout=60) as r, open(path, 'wb') as f:
total = 0
while True:
chunk = r.read(1 << 20)
@@ -109,8 +148,11 @@ def _fetch(url, path, maximum):
raise SourceError('repository file exceeds size limit')
f.write(chunk)
except urllib.error.HTTPError as e:
if e.code == 401 or (e.code == 403 and auth):
raise SourceAuthError('The repository refused the login (HTTP %d); check the username and password'
% e.code if auth else 'This repository needs a username and password') from None
if e.code in (403, 429):
raise _web.limited_error(host, _web.throttle(url, e.headers)) from e
raise _web.limited_error(host, _web.throttle(url, e.headers, auth)) from e
raise
@@ -342,7 +384,7 @@ def sources():
return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True,
# The archive only holds superseded versions; it clutters search unless asked for.
enabled=settings['enabled'].get(i, i != 'fdroid-archive'), trust='community')
for i, n, u, p in builtins] + settings['repos']
for i, n, u, p in builtins] + [dict(s, login=_credentials.has(s['id'])) for s in settings['repos']]
def _text(value):
@@ -432,6 +474,7 @@ def _reduce(path, source):
name=_text(meta.get('name')) or pkg, summary=_summary(meta.get('summary')),
icon=images['icon'], images=images, developer=_text(meta.get('authorName')) or None,
page=meta.get('webSite') or source['url'], vr=None, free=True,
categories=[c for c in meta.get('categories') or [] if isinstance(c, str)][:8],
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
return result
@@ -450,6 +493,7 @@ def _v1(content, path):
localized = app.get('localized', {})
meta = {k: _text({locale: fields[k] for locale, fields in localized.items() if fields.get(k)}) or app.get(k)
for k in ('name', 'summary', 'license', 'webSite', 'authorName')}
meta['categories'] = app.get('categories') or []
for field in ('icon', 'featureGraphic', 'phoneScreenshots', 'sevenInchScreenshots'):
images = {}
for locale, fields in localized.items():
@@ -514,10 +558,12 @@ def _source_lock(source_id):
return _load_locks.setdefault(source_id, threading.Lock())
def _load(source, force=False):
def _load(source, force=False, auth=None):
"""auth: a login to use instead of the saved one (while adding, before anything is saved)."""
if not re.fullmatch(r'[a-z0-9-]+', source['id']):
raise SourceError('invalid source id')
_url(source['url'], source.get('fingerprint'))
auth = auth or _login(source)
cache = frame_host.cache_dir('apk-sources', source['id'] + '.json')
found = None if force else _cached(source, cache)
if found:
@@ -535,7 +581,7 @@ def _load(source, force=False):
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
v2 = True
try:
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024, auth=auth)
except urllib.error.HTTPError as e:
if e.code not in (404, 410):
raise
@@ -543,7 +589,7 @@ def _load(source, force=False):
raise SourceError('repository no longer serves its signed v2 index; '
'refusing to fall back to the older v1 index') from e
v2 = False
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024, auth=auth)
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
timestamp = _v1(content, raw)
_check_timestamp(source, timestamp)
@@ -552,7 +598,7 @@ def _load(source, force=False):
signed = json.loads(content)
timestamp, entry = signed.get('timestamp'), signed['index']
_check_timestamp(source, timestamp)
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024)
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024, auth=auth)
if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']):
raise SourceError('index SHA-256 or size mismatch')
apps = _reduce(raw, source)
@@ -572,7 +618,10 @@ def _load(source, force=False):
raise SourceError('cannot load repository: ' + str(e)) from e
def add_repo(url, fingerprint=None, name=None):
def add_repo(url, fingerprint=None, name=None, username=None, password=None):
"""Check and save a repository. A login (in the URL or apart) is kept out of the
saved URL, in _credentials, and saved only once the repository's index verified."""
url, login = _split_login(url, username, password)
url, pin = _url(url, fingerprint)
with _LOCK:
existing = next((s for s in _read()['repos'] if s['url'] == url), None)
@@ -583,15 +632,26 @@ def add_repo(url, fingerprint=None, name=None):
source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND,
name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname,
url=url, builtin=False, enabled=True, trust='user', fingerprint=pin)
_, source['fingerprint'] = _load(source, force=True) # network work outside the settings lock
# Network work outside the settings lock; without a new login, a saved one (re-adding) is used.
_, source['fingerprint'] = _load(source, force=True, auth=login)
source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None
with _LOCK:
settings = _read()
current = next((s for s in settings['repos'] if s['id'] == source['id']), None)
if current and current['fingerprint'] != source['fingerprint']:
raise SourceError('repository already has a different pinned fingerprint; remove it first')
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
_write(_storage(), settings)
previous = _credentials.get(source['id'])
if login:
_credentials.put(source['id'], login)
try:
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
_write(_storage(), settings)
except BaseException:
if login and previous: # not saved after all: leave the login as it was
_credentials.put(source['id'], previous)
elif login:
_credentials.remove(source['id'])
raise
return source
@@ -602,6 +662,7 @@ def remove_repo(source_id):
raise SourceError('unknown user repository')
settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id]
_write(_storage(), settings)
_credentials.remove(source_id)
with _state_file_lock(), _LOCK: # lock order: state file, then _LOCK
states = _states()
if states.pop(source_id, None) is not None: # re-adding is a deliberate new trust decision
@@ -625,16 +686,39 @@ def set_enabled(source_id, enabled):
_write(_storage(), settings)
def search(source, query, limit=50):
def _showcase(a):
"""Browse order without a query: apps with real artwork first, then the most recently updated."""
images = a.get('images') or {}
return (not images.get('banner'), not images.get('screenshots'), not images.get('icon'),
''.join(chr(0x10ffff - ord(c)) for c in str(a.get('updated') or '')))
def search(source, query, limit=50, keep=None):
"""keep: optional filter applied before the limit (the store's category browse)."""
if not source.get('enabled', True):
return []
apps, _ = _load(source)
words = query.casefold().split()
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)
and (keep is None or keep(a))]
if words:
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
else:
found.sort(key=_showcase)
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
def lookup(source, packages):
"""Entries for these packages from the saved index only (never downloads); missing ones are left out."""
if not source.get('enabled', True):
return {}
found = _cached(source, frame_host.cache_dir('apk-sources', source['id'] + '.json'))
if not found:
return {}
apps = found[0]
return {p: {k: v for k, v in apps[p].items() if k != 'versions'} for p in packages if p in apps}
def details(source, entry_id):
if not source.get('enabled', True):
raise SourceError('repository is disabled')
@@ -663,7 +747,7 @@ def download(source, entry_id, version_code=None):
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
os.close(fd)
try:
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3)
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3, auth=_login(source))
if _sha256(tmp) != sha:
raise SourceError('APK SHA-256 mismatch; download discarded')
os.replace(tmp, path)
@@ -684,6 +768,8 @@ def main():
add.add_argument('url')
add.add_argument('--fingerprint')
add.add_argument('--name')
add.add_argument('--username', help='for a repository behind a login (HTTP basic auth)')
add.add_argument('--password-stdin', action='store_true', help='read the password from the first line of stdin')
sub.add_parser('list')
remove = sub.add_parser('remove')
remove.add_argument('source')
@@ -702,7 +788,12 @@ def main():
def _cli(parser, args):
try:
if args.command == 'add':
result = add_repo(args.url, args.fingerprint, args.name)
password = None
if args.password_stdin:
if not args.username:
raise SourceError('--password-stdin needs --username')
password = sys.stdin.readline().rstrip('\r\n')
result = add_repo(args.url, args.fingerprint, args.name, args.username, password)
elif args.command == 'list':
result = sources()
elif args.command == 'remove':
+88 -8
View File
@@ -1,17 +1,77 @@
"""Curated publisher releases; optional topic discovery is page-only."""
import fnmatch, json, os, re, urllib.parse
import fnmatch, hashlib, json, os, re, sys, threading, urllib.parse
from . import SourceError, _web
from . import SourceAuthError, SourceError, _credentials, _web
KIND = 'github'
API = 'https://api.github.com'
ENV = 'FRAME_GITHUB_TOKEN' # wins over a token saved in source settings
CREDENTIAL = 'github' # _credentials key; only ever sent to api.github.com
_rejected = set() # SHA-256 of tokens GitHub answered 401 to (this run only)
_rejected_lock = threading.Lock()
TOPICS = ('oculus-quest', 'openxr', 'quest')
HOSTS = ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')
def sources():
return [{'id': KIND, 'kind': KIND, 'name': 'GitHub releases', 'url': 'https://github.com',
'builtin': True, 'enabled': True, 'trust': 'community'}]
'builtin': True, 'enabled': True, 'trust': 'community', 'token': token_status()}]
def _token():
"""(token, where it came from) or (None, None). The environment variable wins."""
env = os.environ.get(ENV)
if env:
return env, 'env'
saved = _credentials.get(CREDENTIAL)
if saved and saved.kind == 'bearer':
return saved, 'saved'
return None, None
def _credential(token):
if isinstance(token, _credentials.Credential):
return token
try:
return _credentials.bearer(API + '/', token)
except ValueError as e:
raise SourceError('%s is not a usable GitHub token: %s' % (ENV, e)) from None
def _digest(credential):
return hashlib.sha256(credential.header().encode()).hexdigest()
def token_status():
"""Whether a token is set, where from, and whether GitHub rejected it; never the token."""
token, where = _token()
rejected = False
if token is not None:
try:
with _rejected_lock:
rejected = _digest(_credential(token)) in _rejected
except SourceError:
rejected = True
return {'set': token is not None, 'from': where, 'rejected': rejected}
def set_token(token):
"""Save a personal access token for api.github.com (FRAME_GITHUB_TOKEN still wins)."""
if not isinstance(token, str) or not token.strip():
raise SourceError('Paste a GitHub token')
try:
credential = _credentials.bearer(API + '/', token.strip())
except ValueError as e:
raise SourceError('That is not a usable GitHub token: ' + str(e)) from None
_credentials.put(CREDENTIAL, credential)
with _rejected_lock:
_rejected.discard(_digest(credential)) # a fresh chance
return {'message': 'GitHub token saved' + (' (%s is set and is used instead)' % ENV if os.environ.get(ENV) else '')}
def clear_token():
_credentials.remove(CREDENTIAL)
return {'message': 'GitHub token removed'}
def _curated():
@@ -20,13 +80,33 @@ def _curated():
def _api(path):
"""GET from the GitHub API, with the user's token if there is one. If GitHub rejects
the token (401), retry once without it, like Obtainium, and remember the rejection."""
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
token = os.environ.get('FRAME_GITHUB_TOKEN')
if token:
headers['Authorization'] = 'Bearer ' + token
token, _ = _token()
auth = _credential(token) if token is not None else None
with _rejected_lock:
if auth and _digest(auth) in _rejected:
auth = None # already refused this run: don't spend a request on it again
hint = ' (add a GitHub token in the store\'s source settings, or set %s, to raise the limit)' % ENV
if token is not None and not auth:
hint = ' (GitHub rejected your token; replace it in the store\'s source settings)'
def get(auth):
return _web.read(API + path, ('api.github.com',), headers, name='GitHub',
hint='' if auth else hint, auth=auth)
try:
return json.loads(_web.read(API + path, ('api.github.com',), headers, name='GitHub',
hint='' if token else ' (set FRAME_GITHUB_TOKEN to raise the limit)'))
try:
data = get(auth)
except SourceAuthError:
if not auth:
raise
with _rejected_lock:
_rejected.add(_digest(auth))
print('GitHub rejected the access token (HTTP 401); continuing without it', file=sys.stderr)
hint = ' (GitHub rejected your token; replace it in the store\'s source settings)'
data = get(None)
return json.loads(data)
except (ValueError, TypeError) as e:
raise SourceError('Invalid GitHub response') from e
+66 -14
View File
@@ -15,7 +15,7 @@ import time
import unicodedata
import frame_host
from apk_sources import SourceError, SourceLimited
from apk_sources import SourceError, SourceLimited, _categories as categories
_lock = threading.RLock()
_running = {}
@@ -23,6 +23,7 @@ _pending = {} # source id -> newest query waiting for the running one
_game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
_status = {}
TIMEOUT = 12
BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping
def modules():
@@ -113,6 +114,14 @@ def manage_repo(action, **kwargs):
return getattr(module, action)(**kwargs)
def set_token(source_id, token):
"""Save (token) or remove (None) a source's access token; status is in sources()."""
module, _ = resolve(source_id)
if not hasattr(module, 'set_token'):
raise SourceError('This source does not use an access token')
return module.set_token(token) if token is not None else module.clear_token()
def normalise(name):
return ' '.join(re.findall(r'\w+', unicodedata.normalize('NFKC', name or '').casefold()))
@@ -158,9 +167,11 @@ def verdict(entry):
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
def decorate(entry):
def decorate(entry, shots=12):
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
from apk_sources import _images
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry))
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
**categories.classify(entry))
def details(source_id, entry_id):
@@ -178,10 +189,12 @@ def offer_rank(entry):
entry.get('trust') == 'official')
def group(entries, query='', vr=None, installable=False):
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
groups = {}
for entry in entries:
entry = decorate(entry)
entry = decorate(entry, shots=3)
if not categories.matches(entry, category, phone):
continue
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
continue
if installable and entry['fit']['installable'] is not True:
@@ -195,7 +208,7 @@ def group(entries, query='', vr=None, installable=False):
offers.sort(key=offer_rank, reverse=True)
best = offers[0]
result.append({'name': best.get('name'), 'package': best.get('package'),
'summary': best.get('summary'), 'offers': offers})
'summary': best.get('summary'), 'category': best.get('category'), 'offers': offers})
q = normalise(query)
def browse(a): # a headset store: VR first, then apps with artwork, newest first
@@ -215,10 +228,10 @@ def group(entries, query='', vr=None, installable=False):
return result
def _launch(module, source, query, limit):
def _launch(module, source, query, limit, category=None, phone=False):
"""One search per source at a time; the newest different query runs next."""
key = source['id']
task = {'event': threading.Event(), 'query': (query, limit), 'started': time.monotonic(),
task = {'event': threading.Event(), 'query': (query, limit, category or None, bool(phone)), 'started': time.monotonic(),
'job': (module, source)}
with _lock:
old = _running.get(key)
@@ -237,13 +250,17 @@ def _launch(module, source, query, limit):
def _start(key, task):
module, source = task['job']
query, limit = task['query']
query, limit, category, phone = task['query']
kwargs = {}
if (category or not phone) and 'keep' in inspect.signature(module.search).parameters:
# Large sources filter before their limit, so a category browse isn't cut to the first few hundred.
kwargs['keep'] = lambda e: categories.matches(dict(e, **categories.classify(e)), category, phone)
def run():
queued = None
try:
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
for e in module.search(source, query, limit=limit) if e.get('free') is True]
for e in module.search(source, query, limit=limit, **kwargs) if e.get('free') is True]
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
except Exception as e:
task['error'] = str(e)
@@ -259,14 +276,20 @@ def _start(key, task):
threading.Thread(target=run, daemon=True).start()
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50, category=None, phone=False):
items, errors = registry()
if source and source not in [s['id'] for _, s in items]:
raise SourceError('Unknown source')
if category and category not in categories.IDS:
raise SourceError('Unknown category')
if not query.strip():
limit = max(limit, BROWSE_LIMIT)
else:
phone = True # the phone-only guess hides apps from browsing, never from a search for them
chosen = [(m, s) for m, s in items if s['enabled'] and (not source or s['id'] == source)]
# Page-only sources (SideQuest) can't be searched; offer a link to browse them instead.
elsewhere = [{'name': s['name'], 'url': s['url']} for m, s in chosen if s.get('page_only')]
tasks = [(s, _launch(m, s, query, limit)) for m, s in chosen if not s.get('page_only')]
tasks = [(s, _launch(m, s, query, limit, category, phone)) for m, s in chosen if not s.get('page_only')]
entries, statuses = [], list(errors)
for s, task in tasks:
status = {'id': s['id'], 'name': s['name']}
@@ -281,7 +304,8 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
statuses.append(status)
with _lock:
_status[s['id']] = {k: v for k, v in status.items() if k not in ('id', 'name')}
return {'apps': group(entries, query, vr, installable), 'sources': statuses, 'elsewhere': elsewhere}
return {'apps': group(entries, query, vr, installable, category, phone), 'sources': statuses, 'elsewhere': elsewhere,
'categories': [{'id': c, 'label': label} for c, label in categories.CATEGORIES]}
def warm():
@@ -291,7 +315,35 @@ def warm():
items, _ = registry()
for m, s in items:
if s['enabled'] and not s.get('page_only'):
_launch(m, s, '', 50) # same as the first browse, so that search reuses it
_launch(m, s, '', BROWSE_LIMIT) # same as the first browse, so that search reuses it
def artwork_for(apps):
"""Store artwork for installed apps ({'package', 'label'} each), from saved indexes only:
by package where a source has one, else by name (curated GitHub apps). {package: artwork}."""
from apk_sources import _images
items, _ = registry()
wanted = {a['package']: normalise(a.get('label')) for a in apps if a.get('package')}
found = {}
for module, source in items:
if not source['enabled'] or source.get('page_only'):
continue
try:
if hasattr(module, 'lookup'):
hits = module.lookup(source, [p for p in wanted if p not in found])
elif module.KIND == 'github': # a small local list; its entries have names, not packages
names = {n: p for p, n in wanted.items() if n and p not in found}
hits = {names[normalise(e['name'])]: e for e in module.search(source, '', limit=100)
if normalise(e.get('name')) in names}
else:
continue
except (SourceError, OSError, ValueError, KeyError, TypeError):
continue
for package, entry in hits.items():
art = _images.artwork(entry)
if art['icon'] or art['banner'] or art['screenshots']:
found.setdefault(package, art)
return found
def install(source_id, entry_id, version_code=None, progress=None):
+1 -3
View File
@@ -506,10 +506,8 @@ def apply(gs, event, panel):
say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs
STALE[0] = True
return panel
release = event.get("down") is False and ("button" in event or "key" in event)
if STALE[0] and not stale and not (release and "window" not in event):
if STALE[0] and not stale:
# Anything that goes through (a trackpad move names no panel) means caught up: stop re-syncing.
# A bare release doesn't: the page leaves the panel off releases, so it says nothing about focus.
STALE[0] = False
say("ready", focus=(panel or {}).get("window"), display=(panel or {}).get("display"))
if "fx" in event and panel and panel.get("window"):
+301 -62
View File
@@ -559,6 +559,95 @@
.store-badges { display:flex; flex-wrap:wrap; gap:7px; }
.store-badge { font-size:11px; color:#b7d8ec; background:#6ec9ff14; padding:4px 9px; border-radius:5px; }
.store-badge.works { color:#b8e2ab; background:#70bb5418; }
/* ---- Android: Store and Library ---- */
.store-tabs { border-bottom:0; padding-bottom:0; margin-bottom:26px; }
.store-switch { display:inline-flex; padding:4px; gap:4px; border-radius:12px; background:#0e1723; border:1px solid #ffffff10; }
.store-switch > button { display:inline-flex; align-items:center; gap:9px; height:42px; padding:0 22px; border-radius:9px; font-size:15px; font-weight:600; background:transparent; color:var(--muted); border:0; }
.store-switch > button svg { width:19px; height:19px; }
.store-switch > button.on { background:linear-gradient(180deg,#2a4763,#203a52); color:var(--bright); box-shadow:0 2px 10px #0006, inset 0 1px 0 #ffffff14; }
.store-switch > button:not(.on):hover { color:var(--text); background:#ffffff08; }
#andCount { font-size:12px; font-weight:600; color:#c6e2f5; background:#1a9fff2e; border-radius:10px; padding:1px 8px; }
#andCount:empty { display:none; }
.store-layout { display:grid; grid-template-columns:200px minmax(0,1fr); gap:32px; align-items:start; }
.store-side { position:sticky; top:88px; display:flex; flex-direction:column; gap:18px; }
.store-cats { display:flex; flex-direction:column; gap:2px; }
.store-cats button { display:flex; align-items:center; gap:11px; width:100%; height:38px; padding:0 12px; border-radius:8px; background:transparent; border:0; color:#b5c0cb; font-size:14px; font-weight:500; text-align:left; }
.store-cats button svg { width:18px; height:18px; flex:none; opacity:.85; }
.store-cats button:hover { background:#ffffff09; color:var(--bright); }
.store-cats button[aria-pressed=true] { background:#1a9fff26; color:var(--bright); box-shadow:inset 3px 0 0 var(--blue); }
.store-side-opts { display:flex; flex-direction:column; gap:10px; padding:14px 12px 0; border-top:1px solid #ffffff10; font-size:12px; color:#a8b5c2; }
.store-check { display:flex; align-items:center; gap:8px; cursor:pointer; }
.store-check input { accent-color:var(--blue); width:15px; height:15px; margin:0; }
.store-source-select { display:flex; flex-direction:column; gap:6px; }
.store-source-select select { max-width:none; width:100%; }
.store-main { min-width:0; }
.store-toolbar { margin-bottom:20px; }
@media(max-width:900px) {
.store-layout { display:block; }
.store-side { position:static; margin-bottom:18px; gap:12px; }
.store-cats { flex-direction:row; overflow-x:auto; scrollbar-width:none; gap:6px; }
.store-cats button { width:auto; flex:none; height:34px; border-radius:18px; background:#ffffff07; border:1px solid #ffffff0c; font-size:12px; }
.store-cats button[aria-pressed=true] { box-shadow:none; background:#235779; border-color:#5287a5; }
.store-side-opts { flex-direction:row; flex-wrap:wrap; align-items:center; gap:14px; border:0; padding:0; }
.store-source-select { flex-direction:row; align-items:center; }
}
.store-shelf { display:grid; grid-auto-flow:column; grid-auto-columns:minmax(220px,calc((100% - 54px)/4)); gap:18px; overflow-x:auto; scroll-snap-type:x mandatory; padding:4px 2px 14px; scrollbar-width:thin; }
.store-shelf > * { scroll-snap-align:start; }
.store-row-head .see-all { margin-left:auto; font-size:12px; height:28px; padding:0 12px; border-radius:14px; background:#ffffff0a; color:var(--link); border:0; }
.store-row-head .see-all:hover { background:#ffffff14; color:var(--bright); }
.store-card { display:flex; flex-direction:column; }
.store-card-content { display:flex; flex-direction:column; flex:1; min-width:0; width:100%; }
.store-card-title strong { min-width:0; }
.store-summary { white-space:normal; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; min-height:2.9em; line-height:1.45; }
.store-card-foot { margin-top:auto; padding-top:12px; }
.store-art.shots { display:flex; gap:6px; justify-content:center; padding:10px 10px 0; }
.store-art.shots > img { position:relative; inset:auto; width:auto; height:100%; max-width:31%; object-fit:cover; object-position:top; border-radius:6px 6px 0 0; box-shadow:0 4px 14px #0008; }
.store-art.icon-art > .art-backdrop { position:absolute; inset:-30%; width:160%; height:160%; object-fit:cover; filter:blur(28px) saturate(1.4) brightness(.55); }
.store-art.icon-art > .art-mark { position:absolute; z-index:2; left:50%; top:50%; width:34%; aspect-ratio:1; height:auto; transform:translate(-50%,-50%); border-radius:22%; box-shadow:0 8px 24px #0009; object-fit:cover; }
.store-art.icon-art .art-initial, .store-art.shots .art-initial { display:none; }
.store-cat { color:#a8b5c2; }
.store-fit { font-size:11px; font-weight:600; border-radius:4px; padding:2px 7px; }
.store-fit.works { color:#c3efb2; background:#59bf4029; }
.store-fit.ready { color:#bfe3fa; background:#1a9fff26; }
.store-fit.blocked { color:#f3b2a6; background:#d9412629; }
.store-vr { font-size:10px; font-weight:700; letter-spacing:.8px; color:#0e141b; background:#c2e7ff; border-radius:4px; padding:2px 6px; }
.store-art .store-vr { position:absolute; z-index:3; top:9px; left:9px; box-shadow:0 2px 8px #0007; }
.store-hero .hero-meta { display:flex; gap:8px; align-items:center; }
/* Library */
.lib-head { display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap; margin-bottom:22px; }
.lib-head h1 { font-size:clamp(26px,3vw,34px); letter-spacing:-1px; margin:6px 0 6px; color:var(--bright); font-weight:700; }
.lib-head p { color:#a8b5c2; margin:0; font-size:14px; max-width:620px; }
.lib-tools { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.lib-tools input[type=search] { width:220px; border-radius:8px; }
.lib-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:18px; margin-bottom:36px; }
.lib-card { position:relative; display:flex; flex-direction:column; border-radius:10px; background:#1b2938; border:1px solid #ffffff0b; transition:transform .18s,box-shadow .18s; }
.lib-card:hover { transform:translateY(-3px); box-shadow:0 12px 28px #0005; background:#203247; }
.lib-card .store-art { aspect-ratio:16/9; width:100%; border-radius:10px 10px 0 0; }
.lib-card:has(.lib-more[open]) { z-index:5; transform:none; }
.lib-card.running { border-color:#59bf4080; box-shadow:0 0 0 1px #59bf4040; }
.lib-running { position:absolute; z-index:3; top:9px; right:9px; font-size:11px; font-weight:600; color:#d6f5c8; background:#2d5a1fd9; border-radius:10px; padding:2px 9px; display:inline-flex; align-items:center; gap:6px; }
.lib-running::before { content:""; width:7px; height:7px; border-radius:50%; background:#8ef05f; box-shadow:0 0 6px #8ef05f; }
.lib-body { display:flex; gap:11px; align-items:center; padding:13px 14px 4px; min-width:0; }
.lib-body .store-icon { width:40px; height:40px; border-radius:10px; font-size:19px; }
.lib-body strong { display:block; color:var(--bright); font-size:15px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lib-body small { display:block; color:var(--muted); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lib-body > div { min-width:0; }
.lib-warn { margin:6px 14px 0; font-size:11px; color:#e8c27a; }
.lib-actions { display:flex; gap:8px; align-items:center; padding:12px 14px 14px; margin-top:auto; }
.lib-actions .play, .lib-actions .stop { flex:1; height:36px; font-size:14px; font-weight:600; border-radius:6px; }
.lib-more { position:relative; }
.lib-more > summary { list-style:none; display:flex; align-items:center; justify-content:center; width:38px; height:36px; border-radius:6px; background:var(--btn); color:var(--text); cursor:pointer; font-size:18px; line-height:1; }
.lib-more > summary::-webkit-details-marker { display:none; }
.lib-more > summary:hover, .lib-more[open] > summary { background:var(--btn-hi); }
.lib-menu { position:absolute; right:0; bottom:calc(100% + 6px); z-index:20; min-width:190px; padding:6px; border-radius:9px; background:#23364a; border:1px solid #ffffff1a; box-shadow:0 14px 34px #000a; display:flex; flex-direction:column; }
.lib-menu button { display:block; width:100%; text-align:left; height:auto; padding:8px 10px; border-radius:6px; background:transparent; font-size:13px; font-weight:400; color:var(--text); border:0; }
.lib-menu button small { display:block; color:var(--muted); font-size:11px; margin-top:2px; }
.lib-menu button:hover { background:#ffffff12; }
.lib-menu button.danger { color:#ff9c8a; }
.lib-empty { grid-column:1/-1; text-align:center; padding:48px 20px; border:1px dashed #ffffff1c; border-radius:12px; color:#a8b5c2; }
.lib-empty h2 { color:var(--bright); text-transform:none; letter-spacing:-.3px; font-size:20px; margin:0 0 8px; }
.lib-section { font-size:13px; text-transform:uppercase; letter-spacing:1.6px; color:var(--muted); margin:0 0 14px; font-weight:700; }
.lib-manage { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr)); gap:18px; align-items:start; }
.store-empty { display:flex; flex-direction:column; align-items:center; text-align:center; padding:58px 20px 70px; }
.store-empty svg { width:110px; height:90px; color:var(--link); margin-bottom:14px; }
.store-empty h2 { font-size:23px; color:var(--bright); margin:8px 0; text-transform:none; letter-spacing:0; }
@@ -626,6 +715,8 @@
.source-repo-form input { margin-top:7px; display:block; border-radius:6px; }
.source-repo-form details { font-size:11px; color:var(--muted); }
.source-repo-form button { justify-self:start; border-radius:6px; }
.source-token { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin:8px 0 4px; }
.source-token input { flex:1; min-width:150px; border-radius:6px; font-size:12px; }
#appStore :focus-visible, .store-dialog :focus-visible, .store-tabs :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
.store-search input:focus-visible { outline:0!important; }
@media(min-width:1400px) { .store-grid { grid-template-columns:repeat(5,minmax(0,1fr)); } }
@@ -639,7 +730,14 @@
.store-hero { height:285px; border-radius:9px; margin-bottom:26px; }
.hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; }
.hero-copy > span:not(.store-eyebrow):not(.hero-cta) { font-size:13px; max-width:260px; }
.store-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; }
.store-row-head { display:flex; align-items:center; } .store-row-head h2 { font-size:18px; } .store-row-head p { display:none; }
.store-heading .store-eyebrow, .store-heading p { display:none; } .store-heading h1 { font-size:26px; margin:0 0 12px; }
.store-shelf { grid-auto-columns:minmax(200px,72%); gap:12px; }
.store-switch > button { height:38px; padding:0 14px; font-size:14px; }
.lib-head { margin-bottom:16px; } .lib-head p { font-size:13px; } .lib-tools, .lib-tools input[type=search] { width:100%; }
.lib-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.lib-body { padding:10px 10px 2px; } .lib-body .store-icon { width:30px; height:30px; font-size:14px; } .lib-body strong { font-size:13px; }
.lib-actions { padding:10px; gap:6px; } .lib-actions .play, .lib-actions .stop { height:34px; font-size:13px; }
.store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; }
.store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; }
.store-card-foot { font-size:10px; margin-top:12px; } .store-summary { font-size:11px; }
@@ -932,9 +1030,10 @@
<button data-pad-key="12" title="Enter">Enter</button>
</span>
</div>
<div class="hint">Types and points in whichever panel has focus in the headset, through Valve's own input path: nothing to
install and no internet needed. Accents and emoji go through KDE Connect, which comes with Frame Control and starts
the first time you type one (it copies Valve's build for the Frame, 3.6 MB, into your home folder there). <a href="#" data-about>Licences</a></div>
<div class="hint">Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
else to install. <a href="#" data-about>Licences</a></div>
</section>
<section id="shots">
@@ -987,24 +1086,33 @@
<div class="page" data-page="android">
<section id="android">
<div class="store-tabs" aria-label="Android sections"><button class="on" data-store-tab="browse" aria-pressed="true">Discover</button><button data-store-tab="library" aria-pressed="false">On your Frame <span id="andCount"></span></button><span class="spacer"></span><button id="sourcesOpen" class="small">Sources <span aria-hidden="true">⚙</span></button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
<section id="appStore" aria-label="Discover apps">
<div class="store-toolbar"><div class="store-heading"><div><div class="store-eyebrow">A little more possibility</div><h1>Find your next favorite.</h1><p>Great games and useful apps. All in one place, all free to explore.</p></div></div>
<div class="store-tabs" aria-label="Android sections"><div class="store-switch"><button class="on" data-store-tab="browse" aria-pressed="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 7h16l-1.5 12.5a1 1 0 0 1-1 .9H6.5a1 1 0 0 1-1-.9z"/><path d="M8.5 9V6a3.5 3.5 0 0 1 7 0v3"/></svg>Store</button><button data-store-tab="library" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="3.5" y="4" width="7" height="7" rx="1.5"/><rect x="13.5" y="4" width="7" height="7" rx="1.5"/><rect x="3.5" y="14" width="7" height="7" rx="1.5"/><rect x="13.5" y="14" width="7" height="7" rx="1.5"/></svg>Library <span id="andCount"></span></button></div><span class="spacer"></span><button id="sourcesOpen" class="small">Sources <span aria-hidden="true">⚙</span></button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
<section id="appStore" aria-label="Android store">
<div class="store-layout">
<div class="store-side" role="navigation" aria-label="Store categories">
<div id="storeCats" class="store-cats"></div>
<div class="store-side-opts">
<label class="store-check"><input type="checkbox" id="storeFits"> Only apps that fit the Frame</label>
<label class="store-check" title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="storePhone"> Show phone-only apps</label>
<label class="store-source-select"><span>Source</span><select id="sourceFilter"><option value="">All sources</option></select></label>
</div>
</div>
<div class="store-main">
<div class="store-toolbar"><div class="store-heading" id="storeHeading"><div class="store-eyebrow">Android apps for your Frame</div><h1>Store</h1><p>VR games, emulators, video and reading apps that work in a panel. Free, from open sources.</p></div>
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form></div>
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">For you</button><button data-filter="vr" aria-pressed="false">VR experiences</button><button data-filter="flat" aria-pressed="false">Flat-screen apps</button><button data-filter="installable" aria-pressed="false">Fits your Frame</button></div><label class="store-source-select"><span class="sr-only">Filter by source</span><select id="sourceFilter"><option value="">All sources</option></select></label></div>
<div id="sourceStatus" class="store-notice" role="status" aria-live="polite" hidden></div>
<div id="searchCount" class="store-result-count" aria-live="polite"></div>
<div id="searchGrid" aria-busy="false"></div>
</section>
<div class="and-grid" id="androidLibrary" hidden>
<div class="and-col">
<div class="panel">
<div class="shelf-head"><h2>On the Frame</h2></div>
<div class="list" id="andApps"><div class="sub">Loading…</div></div>
<div class="hint">Each app runs in its own Lepton instance and keeps its data. It's in the Steam
library under its own name and opens in its own panel. Test launches it and records
whether it stays up; Report records how it worked for you.</div>
</div>
</div>
</section>
<div class="lib" id="androidLibrary" hidden>
<div class="lib-head"><div><div class="store-eyebrow">On your Frame</div><h1>Library</h1><p id="libSummary">Your Android apps. Each runs in its own Lepton instance, keeps its data, and is in the Steam library under its own name.</p></div>
<div class="lib-tools"><div class="store-chips" id="libFilters"><button data-lib="all" aria-pressed="true">All</button><button data-lib="vr" aria-pressed="false">VR</button><button data-lib="flat" aria-pressed="false">Flat</button><button data-lib="running" aria-pressed="false">Running</button></div>
<input id="libQ" type="search" aria-label="Filter your apps" placeholder="Filter your apps"></div></div>
<div class="lib-grid" id="andApps"><div class="sub">Loading…</div></div>
<h2 class="lib-section">Display and reports</h2>
<div class="lib-manage">
<section class="panel" id="installQueue" hidden>
<div class="shelf-head"><h2>Install queue</h2><span class="count" id="queueCount"></span></div>
<div class="list" id="queueList"></div>
@@ -1317,7 +1425,7 @@
<button class="store-close" data-close="sourcesDialog" aria-label="Close source settings">×</button>
<div class="sources-intro"><div class="store-eyebrow">Your app shelves</div><h1 id="sourcesTitle">A world of apps.</h1><p>Choose where you discover games and apps. We bring them together for you.</p></div>
<div id="sourcesList"></div>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><button class="action">Add source</button></form>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><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>
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
</dialog>
@@ -3289,7 +3397,22 @@ document.body.addEventListener("click", async e => {
loadOwned();
// ---- Discover apps ----
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
const sourceState = { vr: null, installable: false, source: "", category: "", phone: false, apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
// The store's categories (ui/apk_sources/_categories.py): what a Frame is good for, not F-Droid's phone-centred list.
const STORE_CATS = [
["", "Home", "Android apps for your Frame", "VR games, emulators, video and reading apps that work in a panel. Free, from open sources.", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
["vr", "VR", "Made for headsets", "OpenXR games and experiences that fill the room, not a panel.", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
["games", "Games", "Play in a panel", "Puzzle, strategy, card and action games that play well on a big floating screen.", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/><circle cx="15.5" cy="12" r=".6" fill="currentColor"/><circle cx="17" cy="13.6" r=".6" fill="currentColor"/>'],
["emulators", "Emulators", "Classic games, big screen", "RetroArch, Dolphin, PPSSPP and friends. Bring your own games.", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4"/><path d="M8 10.5h3M9.5 9v3"/><circle cx="15" cy="10.5" r=".8" fill="currentColor"/>'],
["watch", "Watch & listen", "Your own cinema", "Video players, streaming front-ends, music and podcasts.", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z" fill="currentColor"/><path d="M8 20h8"/>'],
["read", "Read & learn", "Books, news and more", "E-book and comic readers, news, dictionaries and learning apps.", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
["create", "Create", "Make something", "Drawing, photo editing, music and recording.", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
["work", "Work", "Get things done", "Notes, tasks, documents, calendars and calculators.", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
["web", "Web & chat", "Stay connected", "Browsers, email, messaging and social apps.", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
["tools", "Tools", "Handy utilities", "File managers, passwords, remote access and developer tools.", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
];
const storeCat = id => STORE_CATS.find(c => c[0] === (id || "")) || STORE_CATS[0];
const catIcon = id => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${storeCat(id)[4]}</svg>`;
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
const storeInitial = name => [...String(name || "?")][0].toUpperCase();
@@ -3297,48 +3420,90 @@ const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/&
const storeOpenSource = o => o.open_source === true || /^(MIT|Apache|[AL]?GPL|BSD|MPL|ISC|CC0|Unlicense)/i.test(o.license || "");
function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } }
function storeImage(path, alt, eager=false) { return /^\/source-image\/[\w-]+$/.test(path || "") ? `<img src="${esc(path)}" alt="${esc(alt)}" loading="${eager ? "eager" : "lazy"}" decoding="async">` : ""; }
function storeArt(o, cls="", eager=false, path=null, alt=null) {
const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0];
return `<span class="store-art ${cls}" style="--hue:${storeHue(o.name)}"><span class="art-initial" aria-hidden="true">${esc(storeInitial(o.name))}</span>${storeImage(image, alt || `${o.name} artwork`, eager)}</span>`;
function storeArt(o, cls="", eager=false, path=null, alt=null, extra="") {
// Best artwork first: a banner, else phone-style screenshots side by side, else the icon over its own blur.
const art = o.artwork || {}, label = alt || `${o.name} artwork`, initial = `<span class="art-initial" aria-hidden="true">${esc(storeInitial(o.name))}</span>`;
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
if (!path && !valid(art.banner) && (art.screenshots || []).some(valid)) {
const shots = art.screenshots.filter(valid).slice(0, 3);
return `<span class="store-art shots ${cls}" style="--hue:${storeHue(o.name)}" role="img" aria-label="${esc(label)}">${initial}${shots.map(u => storeImage(u, "", eager)).join("")}${extra}</span>`;
}
if (!path && !valid(art.banner) && valid(art.icon)) {
return `<span class="store-art icon-art ${cls}" style="--hue:${storeHue(o.name)}" role="img" aria-label="${esc(label)}">${initial}<img class="art-backdrop" src="${esc(art.icon)}" alt="" loading="${eager ? "eager" : "lazy"}" decoding="async"><img class="art-mark" src="${esc(art.icon)}" alt="" loading="${eager ? "eager" : "lazy"}" decoding="async">${extra}</span>`;
}
const image = path || art.banner || art.screenshots?.[0];
return `<span class="store-art ${cls}" style="--hue:${storeHue(o.name)}">${initial}${storeImage(image, label, eager)}${extra}</span>`;
}
function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue(o.name)}" aria-hidden="true">${esc(storeInitial(o.name))}${storeImage(o.artwork?.icon, "")}</span>`; }
document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true);
document.addEventListener("error", e => {
if (!e.target.matches?.(".store-art img, .store-icon img")) return;
const img = e.target;
if (!img.dataset.retried) { // the proxy fetches from the source on first use, which can time out once
img.dataset.retried = "1"; setTimeout(() => { img.src = img.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return;
}
const art = img.closest(".icon-art, .shots"); img.remove();
if (art && !art.querySelector("img")) art.classList.remove("icon-art", "shots"); // show the initial again
}, true);
function sourceMark(s) { const name=storeName(s); return `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); }
// GitHub's optional access token: only whether one is set is ever shown, never its value.
function sourceToken(s) {
const t=s.token;if(!t)return "";
const note=t.rejected ? "GitHub rejected this token, so searches continue without it. Replace or clear it." : t.from==="env" ? "Using the token from FRAME_GITHUB_TOKEN." : t.set ? "A token is saved." : "Optional: a token raises GitHub's limit of 60 requests an hour.";
return `<form class="source-token" data-token="${esc(s.id)}"><input name="token" type="password" autocomplete="off" spellcheck="false" aria-label="${esc(storeName(s.name))} access token" placeholder="${t.set ? "Replace access token" : "Access token"}"><button class="small">Save</button>${t.from==="saved" ? `<button type="button" class="small" data-clear-token="${esc(s.id)}">Clear</button>` : ""}</form><small>${esc(note)}</small>`;
}
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).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
$("sourceFilter").value = sourceState.source;
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</small>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
$("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><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
} catch (e) { $("sourceError").textContent = e.message; }
}
const STORE_FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Won't run" };
function storeFit(o) { const t = o.verdict?.tone; return STORE_FIT[t] ? `<span class="store-fit ${t}" title="${esc(o.verdict.label)}">${STORE_FIT[t]}</span>` : ""; }
function storeCard(app) {
const i=sourceState.apps.indexOf(app), o=app.offers[0];
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span>${o.vr ? "VR experience" : o.vr === false ? "Flat-screen app" : "Android app"}</span><b>Free</b></span></span></button>`;
const i=sourceState.apps.indexOf(app), o=app.offers[0], c=app.category || o.category;
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o,"",false,null,null,o.vr ? '<span class="store-vr">VR</span>' : "")}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span class="store-cat">${esc(o.phone_only ? "Phone app" : c ? storeCat(c)[1] : o.vr === false ? "Flat-screen app" : "Android app")}</span>${storeFit(o) || "<b>Free</b>"}</span></span></button>`;
}
function storeRow(title, subtitle, apps, category) {
return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p>${category !== undefined ? `<button class="see-all" data-cat="${esc(category)}">See all ›</button>` : ""}</div><div class="store-shelf">${apps.slice(0,12).map(storeCard).join("")}</div></section>` : "";
}
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p></div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
function storeEmpty() {
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>A little further to explore.</h2><p>No apps found this time. Try a different name, clear your filters, or explore another source.</p><button class="action" id="storeReset">Explore all apps</button></div>`;
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>A little further to explore.</h2><p>No apps found this time. Try a different name, another category, or turn on phone-only apps.</p><button class="action" id="storeReset">Back to the store</button></div>`;
}
function renderCats() {
if (!$("storeCats").children.length) // built once, so a keyboard user keeps focus on the category they chose
$("storeCats").innerHTML = STORE_CATS.map(([id, label]) => `<button data-cat="${id}" aria-pressed="false">${catIcon(id)}<span>${esc(label)}</span></button>`).join("");
$("storeCats").querySelectorAll("[data-cat]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.cat === sourceState.category)));
const [, label, eyebrow, blurb] = storeCat(sourceState.category);
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Store")}</h1><p>${esc(blurb)}</p>`;
}
function renderStore() {
const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable && !sourceState.source;
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`;
const apps=sourceState.apps, q=$("sourceQ").value.trim(), browsing=!q && !sourceState.category && !sourceState.installable && !sourceState.source;
renderCats();
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${q ? ` for “${q}”` : ""}${sourceState.category ? ` in ${storeCat(sourceState.category)[1]}` : ""}`;
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0];
const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works'));
const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked'));
const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0));
const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||'')));
$("searchGrid").innerHTML=`<button class="store-hero" data-app="${apps.indexOf(featured)}" aria-label="Explore ${esc(featured.name)}">${storeArt(o,"",true)}<span class="hero-copy"><span class="store-eyebrow">Step into something new</span><strong>${esc(featured.name)}</strong><span>${esc(storeText(featured.summary))}</span><span class="hero-cta">Explore ${esc(featured.name)} <span aria-hidden="true">&nbsp; ↗</span></span></span></button>` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent);
// Home: one featured VR app, then a shelf per category; each app appears once, blocked ones not at all.
const ok=apps.filter(a=>a.offers[0].verdict?.tone !== 'blocked'), shown=new Set();
const day=Math.floor(Date.now()/864e5), heroes=ok.filter(a=>a.offers[0].vr && a.offers[0].artwork?.banner);
const featured=heroes[day % (heroes.length || 1)] || ok.find(a=>a.offers[0].artwork?.banner) || ok[0];
if (!featured) { $("searchGrid").innerHTML=storeEmpty(); return; } // nothing here runs on the Frame
const o=featured.offers[0];
shown.add(featured);
const take=(list)=>{ const out=list.filter(a=>!shown.has(a)).slice(0,12); out.forEach(a=>shown.add(a)); return out; };
const rows=STORE_CATS.slice(1).map(([id,label,eyebrow])=>storeRow(label,eyebrow,take(ok.filter(a=>a.category===id)),id)).join("");
const recent=take([...ok].filter(a=>a.offers[0].updated && a.offers[0].artwork?.icon).sort((a,b)=>String(b.offers[0].updated).localeCompare(String(a.offers[0].updated))));
$("searchGrid").innerHTML=`<button class="store-hero" data-app="${apps.indexOf(featured)}" aria-label="Explore ${esc(featured.name)}">${storeArt(o,"",true)}<span class="hero-copy"><span class="hero-meta">${o.vr ? '<span class="store-vr">VR</span>' : ""}<span class="store-eyebrow">Featured</span></span><strong>${esc(featured.name)}</strong><span>${esc(storeText(featured.summary))}</span><span class="hero-cta">See ${esc(featured.name)} <span aria-hidden="true">&nbsp;›</span></span></span></button>` + rows + storeRow("Recently updated","Fresh from their creators",recent);
}
function storeChips() { $("searchFilters").querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",b.dataset.filter==='all' ? sourceState.vr===null && !sourceState.installable : b.dataset.filter==='installable' ? sourceState.installable : sourceState.vr===(b.dataset.filter==='vr'))); }
function storeChips() { renderCats(); $("storeFits").checked = sourceState.installable; $("storePhone").checked = sourceState.phone; }
function storeCategory(id) { sourceState.category = id || ""; $("sourceQ").value = ""; storeChips(); searchSources(); document.getElementById("android")?.scrollIntoView({block:"start", behavior:"smooth"}); }
async function searchSources(quiet) {
const request=++sourceState.request;
$("searchGrid").setAttribute("aria-busy","true");
if(!quiet)$("searchGrid").innerHTML=`<div class="store-grid" role="status" aria-label="Finding apps">${Array.from({length:8},()=>'<div class="store-skeleton" aria-hidden="true"></div>').join("")}</div>`;
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable)});
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable),category:sourceState.category,phone:String(sourceState.phone)});
if(sourceState.vr!==null)params.set("vr",String(sourceState.vr));
try {
const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
@@ -3432,13 +3597,16 @@ let storeSearchTimer;
$("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);};
$("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer);searchSources();};
$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();};
$("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();};
$("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}};
$("storeCats").onclick=e=>{const b=e.target.closest('[data-cat]');if(b)storeCategory(b.dataset.cat);};
$("storeFits").onchange=e=>{sourceState.installable=e.target.checked;searchSources();};
$("storePhone").onchange=e=>{sourceState.phone=e.target.checked;searchSources();};
$("searchGrid").onclick=e=>{const c=e.target.closest('[data-cat]');if(c)return storeCategory(c.dataset.cat);const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.phone=false;sourceState.category='';sourceState.source='';$('sourceFilter').value='';storeChips();searchSources();}};
$("detailBody").onclick=e=>{if(e.target.closest('[data-close]'))return $("appDetail").close();if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}};
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});const c=e.target.closest('[data-clear-token]');if(c)changeSource({action:'clear-token',source:c.dataset.clearToken});};
$("sourcesList").onsubmit=e=>{const f=e.target.closest('[data-token]');if(!f)return;e.preventDefault();const token=f.elements.token.value.trim();f.reset();if(token)changeSource({action:'set-token',source:f.dataset.token,token});};
async function addSource(body) {
$("sourceError").textContent="Checking the repository. Large ones can take a minute…";
let error="";
@@ -3446,7 +3614,49 @@ async function addSource(body) {
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
}
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
// 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.
// Plain text, not the URL parser: an address it rejects (or a login it can't decode) must
// still lose its login first. Decoding falls back to the text as typed.
// Percent-decode what decodes; keep any byte sequence that isn't UTF-8 as typed.
// One forward pass: each lead byte gives its sequence length (at most 4 bytes).
function decodeLenient(s) {
return s.replace(/(?:%[0-9a-f]{2})+/gi, run => {
const b=run.match(/%[0-9a-f]{2}/gi); let out='', i=0;
while(i<b.length){
const v=parseInt(b[i].slice(1),16);
const n=v<0x80 ? 1 : v>=0xC2&&v<0xE0 ? 2 : v>=0xE0&&v<0xF0 ? 3 : v>=0xF0&&v<0xF5 ? 4 : 0;
if(n){ try { out+=decodeURIComponent(b.slice(i,i+n).join('')); i+=n; continue; } catch {} }
out+=b[i]; i++;
}
return out;
});
}
// Moves a login typed into the address into the login fields. Returns false for an
// address the URL parser rejects that may hold a login: it is refused, not sent.
function moveLogin(form) {
// Normalise the way URL parsers do: trim C0 controls and spaces, drop tabs and newlines.
const el=form.elements;
el.url.value=el.url.value.replace(/^[\u0000- ]+|[\u0000- ]+$/g,'').replace(/[\t\n\r]/g,'');
let u;
// A backslash and an '@' together are read differently by browsers and by the
// server, so the address can't be split safely: refuse it rather than guess.
if(el.url.value.includes('@') && el.url.value.includes('\\'))return false;
try { u=new URL(el.url.value); } catch { return !el.url.value.includes('@'); }
if(!u.username && !u.password)return true;
const user=decodeLenient(u.username), pass=decodeLenient(u.password);
u.username=''; u.password=''; el.url.value=u.href;
if(!el.username.value)el.username.value=user;
if(!el.password.value)el.password.value=pass;
el.username.closest('details').open=true;
return true;
}
$("addSource").onsubmit=e=>{
e.preventDefault();
if(!moveLogin(e.target)){ $("sourceError").textContent="That address isn't valid. Put the username and password in the login fields below, not in the address."; return; }
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} : {})});
};
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
function showAndroidTab(tab) {
@@ -3462,6 +3672,7 @@ document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick =
showAndroidTab(button.dataset.storeTab);
if (button.dataset.storeTab === 'library') loadAndroid();
});
storeChips();
loadSources().then(()=>searchSources());
// ---- Android apps (own Lepton instance each) + catalogue ----
@@ -3469,28 +3680,56 @@ 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 ? `${androidApps.length} installed` : "";
$("andApps").innerHTML = androidApps.length ? androidApps.map(a => {
const c = cat.apps && cat.apps.find(x => x.p === a.package);
const icon = c && c.i ? `<img class="and-icon" src="${esc(c.i)}" alt="">` : `<div class="and-icon"></div>`;
return `<div class="item and-item">${icon}
<div class="grow"><div class="t">${esc(a.label)}</div>
<div class="s">${esc(a.package)} · v${esc(String(a.version || "").replace(/^v/i, ""))}</div></div>
${a.running ? `<span class="tag">Running</span>` : ""}
<div class="row and-btns">
${a.running ? `<button class="small" data-and="stop" data-pkg="${esc(a.package)}">Stop</button>`
: `<button class="play small" data-and="launch" data-pkg="${esc(a.package)}">▶</button>`}
${a.art_missing ? `<button class="small" data-and="refresh-art" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
<button class="small" data-and="probe" data-pkg="${esc(a.package)}" title="Launch it and record whether it stays up">Test</button>
<button class="small" data-and="rate" data-pkg="${esc(a.package)}" data-ver="${esc(a.version)}" data-name="${esc(a.label)}" data-src="${esc(a.source || "")}" title="Report how it worked">Report</button>
<button class="small danger" data-and="remove" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}">Remove</button>
</div></div>`;
}).join("") : `<div class="sub">No Android apps yet. Install one from the catalogue, or drop an .apk on Send to Frame.</div>`;
$("andCount").textContent = androidApps.length ? String(androidApps.length) : "";
renderLibrary();
const installed = new Set(androidApps.map(a => a.package));
document.querySelectorAll('#catGrid [data-and="install"]').forEach(b => {
if (installed.has(b.dataset.pkg)) b.outerHTML = `<span class="tag">Installed</span>`;
@@ -4322,10 +4561,10 @@ const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=c
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
// ---- keyboard and trackpad: events go to the Frame through gamescope (ui/frame_touch.py); accents and emoji through KDE Connect ----
// ---- keyboard and trackpad: events go to KDE Connect on the Frame (see ui/frame_input_agent.py) ----
const pad = { state: "off", queue: [], sending: false, poll: null, captured: false };
const PAD_STATES = {
off: "", starting: "Connecting…", installing: "Setting up (first time only)…",
off: "", starting: "Connecting…", installing: "Setting up KDE Connect on the Frame (first time only)…",
pairing: "Pairing with KDE Connect…", ready: "On",
};
// KDE Connect's specialKey numbers (plugins/mousepad in its source).
@@ -4343,7 +4582,7 @@ function padShow(status) {
$("padOn").hidden = ready || busy;
$("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on";
$("padArea").classList.toggle("off", !ready);
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach the Frame."
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame."
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
: pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop."
+46 -194
View File
@@ -63,7 +63,6 @@ import frame_report # noqa: E402
import frame_store # noqa: E402
import frame_telemetry # noqa: E402
import frame_titles # noqa: E402
import frame_touch # noqa: E402
import frame_webinstall # noqa: E402
import frame_vr # noqa: E402
import frame_utilities # noqa: E402
@@ -116,9 +115,6 @@ def route(alias, host_opts):
with _route_lock:
moved = alias != FRAME
if moved:
reset_pad = globals().get("reset_pad_route")
if reset_pad:
reset_pad() # cancel old input before any agent can start on the new route
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
FRAME = frame_android.FRAME = alias
HOST_OPTS = list(host_opts)
@@ -129,6 +125,9 @@ def route(alias, host_opts):
mv = globals().get("macview")
if mv:
mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too
agent = globals().get("_input")
if moved and agent:
agent.stop() # typing and pointing mustn't go on reaching the headset switched away from
LINK = None # the connector (frame_link.Link); None on the Frame itself
@@ -1154,192 +1153,12 @@ def licenses():
return notices
# KDE Connect's specialKey numbers -> Linux key codes (KEY_BACKSPACE, KEY_TAB, ...).
PAD_KEYS = {1: 14, 2: 15, 4: 105, 5: 103, 6: 106, 7: 108, 8: 104, 9: 109, 10: 102, 11: 107, 12: 28, 13: 111, 14: 1,
**{21 + i: code for i, code in enumerate([59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88])}}
PAD_MODS = (("ctrl", 29), ("alt", 56), ("shift", 42), ("super", 125))
PAD_CLICKS = {"singleclick": "left", "rightclick": "right", "middleclick": "middle"}
PAD_NOTCH = 15 # scroll units for one wheel notch
def typed_in_kde(events):
"""Whether these events must go the KDE Connect way: they hold an accent or emoji, or
earlier ones are queued, being sent or still being typed there (typing must stay in
order, so it can't be split between the two ways)."""
with _kde_lock:
waiting = bool(_kde_queue) or time.time() < _kde_until
return waiting or any(ch not in frame_touch.ASCII for e in events for ch in e.get("key", ""))
def pad_events(events, kde=False):
"""The trackpad's and key row's events as (gamescope events, KDE Connect events).
Gamescope's own input reaches every panel and needs nothing installed, but types only
US-keyboard characters. With kde, the keyboard goes to KDE Connect as it was sent, so
text stays in order; the pointer always goes through gamescope.
"""
ascii_keys = frame_touch.ASCII
out, slow = [], []
def tap(code, mods):
down = [{"key": c, "down": True} for c in mods]
out.extend(down + [{"key": code, "down": True}, {"key": code, "down": False}]
+ [{"key": c, "down": False} for c in reversed(mods)])
for e in events:
mods = [code for name, code in PAD_MODS if e.get(name)]
if "dx" in e or "dy" in e:
if not e.get("scroll"):
out.append({k: e[k] for k in ("dx", "dy") if k in e})
if e.get("scroll"):
out.append({"scroll": [0, -(e.get("dy") or 0) * PAD_NOTCH]})
for flag, button in PAD_CLICKS.items():
if e.get(flag):
out += [{"button": button, "down": True}, {"button": button, "down": False}]
if e.get("doubleclick"):
out += [{"button": "left", "down": d} for d in (True, False, True, False)]
if e.get("singlehold"):
out.append({"button": "left", "down": True})
if e.get("singlerelease"):
out.append({"button": "left", "down": False})
if kde and ("key" in e or "specialKey" in e):
slow.append({k: e[k] for k in ("key", "specialKey", "ctrl", "alt", "shift", "super") if k in e})
continue
if "specialKey" in e and e["specialKey"] in PAD_KEYS:
tap(PAD_KEYS[e["specialKey"]], mods)
if "key" in e:
for ch in e["key"]:
if ch not in ascii_keys:
continue # only reached when kde is wrong; the caller checks typed_in_kde first
elif mods:
code, shifted = ascii_keys[ch]
tap(code, mods + ([42] if shifted and 42 not in mods else []))
else:
if out and "text" in out[-1] and len(out[-1]["text"]) < 100:
out[-1]["text"] += ch
else:
out.append({"text": ch})
return out, slow
def remote_input(body):
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent.
"sent" in the answer means the whole batch is taken (the page keeps it and tries again
if not), so the keyboard's share is queued only once that's so.
"""
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent."""
events = body.get("events", [])
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
events = [input_event(e) for e in events]
touch, slow = pad_events(events, typed_in_kde(events))
status = _touch.send(touch)
if touch and not status.get("sent"):
return status
if touch:
_note_typed(touch)
if slow:
_queue_for_kde(slow)
return {**status, "sent": True}
# Typing KDE Connect is to do: it starts the first time it's needed, so this waits for it.
# Nothing on the Frame says when typed text has landed, so each way holds the other back
# for as long as what it was given should take (a short margin on a time estimate).
_kde_queue = []
_kde_lock = threading.Lock()
_pad_send_lock = threading.Lock()
_kde_worker = [None]
_kde_until = 0.0 # keys typed through KDE Connect should have landed by then
_typed_until = 0.0 # when gamescope's agent will have typed everything it's been given
_kde_route = object()
_kde_cancel = threading.Event()
KDE_QUEUE_LIMIT = 200
KDE_WAIT = 90 # seconds to wait for KDE Connect before giving up on what's queued
TYPING_SETTLE = 0.6 # seconds past the estimate, once
def reset_pad_route():
"""Discard input for the headset being left, including batches waiting for an agent."""
global _kde_route, _kde_cancel, _kde_until, _typed_until
with _pad_send_lock, _kde_lock:
_kde_cancel.set()
_kde_cancel = threading.Event()
_kde_route = object()
_kde_queue.clear()
_kde_worker[0] = None
_kde_until = _typed_until = 0.0
_input.stop()
_touch.stop()
def typing_seconds(touch):
"""How long gamescope's agent takes over these: it sleeps 8 ms after every key transition."""
transitions = 0
for e in touch:
if "text" in e:
transitions += sum(2 + 2 * frame_touch.ASCII[ch][1] for ch in e["text"] if ch in frame_touch.ASCII)
elif "key" in e:
transitions += 1
return transitions * 0.008
def _note_typed(touch):
global _typed_until
seconds = typing_seconds(touch)
if seconds:
with _kde_lock: # the end of the work, which queues up; the margin is added once, where it's checked
_typed_until = max(_typed_until, time.time()) + seconds
def _queue_for_kde(events):
with _kde_lock:
_kde_queue.extend(events)
del _kde_queue[:-KDE_QUEUE_LIMIT]
if _kde_worker[0] is None:
_kde_worker[0] = threading.Thread(target=_drain_kde, args=(_kde_route, _kde_cancel), daemon=True)
_kde_worker[0].start()
def _drain_kde(route, cancel):
"""The only sender, in order. A batch leaves the queue while it's being sent (the queue
can be trimmed meanwhile) and goes back to the front if it didn't go."""
global _kde_until
deadline = time.time() + KDE_WAIT
while True:
with _kde_lock:
if route is not _kde_route:
return
batch = _kde_queue[:INPUT_BATCH_LIMIT]
del _kde_queue[:len(batch)]
if not batch:
_kde_worker[0] = None # retired under the lock, so a new request starts another
return
wait = _typed_until + TYPING_SETTLE - time.time()
_kde_until = time.time() + 3600 # held while in flight
if wait > 0 and cancel.wait(wait):
return # a switch cancels the delay and its old-headset batch
# Serialize sending with route resets without blocking callers from queuing input.
with _pad_send_lock:
with _kde_lock:
if route is not _kde_route:
return
status = _input.send(batch)
with _kde_lock:
if route is not _kde_route:
return
if status.get("sent"):
_kde_until = time.time() + len(batch) * 0.03 + TYPING_SETTLE
deadline = time.time() + KDE_WAIT
continue
_kde_until = 0.0
if status.get("state") == "error" or time.time() > deadline:
_kde_queue.clear() # not coming; don't hold it forever
_kde_worker[0] = None
return
_kde_queue[:0] = batch
del _kde_queue[:-KDE_QUEUE_LIMIT] # as when queuing: the oldest go first
if cancel.wait(0.4):
return
return _input.send([input_event(e) for e in events])
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
@@ -2580,16 +2399,29 @@ def source_text(body, key, optional=False):
return value.strip()
def login_text(body, key):
"""A username, password or token: kept exactly (no stripping) and never echoed back."""
value = body.get(key)
if value in (None, ''):
return None
if not isinstance(value, str) or len(value) > 4096:
raise Failure('Provide a valid ' + key, 400)
return value
def source_search(query):
args = parse_qs(query)
q = args.get('q', [''])[0]
vr = args.get('vr', [''])[0]
installable = args.get('installable', [''])[0]
if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'):
category, phone = args.get('category', [''])[0], args.get('phone', [''])[0]
if (len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0')
or phone not in ('', 'true', 'false', '1', '0') or len(category) > 40):
raise Failure('Invalid search filters', 400)
try:
return apk_search.search(q, vr=None if not vr else vr in ('true', '1'),
source=args.get('source', [None])[0], installable=installable in ('true', '1'))
source=args.get('source', [None])[0], installable=installable in ('true', '1'),
category=category or None, phone=phone in ('true', '1'))
except SourceError as e:
raise Failure(str(e), 400)
@@ -2618,19 +2450,31 @@ def source_manage(body):
if action == 'add':
url = source_text(body, 'url')
fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True)
parts = urlparse(url)
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400)
username, password = login_text(body, 'username'), login_text(body, 'password')
try:
parts = urlparse(url)
parts.port
except ValueError:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL', 400) from None
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL', 400)
apk_search.repo_module() # fail now if this build can't manage repositories
def add(): # downloads and verifies the whole index: a job, not a request
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name)
# A login (here or in the URL) is stored apart and never comes back in a response.
login = {'username': username, 'password': password} if username or password else {}
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name, **login)
message = 'Added ' + source['name']
if source.get('trust_on_first_use'):
message += '. Trusted on first use: ' + source['fingerprint'].upper()
return {'message': message, 'source': {k: source.get(k) for k in
('id', 'name', 'fingerprint', 'trust_on_first_use')}}
return start_job('Add repository', add)
if action in ('set-token', 'clear-token'):
token = login_text(body, 'token') if action == 'set-token' else None
if action == 'set-token' and not token:
raise Failure('Paste a token', 400)
return apk_search.set_token(source_text(body, 'source'), token)
if action == 'game-data':
package = source_text(body, 'package')
return start_job('Add game data', lambda: apk_search.add_game_data(package))
@@ -2821,6 +2665,9 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/store":
# The Android store as a headset panel (scripts/store-on-frame.sh).
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/settings/artwork":
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
@@ -2870,7 +2717,12 @@ class Handler(BaseHTTPRequestHandler):
ensure_master()
apps = frame_android.list_apps()
backfill_art(apps=apps)
self.send_json({"apps": apps})
try: # store artwork for the library's cards, from saved indexes only
art = apk_search.artwork_for(apps)
except Exception:
art = {}
self.send_json({"apps": [dict(a, store_art=art[a['package']]) if a.get('package') in art else a
for a in apps]})
elif path == "/api/quest":
self.send_json(quest_status(url.query))
elif path == "/api/meta":
@@ -2889,7 +2741,7 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/touch":
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
elif path == "/api/input":
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
elif path == "/api/job":
self.send_json(job_status(url.query))
elif path == "/api/queue":
+489
View File
@@ -0,0 +1,489 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APK Store</title>
<!-- Frame Control's Android store as a panel in the headset (docs/apks.md →
"The store in the headset"). Served by ui/server.py running on the Frame
(FRAME_LOCAL=1), opened by scripts/store-on-frame.sh at 1280×800. The same
sources, categories and artwork as the Store in Frame Control (/api/search,
ui/apk_sources/_categories.py), drawn for the laser: big targets, pages
instead of drag-scrolling (the panel gets mouse clicks, so a drag would
select text), the thumbstick (a mouse wheel) turns pages, and an on-screen
keyboard for search. -->
<style>
:root {
--bg: #0f1722; --rail: #131d2a; --card: #1b2938; --card-hi: #23364a; --line: #ffffff14;
--text: #e3e8ee; --bright: #fff; --dim: #93a4ba; --blue: #1a9fff; --link: #66c0f4;
--good: #8ed36b; --bad: #ef7a64;
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
--play: linear-gradient(to right, #75b022 5%, #588a1b 95%);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; height: 100%; background: radial-gradient(ellipse at 70% -20%, #1d3a5a 0%, transparent 60%), var(--bg);
color: var(--text); font: 19px/1.35 "Motiva Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
overflow: hidden; user-select: none; -webkit-user-select: none; }
button { font: inherit; color: inherit; background: #ffffff0d; border: 1px solid var(--line); border-radius: 12px;
min-height: 52px; padding: 0 20px; cursor: pointer; }
button:hover { background: #ffffff1c; }
button:disabled { opacity: .45; cursor: default; }
button.primary { background: var(--action); border: 0; font-weight: 600; color: #fff; }
button.play { background: var(--play); border: 0; font-weight: 600; color: #fff; }
button.danger { color: var(--bad); }
svg { width: 22px; height: 22px; flex: none; }
.app { display: grid; grid-template-columns: 236px 1fr; height: 100%; }
/* Rail */
.rail { background: #0c131ccc; border-right: 1px solid var(--line); padding: 18px 12px; display: flex; flex-direction: column; gap: 3px; }
.rail h1 { font-size: 22px; margin: 2px 10px 12px; display: flex; align-items: center; gap: 10px; color: var(--bright); }
.rail h1 svg { width: 28px; height: 28px; color: var(--blue); }
.rail button { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 47px; text-align: left; border: 0;
background: transparent; color: #b5c0cb; font-size: 17px; font-weight: 500; padding: 0 12px; border-radius: 10px; }
.rail button:hover { background: #ffffff10; color: var(--bright); }
.rail button[aria-pressed="true"] { background: #1a9fff2e; color: var(--bright); box-shadow: inset 4px 0 0 var(--blue); }
.rail .sep { height: 1px; background: var(--line); margin: 6px 8px; }
.rail .n { margin-left: auto; font-size: 13px; color: #c6e2f5; background: #1a9fff2e; border-radius: 10px; padding: 1px 8px; }
.rail .n:empty { display: none; }
.rail label { margin-top: auto; display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 15px; color: var(--dim); cursor: pointer; }
.rail input { width: 22px; height: 22px; accent-color: var(--blue); margin: 0; }
/* Main */
main { display: flex; flex-direction: column; min-width: 0; padding: 18px 22px; gap: 12px; }
.bar { display: flex; gap: 12px; align-items: center; }
.search { flex: 1; display: flex; align-items: center; gap: 12px; background: #0c1520; border: 1px solid var(--line);
border-radius: 12px; min-height: 54px; padding: 0 18px; color: var(--dim); }
.search input { flex: 1; background: none; border: 0; color: var(--text); font: inherit; outline: none; min-width: 0; }
.search input::placeholder { color: var(--dim); }
.heading { display: flex; align-items: baseline; gap: 14px; min-height: 30px; }
.heading b { color: var(--bright); font-size: 25px; letter-spacing: -.4px; }
.heading span { color: var(--dim); font-size: 16px; }
.heading .note { margin-left: auto; font-size: 14px; color: var(--dim); max-width: 520px; text-align: right; }
.content { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
.grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 14px; }
.grid.one-row { grid-template-rows: minmax(0, 1fr); }
.card { display: flex; flex-direction: column; padding: 0; overflow: hidden; text-align: left; min-height: 0; border-radius: 14px;
background: var(--card); border: 1px solid var(--line); }
.card:hover { background: var(--card-hi); border-color: #66c0f480; }
.card .art { flex: 0 0 52%; }
.card .body { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px 12px; min-height: 0; flex: 1; width: 100%; }
.card .title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.card .title strong { font-size: 17px; color: var(--bright); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.card .sum { color: var(--dim); font-size: 14px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 13px; color: var(--dim); }
/* Artwork: a banner, else screenshots side by side, else the icon over its own blur, else an initial. */
.art { position: relative; display: block; overflow: hidden; width: 100%;
background: radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%), transparent 70%), linear-gradient(140deg, hsl(var(--hue) 35% 18%), #101827); }
.art > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.art .initial { position: absolute; inset: 0; display: grid; place-items: center; font-size: 64px; font-weight: 700; color: #ffffff45; }
.art.shots { display: flex; gap: 6px; justify-content: center; padding: 8px 8px 0; }
.art.shots > img { position: relative; inset: auto; width: auto; max-width: 31%; height: 100%; object-position: top; border-radius: 6px 6px 0 0; box-shadow: 0 4px 14px #0008; }
.art.iconart > .backdrop { position: absolute; inset: -30%; width: 160%; height: 160%; object-fit: cover; filter: blur(28px) saturate(1.4) brightness(.55); }
.art.iconart > .mark { position: absolute; left: 50%; top: 50%; width: 36%; height: auto; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 22%; box-shadow: 0 8px 24px #0009; object-fit: cover; }
.art.shots .initial, .art.iconart .initial { display: none; }
.art .vr { position: absolute; z-index: 2; top: 8px; left: 8px; }
.icon { position: relative; display: inline-grid; place-items: center; width: 38px; height: 38px; border-radius: 9px; overflow: hidden; flex: none;
background: hsl(var(--hue) 35% 28%); color: #fff; font-size: 18px; font-weight: 600; }
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.vr { font-size: 12px; font-weight: 700; letter-spacing: .8px; color: #0e141b; background: #c2e7ff; border-radius: 5px; padding: 2px 7px; }
.fit { font-size: 12px; font-weight: 600; border-radius: 5px; padding: 2px 8px; white-space: nowrap; }
.fit.works { color: #c3efb2; background: #59bf4029; } .fit.ready { color: #bfe3fa; background: #1a9fff26; } .fit.blocked { color: #f3b2a6; background: #d9412629; }
.tag { font-size: 12px; font-weight: 600; color: #d6f5c8; background: #2d5a1f; border-radius: 10px; padding: 2px 9px; white-space: nowrap; }
/* Home */
.hero { position: relative; flex: 0 0 42%; padding: 0; border: 0; border-radius: 16px; overflow: hidden; text-align: left; isolation: isolate; }
.hero .art { position: absolute; inset: 0; height: 100%; }
.hero::after { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(90deg, #081421f0 0%, #08142180 50%, #08142100 85%); }
.hero .copy { position: relative; z-index: 2; padding: 26px 30px; max-width: 560px; display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.hero .eyebrow { display: flex; gap: 10px; align-items: center; font-size: 13px; font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase; color: #c2e7ff; }
.hero strong { font-size: 38px; color: #fff; line-height: 1.05; letter-spacing: -.8px; }
.hero .blurb { color: #e0e8ef; font-size: 17px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.hero .cta { margin-top: 4px; background: #ffffff22; border: 1px solid #ffffff44; border-radius: 10px; padding: 9px 18px; font-size: 16px; color: #fff; }
/* Pager */
.pager { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.pager button { min-width: 180px; }
.dots { display: flex; gap: 8px; align-items: center; color: var(--dim); font-size: 15px; }
.empty { grid-column: 1 / -1; grid-row: 1 / -1; display: grid; place-items: center; align-content: center; gap: 14px; color: var(--dim); text-align: center; padding: 30px; }
.empty h2 { margin: 0; color: var(--bright); font-size: 24px; }
.skeleton { border-radius: 14px; background: linear-gradient(100deg, #1b2938 30%, #24364a 50%, #1b2938 70%); background-size: 300% 100%; animation: shimmer 1.4s infinite; }
@keyframes shimmer { to { background-position: -150% 0; } }
/* Detail sheet and keyboard sit over everything. */
.overlay { position: fixed; inset: 0; background: #000b; display: grid; place-items: center; z-index: 10; }
.sheet { background: #16212e; border: 1px solid var(--line); border-radius: 22px; width: 1000px; max-height: 740px; overflow: hidden;
display: flex; flex-direction: column; box-shadow: 0 30px 80px #000c; }
.sheet > .art { flex: 0 0 250px; }
.sheet .gallery { flex: 0 0 250px; display: flex; gap: 10px; padding: 14px 14px 0; background: #0c131c; justify-content: center; overflow: hidden; }
.sheet .gallery img { height: 100%; width: auto; max-width: 32%; object-fit: cover; object-position: top; border-radius: 10px 10px 0 0; }
.sheet .inner { padding: 20px 28px 22px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; }
.sheet .top { display: flex; gap: 18px; align-items: center; }
.sheet .top .icon { width: 72px; height: 72px; border-radius: 16px; font-size: 32px; }
.sheet h2 { margin: 0; font-size: 30px; color: var(--bright); line-height: 1.1; }
.sheet .meta { color: var(--dim); font-size: 16px; }
.sheet .text { font-size: 17px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.sheet .why { background: #ffffff0a; border-radius: 12px; padding: 10px 16px; font-size: 15px; color: #c5d0db; }
.sheet .why ul { margin: 4px 0 0; padding-left: 22px; }
.sheet .acts { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; align-items: center; }
.sheet .acts .close { margin-left: auto; }
.progress { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 6px; font-size: 15px; color: var(--dim); }
.progress .track { height: 8px; border-radius: 4px; background: #ffffff14; overflow: hidden; }
.progress .fill { height: 100%; background: var(--blue); width: 30%; transition: width .3s; }
.progress.busy .fill { animation: busy 1.2s ease-in-out infinite; }
@keyframes busy { from { margin-left: -30%; } to { margin-left: 100%; } }
.kbd { position: fixed; left: 0; right: 0; bottom: 0; background: #131d2af5; border-top: 1px solid var(--line);
padding: 12px; display: grid; gap: 8px; z-index: 20; box-shadow: 0 -20px 50px #0009; }
.kbd .row { display: flex; gap: 8px; justify-content: center; }
.kbd button { min-width: 76px; min-height: 60px; padding: 0 10px; font-size: 21px; }
.kbd .wide { min-width: 320px; }
.toast { position: fixed; left: 50%; top: 18px; transform: translateX(-50%); background: #0d2236; border: 1px solid var(--blue);
border-radius: 14px; padding: 12px 20px; z-index: 30; max-width: 900px; box-shadow: 0 10px 30px #0008; }
.toast.bad { background: #3a1416; border-color: var(--bad); }
</style>
</head>
<body>
<div class="app">
<div class="rail" role="navigation" aria-label="Store">
<h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 7h16l-1.5 12.5a1 1 0 0 1-1 .9H6.5a1 1 0 0 1-1-.9z"/><path d="M8.5 9V6a3.5 3.5 0 0 1 7 0v3"/></svg>APK Store</h1>
<div id="cats"></div>
<div class="sep"></div>
<button data-view="installed" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3.5" y="4" width="7" height="7" rx="1.5"/><rect x="13.5" y="4" width="7" height="7" rx="1.5"/><rect x="3.5" y="14" width="7" height="7" rx="1.5"/><rect x="13.5" y="14" width="7" height="7" rx="1.5"/></svg>Installed<span class="n" id="instN"></span></button>
<label title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="phone"> Show phone-only apps</label>
</div>
<main>
<div class="bar">
<label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="q" placeholder="Search games and apps" autocomplete="off" spellcheck="false" aria-label="Search games and apps"></label>
<button id="kbdBtn" title="On-screen keyboard">⌨︎ Keyboard</button>
</div>
<div class="heading"><b id="title">Home</b><span id="count"></span><span class="note" id="note"></span></div>
<div class="content" id="content"></div>
<div class="pager">
<button id="prev">◀ Previous</button>
<span class="dots" id="page"></span>
<button id="next">Next ▶</button>
</div>
</main>
</div>
<div class="overlay" id="detail" hidden><div class="sheet" id="sheet"></div></div>
<div class="kbd" id="kbd" hidden></div>
<div class="toast" id="toast" hidden></div>
<script>
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
const $ = id => document.getElementById(id);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const PER_PAGE = 8;
// Same ids and order as STORE_CATS in index.html and ui/apk_sources/_categories.py.
const CATS = [
["", "Home", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
["vr", "VR", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
["games", "Games", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/>'],
["emulators", "Emulators", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4M8 10.5h3M9.5 9v3"/>'],
["watch", "Watch & listen", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z"/><path d="M8 20h8"/>'],
["read", "Read & learn", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
["create", "Create", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
["work", "Work", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
["web", "Web & chat", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
["tools", "Tools", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
];
const catLabel = id => (CATS.find(c => c[0] === id) || [])[1] || "";
const FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Won't run" };
// view: "" (home), a category id, or "installed"; jobs: offer key -> {stage, percent, done, result, error}
// installedFrom: offer key -> package it installed as (GitHub listings have no package);
// needsData: installed packages still waiting for their game data (any view, while installed)
const st = { view: "", q: "", phone: false, page: 0, apps: [], installed: new Map(), jobs: new Map(), installedFrom: new Map(),
needsData: new Set(), request: 0, loading: false, retry: 0 };
async function api(path, body) {
const opts = body === undefined ? { headers: { "X-Frame-UI": UI_KEY } } : {
method: "POST", headers: { "Content-Type": "application/json", "X-Frame-UI": UI_KEY }, body: JSON.stringify(body) };
const r = await fetch(path, opts);
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
return j;
}
let toastTimer;
function toast(msg, bad = false) {
const t = $("toast");
t.textContent = msg; t.className = "toast" + (bad ? " bad" : ""); t.hidden = false;
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.hidden = true, bad ? 8000 : 4500);
}
// ---- Artwork (only the server's /source-image/ handles are ever used as an image) ----
const hue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
const initial = name => [...String(name || "?")][0].toUpperCase();
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
const img = (u, cls = "") => valid(u) ? `<img${cls ? ` class="${cls}"` : ""} src="${esc(u)}" alt="" decoding="async">` : "";
function art(name, a = {}, extra = "") {
const base = `style="--hue:${hue(name)}"`, ini = `<span class="initial" aria-hidden="true">${esc(initial(name))}</span>`;
if (valid(a.banner)) return `<span class="art" ${base}>${ini}${img(a.banner)}${extra}</span>`;
const shots = (a.screenshots || []).filter(valid).slice(0, 3);
if (shots.length) return `<span class="art shots" ${base}>${ini}${shots.map(u => img(u)).join("")}${extra}</span>`;
if (valid(a.icon)) return `<span class="art iconart" ${base}>${ini}${img(a.icon, "backdrop")}${img(a.icon, "mark")}${extra}</span>`;
return `<span class="art" ${base}>${ini}${extra}</span>`;
}
const icon = (name, a = {}) => `<span class="icon" style="--hue:${hue(name)}" aria-hidden="true">${esc(initial(name))}${img(a.icon)}</span>`;
document.addEventListener("error", e => {
const el = e.target;
if (!el.matches?.(".art img, .icon img")) return;
if (!el.dataset.retried) { el.dataset.retried = "1"; setTimeout(() => { el.src = el.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return; }
const box = el.closest(".shots, .iconart"); el.remove();
if (box && !box.querySelector("img")) box.classList.remove("shots", "iconart");
}, true);
const text = s => String(s || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ");
// ---- Data ----
async function loadInstalled() {
try {
st.installed = new Map((await api("/api/android")).apps.map(a => [a.package, a]));
for (const pkg of st.needsData) if (!st.installed.has(pkg)) { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); } // removed
} catch (e) { toast(`Couldn't list installed apps: ${e.message}`, true); }
$("instN").textContent = st.installed.size || "";
}
async function search(quiet) {
const request = ++st.request;
clearTimeout(st.retry);
if (st.view === "installed") { st.loading = false; st.page = 0; return render(); } // a pending search is now stale
st.loading = !quiet; if (!quiet) render();
const params = new URLSearchParams({ q: st.q, category: st.view, phone: String(st.phone) });
try {
const result = await api("/api/search?" + params);
if (request !== st.request) return;
st.apps = result.apps; st.loading = false;
const loading = result.sources.filter(s => s.status === "loading").map(s => s.name);
$("note").textContent = loading.length ? `Still fetching ${loading.join(" and ")}; more apps will appear.` : "";
if (loading.length) st.retry = setTimeout(() => request === st.request && search(true), 6000);
if (!quiet) st.page = 0;
render();
} catch (e) {
if (request !== st.request) return;
st.loading = false; st.apps = []; render(`Couldn't reach the app shelves: ${e.message}`);
}
}
const offerKey = app => `${app.offers[0].source}:${app.offers[0].id}`;
// Always the latest state: an Installed card's own copy goes stale after Stop or Remove.
function installedOf(app) {
const pkg = app.installed?.package || app.package || st.installedFrom.get(offerKey(app));
return pkg ? st.installed.get(pkg) : undefined;
}
// ---- Pages ----
function list() {
if (st.view === "installed") {
const q = st.q.toLowerCase();
return [...st.installed.values()].filter(m => !q || `${m.label} ${m.package}`.toLowerCase().includes(q))
.map(m => ({ installed: m, name: m.label || m.package, package: m.package, summary: m.version ? `v${String(m.version).replace(/^v/i, "")}` : "",
offers: [{ name: m.label, artwork: m.store_art || {}, vr: m.vr && !m.flatscreen }] }));
}
return st.home ? st.home.rest : st.apps;
}
function card(app, i) {
const o = app.offers[0], m = installedOf(app), job = st.jobs.get(offerKey(app));
const state = m ? `<span class="tag">${m.running ? "Running" : "Installed"}</span>`
: job && !job.done ? `<span class="tag">Installing…</span>`
: FIT[o.verdict?.tone] ? `<span class="fit ${o.verdict.tone}">${FIT[o.verdict.tone]}</span>` : "";
const kind = o.phone_only ? "Phone app" : catLabel(app.category || o.category) || (m ? (o.vr ? "VR app" : "Android app") : "Android app");
return `<button class="card" data-i="${i}" aria-label="${esc(app.name)}">${art(app.name, o.artwork, o.vr ? '<span class="vr">VR</span>' : "")}
<span class="body"><span class="title">${icon(app.name, o.artwork)}<strong>${esc(app.name)}</strong></span>
<span class="sum">${esc(text(app.summary))}</span><span class="foot"><span>${esc(kind)}</span>${state}</span></span></button>`;
}
function render(error) {
const home = st.view === "" && !st.q;
$("title").textContent = st.q ? `Results for “${st.q}”` : st.view === "installed" ? "Installed" : st.view ? catLabel(st.view) : "Home";
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.view === st.view)));
const content = $("content");
if (st.loading) {
content.innerHTML = `<div class="grid">${Array.from({ length: PER_PAGE }, () => '<div class="skeleton"></div>').join("")}</div>`;
$("count").textContent = ""; $("page").textContent = ""; $("prev").disabled = $("next").disabled = true; return;
}
// Home: a featured VR app and four picks, then pages of one app from each category in turn.
st.home = null;
if (home && st.apps.length) {
const ok = st.apps.filter(a => a.offers[0].verdict?.tone !== "blocked");
const heroes = ok.filter(a => a.offers[0].vr && valid(a.offers[0].artwork?.banner));
const featured = heroes[Math.floor(Date.now() / 864e5) % (heroes.length || 1)] || ok.find(a => valid(a.offers[0].artwork?.banner));
const queues = CATS.slice(1).map(([id]) => ok.filter(a => a.category === id && a !== featured));
const mixed = [];
for (let i = 0; queues.some(q => q[i]); i++) queues.forEach(q => q[i] && mixed.push(q[i]));
st.home = { featured, rest: mixed.slice(0, 4 + 5 * PER_PAGE) }; // six pages of picks; the categories have the rest
}
const items = list();
const first = st.home?.featured ? 4 : PER_PAGE; // the first home page shares its space with the banner
const pages = Math.max(1, items.length <= first ? 1 : 1 + Math.ceil((items.length - first) / PER_PAGE));
st.page = Math.min(st.page, pages - 1);
const start = st.page === 0 ? 0 : first + (st.page - 1) * PER_PAGE;
const shown = items.slice(start, start + (st.page === 0 ? first : PER_PAGE));
$("count").textContent = home ? "" : `${items.length.toLocaleString()} app${items.length === 1 ? "" : "s"}`;
$("page").textContent = `Page ${st.page + 1} of ${pages}`;
$("prev").disabled = st.page === 0; $("next").disabled = st.page >= pages - 1;
const empty = error ? `<div class="empty"><h2>No connection</h2>${esc(error)}</div>`
: st.view === "installed" && !st.q ? `<div class="empty"><h2>Nothing installed yet</h2>Pick an app in the store and press Install.<button class="primary" data-view="">Browse the store</button></div>`
: `<div class="empty"><h2>No apps here</h2>Try another category${st.phone ? "" : ", or show phone-only apps"}.</div>`;
let html = "";
if (st.page === 0 && st.home?.featured) {
const f = st.home.featured, o = f.offers[0];
html += `<button class="hero" data-feature="1" aria-label="${esc(f.name)}">${art(f.name, o.artwork)}<span class="copy">
<span class="eyebrow">${o.vr ? '<span class="vr">VR</span>' : ""}Featured</span><strong>${esc(f.name)}</strong>
<span class="blurb">${esc(text(f.summary))}</span><span class="cta">See ${esc(f.name)} ›</span></span></button>`;
}
html += `<div class="grid${st.page === 0 && st.home?.featured ? " one-row" : ""}">${shown.length ? shown.map((a, i) => card(a, start + i)).join("") : empty}</div>`;
content.innerHTML = html;
}
function turn(by) {
if (by < 0 ? $("prev").disabled : $("next").disabled) return;
st.page += by; render();
}
// ---- Detail sheet ----
let current = null; // {app, installed}
const ver = v => String(v || "").replace(/^v/i, "");
const size = n => n >= 1e9 ? (n / 1e9).toFixed(1) + " GB" : n >= 1e6 ? Math.round(n / 1e6) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
function openDetail(app) {
current = app; drawDetail(); $("detail").hidden = false;
const o = app.offers[0];
if (o.source && o.id && !app.detailed) { // the full entry has all the screenshots
api(`/api/sources/details?source=${encodeURIComponent(o.source)}&id=${encodeURIComponent(o.id)}`)
.then(full => { if (current === app) { app.offers[0] = { ...o, ...full }; app.detailed = true; drawDetail(); } }).catch(() => {});
}
}
function drawDetail() {
const app = current; if (!app) return;
const o = app.offers[0], m = installedOf(app), key = offerKey(app), job = st.jobs.get(key), data = m && st.jobs.get("data:" + m.package);
if (app.installed && !m) return closeDetail(); // removed
const shots = (o.artwork?.screenshots || []).filter(valid);
const top = valid(o.artwork?.banner) ? art(app.name, { banner: o.artwork.banner })
: shots.length ? `<div class="gallery">${shots.slice(0, 3).map(u => img(u)).join("")}</div>` : art(app.name, o.artwork || {});
const meta = [o.developer, o.source_name, o.version ? `v${ver(o.version)}` : m?.version ? `v${ver(m.version)}` : "", o.size ? size(o.size) : ""].filter(Boolean).join(" · ");
const reasons = (o.fit?.reasons || []).filter(Boolean);
let acts;
const progress = j => {
const pct = typeof j.percent === "number" ? Math.max(3, Math.min(100, j.percent)) : null;
return `<div class="progress${pct === null ? " busy" : ""}"><span>${esc(j.stage || "Downloading")}${pct === null ? "…" : ` ${Math.round(pct)}%`}</span><div class="track"><div class="fill" style="${pct === null ? "" : `width:${pct}%`}"></div></div></div>`;
};
const gameData = !(m && st.needsData.has(m.package)) ? "" : data && !data.done ? progress(data)
: `<button class="primary" data-act="game-data">Add game data</button><span class="meta">Open it once on the Frame first.</span>`;
if (job && !job.done) {
acts = progress(job);
} else if (m) {
acts = gameData + (m.running ? `<button data-act="stop">■ Stop</button>` : `<button class="play" data-act="launch">▶ Open</button>`)
+ `<button class="danger" data-act="remove">Remove</button>`;
} else if (!o.source) {
acts = "";
} else if (o.downloadable === false) {
acts = `<button disabled>Get it from the developer's page</button>`;
} else if (o.verdict?.tone === "blocked") {
acts = `<button disabled>Won't run on the Frame</button>`;
} else {
acts = `<button class="primary" data-act="install">Install</button>`;
}
const blurb = o.description || o.summary || (app.installed ? "" : app.summary); // an Installed card's summary is its version, already in meta
const failed = data?.error || job?.error;
const note = failed ? `<div class="meta" style="color:var(--bad)">${esc(failed)}</div>` : "";
$("sheet").innerHTML = `${top}<div class="inner">
<div class="top">${icon(app.name, o.artwork)}<div><h2>${esc(app.name)}</h2><div class="meta">${esc(meta)}</div></div>
${o.vr ? '<span class="vr" style="margin-left:auto">VR</span>' : ""}</div>
${blurb ? `<div class="text">${esc(text(blurb))}</div>` : ""}
${o.verdict ? `<div class="why"><b>${esc(o.verdict.label)}</b>${reasons.length ? `<ul>${reasons.slice(0, 3).map(r => `<li>${esc(r)}</li>`).join("")}</ul>` : ""}</div>` : ""}
${note}<div class="acts">${acts}<button class="close" data-act="close">Close</button></div></div>`;
}
function closeDetail() { current = null; $("detail").hidden = true; }
async function runJob(key, start) {
const job = { stage: "Downloading", percent: null, done: false };
st.jobs.set(key, job); drawDetail(); render();
try {
const { job: id } = await start();
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(id)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
Object.assign(job, { stage: j.stage || job.stage, percent: j.percent ?? null });
if (!j.done) { drawDetail(); continue; }
if (j.error) throw new Error(j.error);
toast(j.message); job.result = j.result || {};
break;
}
} catch (e) { job.error = e.message; toast(e.message, true); }
finally {
job.done = true;
if (!job.error) st.jobs.delete(key); // a failure stays, so the sheet can say why
await loadInstalled(); render(); drawDetail();
}
return job;
}
$("sheet").onclick = async e => {
const b = e.target.closest("[data-act]"); if (!b || !current) return;
const app = current, o = app.offers[0], m = installedOf(app), act = b.dataset.act, key = offerKey(app);
if (act === "close") return closeDetail();
if (act === "install") {
toast(`Installing ${app.name}. The first time takes a minute…`);
const job = await runJob(key, () => api("/api/sources/install", { source: o.source, id: o.id, version_code: o.version_code ?? null }));
if (job.result?.package) {
st.installedFrom.set(key, job.result.package);
if (job.result.game_data) st.needsData.add(job.result.package);
}
return render(), drawDetail();
}
if (act === "game-data" && m) { // its own job, by package: a failed copy keeps the pending data, so it can be tried again
const job = await runJob("data:" + m.package, () => api("/api/sources", { action: "game-data", package: m.package }));
if (!job.error) st.needsData.delete(m.package);
return render(), drawDetail();
}
const pkg = m?.package; if (!pkg) return;
if (act === "remove" && b.dataset.sure !== "1") {
b.dataset.sure = "1"; b.textContent = "Remove it and its data?"; return; // no confirm(): a separate window the laser has to find
}
b.disabled = true;
try {
const r = await api("/api/android", { action: act, package: pkg });
toast(r.message);
if (act === "remove") { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); }
if (act === "launch") closeDetail();
} catch (err) { toast(err.message, true); }
await loadInstalled(); render();
if (current === app) drawDetail();
};
$("detail").onclick = e => { if (e.target === $("detail")) closeDetail(); };
// ---- Navigation ----
$("cats").innerHTML = CATS.map(([id, label, path]) => `<button data-view="${id}" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${path}</svg>${esc(label)}</button>`).join("");
document.addEventListener("click", e => {
const v = e.target.closest("[data-view]");
if (v && !e.target.closest("#sheet")) {
st.view = v.dataset.view; st.q = ""; $("q").value = "";
if (st.view === "installed") loadInstalled().then(() => search()); else search();
return;
}
if (e.target.closest("[data-feature]")) return openDetail(st.home.featured);
const c = e.target.closest(".card[data-i]");
if (c) openDetail(list()[+c.dataset.i]);
});
$("phone").onchange = e => { st.phone = e.target.checked; search(); };
$("prev").onclick = () => turn(-1);
$("next").onclick = () => turn(1);
// The thumbstick arrives as a mouse wheel: one notch, one page.
let wheelAt = 0;
document.querySelector("main").addEventListener("wheel", e => {
e.preventDefault();
if (!$("detail").hidden || Date.now() - wheelAt < 350 || Math.abs(e.deltaY) < 4) return;
wheelAt = Date.now(); turn(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
let qTimer;
$("q").oninput = e => { clearTimeout(qTimer); qTimer = setTimeout(() => { st.q = e.target.value.trim(); search(); }, 250); };
document.addEventListener("keydown", e => { if (e.key === "Escape") { closeDetail(); $("kbd").hidden = true; } });
// ---- On-screen keyboard (a text field on a panel may not bring up Steam's) ----
const ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
$("kbd").innerHTML = ROWS.map(r => `<div class="row">${[...r].map(k => `<button data-k="${k}">${k}</button>`).join("")}</div>`).join("")
+ `<div class="row"><button data-k="clear">Clear</button><button class="wide" data-k=" ">space</button><button data-k="back">⌫</button><button data-k="done" class="primary">Done</button></div>`;
$("kbd").onclick = e => {
const b = e.target.closest("[data-k]"); if (!b) return;
const k = b.dataset.k, q = $("q");
if (k === "done") { $("kbd").hidden = true; return; }
q.value = k === "clear" ? "" : k === "back" ? q.value.slice(0, -1) : q.value + k;
q.dispatchEvent(new Event("input"));
};
$("kbdBtn").onclick = () => $("kbd").hidden = !$("kbd").hidden;
loadInstalled().then(() => search());
</script>
</body>
</html>