Compare commits

..
Author SHA1 Message Date
SAPHID cba23fb3af fix(ui): reconcile PR 66 with current main 2026-10-18 00:37:13 +00:00
SAPHID 62b3cbf705 fix(input): cancel old headset input when switching routes 2026-10-17 23:55:09 +00:00
saphidandClaude Sonnet 5.5 024ec593f9 Merge origin/main into pad-gamescope
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 21:06:06 +10:00
saphidandClaude Sonnet 5.5 0373555519 Pad: count gamescope's real typing time, with the settle margin added once
Review found the estimate used 12 ms a character where the agent takes 16 ms
(32 shifted), and added the margin on every request so it piled up.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 21:01:53 +10:00
saphidandClaude Sonnet 5.5 3539215517 Pad: drain the KDE Connect queue without losing, stranding or reordering input
Second review found the queue could be trimmed under an in-flight send (deleting
unsent events), the worker could retire as a request enqueued, and ASCII could
overtake an accent still being typed (or the reverse). The worker now takes its
batch out while sending and puts it back on failure, retires under the lock, and
each way waits out the other's estimated typing time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 20:57:08 +10:00
saphidandClaude Sonnet 5.5 9f3fb89e30 Pad: keep typing in order and acknowledge it once, with accents through one KDE Connect queue
Review found accent-only input repeated forever (sent was false), text split
across two transports out of order, accents stuck until the next keypress,
duplicated on retry, and racy. A batch with an accent now sends its whole
keyboard through KDE Connect; later keys follow while any are queued; the
server owns one ordered, bounded queue drained by a single thread; sent means
the whole batch is taken.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 20:52:41 +10:00
saphidandClaude Sonnet 5.5 dc7854d10b Touch: a bare release no longer clears the stale-focus notice before the page has seen it
The page leaves the panel off releases, so a release says nothing about focus.
Found in review of the stale-latch fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 20:52:41 +10:00
saphidandClaude Sonnet 5.5 5a6b925448 Trackpad and key row go through gamescope's input; KDE Connect only for accents and emoji
The pad reaches whichever panel has focus (Steam's menus too), needs nothing installed, and no longer clamps to 1280x720. Characters that aren't on a US keyboard start KDE Connect the first time they're typed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:03:37 +10:00
saphidandClaude Sonnet 5.5 60ade8c2e8 Merge origin/main into live-touch
Kept both route tables: main's media, agent, assistant and Mac view routes
plus /api/touch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:04:47 +10:00
saphidandClaude Opus 5.5 aa1ac7368c frame_touch: any event that goes through clears the stale flag
A trackpad move names no panel, so waiting for an aimed event could leave
the page re-reading panels for the rest of the session; a release still
aimed at the old panel doesn't count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:13:09 +10:00
saphidandClaude Opus 5.5 46b6966cf7 frame_touch: build the socket path on the Frame, so Windows can import it for tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:41:42 +10:00
saphidandClaude Opus 5.5 be2e770e66 Control: stale clears, pauses reconverge, pastes split per request
- The Frame says it has caught up as soon as an aimed event lands after a
  stale one, so the page stops re-reading the panels.
- After a device pause it lets go of everything it holds (releases that
  arrived while paused were dropped), and waits for the device once per
  batch, not once per event.
- The quick focus check no longer freshens the panel geometry's age.
- Each request carries at most about 100 characters of text.
- Turning Control off while it connects doesn't report an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:40:00 +10:00
saphidandClaude Opus 5.5 597f98af6d Control: fixes from the SWE-2 Max review
- The Frame side tracks keys as well as buttons and lets go of both when the
  session ends.
- A stale tap tells the page, which re-reads the panels at once.
- A paused input device waits instead of ending the session; only a
  disconnect does. An OS error on one event skips it.
- Presses check focus with two property reads and do the full lookup only
  when it changed.
- Writes to an agent's stdin are serialized, so two devices sending at once
  can't tear a line (the keyboard agent too).
- Connecting gives up with a message after 15 s instead of hanging on
  "Connecting…"; text goes in 100-character pieces so releases don't wait
  behind a long paste; a cancelled mouse gesture releases what's held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:28:28 +10:00
saphidandClaude Opus 5.5 ac07efa8f1 Control: close the targeting gaps from the second review
- The focused panel's display comes from GAMESCOPE_FOCUS_DISPLAY (gamescope
  packs ":1" into the first value), so a window id repeated across :0 and :1
  can't be mistaken; the pid is only the fallback.
- Presses, keys, text and scrolls read focus afresh on the Frame; only moves
  use a reading up to a second old.
- A gesture remembers the panel it started on and does nothing more if that
  stops being the one in use; a press with no panel to aim at isn't sent.
- Opening a screenshot clears the panel Control would act on; switching to
  another app releases held keys and buttons.
- Trimming keeps a click with its position; the error backoff holds for new
  input too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:52:00 +10:00
saphidandClaude Opus 5.5 0f4f0b863f Control: fixes from review
- Keys held on the Frame are released with buttons when Control stops or
  the view loses focus; keys for the Frame no longer trigger Frame Control's
  own shortcuts.
- Taps only act when the picture on screen is the panel in use; positions,
  presses, keys, text and scrolls name their panel (display and window: ids
  repeat across :0 and :1, told apart by pid), and the Frame drops them if
  focus has moved on. Releases always go.
- While connecting, a tap keeps its position; on an error only releases wait
  and retries back off; trimming a long queue never drops a release.
- Lifting one of two scrolling fingers ends the scroll; a cancelled touch
  isn't a tap; clicks and holds on the bars around the picture do nothing.
- A capture loop from before a Live restart can't stop the new video.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:40:46 +10:00
saphidandClaude Opus 5.5 e7c81733b1 Live view: a Desktop view that stays still, and Control to tap on the Frame
The live view gets a second source and a way to use the Frame from it:

