Compare commits

..
Author SHA1 Message Date
SAPHID f7f694956d fix(ui): reconcile PR 91 with current main 2026-10-18 00:38:06 +00:00
Alex Southwell f9496d67da Merge pull request #56 from saphid/ui-4-store
Make the Android store look and read like the rest of the app
2026-10-18 11:31:01 +11:00
Alex Southwell 7eae209b1c Merge pull request #55 from saphid/ui-3-home
Lead Home with what's happening, and give settings a page
2026-10-18 11:30:16 +11:00
Alex Southwell 28bc0506a6 Merge pull request #54 from saphid/ui-2-connection
Change the headset's addresses from the connection pill
2026-10-18 11:29:47 +11:00
Alex Southwell 0a4f008d03 Merge pull request #53 from saphid/ui-1-layout
Fit the window at every size and show the battery once
2026-10-18 11:26:14 +11:00
Alex Southwell 16b37bc667 Merge pull request #50 from saphid/flat2vr-mods
VR mods: UEVR install, play in VR and remove from Frame Control
2026-10-18 11:04:17 +11:00
SAPHID 0931ff57d5 merge: preserve itch purchases alongside scoped source logins 2026-10-18 00:02:27 +00:00
Alex Southwell ae8cc1b4a6 Merge pull request #58 from saphid/fix/flat2vr-mods-windows
UEVR helper tests: import and skip cleanly on Windows (fixes PR #50 CI)
2026-10-18 10:54:26 +11:00
SAPHID 63017e1969 chore: resolve conflicts with main 2026-10-17 22:10:04 +00:00
saphidandClaude Opus 5.5 0f8aaa393b Store: show owned itch.io games under either VR filter
itch.io's game records don't say which games are VR, so owned purchases
came back with VR unknown and the "VR experiences" filter hid them (found
in a live test: an owned Quest game vanished). Unknown still counts as flat
for every other source.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:50:23 +11:00
saphidandClaude Opus 5.5 d6fb5e917a UEVR helper tests: import and skip cleanly on Windows
frame_mods runs on the Frame, but the server tests import it on every
desktop OS; the module-level `import fcntl` failed test discovery on
Windows. Import it where the lock is taken, as frame_panels and frame_vr
do. Skip the Frame-side helper tests on Windows, as test_input does for the
input agent: they rely on /proc, flock and POSIX prefix paths. The server
routes and the usage-error check still run there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:28:57 +10:00
saphid daf30515b1 Merge main into flat2vr-mods
# Conflicts:
#	README.md
#	ui/index.html
#	ui/server.py
2026-09-29 13:07:33 +10:00
saphidandClaude Opus 5.5 2f056dbcff UEVR: address the second review; recheck on the Frame
frame_inject.py declares every Win32 signature, checks each result, frees
its remote buffers and closes handles. Uninstall only accepts our folder and
UEVR's own per-game settings folders, and keeps the receipt if anything can't
be removed. The injector's reply is parsed defensively. Rechecked on a real
Frame: install, three cold starts injected (33-35 s), clean uninstall.

Part of #26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:04:12 +10:00
saphidandClaude Opus 5.5 e3176a2de8 UEVR: inject with Frame Control's own script instead of UEVR's GUI injector
UEVR's .NET injector, driven with XSendEvent, injected in only 6 of 8 cold
starts on the Frame: in bad sessions every new injector ignored input or never
showed a window. start() now runs frame_inject.py under python.org's
embeddable Python (pinned SHA-256) in the game's Wine session, repeating the
frontend's own Inject steps: nullify VR plugins, load openvr_api.dll, set
Frontend_RequestedRuntime, load UEVRBackend.dll. Verified 9/9 cold starts in
33-35 s and a clean UI install/play/remove cycle. No .NET download any more
(47 MB installed instead of about 190 MB).

Also from the SWE-2 Max review: one mod action at a time (flock), a download
size cap, errors always reported as JSON, a receipt can't name another app's
prefix, 0 frames counts as an answer, and a longer server timeout for start.

Part of #26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:53:28 +10:00
saphidandClaude Opus 5.5 416f3181cc Add a UEVR mod manager: install, play in VR and remove from Frame Control
Installed Unreal Engine games get a "VR mod" button. ui/frame_mods.py runs on
the Frame: it downloads the pinned official UEVR 1.05 release and Microsoft's
.NET 6 runtimes (hash-checked, safely unpacked) into the game's Proton prefix
with a receipt; starts the game, runs the injector in the game's own Wine
session and presses OpenVR + Inject with XSendEvent (gamescope keeps real
input on the game); confirms UEVRBackend.dll is loaded and SteamVR counts
frames; and removes exactly what it added.

Verified on a real Frame 2026-09-29 with Gravitas, through the UI: install,
four injections (three from a cold start), remove back to the pre-install
prefix listing. The headset was unworn, so the VR image, tracking and controls
are not verified. Part of #26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:52:02 +10:00
23 changed files with 2887 additions and 65 deletions

No files matched your search

+1 -1
View File
@@ -219,7 +219,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [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 |
+82
View File
@@ -135,6 +135,88 @@ is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
Removing a repository clears its state.
## My itch.io purchases
The store can also install the Android (Quest) builds of games you **own** on
itch.io: bought, claimed free, from a bundle or a gift. It is opt-in. In the
store, open the sources dialog and use **My itch.io purchases**: create an API
key on itch.io (account settings › API keys) and paste it there. Frame Control
checks it with itch.io (`GET /profile`) before saving it; a wrong or revoked key
is reported and nothing is saved. Once connected, a source called
**itch.io (my purchases)** appears, and its games are marked **Owned on
itch.io** instead of Free. **Disconnect itch.io** deletes the key file.
How it works, using the documented
[server-side API](https://itch.io/docs/api/serverside) with
`Authorization: Bearer <key>`:
- `GET https://api.itch.io/profile/owned-keys` lists your download keys, page by
page until an empty page (500 per page, at most 40 pages). Games whose
`traits` don't include `p_android` are left out; one entry per game, with the
newest key.
- Opening a game asks `GET /games/<game_id>/uploads?download_key_id=<key id>`
for its files. Android uploads are those marked `p_android` (in `traits`, or
as a `p_android`/`platforms.android` flag) or named `.apk`. `.apk` files come
first, then full games before demos, newest first. Soundtracks, pre-orders and
externally hosted files are skipped.
- Installing asks `GET /uploads/<upload_id>/download?download_key_id=<key id>`.
The answer accepted is a redirect to itch.io's storage host, a JSON body
with a `url` field naming it, or the file itself. Frame Control follows the
first two without the key, to HTTPS on port 443 only, and only to host names
whose every address is public. For IPv4 that means outside every special
block (`0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`,
`192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`,
`198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`), however written. IPv6 is
an allow-list: global unicast (`2000::/3`) only, minus 6to4, Teredo,
documentation, ORCHID and benchmarking blocks, so mapped or NAT64 IPv4,
site-local, unique-local and the rest are all refused. It connects only to
the addresses it checked, trying each in turn within one 20-second budget
for connecting and the TLS handshake, and checks again on each further
redirect. A storage URL, first or after any redirect, that contains the API
key (or 8+ characters of it, in any case, plain or percent-encoded) or an
`api_key` parameter is refused before it is looked up, so the key never
reaches the storage server. The APK then takes
the normal install path, with the same ABI, OpenXR layer and Meta-services
checks and warnings as any other store download.
The key is stored in `frame_host.data_dir('itch-account.json')`
(`~/Library/Application Support/Frame Control/itch-account.json` on macOS),
written through a new 0600 temporary file and moved into place. It is only
sent to `https://api.itch.io`, in the `Authorization` header, never in a URL;
Frame Control does not follow api.itch.io's redirects with it. The key, or any
part of it from 8 characters, never appears in the page, API responses, errors
or logs; the page clears the field as soon as it is sent. A disconnect made
while a key is still being checked wins: that key is not saved.
Owned-game lists are kept in memory for ten minutes and never written to the
disk cache.
When api.itch.io answers 401 or 403, Frame Control asks `GET /profile` with the
same key. If that is refused too (or itch.io says "invalid key"), the key is
deleted and the store says to connect again; otherwise only that game or
download is refused, and the key stays. Rate limits (429) and network errors
only fail that source's results, with a readable message. A storage host's
refusal is reported as a download error, not as a rate limit.
### What is verified and what isn't
Not tested against a live itch.io account at all: everything below was tested
with recorded and hand-made responses (`tests/test_itch_purchases.py`) only.
| Part | Status |
|---|---|
| Bearer key, `GET /profile`, `/profile/owned-keys` paging and its fields, the `errors` array | Documented in the public reference (checked 2026-10-13) |
| `GET /games/<id>/uploads` and `download_key_id` | Not in the public reference; paths and parameter from itch.io's app client, [go-itchio](https://github.com/itchio/go-itchio) |
| Upload fields (`filename`, `display_name`, `size`, `storage`, `type`, `traits`, `updated_at`, `demo`, `preorder`) | Assumed from go-itchio and the API's snake_case convention |
| The `p_android` trait, on games and uploads | Assumed from itch.io's `p_windows`/`p_linux`/`p_osx` naming |
| What `/uploads/<id>/download` returns | Unknown: go-itchio only builds the URL and hands it to a downloader, so a redirect, a JSON `url` and the file are all accepted |
| Whether that endpoint accepts the key as a Bearer header | Unknown: go-itchio puts it in the query string, which Frame Control refuses to do |
| itch.io's storage host names and addresses | Unknown; any public HTTPS host is allowed |
Limits: a game whose page doesn't mark Android, or whose Android build is
inside a `.zip`, won't install. Android builds have no version codes on
itch.io, so only the newest upload can be installed. Owning a game doesn't make
it run on the Frame: Quest builds that need Meta's platform services won't.
## Publish your own repository
Only publish free APKs you own or have the developer's permission to distribute.
+2
View File
@@ -73,6 +73,8 @@ does not suppress the other at the aggregator level. Queries filter the current
feed window locally: this is not an exhaustive historical itch search. Only
explicit zero-price Android entries are returned. Covers are exposed in
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
Games the user owns on itch.io are a separate, opt-in source that downloads
through their own API key: see [My itch.io purchases](apk-repos.md#my-itchio-purchases).
Curated GitHub entries include publisher artwork and plain-language summaries.
Repository image URLs are pinned to inspected commits. Open Brush screenshots
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
+122
View File
@@ -0,0 +1,122 @@
# UEVR from Frame Control, 2026-09-29
**Verified observations**, with limits below. Same device and software as
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
Frame's AEST clock. Headset tests ran under the shared
`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40,
12:44–12:52 and 13:01–13:03. Battery 45 % →
93 %, on charge throughout.
**Not observed:** the VR image, head tracking and controller input. SteamVR
reported `activity_level` 3 (user interaction timeout; the headset was not
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
capture was a uniform dark frame. Frame submits prove the app is presenting
to SteamVR, not that the image is correct.
## Manual injection (11:13–11:28)
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
started with the game process's Wine variables, and its WPF window drew
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
(64-bit, PCD3D_SM5))`, a Wine-side process id.
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
was not restored on the next start.
- XTest clicks landed on the game: `XQueryPointer` reported child
`0x2000001` (the game) at the injector's coordinates, and
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
- `XSendEvent` button events to the injector window worked. After OpenVR and
Inject, `/proc/<game>/maps` contained
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
Proton's `vrclient_x64.dll`. UEVR's log:
```text
[11:17:32.948] Hooking D3D11
[11:17:33.020] Hooked DirectX 11
[11:17:34.263] Framework initialized
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
[11:17:39.791] UGameViewportClient::Draw called for the first time.
[11:17:39.792] is stereo enabled called!
```
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
A second session's injector exited within about 20 seconds. The third,
started fresh, injected again (`frame_submits` 1412). After the game was
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
files were removed.
## Through Frame Control (11:33–11:49)
Local `ui/server.py` against the `frame` alias. First the API, then the
page's **VR mod** dialog, driven with a headless Chromium:
| Step | Result |
|---|---|
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
| `uninstall` while running | Refused: "the game is running; quit it first" |
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) |
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
## The GUI injector wasn't reliable (12:02–12:40)
After review fixes, cold starts through the API with UEVR's own injector:
- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
relaunched the injector didn't rescue a bad session. In both failures of
the last batch, the first injector ignored the clicks and the next two
never showed a window within 60 s.
- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
with OpenVR still unselected. The injector's log had no error.
Lock released at 12:40 before redesigning.
## Frame Control's own injector (12:44–12:52)
`start` now runs `frame_inject.py` under python.org's embeddable Python
3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
the game's Wine session. The old install was removed and the new one
installed without the headset lock, since neither launches anything. The
prefix listing after removal matched the pre-install state; the new install
is 47 MB.
| Step | Result |
|---|---|
| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
Battery 98–100 % on charge. Lock released at 12:52.
## After the second review (13:01–13:03)
`frame_inject.py` now declares every Win32 signature, checks each result,
frees its remote buffers and closes its handles; uninstall accepts only our
folder and UEVR's own settings folders. Rechecked: install; three cold starts
injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging
`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall
left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache,
receipts or processes. Frame Control's injector total: 12 of 12 cold starts
through the API, plus the UI run.
## Left on the Frame
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
installed; nothing else from this work is left.
- No `UEVRInjector.exe` or game processes were left running. Steam and
SteamVR were running at the end.
- The test lock was released at 13:03.
+1
View File
@@ -52,6 +52,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+85 -15
View File
@@ -1,11 +1,14 @@
# Flat-to-VR mods and Beat Saber songs
**Status: feasibility work, not an installer.** Frame Control does not yet
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
stays open: neither UEVR injection nor Beat Saber custom-song playback has
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
has not been inspected. There is no Mods button until the underlying
install, playback and removal have been checked.
**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
songs are blocked.** Installed games have a **VR mod** button. It installs
UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
the VR image, head tracking and controls haven't been seen yet. Beat Saber
custom songs need an owned Beat Saber on the Frame, which there isn't
([below](#beat-saber-songs-first)).
[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those.
The mod manager must be Frame Control's own implementation. Mods and songs
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
@@ -15,27 +18,87 @@ unofficial mod mirrors are part of this work.
## Per-game support
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
**20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
the observation stated, not a promise that the game is playable. “Documented”
means an upstream source describes it; “inferred” means it still needs a test.
| Game / build | Mod or content | Evidence and support status | Next check |
|---|---|---|---|
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. |
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
visible output and failures. It also records a SteamVR restart during the
shared session, which prevents attributing the failed UEVR attempt to FEX.
The test records ([2026-09-28](evidence/mods-2026-09-28.md),
[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
visible output, injection and failures.
## UEVR from Frame Control
![The VR mod dialog while UEVR runs in Gravitas](img/uevr-dialog.png)
**Get games → an installed game → VR mod.** The dialog says whether the game
can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove
UEVR**. The server route is `POST /api/mods {"action": "status" | "install" |
"start" | "uninstall", "appid": …}`, and the work happens on the Frame in
[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library,
like `frame_steam.py`.
- **Which games.** Unreal Engine games, recognised by their
`…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked
with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
prefix, so it must have been played once.
- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05)
and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
Each is checked against a pinned SHA-256; Python's comes from python.org's
release-file API. Archives are unpacked into a staging folder, rejecting
entries that escape it, symlinks and oversized contents, then moved into
`<prefix>/drive_c/frame-control/` (about 47 MB). Nothing goes into the
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists
what to remove, including UEVR's own settings folders only if they didn't
exist before. Install and remove refuse while the game is running, and only
one mod action runs at a time.
- **Play in VR.** Launches the game through Steam if it isn't running and
waits for its window. It then runs a short script under that Python, with
the game's Proton, prefix and Wine settings read from the game process, so
both share one Wine session. The script repeats what UEVR's own injector does
when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in
UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a
`LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start`
then checks that `UEVRBackend.dll` is mapped into the game and reads
SteamVR's frame count for the app (`vrcmd --stats`).
- **Remove.** Deletes what the receipt lists, then the download cache once
no game uses it. Verified: the prefix's file listing matched its
pre-install state afterwards.
Why not UEVR's own injector (verified 2026-09-29):
- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
of downloads) in the prefix.
- Under gamescope, the full-screen game keeps the pointer and focus. XTest
clicks, raising or refocusing the injector window don't change that, so in
the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
events straight to its window does work.
- `--attach=<process>` didn't inject under Wine, and it didn't restore its
saved OpenVR choice.
- Driven that way, it injected in 6 of 8 cold starts. In a bad session every
new injector ignored input or never showed its window, even after
relaunching. Its process list reads each process's main window title, a
cross-process window message; a window that isn't answering would block its
UI thread (inferred from the frontend's source, not proven). Without a
`HOME` it also died in .NET at startup.
- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI.
- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for
Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
Neither stopped the injection.
## Beat Saber: songs first
@@ -62,7 +125,10 @@ or dependency for Frame Control.
## Requirements for our manager
These are **planned**, not implemented or verified:
For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by
fake-library tests (`tests/test_mods.py`), apart from checking ownership
through Steam; item 6 is met for Gravitas. Beat Saber songs would need the
same, and none of it is built for them:
1. Resolve the selected Steam game's real library, installed build, executable
architecture and Proton prefix. Confirm ownership through Steam; a directory
@@ -91,7 +157,11 @@ These are **planned**, not implemented or verified:
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
including the SHA-512 hashes used for the test runtimes.
including the SHA-512 hashes used for the injector tests.
- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs`
(`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows.
- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the
embeddable Python's `sha256_sum`.
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
including per-game installation and the need to preserve original DLLs.
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
+3 -3
View File
@@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's
Steam Frame rating, installs them on the Frame, and searches the Steam store.
This page covers how it works underneath, so you can do the same from a shell.
For flat-to-VR mods and Beat Saber custom songs, see the
[per-game feasibility table](mods.md). Mod support is separate from Steam's
Frame rating; there is no mod installer yet.
Installed Unreal Engine games also have a **VR mod** button that installs,
starts and removes UEVR; see [VR mods](mods.md) for how it works and the
per-game table. Mod support is separate from Steam's Frame rating.
## How it works
+5
View File
@@ -0,0 +1,5 @@
Hand-made itch.io API responses for tests/test_itch_purchases.py; no real account or
network was used. owned-keys-*.json follow the documented /profile/owned-keys sample
(https://itch.io/docs/api/serverside). uploads.json follows the shape the itch.io app's
client library (github.com/itchio/go-itchio) reads from /games/ID/uploads, with the
`traits` array it handles; `p_android` there is assumed, not documented.
+22
View File
@@ -0,0 +1,22 @@
{
"page": 1,
"per_page": 500,
"owned_keys": [
{"id": 9001, "game_id": 341190, "purchase_id": 3923754, "downloads": 6,
"created_at": "2026-03-10T23:46:36Z", "updated_at": "2026-03-10T23:46:36Z",
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
"cover_url": "https://img.itch.zone/aW1nLzE2OTA3NTIuanBn/315x250%23c/Ihj7Wa.jpg",
"short_text": "Grow plants in zero gravity.", "type": "default", "classification": "game",
"min_price": 1299, "traits": ["p_windows", "p_android", "can_be_bought"],
"created_at": "2025-12-04T16:18:04Z", "published_at": "2026-01-03T17:28:47Z",
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio",
"url": "https://maker.itch.io"}}},
{"id": 9002, "game_id": 3, "purchase_id": null, "downloads": 0,
"created_at": "2026-02-01T10:00:00Z", "updated_at": "2026-02-01T10:00:00Z",
"game": {"id": 3, "title": "Desk Only", "url": "https://leafo.itch.io/x-moon",
"cover_url": "https://img.itch.zone/aW1hZ2UvMy8xODM3LnBuZw==/315x250%23/y2uYQI.png",
"type": "default", "classification": "game", "min_price": 0,
"traits": ["p_windows", "p_linux", "p_osx"], "published_at": "2013-03-03T23:02:14Z",
"user": {"id": 1, "username": "leafo", "display_name": "leafo", "url": "https://leafo.itch.io"}}}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"page": 2,
"per_page": 500,
"owned_keys": [
{"id": 8001, "game_id": 341190, "purchase_id": null, "downloads": 1,
"created_at": "2025-06-01T10:00:00Z", "updated_at": "2025-06-01T10:00:00Z",
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
"type": "default", "classification": "game", "min_price": 1299,
"traits": ["p_windows", "p_android", "can_be_bought"],
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio"}}},
{"id": 8002, "game_id": 77, "purchase_id": null, "downloads": 2,
"created_at": "2025-05-01T10:00:00Z", "updated_at": "2025-05-01T10:00:00Z",
"game": {"id": 77, "title": "Free Quest Toy", "url": "https://toys.itch.io/quest-toy",
"cover_url": "http://img.example/not-itch.png",
"type": "default", "classification": "game", "min_price": 0,
"traits": ["p_android"], "published_at": "2025-04-01T00:00:00Z",
"user": {"id": 5, "username": "toys"}}}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"uploads": [
{"id": 501, "game_id": 341190, "filename": "OrbitGarden-win64.zip", "display_name": "Windows",
"size": 734003200, "storage": "hosted", "type": "default", "traits": ["p_windows"],
"created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-02T10:00:00Z"},
{"id": 502, "game_id": 341190, "filename": "OrbitGarden-Quest-old.apk", "display_name": "Quest 1.0",
"size": 104857600, "storage": "hosted", "type": "default", "traits": ["p_android"],
"created_at": "2026-01-01T10:00:00Z", "updated_at": "2026-01-05T10:00:00Z"},
{"id": 503, "game_id": 341190, "filename": "OrbitGarden-Quest.apk", "display_name": "Quest 1.2",
"size": 115343360, "storage": "hosted", "type": "default", "traits": ["p_android"],
"created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-03T10:00:00Z"},
{"id": 504, "game_id": 341190, "filename": "OrbitGarden-demo.apk", "display_name": "Quest demo",
"size": 52428800, "storage": "hosted", "type": "default", "traits": ["p_android", "demo"],
"created_at": "2026-05-01T10:00:00Z", "updated_at": "2026-05-01T10:00:00Z"},
{"id": 505, "game_id": 341190, "filename": "OrbitGarden-android-bundle.zip", "display_name": "Android (zip)",
"size": 120000000, "storage": "hosted", "type": "default", "traits": ["p_android"],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
{"id": 506, "game_id": 341190, "filename": "Soundtrack.apk", "display_name": "Soundtrack",
"size": 1000, "storage": "hosted", "type": "soundtrack", "traits": [],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
{"id": 507, "game_id": 341190, "filename": "mirror.apk", "display_name": "Mirror",
"size": 1000, "storage": "external", "host": "example.com", "type": "default", "traits": ["p_android"],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
{"id": 508, "game_id": 341190, "filename": "OrbitGarden-mac.dmg", "display_name": "macOS",
"size": 1000, "storage": "hosted", "type": "default", "traits": ["p_osx"],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"}
]
}
+12 -1
View File
@@ -19,7 +19,7 @@ function grab(name) {
fail(`unbalanced ${name}`);
}
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "storePrice", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
const code = names.map(grab).join("");
@@ -56,6 +56,17 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
if (html.includes('role="alert"')) fail("a good listing showed the error state");
}
// A game the user owns on itch.io says so instead of "Free".
{
const p = page();
p.sourceState.detail = { app: { name: "Orbit Garden", offers: [{ ...offer, source: "itch-owned", source_name: "itch.io (my purchases)",
owned: true, owned_label: "Owned on itch.io", free: false }] }, choice: 0 };
if (p.renderAppDetail() !== true) fail("an owned listing reported a render failure");
const html = p.els.detailBody.innerHTML;
if (!html.includes('<div class="detail-price">Owned on itch.io</div>')) fail("owned listing has no Owned on itch.io label");
if (html.includes("No purchase needed")) fail("owned listing claims no purchase is needed");
}
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
{
const p = page();
+862
View File
@@ -0,0 +1,862 @@
"""My itch.io purchases: the account key, owned games and their Android downloads.
Offline: a fake HTTPS layer under urllib's real opener (so redirects and header
handling are the real ones) serves the recorded JSON in fixtures/itch_account.
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import contextlib, email.message, http.client, io, ipaddress, json, os, socket, sys, tempfile, threading, time
import unittest
import urllib.error, urllib.parse, urllib.request, zipfile
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, SourceLimited, _itch_account as account, _web, itch, search
import server
FIX = Path(__file__).parent / 'fixtures' / 'itch_account'
KEY = 'TESTKEYabcdefghijklmnopqrstuvwxyz0123456789'
OTHER = 'OTHERKEY0123456789abcdefghijklmnopqrstuv'
def apk_bytes():
out = io.BytesIO()
with zipfile.ZipFile(out, 'w') as z:
z.writestr('AndroidManifest.xml', b'\x03\x00\x08\x00')
z.writestr('classes.dex', b'dex\n035\x00')
return out.getvalue()
class Reply(io.BytesIO):
"""Just enough of http.client.HTTPResponse for urllib's handlers."""
def __init__(self, url, code=200, body=b'', headers=None):
super().__init__(body if isinstance(body, bytes) else json.dumps(body).encode())
self.url, self.code, self.status, self.msg, self.reason = url, code, code, 'fake', 'fake'
self.headers = email.message.Message()
for k, v in (headers or {}).items():
self.headers[k] = v
def info(self):
return self.headers
def geturl(self):
return self.url
def getcode(self):
return self.code
def getheader(self, name, default=None):
return self.headers.get(name, default)
class FakeConnection:
"""_web._connect's http.client connection, answered from the same routes."""
def __init__(self, web, host):
self.web, self.host, self.reply = web, host, None
def request(self, method, path, headers=None):
req = urllib.request.Request('https://' + self.host + path, headers=headers or {})
self.reply = self.web(None, req)
def getresponse(self):
return self.reply
def close(self):
pass
# What the fake resolver answers: the storage host is public; the rest are traps.
ADDRESSES = {'storage.itch-cdn.example': '151.101.1.1', 'other-cdn.example': '151.101.2.2',
'localhost.': '127.0.0.1', 'frame.local': '192.168.1.20', '127.0.0x0.1': '127.0.0.1',
'intranet.example': '10.0.0.5', 'mapped.example': '::ffff:127.0.0.1', 'cgnat.example': '100.64.0.1',
'mixed.example': ('151.101.1.1', '10.0.0.5'), 'dual.example': ('2a04:4e42::81', '151.101.1.1'),
'nat64.example': '64:ff9b:1::a00:1', 'sitelocal.example': 'fec0::1',
'many.example': tuple('151.101.%d.1' % n for n in range(1, 9))}
REAL_CONNECT = _web._connect
class FakeItch:
"""Routes https requests by host and path; records every request with its headers."""
def __init__(self):
self.calls, self.routes = [], {}
def route(self, host_path, reply):
self.routes[host_path] = reply
def __call__(self, handler, req):
url = urllib.parse.urlsplit(req.full_url)
headers = {k.lower(): v for k, v in req.header_items()}
self.calls.append((req.full_url, headers))
reply = self.routes.get(url.hostname + url.path)
if reply is None:
return Reply(req.full_url, 404, {'errors': ['not found']})
if callable(reply):
reply = reply(req, urllib.parse.parse_qs(url.query))
if isinstance(reply, Exception):
raise reply
if isinstance(reply, Reply):
return reply
return Reply(req.full_url, 200, reply)
def urls(self, host_path):
return [u for u, _ in self.calls if urllib.parse.urlsplit(u).hostname + urllib.parse.urlsplit(u).path == host_path]
def owned_pages(req, query):
page = query['page'][0]
return {'1': json.loads((FIX / 'owned-keys-1.json').read_text()),
'2': json.loads((FIX / 'owned-keys-2.json').read_text())}.get(page, {'page': int(page), 'owned_keys': []})
class ItchPurchases(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
for target, value in [(account.frame_host, 'data_dir'), (search.frame_host, 'data_dir')]:
p = patch.object(target, value, lambda *parts: root.joinpath('data', *parts))
p.start()
self.addCleanup(p.stop)
(root / 'cache').mkdir()
for p in (patch.object(_web, 'cache', return_value=str(root / 'cache')),
patch.object(_web.frame_host, 'cache_dir', lambda *parts: root.joinpath('cache', *parts))):
p.start()
self.addCleanup(p.stop)
self.web = FakeItch()
self.web.route('api.itch.io/profile', {'user': {'username': 'player', 'id': 1}})
self.web.route('api.itch.io/profile/owned-keys', owned_pages)
self.web.route('api.itch.io/games/341190/uploads', json.loads((FIX / 'uploads.json').read_text()))
p = patch('urllib.request.HTTPSHandler.https_open', autospec=True, side_effect=self.web)
p.start()
self.addCleanup(p.stop)
p = patch('urllib.request.HTTPHandler.http_open', side_effect=AssertionError('plain HTTP in test'))
p.start()
self.addCleanup(p.stop)
real_lookup = _web.socket.getaddrinfo
def lookup(host, port, *args, **kwargs):
if host not in ADDRESSES:
try:
ipaddress.ip_address(host) # IP literals (and the in-process test server) need no DNS
return real_lookup(host, port, *args, **kwargs)
except ValueError:
pass
if host == 'localhost':
return real_lookup(host, port, *args, **kwargs)
raise _web.socket.gaierror('unknown test host ' + host)
found = ADDRESSES[host]
return [(10 if ':' in a else 2, 1, 6, '', (a, port)) for a in ([found] if isinstance(found, str) else found)]
self.connected, self.unreachable = [], set()
def connect(host, address, timeout, deadline):
self.connected.append((host, address))
if address[0] in self.unreachable:
raise OSError('Network is unreachable')
self.assertLessEqual(deadline - time.monotonic(), _web.CONNECT_BUDGET)
return FakeConnection(self.web, host)
for p in (patch.object(_web.socket, 'getaddrinfo', lookup), patch.object(_web, '_connect', connect)):
p.start()
self.addCleanup(p.stop)
account._forget()
self.addCleanup(account._forget)
_web._limited.clear()
self.addCleanup(_web._limited.clear)
search._status.clear()
def owned_source(self):
return next(s for s in itch.sources() if s['id'] == itch.OWNED)
def connect(self):
self.assertTrue(account.connect(KEY)['itch']['connected'])
# -- the key ---------------------------------------------------------------
def test_connect_checks_the_key_then_saves_it_0600(self):
result = account.connect(' ' + KEY + '\n')
self.assertEqual(result['message'], 'Connected to itch.io as player')
self.assertEqual(result['itch'], {'connected': True, 'username': 'player'})
self.assertNotIn(KEY, json.dumps(result))
url, headers = self.web.calls[0]
self.assertEqual(url, 'https://api.itch.io/profile')
self.assertEqual(headers['authorization'], 'Bearer ' + KEY)
self.assertEqual(json.loads(account.path().read_text())['key'], KEY)
if os.name == 'posix':
self.assertEqual(os.stat(account.path()).st_mode & 0o777, 0o600)
self.assertEqual([p.name for p in account.path().parent.iterdir()], ['itch-account.json']) # no .tmp left
self.assertNotIn(KEY, json.dumps(account.status()))
def test_reconnecting_rewrites_the_file_0600_even_if_it_was_wider(self):
self.connect()
os.chmod(account.path(), 0o644)
account.connect(OTHER)
self.assertEqual(json.loads(account.path().read_text())['key'], OTHER)
if os.name == 'posix':
self.assertEqual(os.stat(account.path()).st_mode & 0o777, 0o600)
def test_bad_or_revoked_key_stays_disconnected(self):
for reply in (Reply('https://api.itch.io/profile', 401, {'errors': ['invalid key']}),
Reply('https://api.itch.io/profile', 403, b'Forbidden'),
{'errors': ['invalid key']}):
self.web.route('api.itch.io/profile', reply)
with self.assertRaisesRegex(account.BadKey, 'did not accept the API key') as caught:
account.connect(KEY)
self.assertNotIn(KEY, str(caught.exception))
self.assertFalse(account.path().exists())
self.assertEqual(account.status(), {'connected': False, 'username': None})
self.assertEqual([s['id'] for s in itch.sources()], ['itch', 'itch-quest'])
with self.assertRaisesRegex(account.BadKey, 'does not look like'):
account.connect('short')
with self.assertRaisesRegex(account.BadKey, 'does not look like'):
account.connect(KEY + '\r\nX-Injected: 1')
self.assertEqual(len(self.web.calls), 3) # malformed keys never reach itch.io
def test_a_refused_request_with_a_working_key_keeps_the_key(self):
self.connect()
self.web.route('api.itch.io/games/341190/uploads', Reply('https://api.itch.io/games/341190/uploads', 403, b''))
with self.assertRaisesRegex(SourceError, r'refused this request \(HTTP 403\)') as caught:
itch.details(self.owned_source(), '341190')
self.assertNotIsInstance(caught.exception, account.BadKey)
self.assertTrue(account.connected())
def test_a_key_revoked_later_is_removed_with_a_clear_message(self):
self.connect()
self.web.route('api.itch.io/profile/owned-keys', Reply('https://api.itch.io/profile/owned-keys', 401, b''))
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', 401, b''))
with self.assertRaisesRegex(account.BadKey, 'no longer accepts the saved API key'):
itch.search(self.owned_source(), '')
self.assertFalse(account.path().exists())
self.assertNotIn(itch.OWNED, [s['id'] for s in itch.sources()])
def test_disconnect_deletes_the_key_file(self):
self.connect()
self.assertIn(itch.OWNED, [s['id'] for s in itch.sources()])
result = account.disconnect()
self.assertFalse(account.path().exists())
self.assertEqual(result['itch'], {'connected': False, 'username': None})
self.assertNotIn(itch.OWNED, [s['id'] for s in itch.sources()])
with self.assertRaisesRegex(SourceError, 'Connect your itch.io account first'):
account.owned_keys()
account.disconnect() # already gone: fine
# -- owned games -------------------------------------------------------------
def test_owned_keys_follow_pages_until_an_empty_one(self):
self.connect()
keys = account.owned_keys()
self.assertEqual([k['id'] for k in keys], [9001, 9002, 8001, 8002])
pages = self.web.urls('api.itch.io/profile/owned-keys')
self.assertEqual([urllib.parse.parse_qs(urllib.parse.urlsplit(u).query) for u in pages],
[{'page': [str(n)], 'per_page': ['500']} for n in (1, 2, 3)])
self.assertTrue(all('Bearer ' + KEY == h['authorization'] for _, h in self.web.calls))
self.assertTrue(all(KEY not in u for u, _ in self.web.calls))
account.owned_keys() # cached in memory, not on disk
self.assertEqual(len(self.web.urls('api.itch.io/profile/owned-keys')), 3)
self.assertFalse(any(KEY in p.read_text(errors='ignore') or b'Orbit' in p.read_bytes()
for p in Path(_web.cache()).iterdir()))
def test_page_cap_stops_a_server_that_never_ends(self):
self.connect()
self.web.route('api.itch.io/profile/owned-keys', lambda req, q: {'owned_keys': [{'id': int(q['page'][0])}]})
with patch.object(account, 'PAGES', 3):
self.assertEqual(len(account.owned_keys()), 3)
def test_search_lists_owned_android_games_labelled(self):
self.connect()
entries = itch.search(self.owned_source(), '')
self.assertEqual([(e['id'], e['name']) for e in entries], [('341190', 'Orbit Garden VR'), ('77', 'Free Quest Toy')])
orbit, toy = entries
self.assertEqual(orbit['download_key_id'], 9001) # the newest key for a game owned twice
self.assertTrue(orbit['owned'])
self.assertEqual(orbit['owned_label'], 'Owned on itch.io')
self.assertFalse(orbit['free'])
self.assertTrue(toy['free'])
self.assertIsNone(toy['icon']) # not an itch.io image host
self.assertEqual(orbit['page'], 'https://maker.itch.io/orbit-garden')
self.assertEqual(itch.search(self.owned_source(), 'zero gravity')[0]['id'], '341190')
# Through the store's search, owned (paid) games pass the free-only filter.
result = search.search('orbit', source=itch.OWNED)
self.assertEqual(result['sources'][0]['status'], 'ok')
offer = result['apps'][0]['offers'][0]
self.assertEqual((offer['source'], offer['source_name'], offer['owned_label']),
(itch.OWNED, 'itch.io (my purchases)', 'Owned on itch.io'))
self.assertNotIn(KEY, json.dumps(result))
def test_owned_games_show_under_either_vr_choice(self):
# itch.io doesn't say which purchases are VR, so neither filter hides them; other
# games of unknown kind still count as flat.
self.connect()
both = ['Free Quest Toy', 'Orbit Garden VR']
for vr in (True, False):
names = sorted(a['name'] for a in search.search('', vr=vr, source=itch.OWNED)['apps'])
self.assertEqual(names, both)
flat = {'source': 'x', 'id': '1', 'name': 'Flat', 'summary': '', 'vr': None}
self.assertEqual(search.group([flat], vr=True), [])
self.assertEqual(len(search.group([flat], vr=False)), 1)
self.assertEqual(search.group([dict(flat, owned=True, vr=False)], vr=True), [])
def test_only_the_account_source_may_mark_paid_games_owned(self):
entry = {'free': False, 'owned': True}
self.assertFalse(search.obtainable({'id': 'fdroid'}, entry))
self.assertTrue(search.obtainable({'id': itch.OWNED, 'owned': True}, entry))
self.assertTrue(search.obtainable({'id': 'fdroid'}, {'free': True}))
def test_android_upload_selection(self):
uploads = json.loads((FIX / 'uploads.json').read_text())['uploads']
# .apk files before other Android files, full games before demos, newest first; never
# soundtracks, externally hosted files or other platforms.
self.assertEqual([u['id'] for u in account.android_uploads(uploads)], [503, 502, 504, 505])
self.assertTrue(account.is_android({'filename': 'Game.APK'}))
self.assertTrue(account.is_android({'filename': 'game.zip', 'p_android': True}))
self.assertTrue(account.is_android({'filename': 'game.zip', 'platforms': {'android': 'all'}}))
self.assertFalse(account.is_android({'filename': 'game.zip', 'traits': ['p_windows']}))
self.assertEqual(account.android_uploads({'not': 'a list'}), [])
def test_details_offer_the_android_uploads(self):
self.connect()
entry = itch.details(self.owned_source(), '341190')
self.assertTrue(entry['downloadable'])
self.assertEqual([v['upload_id'] for v in entry['versions']], [503, 502, 504, 505])
self.assertEqual((entry['version'], entry['size'], entry['updated']), ('Quest 1.2', 115343360, '2026-04-03'))
self.assertTrue(entry['versions'][2]['demo'])
url = self.web.urls('api.itch.io/games/341190/uploads')[0]
self.assertEqual(url, 'https://api.itch.io/games/341190/uploads?download_key_id=9001')
with self.assertRaisesRegex(SourceError, 'not among'):
itch.details(self.owned_source(), '3') # owned, but not for Android
self.web.route('api.itch.io/games/77/uploads', {'uploads': []})
self.assertFalse(itch.details(self.owned_source(), '77')['downloadable'])
with self.assertRaisesRegex(SourceError, 'no Android download'):
itch.download(self.owned_source(), '77')
def test_download_url_building(self):
self.assertEqual(account.download_url(503, 9001),
'https://api.itch.io/uploads/503/download?download_key_id=9001')
for bad in [(0, 1), (1, None), ('5', 1), (True, 1), (-2, 1)]:
with self.assertRaises(SourceError):
account.download_url(*bad)
# -- downloads -----------------------------------------------------------------
def storage(self, location='https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?sig=abc'):
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 302, b'', {'Location': location}))
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk', Reply(location, 200, apk_bytes()))
def test_download_sends_the_key_to_itch_only_and_strips_it_on_redirect(self):
self.connect()
self.storage()
result = itch.download(self.owned_source(), '341190')
self.assertTrue(os.path.isfile(result['apk']))
self.assertFalse(result['verified'])
api, cdn = self.web.calls[-2:]
self.assertEqual(api[0], 'https://api.itch.io/uploads/503/download?download_key_id=9001')
self.assertEqual(api[1]['authorization'], 'Bearer ' + KEY)
self.assertTrue(cdn[0].startswith('https://storage.itch-cdn.example/'))
self.assertNotIn('authorization', cdn[1])
# Connected to the address that was checked, not to a fresh lookup.
self.assertEqual(self.connected, [('storage.itch-cdn.example', ('151.101.1.1', 443))])
self.assertTrue(all(KEY not in u for u, _ in self.web.calls))
with self.assertRaisesRegex(SourceError, 'version codes'):
itch.download(self.owned_source(), '341190', version_code=3)
def test_download_also_accepts_a_json_url_or_the_file_itself(self):
self.connect()
self.storage()
location = 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?sig=abc'
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200, {'url': location},
{'Content-Type': 'application/json'}))
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertNotIn('authorization', self.web.calls[-1][1])
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200, apk_bytes(),
{'Content-Type': 'application/octet-stream'}))
self.connected.clear()
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertEqual(self.connected, []) # api.itch.io served it; no storage host involved
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200, {'nothing': 1},
{'Content-Type': 'application/json'}))
with self.assertRaisesRegex(SourceError, 'did not say where'):
itch.download(self.owned_source(), '341190')
def test_storage_redirects_are_checked_on_every_hop(self):
self.connect()
self.storage('https://other-cdn.example/hop')
self.web.route('other-cdn.example/hop', Reply('https://other-cdn.example/hop', 302, b'',
{'Location': 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk'}))
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertEqual([h for h, _ in self.connected], ['other-cdn.example', 'storage.itch-cdn.example'])
self.web.route('other-cdn.example/hop', Reply('https://other-cdn.example/hop', 302, b'',
{'Location': 'https://intranet.example/a.apk'}))
with self.assertRaisesRegex(SourceError, 'private network address'):
itch.download(self.owned_source(), '341190')
def test_redirects_to_plain_http_or_private_addresses_are_refused(self):
self.connect()
for location in ('http://storage.itch-cdn.example/a.apk', 'https://user:pw@cdn.example/a.apk',
'https://cdn.example:8443/a.apk'):
self.storage(location)
with self.assertRaisesRegex(SourceError, 'unexpected download URL'):
itch.download(self.owned_source(), '341190')
for location in ('https://localhost./a.apk', 'https://frame.local/a.apk', 'https://127.0.0x0.1/a.apk',
'https://intranet.example/a.apk', 'https://mapped.example/a.apk',
'https://cgnat.example/a.apk', 'https://mixed.example/a.apk',
'https://127.0.0.1/a.apk', 'https://[::1]/a.apk', 'https://169.254.169.254/a.apk',
'https://nat64.example/a.apk', 'https://[4000::1]/a.apk', 'https://[64:ff9b:1::a00:1]/a.apk',
'https://[fec0::1]/a.apk', 'https://sitelocal.example/a.apk'):
self.storage(location)
with self.assertRaisesRegex(SourceError, 'private network address'):
itch.download(self.owned_source(), '341190')
self.assertEqual(self.connected, [])
self.assertEqual({urllib.parse.urlsplit(u).hostname for u, _ in self.web.calls}, {'api.itch.io'})
def test_public_address_rules(self):
for ip in ('151.101.1.1', '8.8.8.8', '2a04:4e42::81', '2001:4860:4860::8888'):
self.assertTrue(_web.public_address(ip), ip)
for ip in ('127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.0.1', '169.254.169.254', '100.64.0.1',
'0.0.0.0', '224.0.0.1', '255.255.255.255', '240.0.0.1', '::1', '::', 'fe80::1%en0', 'fc00::1',
'ff02::1', 'not an ip',
'192.0.0.8', '192.0.0.11', '192.0.0.255', '192.0.0.170', '192.0.0.9', '192.0.0.1', # 3.9: global
'192.0.2.1', '192.88.99.1', '198.18.0.1', '198.19.255.255', '198.51.100.7', '203.0.113.9',
'0.1.2.3', '10.255.255.255', '172.31.0.1', '239.1.1.1', '254.0.0.1',
'4000::1', '100::1', 'f000::1', # reserved: Python 3.9's is_global says True for 4000::1
'::ffff:10.0.0.1', '::ffff:127.0.0.1', '::ffff:8.8.8.8', # IPv4-mapped
'::a00:1', '::8.8.8.8', # IPv4-compatible
'64:ff9b::a00:1', '64:ff9b::808:808', # NAT64 well-known prefix
'64:ff9b:1::a00:1', '64:ff9b:1::808:808', # NAT64 local-use: 3.9's is_global says True
'2002:a00:1::1', '2002:808:808::1', # 6to4
'2001:0:4136:e378:8000:63bf:3fff:fdd2', # Teredo
'fec0::1', 'feff::1', # site-local: Python 3.9's is_global says True
'fc00::1', 'fd12:3456::1', 'fe80::1', 'ff00::1', 'ff0e::1', '8000::1', 'c000::1',
'2001:db8::1', '3fff::1', # documentation
'2001:10::1', '2001:20::1', '2001:2::1'): # ORCHID, ORCHIDv2, benchmarking
self.assertFalse(_web.public_address(ip), ip)
self.assertEqual(_web.resolve_public('storage.itch-cdn.example'), [('151.101.1.1', 443)])
self.assertEqual(_web.resolve_public('dual.example'), [('2a04:4e42::81', 443), ('151.101.1.1', 443)])
def test_a_storage_url_carrying_the_key_is_refused(self):
self.connect()
for location in ('https://storage.itch-cdn.example/a.apk?api_key=' + KEY,
'https://storage.itch-cdn.example/a.apk?API_KEY=someone-elses',
'https://storage.itch-cdn.example/a.apk?apikey=x',
'https://storage.itch-cdn.example/' + KEY + '/a.apk',
'https://storage.itch-cdn.example/a.apk#' + KEY[:10],
'https://storage.itch-cdn.example/a.apk?x=' + urllib.parse.quote('%' + KEY),
'https://' + KEY.lower() + '.itch-cdn.example/a.apk'):
self.storage(location)
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused') as caught:
itch.download(self.owned_source(), '341190')
self.assertNotIn(KEY.lower(), str(caught.exception).lower())
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200,
{'url': 'https://storage.itch-cdn.example/a.apk?api_key=' + KEY},
{'Content-Type': 'application/json'}))
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused'):
itch.download(self.owned_source(), '341190')
self.assertEqual(self.connected, [])
# A storage URL's own signature or token parameters are fine.
self.storage('https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?token=abc&Signature=x')
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
def test_a_later_redirect_carrying_the_key_is_refused_before_connecting(self):
self.connect()
bad = 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?api_key=' + KEY
for hops in (['https://other-cdn.example/hop1'], # 2nd hop carries the key
['https://other-cdn.example/hop1', 'https://other-cdn.example/hop2']): # 3rd hop
self.connected.clear()
self.storage(hops[0])
for here, there in zip(hops, hops[1:] + [bad]):
path = urllib.parse.urlsplit(here).path
self.web.route('other-cdn.example' + path, Reply(here, 302, b'', {'Location': there}))
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused') as caught:
itch.download(self.owned_source(), '341190')
self.assertNotIn(KEY, str(caught.exception))
self.assertEqual([h for h, _ in self.connected], ['other-cdn.example'] * len(hops))
self.assertFalse(any(KEY in u for u, _ in self.web.calls))
# The check runs on every hop before the name is even looked up.
seen = []
with patch.object(_web, 'resolve_public', side_effect=AssertionError('looked up')):
with self.assertRaisesRegex(SourceError, 'no thanks'):
_web.public_apk('https://storage.itch-cdn.example/a.apk', 'itch.io',
check=lambda u: seen.append(u) or (_ for _ in ()).throw(SourceError('no thanks')))
self.assertEqual(seen, ['https://storage.itch-cdn.example/a.apk'])
def test_a_partial_match_before_the_key_cannot_hide_its_start(self):
key = 'abcdefghABCDEFGHi1234567890'
for text in (key[:8].upper() + key.upper(), key[:9] + key, 'xx' + key[:8] + key + 'yy'):
out = account._clean(text, key)
self.assertNotIn('1234567890', out, (text, out))
import time
t0 = time.monotonic(); account._clean(('abcdefgh' * 12500)[:100000], key)
self.assertLess(time.monotonic() - t0, 2.0)
def test_redaction_catches_every_spelling_of_the_key(self):
hexed = ''.join('%%%02X' % ord(c) for c in KEY)
for spelling in (KEY, KEY.lower(), KEY.upper(), hexed, hexed.lower(),
KEY[:5] + hexed[15:30] + KEY[10:], # partly encoded
KEY[:9], hexed[:30].lower(), # starts of it, 8+ characters
''.join('%%%02x' % ord(c) for c in KEY.lower()), # encoded after a case change
''.join('%%%02X' % ord(c) for c in KEY.swapcase()),
'%%%02x' % ord(KEY[0].swapcase()) + KEY[1:]): # one encoded, recased letter
cleaned = account._clean('before ' + spelling + ' after', KEY)
self.assertEqual(cleaned, 'before [API key] after', spelling)
double = ''.join('%%25%02X' % ord(c) for c in KEY) # percent-encoded twice
for spelling in (double, double.lower(), double.upper(), double[:5 * 9], # full, hex recased, 9-char start
''.join('%%25%02x' % ord(c) for c in KEY.lower()), # the key recased, then double-encoded
KEY[:4] + double[20:45] + hexed[27:33] + KEY[11:]): # literal, double and single mixed
self.assertEqual(account._clean('before ' + spelling + ' after', KEY), 'before [API key] after', spelling)
self.assertEqual(account._clean(double[:5 * 7], KEY), double[:5 * 7]) # 7 characters: kept
self.assertTrue(account._carries_key('https://cdn.example/a?x=' + double, KEY))
self.assertTrue(account._carries_key('https://cdn.example/' + double[:5 * 8].lower() + '/a', KEY))
self.assertEqual(account._clean('TESTKEY is short', KEY), 'TESTKEY is short') # 7 characters: kept
self.assertEqual(account._clean(KEY + KEY.lower(), KEY), '[API key][API key]')
for url in ('https://cdn.example/a?x=' + hexed, 'https://cdn.example/a?x=' + hexed.lower(),
'https://cdn.example/' + KEY.upper() + '/a', 'https://cdn.example/a?x=' + urllib.parse.quote_plus(KEY)):
self.assertTrue(account._carries_key(url, KEY), url)
self.assertFalse(account._carries_key('https://cdn.example/a?token=abc&Signature=x', KEY))
# Storage errors carrying an encoded or lowercased key lose it, keeping their kind.
self.connect()
self.storage()
with patch.object(_web, 'public_apk', side_effect=SourceLimited('slow ' + hexed.lower(), 42)):
with self.assertRaises(SourceLimited) as caught:
itch.download(self.owned_source(), '341190')
self.assertEqual((str(caught.exception), caught.exception.retry_after), ('slow [API key]', 42))
with patch.object(_web, 'public_apk', side_effect=SourceError('host ' + KEY.lower() + '.cdn')):
with self.assertRaisesRegex(SourceError, r'^host \[API key\]\.cdn$'):
itch.download(self.owned_source(), '341190')
def test_storage_errors_lose_the_key_but_keep_their_kind(self):
self.connect()
self.storage()
with patch.object(_web, 'public_apk', side_effect=SourceLimited('slow down ' + KEY, 90)):
with self.assertRaises(SourceLimited) as caught:
itch.download(self.owned_source(), '341190')
self.assertEqual((str(caught.exception), caught.exception.retry_after), ('slow down [API key]', 90))
with patch.object(_web, 'public_apk', side_effect=SourceError('Could not find the download server ' + KEY)):
with self.assertRaisesRegex(SourceError, r'^Could not find the download server \[API key\]$') as caught:
itch.download(self.owned_source(), '341190')
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(caught.exception.__suppress_context__)
def test_broken_http_responses_are_readable_and_lose_the_key(self):
self.connect()
class Truncated(Reply):
def read(self, *args):
raise http.client.IncompleteRead(b'{"owned_keys": [' + KEY.encode(), 1000)
cases = [http.client.BadStatusLine('HTTP/1.1 ' + KEY + ' oops'),
lambda req, q: Truncated(req.full_url, 200, b''),
http.client.RemoteDisconnected('closed after ' + KEY)]
for case in cases:
account._forget()
self.web.route('api.itch.io/profile/owned-keys', case)
with self.assertRaisesRegex(SourceError, '^Could not reach itch.io: ') as caught:
account.owned_keys()
self.assertNotIn(KEY, str(caught.exception))
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(caught.exception.__suppress_context__)
self.assertTrue(account.connected())
# The same on the download request, for each kind of answer it reads.
for case in (http.client.BadStatusLine('HTTP/1.1 ' + KEY),
lambda req, q: Truncated(req.full_url, 200, b'', {'Content-Type': 'application/json'}),
lambda req, q: Truncated(req.full_url, 200, b'', {'Content-Type': 'application/octet-stream'})):
self.web.route('api.itch.io/uploads/503/download', case)
with self.assertRaises(SourceError) as caught:
account.download(503, 9001)
self.assertNotIn(KEY, str(caught.exception))
self.assertRegex(str(caught.exception), '^Could not (reach itch.io|download APK): ')
def test_connecting_shares_one_deadline_across_addresses(self):
clock, deadlines = [1000.0], []
def slow(host, address, timeout, deadline):
deadlines.append(deadline)
clock[0] += 7 # each address hangs for 7 s before failing
raise socket.timeout('timed out')
with patch.object(_web.time, 'monotonic', lambda: clock[0]), patch.object(_web, '_connect', slow):
with self.assertRaises(OSError):
_web._public_connection('many.example', 60)
self.assertEqual(len(set(deadlines)), 1) # one deadline for every attempt
self.assertEqual(deadlines[0], 1000.0 + _web.CONNECT_BUDGET)
self.assertEqual(len(deadlines), 3) # 8 addresses, but only 21 s of trying, not 56
self.assertLessEqual(clock[0] - 1000.0, _web.CONNECT_BUDGET + 7)
deadlines.clear()
clock[0] = 1000.0
with patch.object(_web.time, 'monotonic', lambda: clock[0]), patch.object(_web, '_connect', slow):
with self.assertRaises(OSError):
_web._public_connection('many.example', 5) # the caller's shorter timeout wins
self.assertEqual(deadlines, [1005.0])
def test_a_stalled_handshake_stops_at_the_deadline(self):
# A local server that accepts and then says nothing: the TLS handshake never completes.
server = socket.socket()
server.bind(('127.0.0.1', 0))
server.listen(1)
self.addCleanup(server.close)
timeouts = []
real_settimeout = socket.socket.settimeout
def settimeout(sock, value):
timeouts.append(value)
return real_settimeout(sock, value)
start = time.monotonic()
# No thread is left behind to shut the connection later: the deadline is only socket timeouts.
with patch.object(_web.threading, 'Timer', side_effect=AssertionError('watchdog thread')), \
patch.object(socket.socket, 'settimeout', settimeout):
with self.assertRaises(OSError):
REAL_CONNECT('storage.itch-cdn.example', server.getsockname(), 60, start + 0.5)
self.assertLess(time.monotonic() - start, 3)
self.assertTrue(timeouts and all(0 < t <= 0.5 for t in timeouts), timeouts) # what's left, each step
with patch.object(_web.socket, 'create_connection', side_effect=AssertionError('connected')):
with self.assertRaises(socket.timeout): # a deadline already gone: no connection attempt at all
REAL_CONNECT('storage.itch-cdn.example', server.getsockname(), 60, time.monotonic() - 1)
def test_an_unreachable_address_falls_back_to_the_next_checked_one(self):
self.connect()
self.storage('https://dual.example/upload/503/OrbitGarden-Quest.apk')
self.web.route('dual.example/upload/503/OrbitGarden-Quest.apk', Reply('https://dual.example/', 200, apk_bytes()))
self.unreachable.add('2a04:4e42::81')
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertEqual(self.connected, [('dual.example', ('2a04:4e42::81', 443)), ('dual.example', ('151.101.1.1', 443))])
self.unreachable.add('151.101.1.1')
with self.assertRaisesRegex(SourceError, '^Could not download APK: Network is unreachable$'):
itch.download(self.owned_source(), '341190')
def test_storage_refusals_are_download_errors_not_rate_limits(self):
self.connect()
self.storage()
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk',
Reply('https://storage.itch-cdn.example/x', 403, b'denied'))
with self.assertRaisesRegex(SourceError, r"itch.io's download server refused the file \(HTTP 403\)") as caught:
itch.download(self.owned_source(), '341190')
self.assertNotIsInstance(caught.exception, SourceLimited)
self.assertTrue(account.connected())
self.assertEqual(_web.wait_time('https://api.itch.io/'), 0)
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk',
Reply('https://storage.itch-cdn.example/x', 429, b'', {'Retry-After': '60'}))
with self.assertRaises(SourceLimited):
itch.download(self.owned_source(), '341190')
def test_a_key_revoked_after_details_were_cached_is_removed_at_download(self):
self.connect()
itch.details(self.owned_source(), '341190') # uploads and owned keys now cached
for code in (401, 403):
self.connect()
self.web.route('api.itch.io/uploads/503/download', Reply('https://api.itch.io/uploads/503/download', code, b''))
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', code, b''))
with self.assertRaisesRegex(account.BadKey, 'no longer accepts the saved API key'):
account.download(503, 9001)
self.assertFalse(account.path().exists())
self.web.route('api.itch.io/profile', {'user': {'username': 'player'}})
# The key still works, only this download is refused: keep the key, say so.
self.connect()
self.web.route('api.itch.io/uploads/503/download', Reply('https://api.itch.io/uploads/503/download', 403, b''))
with self.assertRaisesRegex(SourceError, r'refused this request \(HTTP 403\)') as caught:
account.download(503, 9001)
self.assertNotIsInstance(caught.exception, (account.BadKey, SourceLimited))
self.assertTrue(account.connected())
def test_a_disconnect_during_the_key_check_wins(self):
checking, release = threading.Event(), threading.Event()
def slow_profile(req, query):
checking.set()
release.wait(5)
return {'user': {'username': 'player'}}
self.web.route('api.itch.io/profile', slow_profile)
outcome = {}
def run():
try:
outcome['result'] = account.connect(KEY)
except SourceError as e:
outcome['error'] = e
worker = threading.Thread(target=run)
worker.start()
self.assertTrue(checking.wait(5))
account.disconnect()
release.set()
worker.join(5)
self.assertRegex(str(outcome.get('error')), 'changed while the key was being checked; nothing was saved')
self.assertFalse(account.path().exists())
self.assertFalse(account.connected())
def test_an_old_refusal_does_not_undo_a_reconnect_with_the_same_key(self):
self.connect()
listing, release, profile_ok = threading.Event(), threading.Event(), [True]
def slow_owned(req, query):
listing.set()
release.wait(5)
return Reply(req.full_url, 401, b'')
self.web.route('api.itch.io/profile/owned-keys', slow_owned)
self.web.route('api.itch.io/profile', lambda req, q: {'user': {'username': 'player'}} if profile_ok[0]
else Reply(req.full_url, 401, b''))
outcome = {}
def run():
try:
account.owned_keys()
except SourceError as e:
outcome['error'] = e
worker = threading.Thread(target=run)
worker.start()
self.assertTrue(listing.wait(5))
account.disconnect()
account.connect(KEY) # same key, connected again while the old listing waits
profile_ok[0] = False # the old listing's confirmation now sees a refusal
release.set()
worker.join(5)
self.assertIsInstance(outcome.get('error'), account.BadKey)
self.assertTrue(account.path().exists()) # the fresh connection survives
self.assertEqual(json.loads(account.path().read_text())['key'], KEY)
def test_redacted_text_is_not_mistaken_for_a_key_error(self):
self.connect()
before = len(self.web.urls('api.itch.io/profile'))
self.web.route('api.itch.io/profile/owned-keys', {'errors': ['Invalid request: ' + KEY]})
with self.assertRaisesRegex(SourceError, r'^itch.io: Invalid request: \[API key\]$') as caught:
account.owned_keys()
self.assertNotIsInstance(caught.exception, account.BadKey)
self.assertTrue(account.connected())
self.assertEqual(len(self.web.urls('api.itch.io/profile')), before) # not even a key check needed
def test_an_invalid_key_error_elsewhere_is_confirmed_before_deleting(self):
self.connect()
self.web.route('api.itch.io/profile/owned-keys', {'errors': ['invalid key']})
with self.assertRaisesRegex(SourceError, '^itch.io: invalid key$') as caught:
account.owned_keys() # /profile still accepts the key: keep it
self.assertNotIsInstance(caught.exception, account.BadKey)
self.assertTrue(account.connected())
account._forget()
self.web.route('api.itch.io/profile', {'errors': ['invalid key']})
with self.assertRaisesRegex(account.BadKey, 'no longer accepts'):
account.owned_keys()
self.assertFalse(account.connected())
def test_concurrent_connects_never_share_a_temporary_file(self):
made = []
real = account.tempfile.mkstemp
def mkstemp(*args, **kwargs):
fd, name = real(*args, **kwargs)
made.append(name)
if os.name == 'posix':
self.assertEqual(os.stat(name).st_mode & 0o777, 0o600)
return fd, name
with patch.object(account.tempfile, 'mkstemp', mkstemp):
workers = [threading.Thread(target=lambda k=k: self.swallow(account.connect, k)) for k in (KEY, OTHER) * 3]
for w in workers:
w.start()
for w in workers:
w.join(5)
self.assertEqual(len(made), len(set(made)))
self.assertIn(json.loads(account.path().read_text())['key'], (KEY, OTHER))
self.assertEqual([p.name for p in account.path().parent.iterdir()], ['itch-account.json'])
@staticmethod
def swallow(fn, *args):
try:
fn(*args)
except SourceError:
pass # a connect overtaken by another one refuses to save: fine here
def test_existing_hosts_still_strip_authorization_on_redirect(self):
req = urllib.request.Request('https://api.github.com/a', headers={'Authorization': 'Bearer x'})
nxt = _web.Redirect(('api.github.com',)).redirect_request(req, None, 302, 'Found', {}, 'https://api.github.com/b')
self.assertFalse(nxt.has_header('Authorization'))
with self.assertRaises(SourceError):
_web.Redirect(('api.github.com',)).redirect_request(req, None, 302, 'Found', {}, 'https://evil.example/b')
def test_install_uses_the_normal_apk_install_path(self):
import frame_android
self.connect()
self.storage()
calls = []
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
with zipfile.ZipFile(apk_path) as z:
self.assertIn('AndroidManifest.xml', z.namelist())
calls.append((name, source))
return {'package': 'com.maker.orbit', 'message': 'Installed'}
with patch.object(frame_android, 'install', install):
result = search.install(itch.OWNED, '341190')
self.assertEqual(calls, [('Orbit Garden VR', 'itch.io (my purchases)')])
self.assertEqual(result['package'], 'com.maker.orbit')
# -- errors never carry the key ------------------------------------------------
def test_errors_are_readable_and_never_contain_the_key(self):
self.connect()
cases = [
(Reply('https://api.itch.io/profile/owned-keys', 429, b'', {'Retry-After': '120'}),
SourceLimited, '^itch.io is limiting requests; try again in 2 minutes$'),
(Reply('https://api.itch.io/profile/owned-keys', 503, b''), SourceError, 'HTTP error 503; try again later'),
(urllib.error.URLError('connect to api.itch.io with ' + KEY + ' failed'), SourceError,
r'^Could not reach itch.io: connect to api.itch.io with \[API key\] failed$'),
(OSError('timed out'), SourceError, '^Could not reach itch.io: timed out$'),
(Reply('https://api.itch.io/profile/owned-keys', 200, b'<html>'), SourceError, 'invalid response'),
({'errors': ['echo ' + KEY]}, SourceError, r'^itch.io: echo \[API key\]$'),
# The key straddles the 200-character cut: no piece of it may survive.
({'errors': ['x' * 180, KEY]}, SourceError, r'^itch.io: x{180}; \[API key\]$'),
({'errors': ['x' * 190 + KEY]}, SourceError, r'^itch.io: x{190}\[API key\]$'),
]
self.assertEqual(account._clean('cut at ' + KEY[:9], KEY), 'cut at [API key]') # a truncated key's start
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
for reply, kind, message in cases:
account._forget()
_web._limited.clear()
self.web.route('api.itch.io/profile/owned-keys', reply)
with self.assertRaisesRegex(kind, message) as caught:
account.owned_keys()
self.assertNotIn(KEY, str(caught.exception))
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(account.connected()) # not the key's fault: stays connected
# The store's search reports the failure as a non-fatal source status.
account._forget()
result = search.search('', source=itch.OWNED)
self.assertEqual(result['sources'][0]['status'], 'error')
self.assertNotIn(KEY, json.dumps(result) + stderr.getvalue())
def test_server_api_connects_and_never_echoes_the_key(self):
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
self.addCleanup(httpd.server_close)
self.addCleanup(httpd.shutdown)
def request(method, path, body=None):
c = http.client.HTTPConnection('127.0.0.1', httpd.server_port)
c.request(method, path, json.dumps(body) if body is not None else None,
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
r = c.getresponse()
out = r.status, r.read().decode()
c.close()
return out
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', 401, b''))
bad = request('POST', '/api/sources', {'action': 'itch-connect', 'key': KEY})
self.web.route('api.itch.io/profile', {'user': {'username': 'player'}})
good = request('POST', '/api/sources', {'action': 'itch-connect', 'key': KEY})
listed = request('GET', '/api/sources')
empty = request('POST', '/api/sources', {'action': 'itch-connect', 'key': ' '})
gone = request('POST', '/api/sources', {'action': 'itch-disconnect'})
self.assertEqual(bad[0], 400)
self.assertIn('did not accept the API key', json.loads(bad[1])['error'])
self.assertEqual(good[0], 200)
self.assertEqual(json.loads(good[1])['itch'], {'connected': True, 'username': 'player'})
self.assertEqual(json.loads(listed[1])['itch'], {'connected': True, 'username': 'player'})
self.assertIn(itch.OWNED, [s['id'] for s in json.loads(listed[1])['sources']])
self.assertEqual(empty[0], 400)
self.assertEqual(json.loads(gone[1])['itch'], {'connected': False, 'username': None})
self.assertFalse(account.path().exists())
for text in (bad[1], good[1], listed[1], empty[1], gone[1], stderr.getvalue()):
self.assertNotIn(KEY, text)
if __name__ == '__main__':
unittest.main()
+337
View File
@@ -0,0 +1,337 @@
"""VR mod (UEVR) checks that need no headset or network: a fake Steam library on disk.
Run: python3 -m unittest discover -s tests
"""
import hashlib
import io
import itertools
import json
import stat
import subprocess
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_mods # noqa: E402
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
APPID = 1067310
def zip_bytes(files, links=()):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for name, data in files.items():
z.writestr(name, data)
for name in links:
info = zipfile.ZipInfo(name)
info.external_attr = (stat.S_IFLNK | 0o777) << 16
z.writestr(info, "/etc/passwd")
return buf.getvalue()
@unittest.skipIf(sys.platform == "win32", "the helper runs on the Frame (Linux): /proc, flock and POSIX prefix paths")
class FakeLibrary(unittest.TestCase):
"""A Steam library with Gravitas's real layout, and pinned downloads served from memory."""
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
home = Path(tmp.name)
self.steam = home / ".local/share/Steam"
apps = self.steam / "steamapps"
(apps / "common/Gravitas/SkyArk/Binaries/Win64").mkdir(parents=True)
(apps / "common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").write_bytes(b"MZ" * 100)
(apps / "common/Gravitas/Drop.exe").write_bytes(b"MZ")
self.prefix = apps / f"compatdata/{APPID}/pfx"
for sub in ("Roaming", "Local"):
(self.prefix / "drive_c/users/steamuser/AppData" / sub).mkdir(parents=True)
(apps / f"appmanifest_{APPID}.acf").write_text(
f'"AppState"\n{{\n\t"appid"\t\t"{APPID}"\n\t"name"\t\t"Gravitas"\n\t"installdir"\t\t"Gravitas"\n}}\n')
for name, value in (("STEAM", self.steam), ("CACHE", home / ".cache/frame-control/mods"),
("RECEIPTS", home / ".local/share/frame-control/mods")):
p = mock.patch.object(frame_mods, name, value)
p.start()
self.addCleanup(p.stop)
self.running = None
p = mock.patch.object(frame_mods, "game_pid", side_effect=lambda exe: self.running)
p.start()
self.addCleanup(p.stop)
self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ",
"openvr_api.dll": b"MZ"})}
self.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"})
self.pin(self.downloads)
def pin(self, downloads):
"""Serve these bytes and pin their real hashes in place of the published ones."""
for name in ("UEVR", "PYTHON"):
pinned = getattr(frame_mods, name)
p = mock.patch.object(frame_mods, name,
dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest()))
p.start()
self.addCleanup(p.stop)
p = mock.patch.object(frame_mods.urllib.request, "urlopen",
side_effect=lambda url, timeout: io.BytesIO(downloads[url]))
self.urlopen = p.start()
self.addCleanup(p.stop)
def managed(self):
return self.prefix / "drive_c/frame-control"
class Status(FakeLibrary):
def test_unreal_game_is_eligible_and_not_installed(self):
st = frame_mods.status(APPID)
self.assertEqual((st["eligible"], st["exe"], st["installed"], st["running"], st["prefix"]),
(True, "Drop-Win64-Shipping.exe", False, False, True))
def test_game_without_unreal_binary_is_refused(self):
(self.steam / "steamapps/common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").unlink()
self.assertFalse(frame_mods.status(APPID)["eligible"])
with self.assertRaisesRegex(frame_mods.Fail, "Unreal Engine"):
frame_mods.install(APPID)
self.urlopen.assert_not_called()
def test_uninstalled_game(self):
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
frame_mods.status(620)
class InstallUninstall(FakeLibrary):
def test_install_then_uninstall_leaves_prefix_as_found(self):
before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
res = frame_mods.install(APPID)
self.assertTrue(res["installed"])
self.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").is_file())
self.assertTrue((self.managed() / "python-3.14.7/python.exe").is_file())
self.assertIn("UEVRBackend.dll", (self.managed() / "python-3.14.7/frame_inject.py").read_text())
self.assertFalse(list(self.managed().glob(".staging-*")))
# What UEVR writes on its first injection.
roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
roaming.mkdir(parents=True)
(roaming / "log.txt").write_text("x")
(self.prefix / "drive_c/users/steamuser/AppData/Local/praydog/x").mkdir(parents=True)
frame_mods.uninstall(APPID)
after = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
self.assertEqual(after, before)
self.assertFalse(frame_mods.CACHE.exists(), "the downloads go once no game uses them")
self.assertIsNone(frame_mods.read_receipt(APPID))
def test_existing_uevr_settings_survive_uninstall(self):
profile = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
profile.mkdir(parents=True)
(profile / "config.txt").write_text("mine")
frame_mods.install(APPID)
frame_mods.uninstall(APPID)
self.assertEqual((profile / "config.txt").read_text(), "mine")
def test_second_install_is_a_no_op(self):
frame_mods.install(APPID)
calls = self.urlopen.call_count
self.assertIn("already installed", frame_mods.install(APPID)["message"])
self.assertEqual(self.urlopen.call_count, calls)
def test_refuses_while_game_runs(self):
self.running = 4242
with self.assertRaisesRegex(frame_mods.Fail, "running"):
frame_mods.install(APPID)
self.running = None
frame_mods.install(APPID)
self.running = 4242
with self.assertRaisesRegex(frame_mods.Fail, "running"):
frame_mods.uninstall(APPID)
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
def test_needs_a_prefix(self):
import shutil
shutil.rmtree(self.prefix.parent)
with self.assertRaisesRegex(frame_mods.Fail, "play it once"):
frame_mods.install(APPID)
def test_receipt_cannot_point_outside_the_prefix(self):
frame_mods.install(APPID)
r = frame_mods.read_receipt(APPID)
victim = self.steam / "steamapps/common/Gravitas"
r["remove"].append(str(self.prefix / "../../../common/Gravitas"))
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
frame_mods.uninstall(APPID)
self.assertTrue(victim.is_dir())
self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad")
def test_receipt_cannot_delete_other_things_in_the_prefix(self):
frame_mods.install(APPID)
r = frame_mods.read_receipt(APPID)
users = self.prefix / "drive_c/users"
r["remove"].append(str(users))
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
frame_mods.uninstall(APPID)
self.assertTrue(users.is_dir())
def test_failed_removal_keeps_the_receipt(self):
frame_mods.install(APPID)
with mock.patch.object(frame_mods.shutil, "rmtree"): # e.g. a permission error, swallowed
with self.assertRaisesRegex(frame_mods.Fail, "couldn't remove"):
frame_mods.uninstall(APPID)
self.assertIsNotNone(frame_mods.read_receipt(APPID), "Remove can be tried again")
frame_mods.uninstall(APPID)
self.assertFalse(self.managed().exists())
def test_receipt_cannot_name_another_prefix(self):
frame_mods.install(APPID)
r = frame_mods.read_receipt(APPID)
r["prefix"] = "/"
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
with self.assertRaisesRegex(frame_mods.Fail, "isn't app"):
frame_mods.uninstall(APPID)
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
class Downloads(FakeLibrary):
def test_hash_mismatch_deletes_and_installs_nothing(self):
self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRBackend.dll": b"tampered"})
with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
frame_mods.install(APPID)
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
self.assertFalse(self.managed().exists())
self.assertIsNone(frame_mods.read_receipt(APPID))
def test_archive_escaping_its_folder_is_refused(self):
for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}),
zip_bytes({"UEVRBackend.dll": b"MZ"}, links=["openvr_api.dll"])):
downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad})
self.pin(downloads)
with self.subTest(), self.assertRaises(frame_mods.Fail):
frame_mods.install(APPID)
self.assertFalse((self.prefix / "drive_c/evil.dll").exists())
self.assertFalse(list(self.managed().glob("*")) if self.managed().exists() else [])
import shutil
shutil.rmtree(frame_mods.CACHE, ignore_errors=True)
def test_oversized_archive_is_refused(self):
with mock.patch.object(frame_mods, "MAX_UNPACKED", 3):
with self.assertRaisesRegex(frame_mods.Fail, "more than expected"):
frame_mods.install(APPID)
def test_oversized_download_is_stopped(self):
with mock.patch.object(frame_mods, "MAX_DOWNLOAD", 10):
with self.assertRaisesRegex(frame_mods.Fail, "bigger than expected"):
frame_mods.install(APPID)
self.assertFalse(list(frame_mods.CACHE.glob("*")))
class Actions(FakeLibrary):
def test_one_change_at_a_time(self):
import fcntl
lock = Path(tempfile.mkdtemp()) / "mods.lock"
with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held:
fcntl.flock(held, fcntl.LOCK_EX)
with self.assertRaisesRegex(frame_mods.Fail, "another mod action"):
frame_mods.main(["install", str(APPID)])
self.assertFalse(frame_mods.status(APPID)["installed"])
self.assertEqual(frame_mods.main(["status", str(APPID)])["appid"], APPID, "status never waits")
def test_zero_frames_is_an_answer(self):
stats = json.dumps([{"key": f"steam.app.{APPID}", "frame_submits": 0}, {"operation": "status"}])
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=stats)):
self.assertEqual(frame_mods.wait("frames", 5, lambda: frame_mods.frame_submits(APPID)), 0)
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout='{"odd": 1}')):
self.assertIsNone(frame_mods.frame_submits(APPID))
class Start(FakeLibrary):
def environ(self, text):
"""The game's /proc/<pid>/environ; every other file reads normally."""
real = frame_mods.Path.read_text
return mock.patch.object(frame_mods.Path, "read_text", autospec=True, side_effect=lambda path, *a, **k:
text if str(path).startswith("/proc/") else real(path, *a, **k))
def test_needs_install_first(self):
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
frame_mods.start(APPID)
def run_start(self, reply, loaded=(False, True, True, True)):
frame_mods.install(APPID)
self.running = 4242
env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0"
with self.environ(env), \
mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \
mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \
mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \
mock.patch.object(frame_mods, "frame_submits", return_value=7), \
mock.patch.object(frame_mods.time, "sleep"):
return frame_mods.start(APPID), run
def test_injects_with_the_games_wine_settings(self):
res, run = self.run_start('noise\n{"pid": 320}\n')
self.assertEqual((res["frame_submits"], res["message"]), (7, "UEVR injected into Gravitas; SteamVR is receiving its frames"))
cmd, env = run.call_args.args[0], run.call_args.kwargs["env"]
self.assertEqual(cmd[:2], ["wine", "C:\\frame-control\\python-3.14.7\\python.exe"])
self.assertEqual(cmd[3:], ["Drop-Win64-Shipping.exe", "C:\\frame-control\\uevr-1.05",
"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\Drop-Win64-Shipping"])
self.assertEqual(env["WINEPREFIX"], "/x")
self.assertNotIn("WINESERVERSOCKET", env, "an inherited fd number means nothing to a new process")
self.assertIn("HOME", env, "fontconfig needs a HOME for its cache")
def test_reports_the_injection_error(self):
with self.assertRaisesRegex(frame_mods.Fail, "couldn't open"):
self.run_start('{"error": "couldn\'t open Drop-Win64-Shipping.exe (5)"}\n')
def test_success_is_checked_in_the_game(self):
with self.assertRaisesRegex(frame_mods.Fail, "has no UEVRBackend.dll"):
with mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)):
self.run_start('{"pid": 320}\n', loaded=[False] * 40)
def test_inject_script_is_valid_python(self):
compile(frame_mods.INJECT_PY, "frame_inject.py", "exec")
class Helper(unittest.TestCase):
@unittest.skipIf(sys.platform == "win32", "uninstall takes the flock lock, which only exists on the Frame (Linux)")
def test_unexpected_errors_are_still_json(self):
# A receipt from another version, without "exe": a KeyError, not a traceback.
script = (ROOT / "ui" / "frame_mods.py").read_text().replace(
"def read_receipt(appid):", "def read_receipt(appid):\n return {'prefix': '/x'}\n\n\ndef _unused(appid):")
out = subprocess.run([sys.executable, "-", "uninstall", "1"], input=script,
capture_output=True, text=True, timeout=30, env={"HOME": tempfile.mkdtemp()})
self.assertEqual(out.returncode, 1, out.stderr)
self.assertIn("KeyError", json.loads(out.stdout)["error"])
def test_bad_usage_prints_json_error(self):
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args],
capture_output=True, text=True, timeout=30)
self.assertEqual(out.returncode, 1, args)
self.assertIn("error", json.loads(out.stdout), args)
class ModRoutes(test_server.ServerGuards):
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
def test_mod_input_validation(self):
for body in ({"action": "install", "appid": "1; reboot"}, {"action": "install", "appid": ""},
{"action": "delete", "appid": "1067310"}, {"appid": "1067310"}):
status, payload = self.post("/api/mods", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
def test_needs_custom_header(self):
self.assertEqual(self.request("POST", "/api/mods", {"action": "status", "appid": "1"})[0], 403)
def test_runs_the_mods_helper_with_its_timeout(self):
import server
with mock.patch.object(server, "steam_frame", return_value={"ok": 1}) as run:
self.assertEqual(server.mods({"action": "install", "appid": "1067310"}), {"ok": 1})
run.assert_called_once_with("install", "1067310", timeout=600, script="frame_mods.py")
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
setattr(ModRoutes, name, None)
+3 -1
View File
@@ -32,7 +32,9 @@ Entry dict (missing facts are None, never guessed)
Rules
-----
- Only sources that distribute APKs with the developer's consent: free listings,
never paid apps re-hosted, no licence or entitlement workarounds.
never paid apps re-hosted, no licence or entitlement workarounds. The one paid
exception is a store's own download of a game the user bought there: a source
marked 'owned': True whose entries carry 'owned': True (itch.io purchases).
- Honour each site's terms and robots rules; if automated download isn't allowed,
return entries with 'downloadable': False and a 'page' link instead.
- Cache indexes under frame_host.cache_dir('apk-sources'); send a clear
+394
View File
@@ -0,0 +1,394 @@
"""The user's own itch.io account: an API key, the games it owns and their Android uploads.
Opt-in (store > sources > Connect itch.io). The key is kept in
frame_host.data_dir('itch-account.json') with mode 0600 and is only ever sent to
https://api.itch.io, in an Authorization header. Downloads end on itch.io's storage
host, fetched without the key from a checked public address (_web.public_apk).
The key never goes into a URL, an error, a log line or an API response. Owned-key
listings stay in memory, never in the disk cache.
API: https://itch.io/docs/api/serverside. See docs/apk-repos.md.
"""
import hashlib, http.client, json, os, re, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
import frame_host
from . import SourceError, SourceLimited, _web
API = 'https://api.itch.io'
HOSTS = ('api.itch.io',)
NAME = 'itch.io'
TTL = 600 # owned keys and uploads are re-read after ten minutes
PER_PAGE = 500 # the documented maximum for /profile/owned-keys
PAGES = 40 # stop after 20,000 owned keys
KEY = re.compile(r'[A-Za-z0-9_.-]{16,256}')
BAD_KEY = ('itch.io did not accept the API key. Create one in itch.io settings > '
'API keys and connect again')
REVOKED = ('itch.io no longer accepts the saved API key, so it was removed. '
'Create a new one in itch.io settings > API keys and connect again')
REDIRECTS = (301, 302, 303, 307, 308)
# Everything a connection or response can raise. http.client's errors (IncompleteRead,
# BadStatusLine...) are not OSErrors, and BadStatusLine repeats what the server sent.
NETWORK = (OSError, ValueError, http.client.HTTPException)
_lock = threading.Lock() # the cache
_cache = {} # (generation, key digest, what) -> (time, value)
_account = threading.Lock() # saving and deleting the key file
_generation = [0] # bumped by every save and delete: a connect that started earlier doesn't save
class BadKey(SourceError):
"""itch.io refused the key: wrong, revoked or deleted."""
def path():
return frame_host.data_dir('itch-account.json')
def _load():
try:
data = json.loads(path().read_text())
except (OSError, ValueError):
return None
if not isinstance(data, dict) or not isinstance(data.get('key'), str) or not KEY.fullmatch(data['key']):
return None
return data
def status():
"""{'connected', 'username'}: what the page may know. Never the key; no network."""
data = _load()
return {'connected': bool(data), 'username': data.get('username') if data else None}
def connected():
return _load() is not None
def _key():
data = _load()
if not data:
raise SourceError('Connect your itch.io account first (store > sources > Connect itch.io)')
return data['key']
def _save(key, username):
"""Write the key file through a fresh 0600 temporary file (mkstemp), then move it into place."""
target = path()
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(target.parent), prefix='.itch-account-', suffix='.tmp')
try:
with os.fdopen(fd, 'w') as f:
os.chmod(tmp, 0o600) # mkstemp already does on POSIX; say so for every platform
json.dump({'key': key, 'username': username}, f)
os.replace(tmp, str(target))
finally:
if os.path.exists(tmp):
os.remove(tmp)
def _forget():
with _lock:
_cache.clear()
def _clean(message, key):
"""message without the key, or any start of it (8+ characters, as truncation leaves), in
any letter case, with any of its characters written literally, percent-encoded (%4B or
%4b) or percent-encoded twice (%254B, %254b). The only redaction used here: every message
that might hold the key goes through it. (The key's characters are letters, digits and
_.-, so +/space encoding can't change it.)"""
if not key:
return message
# Each character: itself, or the percent-encoding (once or twice) of either of its letter cases.
tokens = ['(?:%s)' % '|'.join([re.escape(c)] + ['%%(?:25)?%02x' % ord(v) for v in sorted({c.lower(), c.upper()})])
for c in key]
start, rest = re.compile(''.join(tokens[:8]), re.I), [re.compile(t, re.I) for t in tokens[8:]]
spans, pos = [], 0
while True:
found = start.search(message, pos)
if not found:
break
end = found.end()
for token in rest: # as much more of the key as follows
more = token.match(message, end)
if not more:
break
end = more.end()
spans.append((found.start(), end))
# Look again from the next character, not the end: a partial match just before the
# real key can overlap its start. Overlapping spans merge below.
pos = found.start() + 1
out, pos = [], 0
for a, b in spans: # in order of start; only overlapping spans merge
if out and a < pos:
pos = max(pos, b)
continue
out += [message[pos:a], '[API key]']
pos = b
return ''.join(out) + message[pos:]
class _Stay(urllib.request.HTTPRedirectHandler):
"""Don't follow: the caller decides where a redirect may go, and never sends the key there."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _open(url, key, accept='application/json'):
"""GET url on api.itch.io with the key. Redirects come back as HTTPError 30x."""
_web.checked_url(url, HOSTS)
wait = _web.wait_time(url)
if wait:
raise _web.limited_error(NAME, wait)
return urllib.request.build_opener(_Stay()).open(urllib.request.Request(
url, headers={'User-Agent': _web.UA, 'Authorization': 'Bearer ' + key, 'Accept': accept}), timeout=60)
def _refused(code, key, api_path, message=None):
"""A refusal that may be about the key (401/403, or an "invalid key" error): BadKey only
once /profile confirms the key itself is refused; otherwise it's this request (a game or
upload this purchase doesn't cover), and the key stays."""
if api_path != '/profile':
_get('/profile', key) # raises BadKey if the key is the problem
raise SourceError(message or 'itch.io refused this request (HTTP %d); your purchase may not include it' % code)
raise BadKey(BAD_KEY)
def _network_error(e, key):
reason = getattr(e, 'reason', None) or e
return SourceError(_clean('Could not reach itch.io: ' + str(reason), key))
BAD_KEY_TEXT = re.compile(r'\b(invalid|revoked|expired)\b.*\bkey\b|\bkey\b.*\b(invalid|revoked|expired)\b', re.I)
def _checked(raw, key, api_path):
"""Decoded JSON object from api.itch.io, or SourceError/BadKey from its errors array."""
if len(raw) > 8 * 1024 * 1024:
raise SourceError('itch.io response is too large')
try:
data = json.loads(raw)
except ValueError:
raise SourceError('itch.io returned an invalid response') from None
if not isinstance(data, dict):
raise SourceError('itch.io returned an invalid response')
errors = data.get('errors')
if errors: # documented: errors is an array of strings
messages = [str(m) for m in errors if isinstance(m, (str, int))]
# Judged on itch.io's own words: redacted text says "[API key]", which would look like a key error.
about_key = any(BAD_KEY_TEXT.search(m) and 'download key' not in m.lower() for m in messages)
# Each message loses the key before joining and shortening, so no part of it survives the cut.
text = _clean('; '.join(_clean(m, key) for m in messages)[:200], key)
if about_key:
_refused(None, key, api_path, 'itch.io: ' + text)
raise SourceError('itch.io: ' + (text or 'request refused'))
return data
def _get(api_path, key, params=None):
"""JSON from api.itch.io; SourceError with a readable message (never the key) on failure."""
url = API + api_path + ('?' + urllib.parse.urlencode(params) if params else '')
try:
with _open(url, key) as r:
raw = r.read(8 * 1024 * 1024 + 1)
except urllib.error.HTTPError as e:
e.close()
if e.code == 429:
raise _web.limited_error(NAME, _web.throttle(url, e.headers)) from None
if e.code in (401, 403):
_refused(e.code, key, api_path)
raise SourceError('itch.io returned HTTP error %d; try again later' % e.code) from None
except SourceError:
raise
except NETWORK as e:
raise _network_error(e, key) from None
return _checked(raw, key, api_path)
def _cached(key, what, load):
slot = (_generation[0], hashlib.sha256(key.encode()).hexdigest(), what)
with _lock:
hit = _cache.get(slot)
if hit and time.time() - hit[0] < TTL:
return hit[1]
value = load()
with _lock:
_cache[slot] = (time.time(), value)
return value
def _use(call):
"""Run call(key); a key itch.io now refuses is removed, so the account shows disconnected."""
with _account: # the key and the connection it belongs to, read together
key, generation = _key(), _generation[0]
try:
return call(key)
except BadKey:
with _account:
# Only the connection this call started with: a reconnect meanwhile (even with
# the same key) has been checked since, and is not undone by this older verdict.
if _generation[0] == generation and (_load() or {}).get('key') == key:
_delete()
raise BadKey(REVOKED) from None
def connect(key):
"""Check key with itch.io (GET /profile), then save it. A refused key is never saved, and
nor is one whose check finished after a disconnect or another connect."""
key = (key or '').strip() if isinstance(key, str) else ''
if not KEY.fullmatch(key):
raise BadKey('That does not look like an itch.io API key. Copy it from itch.io settings > API keys')
with _account:
started = _generation[0]
user = _get('/profile', key).get('user') # the network, outside the lock
if not isinstance(user, dict):
raise SourceError('itch.io returned an invalid profile')
username = user.get('username') if isinstance(user.get('username'), str) else None
with _account:
if _generation[0] != started:
raise SourceError('The itch.io connection changed while the key was being checked; '
'nothing was saved. Try again')
_generation[0] += 1
_save(key, username)
_forget()
return {'message': 'Connected to itch.io' + (' as ' + username if username else ''), 'itch': status()}
def _delete():
"""Remove the key file; call with _account held."""
_generation[0] += 1
try:
os.remove(str(path()))
except FileNotFoundError:
pass
_forget()
def disconnect():
with _account:
_delete()
return {'message': 'Disconnected from itch.io; the API key was deleted from this computer', 'itch': status()}
def owned_keys():
"""Every download key the account owns, newest first (GET /profile/owned-keys, all pages)."""
def load(key):
keys = []
for page in range(1, PAGES + 1):
batch = _get('/profile/owned-keys', key, {'page': page, 'per_page': PER_PAGE}).get('owned_keys')
# Documented: a page can hold fewer than per_page even when more follow; an empty one ends it.
if not batch or not isinstance(batch, list):
break
keys.extend(k for k in batch if isinstance(k, dict))
return keys
return _use(lambda key: _cached(key, 'owned', lambda: load(key)))
def _number(value):
return value if type(value) is int and value > 0 else None
def is_android(upload):
"""An upload marked for Android, or an .apk file. The marker's field name is not in the
public reference: itch.io's traits use p_<platform> (p_windows...), so p_android is assumed."""
traits = upload.get('traits') if isinstance(upload.get('traits'), list) else []
platforms = upload.get('platforms') if isinstance(upload.get('platforms'), dict) else {}
filename = str(upload.get('filename') or '').lower()
return 'p_android' in traits or upload.get('p_android') is True or 'android' in platforms \
or filename.endswith('.apk')
def android_uploads(uploads):
"""Installable Android uploads, best first: .apk files, then full games, then newest."""
chosen = []
for u in uploads if isinstance(uploads, list) else []:
if not isinstance(u, dict) or not _number(u.get('id')) or not is_android(u):
continue
traits = u.get('traits') if isinstance(u.get('traits'), list) else []
if u.get('type') not in (None, 'default') or u.get('preorder') is True or 'preorder' in traits:
continue # soundtracks, books, placeholders
if u.get('storage') == 'external':
continue # hosted on another site: not something itch.io can hand us
chosen.append(u)
chosen.sort(key=lambda u: str(u.get('updated_at') or u.get('created_at') or ''), reverse=True)
chosen.sort(key=lambda u: (not str(u.get('filename') or '').lower().endswith('.apk'),
u.get('demo') is True or 'demo' in (u.get('traits') or [])))
return chosen
def uploads(game_id, download_key_id):
"""The game's uploads this download key may fetch (GET /games/ID/uploads)."""
game_id, download_key_id = _number(game_id), _number(download_key_id)
if not game_id or not download_key_id:
raise SourceError('Invalid itch.io game')
return _use(lambda key: _cached(key, ('uploads', game_id, download_key_id), lambda: _get(
'/games/%d/uploads' % game_id, key, {'download_key_id': download_key_id}).get('uploads') or []))
def download_url(upload_id, download_key_id):
upload_id, download_key_id = _number(upload_id), _number(download_key_id)
if not upload_id or not download_key_id:
raise SourceError('Invalid itch.io upload')
return API + '/uploads/%d/download?' % upload_id + urllib.parse.urlencode({'download_key_id': download_key_id})
def _fetch(url, key):
"""Ask api.itch.io for the file. Not checked against a live account, so this accepts each
plausible answer: a redirect to storage, JSON naming the storage URL, or the file itself."""
try:
r = _open(url, key, 'application/vnd.android.package-archive, application/json;q=0.5, */*;q=0.1')
except urllib.error.HTTPError as e:
e.close()
if e.code in REDIRECTS:
target = e.headers.get('Location')
elif e.code == 429:
raise _web.limited_error(NAME, _web.throttle(url, e.headers)) from None
elif e.code in (401, 403):
_refused(e.code, key, '/uploads')
else:
raise SourceError('itch.io returned HTTP error %d for the download; try again later' % e.code) from None
except SourceError:
raise
except NETWORK as e:
raise _network_error(e, key) from None
else:
with r:
if 'json' not in (r.headers.get('Content-Type') or '').lower():
try:
return _web.save_apk(r) # served by api.itch.io itself
except NETWORK + (zipfile.BadZipFile,) as e:
raise SourceError(_clean('Could not download APK: ' + str(e), key)) from None
try:
raw = r.read(1024 * 1024 + 1)
except NETWORK as e:
raise _network_error(e, key) from None
target = _checked(raw, key, '/uploads').get('url')
if not isinstance(target, str) or not target:
raise SourceError('itch.io did not say where to download the file')
def refuse(hop): # on the first URL and on every redirect after it, before any lookup
if _carries_key(hop, key):
raise SourceError('itch.io pointed the download at a URL carrying your API key; refused, '
'so the key is not sent to the storage server')
try:
return _web.public_apk(urllib.parse.urljoin(url, target), NAME, check=refuse) # no key sent
except SourceLimited as e:
raise SourceLimited(_clean(str(e), key), e.retry_after) from None
except SourceError as e:
raise type(e)(_clean(str(e), key)) from None
def _carries_key(url, key):
"""True if url holds the key (or 8+ characters of its start), however cased or encoded, or
an itch.io API-key parameter: go-itchio puts the key in ?api_key=, and storage must never
get it. Other parameters (signatures, tokens) are the storage URL's own and are kept."""
for form in (url, urllib.parse.unquote(url), urllib.parse.unquote_plus(url)):
if _clean(form, key) != form:
return True
names = {name.lower() for name, _ in urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query,
keep_blank_values=True)}
return bool(names & {'api_key', 'apikey', 'api-key'})
def download(upload_id, download_key_id):
"""The APK: the key goes to api.itch.io only, never to the storage host."""
url = download_url(upload_id, download_key_id)
return _use(lambda key: _fetch(url, key))
+159 -11
View File
@@ -1,5 +1,6 @@
"""Small HTTPS cache and APK downloader for public publisher sources."""
import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
import hashlib, http.client, ipaddress, os, shutil, socket, ssl, tempfile, threading, time
import urllib.error, urllib.parse, urllib.request, zipfile
from email.utils import parsedate_to_datetime
import frame_host
@@ -211,19 +212,16 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
raise SourceError('Could not read source: ' + str(e)) from e
def apk(url, hosts, digest=None, name=None):
def save_apk(response, digest=None):
"""Stream response (anything with read(n)) into the cache as a checked APK."""
tmp = None
name = name or _host(url)
wait = wait_time(url)
if wait:
raise limited_error(name, wait)
try:
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
h = hashlib.sha256()
with os.fdopen(fd, 'wb') as f, open_url(url, hosts) as r:
with os.fdopen(fd, 'wb') as f:
size = 0
while True:
chunk = r.read(1 << 20)
chunk = response.read(1 << 20)
if not chunk:
break
size += len(chunk)
@@ -240,12 +238,162 @@ def apk(url, hosts, digest=None, name=None):
path = os.path.join(cache(), actual + '.apk')
os.replace(tmp, path)
return {'apk': path, 'obb': [], 'sha256': actual, 'verified': bool(digest)}
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
def apk(url, hosts, digest=None, name=None):
name = name or _host(url)
wait = wait_time(url)
if wait:
raise limited_error(name, wait)
try:
with open_url(url, hosts) as r:
return save_apk(r, digest)
except urllib.error.HTTPError as e:
if e.code in (403, 429):
raise limited_error(name, throttle(url, e.headers)) from e
raise SourceError('Could not download APK: HTTP error ' + str(e.code)) from e
except (OSError, ValueError, zipfile.BadZipFile) as e:
raise SourceError('Could not download APK: ' + str(e)) from e
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
# IPv6 is an allow-list: only global unicast (2000::/3), which rules out everything else at
# once (loopback, unspecified, mapped/compatible IPv4, NAT64 64:ff9b::, site-local fec0::,
# unique-local fc00::, link-local, multicast, reserved). Inside it, these special blocks are
# refused too: 6to4 and Teredo carry IPv4 that may be private; the rest aren't the internet.
_GLOBAL6 = ipaddress.ip_network('2000::/3')
_SPECIAL6 = tuple(ipaddress.ip_network(n) for n in (
'2001::/32', # Teredo
'2001:2::/48', # benchmarking
'2001:10::/28', # ORCHID
'2001:20::/28', # ORCHIDv2
'2001:db8::/32', # documentation
'2002::/16', # 6to4
'3fff::/20')) # documentation (2024)
# IPv4 blocks that are never the public internet, listed in full rather than trusting
# is_global (Python 3.9 calls 192.0.0.8, .11 and .255 global).
_SPECIAL4 = tuple(ipaddress.ip_network(n) for n in (
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
'192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15',
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32'))
def public_address(ip):
"""True for an address on the public internet: never loopback, private, link-local,
site-local, shared (CGNAT), reserved, unspecified or multicast, nor an IPv6 wrapper
around IPv4. Python 3.9's is_global alone lets several of these through."""
try:
a = ipaddress.ip_address(ip.split('%', 1)[0])
except ValueError:
return False
if a.version == 6 and (a not in _GLOBAL6 or any(a in n for n in _SPECIAL6)):
return False
if a.version == 4 and any(a in n for n in _SPECIAL4):
return False
return a.is_global and not (a.is_reserved or a.is_private or a.is_loopback or a.is_link_local
or a.is_multicast or a.is_unspecified)
def resolve_public(host):
"""host's addresses for port 443, if every address it has is public; else SourceError."""
try:
addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
except (OSError, UnicodeError) as e:
raise SourceError('Could not find the download server ' + host) from e
if not addresses or not all(public_address(a[4][0]) for a in addresses):
raise SourceError('The download server is on a private network address; refused')
found = []
for a in addresses:
if a[4][:2] not in found:
found.append(a[4][:2])
return found
CONNECT_BUDGET = 20 # seconds for connecting, all addresses and TLS handshakes together
def _public_connection(host, timeout, budget=CONNECT_BUDGET):
"""A verified TLS connection to host:443, made to an address checked to be public.
Each checked address is tried in turn, all within one deadline (budget, or timeout if
shorter) covering both connecting and the handshake. The one connected to is always one
that was checked, so a second lookup can't swap in another. timeout is for reads after."""
deadline = time.monotonic() + min(budget, timeout)
error = None
for address in resolve_public(host):
if time.monotonic() >= deadline:
break
try:
return _connect(host, address, timeout, deadline)
except OSError as e: # unreachable, refused, or its TLS failed: try the next one
error = e
raise error or socket.timeout('Timed out connecting to ' + host)
def _connect(host, address, timeout, deadline):
"""TLS to exactly address, verified as host, connected and handshaken before deadline
(a time.monotonic() value); reads then get timeout seconds each."""
def remaining():
left = deadline - time.monotonic()
if left <= 0:
raise socket.timeout('Timed out connecting to ' + host)
return left
sock = socket.create_connection(address, timeout=remaining())
try:
# Each step gets what is left of the deadline as its socket timeout. A handshake
# read as several receives could in theory run past it by one timeout; no thread
# is kept to cut it short, as one could fire after the connection was handed over.
sock.settimeout(remaining())
sock = ssl.create_default_context().wrap_socket(sock, server_hostname=host,
do_handshake_on_connect=False)
sock.settimeout(remaining())
sock.do_handshake()
sock.settimeout(timeout)
except BaseException:
sock.close()
raise
conn = http.client.HTTPConnection(host, 443, timeout=timeout)
conn.sock = sock
return conn
def public_apk(url, name, redirects=3, timeout=60, check=None):
"""Download an APK from a public HTTPS host that no source lists in advance (itch.io's
storage). No credentials are sent. Every hop, redirects included, is re-checked, and
check(url), if given, runs on each hop before any lookup or connection (it raises)."""
for _ in range(redirects + 1):
if check:
check(url)
try:
p = urllib.parse.urlsplit(url)
port = p.port
except (ValueError, TypeError) as e:
raise SourceError(name + ' returned an invalid download URL') from e
if p.scheme != 'https' or not p.hostname or p.username or p.password or port not in (None, 443):
raise SourceError(name + ' returned an unexpected download URL')
wait = wait_time(url)
if wait:
raise limited_error(name, wait)
try:
conn = _public_connection(p.hostname, timeout)
except OSError as e:
raise SourceError('Could not download APK: ' + str(e)) from e
try:
conn.request('GET', (p.path or '/') + ('?' + p.query if p.query else ''), headers={'User-Agent': UA})
r = conn.getresponse()
if r.status in (301, 302, 303, 307, 308):
url = urllib.parse.urljoin(url, r.getheader('Location') or '')
continue
if r.status == 429:
raise limited_error(name, throttle(url, r.headers))
if r.status != 200:
raise SourceError("%s's download server refused the file (HTTP %d); try again later"
% (name, r.status))
return save_apk(r)
except (OSError, ValueError, http.client.HTTPException, zipfile.BadZipFile) as e:
raise SourceError('Could not download APK: ' + str(e)) from e
finally:
conn.close()
raise SourceError(name + ' redirected the download too many times')
+97 -15
View File
@@ -1,17 +1,35 @@
"""Free Android VR RSS listings. Download pages stay in the publisher's UI."""
"""Free Android VR RSS listings, plus the Android builds of games the user owns.
The free feeds' download pages stay in the publisher's UI. Owned games appear only
after the user connects their itch.io account with an API key (_itch_account.py);
they download through itch.io's own API, with the download key of that purchase.
"""
import html, re, urllib.parse, xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from . import SourceError, _web
from . import SourceError, _itch_account, _web
KIND = 'itch'
FEEDS = ('openxr', 'oculus-quest')
OWNED = 'itch-owned'
OWNED_LABEL = 'Owned on itch.io'
def sources():
return [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
found = [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
if _itch_account.connected():
found.append({'id': OWNED, 'kind': KIND, 'name': 'itch.io (my purchases)',
'url': 'https://itch.io/my-purchases', 'owned': True,
'description': 'Android builds of games you own on itch.io',
'builtin': True, 'enabled': True, 'trust': 'user'})
return found
def _itch_page(url):
p = urllib.parse.urlsplit(url if isinstance(url, str) else '')
return p.scheme == 'https' and (p.hostname or '').endswith('.itch.io') and not p.username and ':' not in p.netloc
def _parse(source, data):
@@ -22,8 +40,7 @@ def _parse(source, data):
entries = []
for item in root.findall('./channel/item'):
page = item.findtext('link') or ''
p = urllib.parse.urlsplit(page)
if p.scheme != 'https' or not (p.hostname or '').endswith('.itch.io') or p.username or ':' in p.netloc:
if not _itch_page(page):
continue
if item.findtext('price') != '$0.00' or item.findtext('platforms/android') != 'yes':
continue
@@ -45,23 +62,81 @@ def _parse(source, data):
return entries
def _owned_entry(source, key):
"""Entry for one owned download key, or None if its game isn't for Android."""
game = key.get('game') if isinstance(key.get('game'), dict) else {}
game_id = key.get('game_id') or game.get('id')
if type(game_id) is not int or type(key.get('id')) is not int:
return None
traits = game.get('traits')
if isinstance(traits, list) and 'p_android' not in traits:
return None # the game's page offers no Android download
cover = game.get('cover_url') if isinstance(game.get('cover_url'), str) else None
if cover and not cover.startswith('https://img.itch.zone/'):
cover = None
user = game.get('user') if isinstance(game.get('user'), dict) else {}
price = game.get('min_price')
return {'source': source['id'], 'id': str(game_id), 'package': None,
'name': str(game.get('title') or 'itch.io game ' + str(game_id)),
'summary': str(game.get('short_text') or OWNED_LABEL),
'developer': user.get('display_name') or user.get('username'),
'icon': cover, 'images': {'icon': cover, 'banner': cover, 'screenshots': []},
'page': game['url'] if _itch_page(game.get('url')) else None,
'version': None, 'version_code': None, 'min_sdk': None, 'abis': None,
'vr': None, 'size': None, 'free': price == 0 if type(price) is int else None,
'owned': True, 'owned_label': OWNED_LABEL, 'license': None,
'updated': str(game.get('published_at') or '')[:10] or None,
'downloadable': None, 'download_key_id': key['id']}
def _owned(source):
entries = {}
for key in _itch_account.owned_keys():
entry = _owned_entry(source, key)
if entry:
entries.setdefault(entry['id'], entry) # keys come newest first; one entry per game
return entries
def search(source, query, limit=50):
limit = max(0, min(int(limit), 100))
if not limit:
return []
entries = {}
tag = source.get('tag', 'openxr')
if tag not in FEEDS:
raise SourceError('Unsupported itch.io feed')
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
entries.setdefault(entry['id'], entry)
if source.get('owned'):
entries = _owned(source)
else:
entries = {}
tag = source.get('tag', 'openxr')
if tag not in FEEDS:
raise SourceError('Unsupported itch.io feed')
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
entries.setdefault(entry['id'], entry)
words = query.lower().split()
return [e for e in entries.values() if all(w in (e['name'] + ' ' + e['summary']).lower()
for w in words)][:limit]
def _owned_details(source, entry_id):
entry = _owned(source).get(str(entry_id))
if not entry:
raise SourceError('This game is not among the Android games you own on itch.io')
uploads = _itch_account.uploads(int(entry['id']), entry['download_key_id'])
versions = [{'version': str(u.get('display_name') or u.get('filename') or u['id']), 'version_code': None,
'upload_id': u['id'], 'name': u.get('filename'), 'min_sdk': None,
'demo': u.get('demo') is True or 'demo' in (u.get('traits') or []),
'size': u.get('size') if type(u.get('size')) is int else None,
'updated': str(u.get('updated_at') or u.get('created_at') or '')[:10] or None}
for u in _itch_account.android_uploads(uploads)]
entry = dict(entry, versions=versions, downloadable=bool(versions))
if versions:
entry.update({k: versions[0][k] for k in ('version', 'size', 'updated')})
return entry
def details(source, entry_id):
if source.get('owned'):
return _owned_details(source, entry_id)
entry = next((e for e in search(source, '', 100) if e['id'] == entry_id), None)
if not entry:
raise SourceError('Game is not in the current free Android VR feed; open its publisher page')
@@ -69,4 +144,11 @@ def details(source, entry_id):
def download(source, entry_id, version_code=None):
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
if not source.get('owned'):
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
if version_code is not None:
raise SourceError('itch.io does not publish Android version codes; download the latest upload')
entry = _owned_details(source, entry_id)
if not entry['versions']:
raise SourceError('This game has no Android download on itch.io')
return _itch_account.download(entry['versions'][0]['upload_id'], entry['download_key_id'])
+11 -3
View File
@@ -190,7 +190,10 @@ def group(entries, query='', vr=None, installable=False):
groups = {}
for entry in entries:
entry = decorate(entry)
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
# Unknown counts as flat, except the user's own purchases (itch.io doesn't say which are
# VR): those show under either choice rather than vanish from one.
if vr is not None and (entry.get('vr') is True) != vr \
and not (entry.get('owned') is True and entry.get('vr') is None):
continue
if installable and entry['fit']['installable'] is not True:
continue
@@ -223,6 +226,11 @@ def group(entries, query='', vr=None, installable=False):
return result
def obtainable(source, entry):
"""Free, or a game the user owns, listed by their own account's source (itch.io purchases)."""
return entry.get('free') is True or (source.get('owned') is True and entry.get('owned') is True)
def _launch(module, source, query, limit):
"""One search per source at a time; the newest different query runs next."""
key = source['id']
@@ -251,7 +259,7 @@ def _start(key, task):
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) if obtainable(source, e)]
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
except Exception as e:
task['error'] = str(e)
@@ -308,7 +316,7 @@ def install(source_id, entry_id, version_code=None, progress=None):
if not source['enabled']:
raise SourceError('This source is disabled')
entry = module.details(source, entry_id)
if entry.get('free') is not True or entry.get('downloadable') is not True:
if not obtainable(source, entry) or entry.get('downloadable') is not True:
raise SourceError('This app must be obtained from its developer page')
if progress:
progress('Downloading', None)
+532
View File
@@ -0,0 +1,532 @@
#!/usr/bin/env python3
"""Runs ON the Frame (piped over SSH): UEVR, a flat-to-VR mod for Unreal games.
Usage: python3 - status APPID # can UEVR run here, is it installed, is the game running
python3 - install APPID # download the pinned official release into the game's prefix
python3 - start APPID # launch the game if needed, inject UEVR, confirm SteamVR frames
python3 - uninstall APPID # remove exactly what install (and UEVR's first run) created
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
Verified 2026-09-29 on SteamOS 0.4.1 (build 20260925.6191901), Proton 11.0
ARM64 and SteamVR 2.18.1 with Gravitas (1067310): after injection the game has
UEVRBackend.dll loaded and SteamVR counts frame submits for it. Stereo image,
head tracking and controls were not seen: the headset was unworn (docs/mods.md).
UEVR's own injector (a .NET WPF app) is not used. Under Wine and FEX it hung
or ignored input in about one session in four, and gamescope keeps real input
away from it. Instead the official Windows embeddable Python runs INJECT_PY in
the game's Wine session, doing what the injector does on Inject.
Nothing here is bundled. UEVR comes from praydog's GitHub release and Python
from python.org, each checked against a pinned hash. Files live in
<prefix>/drive_c/frame-control, never in the game's own folder, and a receipt
records what to remove.
"""
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request
import zipfile
from pathlib import Path
HOME = Path.home()
STEAM = HOME / ".local/share/Steam"
CACHE = HOME / ".cache/frame-control/mods"
RECEIPTS = HOME / ".local/share/frame-control/mods"
LOCK = Path("/tmp/frame-control-mods.lock") # tmpfs: gone at reboot, never left in ~
VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
MANAGED = "frame-control" # drive_c/<this> inside the game's prefix
UEVR = {"version": "1.05", "dir": "uevr-1.05",
"url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip",
"sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"}
# Windows x64 embeddable Python, to run INJECT_PY. Hash from python.org's
# release-file API (sha256_sum).
PYTHON = {"version": "3.14.7", "dir": "python-3.14.7",
"url": "https://www.python.org/ftp/python/3.14.7/python-3.14.7-embed-amd64.zip",
"sha256": "d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15"}
MAX_UNPACKED = 200 << 20 # the two archives unpack to about 50 MB
MAX_DOWNLOAD = 50 << 20 # each archive is 7–13 MB
# The game's environment a second program needs to join its Wine session. Not
# WINESERVERSOCKET and friends: those are inherited file descriptors.
GAME_ENV = re.compile(r"(WINEPREFIX|WINEDLLPATH|WINEDLLOVERRIDES|WINEFSYNC|WINEDEBUG|PATH|DISPLAY|"
r"XDG_RUNTIME_DIR|PROTON_VR_RUNTIME|FEX_APP_CONFIG|FEX_APP_CONFIG_LOCATION|"
r"SteamAppId|SteamGameId|STEAM_COMPAT_DATA_PATH|WINE_LARGE_ADDRESS_AWARE)=")
# Runs under Windows Python in the game's Wine session: the steps of UEVR
# 1.05's frontend (UEVR/MainWindow.xaml.cs Inject_Clicked, UEVR/Injector.cs)
# with "Nullify VR plugins" on and the OpenVR runtime. Prints one JSON line.
INJECT_PY = r"""
import ctypes, json, os, sys
from ctypes import wintypes as W
exe, uevr, profile = sys.argv[1:4]
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
H, P, SZ = W.HANDLE, ctypes.c_void_p, ctypes.c_size_t
for name, res, args in (
("OpenProcess", H, [W.DWORD, W.BOOL, W.DWORD]),
("CloseHandle", W.BOOL, [H]),
("VirtualAllocEx", P, [H, P, SZ, W.DWORD, W.DWORD]),
("VirtualFreeEx", W.BOOL, [H, P, SZ, W.DWORD]),
("WriteProcessMemory", W.BOOL, [H, P, P, SZ, P]),
("CreateRemoteThread", H, [H, P, SZ, P, P, W.DWORD, P]),
("WaitForSingleObject", W.DWORD, [H, W.DWORD]),
("GetModuleHandleW", W.HMODULE, [W.LPCWSTR]),
("LoadLibraryW", W.HMODULE, [W.LPCWSTR]),
("FreeLibrary", W.BOOL, [W.HMODULE]),
("GetProcAddress", P, [W.HMODULE, ctypes.c_char_p]),
("CreateToolhelp32Snapshot", H, [W.DWORD, W.DWORD]),
("Process32FirstW", W.BOOL, [H, P]), ("Process32NextW", W.BOOL, [H, P]),
("Module32FirstW", W.BOOL, [H, P]), ("Module32NextW", W.BOOL, [H, P])):
fn = getattr(k32, name)
fn.restype, fn.argtypes = res, args
INVALID = ctypes.c_void_p(-1).value
class Entry(ctypes.Structure): # PROCESSENTRY32W
_fields_ = [("size", W.DWORD), ("usage", W.DWORD), ("pid", W.DWORD), ("heap", ctypes.c_void_p),
("module", W.DWORD), ("threads", W.DWORD), ("parent", W.DWORD), ("prio", ctypes.c_long),
("flags", W.DWORD), ("name", W.WCHAR * 260)]
class Module(ctypes.Structure): # MODULEENTRY32W
_fields_ = [("size", W.DWORD), ("mid", W.DWORD), ("pid", W.DWORD), ("glbl", W.DWORD), ("proc", W.DWORD),
("base", ctypes.c_void_p), ("bytes", W.DWORD), ("handle", W.HMODULE),
("name", W.WCHAR * 256), ("path", W.WCHAR * 260)]
def fail(msg):
print(json.dumps({"error": f"{msg} (Windows error {ctypes.get_last_error()})"})); sys.exit(1)
def snapshot(flags, pid, entry, first, next_, match):
snap = k32.CreateToolhelp32Snapshot(flags, pid)
if not snap or snap == INVALID:
fail("couldn't list processes")
try:
entry.size = ctypes.sizeof(entry)
ok = first(snap, ctypes.byref(entry))
while ok:
if match(entry):
return entry
ok = next_(snap, ctypes.byref(entry))
finally:
k32.CloseHandle(snap)
def remote_call(proc, fn, arg, wait_ms):
t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None)
if not t:
fail("CreateRemoteThread failed")
try:
return k32.WaitForSingleObject(t, wait_ms) == 0 # WAIT_OBJECT_0
finally:
k32.CloseHandle(t)
def inject(proc, pid, name):
path = os.path.join(uevr, name)
data = ctypes.create_unicode_buffer(path)
mem = k32.VirtualAllocEx(proc, None, ctypes.sizeof(data), 0x3000, 0x04) # commit|reserve, read/write
if not mem:
fail(f"couldn't allocate in {exe}")
try:
if not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None):
fail(f"couldn't write into {exe}")
load = k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW")
if not remote_call(proc, load, mem, 30000):
fail(f"loading {name} into {exe} didn't finish in 30 s")
finally:
k32.VirtualFreeEx(proc, mem, 0, 0x8000) # MEM_RELEASE
m = snapshot(0x18, pid, Module(), k32.Module32FirstW, k32.Module32NextW, # TH32CS_SNAPMODULE | SNAPMODULE32
lambda m: m.path.lower() == path.lower())
if not m:
fail(f"{name} didn't load into {exe}")
return path, m.base
e = snapshot(2, 0, Entry(), k32.Process32FirstW, k32.Process32NextW, # TH32CS_SNAPPROCESS
lambda e: e.name.lower() == exe.lower())
if not e:
fail(f"{exe} isn't running")
pid = e.pid
proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS
if not proc:
fail(f"couldn't open {exe}")
path, base = inject(proc, pid, "UEVRPluginNullifier.dll")
local = k32.LoadLibraryW(path)
fn = local and k32.GetProcAddress(local, b"nullify")
if not fn:
fail("couldn't find UEVRPluginNullifier.dll's nullify")
offset = fn - local # same DLL, same layout in both processes
k32.FreeLibrary(local)
# The frontend waits 2 s for nullify and carries on either way; so do we, but say so.
nullified = remote_call(proc, base + offset, None, 2000)
inject(proc, pid, "openvr_api.dll")
# The frontend saves the runtime choice in the per-game config before UEVRBackend reads it.
os.makedirs(profile, exist_ok=True)
cfg = os.path.join(profile, "config.txt")
lines = open(cfg).read().splitlines() if os.path.exists(cfg) else []
lines = [l for l in lines if not l.startswith("Frontend_RequestedRuntime=")] + ["Frontend_RequestedRuntime=openvr_api.dll"]
open(cfg, "w").write("\n".join(lines) + "\n")
inject(proc, pid, "UEVRBackend.dll")
k32.CloseHandle(proc)
print(json.dumps({"pid": pid, "nullified": nullified}))
"""
class Fail(Exception):
pass
def libraries():
"""Steam library folders: the default one plus any in libraryfolders.vdf."""
libs = [STEAM]
try:
text = (STEAM / "steamapps/libraryfolders.vdf").read_text(errors="replace")
libs += [Path(p.replace("\\\\", "\\")) for p in re.findall(r'"path"\s+"([^"]+)"', text)]
except OSError:
pass
seen, out = set(), []
for lib in libs:
key = os.path.realpath(lib)
if key not in seen:
seen.add(key)
out.append(lib)
return out
def game(appid):
"""Where the game is installed and its Proton prefix, from its app manifest."""
for lib in libraries():
manifest = lib / f"steamapps/appmanifest_{appid}.acf"
try:
text = manifest.read_text(errors="replace")
except OSError:
continue
m = re.search(r'"installdir"\s+"([^"]+)"', text)
if not m:
continue
name = re.search(r'"name"\s+"([^"]*)"', text)
return {"name": name.group(1) if name else str(appid),
"dir": lib / "steamapps/common" / m.group(1),
"prefix": lib / f"steamapps/compatdata/{appid}/pfx"}
raise Fail(f"app {appid} isn't installed on this Frame")
def shipping_exe(game_dir):
"""The Unreal Engine game binary (…/Binaries/Win64/*-Win64-Shipping.exe), or None."""
found = [p for p in game_dir.glob("*/Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
found += [p for p in game_dir.glob("Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
return max(found, key=lambda p: p.stat().st_size) if found else None
def game_pid(exe_name):
"""The running Wine process for this .exe (its cmdline is the Windows path)."""
want = "\\" + exe_name.lower()
for d in Path("/proc").iterdir():
if not d.name.isdigit():
continue
try:
cmd = (d / "cmdline").read_bytes().split(b"\0")[0].decode(errors="replace").lower()
except OSError:
continue
if cmd.endswith(want):
return int(d.name)
return None
def loaded(pid, name):
try:
return any(line.rstrip().endswith("/" + name) for line in open(f"/proc/{pid}/maps"))
except OSError:
return False
def receipt_path(appid):
return RECEIPTS / f"{appid}-uevr.json"
def read_receipt(appid):
try:
return json.loads(receipt_path(appid).read_text())
except (OSError, ValueError):
return None
def managed_dir(g):
return g["prefix"] / "drive_c" / MANAGED
def user_dirs(g, exe):
"""What UEVR itself writes into the prefix when it first runs."""
user = g["prefix"] / "drive_c/users/steamuser/AppData"
return {"profile": user / "Roaming/UnrealVRMod" / exe.stem, # per-game settings and log.txt
"injector": user / "Local/praydog"} # the injector's own settings
def status(appid):
g = game(appid)
exe = shipping_exe(g["dir"])
r = read_receipt(appid)
pid = game_pid(exe.name) if exe else None
return {"appid": appid, "name": g["name"], "mod": "UEVR", "version": UEVR["version"],
"eligible": bool(exe), "exe": exe.name if exe else None,
"prefix": g["prefix"].is_dir(), "installed": bool(r),
"running": bool(pid), "injected": bool(pid and loaded(pid, "UEVRBackend.dll"))}
def fetch(url, digest, algo):
"""Download into the cache once, and check its hash every time it's used."""
CACHE.mkdir(parents=True, exist_ok=True)
path = CACHE / url.rsplit("/", 1)[1]
if not path.exists():
part = path.with_suffix(path.suffix + ".part")
try:
with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f:
for block in iter(lambda: r.read(1 << 20), b""):
if f.tell() + len(block) > MAX_DOWNLOAD:
raise Fail(f"{url} is bigger than expected; stopped the download")
f.write(block)
except (OSError, Fail) as e:
part.unlink(missing_ok=True)
raise Fail(f"download failed: {url}: {e}")
part.rename(path)
h = hashlib.new(algo)
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
if h.hexdigest() != digest:
path.unlink()
raise Fail(f"{path.name} doesn't match its published {algo}; deleted it, try again")
return path
def unpack(archive, dest, budget):
"""Extract a ZIP into dest, refusing anything that would land outside it."""
root = os.path.realpath(dest)
with zipfile.ZipFile(archive) as z:
for info in z.infolist():
target = os.path.realpath(os.path.join(dest, info.filename))
if target != root and not target.startswith(root + os.sep):
raise Fail(f"{archive.name} has an entry outside its folder: {info.filename}")
if (info.external_attr >> 16) & 0o170000 == 0o120000:
raise Fail(f"{archive.name} contains a symlink: {info.filename}")
budget -= info.file_size
if budget < 0:
raise Fail(f"{archive.name} unpacks to more than expected")
z.extractall(dest)
return budget
def install(appid):
g = game(appid)
exe = shipping_exe(g["dir"])
if not exe:
raise Fail(f"{g['name']} doesn't look like an Unreal Engine game (no *-Win64-Shipping.exe), so UEVR can't hook it")
if not g["prefix"].is_dir():
raise Fail(f"{g['name']} has no Proton prefix yet; play it once, then add the mod")
if game_pid(exe.name):
raise Fail(f"{g['name']} is running; quit it first")
if read_receipt(appid):
return {"message": f"UEVR {UEVR['version']} is already installed for {g['name']}", **status(appid)}
archives = [(fetch(UEVR["url"], UEVR["sha256"], "sha256"), UEVR["dir"])]
archives.append((fetch(PYTHON["url"], PYTHON["sha256"], "sha256"), PYTHON["dir"]))
base = managed_dir(g)
dirs = user_dirs(g, exe)
fresh = [str(d) for d in (dirs["profile"].parent, base) if not d.exists()] # remove on uninstall if empty
stage = base / f".staging-{os.getpid()}"
shutil.rmtree(stage, ignore_errors=True)
try:
budget = MAX_UNPACKED
for archive, sub in archives:
budget = unpack(archive, stage / sub, budget)
for need in (stage / UEVR["dir"] / "UEVRBackend.dll", stage / PYTHON["dir"] / "python.exe"):
if not need.is_file():
raise Fail(f"the download has no {need.name}")
if game_pid(exe.name): # it may have started during the download
raise Fail(f"{g['name']} started; quit it first")
(stage / PYTHON["dir"] / "frame_inject.py").write_text(INJECT_PY)
for sub in (UEVR["dir"], PYTHON["dir"]):
shutil.rmtree(base / sub, ignore_errors=True)
(stage / sub).rename(base / sub)
finally:
shutil.rmtree(stage, ignore_errors=True)
if str(base) in fresh:
try:
base.rmdir() # only succeeds if the install failed and left it empty
except OSError:
pass
receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name,
"installed": int(time.time()), "prefix": str(g["prefix"]),
"remove": [str(base / UEVR["dir"]), str(base / PYTHON["dir"])],
# Parents that didn't exist yet (UEVR makes UnrealVRMod), deepest first.
"remove_if_empty": fresh,
# UEVR creates these on first injection. Remove them on uninstall
# only if they weren't there before, so earlier settings survive.
"remove_if_created": {k: str(v) for k, v in dirs.items() if not v.exists()},
"sources": [UEVR["url"], PYTHON["url"]]}
RECEIPTS.mkdir(parents=True, exist_ok=True)
tmp = receipt_path(appid).with_suffix(".tmp")
tmp.write_text(json.dumps(receipt, indent=1))
tmp.rename(receipt_path(appid))
return {"message": f"Installed UEVR {UEVR['version']} for {g['name']}", **status(appid)}
def x11_windows(display):
"""(id, name, width) for each named top-level window, via xwininfo."""
try:
out = subprocess.run(["xwininfo", "-root", "-tree"], env={**os.environ, "DISPLAY": display},
capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return []
return [(int(m[1], 16), m[2], int(m[3])) for m in re.finditer(r'(0x[0-9a-f]+) "([^"]*)":.*?\)\s+(\d+)x\d+', out)]
def frame_submits(appid):
"""SteamVR's count of frames the app has submitted, or None if it isn't a scene app."""
try:
stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout)
except (OSError, ValueError, subprocess.TimeoutExpired):
return None
if not isinstance(stats, list):
return None
return next((s.get("frame_submits") for s in stats if isinstance(s, dict) and s.get("key") == f"steam.app.{appid}"), None)
def wait(what, seconds, check, step=2):
end = time.time() + seconds
while time.time() < end:
v = check()
if v is not None and v is not False: # 0 frames is still an answer
return v
time.sleep(step)
raise Fail(f"timed out waiting for {what}")
def start(appid):
g = game(appid)
r = read_receipt(appid)
if not r:
raise Fail(f"UEVR isn't installed for {g['name']}")
exe = r["exe"]
pid = game_pid(exe)
if pid and loaded(pid, "UEVRBackend.dll"):
return {"message": f"UEVR is already running in {g['name']}", "frame_submits": frame_submits(appid), **status(appid)}
if not pid:
subprocess.Popen(["steam", f"steam://rungameid/{appid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")}, start_new_session=True)
pid = wait(f"{g['name']} to start", 120, lambda: game_pid(exe))
try:
environ = Path(f"/proc/{pid}/environ").read_text(errors="replace")
except OSError:
raise Fail(f"{exe} exited") from None
game_env = dict(line.split("=", 1) for line in environ.split("\0") if GAME_ENV.match(line))
display = game_env.get("DISPLAY")
if not display or "WINEPREFIX" not in game_env:
raise Fail(f"{exe} doesn't look like a Proton game process")
# UEVR hooks the game's D3D11/12 device, so wait for its window. Proton's
# own helper windows ("Steam", "Default IME", …) are small.
wait("the game window", 90, lambda: any(w[2] >= 640 for w in x11_windows(display)))
time.sleep(10)
# Our own environment (HOME and the rest) under the game's Wine settings.
env = {**os.environ, **game_env}
base = f"C:\\{MANAGED}"
profile = f"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\{Path(exe).stem}"
try:
out = subprocess.run(["wine", f"{base}\\{PYTHON['dir']}\\python.exe", f"{base}\\{PYTHON['dir']}\\frame_inject.py",
exe, f"{base}\\{UEVR['dir']}", profile], env=env, capture_output=True, text=True,
timeout=120, stdin=subprocess.DEVNULL,
cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED))
except subprocess.TimeoutExpired:
raise Fail("the injection step didn't finish in 2 minutes") from None
reply = None
for line in reversed(out.stdout.splitlines()):
try:
reply = json.loads(line)
break
except ValueError:
continue
if not isinstance(reply, dict):
reply = None
if not reply or "error" in reply:
raise Fail("UEVR injection failed: " + (reply or {}).get("error", (out.stderr.strip().splitlines() or ["no output"])[-1]))
try:
wait("UEVRBackend.dll to load in the game", 20, lambda: loaded(pid, "UEVRBackend.dll"))
except Fail:
raise Fail(f"the injection reported success but {exe} has no UEVRBackend.dll") from None
submits = None
try:
submits = wait("SteamVR frames", 30, lambda: frame_submits(appid))
except Fail:
pass
return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits is not None
else "; SteamVR hasn't reported frames from it yet"),
"frame_submits": submits, **status(appid)}
def uninstall(appid):
r = read_receipt(appid)
if not r:
raise Fail(f"UEVR isn't installed for app {appid}")
if game_pid(r["exe"]):
raise Fail("the game is running; quit it first")
prefix = os.path.realpath(r["prefix"])
if not prefix.endswith(f"/steamapps/compatdata/{appid}/pfx"):
raise Fail(f"receipt names a prefix that isn't app {appid}'s: {r['prefix']}")
# Only ever our folder and UEVR's own settings folders for this game, even
# if the receipt says otherwise.
managed = os.path.join(prefix, "drive_c", MANAGED)
uevr_dirs = {os.path.realpath(str(d)) for d in user_dirs({"prefix": Path(prefix)}, Path(r["exe"])).values()}
targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values())
for t in targets:
real = os.path.realpath(t)
if not (real.startswith(managed + os.sep) or real in uevr_dirs):
raise Fail(f"receipt lists a path Frame Control didn't create: {t}")
for t in targets:
shutil.rmtree(t, ignore_errors=True)
left = [t for t in targets if os.path.lexists(t)]
if left: # keep the receipt, so Remove can be tried again
raise Fail("couldn't remove " + ", ".join(left))
parents = {os.path.realpath(os.path.join(prefix, "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod")), managed}
for t in r.get("remove_if_empty", []):
if os.path.realpath(t) in parents:
try:
os.rmdir(t)
except OSError:
pass # something else lives there now
receipt_path(appid).unlink()
if not any(RECEIPTS.glob("*-uevr.json")): # no other game uses the downloads
shutil.rmtree(CACHE, ignore_errors=True)
try:
RECEIPTS.rmdir()
except OSError:
pass
return {"message": "Removed UEVR", "appid": appid, "removed": targets}
def main(argv):
if len(argv) != 2 or argv[0] not in ("status", "install", "start", "uninstall") or not argv[1].isdigit():
raise Fail("usage: status|install|start|uninstall APPID")
action = {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]]
if action is status:
return status(int(argv[1]))
import fcntl # only on the Frame; the server and its tests also import this module on Windows
# One change at a time, or an uninstall could delete what an install is moving in.
LOCK.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK, "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
raise Fail("another mod action is still running on the Frame; try again when it finishes") from None
return action(int(argv[1]))
if __name__ == "__main__":
try:
print(json.dumps(main(sys.argv[1:])))
except Fail as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
except Exception as e: # still one JSON object, for the server to show
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
sys.exit(1)
+81 -9
View File
@@ -418,9 +418,9 @@
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
.and-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; }
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); }
#bugDlg { width: min(640px, calc(100vw - 40px)); }
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
@@ -429,7 +429,7 @@
#bugForm .popt { margin: 14px 0 0; }
#bugForm .sentlog { max-height: 200px; }
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2, #modDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
.about-text { max-height: 55vh; overflow: auto; }
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
.about-text summary { cursor: pointer; margin: 8px 0; }
@@ -1431,6 +1431,7 @@
<div class="sources-intro"><h1 id="sourcesTitle">Sources</h1><p>Where Discover looks for apps. Turn a source off to hide its apps.</p></div>
<div id="sourcesList"></div>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Add an F-Droid repository by its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><details><summary>This repository needs a login (optional)</summary><label>Username<input name="username" type="text" autocomplete="off" autocapitalize="none" spellcheck="false"></label><label>Password<input name="password" type="password" autocomplete="new-password"></label><p>Kept on this computer, apart from the address, and only sent to this repository over HTTPS.</p></details><button class="action">Add source</button></form>
<form id="itchAccount" class="source-repo-form"><h2>My itch.io purchases</h2><p id="itchAccountNote">Install the Android builds of games you own on itch.io. Create an API key in itch.io settings › API keys and paste it here. It stays on this computer and is only sent to itch.io.</p><label id="itchKeyField">itch.io API key<input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="300" placeholder="Paste your API key"></label><button class="action" id="itchConnect">Connect itch.io</button><button type="button" class="small" id="itchDisconnect" hidden>Disconnect itch.io</button></form>
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
</dialog>
@@ -1444,6 +1445,21 @@
<div class="actions"><button id="apkAltClose">Close</button></div>
</dialog>
<dialog id="modDlg" aria-labelledby="modTitle">
<h2 id="modTitle">Play in VR with UEVR</h2>
<p id="modState" class="sub"></p>
<p class="sub">UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads
the official UEVR 1.05 release and python.org's Windows Python, which it uses to load UEVR into the game
(about 20 MB), checks their hashes, and keeps them in this game's Proton prefix, not the game's folder.
Remove takes them out again.</p>
<p class="sub">Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies;
UEVR's own menu in the headset has the per-game settings.</p>
<div class="row rep-actions"><span class="sub" id="modMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="modClose">Close</button>
<button type="button" class="small" id="modRemove" hidden>Remove UEVR</button>
<button type="button" class="action small" id="modInstall" hidden>Install UEVR</button>
<button type="button" class="play small" id="modStart" hidden>▶ PLAY IN VR</button></div>
</dialog>
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
<h2 id="aboutTitle">About and licences</h2>
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
@@ -3379,7 +3395,8 @@ function gameButtons(id, name, free) {
const open = `<button class="small" data-gm="store" data-id="${id}" data-name="${esc(name)}" title="Open the store page in the headset">Store on Frame</button>`;
if (d && d.appid === id) return `<span class="tag">${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%</span>`;
if (g && g.installed) return `<span class="tag">Installed</span>` +
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>`;
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>` +
`<button class="small" data-mod="${id}" data-name="${esc(name)}" title="Flat-to-VR mod for Unreal Engine games">VR mod</button>`;
if (g) return `<button class="action small" data-gm="install" data-id="${id}" data-name="${esc(name)}">Install on Frame</button>`;
return `<a href="${storeUrl}" target="_blank" rel="noopener" title="Opens in your browser">${free ? "Get" : "Buy"} on Steam ↗</a>${open}`;
}
@@ -3451,6 +3468,45 @@ document.body.addEventListener("click", async e => {
});
loadOwned();
// ---- VR mod (UEVR) for one installed game; frame_mods.py does the work ----
const mod = { appid: null, name: "" };
function showMod(st, note) {
const busy = note === null;
$("modTitle").textContent = `${mod.name} in VR`;
$("modState").textContent = !st ? "Checking the game on the Frame…"
: !st.eligible ? "This doesn't look like an Unreal Engine game, so UEVR can't run it."
: !st.prefix ? "Play the game once first, so Steam sets up its Proton prefix."
: st.injected ? "UEVR is running in the game now. Put on the headset."
: st.installed ? `UEVR ${st.version} is installed for this game.${st.running ? " The game is running; Play in VR adds UEVR to it." : ""}`
: `UEVR ${st.version} isn't installed for this game yet.`;
$("modMsg").textContent = busy ? "Working… this can take a few minutes" : note || "";
const ok = st && st.eligible && st.prefix;
$("modInstall").hidden = !ok || st.installed; $("modStart").hidden = !ok || !st.installed || st.injected;
$("modRemove").hidden = !st || !st.installed;
for (const b of ["modInstall", "modStart", "modRemove"]) $(b).disabled = busy || (b === "modRemove" && st && st.running);
}
async function modAction(action, label) {
const appid = mod.appid;
showMod(mod.st, null);
const res = await act(`${label} for ${mod.name}`, () => api("/api/mods", { action, appid }));
if (appid !== mod.appid) return;
try { mod.st = await api("/api/mods", { action: "status", appid }); } catch {}
showMod(mod.st, res ? res.message : "Didn't work; the log has the reason");
}
document.body.addEventListener("click", async e => {
const b = e.target.closest("[data-mod]"); if (!b) return;
mod.appid = b.dataset.mod; mod.name = b.dataset.name; mod.st = null;
showMod(null, "");
$("modDlg").showModal();
const appid = mod.appid;
try { const st = await api("/api/mods", { action: "status", appid }); if (appid === mod.appid) { mod.st = st; showMod(st, ""); } }
catch (err) { if (appid === mod.appid) $("modState").textContent = err.message; }
});
$("modInstall").onclick = () => modAction("install", "Install UEVR");
$("modStart").onclick = () => modAction("start", "Start UEVR");
$("modRemove").onclick = () => modAction("uninstall", "Remove UEVR");
$("modClose").onclick = () => $("modDlg").close();
// ---- Discover apps ----
const sourceState = { vr: null, installable: false, apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
@@ -3494,10 +3550,23 @@ function sourceToken(s) {
}
async function loadSources() {
try {
const {sources} = await api("/api/sources"); sourceState.sources = sources;
const {sources, itch} = await api("/api/sources"); sourceState.sources = sources; showItchAccount(itch);
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}${s.login ? " · Uses a login" : ""}</small>${sourceToken(s)}${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div>${s.page_only ? `<a class="source-web" href="${esc(sourcePage(s.url))}" target="_blank" rel="noopener" title="Its apps can't be searched from here">Website ↗</a>` : `<input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}>`}</div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
} catch (e) { $("sourceError").textContent = e.message; }
}
// Owned on itch.io (the user's own purchases) or free: what the store shows instead of a price.
const storePrice = o => o.owned ? (o.owned_label || "Owned") : "Free";
function showItchAccount(itch) {
const on = !!itch?.connected;
$("itchAccountNote").textContent = on ? `Connected${itch.username ? " as " + itch.username : ""}. Android builds of games you own appear in the store, marked Owned on itch.io. Disconnecting deletes the API key from this computer.` : "Install the Android builds of games you own on itch.io. Create an API key in itch.io settings › API keys and paste it here. It stays on this computer and is only sent to itch.io.";
$("itchKeyField").hidden = on; $("itchConnect").hidden = on; $("itchDisconnect").hidden = !on;
$("itchAccount").elements.key.required = !on;
}
async function itchAccount(body) {
$("sourceError").textContent = body.action === 'itch-connect' ? "Checking the key with itch.io…" : "Disconnecting…";
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;showItchAccount(result.itch);await loadSources();await searchSources(); }
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
}
// Will it run? Short enough for a card; the full sentence is in its tooltip and the details.
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"] };
function storeRuns(o) {
@@ -3506,8 +3575,8 @@ function storeRuns(o) {
}
const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app";
function storeCard(app) {
const i=sourceState.apps.indexOf(app), o=app.offers[0];
return `<button class="store-card" data-app="${i}" aria-label="${esc(app.name)}: ${esc(o.verdict?.label || "not yet checked on the Frame")}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "From " + storeName(o.source_name))}</span><span class="store-card-foot"><span>${storeKind(o)}</span>${storeRuns(o)}</span></span></button>`;
const i=sourceState.apps.indexOf(app), o=app.offers[0], owned=app.offers.find(x=>x.owned);
return `<button class="store-card" data-app="${i}" aria-label="${esc(app.name)}: ${esc(o.verdict?.label || "not yet checked on the Frame")}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "From " + storeName(o.source_name))}</span>${owned ? `<span class="store-summary">${esc(storePrice(owned))}</span>` : ""}<span class="store-card-foot"><span>${storeKind(o)}</span>${storeRuns(o)}</span></span></button>`;
}
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2>${subtitle ? `<p>${esc(subtitle)}</p>` : ""}</div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
function storeEmpty(message) {
@@ -3563,7 +3632,7 @@ async function searchSources(quiet) {
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
}
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
function detailBadges(o) { return `<span class="store-badge">${storeKind(o)}</span>${storeRuns(o)}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
function detailBadges(o) { return `<span class="store-badge">${storeKind(o)}</span>${storeRuns(o)}<span class="store-badge">${esc(storePrice(o))}</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
// The store's app page. (Not renderDetail: the Devices page declares that further down,
// and a later function declaration silently replaces an earlier one; issue #68.)
// True when the page rendered; false when it shows the error instead.
@@ -3579,7 +3648,7 @@ function renderAppDetail() {
function renderAppDetailBody() {
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">${esc(storePrice(o))}</div><p class="detail-small">${o.owned ? "Downloads with your itch.io account." : "No purchase needed."}</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
updateInstall();
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
}
@@ -3698,6 +3767,9 @@ $("addSource").onsubmit=e=>{
const f=new FormData(e.target), url=f.get('url'), username=f.get('username'), password=f.get('password');
addSource({action:'add',url,fingerprint:f.get('fingerprint'),...(username ? {username,password} : {})});
};
// The key goes to the server once and leaves the page straight away: the field is cleared before the reply.
$("itchAccount").onsubmit=e=>{e.preventDefault();const input=e.target.elements.key, key=input.value.trim();input.value='';if(key)itchAccount({action:'itch-connect',key});};
$("itchDisconnect").onclick=()=>itchAccount({action:'itch-disconnect'});
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
function showAndroidTab(tab) {
+29 -6
View File
@@ -46,6 +46,7 @@ import frame_assistant # noqa: E402
import frame_android # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
from apk_sources import fdroid as apk_fdroid # noqa: E402
from apk_sources import _itch_account as itch_account # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_devices # noqa: E402
@@ -709,13 +710,14 @@ def launch(body):
return {"message": f"Launching {appid}"}
def steam_frame(*args, timeout=40):
"""Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON."""
def steam_frame(*args, timeout=40, script="frame_steam.py"):
"""Run frame_steam.py (it drives the Steam client), or another on-Frame
helper with the same JSON contract, on the Frame and return its JSON."""
try:
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout)
stdin=(HERE / script).read_text(), timeout=timeout)
except Failure as e:
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh()
# The helper prints {"error": ...} on stdout when it fails, but ssh()
# reports stderr instead if there was any, so look in both.
for line in [*reversed(getattr(e, "stdout", "").splitlines()), *reversed(str(e).splitlines())]:
try:
@@ -766,6 +768,20 @@ def vr(body):
return result
# Downloads are about 20 MB; starting waits for the game, the injection and SteamVR.
MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60}
def mods(body):
"""UEVR for one installed Unreal game: status, install, start (inject) or uninstall."""
appid, action = str(body.get("appid", "")), body.get("action")
if not APPID.match(appid):
raise Failure("bad appid", 400)
if action not in MOD_TIMEOUT:
raise Failure("action must be " + ", ".join(MOD_TIMEOUT), 400)
return steam_frame(action, appid, timeout=MOD_TIMEOUT[action], script="frame_mods.py")
def steam_search(query):
q = parse_qs(query)
cc = (q.get("cc") or [""])[0].upper()
@@ -2478,6 +2494,13 @@ def source_manage(body):
if action == 'remove':
apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source'))
return {'message': 'Repository removed'}
if action == 'itch-connect': # the key is checked with itch.io, saved 0600, never echoed
key = body.get('key')
if not isinstance(key, str) or not key.strip() or len(key) > 300:
raise Failure('Paste your itch.io API key', 400)
return itch_account.connect(key)
if action == 'itch-disconnect':
return itch_account.disconnect()
raise Failure('Unknown source action', 400)
except SourceError as e:
raise Failure(str(e), 400)
@@ -2489,7 +2512,7 @@ POST = {
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
"/api/input": remote_input, "/api/touch": remote_touch,
"/api/settings/artwork": frame_steamgriddb.save_settings,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel,
@@ -2702,7 +2725,7 @@ class Handler(BaseHTTPRequestHandler):
except SourceError as e:
raise Failure(str(e), 400)
elif path == "/api/sources":
self.send_json({"sources": apk_search.sources()})
self.send_json({"sources": apk_search.sources(), "itch": itch_account.status()})
elif path == "/api/search":
self.send_json(source_search(url.query))
elif path == "/api/apk-versions":