mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-18 11:01:18 +02:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7f694956d | ||
|
|
f9496d67da | ||
|
|
7eae209b1c | ||
|
|
28bc0506a6 | ||
|
|
0a4f008d03 | ||
|
|
0931ff57d5 | ||
|
|
a8e5d7cf61 | ||
|
|
5cdfee4f62 | ||
|
|
dd80ca7b74 | ||
|
|
612a766057 | ||
|
|
75acf4a3e9 | ||
|
|
629a2fafb6 | ||
|
|
b0d848dc5c | ||
|
|
fa9ee01cc1 | ||
|
|
0f8aaa393b | ||
|
|
382fb0f906 | ||
|
|
76e90af5b7 | ||
|
|
5456f2f6f6 | ||
|
|
2cfb61a208 | ||
|
|
c6e4362c66 | ||
|
|
88d61cb3b6 | ||
|
|
2d63dacdf9 | ||
|
|
adefdf4398 | ||
|
|
2c3f8b3e0c | ||
|
|
87884f382b | ||
|
|
f98f3e728b | ||
|
|
90034540ae | ||
|
|
cc9af9eba4 | ||
|
|
bc0e0fc043 | ||
|
|
5b4efc87b3 | ||
|
|
c2c1cf0168 | ||
|
|
01ba796881 | ||
|
|
b9f96866e5 | ||
|
|
3fb876ad5a | ||
|
|
9bd6890fe8 | ||
|
|
0d181b676e | ||
|
|
8de0292568 | ||
|
|
8e8c24f370 | ||
|
|
79dda60bde | ||
|
|
0bb2f9eb03 | ||
|
|
30509da71f | ||
|
|
d4b34e1bad | ||
|
|
22548bc42f | ||
|
|
1863c92101 | ||
|
|
497b5d9ae6 | ||
|
|
04995c066f | ||
|
|
0bceb5c928 | ||
|
|
22bb9c6469 | ||
|
|
fab04f0558 | ||
|
|
c0ae75d56b | ||
|
|
0520082586 | ||
|
|
36dd5a05f2 | ||
|
|
002c040223 | ||
|
|
4b1bb1557d | ||
|
|
b151ae691e | ||
|
|
36db6b1ac6 | ||
|
|
12aaec7888 | ||
|
|
7ea999d5b4 | ||
|
|
9e25ae9025 | ||
|
|
ed4a0ad5cd | ||
|
|
cb0a90a9c3 | ||
|
|
32fbbfe475 |
No files matched your search
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
||||
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
|
||||
diagnostics with personal details removed, shows you exactly what's included,
|
||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
|
||||
|
||||
## Assistant panel
|
||||
|
||||
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
||||
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
|
||||
To put the same page in the headset, with the HTTP server still running:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-3
@@ -3,8 +3,9 @@
|
||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||
reached at more than one address: a LAN IP at home, another at the office, its
|
||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||
tab (key 5) lists them, and the connection pill in the header shows what the
|
||||
app is doing to reach the one in use, step by step, as it happens.
|
||||
tab (key 5) lists them. The connection pill in the header says whether the one in
|
||||
use is connected and how (Tailscale, or the network's name); click it to add,
|
||||
edit or reorder that headset's addresses, or to see each step of connecting.
|
||||
|
||||
The code is in three modules, all stdlib-only Python on your computer:
|
||||
|
||||
@@ -57,7 +58,7 @@ and ranks them:
|
||||
5. addresses that only ever worked on other networks;
|
||||
6. Tailscale addresses while Tailscale is off.
|
||||
|
||||
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
||||
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
|
||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||
still trying. If SSH to the winner fails in a way another address could fix
|
||||
(a different device answered there, or the link dropped), the next one that
|
||||
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
|
||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||
services and checks `ALIAS.local` and `frame.local`.
|
||||
|
||||
**The pill's dialog** lists the same addresses, with what each one answered, and
|
||||
can add, edit, reorder and remove them without leaving the page you're on. When
|
||||
the headset reports a LAN IP on the same network as this computer and that IP
|
||||
isn't saved, it offers to add it. The offer puts the address first in the list,
|
||||
so on that network it wins over the Tailscale name; away from home the Tailscale
|
||||
name still leads. A new or edited address is tested straight away. While
|
||||
connected, **Reconnect** applies your changes now rather than at the next
|
||||
connection: it tries the addresses again, ranked as above, and the best-ranked
|
||||
one that answers promptly wins. It doesn't pick a particular address.
|
||||
|
||||
## Networks
|
||||
|
||||
A network is told apart by its default gateway: the router's IP address plus its
|
||||
|
||||
@@ -58,7 +58,7 @@ not been verified and is not guaranteed.
|
||||
|
||||
## Casting
|
||||
|
||||
**Cast headset view** starts the existing headset Live view and requests full
|
||||
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
|
||||
screen where supported. Show that screen to people in the room, or use the
|
||||
computer/phone's own screen mirroring. It creates no new stream transport,
|
||||
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
||||
|
||||
+16
-10
@@ -17,15 +17,21 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has six tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power), **Devices** (your headsets and their addresses),
|
||||
and **Control** (the searchable Control Center, metrics, supported settings and
|
||||
headset guidance). Keys 1–6 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
The window has six tabs: **Home** (what the headset's doing and what you'd do
|
||||
next, the headset view, screenshots, family and comfort, the keyboard and
|
||||
trackpad, and VR performance, folded away), **Games** (installed games,
|
||||
sideloaded titles, getting games), **Android** (apps, the catalogue, display
|
||||
settings, reports), **Tools** (in the headset: your computer's windows, the media
|
||||
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
||||
power), **Devices** (your headsets and their addresses), and **Control** (the
|
||||
searchable Control Center, metrics, supported settings and headset guidance).
|
||||
Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**:
|
||||
privacy and updates, library artwork and the assistant. The battery chip opens
|
||||
the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
|
||||
build. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset and whether it's
|
||||
connected, and how (Tailscale, or the network's name). Click it to add, edit or
|
||||
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
|
||||
reached, a banner says why in plain words, what was tried, and counts down to
|
||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
@@ -239,5 +245,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
|
||||
require a separate user approval; power also retains its password prompt. The
|
||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||
operate the headset. Tools → Open assistant opens the page; the linked guide
|
||||
operate the headset. Settings → Open assistant settings opens the page; the linked guide
|
||||
covers putting it in a Chromium panel on the Frame.
|
||||
+2
-2
@@ -114,8 +114,8 @@ don't.
|
||||
|
||||
## Family and comfort
|
||||
|
||||
The shared Home card sets session limits, breaks and check-ins, and offers
|
||||
**Cast headset view**. **Enable / test notifications** requests iOS notification
|
||||
The shared Home card sets session limits, breaks and check-ins; **Cast to this
|
||||
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
|
||||
permission and sends a local test. These are local notifications, not APNs push;
|
||||
iOS background suspension can interrupt phone alerts. The headset timer still
|
||||
runs. See [the behavior and verification limits](family-comfort.md).
|
||||
+1
-1
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
|
||||
separate from Frame Control. Public reports describe some Proton support;
|
||||
Windows-only does not by itself prove a Frame app cannot run. Local status
|
||||
and sources are in [VR utilities](vr-utilities.md).
|
||||
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
|
||||
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
|
||||
headset creates its own gamescope panel using built-in tools. It needs no
|
||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||
|
||||
|
||||
+2
-2
@@ -132,8 +132,8 @@ runs.
|
||||
|
||||
## Report a problem
|
||||
|
||||
**Report a problem** is the warning-sign button in the header, also in the
|
||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||
**Report a problem** is the speech-bubble button in the header, also in the
|
||||
Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report
|
||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||
same way as the analytics above, so only the maintainer can read it and
|
||||
nothing is published. It works whatever the analytics settings are, because
|
||||
|
||||
@@ -65,6 +65,8 @@ flow, not evidence of an anonymous download API. It is not implemented here.
|
||||
- `search()` raises a user-readable `SourceError` with the browse URL (zero
|
||||
limit returns no rows). It does not invent app results or report a false
|
||||
“no matching games”. The aggregate search UI should surface this source error.
|
||||
In the app, Discover doesn't link to it; the **Sources** dialog lists SideQuest
|
||||
with a link to its website instead of an on/off switch.
|
||||
- `details()` accepts a numeric listing id and returns its canonical page link,
|
||||
`downloadable: False`, empty versions/tags/headsets and the `images` shape
|
||||
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as
|
||||
an installed app with a blank tile.
|
||||
|
||||
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
||||
Set the optional free key in Frame Control's **Library artwork settings**, or
|
||||
Set the optional free key in Frame Control's **Settings → Library artwork**, or
|
||||
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
||||
settings override the saved key. Without a key there are no provider calls or
|
||||
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
||||
|
||||
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
|
||||
|
||||
## Our performance HUD
|
||||
|
||||
On **Home → VR comfort and performance**, the app shows a timestamped sample
|
||||
On **Home → VR performance** (folded until you open it), the app shows a timestamped sample
|
||||
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
||||
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
||||
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
|
||||
|
||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||
it running. This mode also returned live timing in a read-only device probe.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path.
|
||||
|
||||
## Use it
|
||||
|
||||
In **Tools → Media in the headset**, send a file, choose its layout and press
|
||||
In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press
|
||||
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
||||
**Stop** removes both. Refresh reads the library and the player's state.
|
||||
The screen follows your head; it isn't a saved world-space panel.
|
||||
|
||||
@@ -64,7 +64,7 @@ function sharedPage({productionApi=false} = {}) {
|
||||
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||||
};
|
||||
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||||
resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
vm.runInContext(`const UI_KEY = 'test-only';
|
||||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
|
||||
@@ -443,20 +443,20 @@ test('every linked workflow selects its real page and section, preserving shortc
|
||||
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
|
||||
let activePage,scrolled;
|
||||
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
||||
const nodes=['home','games','android','tools','devices','control','settings'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
||||
const sections=new Map();
|
||||
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
|
||||
window:{scrollTo(){}},$:id=>{
|
||||
if(!ids.has(id))return null;
|
||||
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
|
||||
if(!sections.has(id))sections.set(id,{hidden:false,classList:{toggle(){}},scrollIntoView(){scrolled=id;}});
|
||||
return sections.get(id);
|
||||
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
|
||||
},live:false,toggleLive(){},scrollToY(){},loadMacView(){},loadPanelSwitcher(){}};
|
||||
const androidStart=html.indexOf('function showAndroidTab(');
|
||||
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
|
||||
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
|
||||
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\n'+html.match(/^PAGES\.push\([^\n]+/m)[0],context);
|
||||
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
|
||||
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
|
||||
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
|
||||
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
|
||||
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||||
if(!href.startsWith('#')||href==='#connection')continue;
|
||||
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
|
||||
@@ -468,4 +468,5 @@ test('every linked workflow selects its real page and section, preserving shortc
|
||||
}
|
||||
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
|
||||
assert.equal(vm.runInContext('PAGES[5]',context),'control');
|
||||
assert.equal(vm.runInContext('PAGES[6]',context),'settings');
|
||||
});
|
||||
+5
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -19,8 +19,8 @@ function grab(name) {
|
||||
fail(`unbalanced ${name}`);
|
||||
}
|
||||
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
||||
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails"];
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "storePrice", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
|
||||
const code = names.map(grab).join("");
|
||||
|
||||
// A fake page: elements by id. The install panel's elements exist only while
|
||||
@@ -34,8 +34,10 @@ function page(api = async () => ({})) {
|
||||
const document = { activeElement: null, querySelector: () => null };
|
||||
const console = { error() {} };
|
||||
const calls = [];
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); });
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "runJob", "loadAndroid", "act", "window", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); },
|
||||
async (title, key, work) => work(), () => {}, async (title, work) => work(),
|
||||
{ open() { fail("an in-app installation opened an external page"); } });
|
||||
return { els, sourceState, calls, ...fns };
|
||||
}
|
||||
|
||||
@@ -54,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();
|
||||
@@ -68,7 +81,7 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
|
||||
{
|
||||
const p = page(async () => { throw new Error("offline"); });
|
||||
p.sourceState.apps = [{ name: "Broken", offers: [] }];
|
||||
await p.openStoreApp(0);
|
||||
await p.openStoreApp(0, true);
|
||||
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error");
|
||||
if (p.calls.length) fail("asked for details of a listing that couldn't render");
|
||||
}
|
||||
@@ -90,4 +103,18 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
|
||||
if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request");
|
||||
if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request");
|
||||
}
|
||||
// The featured Install starts a new or failed job, while a job already running or
|
||||
// installed opens its details without another install or a launch command.
|
||||
for (const [job, expected] of [[null, 1], [{ done: false }, 0],
|
||||
[{ done: true, result: { package: offer.package } }, 0], [{ done: true, error: "offline" }, 1]]) {
|
||||
const p = page(async path => path === "/api/sources/install" ? { package: offer.package } : {});
|
||||
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
|
||||
if (job) p.sourceState.jobs.set(`${offer.source}:${offer.id}`, job);
|
||||
await p.openStoreApp(0, true);
|
||||
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('id="detailInstall"')) fail("hero installation didn't open details");
|
||||
const installs = p.calls.filter(([path]) => path === "/api/sources/install");
|
||||
if (installs.length !== expected) fail(`expected ${expected} installation requests, got ${installs.length}`);
|
||||
if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
|
||||
if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
|
||||
}
|
||||
console.log("ok");
|
||||
@@ -56,6 +56,48 @@ const log = ()=>{}, toast = ()=>{};
|
||||
assert.equal($('sessionMinutes').disabled,false);
|
||||
assert.equal($('sessionCancel').disabled,true);
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_a_finished_session_leaves_the_defaults(self):
|
||||
"""A one-minute test session that has ended mustn't fill the form with 1 / 0 / 0."""
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
|
||||
setup = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const elements = new Map();
|
||||
const $ = id => {
|
||||
// The form's own defaults, as in the page's HTML.
|
||||
const value = {sessionMinutes:'30', breakMinutes:'20', stillMinutes:'30'}[id] || '';
|
||||
if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value, defaultValue:value,
|
||||
checked:true, defaultChecked:true, type: /Alert$/.test(id) ? 'checkbox' : 'number'});
|
||||
return elements.get(id);
|
||||
};
|
||||
const window = {frameApp:{notify:async()=>{}}};
|
||||
const log = ()=>{}, toast = ()=>{};
|
||||
'''
|
||||
checks = r'''
|
||||
renderComfort({id:'old', active:false, time:100, options:{minutes:1,breakMinutes:0,stillMinutes:0,batteryAlert:false,heatAlert:false},
|
||||
events:[{id:'old:1',kind:'finished',time:90,message:'Session ended'}]});
|
||||
assert.equal($('sessionMinutes').value, '30');
|
||||
assert.equal($('breakMinutes').value, '20');
|
||||
assert.equal($('batteryAlert').checked, true);
|
||||
assert.match($('comfortEvents').textContent, /Session ended/);
|
||||
assert.notEqual($('comfortEvents').textContent, 'Session ended'); // it carries a time
|
||||
renderComfort({id:'new', active:true, time:200, remaining:600, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, events:[]});
|
||||
assert.equal($('sessionMinutes').value, 45); // a running session shows its own settings
|
||||
assert.equal($('heatAlert').checked, false);
|
||||
// The page stays open while that session ends: the next one starts from the defaults again.
|
||||
renderComfort({id:'new', active:false, time:900, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false},
|
||||
events:[{id:'new:1',kind:'finished',time:899,message:'Session ended'}]});
|
||||
assert.equal($('sessionMinutes').value, '30');
|
||||
assert.equal($('breakMinutes').value, '20');
|
||||
assert.equal($('heatAlert').checked, true);
|
||||
renderComfort({id:'new', active:false, time:905, options:{minutes:45}, events:[]});
|
||||
$('sessionMinutes').value = '50'; // what's typed for the next session
|
||||
renderComfort({id:'new', active:false, time:910, options:{minutes:45}, events:[]});
|
||||
assert.equal($('sessionMinutes').value, '50'); // later polls leave it alone
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@@ -323,6 +323,20 @@ class Pins(Base):
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
def test_an_address_added_first_wins_on_its_own_network(self):
|
||||
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
|
||||
# worked here before. The LAN address has to go ahead of it to be used at home.
|
||||
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
|
||||
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
|
||||
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
|
||||
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
|
||||
addrs = self.reg.get(d["id"])["addresses"]
|
||||
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
|
||||
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
|
||||
self.assertEqual(at_home[0], "192.168.1.40")
|
||||
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
|
||||
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
|
||||
|
||||
def test_address_editing(self):
|
||||
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||
|
||||
@@ -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()
|
||||
@@ -897,6 +897,47 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual(self.routes, [])
|
||||
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
||||
|
||||
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
|
||||
d = self.device("nothing.invalid")
|
||||
entered = {1: threading.Event(), 2: threading.Event()}
|
||||
release = {1: threading.Event(), 2: threading.Event()}
|
||||
calls = []
|
||||
|
||||
def probe(host, port, update=None):
|
||||
calls.append(host)
|
||||
n = len(calls)
|
||||
entered[n].set()
|
||||
release[n].wait(10)
|
||||
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
|
||||
|
||||
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
|
||||
with mock.patch.object(fl, "probe", probe):
|
||||
try:
|
||||
tests[0].start()
|
||||
self.assertTrue(entered[1].wait(5), "the first test never probed")
|
||||
tests[1].start()
|
||||
self.assertTrue(entered[2].wait(5), "the second test never probed")
|
||||
# The first finishes while the second is still probing: it mustn't show its
|
||||
# rows or mark the second done.
|
||||
release[1].set()
|
||||
tests[0].join(10)
|
||||
self.assertFalse(tests[0].is_alive())
|
||||
running = self.link.snapshot()["tests"][d["id"]]
|
||||
self.assertFalse(running["done"])
|
||||
self.assertEqual(running["rows"][0]["detail"], "Waiting")
|
||||
release[2].set()
|
||||
tests[1].join(10)
|
||||
self.assertFalse(tests[1].is_alive())
|
||||
finally:
|
||||
for e in release.values():
|
||||
e.set()
|
||||
for t in tests:
|
||||
if t.ident: # started
|
||||
t.join(10)
|
||||
result = self.link.snapshot()["tests"][d["id"]]
|
||||
self.assertTrue(result["done"])
|
||||
self.assertEqual(result["rows"][0]["detail"], "test 2")
|
||||
|
||||
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
|
||||
@@ -56,18 +56,19 @@ class LinkUI(unittest.TestCase):
|
||||
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_the_pill_leads_with_the_reason(self):
|
||||
# The header pill cut off "Offline: checking the headset's identity failed · retry in…":
|
||||
# the reason comes first, in a few words, and only the countdown gets cut.
|
||||
def test_the_pill_keeps_the_failure_detail_in_its_tooltip(self):
|
||||
# The connection redesign keeps the state and retry countdown in the pill,
|
||||
# with the full failure explanation in its tooltip and details dialog.
|
||||
self.run_page(r'''
|
||||
link.s = changed;
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>SSH identity changed · retry in 12 s<\/span>/);
|
||||
assert.equal($('conn').title, 'Office Frame is offline: SSH identity changed · retry in 12 s. Click for details.');
|
||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
|
||||
assert.ok($('conn').title.includes(changed.error.message));
|
||||
// Without a short reason: the stage that failed.
|
||||
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<span>Checking the headset's identity failed<\/span>/);
|
||||
assert.match($('conn').innerHTML, /<span>Offline<\/span>/);
|
||||
assert.ok($('conn').title.includes('Something.'));
|
||||
''')
|
||||
|
||||
def test_the_banner_never_doubles_a_full_stop(self):
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""The page's inline scripts share one global scope, so a second top-level function or
|
||||
variable with a name already used replaces the first everywhere, silently."""
|
||||
import html.parser
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
# Compiles the scripts as one strict-mode block. There, functions are block scoped like let
|
||||
# and const, so V8 itself rejects a name declared twice in the shared scope (however it's
|
||||
# indented or declared: function, class, let, const, destructuring), while helpers with the
|
||||
# same name inside different functions stay legal. var is the exception (declaring one twice
|
||||
# is allowed), so the page doesn't use var at all: Node's own copy of the acorn parser finds
|
||||
# real var declarations, not the word in comments, strings or CSS var().
|
||||
DUPLICATES = r'''
|
||||
const vm = require('vm');
|
||||
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
try { new vm.Script('"use strict"; {\n' + scripts.join('\n;\n') + '\n}'); console.log('ok'); }
|
||||
catch (e) { console.log(e.message); }
|
||||
'''
|
||||
VARS = r'''
|
||||
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
let acorn, walk;
|
||||
try {
|
||||
acorn = require('internal/deps/acorn/acorn/dist/acorn');
|
||||
walk = require('internal/deps/acorn/acorn-walk/dist/walk');
|
||||
} catch { console.log('no parser'); process.exit(0); }
|
||||
for (const code of scripts) {
|
||||
let found = null;
|
||||
try {
|
||||
walk.simple(acorn.parse(code, { ecmaVersion: 'latest' }), {
|
||||
VariableDeclaration(n) { if (n.kind === 'var' && !found) found = code.slice(n.start, n.end).slice(0, 40); },
|
||||
});
|
||||
} catch (e) { console.log(e.message); process.exit(0); }
|
||||
if (found) { console.log('declares with var: ' + found); process.exit(0); }
|
||||
}
|
||||
console.log('ok');
|
||||
'''
|
||||
|
||||
|
||||
def node(script, scripts, *flags):
|
||||
r = subprocess.run(['node', *flags, '-e', script], input=json.dumps(scripts), capture_output=True, text=True)
|
||||
return r.stdout.strip() or r.stderr.strip()
|
||||
|
||||
|
||||
def check(scripts):
|
||||
"""'ok', or V8's message for the first name declared twice."""
|
||||
return node(DUPLICATES, scripts)
|
||||
|
||||
|
||||
def check_vars(scripts):
|
||||
"""'ok', or the first var declaration. Skips where Node doesn't include acorn."""
|
||||
out = node(VARS, scripts, '--expose-internals')
|
||||
if out == 'no parser':
|
||||
raise unittest.SkipTest("this Node doesn't include acorn")
|
||||
return out
|
||||
|
||||
|
||||
class ClassicScripts(html.parser.HTMLParser):
|
||||
"""The page's inline classic scripts: no src, and no type other than a JavaScript one (a
|
||||
module has its own scope, and JSON data isn't code). The types a browser runs as script:
|
||||
https://mimesniff.spec.whatwg.org/#javascript-mime-type"""
|
||||
JS = {'', 'application/ecmascript', 'application/javascript', 'application/x-ecmascript',
|
||||
'application/x-javascript', 'text/ecmascript', 'text/javascript', 'text/javascript1.0',
|
||||
'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4',
|
||||
'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript'}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.scripts, self.current = [], None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'script':
|
||||
a = dict(attrs)
|
||||
inline = 'src' not in a and (a.get('type') or '').strip().lower() in self.JS
|
||||
self.current = [] if inline else None
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.current is not None:
|
||||
self.current.append(data)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == 'script' and self.current is not None:
|
||||
self.scripts.append(''.join(self.current))
|
||||
if tag == 'script':
|
||||
self.current = None
|
||||
|
||||
|
||||
def classic_scripts(page):
|
||||
p = ClassicScripts()
|
||||
p.feed(page)
|
||||
return p.scripts
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node parses the page scripts')
|
||||
class PageScripts(unittest.TestCase):
|
||||
def test_no_top_level_name_is_declared_twice(self):
|
||||
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
|
||||
self.assertGreaterEqual(len(scripts), 2)
|
||||
self.assertEqual(check(scripts), 'ok')
|
||||
|
||||
def test_the_page_declares_nothing_with_var(self):
|
||||
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
|
||||
self.assertEqual(check_vars(scripts), 'ok')
|
||||
|
||||
def test_the_check_finds_what_it_should(self):
|
||||
twice = {
|
||||
'indented function': ['function loadPanels() {}', ' async function loadPanels() {}'],
|
||||
'class': ['class Panel {}', 'class Panel {}'],
|
||||
'destructured': ['const { a, b } = {};', 'let [b] = [];'],
|
||||
'later declarator': ['let x = 1;', 'const y = 2, x = 3;'],
|
||||
'function and const': ['function f() {}', 'const f = 1;'],
|
||||
}
|
||||
for what, scripts in twice.items():
|
||||
with self.subTest(what):
|
||||
self.assertIn('already been declared', check(scripts))
|
||||
helpers = ['function a() { function help() {} }', 'function b() { const help = 1; }']
|
||||
self.assertEqual(check(helpers), 'ok')
|
||||
|
||||
def test_the_var_check_finds_what_it_should(self):
|
||||
# var: legal to declare twice, so not allowed at all; however it's written.
|
||||
for code in ['var x = 1;', 'var/*c*/x = 1;', 'var {x} = {x: 1};', 'function f() { var y; }']:
|
||||
with self.subTest(code):
|
||||
self.assertIn('declares with var', check_vars([code]))
|
||||
# The word var elsewhere is fine.
|
||||
text = ['// var x\nconst a = "var y", b = `var ${a}`, c = "color: var(--blue)", d = {}.var;']
|
||||
self.assertEqual(check_vars(text), 'ok')
|
||||
|
||||
def test_only_inline_classic_scripts_are_checked(self):
|
||||
page = ('<script>let a;</script><script type="module">export const m = 1;</script>'
|
||||
'<script type="application/json">{"b": 1}</script><script src = "x.js"></script>'
|
||||
'<script data-src="y" type="text/javascript">let c;</script>'
|
||||
'<script type="text/ecmascript">let d;</script><script type=" Application/X-JavaScript ">let e;</script>')
|
||||
self.assertEqual(classic_scripts(page), ['let a;', 'let c;', 'let d;', 'let e;'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""The page calls showPage() while its first script is still running, before names declared
|
||||
later (in that script or the second one) exist. Touching one of them there throws, and the
|
||||
rest of the page's setup never runs: opening Frame Control at #games did exactly that."""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab']
|
||||
|
||||
RUN = r'''
|
||||
const vm = require('vm');
|
||||
const {code, hashes} = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
const failures = [];
|
||||
for (const hash of hashes) {
|
||||
const context = vm.createContext({
|
||||
location: {hash}, document: {querySelectorAll: () => [], title: ''}, live: false,
|
||||
});
|
||||
try { vm.runInContext(code, context); }
|
||||
catch (e) { failures.push(`${hash}: ${e.message}`); }
|
||||
}
|
||||
console.log(JSON.stringify(failures));
|
||||
'''
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node runs the page code')
|
||||
class PageStartup(unittest.TestCase):
|
||||
def test_opening_any_page_or_section_at_startup_runs(self):
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
first, second = re.findall(r'<script>(.*?)</script>', page, re.S)[:2]
|
||||
tables = first[first.index('const PAGES ='):first.index('let page =')]
|
||||
start = first.index('function showPage(')
|
||||
show = first[start:first.index('\n}\n', start) + 3]
|
||||
# Everything declared after the startup call is still uninitialised when it runs.
|
||||
call = re.search(r'^showPage\(\);', first, re.M).end()
|
||||
later = re.findall(r'^(?:const|let)\s+(\w+)', first[call:] + second, re.M)
|
||||
later = [n for n in dict.fromkeys(later) if n not in STUBS and n not in ('PAGES', 'SECTION_PAGE', 'page')]
|
||||
self.assertIn('link', later) # the name that broke #games
|
||||
stubs = ''.join(f'function {n}() {{ return {{ classList: {{ toggle() {{}} }}, scrollIntoView() {{}} }}; }}\n'
|
||||
for n in STUBS if n != '$')
|
||||
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + show +
|
||||
'showPage();\n' + ''.join(f'let {n};\n' for n in later))
|
||||
hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#control', '#nowhere']
|
||||
hashes += ['#' + k for k in re.findall(r'(\w+): "', tables)]
|
||||
r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertEqual(json.loads(r.stdout), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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'])
|
||||
@@ -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)
|
||||
|
||||
+4
-2
@@ -740,7 +740,9 @@ class Registry:
|
||||
return a
|
||||
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
||||
|
||||
def add_address(self, device_id, host, kind=None, label=""):
|
||||
def add_address(self, device_id, host, kind=None, label="", first=False):
|
||||
"""Add an address at the end of the list, or at the front (first=True), where the
|
||||
user's order makes it win over the others that work on the same network."""
|
||||
with self._changing():
|
||||
d = self._find(device_id)
|
||||
a = new_address(host, kind, label)
|
||||
@@ -748,7 +750,7 @@ class Registry:
|
||||
raise DeviceError(f"{a['host']} is already on the list")
|
||||
if len(d["addresses"]) >= 32:
|
||||
raise DeviceError("That's enough addresses for one headset")
|
||||
d["addresses"].append(a)
|
||||
d["addresses"].insert(0 if first else len(d["addresses"]), a)
|
||||
self.save()
|
||||
return copy.deepcopy(a)
|
||||
|
||||
|
||||
+7
-1
@@ -356,6 +356,7 @@ class Link:
|
||||
self.version = 0
|
||||
self.stopped = False
|
||||
self.kicks = [] # reasons someone asked for a (re)connect
|
||||
self.test_gen = {} # device id -> its newest test of the addresses (see test())
|
||||
self.busy = False # the loop is handling kicks
|
||||
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
|
||||
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
|
||||
@@ -1306,9 +1307,13 @@ class Link:
|
||||
started = now()
|
||||
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
|
||||
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
|
||||
with self.cond:
|
||||
gen = self.test_gen[device_id] = self.test_gen.get(device_id, 0) + 1
|
||||
|
||||
def put(**fields):
|
||||
with self.cond:
|
||||
if gen != self.test_gen[device_id]:
|
||||
return # a newer test has started: its results are the ones to show
|
||||
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
|
||||
self.version += 1
|
||||
self.cond.notify_all()
|
||||
@@ -1617,7 +1622,8 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
|
||||
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
|
||||
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
|
||||
elif action == "address-add":
|
||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
|
||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "",
|
||||
first=body.get("first") is True)
|
||||
if is_active and link.state["phase"] == "failed":
|
||||
link.kick("retry")
|
||||
msg = f"Added {a['host']}"
|
||||
|
||||
+661
-289
File diff suppressed because it is too large.
Load diff
+9
-1
@@ -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
|
||||
@@ -2493,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)
|
||||
@@ -2717,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":
|
||||
|
||||
Reference in new issue
Block a user