mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-18 05:01:16 +02:00
Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5521a917f | ||
|
|
f9496d67da | ||
|
|
7eae209b1c | ||
|
|
28bc0506a6 | ||
|
|
0a4f008d03 | ||
|
|
16b37bc667 | ||
|
|
a8e5d7cf61 | ||
|
|
5cdfee4f62 | ||
|
|
dd80ca7b74 | ||
|
|
612a766057 | ||
|
|
d546568a11 | ||
|
|
5b531c84fb | ||
|
|
e89912d936 | ||
|
|
4c27a3397c | ||
|
|
75acf4a3e9 | ||
|
|
629a2fafb6 | ||
|
|
b0d848dc5c | ||
|
|
57a9d8c717 | ||
|
|
fa9ee01cc1 | ||
|
|
6aab62983e | ||
|
|
baaddd7622 | ||
|
|
07fc7ca3ff | ||
|
|
e885e9adba | ||
|
|
7aa7c55be9 | ||
|
|
2554309c7d | ||
|
|
adc1e44cf1 | ||
|
|
7f46cd54a4 | ||
|
|
4ecc03280e | ||
|
|
2f1aeaac72 | ||
|
|
8347c2c3f7 | ||
|
|
a94c45d014 | ||
|
|
e20225604d | ||
|
|
2d81c92114 | ||
|
|
fe73906f1f | ||
|
|
84912ea4e1 | ||
|
|
87884f382b | ||
|
|
f98f3e728b | ||
|
|
90034540ae | ||
|
|
cc9af9eba4 | ||
|
|
bc0e0fc043 | ||
|
|
5b4efc87b3 | ||
|
|
c2c1cf0168 | ||
|
|
01ba796881 | ||
|
|
b9f96866e5 | ||
|
|
3fb876ad5a | ||
|
|
9bd6890fe8 | ||
|
|
0d181b676e | ||
|
|
8de0292568 | ||
|
|
8e8c24f370 | ||
|
|
79dda60bde | ||
|
|
0bb2f9eb03 | ||
|
|
30509da71f | ||
|
|
d4b34e1bad | ||
|
|
22548bc42f | ||
|
|
1863c92101 | ||
|
|
497b5d9ae6 | ||
|
|
04995c066f | ||
|
|
0bceb5c928 | ||
|
|
22bb9c6469 | ||
|
|
fab04f0558 | ||
|
|
c0ae75d56b | ||
|
|
0520082586 | ||
|
|
36dd5a05f2 | ||
|
|
002c040223 | ||
|
|
4b1bb1557d | ||
|
|
73b97fedc5 | ||
|
|
924e37aa30 | ||
|
|
b151ae691e | ||
|
|
36db6b1ac6 | ||
|
|
bd24436fbc | ||
|
|
895cc0ebbd | ||
|
|
12aaec7888 | ||
|
|
7ea999d5b4 | ||
|
|
f7dbad500a | ||
|
|
3f20e5f9d3 | ||
|
|
8051687e1e | ||
|
|
9e25ae9025 | ||
|
|
ed4a0ad5cd | ||
|
|
cb0a90a9c3 | ||
|
|
32fbbfe475 |
No files matched your search
@@ -22,6 +22,7 @@ desktop or panels.
|
||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
|
||||
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
|
||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# A newer push to the same pull request supersedes a run still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -17,7 +22,9 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
sh -n ui/local-bin/ssh
|
||||
@@ -84,6 +91,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: End-to-end tests
|
||||
run: scripts/e2e.sh
|
||||
@@ -2,14 +2,22 @@ name: release
|
||||
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that touch the app build the same installers as artifacts.
|
||||
# Pull requests that change how the installers are built (the Electron app, the
|
||||
# macOS helper it compiles, or this workflow) build the same installers as
|
||||
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||
# are tested by the checks workflow; run this one by hand to package them early.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -94,7 +102,9 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||
# Tagged builds are attached to the release above; only other builds need artifacts.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
path: |
|
||||
|
||||
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
||||
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
|
||||
diagnostics with personal details removed, shows you exactly what's included,
|
||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||
@@ -224,7 +224,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
<details>
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
|
||||
|
||||
## Assistant panel
|
||||
|
||||
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
||||
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
|
||||
To put the same page in the headset, with the HTTP server still running:
|
||||
|
||||
```sh
|
||||
|
||||
+54
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-3
@@ -3,8 +3,9 @@
|
||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||
reached at more than one address: a LAN IP at home, another at the office, its
|
||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||
tab (key 5) lists them, and the connection pill in the header shows what the
|
||||
app is doing to reach the one in use, step by step, as it happens.
|
||||
tab (key 5) lists them. The connection pill in the header says whether the one in
|
||||
use is connected and how (Tailscale, or the network's name); click it to add,
|
||||
edit or reorder that headset's addresses, or to see each step of connecting.
|
||||
|
||||
The code is in three modules, all stdlib-only Python on your computer:
|
||||
|
||||
@@ -57,7 +58,7 @@ and ranks them:
|
||||
5. addresses that only ever worked on other networks;
|
||||
6. Tailscale addresses while Tailscale is off.
|
||||
|
||||
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
||||
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
|
||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||
still trying. If SSH to the winner fails in a way another address could fix
|
||||
(a different device answered there, or the link dropped), the next one that
|
||||
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
|
||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||
services and checks `ALIAS.local` and `frame.local`.
|
||||
|
||||
**The pill's dialog** lists the same addresses, with what each one answered, and
|
||||
can add, edit, reorder and remove them without leaving the page you're on. When
|
||||
the headset reports a LAN IP on the same network as this computer and that IP
|
||||
isn't saved, it offers to add it. The offer puts the address first in the list,
|
||||
so on that network it wins over the Tailscale name; away from home the Tailscale
|
||||
name still leads. A new or edited address is tested straight away. While
|
||||
connected, **Reconnect** applies your changes now rather than at the next
|
||||
connection: it tries the addresses again, ranked as above, and the best-ranked
|
||||
one that answers promptly wins. It doesn't pick a particular address.
|
||||
|
||||
## Networks
|
||||
|
||||
A network is told apart by its default gateway: the router's IP address plus its
|
||||
|
||||
@@ -58,7 +58,7 @@ not been verified and is not guaranteed.
|
||||
|
||||
## Casting
|
||||
|
||||
**Cast headset view** starts the existing headset Live view and requests full
|
||||
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
|
||||
screen where supported. Show that screen to people in the room, or use the
|
||||
computer/phone's own screen mirroring. It creates no new stream transport,
|
||||
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
||||
|
||||
+17
-11
@@ -17,15 +17,21 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has six tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power), **Devices** (your headsets and their addresses),
|
||||
and **Control** (the searchable Control Center, metrics, supported settings and
|
||||
headset guidance). Keys 1–6 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
The window has six tabs: **Home** (what the headset's doing and what you'd do
|
||||
next, the headset view, screenshots, family and comfort, the keyboard and
|
||||
trackpad, and VR performance, folded away), **Games** (installed games,
|
||||
sideloaded titles, getting games), **Android** (apps, the catalogue, display
|
||||
settings, reports), **Tools** (in the headset: your computer's windows, the media
|
||||
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
||||
power), **Devices** (your headsets and their addresses), and **Control** (the
|
||||
searchable Control Center, metrics, supported settings and headset guidance).
|
||||
Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**:
|
||||
privacy and updates, library artwork and the assistant. The battery chip opens
|
||||
the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
|
||||
build. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset and whether it's
|
||||
connected, and how (Tailscale, or the network's name). Click it to add, edit or
|
||||
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
|
||||
reached, a banner says why in plain words, what was tried, and counts down to
|
||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
@@ -133,7 +139,7 @@ rejects invalid types, out-of-range levels and invalid mute states before SSH.
|
||||
Other settings link to the existing forms and their validation, progress,
|
||||
confirmations and reset behavior: per-instance Android resolution/density/text,
|
||||
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
|
||||
sources and artwork. Android display links reveal **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.
|
||||
|
||||
@@ -239,5 +245,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
|
||||
require a separate user approval; power also retains its password prompt. The
|
||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||
operate the headset. Tools → Open assistant opens the page; the linked guide
|
||||
operate the headset. Settings → Open assistant settings opens the page; the linked guide
|
||||
covers putting it in a Chromium panel on the Frame.
|
||||
+2
-2
@@ -114,8 +114,8 @@ don't.
|
||||
|
||||
## Family and comfort
|
||||
|
||||
The shared Home card sets session limits, breaks and check-ins, and offers
|
||||
**Cast headset view**. **Enable / test notifications** requests iOS notification
|
||||
The shared Home card sets session limits, breaks and check-ins; **Cast to this
|
||||
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
|
||||
permission and sends a local test. These are local notifications, not APNs push;
|
||||
iOS background suspension can interrupt phone alerts. The headset timer still
|
||||
runs. See [the behavior and verification limits](family-comfort.md).
|
||||
+1
-1
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
|
||||
separate from Frame Control. Public reports describe some Proton support;
|
||||
Windows-only does not by itself prove a Frame app cannot run. Local status
|
||||
and sources are in [VR utilities](vr-utilities.md).
|
||||
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
|
||||
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
|
||||
headset creates its own gamescope panel using built-in tools. It needs no
|
||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||
|
||||
|
||||
+2
-2
@@ -132,8 +132,8 @@ runs.
|
||||
|
||||
## Report a problem
|
||||
|
||||
**Report a problem** is the warning-sign button in the header, also in the
|
||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||
**Report a problem** is the speech-bubble button in the header, also in the
|
||||
Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report
|
||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||
same way as the analytics above, so only the maintainer can read it and
|
||||
nothing is published. It works whatever the analytics settings are, because
|
||||
|
||||
@@ -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**) |
|
||||
|
||||
@@ -65,6 +65,8 @@ flow, not evidence of an anonymous download API. It is not implemented here.
|
||||
- `search()` raises a user-readable `SourceError` with the browse URL (zero
|
||||
limit returns no rows). It does not invent app results or report a false
|
||||
“no matching games”. The aggregate search UI should surface this source error.
|
||||
In the app, Discover doesn't link to it; the **Sources** dialog lists SideQuest
|
||||
with a link to its website instead of an on/off switch.
|
||||
- `details()` accepts a numeric listing id and returns its canonical page link,
|
||||
`downloadable: False`, empty versions/tags/headsets and the `images` shape
|
||||
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
|
||||
|
||||
@@ -21,6 +21,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
|
||||
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
|
||||
|
||||
**RDP from Windows, verified 2026-09-30:** Windows 11 25H2's Remote Desktop
|
||||
(`mstsc`) against BUILD_ID 20260925.6191901. xrdp picks TLS, not NLA, so
|
||||
Remote Desktop never asks for a user or password. It warns that the certificate
|
||||
(`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to
|
||||
frame" box with the username blank. Any user but `steamos` gets "User does not
|
||||
exist, or could not be authenticated". Signing in as `steamos` with the
|
||||
Developer Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||
It's a new session on display `:10`, separate from what the headset shows,
|
||||
and it uses about 1.3 GB of the Frame's memory. Closing Remote Desktop leaves
|
||||
it running, and the next sign-in reconnects to it. To end it, find its
|
||||
session with `loginctl list-sessions` over SSH (its leader is `xrdp-sesexec`)
|
||||
and run `loginctl terminate-session <id>`; the headset's own session keeps
|
||||
running.
|
||||
|
||||
**Recommendation for A:** start with Steam Link for macOS, because Valve
|
||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||
Mac with keyboard, mouse, and clipboard.
|
||||
|
||||
@@ -8,6 +8,7 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
|
||||
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
|
||||
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
|
||||
| [Windows test VM](#windows-test-vm) | `scripts/windows-vm.sh` | A Linux machine with KVM and Docker | The Windows build on a real Windows desktop, against a real Frame when needed |
|
||||
|
||||
## Unit tests
|
||||
|
||||
@@ -186,6 +187,86 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
|
||||
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||
Steam accepts.
|
||||
|
||||
## Windows test VM
|
||||
|
||||
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||
depends on the Windows desktop (Remote Desktop, the installer, the bundled
|
||||
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
|
||||
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
|
||||
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
|
||||
|
||||
Setting it up, once, on the Linux machine:
|
||||
|
||||
- **Windows comes from Microsoft.** The container downloads the Windows 11
|
||||
image from Microsoft on first start. Windows runs unactivated, which is fine
|
||||
for testing. Don't use activation workarounds or third-party Windows images.
|
||||
- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH),
|
||||
`127.0.0.1:8006:8006` (the web console) and, if you need it,
|
||||
`127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use
|
||||
`restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is
|
||||
testing, and a `docker stop` shuts Windows down cleanly.
|
||||
- **Give it SSH on first sign-in.** The container runs `/oem/install.bat`
|
||||
once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell
|
||||
as its default shell, and put a dedicated public key (for example
|
||||
`~/.ssh/id_ed25519_winvm` on the Mac) in
|
||||
`C:\ProgramData\ssh\administrators_authorized_keys`.
|
||||
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
|
||||
and the tests.
|
||||
|
||||
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
|
||||
|
||||
```sh
|
||||
scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes)
|
||||
scripts/windows-vm.sh put Frame-Control-Setup-x64.exe
|
||||
scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait'
|
||||
scripts/windows-vm.sh shot screen.png # what's on its screen
|
||||
scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot
|
||||
scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names
|
||||
scripts/windows-vm.sh down # shut Windows down
|
||||
```
|
||||
|
||||
- **Clicks go through a scheduled task.** Commands over SSH run in a
|
||||
session with no desktop, so `click` and `scroll` write the position to a
|
||||
file, and a scheduled task running as the signed-in user replays it. The
|
||||
VM's screen must be signed in; it is after `up`. Clicks and scrolls sent at
|
||||
the same time run one after another. QEMU's own `mouse_move` is relative
|
||||
and drifts, so the script doesn't use it.
|
||||
- **Screenshots may not show the pointer.** Check the result of a click (a
|
||||
menu that opens, a button that changes) rather than the pointer's position.
|
||||
- **Windows' `ssh` waits for stdin.** The script closes it for every command.
|
||||
Do the same if you run `ssh` in the VM by hand.
|
||||
- **Starting the app.** Run Frame Control in the signed-in session, not over
|
||||
SSH. Use its Start-menu shortcut via `click`, or a scheduled task like the
|
||||
one `click` uses.
|
||||
|
||||
**Testing against a real Frame.** The VM reaches the headset on the LAN like
|
||||
any other computer. Run **Set Up Connection** in the VM's Frame Control once;
|
||||
that makes the VM's keys. So the VM doesn't keep access between test runs,
|
||||
afterwards take the lines it added out of the headset's
|
||||
`~/.ssh/authorized_keys`: the ones matching the VM's
|
||||
`~/.ssh/id_ed25519_frame.pub` and, if pairing made one,
|
||||
`~/.ssh/id_rsa_frame_devkit.pub`. Check that `ssh -o BatchMode=yes frame true`
|
||||
in the VM now fails. Then, around each run:
|
||||
|
||||
```sh
|
||||
scripts/windows-vm.sh frame-key add # let the VM's key into the headset
|
||||
# ... test ...
|
||||
scripts/windows-vm.sh frame-key remove # and take it out again
|
||||
```
|
||||
|
||||
`add` appends the VM's key tagged `windows-vm-test`, unless the headset
|
||||
already trusts that key through another line. `remove` deletes only the exact
|
||||
line `add` wrote, so it doesn't undo what Set Up Connection did, and it leaves
|
||||
the file alone if it can't rewrite it. Follow the shared-device
|
||||
procedure in [Headset smoke test](#headset-smoke-test) before installing or
|
||||
launching anything on the headset.
|
||||
|
||||
**Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on
|
||||
BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0,
|
||||
connect it to the Frame and follow Remote Desktop through to the Frame's
|
||||
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
|
||||
`frame-key` left `authorized_keys` byte-for-byte as it was.
|
||||
|
||||
## Owned media player
|
||||
|
||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as
|
||||
an installed app with a blank tile.
|
||||
|
||||
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
||||
Set the optional free key in Frame Control's **Library artwork settings**, or
|
||||
Set the optional free key in Frame Control's **Settings → Library artwork**, or
|
||||
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
||||
settings override the saved key. Without a key there are no provider calls or
|
||||
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
||||
|
||||
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
|
||||
|
||||
## Our performance HUD
|
||||
|
||||
On **Home → VR comfort and performance**, the app shows a timestamped sample
|
||||
On **Home → VR performance** (folded until you open it), the app shows a timestamped sample
|
||||
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
||||
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
||||
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
|
||||
|
||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||
it running. This mode also returned live timing in a read-only device probe.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path.
|
||||
|
||||
## Use it
|
||||
|
||||
In **Tools → Media in the headset**, send a file, choose its layout and press
|
||||
In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press
|
||||
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
||||
**Stop** removes both. Refresh reads the library and the player's state.
|
||||
The screen follows your head; it isn't a saved world-space panel.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ started=$SECONDS
|
||||
|
||||
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
|
||||
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
|
||||
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
|
||||
# That second build also rides out a flaky package mirror: only its failure is fatal.
|
||||
build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 }
|
||||
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
|
||||
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
|
||||
|
||||
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: the APK Store in the headset. Frame Control's Android catalogue
|
||||
# (the same rated F-Droid list, Install, Open and Remove) as its own floating
|
||||
# panel on the Steam Frame, so you can browse and install apps from inside.
|
||||
# See docs/apks.md → "The store in the headset".
|
||||
#
|
||||
# Frame Control's server runs on the Frame itself (FRAME_LOCAL=1, as the iPhone
|
||||
# app runs it) on the Frame's loopback, and Chromium opens ui/store.html as a
|
||||
# panel. Installs then download and verify the APK on the Frame; the Mac isn't
|
||||
# needed after `open`.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
|
||||
# scripts/store-on-frame.sh close # close the panel and stop the server
|
||||
# scripts/store-on-frame.sh status
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
HERE=${0:A:h}
|
||||
ROOT=${HERE:h}
|
||||
PORT=47812 # the Frame's 127.0.0.1 only
|
||||
APPID=2001947812 # the panel's id (panel-on-frame.sh's range)
|
||||
# Not ~/.cache/frame-control: the iPhone app prunes other versions there.
|
||||
REMOTE=.local/share/frame-control/store-server
|
||||
|
||||
cmd=${1:-}
|
||||
|
||||
# Same bundle the iPhone app copies (ios/scripts/make_frame_bundle.py), so the
|
||||
# server on the Frame has exactly the files the desktop app ships.
|
||||
deploy() {
|
||||
local tmp version
|
||||
tmp=$(mktemp -d)
|
||||
trap "rm -rf ${(q)tmp}" EXIT
|
||||
version=$(python3 "$ROOT/ios/scripts/make_frame_bundle.py" "$tmp/bundle.tar.gz")
|
||||
if ! ssh "$FRAME_ALIAS" "test -f $REMOTE/$version/ui/server.py"; then
|
||||
print -u2 "==> Copying Frame Control ($version) to the Frame"
|
||||
ssh "$FRAME_ALIAS" "set -e; mkdir -p $REMOTE; cd $REMOTE; rm -rf $version.tmp; mkdir $version.tmp
|
||||
tar xzf - -C $version.tmp; rm -rf $version; mv $version.tmp $version" <"$tmp/bundle.tar.gz"
|
||||
fi
|
||||
# Older versions, unless a server is still running from one.
|
||||
ssh "$FRAME_ALIAS" "cd $REMOTE && for d in */; do d=\${d%/}; [ \"\$d\" = $version ] && continue
|
||||
pgrep -f \"\$PWD/\$d/\" >/dev/null && continue; rm -rf -- \"\$d\"; done"
|
||||
print -r -- "$version"
|
||||
}
|
||||
|
||||
# Runs on the Frame. Args: dir port appid key
|
||||
OPEN=$(cat <<'EOF'
|
||||
set -u
|
||||
dir=$HOME/$1 port=$2 appid=$3 key=$4
|
||||
export DISPLAY=:0 LC_ALL=C.UTF-8
|
||||
unset WAYLAND_DISPLAY
|
||||
xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1 || { echo "The headset isn't showing anything (gamescope's :0 isn't up). Wake it and try again." >&2; exit 2; }
|
||||
if [ -x "$HOME/chromium-xr/chrome" ]; then
|
||||
# Steam's overlay library crashes Chromium's zygote (docs/webxr-chromium.md).
|
||||
browser=(env LD_PRELOAD= "$HOME/chromium-xr/chrome" --user-data-dir="$HOME/.local/share/frame-control/store")
|
||||
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
|
||||
browser=(flatpak run org.chromium.Chromium --user-data-dir="$HOME/.var/app/org.chromium.Chromium/frame-store")
|
||||
else
|
||||
echo "The store needs a Chromium on the Frame: Chromium XR (docs/webxr-chromium.md) or the org.chromium.Chromium Flatpak (scripts/install-apps.sh)." >&2
|
||||
exit 3
|
||||
fi
|
||||
# A fresh server each time, so it runs this version with this key.
|
||||
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null
|
||||
# Match the browser by its profile flag and the server by its script path: this
|
||||
# launcher's own command line holds the bundle path, so a looser pattern kills it.
|
||||
pkill -f '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) ' || true
|
||||
pkill -f '[s]tore-server/[^/ ]*/ui/server[.]py' || true
|
||||
sleep 0.5
|
||||
cd "$dir" || exit 1
|
||||
FRAME_LOCAL=1 FRAME_UI_KEY=$key FRAME_DEVICE=headset setsid nohup python3 -I -u -B "$dir/ui/server.py" --port "$port" \
|
||||
>/tmp/frame-store-server.log 2>&1 </dev/null &
|
||||
echo $! > /tmp/frame-store-server.pid
|
||||
up=
|
||||
for _ in $(seq 1 40); do
|
||||
curl -s -o /dev/null "http://127.0.0.1:$port/store" && { up=1; break; }
|
||||
sleep 0.25
|
||||
done
|
||||
[ -n "$up" ] || { echo "Frame Control's server didn't start on the Frame:" >&2; tail -n 15 /tmp/frame-store-server.log >&2; exit 1; }
|
||||
setsid nohup "${browser[@]}" --ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check \
|
||||
--password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars \
|
||||
--disable-features=Translate,MediaRouter --window-size=1280,800 \
|
||||
"--app=http://127.0.0.1:$port/store?key=$key" >/tmp/frame-store.log 2>&1 </dev/null &
|
||||
# Tag the window so it's its own panel (docs/panels.md).
|
||||
for _ in $(seq 1 60); do
|
||||
sleep 0.5
|
||||
for win in $(xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}'); do
|
||||
xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "APK Store" || continue
|
||||
if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
|
||||
echo "panel valve.steam.desktopgame.$appid window $win"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "Chromium started, but its window didn't appear within 30 s:" >&2
|
||||
tail -n 15 /tmp/frame-store.log >&2
|
||||
exit 1
|
||||
EOF
|
||||
)
|
||||
|
||||
case "$cmd" in
|
||||
open)
|
||||
version=$(deploy | tail -n 1)
|
||||
key=$(openssl rand -hex 16)
|
||||
ssh "$FRAME_ALIAS" bash -s -- "$REMOTE/$version" "$PORT" "$APPID" "$key" <<<"$OPEN"
|
||||
;;
|
||||
close)
|
||||
ssh "$FRAME_ALIAS" 'pkill -f "[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) " || true
|
||||
pkill -f "[s]tore-server/[^/ ]*/ui/server[.]py" || true
|
||||
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null; rm -f /tmp/frame-store-server.pid; true'
|
||||
;;
|
||||
status)
|
||||
ssh "$FRAME_ALIAS" "ls $REMOTE 2>/dev/null; curl -s -o /dev/null http://127.0.0.1:$PORT/store && echo 'server up' || echo 'server down'
|
||||
pgrep -af '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) |[s]tore-server/[^/ ]*/ui/server[.]py' | head -n 1 | cut -c1-120"
|
||||
;;
|
||||
*) sed -n '2,15p' "$0"; exit 2 ;;
|
||||
esac
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac or Linux: drive a Windows 11 test VM for Frame Control's Windows build.
|
||||
# The VM is a dockur/windows container on another machine; this reaches it over
|
||||
# SSH through that machine. See docs/testing.md#windows-test-vm.
|
||||
#
|
||||
# Usage: scripts/windows-vm.sh <command> [args]
|
||||
# up | down | status start it (waits for SSH), shut Windows down cleanly, show state
|
||||
# ps '<PowerShell>' run PowerShell as the VM's user
|
||||
# put <file> [<dir>] copy a file in (default: the user's Downloads)
|
||||
# shot <out.png> save the VM's screen
|
||||
# click <x> <y> click screen pixel x,y in the signed-in session
|
||||
# scroll <x> <y> <n> turn the wheel n notches at x,y (negative scrolls down)
|
||||
# keys <key>... type QEMU key names: a, shift-a, ret, tab, esc, spc ...
|
||||
# frame-key add|remove let the VM's Frame Control key into the headset (`frame`
|
||||
# alias) for a test run, then take it out again
|
||||
#
|
||||
# Set WINVM_HOST to the ssh alias of the machine running the container. Optional:
|
||||
# WINVM_CONTAINER (frame-winvm), WINVM_USER (frame), WINVM_PORT (2222: the VM's
|
||||
# sshd, published on that machine's loopback), WINVM_KEY (~/.ssh/id_ed25519_winvm).
|
||||
set -euo pipefail
|
||||
setopt extendedglob
|
||||
|
||||
host=${WINVM_HOST:?set WINVM_HOST to the ssh alias of the machine running the VM}
|
||||
ctr=${WINVM_CONTAINER:-frame-winvm}
|
||||
user=${WINVM_USER:-frame}
|
||||
port=${WINVM_PORT:-2222}
|
||||
key=${WINVM_KEY:-$HOME/.ssh/id_ed25519_winvm}
|
||||
opts=(-o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new
|
||||
-o UserKnownHostsFile=${TMPDIR:-/tmp}/windows-vm-known_hosts -i $key -J $host)
|
||||
TAG=windows-vm-test # comment on the VM's key in the headset's authorized_keys
|
||||
|
||||
die() { print -u2 "windows-vm: $*"; exit 1 }
|
||||
int() { [[ $1 == (-|)<-99999> ]] || die "not a whole number (up to 99999): $1" }
|
||||
# These go into commands run by a shell on the other machine, so keep them plain.
|
||||
for v in $host $ctr $user; do [[ $v == [A-Za-z0-9_.]##[A-Za-z0-9_.-]# ]] || die "not a plain name: $v"; done
|
||||
[[ $port == <1-65535> ]] || die "WINVM_PORT isn't a port: $port"
|
||||
|
||||
# Windows' OpenSSH waits for stdin to close, so it always gets /dev/null. The
|
||||
# script travels UTF-16 base64-encoded, so no quoting survives two shells.
|
||||
vm_ps() {
|
||||
local b64=$(print -rn -- "\$ProgressPreference = 'SilentlyContinue'"$'\n'"$1" |
|
||||
iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n')
|
||||
ssh $opts -p $port $user@127.0.0.1 "powershell -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand $b64" </dev/null
|
||||
}
|
||||
|
||||
# QEMU's monitor inside the container: one command per line on stdin.
|
||||
monitor() { ssh $host "docker exec -i $ctr nc -q 1 -U /run/shm/monitor.sock" >/dev/null }
|
||||
|
||||
# Input has to come from the signed-in desktop session, not SSH's session 0, so
|
||||
# a scheduled task running as the user replays one click or wheel turn written
|
||||
# to input.txt, then deletes the file to say it's done. Any error stops it
|
||||
# before that, so the caller times out instead of reporting a click.
|
||||
INPUT_PS1='$ErrorActionPreference = "Stop"
|
||||
$a = (Get-Content "$PSScriptRoot\input.txt").Trim() -split "\s+"
|
||||
Add-Type -Namespace WinVm -Name Input -MemberDefinition @"
|
||||
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, System.IntPtr extra);
|
||||
"@
|
||||
[WinVm.Input]::SetProcessDPIAware() | Out-Null
|
||||
if (-not [WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1])) { throw "SetCursorPos failed" }
|
||||
Start-Sleep -Milliseconds 150
|
||||
if ($a[2] -eq "click") {
|
||||
[WinVm.Input]::mouse_event(0x2, 0, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 60
|
||||
[WinVm.Input]::mouse_event(0x4, 0, 0, 0, [IntPtr]::Zero)
|
||||
} else { [WinVm.Input]::mouse_event(0x800, 0, 0, 120 * [int]$a[3], [IntPtr]::Zero) }
|
||||
Remove-Item "$PSScriptRoot\input.txt"'
|
||||
|
||||
pointer() { # x y click|wheel [notches]
|
||||
local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n')
|
||||
vm_ps '$ErrorActionPreference = "Stop"
|
||||
$d = Join-Path $env:LOCALAPPDATA "windows-vm"; $req = "$d\input.txt"; $mine = $false
|
||||
# Giving up: end the helper first, or it could wake up and act in a later request.
|
||||
function Abandon {
|
||||
Stop-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue
|
||||
foreach ($i in 1..25) {
|
||||
if ((Get-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue).State -ne "Running") { break }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
Remove-Item $req -ErrorAction SilentlyContinue
|
||||
}
|
||||
trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($mine) { Abandon }; exit 1 }
|
||||
# One request at a time: the helper, the task and input.txt are shared. Windows
|
||||
# frees the mutex when this process ends, however it ends.
|
||||
$lock = New-Object Threading.Mutex($false, "windows-vm-input")
|
||||
try { $mine = $lock.WaitOne(15000) } catch [Threading.AbandonedMutexException] { $mine = $true }
|
||||
if (-not $mine) { [Console]::Error.WriteLine("windows-vm: another click or scroll is still in progress"); exit 1 }
|
||||
New-Item -ItemType Directory -Force $d | Out-Null
|
||||
[IO.File]::WriteAllText($req, "'"$*"'")
|
||||
[IO.File]::WriteAllText("$d\input.ps1", [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'$b64'")))
|
||||
$act = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$d\input.ps1`""
|
||||
$who = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
|
||||
Register-ScheduledTask -TaskName WindowsVmInput -Action $act -Principal $who -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName WindowsVmInput
|
||||
foreach ($i in 1..50) { if (-not (Test-Path $req)) { exit 0 }; Start-Sleep -Milliseconds 200 }
|
||||
Abandon
|
||||
[Console]::Error.WriteLine("windows-vm: no input after 10 s: is $env:USERNAME signed in on the VM screen, and is x,y on it?"); exit 1'
|
||||
}
|
||||
|
||||
(( $# )) || die "usage: see the top of $0"
|
||||
cmd=$1; shift
|
||||
case $cmd in
|
||||
up)
|
||||
ssh $host "docker start $ctr" >/dev/null
|
||||
for i in {1..60}; do
|
||||
vm_ps 'exit 0' 2>/dev/null && { print "up"; exit 0 }
|
||||
sleep 5
|
||||
done
|
||||
die "Windows didn't answer on SSH within 5 minutes" ;;
|
||||
down) # the container turns SIGTERM into an ACPI shutdown and waits for Windows
|
||||
ssh $host "docker stop -t 150 $ctr" >/dev/null && print "down" ;;
|
||||
status) ssh $host "docker ps -a --filter 'name=^$ctr\$' --format '{{.Names}}: {{.Status}}'" ;;
|
||||
ps) (( $# == 1 )) || die "usage: ps '<PowerShell>'"; vm_ps "$1" ;;
|
||||
put)
|
||||
[[ -f ${1:-} ]] || die "usage: put <file> [<dir>]"
|
||||
scp -q $opts -P $port $1 "$user@127.0.0.1:${2:-C:/Users/$user/Downloads}/${1:t}" </dev/null ;;
|
||||
shot)
|
||||
(( $# == 1 )) || die "usage: shot <out.png>"
|
||||
print "screendump /tmp/windows-vm-shot.ppm" | monitor
|
||||
sleep 1
|
||||
ssh $host "docker exec $ctr sh -c 'cat /tmp/windows-vm-shot.ppm && rm /tmp/windows-vm-shot.ppm'" | python3 -c '
|
||||
import re, struct, sys, zlib
|
||||
d = sys.stdin.buffer.read()
|
||||
m = re.match(rb"P6\s+(\d+)\s+(\d+)\s+255\s", d) or sys.exit("windows-vm: no screen dump")
|
||||
w, h = int(m[1]), int(m[2]); px = d[m.end():]
|
||||
raw = b"".join(b"\0" + px[y * w * 3:(y + 1) * w * 3] for y in range(h))
|
||||
chunk = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b))
|
||||
open(sys.argv[1], "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
|
||||
' $1
|
||||
print $1 ;;
|
||||
click) (( $# == 2 )) || die "usage: click <x> <y>"; int $1; int $2; pointer $1 $2 click ;;
|
||||
scroll) (( $# == 3 )) || die "usage: scroll <x> <y> <n>"; int $1; int $2; int $3; pointer $1 $2 wheel $3 ;;
|
||||
keys)
|
||||
(( $# )) || die "usage: keys <key>..."
|
||||
for k; do [[ $k == [a-z0-9_.,/=-]## ]] || die "not a QEMU key name: $k"; done
|
||||
for k; do print "sendkey $k"; done | monitor ;;
|
||||
frame-key)
|
||||
[[ ${1:-} == (add|remove) ]] || die "usage: frame-key add|remove"
|
||||
pub=(${=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r')})
|
||||
[[ ${pub[1]:-} == ssh-ed25519 && ${pub[2]:-} == [A-Za-z0-9+/=]## ]] ||
|
||||
die "the VM has no Frame Control key yet: run Set Up Connection in the app first"
|
||||
# $1: add|remove, $2: the key's base64, $3: the exact line this script owns.
|
||||
ssh frame "sh -s -- $1 '$pub[2]' '$pub[1] $pub[2] $TAG'" <<'EOF'
|
||||
f=~/.ssh/authorized_keys
|
||||
if [ "$1" = add ]; then
|
||||
if grep -qxF "$3" "$f"; then exit 0; fi
|
||||
if grep -qF "$2" "$f"; then echo "the headset already trusts this key through another entry; left as it is"; exit 0; fi
|
||||
[ -z "$(tail -c 1 "$f")" ] || echo >> "$f" # a last line without a newline would swallow ours
|
||||
echo "$3" >> "$f"
|
||||
else
|
||||
t=$(mktemp "$f.XXXXXX") || exit 1
|
||||
grep -vxF "$3" "$f" > "$t" # 0: lines left, 1: none left, more: couldn't read or write
|
||||
if [ $? -gt 1 ] || ! chmod 600 "$t" || ! mv "$t" "$f"; then
|
||||
rm -f "$t"; echo "couldn't rewrite $f; it's unchanged" >&2; exit 1
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
print "frame-key $1: done" ;;
|
||||
*) die "unknown command: $cmd (see the top of $0)" ;;
|
||||
esac
|
||||
@@ -64,7 +64,7 @@ function sharedPage({productionApi=false} = {}) {
|
||||
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||||
};
|
||||
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||||
resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
vm.runInContext(`const UI_KEY = 'test-only';
|
||||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
|
||||
@@ -443,20 +443,20 @@ test('every linked workflow selects its real page and section, preserving shortc
|
||||
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
|
||||
let activePage,scrolled;
|
||||
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
||||
const nodes=['home','games','android','tools','devices','control','settings'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
||||
const sections=new Map();
|
||||
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
|
||||
window:{scrollTo(){}},$:id=>{
|
||||
if(!ids.has(id))return null;
|
||||
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
|
||||
if(!sections.has(id))sections.set(id,{hidden:false,classList:{toggle(){}},scrollIntoView(){scrolled=id;}});
|
||||
return sections.get(id);
|
||||
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
|
||||
},live:false,toggleLive(){},scrollToY(){},loadMacView(){},loadPanelSwitcher(){}};
|
||||
const androidStart=html.indexOf('function showAndroidTab(');
|
||||
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
|
||||
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
|
||||
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\n'+html.match(/^PAGES\.push\([^\n]+/m)[0],context);
|
||||
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
|
||||
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
|
||||
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
|
||||
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
|
||||
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||||
if(!href.startsWith('#')||href==='#connection')continue;
|
||||
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
|
||||
@@ -468,4 +468,5 @@ test('every linked workflow selects its real page and section, preserving shortc
|
||||
}
|
||||
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
|
||||
assert.equal(vm.runInContext('PAGES[5]',context),'control');
|
||||
assert.equal(vm.runInContext('PAGES[6]',context),'settings');
|
||||
});
|
||||
@@ -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");
|
||||
@@ -19,8 +19,8 @@ function grab(name) {
|
||||
fail(`unbalanced ${name}`);
|
||||
}
|
||||
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
||||
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails"];
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
|
||||
const code = names.map(grab).join("");
|
||||
|
||||
// A fake page: elements by id. The install panel's elements exist only while
|
||||
@@ -34,8 +34,10 @@ function page(api = async () => ({})) {
|
||||
const document = { activeElement: null, querySelector: () => null };
|
||||
const console = { error() {} };
|
||||
const calls = [];
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); });
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "runJob", "loadAndroid", "act", "window", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); },
|
||||
async (title, key, work) => work(), () => {}, async (title, work) => work(),
|
||||
{ open() { fail("an in-app installation opened an external page"); } });
|
||||
return { els, sourceState, calls, ...fns };
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
|
||||
{
|
||||
const p = page(async () => { throw new Error("offline"); });
|
||||
p.sourceState.apps = [{ name: "Broken", offers: [] }];
|
||||
await p.openStoreApp(0);
|
||||
await p.openStoreApp(0, true);
|
||||
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error");
|
||||
if (p.calls.length) fail("asked for details of a listing that couldn't render");
|
||||
}
|
||||
@@ -90,4 +92,18 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
|
||||
if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request");
|
||||
if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request");
|
||||
}
|
||||
// The featured Install starts a new or failed job, while a job already running or
|
||||
// installed opens its details without another install or a launch command.
|
||||
for (const [job, expected] of [[null, 1], [{ done: false }, 0],
|
||||
[{ done: true, result: { package: offer.package } }, 0], [{ done: true, error: "offline" }, 1]]) {
|
||||
const p = page(async path => path === "/api/sources/install" ? { package: offer.package } : {});
|
||||
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
|
||||
if (job) p.sourceState.jobs.set(`${offer.source}:${offer.id}`, job);
|
||||
await p.openStoreApp(0, true);
|
||||
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('id="detailInstall"')) fail("hero installation didn't open details");
|
||||
const installs = p.calls.filter(([path]) => path === "/api/sources/install");
|
||||
if (installs.length !== expected) fail(`expected ${expected} installation requests, got ${installs.length}`);
|
||||
if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
|
||||
if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
|
||||
}
|
||||
console.log("ok");
|
||||
@@ -19,7 +19,9 @@ class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
with _images._lock:
|
||||
_images._urls.clear()
|
||||
_images._tokens.clear()
|
||||
_images._cache.clear()
|
||||
_images._dns.clear()
|
||||
|
||||
def test_only_registered_source_images_are_fetchable(self):
|
||||
with patch.object(_images, 'fetch') as fetch:
|
||||
@@ -71,9 +73,13 @@ class ArtworkTests(unittest.TestCase):
|
||||
_images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE)
|
||||
|
||||
def test_handles_are_bounded(self):
|
||||
for i in range(4100):
|
||||
_images.register('https://example.com/%d.png' % i)
|
||||
self.assertEqual(len(_images._urls),4096)
|
||||
with patch.object(_images, 'MAX_TOKENS', 50):
|
||||
first = _images.register('https://example.com/0.png')
|
||||
for i in range(1, 60):
|
||||
_images.register('https://example.com/%d.png' % i)
|
||||
self.assertEqual((len(_images._urls), len(_images._tokens)), (50, 50))
|
||||
self.assertNotEqual(_images.register('https://example.com/0.png'), first) # forgotten, so a new handle
|
||||
self.assertEqual(_images.register('https://example.com/59.png'), _images.register('https://example.com/59.png'))
|
||||
|
||||
def test_plain_language_verdict_is_evidence_based(self):
|
||||
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
|
||||
|
||||
@@ -122,7 +122,7 @@ class SearchTests(SettingsTest):
|
||||
break
|
||||
time.sleep(.01)
|
||||
self.assertTrue(arrived[1]['event'].wait(2))
|
||||
self.assertEqual(arrived[1]['query'], ('second', 50))
|
||||
self.assertEqual(arrived[1]['query'][:2], ('second', 50))
|
||||
|
||||
def test_set_enabled_does_not_hold_search_lock_in_source(self):
|
||||
free = []
|
||||
@@ -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')
|
||||
|
||||
@@ -56,6 +56,48 @@ const log = ()=>{}, toast = ()=>{};
|
||||
assert.equal($('sessionMinutes').disabled,false);
|
||||
assert.equal($('sessionCancel').disabled,true);
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_a_finished_session_leaves_the_defaults(self):
|
||||
"""A one-minute test session that has ended mustn't fill the form with 1 / 0 / 0."""
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
|
||||
setup = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const elements = new Map();
|
||||
const $ = id => {
|
||||
// The form's own defaults, as in the page's HTML.
|
||||
const value = {sessionMinutes:'30', breakMinutes:'20', stillMinutes:'30'}[id] || '';
|
||||
if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value, defaultValue:value,
|
||||
checked:true, defaultChecked:true, type: /Alert$/.test(id) ? 'checkbox' : 'number'});
|
||||
return elements.get(id);
|
||||
};
|
||||
const window = {frameApp:{notify:async()=>{}}};
|
||||
const log = ()=>{}, toast = ()=>{};
|
||||
'''
|
||||
checks = r'''
|
||||
renderComfort({id:'old', active:false, time:100, options:{minutes:1,breakMinutes:0,stillMinutes:0,batteryAlert:false,heatAlert:false},
|
||||
events:[{id:'old:1',kind:'finished',time:90,message:'Session ended'}]});
|
||||
assert.equal($('sessionMinutes').value, '30');
|
||||
assert.equal($('breakMinutes').value, '20');
|
||||
assert.equal($('batteryAlert').checked, true);
|
||||
assert.match($('comfortEvents').textContent, /Session ended/);
|
||||
assert.notEqual($('comfortEvents').textContent, 'Session ended'); // it carries a time
|
||||
renderComfort({id:'new', active:true, time:200, remaining:600, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, events:[]});
|
||||
assert.equal($('sessionMinutes').value, 45); // a running session shows its own settings
|
||||
assert.equal($('heatAlert').checked, false);
|
||||
// The page stays open while that session ends: the next one starts from the defaults again.
|
||||
renderComfort({id:'new', active:false, time:900, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false},
|
||||
events:[{id:'new:1',kind:'finished',time:899,message:'Session ended'}]});
|
||||
assert.equal($('sessionMinutes').value, '30');
|
||||
assert.equal($('breakMinutes').value, '20');
|
||||
assert.equal($('heatAlert').checked, true);
|
||||
renderComfort({id:'new', active:false, time:905, options:{minutes:45}, events:[]});
|
||||
$('sessionMinutes').value = '50'; // what's typed for the next session
|
||||
renderComfort({id:'new', active:false, time:910, options:{minutes:45}, events:[]});
|
||||
assert.equal($('sessionMinutes').value, '50'); // later polls leave it alone
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -323,6 +323,20 @@ class Pins(Base):
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
def test_an_address_added_first_wins_on_its_own_network(self):
|
||||
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
|
||||
# worked here before. The LAN address has to go ahead of it to be used at home.
|
||||
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
|
||||
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
|
||||
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
|
||||
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
|
||||
addrs = self.reg.get(d["id"])["addresses"]
|
||||
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
|
||||
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
|
||||
self.assertEqual(at_home[0], "192.168.1.40")
|
||||
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
|
||||
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
|
||||
|
||||
def test_address_editing(self):
|
||||
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,7 +121,30 @@ class LauncherTests(unittest.TestCase):
|
||||
FIXTURES = ROOT / 'tests/fixtures/library'
|
||||
|
||||
|
||||
def wait_for_idle_resolvers(timeout=10):
|
||||
# Name lookups that outlast their deadline give their slot back from their own thread,
|
||||
# which a busy runner may not schedule before the next test; wait until all are back.
|
||||
from apk_sources import _images
|
||||
held = []
|
||||
try:
|
||||
end = time.monotonic() + timeout
|
||||
while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())):
|
||||
held.append(1)
|
||||
if len(held) == 4:
|
||||
return
|
||||
raise AssertionError('artwork name lookups from an earlier test are still running')
|
||||
finally:
|
||||
for _ in held:
|
||||
_images._resolvers.release()
|
||||
|
||||
|
||||
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
|
||||
data = (FIXTURES / 'icon.png').read_bytes()
|
||||
@@ -232,10 +255,7 @@ class ArtworkTests(unittest.TestCase):
|
||||
self.assertEqual(sum('Too many' in e for e in errors), 2)
|
||||
finally:
|
||||
gate.set()
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
|
||||
time.sleep(0.01)
|
||||
_images._resolvers.release() # the stuck lookups finished and gave their slots back
|
||||
wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back
|
||||
|
||||
def test_resolver_slot_released_when_thread_cannot_start(self):
|
||||
from apk_sources import _images
|
||||
@@ -243,10 +263,14 @@ class ArtworkTests(unittest.TestCase):
|
||||
for _ in range(6):
|
||||
with self.assertRaises(RuntimeError):
|
||||
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
|
||||
for _ in range(4): # every slot came back
|
||||
self.assertTrue(_images._resolvers.acquire(blocking=False))
|
||||
for _ in range(4):
|
||||
_images._resolvers.release()
|
||||
held = 0
|
||||
try:
|
||||
while held < 4 and _images._resolvers.acquire(blocking=False):
|
||||
held += 1
|
||||
self.assertEqual(held, 4) # every slot came back
|
||||
finally:
|
||||
for _ in range(held): # even on failure, so later tests don't inherit the leak
|
||||
_images._resolvers.release()
|
||||
|
||||
def test_deadline_covers_name_resolution(self):
|
||||
import threading
|
||||
|
||||
@@ -897,6 +897,47 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual(self.routes, [])
|
||||
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
||||
|
||||
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
|
||||
d = self.device("nothing.invalid")
|
||||
entered = {1: threading.Event(), 2: threading.Event()}
|
||||
release = {1: threading.Event(), 2: threading.Event()}
|
||||
calls = []
|
||||
|
||||
def probe(host, port, update=None):
|
||||
calls.append(host)
|
||||
n = len(calls)
|
||||
entered[n].set()
|
||||
release[n].wait(10)
|
||||
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
|
||||
|
||||
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
|
||||
with mock.patch.object(fl, "probe", probe):
|
||||
try:
|
||||
tests[0].start()
|
||||
self.assertTrue(entered[1].wait(5), "the first test never probed")
|
||||
tests[1].start()
|
||||
self.assertTrue(entered[2].wait(5), "the second test never probed")
|
||||
# The first finishes while the second is still probing: it mustn't show its
|
||||
# rows or mark the second done.
|
||||
release[1].set()
|
||||
tests[0].join(10)
|
||||
self.assertFalse(tests[0].is_alive())
|
||||
running = self.link.snapshot()["tests"][d["id"]]
|
||||
self.assertFalse(running["done"])
|
||||
self.assertEqual(running["rows"][0]["detail"], "Waiting")
|
||||
release[2].set()
|
||||
tests[1].join(10)
|
||||
self.assertFalse(tests[1].is_alive())
|
||||
finally:
|
||||
for e in release.values():
|
||||
e.set()
|
||||
for t in tests:
|
||||
if t.ident: # started
|
||||
t.join(10)
|
||||
result = self.link.snapshot()["tests"][d["id"]]
|
||||
self.assertTrue(result["done"])
|
||||
self.assertEqual(result["rows"][0]["detail"], "test 2")
|
||||
|
||||
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
|
||||
@@ -56,18 +56,19 @@ class LinkUI(unittest.TestCase):
|
||||
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_the_pill_leads_with_the_reason(self):
|
||||
# The header pill cut off "Offline: checking the headset's identity failed · retry in…":
|
||||
# the reason comes first, in a few words, and only the countdown gets cut.
|
||||
def test_the_pill_keeps_the_failure_detail_in_its_tooltip(self):
|
||||
# The connection redesign keeps the state and retry countdown in the pill,
|
||||
# with the full failure explanation in its tooltip and details dialog.
|
||||
self.run_page(r'''
|
||||
link.s = changed;
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>SSH identity changed · retry in 12 s<\/span>/);
|
||||
assert.equal($('conn').title, 'Office Frame is offline: SSH identity changed · retry in 12 s. Click for details.');
|
||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
|
||||
assert.ok($('conn').title.includes(changed.error.message));
|
||||
// Without a short reason: the stage that failed.
|
||||
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<span>Checking the headset's identity failed<\/span>/);
|
||||
assert.match($('conn').innerHTML, /<span>Offline<\/span>/);
|
||||
assert.ok($('conn').title.includes('Something.'));
|
||||
''')
|
||||
|
||||
def test_the_banner_never_doubles_a_full_stop(self):
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""The page's inline scripts share one global scope, so a second top-level function or
|
||||
variable with a name already used replaces the first everywhere, silently."""
|
||||
import html.parser
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
# Compiles the scripts as one strict-mode block. There, functions are block scoped like let
|
||||
# and const, so V8 itself rejects a name declared twice in the shared scope (however it's
|
||||
# indented or declared: function, class, let, const, destructuring), while helpers with the
|
||||
# same name inside different functions stay legal. var is the exception (declaring one twice
|
||||
# is allowed), so the page doesn't use var at all: Node's own copy of the acorn parser finds
|
||||
# real var declarations, not the word in comments, strings or CSS var().
|
||||
DUPLICATES = r'''
|
||||
const vm = require('vm');
|
||||
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
try { new vm.Script('"use strict"; {\n' + scripts.join('\n;\n') + '\n}'); console.log('ok'); }
|
||||
catch (e) { console.log(e.message); }
|
||||
'''
|
||||
VARS = r'''
|
||||
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
let acorn, walk;
|
||||
try {
|
||||
acorn = require('internal/deps/acorn/acorn/dist/acorn');
|
||||
walk = require('internal/deps/acorn/acorn-walk/dist/walk');
|
||||
} catch { console.log('no parser'); process.exit(0); }
|
||||
for (const code of scripts) {
|
||||
let found = null;
|
||||
try {
|
||||
walk.simple(acorn.parse(code, { ecmaVersion: 'latest' }), {
|
||||
VariableDeclaration(n) { if (n.kind === 'var' && !found) found = code.slice(n.start, n.end).slice(0, 40); },
|
||||
});
|
||||
} catch (e) { console.log(e.message); process.exit(0); }
|
||||
if (found) { console.log('declares with var: ' + found); process.exit(0); }
|
||||
}
|
||||
console.log('ok');
|
||||
'''
|
||||
|
||||
|
||||
def node(script, scripts, *flags):
|
||||
r = subprocess.run(['node', *flags, '-e', script], input=json.dumps(scripts), capture_output=True, text=True)
|
||||
return r.stdout.strip() or r.stderr.strip()
|
||||
|
||||
|
||||
def check(scripts):
|
||||
"""'ok', or V8's message for the first name declared twice."""
|
||||
return node(DUPLICATES, scripts)
|
||||
|
||||
|
||||
def check_vars(scripts):
|
||||
"""'ok', or the first var declaration. Skips where Node doesn't include acorn."""
|
||||
out = node(VARS, scripts, '--expose-internals')
|
||||
if out == 'no parser':
|
||||
raise unittest.SkipTest("this Node doesn't include acorn")
|
||||
return out
|
||||
|
||||
|
||||
class ClassicScripts(html.parser.HTMLParser):
|
||||
"""The page's inline classic scripts: no src, and no type other than a JavaScript one (a
|
||||
module has its own scope, and JSON data isn't code). The types a browser runs as script:
|
||||
https://mimesniff.spec.whatwg.org/#javascript-mime-type"""
|
||||
JS = {'', 'application/ecmascript', 'application/javascript', 'application/x-ecmascript',
|
||||
'application/x-javascript', 'text/ecmascript', 'text/javascript', 'text/javascript1.0',
|
||||
'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4',
|
||||
'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript'}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.scripts, self.current = [], None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'script':
|
||||
a = dict(attrs)
|
||||
inline = 'src' not in a and (a.get('type') or '').strip().lower() in self.JS
|
||||
self.current = [] if inline else None
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.current is not None:
|
||||
self.current.append(data)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == 'script' and self.current is not None:
|
||||
self.scripts.append(''.join(self.current))
|
||||
if tag == 'script':
|
||||
self.current = None
|
||||
|
||||
|
||||
def classic_scripts(page):
|
||||
p = ClassicScripts()
|
||||
p.feed(page)
|
||||
return p.scripts
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node parses the page scripts')
|
||||
class PageScripts(unittest.TestCase):
|
||||
def test_no_top_level_name_is_declared_twice(self):
|
||||
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
|
||||
self.assertGreaterEqual(len(scripts), 2)
|
||||
self.assertEqual(check(scripts), 'ok')
|
||||
|
||||
def test_the_page_declares_nothing_with_var(self):
|
||||
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
|
||||
self.assertEqual(check_vars(scripts), 'ok')
|
||||
|
||||
def test_the_check_finds_what_it_should(self):
|
||||
twice = {
|
||||
'indented function': ['function loadPanels() {}', ' async function loadPanels() {}'],
|
||||
'class': ['class Panel {}', 'class Panel {}'],
|
||||
'destructured': ['const { a, b } = {};', 'let [b] = [];'],
|
||||
'later declarator': ['let x = 1;', 'const y = 2, x = 3;'],
|
||||
'function and const': ['function f() {}', 'const f = 1;'],
|
||||
}
|
||||
for what, scripts in twice.items():
|
||||
with self.subTest(what):
|
||||
self.assertIn('already been declared', check(scripts))
|
||||
helpers = ['function a() { function help() {} }', 'function b() { const help = 1; }']
|
||||
self.assertEqual(check(helpers), 'ok')
|
||||
|
||||
def test_the_var_check_finds_what_it_should(self):
|
||||
# var: legal to declare twice, so not allowed at all; however it's written.
|
||||
for code in ['var x = 1;', 'var/*c*/x = 1;', 'var {x} = {x: 1};', 'function f() { var y; }']:
|
||||
with self.subTest(code):
|
||||
self.assertIn('declares with var', check_vars([code]))
|
||||
# The word var elsewhere is fine.
|
||||
text = ['// var x\nconst a = "var y", b = `var ${a}`, c = "color: var(--blue)", d = {}.var;']
|
||||
self.assertEqual(check_vars(text), 'ok')
|
||||
|
||||
def test_only_inline_classic_scripts_are_checked(self):
|
||||
page = ('<script>let a;</script><script type="module">export const m = 1;</script>'
|
||||
'<script type="application/json">{"b": 1}</script><script src = "x.js"></script>'
|
||||
'<script data-src="y" type="text/javascript">let c;</script>'
|
||||
'<script type="text/ecmascript">let d;</script><script type=" Application/X-JavaScript ">let e;</script>')
|
||||
self.assertEqual(classic_scripts(page), ['let a;', 'let c;', 'let d;', 'let e;'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""The page calls showPage() while its first script is still running, before names declared
|
||||
later (in that script or the second one) exist. Touching one of them there throws, and the
|
||||
rest of the page's setup never runs: opening Frame Control at #games did exactly that."""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab']
|
||||
|
||||
RUN = r'''
|
||||
const vm = require('vm');
|
||||
const {code, hashes} = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
const failures = [];
|
||||
for (const hash of hashes) {
|
||||
const context = vm.createContext({
|
||||
location: {hash}, document: {querySelectorAll: () => [], title: ''}, live: false,
|
||||
});
|
||||
try { vm.runInContext(code, context); }
|
||||
catch (e) { failures.push(`${hash}: ${e.message}`); }
|
||||
}
|
||||
console.log(JSON.stringify(failures));
|
||||
'''
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node runs the page code')
|
||||
class PageStartup(unittest.TestCase):
|
||||
def test_opening_any_page_or_section_at_startup_runs(self):
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
first, second = re.findall(r'<script>(.*?)</script>', page, re.S)[:2]
|
||||
tables = first[first.index('const PAGES ='):first.index('let page =')]
|
||||
start = first.index('function showPage(')
|
||||
show = first[start:first.index('\n}\n', start) + 3]
|
||||
# Everything declared after the startup call is still uninitialised when it runs.
|
||||
call = re.search(r'^showPage\(\);', first, re.M).end()
|
||||
later = re.findall(r'^(?:const|let)\s+(\w+)', first[call:] + second, re.M)
|
||||
later = [n for n in dict.fromkeys(later) if n not in STUBS and n not in ('PAGES', 'SECTION_PAGE', 'page')]
|
||||
self.assertIn('link', later) # the name that broke #games
|
||||
stubs = ''.join(f'function {n}() {{ return {{ classList: {{ toggle() {{}} }}, scrollIntoView() {{}} }}; }}\n'
|
||||
for n in STUBS if n != '$')
|
||||
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + show +
|
||||
'showPage();\n' + ''.join(f'let {n};\n' for n in later))
|
||||
hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#control', '#nowhere']
|
||||
hashes += ['#' + k for k in re.findall(r'(\w+): "', tables)]
|
||||
r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertEqual(json.loads(r.stdout), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -88,6 +88,12 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertIn(mime, headers['Content-Type'])
|
||||
self.assertIn(marker, payload)
|
||||
|
||||
def test_store_page_served_with_anti_framing_headers(self):
|
||||
status, headers, payload = self.request("GET", "/store")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(headers["X-Frame-Options"], "DENY")
|
||||
self.assertIn(b"<title>APK Store</title>", payload)
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
+4
-2
@@ -740,7 +740,9 @@ class Registry:
|
||||
return a
|
||||
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
||||
|
||||
def add_address(self, device_id, host, kind=None, label=""):
|
||||
def add_address(self, device_id, host, kind=None, label="", first=False):
|
||||
"""Add an address at the end of the list, or at the front (first=True), where the
|
||||
user's order makes it win over the others that work on the same network."""
|
||||
with self._changing():
|
||||
d = self._find(device_id)
|
||||
a = new_address(host, kind, label)
|
||||
@@ -748,7 +750,7 @@ class Registry:
|
||||
raise DeviceError(f"{a['host']} is already on the list")
|
||||
if len(d["addresses"]) >= 32:
|
||||
raise DeviceError("That's enough addresses for one headset")
|
||||
d["addresses"].append(a)
|
||||
d["addresses"].insert(0 if first else len(d["addresses"]), a)
|
||||
self.save()
|
||||
return copy.deepcopy(a)
|
||||
|
||||
|
||||
+7
-1
@@ -356,6 +356,7 @@ class Link:
|
||||
self.version = 0
|
||||
self.stopped = False
|
||||
self.kicks = [] # reasons someone asked for a (re)connect
|
||||
self.test_gen = {} # device id -> its newest test of the addresses (see test())
|
||||
self.busy = False # the loop is handling kicks
|
||||
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
|
||||
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
|
||||
@@ -1306,9 +1307,13 @@ class Link:
|
||||
started = now()
|
||||
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
|
||||
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
|
||||
with self.cond:
|
||||
gen = self.test_gen[device_id] = self.test_gen.get(device_id, 0) + 1
|
||||
|
||||
def put(**fields):
|
||||
with self.cond:
|
||||
if gen != self.test_gen[device_id]:
|
||||
return # a newer test has started: its results are the ones to show
|
||||
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
|
||||
self.version += 1
|
||||
self.cond.notify_all()
|
||||
@@ -1617,7 +1622,8 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
|
||||
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
|
||||
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
|
||||
elif action == "address-add":
|
||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
|
||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "",
|
||||
first=body.get("first") is True)
|
||||
if is_active and link.state["phase"] == "failed":
|
||||
link.kick("retry")
|
||||
msg = f"Added {a['host']}"
|
||||
|
||||
+898
-319
File diff suppressed because it is too large.
Load diff
+40
-7
@@ -2414,16 +2414,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)
|
||||
|
||||
@@ -2452,19 +2465,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))
|
||||
@@ -2655,6 +2680,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":
|
||||
@@ -2704,7 +2732,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":
|
||||
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>APK Store</title>
|
||||
<!-- Frame Control's Android store as a panel in the headset (docs/apks.md →
|
||||
"The store in the headset"). Served by ui/server.py running on the Frame
|
||||
(FRAME_LOCAL=1), opened by scripts/store-on-frame.sh at 1280×800. The same
|
||||
sources, categories and artwork as the Store in Frame Control (/api/search,
|
||||
ui/apk_sources/_categories.py), drawn for the laser: big targets, pages
|
||||
instead of drag-scrolling (the panel gets mouse clicks, so a drag would
|
||||
select text), the thumbstick (a mouse wheel) turns pages, and an on-screen
|
||||
keyboard for search. -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1722; --rail: #131d2a; --card: #1b2938; --card-hi: #23364a; --line: #ffffff14;
|
||||
--text: #e3e8ee; --bright: #fff; --dim: #93a4ba; --blue: #1a9fff; --link: #66c0f4;
|
||||
--good: #8ed36b; --bad: #ef7a64;
|
||||
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
|
||||
--play: linear-gradient(to right, #75b022 5%, #588a1b 95%);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body { margin: 0; height: 100%; background: radial-gradient(ellipse at 70% -20%, #1d3a5a 0%, transparent 60%), var(--bg);
|
||||
color: var(--text); font: 19px/1.35 "Motiva Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: hidden; user-select: none; -webkit-user-select: none; }
|
||||
button { font: inherit; color: inherit; background: #ffffff0d; border: 1px solid var(--line); border-radius: 12px;
|
||||
min-height: 52px; padding: 0 20px; cursor: pointer; }
|
||||
button:hover { background: #ffffff1c; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
button.primary { background: var(--action); border: 0; font-weight: 600; color: #fff; }
|
||||
button.play { background: var(--play); border: 0; font-weight: 600; color: #fff; }
|
||||
button.danger { color: var(--bad); }
|
||||
svg { width: 22px; height: 22px; flex: none; }
|
||||
.app { display: grid; grid-template-columns: 236px 1fr; height: 100%; }
|
||||
/* Rail */
|
||||
.rail { background: #0c131ccc; border-right: 1px solid var(--line); padding: 18px 12px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.rail h1 { font-size: 22px; margin: 2px 10px 12px; display: flex; align-items: center; gap: 10px; color: var(--bright); }
|
||||
.rail h1 svg { width: 28px; height: 28px; color: var(--blue); }
|
||||
.rail button { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 47px; text-align: left; border: 0;
|
||||
background: transparent; color: #b5c0cb; font-size: 17px; font-weight: 500; padding: 0 12px; border-radius: 10px; }
|
||||
.rail button:hover { background: #ffffff10; color: var(--bright); }
|
||||
.rail button[aria-pressed="true"] { background: #1a9fff2e; color: var(--bright); box-shadow: inset 4px 0 0 var(--blue); }
|
||||
.rail .sep { height: 1px; background: var(--line); margin: 6px 8px; }
|
||||
.rail .n { margin-left: auto; font-size: 13px; color: #c6e2f5; background: #1a9fff2e; border-radius: 10px; padding: 1px 8px; }
|
||||
.rail .n:empty { display: none; }
|
||||
.rail label { margin-top: auto; display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 15px; color: var(--dim); cursor: pointer; }
|
||||
.rail input { width: 22px; height: 22px; accent-color: var(--blue); margin: 0; }
|
||||
/* Main */
|
||||
main { display: flex; flex-direction: column; min-width: 0; padding: 18px 22px; gap: 12px; }
|
||||
.bar { display: flex; gap: 12px; align-items: center; }
|
||||
.search { flex: 1; display: flex; align-items: center; gap: 12px; background: #0c1520; border: 1px solid var(--line);
|
||||
border-radius: 12px; min-height: 54px; padding: 0 18px; color: var(--dim); }
|
||||
.search input { flex: 1; background: none; border: 0; color: var(--text); font: inherit; outline: none; min-width: 0; }
|
||||
.search input::placeholder { color: var(--dim); }
|
||||
.heading { display: flex; align-items: baseline; gap: 14px; min-height: 30px; }
|
||||
.heading b { color: var(--bright); font-size: 25px; letter-spacing: -.4px; }
|
||||
.heading span { color: var(--dim); font-size: 16px; }
|
||||
.heading .note { margin-left: auto; font-size: 14px; color: var(--dim); max-width: 520px; text-align: right; }
|
||||
.content { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
.grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.grid.one-row { grid-template-rows: minmax(0, 1fr); }
|
||||
.card { display: flex; flex-direction: column; padding: 0; overflow: hidden; text-align: left; min-height: 0; border-radius: 14px;
|
||||
background: var(--card); border: 1px solid var(--line); }
|
||||
.card:hover { background: var(--card-hi); border-color: #66c0f480; }
|
||||
.card .art { flex: 0 0 52%; }
|
||||
.card .body { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px 12px; min-height: 0; flex: 1; width: 100%; }
|
||||
.card .title { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.card .title strong { font-size: 17px; color: var(--bright); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||
.card .sum { color: var(--dim); font-size: 14px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card .foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 13px; color: var(--dim); }
|
||||
/* Artwork: a banner, else screenshots side by side, else the icon over its own blur, else an initial. */
|
||||
.art { position: relative; display: block; overflow: hidden; width: 100%;
|
||||
background: radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%), transparent 70%), linear-gradient(140deg, hsl(var(--hue) 35% 18%), #101827); }
|
||||
.art > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.art .initial { position: absolute; inset: 0; display: grid; place-items: center; font-size: 64px; font-weight: 700; color: #ffffff45; }
|
||||
.art.shots { display: flex; gap: 6px; justify-content: center; padding: 8px 8px 0; }
|
||||
.art.shots > img { position: relative; inset: auto; width: auto; max-width: 31%; height: 100%; object-position: top; border-radius: 6px 6px 0 0; box-shadow: 0 4px 14px #0008; }
|
||||
.art.iconart > .backdrop { position: absolute; inset: -30%; width: 160%; height: 160%; object-fit: cover; filter: blur(28px) saturate(1.4) brightness(.55); }
|
||||
.art.iconart > .mark { position: absolute; left: 50%; top: 50%; width: 36%; height: auto; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 22%; box-shadow: 0 8px 24px #0009; object-fit: cover; }
|
||||
.art.shots .initial, .art.iconart .initial { display: none; }
|
||||
.art .vr { position: absolute; z-index: 2; top: 8px; left: 8px; }
|
||||
.icon { position: relative; display: inline-grid; place-items: center; width: 38px; height: 38px; border-radius: 9px; overflow: hidden; flex: none;
|
||||
background: hsl(var(--hue) 35% 28%); color: #fff; font-size: 18px; font-weight: 600; }
|
||||
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.vr { font-size: 12px; font-weight: 700; letter-spacing: .8px; color: #0e141b; background: #c2e7ff; border-radius: 5px; padding: 2px 7px; }
|
||||
.fit { font-size: 12px; font-weight: 600; border-radius: 5px; padding: 2px 8px; white-space: nowrap; }
|
||||
.fit.works { color: #c3efb2; background: #59bf4029; } .fit.ready { color: #bfe3fa; background: #1a9fff26; } .fit.blocked { color: #f3b2a6; background: #d9412629; }
|
||||
.tag { font-size: 12px; font-weight: 600; color: #d6f5c8; background: #2d5a1f; border-radius: 10px; padding: 2px 9px; white-space: nowrap; }
|
||||
/* Home */
|
||||
.hero { position: relative; flex: 0 0 42%; padding: 0; border: 0; border-radius: 16px; overflow: hidden; text-align: left; isolation: isolate; }
|
||||
.hero .art { position: absolute; inset: 0; height: 100%; }
|
||||
.hero::after { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(90deg, #081421f0 0%, #08142180 50%, #08142100 85%); }
|
||||
.hero .copy { position: relative; z-index: 2; padding: 26px 30px; max-width: 560px; display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
.hero .eyebrow { display: flex; gap: 10px; align-items: center; font-size: 13px; font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase; color: #c2e7ff; }
|
||||
.hero strong { font-size: 38px; color: #fff; line-height: 1.05; letter-spacing: -.8px; }
|
||||
.hero .blurb { color: #e0e8ef; font-size: 17px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.hero .cta { margin-top: 4px; background: #ffffff22; border: 1px solid #ffffff44; border-radius: 10px; padding: 9px 18px; font-size: 16px; color: #fff; }
|
||||
/* Pager */
|
||||
.pager { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.pager button { min-width: 180px; }
|
||||
.dots { display: flex; gap: 8px; align-items: center; color: var(--dim); font-size: 15px; }
|
||||
.empty { grid-column: 1 / -1; grid-row: 1 / -1; display: grid; place-items: center; align-content: center; gap: 14px; color: var(--dim); text-align: center; padding: 30px; }
|
||||
.empty h2 { margin: 0; color: var(--bright); font-size: 24px; }
|
||||
.skeleton { border-radius: 14px; background: linear-gradient(100deg, #1b2938 30%, #24364a 50%, #1b2938 70%); background-size: 300% 100%; animation: shimmer 1.4s infinite; }
|
||||
@keyframes shimmer { to { background-position: -150% 0; } }
|
||||
/* Detail sheet and keyboard sit over everything. */
|
||||
.overlay { position: fixed; inset: 0; background: #000b; display: grid; place-items: center; z-index: 10; }
|
||||
.sheet { background: #16212e; border: 1px solid var(--line); border-radius: 22px; width: 1000px; max-height: 740px; overflow: hidden;
|
||||
display: flex; flex-direction: column; box-shadow: 0 30px 80px #000c; }
|
||||
.sheet > .art { flex: 0 0 250px; }
|
||||
.sheet .gallery { flex: 0 0 250px; display: flex; gap: 10px; padding: 14px 14px 0; background: #0c131c; justify-content: center; overflow: hidden; }
|
||||
.sheet .gallery img { height: 100%; width: auto; max-width: 32%; object-fit: cover; object-position: top; border-radius: 10px 10px 0 0; }
|
||||
.sheet .inner { padding: 20px 28px 22px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; }
|
||||
.sheet .top { display: flex; gap: 18px; align-items: center; }
|
||||
.sheet .top .icon { width: 72px; height: 72px; border-radius: 16px; font-size: 32px; }
|
||||
.sheet h2 { margin: 0; font-size: 30px; color: var(--bright); line-height: 1.1; }
|
||||
.sheet .meta { color: var(--dim); font-size: 16px; }
|
||||
.sheet .text { font-size: 17px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.sheet .why { background: #ffffff0a; border-radius: 12px; padding: 10px 16px; font-size: 15px; color: #c5d0db; }
|
||||
.sheet .why ul { margin: 4px 0 0; padding-left: 22px; }
|
||||
.sheet .acts { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; align-items: center; }
|
||||
.sheet .acts .close { margin-left: auto; }
|
||||
.progress { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 6px; font-size: 15px; color: var(--dim); }
|
||||
.progress .track { height: 8px; border-radius: 4px; background: #ffffff14; overflow: hidden; }
|
||||
.progress .fill { height: 100%; background: var(--blue); width: 30%; transition: width .3s; }
|
||||
.progress.busy .fill { animation: busy 1.2s ease-in-out infinite; }
|
||||
@keyframes busy { from { margin-left: -30%; } to { margin-left: 100%; } }
|
||||
.kbd { position: fixed; left: 0; right: 0; bottom: 0; background: #131d2af5; border-top: 1px solid var(--line);
|
||||
padding: 12px; display: grid; gap: 8px; z-index: 20; box-shadow: 0 -20px 50px #0009; }
|
||||
.kbd .row { display: flex; gap: 8px; justify-content: center; }
|
||||
.kbd button { min-width: 76px; min-height: 60px; padding: 0 10px; font-size: 21px; }
|
||||
.kbd .wide { min-width: 320px; }
|
||||
.toast { position: fixed; left: 50%; top: 18px; transform: translateX(-50%); background: #0d2236; border: 1px solid var(--blue);
|
||||
border-radius: 14px; padding: 12px 20px; z-index: 30; max-width: 900px; box-shadow: 0 10px 30px #0008; }
|
||||
.toast.bad { background: #3a1416; border-color: var(--bad); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="rail" role="navigation" aria-label="Store">
|
||||
<h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 7h16l-1.5 12.5a1 1 0 0 1-1 .9H6.5a1 1 0 0 1-1-.9z"/><path d="M8.5 9V6a3.5 3.5 0 0 1 7 0v3"/></svg>APK Store</h1>
|
||||
<div id="cats"></div>
|
||||
<div class="sep"></div>
|
||||
<button data-view="installed" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3.5" y="4" width="7" height="7" rx="1.5"/><rect x="13.5" y="4" width="7" height="7" rx="1.5"/><rect x="3.5" y="14" width="7" height="7" rx="1.5"/><rect x="13.5" y="14" width="7" height="7" rx="1.5"/></svg>Installed<span class="n" id="instN"></span></button>
|
||||
<label title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="phone"> Show phone-only apps</label>
|
||||
</div>
|
||||
<main>
|
||||
<div class="bar">
|
||||
<label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="q" placeholder="Search games and apps" autocomplete="off" spellcheck="false" aria-label="Search games and apps"></label>
|
||||
<button id="kbdBtn" title="On-screen keyboard">⌨︎ Keyboard</button>
|
||||
</div>
|
||||
<div class="heading"><b id="title">Home</b><span id="count"></span><span class="note" id="note"></span></div>
|
||||
<div class="content" id="content"></div>
|
||||
<div class="pager">
|
||||
<button id="prev">◀ Previous</button>
|
||||
<span class="dots" id="page"></span>
|
||||
<button id="next">Next ▶</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="detail" hidden><div class="sheet" id="sheet"></div></div>
|
||||
<div class="kbd" id="kbd" hidden></div>
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
|
||||
<script>
|
||||
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const PER_PAGE = 8;
|
||||
// Same ids and order as STORE_CATS in index.html and ui/apk_sources/_categories.py.
|
||||
const CATS = [
|
||||
["", "Home", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
|
||||
["vr", "VR", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
|
||||
["games", "Games", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/>'],
|
||||
["emulators", "Emulators", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4M8 10.5h3M9.5 9v3"/>'],
|
||||
["watch", "Watch & listen", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z"/><path d="M8 20h8"/>'],
|
||||
["read", "Read & learn", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
|
||||
["create", "Create", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
|
||||
["work", "Work", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
|
||||
["web", "Web & chat", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
|
||||
["tools", "Tools", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
|
||||
];
|
||||
const catLabel = id => (CATS.find(c => c[0] === id) || [])[1] || "";
|
||||
const FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Won't run" };
|
||||
// view: "" (home), a category id, or "installed"; jobs: offer key -> {stage, percent, done, result, error}
|
||||
// installedFrom: offer key -> package it installed as (GitHub listings have no package);
|
||||
// needsData: installed packages still waiting for their game data (any view, while installed)
|
||||
const st = { view: "", q: "", phone: false, page: 0, apps: [], installed: new Map(), jobs: new Map(), installedFrom: new Map(),
|
||||
needsData: new Set(), request: 0, loading: false, retry: 0 };
|
||||
|
||||
async function api(path, body) {
|
||||
const opts = body === undefined ? { headers: { "X-Frame-UI": UI_KEY } } : {
|
||||
method: "POST", headers: { "Content-Type": "application/json", "X-Frame-UI": UI_KEY }, body: JSON.stringify(body) };
|
||||
const r = await fetch(path, opts);
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
|
||||
return j;
|
||||
}
|
||||
let toastTimer;
|
||||
function toast(msg, bad = false) {
|
||||
const t = $("toast");
|
||||
t.textContent = msg; t.className = "toast" + (bad ? " bad" : ""); t.hidden = false;
|
||||
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.hidden = true, bad ? 8000 : 4500);
|
||||
}
|
||||
|
||||
// ---- Artwork (only the server's /source-image/ handles are ever used as an image) ----
|
||||
const hue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
|
||||
const initial = name => [...String(name || "?")][0].toUpperCase();
|
||||
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
|
||||
const img = (u, cls = "") => valid(u) ? `<img${cls ? ` class="${cls}"` : ""} src="${esc(u)}" alt="" decoding="async">` : "";
|
||||
function art(name, a = {}, extra = "") {
|
||||
const base = `style="--hue:${hue(name)}"`, ini = `<span class="initial" aria-hidden="true">${esc(initial(name))}</span>`;
|
||||
if (valid(a.banner)) return `<span class="art" ${base}>${ini}${img(a.banner)}${extra}</span>`;
|
||||
const shots = (a.screenshots || []).filter(valid).slice(0, 3);
|
||||
if (shots.length) return `<span class="art shots" ${base}>${ini}${shots.map(u => img(u)).join("")}${extra}</span>`;
|
||||
if (valid(a.icon)) return `<span class="art iconart" ${base}>${ini}${img(a.icon, "backdrop")}${img(a.icon, "mark")}${extra}</span>`;
|
||||
return `<span class="art" ${base}>${ini}${extra}</span>`;
|
||||
}
|
||||
const icon = (name, a = {}) => `<span class="icon" style="--hue:${hue(name)}" aria-hidden="true">${esc(initial(name))}${img(a.icon)}</span>`;
|
||||
document.addEventListener("error", e => {
|
||||
const el = e.target;
|
||||
if (!el.matches?.(".art img, .icon img")) return;
|
||||
if (!el.dataset.retried) { el.dataset.retried = "1"; setTimeout(() => { el.src = el.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return; }
|
||||
const box = el.closest(".shots, .iconart"); el.remove();
|
||||
if (box && !box.querySelector("img")) box.classList.remove("shots", "iconart");
|
||||
}, true);
|
||||
const text = s => String(s || "").replace(/<[^>]*>/g, " ").replace(/ /g, " ");
|
||||
|
||||
// ---- Data ----
|
||||
async function loadInstalled() {
|
||||
try {
|
||||
st.installed = new Map((await api("/api/android")).apps.map(a => [a.package, a]));
|
||||
for (const pkg of st.needsData) if (!st.installed.has(pkg)) { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); } // removed
|
||||
} catch (e) { toast(`Couldn't list installed apps: ${e.message}`, true); }
|
||||
$("instN").textContent = st.installed.size || "";
|
||||
}
|
||||
async function search(quiet) {
|
||||
const request = ++st.request;
|
||||
clearTimeout(st.retry);
|
||||
if (st.view === "installed") { st.loading = false; st.page = 0; return render(); } // a pending search is now stale
|
||||
st.loading = !quiet; if (!quiet) render();
|
||||
const params = new URLSearchParams({ q: st.q, category: st.view, phone: String(st.phone) });
|
||||
try {
|
||||
const result = await api("/api/search?" + params);
|
||||
if (request !== st.request) return;
|
||||
st.apps = result.apps; st.loading = false;
|
||||
const loading = result.sources.filter(s => s.status === "loading").map(s => s.name);
|
||||
$("note").textContent = loading.length ? `Still fetching ${loading.join(" and ")}; more apps will appear.` : "";
|
||||
if (loading.length) st.retry = setTimeout(() => request === st.request && search(true), 6000);
|
||||
if (!quiet) st.page = 0;
|
||||
render();
|
||||
} catch (e) {
|
||||
if (request !== st.request) return;
|
||||
st.loading = false; st.apps = []; render(`Couldn't reach the app shelves: ${e.message}`);
|
||||
}
|
||||
}
|
||||
const offerKey = app => `${app.offers[0].source}:${app.offers[0].id}`;
|
||||
// Always the latest state: an Installed card's own copy goes stale after Stop or Remove.
|
||||
function installedOf(app) {
|
||||
const pkg = app.installed?.package || app.package || st.installedFrom.get(offerKey(app));
|
||||
return pkg ? st.installed.get(pkg) : undefined;
|
||||
}
|
||||
|
||||
// ---- Pages ----
|
||||
function list() {
|
||||
if (st.view === "installed") {
|
||||
const q = st.q.toLowerCase();
|
||||
return [...st.installed.values()].filter(m => !q || `${m.label} ${m.package}`.toLowerCase().includes(q))
|
||||
.map(m => ({ installed: m, name: m.label || m.package, package: m.package, summary: m.version ? `v${String(m.version).replace(/^v/i, "")}` : "",
|
||||
offers: [{ name: m.label, artwork: m.store_art || {}, vr: m.vr && !m.flatscreen }] }));
|
||||
}
|
||||
return st.home ? st.home.rest : st.apps;
|
||||
}
|
||||
function card(app, i) {
|
||||
const o = app.offers[0], m = installedOf(app), job = st.jobs.get(offerKey(app));
|
||||
const state = m ? `<span class="tag">${m.running ? "Running" : "Installed"}</span>`
|
||||
: job && !job.done ? `<span class="tag">Installing…</span>`
|
||||
: FIT[o.verdict?.tone] ? `<span class="fit ${o.verdict.tone}">${FIT[o.verdict.tone]}</span>` : "";
|
||||
const kind = o.phone_only ? "Phone app" : catLabel(app.category || o.category) || (m ? (o.vr ? "VR app" : "Android app") : "Android app");
|
||||
return `<button class="card" data-i="${i}" aria-label="${esc(app.name)}">${art(app.name, o.artwork, o.vr ? '<span class="vr">VR</span>' : "")}
|
||||
<span class="body"><span class="title">${icon(app.name, o.artwork)}<strong>${esc(app.name)}</strong></span>
|
||||
<span class="sum">${esc(text(app.summary))}</span><span class="foot"><span>${esc(kind)}</span>${state}</span></span></button>`;
|
||||
}
|
||||
function render(error) {
|
||||
const home = st.view === "" && !st.q;
|
||||
$("title").textContent = st.q ? `Results for “${st.q}”` : st.view === "installed" ? "Installed" : st.view ? catLabel(st.view) : "Home";
|
||||
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.view === st.view)));
|
||||
const content = $("content");
|
||||
if (st.loading) {
|
||||
content.innerHTML = `<div class="grid">${Array.from({ length: PER_PAGE }, () => '<div class="skeleton"></div>').join("")}</div>`;
|
||||
$("count").textContent = ""; $("page").textContent = ""; $("prev").disabled = $("next").disabled = true; return;
|
||||
}
|
||||
// Home: a featured VR app and four picks, then pages of one app from each category in turn.
|
||||
st.home = null;
|
||||
if (home && st.apps.length) {
|
||||
const ok = st.apps.filter(a => a.offers[0].verdict?.tone !== "blocked");
|
||||
const heroes = ok.filter(a => a.offers[0].vr && valid(a.offers[0].artwork?.banner));
|
||||
const featured = heroes[Math.floor(Date.now() / 864e5) % (heroes.length || 1)] || ok.find(a => valid(a.offers[0].artwork?.banner));
|
||||
const queues = CATS.slice(1).map(([id]) => ok.filter(a => a.category === id && a !== featured));
|
||||
const mixed = [];
|
||||
for (let i = 0; queues.some(q => q[i]); i++) queues.forEach(q => q[i] && mixed.push(q[i]));
|
||||
st.home = { featured, rest: mixed.slice(0, 4 + 5 * PER_PAGE) }; // six pages of picks; the categories have the rest
|
||||
}
|
||||
const items = list();
|
||||
const first = st.home?.featured ? 4 : PER_PAGE; // the first home page shares its space with the banner
|
||||
const pages = Math.max(1, items.length <= first ? 1 : 1 + Math.ceil((items.length - first) / PER_PAGE));
|
||||
st.page = Math.min(st.page, pages - 1);
|
||||
const start = st.page === 0 ? 0 : first + (st.page - 1) * PER_PAGE;
|
||||
const shown = items.slice(start, start + (st.page === 0 ? first : PER_PAGE));
|
||||
$("count").textContent = home ? "" : `${items.length.toLocaleString()} app${items.length === 1 ? "" : "s"}`;
|
||||
$("page").textContent = `Page ${st.page + 1} of ${pages}`;
|
||||
$("prev").disabled = st.page === 0; $("next").disabled = st.page >= pages - 1;
|
||||
const empty = error ? `<div class="empty"><h2>No connection</h2>${esc(error)}</div>`
|
||||
: st.view === "installed" && !st.q ? `<div class="empty"><h2>Nothing installed yet</h2>Pick an app in the store and press Install.<button class="primary" data-view="">Browse the store</button></div>`
|
||||
: `<div class="empty"><h2>No apps here</h2>Try another category${st.phone ? "" : ", or show phone-only apps"}.</div>`;
|
||||
let html = "";
|
||||
if (st.page === 0 && st.home?.featured) {
|
||||
const f = st.home.featured, o = f.offers[0];
|
||||
html += `<button class="hero" data-feature="1" aria-label="${esc(f.name)}">${art(f.name, o.artwork)}<span class="copy">
|
||||
<span class="eyebrow">${o.vr ? '<span class="vr">VR</span>' : ""}Featured</span><strong>${esc(f.name)}</strong>
|
||||
<span class="blurb">${esc(text(f.summary))}</span><span class="cta">See ${esc(f.name)} ›</span></span></button>`;
|
||||
}
|
||||
html += `<div class="grid${st.page === 0 && st.home?.featured ? " one-row" : ""}">${shown.length ? shown.map((a, i) => card(a, start + i)).join("") : empty}</div>`;
|
||||
content.innerHTML = html;
|
||||
}
|
||||
function turn(by) {
|
||||
if (by < 0 ? $("prev").disabled : $("next").disabled) return;
|
||||
st.page += by; render();
|
||||
}
|
||||
|
||||
// ---- Detail sheet ----
|
||||
let current = null; // {app, installed}
|
||||
const ver = v => String(v || "").replace(/^v/i, "");
|
||||
const size = n => n >= 1e9 ? (n / 1e9).toFixed(1) + " GB" : n >= 1e6 ? Math.round(n / 1e6) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
|
||||
function openDetail(app) {
|
||||
current = app; drawDetail(); $("detail").hidden = false;
|
||||
const o = app.offers[0];
|
||||
if (o.source && o.id && !app.detailed) { // the full entry has all the screenshots
|
||||
api(`/api/sources/details?source=${encodeURIComponent(o.source)}&id=${encodeURIComponent(o.id)}`)
|
||||
.then(full => { if (current === app) { app.offers[0] = { ...o, ...full }; app.detailed = true; drawDetail(); } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
function drawDetail() {
|
||||
const app = current; if (!app) return;
|
||||
const o = app.offers[0], m = installedOf(app), key = offerKey(app), job = st.jobs.get(key), data = m && st.jobs.get("data:" + m.package);
|
||||
if (app.installed && !m) return closeDetail(); // removed
|
||||
const shots = (o.artwork?.screenshots || []).filter(valid);
|
||||
const top = valid(o.artwork?.banner) ? art(app.name, { banner: o.artwork.banner })
|
||||
: shots.length ? `<div class="gallery">${shots.slice(0, 3).map(u => img(u)).join("")}</div>` : art(app.name, o.artwork || {});
|
||||
const meta = [o.developer, o.source_name, o.version ? `v${ver(o.version)}` : m?.version ? `v${ver(m.version)}` : "", o.size ? size(o.size) : ""].filter(Boolean).join(" · ");
|
||||
const reasons = (o.fit?.reasons || []).filter(Boolean);
|
||||
let acts;
|
||||
const progress = j => {
|
||||
const pct = typeof j.percent === "number" ? Math.max(3, Math.min(100, j.percent)) : null;
|
||||
return `<div class="progress${pct === null ? " busy" : ""}"><span>${esc(j.stage || "Downloading")}${pct === null ? "…" : ` ${Math.round(pct)}%`}</span><div class="track"><div class="fill" style="${pct === null ? "" : `width:${pct}%`}"></div></div></div>`;
|
||||
};
|
||||
const gameData = !(m && st.needsData.has(m.package)) ? "" : data && !data.done ? progress(data)
|
||||
: `<button class="primary" data-act="game-data">Add game data</button><span class="meta">Open it once on the Frame first.</span>`;
|
||||
if (job && !job.done) {
|
||||
acts = progress(job);
|
||||
} else if (m) {
|
||||
acts = gameData + (m.running ? `<button data-act="stop">■ Stop</button>` : `<button class="play" data-act="launch">▶ Open</button>`)
|
||||
+ `<button class="danger" data-act="remove">Remove</button>`;
|
||||
} else if (!o.source) {
|
||||
acts = "";
|
||||
} else if (o.downloadable === false) {
|
||||
acts = `<button disabled>Get it from the developer's page</button>`;
|
||||
} else if (o.verdict?.tone === "blocked") {
|
||||
acts = `<button disabled>Won't run on the Frame</button>`;
|
||||
} else {
|
||||
acts = `<button class="primary" data-act="install">Install</button>`;
|
||||
}
|
||||
const blurb = o.description || o.summary || (app.installed ? "" : app.summary); // an Installed card's summary is its version, already in meta
|
||||
const failed = data?.error || job?.error;
|
||||
const note = failed ? `<div class="meta" style="color:var(--bad)">${esc(failed)}</div>` : "";
|
||||
$("sheet").innerHTML = `${top}<div class="inner">
|
||||
<div class="top">${icon(app.name, o.artwork)}<div><h2>${esc(app.name)}</h2><div class="meta">${esc(meta)}</div></div>
|
||||
${o.vr ? '<span class="vr" style="margin-left:auto">VR</span>' : ""}</div>
|
||||
${blurb ? `<div class="text">${esc(text(blurb))}</div>` : ""}
|
||||
${o.verdict ? `<div class="why"><b>${esc(o.verdict.label)}</b>${reasons.length ? `<ul>${reasons.slice(0, 3).map(r => `<li>${esc(r)}</li>`).join("")}</ul>` : ""}</div>` : ""}
|
||||
${note}<div class="acts">${acts}<button class="close" data-act="close">Close</button></div></div>`;
|
||||
}
|
||||
function closeDetail() { current = null; $("detail").hidden = true; }
|
||||
|
||||
async function runJob(key, start) {
|
||||
const job = { stage: "Downloading", percent: null, done: false };
|
||||
st.jobs.set(key, job); drawDetail(); render();
|
||||
try {
|
||||
const { job: id } = await start();
|
||||
for (let misses = 0; ;) {
|
||||
await new Promise(ok => setTimeout(ok, 1500));
|
||||
let j;
|
||||
try { j = await api(`/api/job?id=${encodeURIComponent(id)}`); misses = 0; }
|
||||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||||
Object.assign(job, { stage: j.stage || job.stage, percent: j.percent ?? null });
|
||||
if (!j.done) { drawDetail(); continue; }
|
||||
if (j.error) throw new Error(j.error);
|
||||
toast(j.message); job.result = j.result || {};
|
||||
break;
|
||||
}
|
||||
} catch (e) { job.error = e.message; toast(e.message, true); }
|
||||
finally {
|
||||
job.done = true;
|
||||
if (!job.error) st.jobs.delete(key); // a failure stays, so the sheet can say why
|
||||
await loadInstalled(); render(); drawDetail();
|
||||
}
|
||||
return job;
|
||||
}
|
||||
$("sheet").onclick = async e => {
|
||||
const b = e.target.closest("[data-act]"); if (!b || !current) return;
|
||||
const app = current, o = app.offers[0], m = installedOf(app), act = b.dataset.act, key = offerKey(app);
|
||||
if (act === "close") return closeDetail();
|
||||
if (act === "install") {
|
||||
toast(`Installing ${app.name}. The first time takes a minute…`);
|
||||
const job = await runJob(key, () => api("/api/sources/install", { source: o.source, id: o.id, version_code: o.version_code ?? null }));
|
||||
if (job.result?.package) {
|
||||
st.installedFrom.set(key, job.result.package);
|
||||
if (job.result.game_data) st.needsData.add(job.result.package);
|
||||
}
|
||||
return render(), drawDetail();
|
||||
}
|
||||
if (act === "game-data" && m) { // its own job, by package: a failed copy keeps the pending data, so it can be tried again
|
||||
const job = await runJob("data:" + m.package, () => api("/api/sources", { action: "game-data", package: m.package }));
|
||||
if (!job.error) st.needsData.delete(m.package);
|
||||
return render(), drawDetail();
|
||||
}
|
||||
const pkg = m?.package; if (!pkg) return;
|
||||
if (act === "remove" && b.dataset.sure !== "1") {
|
||||
b.dataset.sure = "1"; b.textContent = "Remove it and its data?"; return; // no confirm(): a separate window the laser has to find
|
||||
}
|
||||
b.disabled = true;
|
||||
try {
|
||||
const r = await api("/api/android", { action: act, package: pkg });
|
||||
toast(r.message);
|
||||
if (act === "remove") { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); }
|
||||
if (act === "launch") closeDetail();
|
||||
} catch (err) { toast(err.message, true); }
|
||||
await loadInstalled(); render();
|
||||
if (current === app) drawDetail();
|
||||
};
|
||||
$("detail").onclick = e => { if (e.target === $("detail")) closeDetail(); };
|
||||
|
||||
// ---- Navigation ----
|
||||
$("cats").innerHTML = CATS.map(([id, label, path]) => `<button data-view="${id}" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${path}</svg>${esc(label)}</button>`).join("");
|
||||
document.addEventListener("click", e => {
|
||||
const v = e.target.closest("[data-view]");
|
||||
if (v && !e.target.closest("#sheet")) {
|
||||
st.view = v.dataset.view; st.q = ""; $("q").value = "";
|
||||
if (st.view === "installed") loadInstalled().then(() => search()); else search();
|
||||
return;
|
||||
}
|
||||
if (e.target.closest("[data-feature]")) return openDetail(st.home.featured);
|
||||
const c = e.target.closest(".card[data-i]");
|
||||
if (c) openDetail(list()[+c.dataset.i]);
|
||||
});
|
||||
$("phone").onchange = e => { st.phone = e.target.checked; search(); };
|
||||
$("prev").onclick = () => turn(-1);
|
||||
$("next").onclick = () => turn(1);
|
||||
// The thumbstick arrives as a mouse wheel: one notch, one page.
|
||||
let wheelAt = 0;
|
||||
document.querySelector("main").addEventListener("wheel", e => {
|
||||
e.preventDefault();
|
||||
if (!$("detail").hidden || Date.now() - wheelAt < 350 || Math.abs(e.deltaY) < 4) return;
|
||||
wheelAt = Date.now(); turn(e.deltaY > 0 ? 1 : -1);
|
||||
}, { passive: false });
|
||||
let qTimer;
|
||||
$("q").oninput = e => { clearTimeout(qTimer); qTimer = setTimeout(() => { st.q = e.target.value.trim(); search(); }, 250); };
|
||||
document.addEventListener("keydown", e => { if (e.key === "Escape") { closeDetail(); $("kbd").hidden = true; } });
|
||||
|
||||
// ---- On-screen keyboard (a text field on a panel may not bring up Steam's) ----
|
||||
const ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
|
||||
$("kbd").innerHTML = ROWS.map(r => `<div class="row">${[...r].map(k => `<button data-k="${k}">${k}</button>`).join("")}</div>`).join("")
|
||||
+ `<div class="row"><button data-k="clear">Clear</button><button class="wide" data-k=" ">space</button><button data-k="back">⌫</button><button data-k="done" class="primary">Done</button></div>`;
|
||||
$("kbd").onclick = e => {
|
||||
const b = e.target.closest("[data-k]"); if (!b) return;
|
||||
const k = b.dataset.k, q = $("q");
|
||||
if (k === "done") { $("kbd").hidden = true; return; }
|
||||
q.value = k === "clear" ? "" : k === "back" ? q.value.slice(0, -1) : q.value + k;
|
||||
q.dispatchEvent(new Event("input"));
|
||||
};
|
||||
$("kbdBtn").onclick = () => $("kbd").hidden = !$("kbd").hidden;
|
||||
|
||||
loadInstalled().then(() => search());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user