- Desktop: the app panel in use in the headset, streamed from its own window
  (x11grab of gamescope's redirected window), so it doesn't move as the
  wearer looks around. A picker shows any other panel, view only.
- Control: on the Desktop view a tap or click lands exactly where you put it;
  drag is a mouse drag, press and hold right-clicks, two fingers scroll, and
  on a computer the mouse, wheel and keyboard work directly. On the headset
  view the view is a trackpad. A text field and key row type from a phone.

Input goes through gamescope's own EIS socket (the way Steam feeds Remote
Play input) with the libei already on the image: ui/frame_touch.py, over
the same long-lived ssh machinery as the keyboard agent, nothing to
install. It reaches the panel that has focus on either X display, which
the KDE Connect route can't. Verified on the Frame and from the iPhone app
in the Simulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:23:55 +10:00
20 changed files with 505 additions and 1748 deletions

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
Browse the shots you take in the headset and save them to your Pictures folder.
**⌨️ Keyboard and trackpad**<br>
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one.
</td>
</tr>
-82
View File
@@ -135,88 +135,6 @@ is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
Removing a repository clears its state.
## My itch.io purchases
The store can also install the Android (Quest) builds of games you **own** on
itch.io: bought, claimed free, from a bundle or a gift. It is opt-in. In the
store, open the sources dialog and use **My itch.io purchases**: create an API
key on itch.io (account settings › API keys) and paste it there. Frame Control
checks it with itch.io (`GET /profile`) before saving it; a wrong or revoked key
is reported and nothing is saved. Once connected, a source called
**itch.io (my purchases)** appears, and its games are marked **Owned on
itch.io** instead of Free. **Disconnect itch.io** deletes the key file.
How it works, using the documented
[server-side API](https://itch.io/docs/api/serverside) with
`Authorization: Bearer <key>`:
- `GET https://api.itch.io/profile/owned-keys` lists your download keys, page by
page until an empty page (500 per page, at most 40 pages). Games whose
`traits` don't include `p_android` are left out; one entry per game, with the
newest key.
- Opening a game asks `GET /games/<game_id>/uploads?download_key_id=<key id>`
for its files. Android uploads are those marked `p_android` (in `traits`, or
as a `p_android`/`platforms.android` flag) or named `.apk`. `.apk` files come
first, then full games before demos, newest first. Soundtracks, pre-orders and
externally hosted files are skipped.
- Installing asks `GET /uploads/<upload_id>/download?download_key_id=<key id>`.
The answer accepted is a redirect to itch.io's storage host, a JSON body
with a `url` field naming it, or the file itself. Frame Control follows the
first two without the key, to HTTPS on port 443 only, and only to host names
whose every address is public. For IPv4 that means outside every special
block (`0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`,
`192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`,
`198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`), however written. IPv6 is
an allow-list: global unicast (`2000::/3`) only, minus 6to4, Teredo,
documentation, ORCHID and benchmarking blocks, so mapped or NAT64 IPv4,
site-local, unique-local and the rest are all refused. It connects only to
the addresses it checked, trying each in turn within one 20-second budget
for connecting and the TLS handshake, and checks again on each further
redirect. A storage URL, first or after any redirect, that contains the API
key (or 8+ characters of it, in any case, plain or percent-encoded) or an
`api_key` parameter is refused before it is looked up, so the key never
reaches the storage server. The APK then takes
the normal install path, with the same ABI, OpenXR layer and Meta-services
checks and warnings as any other store download.
The key is stored in `frame_host.data_dir('itch-account.json')`
(`~/Library/Application Support/Frame Control/itch-account.json` on macOS),
written through a new 0600 temporary file and moved into place. It is only
sent to `https://api.itch.io`, in the `Authorization` header, never in a URL;
Frame Control does not follow api.itch.io's redirects with it. The key, or any
part of it from 8 characters, never appears in the page, API responses, errors
or logs; the page clears the field as soon as it is sent. A disconnect made
while a key is still being checked wins: that key is not saved.
Owned-game lists are kept in memory for ten minutes and never written to the
disk cache.
When api.itch.io answers 401 or 403, Frame Control asks `GET /profile` with the
same key. If that is refused too (or itch.io says "invalid key"), the key is
deleted and the store says to connect again; otherwise only that game or
download is refused, and the key stays. Rate limits (429) and network errors
only fail that source's results, with a readable message. A storage host's
refusal is reported as a download error, not as a rate limit.
### What is verified and what isn't
Not tested against a live itch.io account at all: everything below was tested
with recorded and hand-made responses (`tests/test_itch_purchases.py`) only.
| Part | Status |
|---|---|
| Bearer key, `GET /profile`, `/profile/owned-keys` paging and its fields, the `errors` array | Documented in the public reference (checked 2026-10-13) |
| `GET /games/<id>/uploads` and `download_key_id` | Not in the public reference; paths and parameter from itch.io's app client, [go-itchio](https://github.com/itchio/go-itchio) |
| Upload fields (`filename`, `display_name`, `size`, `storage`, `type`, `traits`, `updated_at`, `demo`, `preorder`) | Assumed from go-itchio and the API's snake_case convention |
| The `p_android` trait, on games and uploads | Assumed from itch.io's `p_windows`/`p_linux`/`p_osx` naming |
| What `/uploads/<id>/download` returns | Unknown: go-itchio only builds the URL and hands it to a downloader, so a redirect, a JSON `url` and the file are all accepted |
| Whether that endpoint accepts the key as a Bearer header | Unknown: go-itchio puts it in the query string, which Frame Control refuses to do |
| itch.io's storage host names and addresses | Unknown; any public HTTPS host is allowed |
Limits: a game whose page doesn't mark Android, or whose Android build is
inside a `.zip`, won't install. Android builds have no version codes on
itch.io, so only the newest upload can be installed. Owning a game doesn't make
it run on the Frame: Quest builds that need Meta's platform services won't.
## Publish your own repository
Only publish free APKs you own or have the developer's permission to distribute.
-2
View File
@@ -73,8 +73,6 @@ 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
+7 -4
View File
@@ -151,10 +151,13 @@ two fingers to scroll, two-finger tap to right-click) plus a text field that
types on the Frame. On a computer, clicking the pad passes your mouse and
keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac).
It goes through **KDE Connect**, the first-party route (KDE makes the Frame's
desktop): Frame Control's server runs [`ui/frame_input_agent.py`](../ui/frame_input_agent.py)
on the Frame, which talks KDE Connect's own LAN protocol to the Frame's
`kdeconnectd` as if it were a phone. KDE Connect does the typing and clicking.
Since Control (below), the pad goes through **gamescope's own input socket**
([`ui/frame_touch.py`](../ui/frame_touch.py)), so it reaches whichever panel has
focus, Steam's own menus included, and needs nothing installed on the Frame.
Only characters that aren't on a US keyboard (accents, emoji) go through
**KDE Connect** as below; it starts the first time you type one. (Before
Control, everything went through KDE Connect, which is why the notes below
describe it in detail.)
**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
-5
View File
@@ -1,5 +0,0 @@
Hand-made itch.io API responses for tests/test_itch_purchases.py; no real account or
network was used. owned-keys-*.json follow the documented /profile/owned-keys sample
(https://itch.io/docs/api/serverside). uploads.json follows the shape the itch.io app's
client library (github.com/itchio/go-itchio) reads from /games/ID/uploads, with the
`traits` array it handles; `p_android` there is assumed, not documented.
-22
View File
@@ -1,22 +0,0 @@
{
"page": 1,
"per_page": 500,
"owned_keys": [
{"id": 9001, "game_id": 341190, "purchase_id": 3923754, "downloads": 6,
"created_at": "2026-03-10T23:46:36Z", "updated_at": "2026-03-10T23:46:36Z",
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
"cover_url": "https://img.itch.zone/aW1nLzE2OTA3NTIuanBn/315x250%23c/Ihj7Wa.jpg",
"short_text": "Grow plants in zero gravity.", "type": "default", "classification": "game",
"min_price": 1299, "traits": ["p_windows", "p_android", "can_be_bought"],
"created_at": "2025-12-04T16:18:04Z", "published_at": "2026-01-03T17:28:47Z",
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio",
"url": "https://maker.itch.io"}}},
{"id": 9002, "game_id": 3, "purchase_id": null, "downloads": 0,
"created_at": "2026-02-01T10:00:00Z", "updated_at": "2026-02-01T10:00:00Z",
"game": {"id": 3, "title": "Desk Only", "url": "https://leafo.itch.io/x-moon",
"cover_url": "https://img.itch.zone/aW1hZ2UvMy8xODM3LnBuZw==/315x250%23/y2uYQI.png",
"type": "default", "classification": "game", "min_price": 0,
"traits": ["p_windows", "p_linux", "p_osx"], "published_at": "2013-03-03T23:02:14Z",
"user": {"id": 1, "username": "leafo", "display_name": "leafo", "url": "https://leafo.itch.io"}}}
]
}
-19
View File
@@ -1,19 +0,0 @@
{
"page": 2,
"per_page": 500,
"owned_keys": [
{"id": 8001, "game_id": 341190, "purchase_id": null, "downloads": 1,
"created_at": "2025-06-01T10:00:00Z", "updated_at": "2025-06-01T10:00:00Z",
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
"type": "default", "classification": "game", "min_price": 1299,
"traits": ["p_windows", "p_android", "can_be_bought"],
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio"}}},
{"id": 8002, "game_id": 77, "purchase_id": null, "downloads": 2,
"created_at": "2025-05-01T10:00:00Z", "updated_at": "2025-05-01T10:00:00Z",
"game": {"id": 77, "title": "Free Quest Toy", "url": "https://toys.itch.io/quest-toy",
"cover_url": "http://img.example/not-itch.png",
"type": "default", "classification": "game", "min_price": 0,
"traits": ["p_android"], "published_at": "2025-04-01T00:00:00Z",
"user": {"id": 5, "username": "toys"}}}
]
}
-28
View File
@@ -1,28 +0,0 @@
{
"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"}
]
}
+1 -12
View File
@@ -19,7 +19,7 @@ function grab(name) {
fail(`unbalanced ${name}`);
}
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "storePrice", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
const code = names.map(grab).join("");
@@ -56,17 +56,6 @@ 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();
+255
View File
@@ -18,6 +18,7 @@ import tempfile
import threading
import time
import unittest
from unittest import mock
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -631,3 +632,257 @@ class PageQueue(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class PadEvents(unittest.TestCase):
"""The trackpad and key row go through gamescope; accents and emoji go the KDE Connect way."""
@classmethod
def setUpClass(cls):
import server
cls.pad = staticmethod(server.pad_events)
def test_moves_clicks_and_scroll(self):
out, extra = self.pad([{"dx": 3, "dy": -2}, {"singleclick": True}, {"singlehold": True},
{"singlerelease": True}, {"scroll": True, "dy": 1}, {"rightclick": True}])
self.assertEqual(out[0], {"dx": 3, "dy": -2})
self.assertEqual(out[1:3], [{"button": "left", "down": True}, {"button": "left", "down": False}])
self.assertEqual(out[3:5], [{"button": "left", "down": True}, {"button": "left", "down": False}])
self.assertEqual(out[5], {"scroll": [0, -15]}) # up = content follows the finger
self.assertEqual(out[6:], [{"button": "right", "down": True}, {"button": "right", "down": False}])
self.assertEqual(extra, [])
def test_keys_and_modifiers(self):
out, _ = self.pad([{"specialKey": 12}, {"specialKey": 1, "ctrl": True}, {"key": "c", "ctrl": True}])
self.assertEqual(out[:2], [{"key": 28, "down": True}, {"key": 28, "down": False}])
self.assertEqual([e["key"] for e in out[2:6]], [29, 14, 14, 29]) # Ctrl+Backspace
self.assertEqual([(e["key"], e["down"]) for e in out[6:]],
[(29, True), (46, True), (46, False), (29, False)]) # Ctrl+C
def test_text_stays_ascii_through_gamescope(self):
out, slow = self.pad([{"key": "Hi "}, {"key": "there"}])
self.assertEqual(out, [{"text": "Hi there"}])
self.assertEqual(slow, [])
def test_keyboard_goes_to_kde_whole_and_in_order(self):
events = [{"key": "a"}, {"key": "é"}, {"specialKey": 12}, {"key": "c", "ctrl": True}, {"dx": 2, "dy": 1}]
out, slow = self.pad(events, kde=True)
self.assertEqual(out, [{"dx": 2, "dy": 1}]) # the pointer still goes through gamescope
self.assertEqual(slow, events[:4]) # the keyboard as sent, in order
def test_shifted_key_with_modifier(self):
out, _ = self.pad([{"key": "A", "ctrl": True}])
self.assertEqual([e["key"] for e in out], [29, 42, 30, 30, 42, 29])
class PadDelivery(unittest.TestCase):
"""remote_input's promises: "sent" means the whole batch is taken; accents go once, in order."""
class Agent:
def __init__(self, sent=True, state="ready"):
self.sent, self.state, self.got = sent, state, []
def send(self, events):
if events and self.sent:
self.got.append(events)
return {"state": self.state, "sent": self.sent and bool(events)}
def setUp(self):
import server
self.s = server
self.saved = (server._touch, server._input, server.KDE_WAIT)
server._touch, server._input = self.Agent(), self.Agent()
self.reset()
def tearDown(self):
self.s._touch, self.s._input, self.s.KDE_WAIT = self.saved
self.settle()
self.reset()
def reset(self):
self.s._kde_queue.clear()
self.s._kde_until = self.s._typed_until = 0.0
self.s._kde_worker[0] = None
def settle(self):
worker = self.s._kde_worker[0]
if worker:
worker.join(5)
def typed(self):
return [e for batch in self.s._input.got for e in batch]
def test_accent_only_batch_is_acknowledged_and_typed_once(self):
status = self.s.remote_input({"events": [{"key": "é"}]})
self.assertTrue(status["sent"]) # so the page doesn't send it again
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}])
def test_mixed_text_stays_in_one_ordered_stream(self):
self.s.remote_input({"events": [{"key": "aéb"}, {"specialKey": 12}]})
self.settle()
self.assertEqual(self.typed(), [{"key": "aéb"}, {"specialKey": 12}])
self.assertEqual(self.s._touch.got, []) # none of it split off through gamescope
def test_batch_gamescope_didnt_take_is_not_queued(self):
self.s._touch = self.Agent(sent=False, state="starting")
status = self.s.remote_input({"events": [{"dx": 1, "dy": 1}, {"key": "é"}]})
self.assertFalse(status["sent"]) # the page tries the whole batch again...
self.assertEqual(self.s._kde_queue, []) # ...so the accent isn't queued twice
def test_waits_for_kde_connect_then_sends_without_more_input(self):
self.s._input = self.Agent(sent=False, state="starting")
self.s.remote_input({"events": [{"key": "é"}]})
time.sleep(0.2)
self.assertEqual(self.s._kde_queue, [{"key": "é"}])
self.s._input.sent = True
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}])
self.assertEqual(self.s._kde_queue, [])
def test_later_ascii_waits_behind_a_pending_accent(self):
self.s._input = self.Agent(sent=False, state="starting")
self.s.remote_input({"events": [{"key": "é"}]})
self.s.remote_input({"events": [{"key": "x"}]})
self.assertEqual(self.s._touch.got, []) # "x" can't overtake the "é"
self.assertEqual(self.s._kde_queue, [{"key": "é"}, {"key": "x"}])
self.s._input.sent = True
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
def test_queue_is_bounded_and_dropped_on_error(self):
self.s._input = self.Agent(sent=False, state="error")
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.settle()
self.assertEqual(self.s._kde_queue, [])
self.s._input = self.Agent(sent=False, state="starting")
self.s.KDE_WAIT = 0.3
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.s.remote_input({"events": [{"key": "é"}] * 150})
self.assertLessEqual(len(self.s._kde_queue), self.s.KDE_QUEUE_LIMIT)
self.settle()
self.assertEqual(self.s._kde_queue, [])
def test_ascii_just_after_an_accent_stays_behind_it(self):
self.s.remote_input({"events": [{"key": "é"}]})
self.settle()
self.s.remote_input({"events": [{"key": "x"}]}) # KDE Connect may still be typing the é
self.settle()
self.assertEqual(self.s._touch.got, [])
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
def test_accent_just_after_gamescope_text_waits_for_it(self):
self.s.remote_input({"events": [{"key": "x" * 50}]})
self.assertEqual(self.s._touch.got, [[{"text": "x" * 50}]])
start = time.time()
self.s.remote_input({"events": [{"key": "é"}]})
self.settle()
self.assertGreater(time.time() - start, 0.6) # 50 keys at 16 ms, plus the margin
self.assertEqual(self.typed(), [{"key": "é"}])
def test_typing_time_counts_every_key_transition(self):
t = self.s.typing_seconds
self.assertAlmostEqual(t([{"text": "ab"}]), 4 * 0.008)
self.assertAlmostEqual(t([{"text": "A"}]), 4 * 0.008) # shift down, key down, key up, shift up
self.assertAlmostEqual(t([{"text": "x" * 500}]), 8.0) # a long paste
self.assertAlmostEqual(t([{"key": 29, "down": True}, {"dx": 3, "dy": 0}]), 0.008)
def test_the_margin_is_added_once_not_per_request(self):
for _ in range(10):
self.s.remote_input({"events": [{"key": "ab"}]})
# ten requests of 4 transitions each: the work queues up, with no margin added per request
self.assertLess(self.s._typed_until - time.time(), 10 * 4 * 0.008 + 0.05)
def test_trimming_while_sending_doesnt_drop_unsent_events(self):
slow = self.Agent()
gate = threading.Event()
send = slow.send
def held(events):
gate.wait(5)
return send(events)
slow.send = held
self.s._input = slow
self.s.remote_input({"events": [{"key": "é"}] * 150})
time.sleep(0.2) # the worker has taken its 150 and is mid-send
self.s.remote_input({"events": [{"key": "ü"}] * 150})
gate.set()
self.settle()
sent = self.typed()
self.assertEqual(sent[:150], [{"key": "é"}] * 150)
self.assertEqual(sent[150:], [{"key": "ü"}] * 150) # none of the new ones went missing
def test_input_queued_as_the_worker_finishes_is_still_sent(self):
for _ in range(40):
self.reset()
self.s._input.got.clear()
self.s.remote_input({"events": [{"key": "é"}]})
time.sleep(0.002)
self.s.remote_input({"events": [{"key": "ü"}]})
self.settle()
self.assertEqual(self.s._kde_queue, [])
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "ü"}])
class PadRoute(unittest.TestCase):
def setUp(self):
import server
self.s = server
self.agents = mock.patch.multiple(server, _touch=mock.Mock(), _input=mock.Mock())
self.agents.start()
self.addCleanup(self.agents.stop)
self.s._touch.send.return_value = {"state": "ready", "sent": True}
self.s._input.send.return_value = {"state": "ready", "sent": True}
self.s.reset_pad_route()
self.s._touch.reset_mock(); self.s._input.reset_mock()
self.addCleanup(self.s.reset_pad_route)
def switch(self):
with mock.patch.multiple(self.s, FRAME="frame-a", HOST_OPTS=[], MUX=[], SSH=[], macview=None), \
mock.patch.multiple(self.s.frame_android, FRAME="frame-a", SSH_OPTS=[]):
self.s.route("frame-b", [])
def test_switch_stops_both_input_agents_and_discards_old_queue(self):
self.s._kde_queue.append({"key": "old"})
self.s._kde_until = self.s._typed_until = time.time() + 60
cancel = self.s._kde_cancel
self.switch()
self.s._input.stop.assert_called_once()
self.s._touch.stop.assert_called_once()
self.assertEqual(self.s._kde_queue, [])
self.assertEqual((self.s._kde_until, self.s._typed_until), (0, 0))
self.assertTrue(cancel.is_set())
def test_switch_cancels_delayed_and_retrying_text_before_new_input(self):
for delaying in (True, False):
with self.subTest(delaying=delaying):
waiting, cancel = threading.Event(), threading.Event()
class ObservedCancel:
def wait(self, timeout):
waiting.set()
return cancel.wait(timeout)
def set(self):
cancel.set()
self.s.reset_pad_route()
self.s._kde_cancel = ObservedCancel()
self.s._typed_until = time.time() + 60 if delaying else 0
self.s._input.reset_mock()
self.s._input.send.return_value = {"state": "starting", "sent": False}
self.s.remote_input({"events": [{"key": "é"}]})
worker = self.s._kde_worker[0]
self.assertTrue(waiting.wait(1), "worker never reached its cancellable delay")
self.switch()
worker.join(1)
self.assertFalse(worker.is_alive())
attempts = self.s._input.send.call_count
self.assertEqual(attempts, 0 if delaying else 1)
self.s._input.send.return_value = {"state": "ready", "sent": True}
self.s.remote_input({"events": [{"key": "ü"}]})
next_worker = self.s._kde_worker[0]
if next_worker:
next_worker.join(1)
self.assertFalse(next_worker.is_alive())
self.assertEqual(self.s._input.send.call_count, attempts + 1)
self.assertEqual(self.s._input.send.call_args.args[0], [{"key": "ü"}])
self.assertEqual(self.s._kde_queue, [])
-862
View File
@@ -1,862 +0,0 @@
"""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()
+8
View File
@@ -150,6 +150,14 @@ class Apply(unittest.TestCase):
self.assertFalse(self.t.STALE[0])
self.assertEqual(self.said[-1][1].get("stale"), None)
def test_stale_survives_a_bare_release(self):
gs = FakeGamescope()
panel = self.t.apply(gs, {"button": "left", "down": True, "window": 99, "display": ":1"}, None)
self.assertTrue(self.t.STALE[0])
self.t.apply(gs, {"button": "left", "down": False}, panel) # the page's release names no panel
self.assertTrue(self.t.STALE[0])
self.assertEqual(self.said[-1][1].get("stale"), True)
def test_same_window_id_on_the_other_display_is_another_panel(self):
gs = FakeGamescope()
self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None)
+1 -3
View File
@@ -32,9 +32,7 @@ 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. 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).
never paid apps re-hosted, no licence or entitlement workarounds.
- Honour each site's terms and robots rules; if automated download isn't allowed,
return entries with 'downloadable': False and a 'page' link instead.
- Cache indexes under frame_host.cache_dir('apk-sources'); send a clear
-394
View File
@@ -1,394 +0,0 @@
"""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))
+11 -159
View File
@@ -1,6 +1,5 @@
"""Small HTTPS cache and APK downloader for public publisher sources."""
import hashlib, http.client, ipaddress, os, shutil, socket, ssl, tempfile, threading, time
import urllib.error, urllib.parse, urllib.request, zipfile
import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
from email.utils import parsedate_to_datetime
import frame_host
@@ -212,16 +211,19 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
raise SourceError('Could not read source: ' + str(e)) from e
def save_apk(response, digest=None):
"""Stream response (anything with read(n)) into the cache as a checked APK."""
def apk(url, hosts, digest=None, name=None):
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:
with os.fdopen(fd, 'wb') as f, open_url(url, hosts) as r:
size = 0
while True:
chunk = response.read(1 << 20)
chunk = r.read(1 << 20)
if not chunk:
break
size += len(chunk)
@@ -238,162 +240,12 @@ def save_apk(response, digest=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
# 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')
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
+15 -97
View File
@@ -1,35 +1,17 @@
"""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.
"""
"""Free Android VR RSS listings. Download pages stay in the publisher's UI."""
import html, re, urllib.parse, xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from . import SourceError, _itch_account, _web
from . import SourceError, _web
KIND = 'itch'
FEEDS = ('openxr', 'oculus-quest')
OWNED = 'itch-owned'
OWNED_LABEL = 'Owned on itch.io'
def sources():
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
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]
def _parse(source, data):
@@ -40,7 +22,8 @@ def _parse(source, data):
entries = []
for item in root.findall('./channel/item'):
page = item.findtext('link') or ''
if not _itch_page(page):
p = urllib.parse.urlsplit(page)
if p.scheme != 'https' or not (p.hostname or '').endswith('.itch.io') or p.username or ':' in p.netloc:
continue
if item.findtext('price') != '$0.00' or item.findtext('platforms/android') != 'yes':
continue
@@ -62,81 +45,23 @@ 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 []
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)
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')
@@ -144,11 +69,4 @@ def details(source, entry_id):
def download(source, entry_id, version_code=None):
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'])
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
+3 -11
View File
@@ -190,10 +190,7 @@ def group(entries, query='', vr=None, installable=False):
groups = {}
for entry in entries:
entry = decorate(entry)
# Unknown counts as flat, except the user's own purchases (itch.io doesn't say which are
# VR): those show under either choice rather than vanish from one.
if vr is not None and (entry.get('vr') is True) != vr \
and not (entry.get('owned') is True and entry.get('vr') is None):
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
continue
if installable and entry['fit']['installable'] is not True:
continue
@@ -226,11 +223,6 @@ 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']
@@ -259,7 +251,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 obtainable(source, e)]
for e in module.search(source, query, limit=limit) if e.get('free') is True]
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
except Exception as e:
task['error'] = str(e)
@@ -316,7 +308,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 not obtainable(source, entry) or entry.get('downloadable') is not True:
if entry.get('free') is not True or entry.get('downloadable') is not True:
raise SourceError('This app must be obtained from its developer page')
if progress:
progress('Downloading', None)
+3 -1
View File
@@ -506,8 +506,10 @@ def apply(gs, event, panel):
say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs
STALE[0] = True
return panel
if STALE[0] and not stale:
release = event.get("down") is False and ("button" in event or "key" in event)
if STALE[0] and not stale and not (release and "window" not in event):
# Anything that goes through (a trackpad move names no panel) means caught up: stop re-syncing.
# A bare release doesn't: the page leaves the panel off releases, so it says nothing about focus.
STALE[0] = False
say("ready", focus=(panel or {}).get("window"), display=(panel or {}).get("display"))
if "fx" in event and panel and panel.get("window"):
+12 -31
View File
@@ -992,8 +992,7 @@
<div class="shelf-head"><h2>Keyboard and trackpad</h2><span class="sub" id="padState"></span><span class="spacer"></span>
<button class="small" id="padOn">Turn on</button>
</div>
<p class="sub" id="padIntro">Use this computer's keyboard and mouse in apps on the Frame, such as Chromium or the
desktop's Linux apps.</p>
<p class="sub" id="padIntro">Use this computer's keyboard and mouse in the panel you're using in the headset.</p>
<div id="padBody" hidden>
<div class="pad-area" id="padArea" tabindex="0" role="application" aria-label="Trackpad for the Frame">
<div class="pad-hint" id="padHint"></div>
@@ -1016,10 +1015,9 @@
<button data-pad-key="12" title="Enter">Enter</button>
</span>
</div>
<div class="hint">Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
else to install. <a href="#" data-about>Licences</a></div>
<div class="hint">Types and points in whichever panel has focus in the headset, through Valve's own input path: nothing to
install and no internet needed. Accents and emoji go through KDE Connect, which comes with Frame Control and starts
the first time you type one (it copies Valve's build for the Frame, 3.6 MB, into your home folder there). <a href="#" data-about>Licences</a></div>
</div>
</section>
@@ -1431,7 +1429,6 @@
<div class="sources-intro"><h1 id="sourcesTitle">Sources</h1><p>Where Discover looks for apps. Turn a source off to hide its apps.</p></div>
<div id="sourcesList"></div>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Add an F-Droid repository by its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><details><summary>This repository needs a login (optional)</summary><label>Username<input name="username" type="text" autocomplete="off" autocapitalize="none" spellcheck="false"></label><label>Password<input name="password" type="password" autocomplete="new-password"></label><p>Kept on this computer, apart from the address, and only sent to this repository over HTTPS.</p></details><button class="action">Add source</button></form>
<form id="itchAccount" class="source-repo-form"><h2>My itch.io purchases</h2><p id="itchAccountNote">Install the Android builds of games you own on itch.io. Create an API key in itch.io settings › API keys and paste it here. It stays on this computer and is only sent to itch.io.</p><label id="itchKeyField">itch.io API key<input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="300" placeholder="Paste your API key"></label><button class="action" id="itchConnect">Connect itch.io</button><button type="button" class="small" id="itchDisconnect" hidden>Disconnect itch.io</button></form>
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
</dialog>
@@ -3550,23 +3547,10 @@ function sourceToken(s) {
}
async function loadSources() {
try {
const {sources, itch} = await api("/api/sources"); sourceState.sources = sources; showItchAccount(itch);
const {sources} = await api("/api/sources"); sourceState.sources = sources;
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}${s.login ? " · Uses a login" : ""}</small>${sourceToken(s)}${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div>${s.page_only ? `<a class="source-web" href="${esc(sourcePage(s.url))}" target="_blank" rel="noopener" title="Its apps can't be searched from here">Website ↗</a>` : `<input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}>`}</div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
} catch (e) { $("sourceError").textContent = e.message; }
}
// Owned on itch.io (the user's own purchases) or free: what the store shows instead of a price.
const storePrice = o => o.owned ? (o.owned_label || "Owned") : "Free";
function showItchAccount(itch) {
const on = !!itch?.connected;
$("itchAccountNote").textContent = on ? `Connected${itch.username ? " as " + itch.username : ""}. Android builds of games you own appear in the store, marked Owned on itch.io. Disconnecting deletes the API key from this computer.` : "Install the Android builds of games you own on itch.io. Create an API key in itch.io settings › API keys and paste it here. It stays on this computer and is only sent to itch.io.";
$("itchKeyField").hidden = on; $("itchConnect").hidden = on; $("itchDisconnect").hidden = !on;
$("itchAccount").elements.key.required = !on;
}
async function itchAccount(body) {
$("sourceError").textContent = body.action === 'itch-connect' ? "Checking the key with itch.io…" : "Disconnecting…";
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;showItchAccount(result.itch);await loadSources();await searchSources(); }
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
}
// Will it run? Short enough for a card; the full sentence is in its tooltip and the details.
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"] };
function storeRuns(o) {
@@ -3575,8 +3559,8 @@ function storeRuns(o) {
}
const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app";
function storeCard(app) {
const i=sourceState.apps.indexOf(app), o=app.offers[0], owned=app.offers.find(x=>x.owned);
return `<button class="store-card" data-app="${i}" aria-label="${esc(app.name)}: ${esc(o.verdict?.label || "not yet checked on the Frame")}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "From " + storeName(o.source_name))}</span>${owned ? `<span class="store-summary">${esc(storePrice(owned))}</span>` : ""}<span class="store-card-foot"><span>${storeKind(o)}</span>${storeRuns(o)}</span></span></button>`;
const i=sourceState.apps.indexOf(app), o=app.offers[0];
return `<button class="store-card" data-app="${i}" aria-label="${esc(app.name)}: ${esc(o.verdict?.label || "not yet checked on the Frame")}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "From " + storeName(o.source_name))}</span><span class="store-card-foot"><span>${storeKind(o)}</span>${storeRuns(o)}</span></span></button>`;
}
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2>${subtitle ? `<p>${esc(subtitle)}</p>` : ""}</div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
function storeEmpty(message) {
@@ -3632,7 +3616,7 @@ async function searchSources(quiet) {
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
}
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
function detailBadges(o) { return `<span class="store-badge">${storeKind(o)}</span>${storeRuns(o)}<span class="store-badge">${esc(storePrice(o))}</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
function detailBadges(o) { return `<span class="store-badge">${storeKind(o)}</span>${storeRuns(o)}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
// The store's app page. (Not renderDetail: the Devices page declares that further down,
// and a later function declaration silently replaces an earlier one; issue #68.)
// True when the page rendered; false when it shows the error instead.
@@ -3648,7 +3632,7 @@ function renderAppDetail() {
function renderAppDetailBody() {
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">${esc(storePrice(o))}</div><p class="detail-small">${o.owned ? "Downloads with your itch.io account." : "No purchase needed."}</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
updateInstall();
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
}
@@ -3767,9 +3751,6 @@ $("addSource").onsubmit=e=>{
const f=new FormData(e.target), url=f.get('url'), username=f.get('username'), password=f.get('password');
addSource({action:'add',url,fingerprint:f.get('fingerprint'),...(username ? {username,password} : {})});
};
// The key goes to the server once and leaves the page straight away: the field is cleared before the reply.
$("itchAccount").onsubmit=e=>{e.preventDefault();const input=e.target.elements.key, key=input.value.trim();input.value='';if(key)itchAccount({action:'itch-connect',key});};
$("itchDisconnect").onclick=()=>itchAccount({action:'itch-disconnect'});
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
function showAndroidTab(tab) {
@@ -4652,10 +4633,10 @@ const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=c
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
// ---- keyboard and trackpad: events go to KDE Connect on the Frame (see ui/frame_input_agent.py) ----
// ---- keyboard and trackpad: events go to the Frame through gamescope (ui/frame_touch.py); accents and emoji through KDE Connect ----
const pad = { state: "off", queue: [], sending: false, poll: null, captured: false };
const PAD_STATES = {
off: "", starting: "Connecting…", installing: "Setting up KDE Connect on the Frame (first time only)…",
off: "", starting: "Connecting…", installing: "Setting up (first time only)…",
pairing: "Pairing with KDE Connect…", ready: "On",
};
// KDE Connect's specialKey numbers (plugins/mousepad in its source).
@@ -4675,7 +4656,7 @@ function padShow(status) {
$("padArea").classList.toggle("off", !ready);
$("padBody").hidden = pad.state === "off";
$("padIntro").hidden = pad.state !== "off";
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame."
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach the Frame."
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
: pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop."
+188 -15
View File
@@ -46,7 +46,6 @@ 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
@@ -64,6 +63,7 @@ import frame_report # noqa: E402
import frame_store # noqa: E402
import frame_telemetry # noqa: E402
import frame_titles # noqa: E402
import frame_touch # noqa: E402
import frame_webinstall # noqa: E402
import frame_vr # noqa: E402
import frame_utilities # noqa: E402
@@ -116,6 +116,9 @@ def route(alias, host_opts):
with _route_lock:
moved = alias != FRAME
if moved:
reset_pad = globals().get("reset_pad_route")
if reset_pad:
reset_pad() # cancel old input before any agent can start on the new route
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
FRAME = frame_android.FRAME = alias
HOST_OPTS = list(host_opts)
@@ -126,9 +129,6 @@ def route(alias, host_opts):
mv = globals().get("macview")
if mv:
mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too
agent = globals().get("_input")
if moved and agent:
agent.stop() # typing and pointing mustn't go on reaching the headset switched away from
LINK = None # the connector (frame_link.Link); None on the Frame itself
@@ -1169,12 +1169,192 @@ def licenses():
return notices
# KDE Connect's specialKey numbers -> Linux key codes (KEY_BACKSPACE, KEY_TAB, ...).
PAD_KEYS = {1: 14, 2: 15, 4: 105, 5: 103, 6: 106, 7: 108, 8: 104, 9: 109, 10: 102, 11: 107, 12: 28, 13: 111, 14: 1,
**{21 + i: code for i, code in enumerate([59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88])}}
PAD_MODS = (("ctrl", 29), ("alt", 56), ("shift", 42), ("super", 125))
PAD_CLICKS = {"singleclick": "left", "rightclick": "right", "middleclick": "middle"}
PAD_NOTCH = 15 # scroll units for one wheel notch
def typed_in_kde(events):
"""Whether these events must go the KDE Connect way: they hold an accent or emoji, or
earlier ones are queued, being sent or still being typed there (typing must stay in
order, so it can't be split between the two ways)."""
with _kde_lock:
waiting = bool(_kde_queue) or time.time() < _kde_until
return waiting or any(ch not in frame_touch.ASCII for e in events for ch in e.get("key", ""))
def pad_events(events, kde=False):
"""The trackpad's and key row's events as (gamescope events, KDE Connect events).
Gamescope's own input reaches every panel and needs nothing installed, but types only
US-keyboard characters. With kde, the keyboard goes to KDE Connect as it was sent, so
text stays in order; the pointer always goes through gamescope.
"""
ascii_keys = frame_touch.ASCII
out, slow = [], []
def tap(code, mods):
down = [{"key": c, "down": True} for c in mods]
out.extend(down + [{"key": code, "down": True}, {"key": code, "down": False}]
+ [{"key": c, "down": False} for c in reversed(mods)])
for e in events:
mods = [code for name, code in PAD_MODS if e.get(name)]
if "dx" in e or "dy" in e:
if not e.get("scroll"):
out.append({k: e[k] for k in ("dx", "dy") if k in e})
if e.get("scroll"):
out.append({"scroll": [0, -(e.get("dy") or 0) * PAD_NOTCH]})
for flag, button in PAD_CLICKS.items():
if e.get(flag):
out += [{"button": button, "down": True}, {"button": button, "down": False}]
if e.get("doubleclick"):
out += [{"button": "left", "down": d} for d in (True, False, True, False)]
if e.get("singlehold"):
out.append({"button": "left", "down": True})
if e.get("singlerelease"):
out.append({"button": "left", "down": False})
if kde and ("key" in e or "specialKey" in e):
slow.append({k: e[k] for k in ("key", "specialKey", "ctrl", "alt", "shift", "super") if k in e})
continue
if "specialKey" in e and e["specialKey"] in PAD_KEYS:
tap(PAD_KEYS[e["specialKey"]], mods)
if "key" in e:
for ch in e["key"]:
if ch not in ascii_keys:
continue # only reached when kde is wrong; the caller checks typed_in_kde first
elif mods:
code, shifted = ascii_keys[ch]
tap(code, mods + ([42] if shifted and 42 not in mods else []))
else:
if out and "text" in out[-1] and len(out[-1]["text"]) < 100:
out[-1]["text"] += ch
else:
out.append({"text": ch})
return out, slow
def remote_input(body):
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent."""
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent.
"sent" in the answer means the whole batch is taken (the page keeps it and tries again
if not), so the keyboard's share is queued only once that's so.
"""
events = body.get("events", [])
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
return _input.send([input_event(e) for e in events])
events = [input_event(e) for e in events]
touch, slow = pad_events(events, typed_in_kde(events))
status = _touch.send(touch)
if touch and not status.get("sent"):
return status
if touch:
_note_typed(touch)
if slow:
_queue_for_kde(slow)
return {**status, "sent": True}
# Typing KDE Connect is to do: it starts the first time it's needed, so this waits for it.
# Nothing on the Frame says when typed text has landed, so each way holds the other back
# for as long as what it was given should take (a short margin on a time estimate).
_kde_queue = []
_kde_lock = threading.Lock()
_pad_send_lock = threading.Lock()
_kde_worker = [None]
_kde_until = 0.0 # keys typed through KDE Connect should have landed by then
_typed_until = 0.0 # when gamescope's agent will have typed everything it's been given
_kde_route = object()
_kde_cancel = threading.Event()
KDE_QUEUE_LIMIT = 200
KDE_WAIT = 90 # seconds to wait for KDE Connect before giving up on what's queued
TYPING_SETTLE = 0.6 # seconds past the estimate, once
def reset_pad_route():
"""Discard input for the headset being left, including batches waiting for an agent."""
global _kde_route, _kde_cancel, _kde_until, _typed_until
with _pad_send_lock, _kde_lock:
_kde_cancel.set()
_kde_cancel = threading.Event()
_kde_route = object()
_kde_queue.clear()
_kde_worker[0] = None
_kde_until = _typed_until = 0.0
_input.stop()
_touch.stop()
def typing_seconds(touch):
"""How long gamescope's agent takes over these: it sleeps 8 ms after every key transition."""
transitions = 0
for e in touch:
if "text" in e:
transitions += sum(2 + 2 * frame_touch.ASCII[ch][1] for ch in e["text"] if ch in frame_touch.ASCII)
elif "key" in e:
transitions += 1
return transitions * 0.008
def _note_typed(touch):
global _typed_until
seconds = typing_seconds(touch)
if seconds:
with _kde_lock: # the end of the work, which queues up; the margin is added once, where it's checked
_typed_until = max(_typed_until, time.time()) + seconds
def _queue_for_kde(events):
with _kde_lock:
_kde_queue.extend(events)
del _kde_queue[:-KDE_QUEUE_LIMIT]
if _kde_worker[0] is None:
_kde_worker[0] = threading.Thread(target=_drain_kde, args=(_kde_route, _kde_cancel), daemon=True)
_kde_worker[0].start()
def _drain_kde(route, cancel):
"""The only sender, in order. A batch leaves the queue while it's being sent (the queue
can be trimmed meanwhile) and goes back to the front if it didn't go."""
global _kde_until
deadline = time.time() + KDE_WAIT
while True:
with _kde_lock:
if route is not _kde_route:
return
batch = _kde_queue[:INPUT_BATCH_LIMIT]
del _kde_queue[:len(batch)]
if not batch:
_kde_worker[0] = None # retired under the lock, so a new request starts another
return
wait = _typed_until + TYPING_SETTLE - time.time()
_kde_until = time.time() + 3600 # held while in flight
if wait > 0 and cancel.wait(wait):
return # a switch cancels the delay and its old-headset batch
# Serialize sending with route resets without blocking callers from queuing input.
with _pad_send_lock:
with _kde_lock:
if route is not _kde_route:
return
status = _input.send(batch)
with _kde_lock:
if route is not _kde_route:
return
if status.get("sent"):
_kde_until = time.time() + len(batch) * 0.03 + TYPING_SETTLE
deadline = time.time() + KDE_WAIT
continue
_kde_until = 0.0
if status.get("state") == "error" or time.time() > deadline:
_kde_queue.clear() # not coming; don't hold it forever
_kde_worker[0] = None
return
_kde_queue[:0] = batch
del _kde_queue[:-KDE_QUEUE_LIMIT] # as when queuing: the oldest go first
if cancel.wait(0.4):
return
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
@@ -2494,13 +2674,6 @@ 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)
@@ -2725,7 +2898,7 @@ class Handler(BaseHTTPRequestHandler):
except SourceError as e:
raise Failure(str(e), 400)
elif path == "/api/sources":
self.send_json({"sources": apk_search.sources(), "itch": itch_account.status()})
self.send_json({"sources": apk_search.sources()})
elif path == "/api/search":
self.send_json(source_search(url.query))
elif path == "/api/apk-versions":
@@ -2753,7 +2926,7 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/touch":
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
elif path == "/api/input":
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
elif path == "/api/job":
self.send_json(job_status(url.query))
elif path == "/api/queue":