mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-21 00:02:05 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7f694956d | ||
|
|
0931ff57d5 | ||
|
|
0f8aaa393b | ||
|
|
382fb0f906 | ||
|
|
76e90af5b7 | ||
|
|
5456f2f6f6 | ||
|
|
2cfb61a208 | ||
|
|
c6e4362c66 | ||
|
|
88d61cb3b6 | ||
|
|
2d63dacdf9 | ||
|
|
adefdf4398 | ||
|
|
2c3f8b3e0c |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.8",
|
||||
"version": "0.4.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.8",
|
||||
"version": "0.4.7",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.8",
|
||||
"version": "0.4.7",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Import an Android game backup
|
||||
|
||||
A SideQuest backup can contain a small APK and much larger `main.…obb` and
|
||||
`patch.…obb` files. All of them belong to the same game. Installing only the APK
|
||||
can leave the game unable to start because its assets are missing.
|
||||
|
||||
In **Android → Import game files…**, choose the APK and its OBB files together.
|
||||
You can also drop them anywhere in the window. In the desktop app, dropping the
|
||||
APK alone finds matching OBB files beside it; dropping a folder containing the
|
||||
APK and OBBs also works. Browsers can read only the files you explicitly choose.
|
||||
|
||||
The import screen shows the game app and its game-data files together. **Import
|
||||
game & data** installs the APK, starts its own Steam library instance, waits for
|
||||
Android and the installed package, copies the OBBs, and closes the game. Open it
|
||||
again from the Steam library to play. The screen explains this before import;
|
||||
choosing files alone never starts a game.
|
||||
|
||||
Files are matched using the package inside the APK and the standard
|
||||
`main|patch.<version>.<package>.obb` name, not the APK's filename. Both main and
|
||||
patch files are preserved. OBB version numbers need not equal the APK's current
|
||||
version code: updates can reuse older expansion files. The copy uses the existing
|
||||
SHA-256 transfer check inside Android and writes to that game's own
|
||||
`/sdcard/Android/obb/<package>` folder.
|
||||
|
||||
If no data files are selected, the screen reminds you to include any OBBs in the
|
||||
backup before offering **Install game app**. An APK cannot reliably tell Frame
|
||||
Control whether separate expansion files are required. Game-data files are never
|
||||
silently sent to Downloads by this flow.
|
||||
|
||||
If setup fails, the import screen keeps the error and retry instructions visible.
|
||||
The APK may already be installed, but the game is not reported ready. Import the
|
||||
same backup again, or choose just its OBB files to add data to an existing install.
|
||||
The importer checks the installed package and version before writing data and
|
||||
closes the instance after a failed copy. Unrelated or duplicate files, running
|
||||
games that would be replaced, and downgrades are rejected. Uploaded staging files
|
||||
are discarded after use or cancellation; abandoned imports expire after an hour
|
||||
when the importer is next used, and dead-server staging folders are swept at
|
||||
startup.
|
||||
|
||||
This flow handles one ordinary APK and standard OBB files for one game at a time.
|
||||
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
|
||||
archives need their own import handling. PC ZIP/folder/EXE imports keep their
|
||||
existing route. Quest USB and Meta library imports keep their existing workflow.
|
||||
|
||||
Validation covers actual APK parsing, the three-file backup layout, package
|
||||
matching, sibling discovery, browser upload staging and cleanup, instance/version
|
||||
guards and failure recovery with mocked headset operations. Browser verification
|
||||
uses the real page and upload/inspection endpoints with simulated installation.
|
||||
**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`:
|
||||
F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic
|
||||
install/start/copy/close sequence. Reopening its persistent instance confirmed
|
||||
both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app,
|
||||
shortcut and instance were removed afterward, the shared device lock was
|
||||
released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json).
|
||||
The headset was unworn; this checks transfer and persistence, not rendering,
|
||||
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
|
||||
flow does not change APK compatibility or remove licence checks.
|
||||
@@ -135,6 +135,88 @@ is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
|
||||
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
|
||||
Removing a repository clears its state.
|
||||
|
||||
## My itch.io purchases
|
||||
|
||||
The store can also install the Android (Quest) builds of games you **own** on
|
||||
itch.io: bought, claimed free, from a bundle or a gift. It is opt-in. In the
|
||||
store, open the sources dialog and use **My itch.io purchases**: create an API
|
||||
key on itch.io (account settings › API keys) and paste it there. Frame Control
|
||||
checks it with itch.io (`GET /profile`) before saving it; a wrong or revoked key
|
||||
is reported and nothing is saved. Once connected, a source called
|
||||
**itch.io (my purchases)** appears, and its games are marked **Owned on
|
||||
itch.io** instead of Free. **Disconnect itch.io** deletes the key file.
|
||||
|
||||
How it works, using the documented
|
||||
[server-side API](https://itch.io/docs/api/serverside) with
|
||||
`Authorization: Bearer <key>`:
|
||||
|
||||
- `GET https://api.itch.io/profile/owned-keys` lists your download keys, page by
|
||||
page until an empty page (500 per page, at most 40 pages). Games whose
|
||||
`traits` don't include `p_android` are left out; one entry per game, with the
|
||||
newest key.
|
||||
- Opening a game asks `GET /games/<game_id>/uploads?download_key_id=<key id>`
|
||||
for its files. Android uploads are those marked `p_android` (in `traits`, or
|
||||
as a `p_android`/`platforms.android` flag) or named `.apk`. `.apk` files come
|
||||
first, then full games before demos, newest first. Soundtracks, pre-orders and
|
||||
externally hosted files are skipped.
|
||||
- Installing asks `GET /uploads/<upload_id>/download?download_key_id=<key id>`.
|
||||
The answer accepted is a redirect to itch.io's storage host, a JSON body
|
||||
with a `url` field naming it, or the file itself. Frame Control follows the
|
||||
first two without the key, to HTTPS on port 443 only, and only to host names
|
||||
whose every address is public. For IPv4 that means outside every special
|
||||
block (`0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`,
|
||||
`192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`,
|
||||
`198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`), however written. IPv6 is
|
||||
an allow-list: global unicast (`2000::/3`) only, minus 6to4, Teredo,
|
||||
documentation, ORCHID and benchmarking blocks, so mapped or NAT64 IPv4,
|
||||
site-local, unique-local and the rest are all refused. It connects only to
|
||||
the addresses it checked, trying each in turn within one 20-second budget
|
||||
for connecting and the TLS handshake, and checks again on each further
|
||||
redirect. A storage URL, first or after any redirect, that contains the API
|
||||
key (or 8+ characters of it, in any case, plain or percent-encoded) or an
|
||||
`api_key` parameter is refused before it is looked up, so the key never
|
||||
reaches the storage server. The APK then takes
|
||||
the normal install path, with the same ABI, OpenXR layer and Meta-services
|
||||
checks and warnings as any other store download.
|
||||
|
||||
The key is stored in `frame_host.data_dir('itch-account.json')`
|
||||
(`~/Library/Application Support/Frame Control/itch-account.json` on macOS),
|
||||
written through a new 0600 temporary file and moved into place. It is only
|
||||
sent to `https://api.itch.io`, in the `Authorization` header, never in a URL;
|
||||
Frame Control does not follow api.itch.io's redirects with it. The key, or any
|
||||
part of it from 8 characters, never appears in the page, API responses, errors
|
||||
or logs; the page clears the field as soon as it is sent. A disconnect made
|
||||
while a key is still being checked wins: that key is not saved.
|
||||
Owned-game lists are kept in memory for ten minutes and never written to the
|
||||
disk cache.
|
||||
|
||||
When api.itch.io answers 401 or 403, Frame Control asks `GET /profile` with the
|
||||
same key. If that is refused too (or itch.io says "invalid key"), the key is
|
||||
deleted and the store says to connect again; otherwise only that game or
|
||||
download is refused, and the key stays. Rate limits (429) and network errors
|
||||
only fail that source's results, with a readable message. A storage host's
|
||||
refusal is reported as a download error, not as a rate limit.
|
||||
|
||||
### What is verified and what isn't
|
||||
|
||||
Not tested against a live itch.io account at all: everything below was tested
|
||||
with recorded and hand-made responses (`tests/test_itch_purchases.py`) only.
|
||||
|
||||
| Part | Status |
|
||||
|---|---|
|
||||
| Bearer key, `GET /profile`, `/profile/owned-keys` paging and its fields, the `errors` array | Documented in the public reference (checked 2026-10-13) |
|
||||
| `GET /games/<id>/uploads` and `download_key_id` | Not in the public reference; paths and parameter from itch.io's app client, [go-itchio](https://github.com/itchio/go-itchio) |
|
||||
| Upload fields (`filename`, `display_name`, `size`, `storage`, `type`, `traits`, `updated_at`, `demo`, `preorder`) | Assumed from go-itchio and the API's snake_case convention |
|
||||
| The `p_android` trait, on games and uploads | Assumed from itch.io's `p_windows`/`p_linux`/`p_osx` naming |
|
||||
| What `/uploads/<id>/download` returns | Unknown: go-itchio only builds the URL and hands it to a downloader, so a redirect, a JSON `url` and the file are all accepted |
|
||||
| Whether that endpoint accepts the key as a Bearer header | Unknown: go-itchio puts it in the query string, which Frame Control refuses to do |
|
||||
| itch.io's storage host names and addresses | Unknown; any public HTTPS host is allowed |
|
||||
|
||||
Limits: a game whose page doesn't mark Android, or whose Android build is
|
||||
inside a `.zip`, won't install. Android builds have no version codes on
|
||||
itch.io, so only the newest upload can be installed. Owning a game doesn't make
|
||||
it run on the Frame: Quest builds that need Meta's platform services won't.
|
||||
|
||||
## Publish your own repository
|
||||
|
||||
Only publish free APKs you own or have the developer's permission to distribute.
|
||||
|
||||
@@ -73,6 +73,8 @@ does not suppress the other at the aggregator level. Queries filter the current
|
||||
feed window locally: this is not an exhaustive historical itch search. Only
|
||||
explicit zero-price Android entries are returned. Covers are exposed in
|
||||
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
|
||||
Games the user owns on itch.io are a separate, opt-in source that downloads
|
||||
through their own API key: see [My itch.io purchases](apk-repos.md#my-itchio-purchases).
|
||||
Curated GitHub entries include publisher artwork and plain-language summaries.
|
||||
Repository image URLs are pinned to inspected commits. Open Brush screenshots
|
||||
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"date": "2026-10-20T03:53:25Z",
|
||||
"build_id": "20260925.6191901",
|
||||
"app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)",
|
||||
"input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050",
|
||||
"sibling_discovery": true,
|
||||
"automatic_import": "passed",
|
||||
"closed_after_import": true,
|
||||
"independent_readback": [
|
||||
"5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb",
|
||||
"aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb"
|
||||
],
|
||||
"cleanup": {
|
||||
"test_install_removed": true,
|
||||
"running_instances_before": {},
|
||||
"running_instances_after": {},
|
||||
"battery_before": [
|
||||
"100",
|
||||
"Full",
|
||||
"Charging"
|
||||
],
|
||||
"battery_after": [
|
||||
"99",
|
||||
"Discharging",
|
||||
"Charging"
|
||||
],
|
||||
"shared_lock_released": true
|
||||
},
|
||||
"limits": [
|
||||
"No headset rendering or gameplay assertion; headset unworn.",
|
||||
"Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.",
|
||||
"A game that exits before its Lepton instance can accept data was not tested."
|
||||
]
|
||||
}
|
||||
+1
-4
@@ -24,7 +24,7 @@ No SideQuest implementation code was copied.
|
||||
| Installed-app management (launch, uninstall, backup) | List, launch, stop, remove, probe | Already mostly covered. Backup added here. |
|
||||
| Update notices / account library | Compatible-version lookup; no source-aware installed update notices | Useful later, medium; needs original version code and source identity recorded on install. |
|
||||
| Custom repositories | Built-in F-Droid catalogue and compatible-version indexes | Separate user-repos worker. Legacy SideQuest source has a fixed SideQuestRepos index; arbitrary current custom-repo support was not verified. |
|
||||
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | The [Android backup importer](android-backups.md) now groups the APK and matching OBBs, detects desktop siblings and guides installation and data setup. |
|
||||
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | OBB backend added here; future UI can call it. UI drop wiring is not included. |
|
||||
| Tags, price, headset filters, reviews | Text search and Lepton verdicts, not Quest headset metadata | Useful, medium; search worker owns filters. Keep source headset claims distinct from tested Frame compatibility. |
|
||||
| Screenshot/video capture and streaming | Frame screenshots/VR capture already present | Reuse existing tools; do not port Quest capture commands. |
|
||||
| Device settings and ADB utilities | Frame/Android display settings, SSH and own-instance tools | Borrow selectively; Quest CPU/GPU presets and wireless-ADB setup do not map directly to Lepton. |
|
||||
@@ -85,9 +85,6 @@ local SHA-256 alone must not be called publisher verification.
|
||||
|
||||
## OBB files
|
||||
|
||||
For the app's guided APK and OBB import, see [Import an Android game backup](android-backups.md).
|
||||
The CLI helper below remains available for a running instance.
|
||||
|
||||
```sh
|
||||
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb
|
||||
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb patch.42.org.example.game.obb
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Hand-made itch.io API responses for tests/test_itch_purchases.py; no real account or
|
||||
network was used. owned-keys-*.json follow the documented /profile/owned-keys sample
|
||||
(https://itch.io/docs/api/serverside). uploads.json follows the shape the itch.io app's
|
||||
client library (github.com/itchio/go-itchio) reads from /games/ID/uploads, with the
|
||||
`traits` array it handles; `p_android` there is assumed, not documented.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"page": 1,
|
||||
"per_page": 500,
|
||||
"owned_keys": [
|
||||
{"id": 9001, "game_id": 341190, "purchase_id": 3923754, "downloads": 6,
|
||||
"created_at": "2026-03-10T23:46:36Z", "updated_at": "2026-03-10T23:46:36Z",
|
||||
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
|
||||
"cover_url": "https://img.itch.zone/aW1nLzE2OTA3NTIuanBn/315x250%23c/Ihj7Wa.jpg",
|
||||
"short_text": "Grow plants in zero gravity.", "type": "default", "classification": "game",
|
||||
"min_price": 1299, "traits": ["p_windows", "p_android", "can_be_bought"],
|
||||
"created_at": "2025-12-04T16:18:04Z", "published_at": "2026-01-03T17:28:47Z",
|
||||
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio",
|
||||
"url": "https://maker.itch.io"}}},
|
||||
{"id": 9002, "game_id": 3, "purchase_id": null, "downloads": 0,
|
||||
"created_at": "2026-02-01T10:00:00Z", "updated_at": "2026-02-01T10:00:00Z",
|
||||
"game": {"id": 3, "title": "Desk Only", "url": "https://leafo.itch.io/x-moon",
|
||||
"cover_url": "https://img.itch.zone/aW1hZ2UvMy8xODM3LnBuZw==/315x250%23/y2uYQI.png",
|
||||
"type": "default", "classification": "game", "min_price": 0,
|
||||
"traits": ["p_windows", "p_linux", "p_osx"], "published_at": "2013-03-03T23:02:14Z",
|
||||
"user": {"id": 1, "username": "leafo", "display_name": "leafo", "url": "https://leafo.itch.io"}}}
|
||||
]
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"page": 2,
|
||||
"per_page": 500,
|
||||
"owned_keys": [
|
||||
{"id": 8001, "game_id": 341190, "purchase_id": null, "downloads": 1,
|
||||
"created_at": "2025-06-01T10:00:00Z", "updated_at": "2025-06-01T10:00:00Z",
|
||||
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
|
||||
"type": "default", "classification": "game", "min_price": 1299,
|
||||
"traits": ["p_windows", "p_android", "can_be_bought"],
|
||||
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio"}}},
|
||||
{"id": 8002, "game_id": 77, "purchase_id": null, "downloads": 2,
|
||||
"created_at": "2025-05-01T10:00:00Z", "updated_at": "2025-05-01T10:00:00Z",
|
||||
"game": {"id": 77, "title": "Free Quest Toy", "url": "https://toys.itch.io/quest-toy",
|
||||
"cover_url": "http://img.example/not-itch.png",
|
||||
"type": "default", "classification": "game", "min_price": 0,
|
||||
"traits": ["p_android"], "published_at": "2025-04-01T00:00:00Z",
|
||||
"user": {"id": 5, "username": "toys"}}}
|
||||
]
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"uploads": [
|
||||
{"id": 501, "game_id": 341190, "filename": "OrbitGarden-win64.zip", "display_name": "Windows",
|
||||
"size": 734003200, "storage": "hosted", "type": "default", "traits": ["p_windows"],
|
||||
"created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-02T10:00:00Z"},
|
||||
{"id": 502, "game_id": 341190, "filename": "OrbitGarden-Quest-old.apk", "display_name": "Quest 1.0",
|
||||
"size": 104857600, "storage": "hosted", "type": "default", "traits": ["p_android"],
|
||||
"created_at": "2026-01-01T10:00:00Z", "updated_at": "2026-01-05T10:00:00Z"},
|
||||
{"id": 503, "game_id": 341190, "filename": "OrbitGarden-Quest.apk", "display_name": "Quest 1.2",
|
||||
"size": 115343360, "storage": "hosted", "type": "default", "traits": ["p_android"],
|
||||
"created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-03T10:00:00Z"},
|
||||
{"id": 504, "game_id": 341190, "filename": "OrbitGarden-demo.apk", "display_name": "Quest demo",
|
||||
"size": 52428800, "storage": "hosted", "type": "default", "traits": ["p_android", "demo"],
|
||||
"created_at": "2026-05-01T10:00:00Z", "updated_at": "2026-05-01T10:00:00Z"},
|
||||
{"id": 505, "game_id": 341190, "filename": "OrbitGarden-android-bundle.zip", "display_name": "Android (zip)",
|
||||
"size": 120000000, "storage": "hosted", "type": "default", "traits": ["p_android"],
|
||||
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
|
||||
{"id": 506, "game_id": 341190, "filename": "Soundtrack.apk", "display_name": "Soundtrack",
|
||||
"size": 1000, "storage": "hosted", "type": "soundtrack", "traits": [],
|
||||
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
|
||||
{"id": 507, "game_id": 341190, "filename": "mirror.apk", "display_name": "Mirror",
|
||||
"size": 1000, "storage": "external", "host": "example.com", "type": "default", "traits": ["p_android"],
|
||||
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
|
||||
{"id": 508, "game_id": 341190, "filename": "OrbitGarden-mac.dmg", "display_name": "macOS",
|
||||
"size": 1000, "storage": "hosted", "type": "default", "traits": ["p_osx"],
|
||||
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"}
|
||||
]
|
||||
}
|
||||
@@ -19,7 +19,7 @@ function grab(name) {
|
||||
fail(`unbalanced ${name}`);
|
||||
}
|
||||
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "storePrice", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
|
||||
const code = names.map(grab).join("");
|
||||
|
||||
@@ -56,6 +56,17 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
|
||||
if (html.includes('role="alert"')) fail("a good listing showed the error state");
|
||||
}
|
||||
|
||||
// A game the user owns on itch.io says so instead of "Free".
|
||||
{
|
||||
const p = page();
|
||||
p.sourceState.detail = { app: { name: "Orbit Garden", offers: [{ ...offer, source: "itch-owned", source_name: "itch.io (my purchases)",
|
||||
owned: true, owned_label: "Owned on itch.io", free: false }] }, choice: 0 };
|
||||
if (p.renderAppDetail() !== true) fail("an owned listing reported a render failure");
|
||||
const html = p.els.detailBody.innerHTML;
|
||||
if (!html.includes('<div class="detail-price">Owned on itch.io</div>')) fail("owned listing has no Owned on itch.io label");
|
||||
if (html.includes("No purchase needed")) fail("owned listing claims no purchase is needed");
|
||||
}
|
||||
|
||||
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
|
||||
{
|
||||
const p = page();
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"""Local APK/OBB backups: inspect as a unit, verify the target, and finish setup."""
|
||||
import sandbox # noqa: F401
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_android_import as backup
|
||||
import server
|
||||
from apk_fixtures import apk, manifest
|
||||
|
||||
PKG = 'com.Armature.VR4'
|
||||
META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210,
|
||||
'instance': 2800000001, 'shortcut': 123, 'game_id': 456}
|
||||
|
||||
|
||||
class BackupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.apk = self.root / 'VR4-Android-Shipping-arm64.apk'
|
||||
self.apk.write_bytes(apk({'AndroidManifest.xml': manifest(PKG, 0x7f010000, 0x7f010001, 26)}))
|
||||
self.obb = [self.root / f'{kind}.203.{PKG}.obb' for kind in ('main', 'patch')]
|
||||
for p in self.obb:
|
||||
p.write_bytes(b'game data')
|
||||
|
||||
def test_desktop_apk_finds_matching_siblings_but_browser_requires_selection(self):
|
||||
(self.root / 'main.1.org.other.game.obb').write_bytes(b'other game')
|
||||
with patch.object(backup.android, 'ssh') as ssh:
|
||||
desktop = backup.inspect([str(self.apk)], discover=True)
|
||||
browser = backup.inspect([str(self.apk)])
|
||||
selected = backup.inspect([str(self.apk), *map(str, self.obb)])
|
||||
ssh.assert_not_called()
|
||||
self.assertEqual(desktop['obb'], self.obb)
|
||||
self.assertEqual(browser['obb'], [])
|
||||
self.assertEqual(backup.public(selected)['obb'][0]['name'], self.obb[0].name)
|
||||
self.assertEqual(backup.public(selected)['package'], PKG)
|
||||
self.assertNotIn(str(self.root), str(backup.public(selected)))
|
||||
|
||||
def test_wrong_data_empty_data_duplicate_names_and_multiple_apks_fail_before_install(self):
|
||||
other = self.root / 'main.203.org.other.game.obb'
|
||||
other.write_bytes(b'other game')
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'does not belong'):
|
||||
backup.inspect([str(self.apk), str(other)])
|
||||
self.obb[0].write_bytes(b'')
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'nonempty'):
|
||||
backup.inspect([str(self.apk), str(self.obb[0])])
|
||||
second = self.root / 'another.apk'
|
||||
second.write_bytes(self.apk.read_bytes())
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'one game at a time'):
|
||||
backup.inspect([str(self.apk), str(second)])
|
||||
self.obb[0].write_bytes(b'data')
|
||||
folder = self.root / 'copy'
|
||||
folder.mkdir()
|
||||
duplicate = folder / self.obb[0].name
|
||||
duplicate.write_bytes(b'data')
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'unique names'):
|
||||
backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)])
|
||||
|
||||
def test_obb_only_repairs_existing_game_and_requests_apk_when_not_installed(self):
|
||||
with patch.object(backup.android, 'read_meta', return_value=None):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'Add its APK'):
|
||||
backup.inspect(list(map(str, self.obb)))
|
||||
with patch.object(backup.android, 'read_meta', return_value=META):
|
||||
plan = backup.inspect(list(map(str, self.obb)))
|
||||
self.assertIsNone(plan['apk'])
|
||||
self.assertEqual(plan['checked']['version_code'], 210)
|
||||
|
||||
def test_apk_and_both_data_files_install_launch_verify_copy_close_in_order(self):
|
||||
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
|
||||
order = []
|
||||
def added(pkg, checked, files, *args, **kwargs):
|
||||
order.append('copy-close')
|
||||
self.assertEqual(pkg, PKG)
|
||||
self.assertEqual(files, self.obb)
|
||||
return {'closed': True, 'message': 'copied', 'app': META, 'needs_game_data': False}
|
||||
with patch.object(backup.quest, '_running', side_effect=[False, False, True]), \
|
||||
patch.object(backup.android, 'read_meta', return_value=None), \
|
||||
patch.object(backup.android, 'install', side_effect=lambda *a, **k: order.append('install') or META), \
|
||||
patch.object(backup.android, 'launch', side_effect=lambda *a: order.append('launch')), \
|
||||
patch.object(backup.quest, '_wait_ready', side_effect=lambda *a: order.append('ready')), \
|
||||
patch.object(backup.quest, '_check_android_package', side_effect=lambda *a: order.append('verify')), \
|
||||
patch.object(backup.quest, 'add_game_data', side_effect=added):
|
||||
result = backup.install(plan)
|
||||
self.assertEqual(order, ['install', 'launch', 'ready', 'verify', 'copy-close'])
|
||||
self.assertIn('2 game-data files', result['message'])
|
||||
self.assertIn('Ready to open', result['message'])
|
||||
|
||||
def test_copy_failure_closes_game_and_keeps_retry_instruction(self):
|
||||
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
|
||||
with patch.object(backup.quest, '_running', side_effect=[False, True]), \
|
||||
patch.object(backup.android, 'read_meta', return_value=None), \
|
||||
patch.object(backup.android, 'install', return_value=META), \
|
||||
patch.object(backup.quest, '_wait_ready'), patch.object(backup.quest, '_check_android_package'), \
|
||||
patch.object(backup.quest, 'add_game_data', side_effect=backup.android.FrameError('transfer failed')), \
|
||||
patch.object(backup.quest, '_close', return_value=(True, None)) as close:
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'installed, but.*Import the same backup again'):
|
||||
backup.install(plan)
|
||||
close.assert_called_once_with(PKG)
|
||||
|
||||
def test_changed_files_are_rejected_before_frame_access(self):
|
||||
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
|
||||
self.obb[0].write_bytes(b'changed contents')
|
||||
with patch.object(backup.android, 'ssh') as ssh:
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'backup changed'):
|
||||
backup.install(plan)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_running_game_and_newer_installed_version_are_not_replaced(self):
|
||||
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
|
||||
newer = {**META, 'version_code': plan['checked']['version_code'] + 1}
|
||||
for running, existing, message in [(True, None, 'Close'), (False, newer, 'newer')]:
|
||||
with self.subTest(running=running), \
|
||||
patch.object(backup.quest, '_running', return_value=running), \
|
||||
patch.object(backup.android, 'read_meta', return_value=existing), \
|
||||
patch.object(backup.android, 'install') as install:
|
||||
with self.assertRaisesRegex(backup.android.FrameError, message):
|
||||
backup.install(plan)
|
||||
install.assert_not_called()
|
||||
|
||||
def test_apk_without_data_installs_without_launching(self):
|
||||
plan = backup.inspect([str(self.apk)])
|
||||
with patch.object(backup.quest, '_running', return_value=False), \
|
||||
patch.object(backup.android, 'read_meta', return_value=None), \
|
||||
patch.object(backup.android, 'install', return_value=META), \
|
||||
patch.object(backup.android, 'launch') as launch:
|
||||
result = backup.install(plan)
|
||||
launch.assert_not_called()
|
||||
self.assertFalse(result['needs_game_data'])
|
||||
|
||||
|
||||
class StagingTests(unittest.TestCase):
|
||||
setUp = BackupTests.setUp
|
||||
def tearDown(self):
|
||||
for entry in server._android_backups.values():
|
||||
server._discard_android_backup(entry)
|
||||
server._android_backups.clear()
|
||||
|
||||
def test_uploaded_batch_is_inspected_together_and_cancel_removes_all_uploads(self):
|
||||
token = server.android_backup({'action': 'begin'})['token']
|
||||
for path in [self.apk, *self.obb]:
|
||||
class Request:
|
||||
headers = {'X-Filename': path.name, 'X-Mode': 'android-backup',
|
||||
'X-Android-Backup': token, 'Content-Length': str(path.stat().st_size)}
|
||||
rfile = io.BytesIO(path.read_bytes())
|
||||
server.Handler.upload(Request())
|
||||
read = server.android_backup({'action': 'inspect', 'token': token})
|
||||
self.assertEqual(len(read['plan']['obb']), 2)
|
||||
folders = list(server._android_backups[token]['dirs'])
|
||||
server.android_backup({'action': 'discard', 'token': token})
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
self.assertTrue(self.apk.exists())
|
||||
|
||||
def test_headset_change_refuses_import_and_pc_folder_is_left_to_title_importer(self):
|
||||
class Link:
|
||||
device = 'a'
|
||||
def active_device(self):
|
||||
return {'id': self.device}
|
||||
link = Link()
|
||||
with patch.object(server, 'LINK', link):
|
||||
read = server.android_backup({'action': 'inspect', 'paths': [str(self.apk)]})
|
||||
link.device = 'b'
|
||||
with self.assertRaisesRegex(server.Failure, 'headset changed'):
|
||||
server.android_backup({'action': 'install', 'token': read['token']})
|
||||
pc = self.root / 'pc'
|
||||
pc.mkdir()
|
||||
(pc / 'game.exe').write_bytes(b'program')
|
||||
self.assertEqual(server.android_backup({'action': 'inspect', 'paths': [str(pc)], 'probe': True}),
|
||||
{'android': False})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -323,15 +323,6 @@ class Pins(Base):
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
def test_replaced_registry_is_read_even_when_timestamp_and_size_match(self):
|
||||
d = self.reg.add_device('frame', name='First')
|
||||
before = self.reg.path.stat()
|
||||
other = fd.Registry(self.reg.path)
|
||||
other.update_device(d['id'], name='Other')
|
||||
os.utime(self.reg.path, ns=(before.st_atime_ns, before.st_mtime_ns))
|
||||
self.assertEqual(self.reg.path.stat().st_size, before.st_size)
|
||||
self.assertEqual(self.reg.get(d['id'])['name'], 'Other')
|
||||
|
||||
def test_an_address_added_first_wins_on_its_own_network(self):
|
||||
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
|
||||
# worked here before. The LAN address has to go ahead of it to be used at home.
|
||||
|
||||
@@ -0,0 +1,862 @@
|
||||
"""My itch.io purchases: the account key, owned games and their Android downloads.
|
||||
|
||||
Offline: a fake HTTPS layer under urllib's real opener (so redirects and header
|
||||
handling are the real ones) serves the recorded JSON in fixtures/itch_account.
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import contextlib, email.message, http.client, io, ipaddress, json, os, socket, sys, tempfile, threading, time
|
||||
import unittest
|
||||
import urllib.error, urllib.parse, urllib.request, zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
from apk_sources import SourceError, SourceLimited, _itch_account as account, _web, itch, search
|
||||
import server
|
||||
|
||||
FIX = Path(__file__).parent / 'fixtures' / 'itch_account'
|
||||
KEY = 'TESTKEYabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
OTHER = 'OTHERKEY0123456789abcdefghijklmnopqrstuv'
|
||||
|
||||
|
||||
def apk_bytes():
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, 'w') as z:
|
||||
z.writestr('AndroidManifest.xml', b'\x03\x00\x08\x00')
|
||||
z.writestr('classes.dex', b'dex\n035\x00')
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class Reply(io.BytesIO):
|
||||
"""Just enough of http.client.HTTPResponse for urllib's handlers."""
|
||||
|
||||
def __init__(self, url, code=200, body=b'', headers=None):
|
||||
super().__init__(body if isinstance(body, bytes) else json.dumps(body).encode())
|
||||
self.url, self.code, self.status, self.msg, self.reason = url, code, code, 'fake', 'fake'
|
||||
self.headers = email.message.Message()
|
||||
for k, v in (headers or {}).items():
|
||||
self.headers[k] = v
|
||||
|
||||
def info(self):
|
||||
return self.headers
|
||||
|
||||
def geturl(self):
|
||||
return self.url
|
||||
|
||||
def getcode(self):
|
||||
return self.code
|
||||
|
||||
def getheader(self, name, default=None):
|
||||
return self.headers.get(name, default)
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
"""_web._connect's http.client connection, answered from the same routes."""
|
||||
|
||||
def __init__(self, web, host):
|
||||
self.web, self.host, self.reply = web, host, None
|
||||
|
||||
def request(self, method, path, headers=None):
|
||||
req = urllib.request.Request('https://' + self.host + path, headers=headers or {})
|
||||
self.reply = self.web(None, req)
|
||||
|
||||
def getresponse(self):
|
||||
return self.reply
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
# What the fake resolver answers: the storage host is public; the rest are traps.
|
||||
ADDRESSES = {'storage.itch-cdn.example': '151.101.1.1', 'other-cdn.example': '151.101.2.2',
|
||||
'localhost.': '127.0.0.1', 'frame.local': '192.168.1.20', '127.0.0x0.1': '127.0.0.1',
|
||||
'intranet.example': '10.0.0.5', 'mapped.example': '::ffff:127.0.0.1', 'cgnat.example': '100.64.0.1',
|
||||
'mixed.example': ('151.101.1.1', '10.0.0.5'), 'dual.example': ('2a04:4e42::81', '151.101.1.1'),
|
||||
'nat64.example': '64:ff9b:1::a00:1', 'sitelocal.example': 'fec0::1',
|
||||
'many.example': tuple('151.101.%d.1' % n for n in range(1, 9))}
|
||||
REAL_CONNECT = _web._connect
|
||||
|
||||
|
||||
class FakeItch:
|
||||
"""Routes https requests by host and path; records every request with its headers."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls, self.routes = [], {}
|
||||
|
||||
def route(self, host_path, reply):
|
||||
self.routes[host_path] = reply
|
||||
|
||||
def __call__(self, handler, req):
|
||||
url = urllib.parse.urlsplit(req.full_url)
|
||||
headers = {k.lower(): v for k, v in req.header_items()}
|
||||
self.calls.append((req.full_url, headers))
|
||||
reply = self.routes.get(url.hostname + url.path)
|
||||
if reply is None:
|
||||
return Reply(req.full_url, 404, {'errors': ['not found']})
|
||||
if callable(reply):
|
||||
reply = reply(req, urllib.parse.parse_qs(url.query))
|
||||
if isinstance(reply, Exception):
|
||||
raise reply
|
||||
if isinstance(reply, Reply):
|
||||
return reply
|
||||
return Reply(req.full_url, 200, reply)
|
||||
|
||||
def urls(self, host_path):
|
||||
return [u for u, _ in self.calls if urllib.parse.urlsplit(u).hostname + urllib.parse.urlsplit(u).path == host_path]
|
||||
|
||||
|
||||
def owned_pages(req, query):
|
||||
page = query['page'][0]
|
||||
return {'1': json.loads((FIX / 'owned-keys-1.json').read_text()),
|
||||
'2': json.loads((FIX / 'owned-keys-2.json').read_text())}.get(page, {'page': int(page), 'owned_keys': []})
|
||||
|
||||
|
||||
class ItchPurchases(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
root = Path(self.tmp.name)
|
||||
for target, value in [(account.frame_host, 'data_dir'), (search.frame_host, 'data_dir')]:
|
||||
p = patch.object(target, value, lambda *parts: root.joinpath('data', *parts))
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
(root / 'cache').mkdir()
|
||||
for p in (patch.object(_web, 'cache', return_value=str(root / 'cache')),
|
||||
patch.object(_web.frame_host, 'cache_dir', lambda *parts: root.joinpath('cache', *parts))):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.web = FakeItch()
|
||||
self.web.route('api.itch.io/profile', {'user': {'username': 'player', 'id': 1}})
|
||||
self.web.route('api.itch.io/profile/owned-keys', owned_pages)
|
||||
self.web.route('api.itch.io/games/341190/uploads', json.loads((FIX / 'uploads.json').read_text()))
|
||||
p = patch('urllib.request.HTTPSHandler.https_open', autospec=True, side_effect=self.web)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
p = patch('urllib.request.HTTPHandler.http_open', side_effect=AssertionError('plain HTTP in test'))
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
real_lookup = _web.socket.getaddrinfo
|
||||
|
||||
def lookup(host, port, *args, **kwargs):
|
||||
if host not in ADDRESSES:
|
||||
try:
|
||||
ipaddress.ip_address(host) # IP literals (and the in-process test server) need no DNS
|
||||
return real_lookup(host, port, *args, **kwargs)
|
||||
except ValueError:
|
||||
pass
|
||||
if host == 'localhost':
|
||||
return real_lookup(host, port, *args, **kwargs)
|
||||
raise _web.socket.gaierror('unknown test host ' + host)
|
||||
found = ADDRESSES[host]
|
||||
return [(10 if ':' in a else 2, 1, 6, '', (a, port)) for a in ([found] if isinstance(found, str) else found)]
|
||||
self.connected, self.unreachable = [], set()
|
||||
|
||||
def connect(host, address, timeout, deadline):
|
||||
self.connected.append((host, address))
|
||||
if address[0] in self.unreachable:
|
||||
raise OSError('Network is unreachable')
|
||||
self.assertLessEqual(deadline - time.monotonic(), _web.CONNECT_BUDGET)
|
||||
return FakeConnection(self.web, host)
|
||||
for p in (patch.object(_web.socket, 'getaddrinfo', lookup), patch.object(_web, '_connect', connect)):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
account._forget()
|
||||
self.addCleanup(account._forget)
|
||||
_web._limited.clear()
|
||||
self.addCleanup(_web._limited.clear)
|
||||
search._status.clear()
|
||||
|
||||
def owned_source(self):
|
||||
return next(s for s in itch.sources() if s['id'] == itch.OWNED)
|
||||
|
||||
def connect(self):
|
||||
self.assertTrue(account.connect(KEY)['itch']['connected'])
|
||||
|
||||
# -- the key ---------------------------------------------------------------
|
||||
|
||||
def test_connect_checks_the_key_then_saves_it_0600(self):
|
||||
result = account.connect(' ' + KEY + '\n')
|
||||
self.assertEqual(result['message'], 'Connected to itch.io as player')
|
||||
self.assertEqual(result['itch'], {'connected': True, 'username': 'player'})
|
||||
self.assertNotIn(KEY, json.dumps(result))
|
||||
url, headers = self.web.calls[0]
|
||||
self.assertEqual(url, 'https://api.itch.io/profile')
|
||||
self.assertEqual(headers['authorization'], 'Bearer ' + KEY)
|
||||
self.assertEqual(json.loads(account.path().read_text())['key'], KEY)
|
||||
if os.name == 'posix':
|
||||
self.assertEqual(os.stat(account.path()).st_mode & 0o777, 0o600)
|
||||
self.assertEqual([p.name for p in account.path().parent.iterdir()], ['itch-account.json']) # no .tmp left
|
||||
self.assertNotIn(KEY, json.dumps(account.status()))
|
||||
|
||||
def test_reconnecting_rewrites_the_file_0600_even_if_it_was_wider(self):
|
||||
self.connect()
|
||||
os.chmod(account.path(), 0o644)
|
||||
account.connect(OTHER)
|
||||
self.assertEqual(json.loads(account.path().read_text())['key'], OTHER)
|
||||
if os.name == 'posix':
|
||||
self.assertEqual(os.stat(account.path()).st_mode & 0o777, 0o600)
|
||||
|
||||
def test_bad_or_revoked_key_stays_disconnected(self):
|
||||
for reply in (Reply('https://api.itch.io/profile', 401, {'errors': ['invalid key']}),
|
||||
Reply('https://api.itch.io/profile', 403, b'Forbidden'),
|
||||
{'errors': ['invalid key']}):
|
||||
self.web.route('api.itch.io/profile', reply)
|
||||
with self.assertRaisesRegex(account.BadKey, 'did not accept the API key') as caught:
|
||||
account.connect(KEY)
|
||||
self.assertNotIn(KEY, str(caught.exception))
|
||||
self.assertFalse(account.path().exists())
|
||||
self.assertEqual(account.status(), {'connected': False, 'username': None})
|
||||
self.assertEqual([s['id'] for s in itch.sources()], ['itch', 'itch-quest'])
|
||||
with self.assertRaisesRegex(account.BadKey, 'does not look like'):
|
||||
account.connect('short')
|
||||
with self.assertRaisesRegex(account.BadKey, 'does not look like'):
|
||||
account.connect(KEY + '\r\nX-Injected: 1')
|
||||
self.assertEqual(len(self.web.calls), 3) # malformed keys never reach itch.io
|
||||
|
||||
def test_a_refused_request_with_a_working_key_keeps_the_key(self):
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/games/341190/uploads', Reply('https://api.itch.io/games/341190/uploads', 403, b''))
|
||||
with self.assertRaisesRegex(SourceError, r'refused this request \(HTTP 403\)') as caught:
|
||||
itch.details(self.owned_source(), '341190')
|
||||
self.assertNotIsInstance(caught.exception, account.BadKey)
|
||||
self.assertTrue(account.connected())
|
||||
|
||||
def test_a_key_revoked_later_is_removed_with_a_clear_message(self):
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/profile/owned-keys', Reply('https://api.itch.io/profile/owned-keys', 401, b''))
|
||||
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', 401, b''))
|
||||
with self.assertRaisesRegex(account.BadKey, 'no longer accepts the saved API key'):
|
||||
itch.search(self.owned_source(), '')
|
||||
self.assertFalse(account.path().exists())
|
||||
self.assertNotIn(itch.OWNED, [s['id'] for s in itch.sources()])
|
||||
|
||||
def test_disconnect_deletes_the_key_file(self):
|
||||
self.connect()
|
||||
self.assertIn(itch.OWNED, [s['id'] for s in itch.sources()])
|
||||
result = account.disconnect()
|
||||
self.assertFalse(account.path().exists())
|
||||
self.assertEqual(result['itch'], {'connected': False, 'username': None})
|
||||
self.assertNotIn(itch.OWNED, [s['id'] for s in itch.sources()])
|
||||
with self.assertRaisesRegex(SourceError, 'Connect your itch.io account first'):
|
||||
account.owned_keys()
|
||||
account.disconnect() # already gone: fine
|
||||
|
||||
# -- owned games -------------------------------------------------------------
|
||||
|
||||
def test_owned_keys_follow_pages_until_an_empty_one(self):
|
||||
self.connect()
|
||||
keys = account.owned_keys()
|
||||
self.assertEqual([k['id'] for k in keys], [9001, 9002, 8001, 8002])
|
||||
pages = self.web.urls('api.itch.io/profile/owned-keys')
|
||||
self.assertEqual([urllib.parse.parse_qs(urllib.parse.urlsplit(u).query) for u in pages],
|
||||
[{'page': [str(n)], 'per_page': ['500']} for n in (1, 2, 3)])
|
||||
self.assertTrue(all('Bearer ' + KEY == h['authorization'] for _, h in self.web.calls))
|
||||
self.assertTrue(all(KEY not in u for u, _ in self.web.calls))
|
||||
account.owned_keys() # cached in memory, not on disk
|
||||
self.assertEqual(len(self.web.urls('api.itch.io/profile/owned-keys')), 3)
|
||||
self.assertFalse(any(KEY in p.read_text(errors='ignore') or b'Orbit' in p.read_bytes()
|
||||
for p in Path(_web.cache()).iterdir()))
|
||||
|
||||
def test_page_cap_stops_a_server_that_never_ends(self):
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/profile/owned-keys', lambda req, q: {'owned_keys': [{'id': int(q['page'][0])}]})
|
||||
with patch.object(account, 'PAGES', 3):
|
||||
self.assertEqual(len(account.owned_keys()), 3)
|
||||
|
||||
def test_search_lists_owned_android_games_labelled(self):
|
||||
self.connect()
|
||||
entries = itch.search(self.owned_source(), '')
|
||||
self.assertEqual([(e['id'], e['name']) for e in entries], [('341190', 'Orbit Garden VR'), ('77', 'Free Quest Toy')])
|
||||
orbit, toy = entries
|
||||
self.assertEqual(orbit['download_key_id'], 9001) # the newest key for a game owned twice
|
||||
self.assertTrue(orbit['owned'])
|
||||
self.assertEqual(orbit['owned_label'], 'Owned on itch.io')
|
||||
self.assertFalse(orbit['free'])
|
||||
self.assertTrue(toy['free'])
|
||||
self.assertIsNone(toy['icon']) # not an itch.io image host
|
||||
self.assertEqual(orbit['page'], 'https://maker.itch.io/orbit-garden')
|
||||
self.assertEqual(itch.search(self.owned_source(), 'zero gravity')[0]['id'], '341190')
|
||||
# Through the store's search, owned (paid) games pass the free-only filter.
|
||||
result = search.search('orbit', source=itch.OWNED)
|
||||
self.assertEqual(result['sources'][0]['status'], 'ok')
|
||||
offer = result['apps'][0]['offers'][0]
|
||||
self.assertEqual((offer['source'], offer['source_name'], offer['owned_label']),
|
||||
(itch.OWNED, 'itch.io (my purchases)', 'Owned on itch.io'))
|
||||
self.assertNotIn(KEY, json.dumps(result))
|
||||
|
||||
def test_owned_games_show_under_either_vr_choice(self):
|
||||
# itch.io doesn't say which purchases are VR, so neither filter hides them; other
|
||||
# games of unknown kind still count as flat.
|
||||
self.connect()
|
||||
both = ['Free Quest Toy', 'Orbit Garden VR']
|
||||
for vr in (True, False):
|
||||
names = sorted(a['name'] for a in search.search('', vr=vr, source=itch.OWNED)['apps'])
|
||||
self.assertEqual(names, both)
|
||||
flat = {'source': 'x', 'id': '1', 'name': 'Flat', 'summary': '', 'vr': None}
|
||||
self.assertEqual(search.group([flat], vr=True), [])
|
||||
self.assertEqual(len(search.group([flat], vr=False)), 1)
|
||||
self.assertEqual(search.group([dict(flat, owned=True, vr=False)], vr=True), [])
|
||||
|
||||
def test_only_the_account_source_may_mark_paid_games_owned(self):
|
||||
entry = {'free': False, 'owned': True}
|
||||
self.assertFalse(search.obtainable({'id': 'fdroid'}, entry))
|
||||
self.assertTrue(search.obtainable({'id': itch.OWNED, 'owned': True}, entry))
|
||||
self.assertTrue(search.obtainable({'id': 'fdroid'}, {'free': True}))
|
||||
|
||||
def test_android_upload_selection(self):
|
||||
uploads = json.loads((FIX / 'uploads.json').read_text())['uploads']
|
||||
# .apk files before other Android files, full games before demos, newest first; never
|
||||
# soundtracks, externally hosted files or other platforms.
|
||||
self.assertEqual([u['id'] for u in account.android_uploads(uploads)], [503, 502, 504, 505])
|
||||
self.assertTrue(account.is_android({'filename': 'Game.APK'}))
|
||||
self.assertTrue(account.is_android({'filename': 'game.zip', 'p_android': True}))
|
||||
self.assertTrue(account.is_android({'filename': 'game.zip', 'platforms': {'android': 'all'}}))
|
||||
self.assertFalse(account.is_android({'filename': 'game.zip', 'traits': ['p_windows']}))
|
||||
self.assertEqual(account.android_uploads({'not': 'a list'}), [])
|
||||
|
||||
def test_details_offer_the_android_uploads(self):
|
||||
self.connect()
|
||||
entry = itch.details(self.owned_source(), '341190')
|
||||
self.assertTrue(entry['downloadable'])
|
||||
self.assertEqual([v['upload_id'] for v in entry['versions']], [503, 502, 504, 505])
|
||||
self.assertEqual((entry['version'], entry['size'], entry['updated']), ('Quest 1.2', 115343360, '2026-04-03'))
|
||||
self.assertTrue(entry['versions'][2]['demo'])
|
||||
url = self.web.urls('api.itch.io/games/341190/uploads')[0]
|
||||
self.assertEqual(url, 'https://api.itch.io/games/341190/uploads?download_key_id=9001')
|
||||
with self.assertRaisesRegex(SourceError, 'not among'):
|
||||
itch.details(self.owned_source(), '3') # owned, but not for Android
|
||||
self.web.route('api.itch.io/games/77/uploads', {'uploads': []})
|
||||
self.assertFalse(itch.details(self.owned_source(), '77')['downloadable'])
|
||||
with self.assertRaisesRegex(SourceError, 'no Android download'):
|
||||
itch.download(self.owned_source(), '77')
|
||||
|
||||
def test_download_url_building(self):
|
||||
self.assertEqual(account.download_url(503, 9001),
|
||||
'https://api.itch.io/uploads/503/download?download_key_id=9001')
|
||||
for bad in [(0, 1), (1, None), ('5', 1), (True, 1), (-2, 1)]:
|
||||
with self.assertRaises(SourceError):
|
||||
account.download_url(*bad)
|
||||
|
||||
# -- downloads -----------------------------------------------------------------
|
||||
|
||||
def storage(self, location='https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?sig=abc'):
|
||||
self.web.route('api.itch.io/uploads/503/download',
|
||||
Reply('https://api.itch.io/uploads/503/download', 302, b'', {'Location': location}))
|
||||
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk', Reply(location, 200, apk_bytes()))
|
||||
|
||||
def test_download_sends_the_key_to_itch_only_and_strips_it_on_redirect(self):
|
||||
self.connect()
|
||||
self.storage()
|
||||
result = itch.download(self.owned_source(), '341190')
|
||||
self.assertTrue(os.path.isfile(result['apk']))
|
||||
self.assertFalse(result['verified'])
|
||||
api, cdn = self.web.calls[-2:]
|
||||
self.assertEqual(api[0], 'https://api.itch.io/uploads/503/download?download_key_id=9001')
|
||||
self.assertEqual(api[1]['authorization'], 'Bearer ' + KEY)
|
||||
self.assertTrue(cdn[0].startswith('https://storage.itch-cdn.example/'))
|
||||
self.assertNotIn('authorization', cdn[1])
|
||||
# Connected to the address that was checked, not to a fresh lookup.
|
||||
self.assertEqual(self.connected, [('storage.itch-cdn.example', ('151.101.1.1', 443))])
|
||||
self.assertTrue(all(KEY not in u for u, _ in self.web.calls))
|
||||
with self.assertRaisesRegex(SourceError, 'version codes'):
|
||||
itch.download(self.owned_source(), '341190', version_code=3)
|
||||
|
||||
def test_download_also_accepts_a_json_url_or_the_file_itself(self):
|
||||
self.connect()
|
||||
self.storage()
|
||||
location = 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?sig=abc'
|
||||
self.web.route('api.itch.io/uploads/503/download',
|
||||
Reply('https://api.itch.io/uploads/503/download', 200, {'url': location},
|
||||
{'Content-Type': 'application/json'}))
|
||||
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
|
||||
self.assertNotIn('authorization', self.web.calls[-1][1])
|
||||
self.web.route('api.itch.io/uploads/503/download',
|
||||
Reply('https://api.itch.io/uploads/503/download', 200, apk_bytes(),
|
||||
{'Content-Type': 'application/octet-stream'}))
|
||||
self.connected.clear()
|
||||
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
|
||||
self.assertEqual(self.connected, []) # api.itch.io served it; no storage host involved
|
||||
self.web.route('api.itch.io/uploads/503/download',
|
||||
Reply('https://api.itch.io/uploads/503/download', 200, {'nothing': 1},
|
||||
{'Content-Type': 'application/json'}))
|
||||
with self.assertRaisesRegex(SourceError, 'did not say where'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
|
||||
def test_storage_redirects_are_checked_on_every_hop(self):
|
||||
self.connect()
|
||||
self.storage('https://other-cdn.example/hop')
|
||||
self.web.route('other-cdn.example/hop', Reply('https://other-cdn.example/hop', 302, b'',
|
||||
{'Location': 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk'}))
|
||||
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
|
||||
self.assertEqual([h for h, _ in self.connected], ['other-cdn.example', 'storage.itch-cdn.example'])
|
||||
self.web.route('other-cdn.example/hop', Reply('https://other-cdn.example/hop', 302, b'',
|
||||
{'Location': 'https://intranet.example/a.apk'}))
|
||||
with self.assertRaisesRegex(SourceError, 'private network address'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
|
||||
def test_redirects_to_plain_http_or_private_addresses_are_refused(self):
|
||||
self.connect()
|
||||
for location in ('http://storage.itch-cdn.example/a.apk', 'https://user:pw@cdn.example/a.apk',
|
||||
'https://cdn.example:8443/a.apk'):
|
||||
self.storage(location)
|
||||
with self.assertRaisesRegex(SourceError, 'unexpected download URL'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
for location in ('https://localhost./a.apk', 'https://frame.local/a.apk', 'https://127.0.0x0.1/a.apk',
|
||||
'https://intranet.example/a.apk', 'https://mapped.example/a.apk',
|
||||
'https://cgnat.example/a.apk', 'https://mixed.example/a.apk',
|
||||
'https://127.0.0.1/a.apk', 'https://[::1]/a.apk', 'https://169.254.169.254/a.apk',
|
||||
'https://nat64.example/a.apk', 'https://[4000::1]/a.apk', 'https://[64:ff9b:1::a00:1]/a.apk',
|
||||
'https://[fec0::1]/a.apk', 'https://sitelocal.example/a.apk'):
|
||||
self.storage(location)
|
||||
with self.assertRaisesRegex(SourceError, 'private network address'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertEqual(self.connected, [])
|
||||
self.assertEqual({urllib.parse.urlsplit(u).hostname for u, _ in self.web.calls}, {'api.itch.io'})
|
||||
|
||||
def test_public_address_rules(self):
|
||||
for ip in ('151.101.1.1', '8.8.8.8', '2a04:4e42::81', '2001:4860:4860::8888'):
|
||||
self.assertTrue(_web.public_address(ip), ip)
|
||||
for ip in ('127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.0.1', '169.254.169.254', '100.64.0.1',
|
||||
'0.0.0.0', '224.0.0.1', '255.255.255.255', '240.0.0.1', '::1', '::', 'fe80::1%en0', 'fc00::1',
|
||||
'ff02::1', 'not an ip',
|
||||
'192.0.0.8', '192.0.0.11', '192.0.0.255', '192.0.0.170', '192.0.0.9', '192.0.0.1', # 3.9: global
|
||||
'192.0.2.1', '192.88.99.1', '198.18.0.1', '198.19.255.255', '198.51.100.7', '203.0.113.9',
|
||||
'0.1.2.3', '10.255.255.255', '172.31.0.1', '239.1.1.1', '254.0.0.1',
|
||||
'4000::1', '100::1', 'f000::1', # reserved: Python 3.9's is_global says True for 4000::1
|
||||
'::ffff:10.0.0.1', '::ffff:127.0.0.1', '::ffff:8.8.8.8', # IPv4-mapped
|
||||
'::a00:1', '::8.8.8.8', # IPv4-compatible
|
||||
'64:ff9b::a00:1', '64:ff9b::808:808', # NAT64 well-known prefix
|
||||
'64:ff9b:1::a00:1', '64:ff9b:1::808:808', # NAT64 local-use: 3.9's is_global says True
|
||||
'2002:a00:1::1', '2002:808:808::1', # 6to4
|
||||
'2001:0:4136:e378:8000:63bf:3fff:fdd2', # Teredo
|
||||
'fec0::1', 'feff::1', # site-local: Python 3.9's is_global says True
|
||||
'fc00::1', 'fd12:3456::1', 'fe80::1', 'ff00::1', 'ff0e::1', '8000::1', 'c000::1',
|
||||
'2001:db8::1', '3fff::1', # documentation
|
||||
'2001:10::1', '2001:20::1', '2001:2::1'): # ORCHID, ORCHIDv2, benchmarking
|
||||
self.assertFalse(_web.public_address(ip), ip)
|
||||
self.assertEqual(_web.resolve_public('storage.itch-cdn.example'), [('151.101.1.1', 443)])
|
||||
self.assertEqual(_web.resolve_public('dual.example'), [('2a04:4e42::81', 443), ('151.101.1.1', 443)])
|
||||
|
||||
def test_a_storage_url_carrying_the_key_is_refused(self):
|
||||
self.connect()
|
||||
for location in ('https://storage.itch-cdn.example/a.apk?api_key=' + KEY,
|
||||
'https://storage.itch-cdn.example/a.apk?API_KEY=someone-elses',
|
||||
'https://storage.itch-cdn.example/a.apk?apikey=x',
|
||||
'https://storage.itch-cdn.example/' + KEY + '/a.apk',
|
||||
'https://storage.itch-cdn.example/a.apk#' + KEY[:10],
|
||||
'https://storage.itch-cdn.example/a.apk?x=' + urllib.parse.quote('%' + KEY),
|
||||
'https://' + KEY.lower() + '.itch-cdn.example/a.apk'):
|
||||
self.storage(location)
|
||||
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused') as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertNotIn(KEY.lower(), str(caught.exception).lower())
|
||||
self.web.route('api.itch.io/uploads/503/download',
|
||||
Reply('https://api.itch.io/uploads/503/download', 200,
|
||||
{'url': 'https://storage.itch-cdn.example/a.apk?api_key=' + KEY},
|
||||
{'Content-Type': 'application/json'}))
|
||||
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertEqual(self.connected, [])
|
||||
# A storage URL's own signature or token parameters are fine.
|
||||
self.storage('https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?token=abc&Signature=x')
|
||||
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
|
||||
|
||||
def test_a_later_redirect_carrying_the_key_is_refused_before_connecting(self):
|
||||
self.connect()
|
||||
bad = 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?api_key=' + KEY
|
||||
for hops in (['https://other-cdn.example/hop1'], # 2nd hop carries the key
|
||||
['https://other-cdn.example/hop1', 'https://other-cdn.example/hop2']): # 3rd hop
|
||||
self.connected.clear()
|
||||
self.storage(hops[0])
|
||||
for here, there in zip(hops, hops[1:] + [bad]):
|
||||
path = urllib.parse.urlsplit(here).path
|
||||
self.web.route('other-cdn.example' + path, Reply(here, 302, b'', {'Location': there}))
|
||||
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused') as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertNotIn(KEY, str(caught.exception))
|
||||
self.assertEqual([h for h, _ in self.connected], ['other-cdn.example'] * len(hops))
|
||||
self.assertFalse(any(KEY in u for u, _ in self.web.calls))
|
||||
# The check runs on every hop before the name is even looked up.
|
||||
seen = []
|
||||
with patch.object(_web, 'resolve_public', side_effect=AssertionError('looked up')):
|
||||
with self.assertRaisesRegex(SourceError, 'no thanks'):
|
||||
_web.public_apk('https://storage.itch-cdn.example/a.apk', 'itch.io',
|
||||
check=lambda u: seen.append(u) or (_ for _ in ()).throw(SourceError('no thanks')))
|
||||
self.assertEqual(seen, ['https://storage.itch-cdn.example/a.apk'])
|
||||
|
||||
def test_a_partial_match_before_the_key_cannot_hide_its_start(self):
|
||||
key = 'abcdefghABCDEFGHi1234567890'
|
||||
for text in (key[:8].upper() + key.upper(), key[:9] + key, 'xx' + key[:8] + key + 'yy'):
|
||||
out = account._clean(text, key)
|
||||
self.assertNotIn('1234567890', out, (text, out))
|
||||
import time
|
||||
t0 = time.monotonic(); account._clean(('abcdefgh' * 12500)[:100000], key)
|
||||
self.assertLess(time.monotonic() - t0, 2.0)
|
||||
|
||||
def test_redaction_catches_every_spelling_of_the_key(self):
|
||||
hexed = ''.join('%%%02X' % ord(c) for c in KEY)
|
||||
for spelling in (KEY, KEY.lower(), KEY.upper(), hexed, hexed.lower(),
|
||||
KEY[:5] + hexed[15:30] + KEY[10:], # partly encoded
|
||||
KEY[:9], hexed[:30].lower(), # starts of it, 8+ characters
|
||||
''.join('%%%02x' % ord(c) for c in KEY.lower()), # encoded after a case change
|
||||
''.join('%%%02X' % ord(c) for c in KEY.swapcase()),
|
||||
'%%%02x' % ord(KEY[0].swapcase()) + KEY[1:]): # one encoded, recased letter
|
||||
cleaned = account._clean('before ' + spelling + ' after', KEY)
|
||||
self.assertEqual(cleaned, 'before [API key] after', spelling)
|
||||
double = ''.join('%%25%02X' % ord(c) for c in KEY) # percent-encoded twice
|
||||
for spelling in (double, double.lower(), double.upper(), double[:5 * 9], # full, hex recased, 9-char start
|
||||
''.join('%%25%02x' % ord(c) for c in KEY.lower()), # the key recased, then double-encoded
|
||||
KEY[:4] + double[20:45] + hexed[27:33] + KEY[11:]): # literal, double and single mixed
|
||||
self.assertEqual(account._clean('before ' + spelling + ' after', KEY), 'before [API key] after', spelling)
|
||||
self.assertEqual(account._clean(double[:5 * 7], KEY), double[:5 * 7]) # 7 characters: kept
|
||||
self.assertTrue(account._carries_key('https://cdn.example/a?x=' + double, KEY))
|
||||
self.assertTrue(account._carries_key('https://cdn.example/' + double[:5 * 8].lower() + '/a', KEY))
|
||||
self.assertEqual(account._clean('TESTKEY is short', KEY), 'TESTKEY is short') # 7 characters: kept
|
||||
self.assertEqual(account._clean(KEY + KEY.lower(), KEY), '[API key][API key]')
|
||||
for url in ('https://cdn.example/a?x=' + hexed, 'https://cdn.example/a?x=' + hexed.lower(),
|
||||
'https://cdn.example/' + KEY.upper() + '/a', 'https://cdn.example/a?x=' + urllib.parse.quote_plus(KEY)):
|
||||
self.assertTrue(account._carries_key(url, KEY), url)
|
||||
self.assertFalse(account._carries_key('https://cdn.example/a?token=abc&Signature=x', KEY))
|
||||
# Storage errors carrying an encoded or lowercased key lose it, keeping their kind.
|
||||
self.connect()
|
||||
self.storage()
|
||||
with patch.object(_web, 'public_apk', side_effect=SourceLimited('slow ' + hexed.lower(), 42)):
|
||||
with self.assertRaises(SourceLimited) as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertEqual((str(caught.exception), caught.exception.retry_after), ('slow [API key]', 42))
|
||||
with patch.object(_web, 'public_apk', side_effect=SourceError('host ' + KEY.lower() + '.cdn')):
|
||||
with self.assertRaisesRegex(SourceError, r'^host \[API key\]\.cdn$'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
|
||||
def test_storage_errors_lose_the_key_but_keep_their_kind(self):
|
||||
self.connect()
|
||||
self.storage()
|
||||
with patch.object(_web, 'public_apk', side_effect=SourceLimited('slow down ' + KEY, 90)):
|
||||
with self.assertRaises(SourceLimited) as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertEqual((str(caught.exception), caught.exception.retry_after), ('slow down [API key]', 90))
|
||||
with patch.object(_web, 'public_apk', side_effect=SourceError('Could not find the download server ' + KEY)):
|
||||
with self.assertRaisesRegex(SourceError, r'^Could not find the download server \[API key\]$') as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertIsNone(caught.exception.__cause__)
|
||||
self.assertTrue(caught.exception.__suppress_context__)
|
||||
|
||||
def test_broken_http_responses_are_readable_and_lose_the_key(self):
|
||||
self.connect()
|
||||
|
||||
class Truncated(Reply):
|
||||
def read(self, *args):
|
||||
raise http.client.IncompleteRead(b'{"owned_keys": [' + KEY.encode(), 1000)
|
||||
cases = [http.client.BadStatusLine('HTTP/1.1 ' + KEY + ' oops'),
|
||||
lambda req, q: Truncated(req.full_url, 200, b''),
|
||||
http.client.RemoteDisconnected('closed after ' + KEY)]
|
||||
for case in cases:
|
||||
account._forget()
|
||||
self.web.route('api.itch.io/profile/owned-keys', case)
|
||||
with self.assertRaisesRegex(SourceError, '^Could not reach itch.io: ') as caught:
|
||||
account.owned_keys()
|
||||
self.assertNotIn(KEY, str(caught.exception))
|
||||
self.assertIsNone(caught.exception.__cause__)
|
||||
self.assertTrue(caught.exception.__suppress_context__)
|
||||
self.assertTrue(account.connected())
|
||||
# The same on the download request, for each kind of answer it reads.
|
||||
for case in (http.client.BadStatusLine('HTTP/1.1 ' + KEY),
|
||||
lambda req, q: Truncated(req.full_url, 200, b'', {'Content-Type': 'application/json'}),
|
||||
lambda req, q: Truncated(req.full_url, 200, b'', {'Content-Type': 'application/octet-stream'})):
|
||||
self.web.route('api.itch.io/uploads/503/download', case)
|
||||
with self.assertRaises(SourceError) as caught:
|
||||
account.download(503, 9001)
|
||||
self.assertNotIn(KEY, str(caught.exception))
|
||||
self.assertRegex(str(caught.exception), '^Could not (reach itch.io|download APK): ')
|
||||
|
||||
def test_connecting_shares_one_deadline_across_addresses(self):
|
||||
clock, deadlines = [1000.0], []
|
||||
|
||||
def slow(host, address, timeout, deadline):
|
||||
deadlines.append(deadline)
|
||||
clock[0] += 7 # each address hangs for 7 s before failing
|
||||
raise socket.timeout('timed out')
|
||||
with patch.object(_web.time, 'monotonic', lambda: clock[0]), patch.object(_web, '_connect', slow):
|
||||
with self.assertRaises(OSError):
|
||||
_web._public_connection('many.example', 60)
|
||||
self.assertEqual(len(set(deadlines)), 1) # one deadline for every attempt
|
||||
self.assertEqual(deadlines[0], 1000.0 + _web.CONNECT_BUDGET)
|
||||
self.assertEqual(len(deadlines), 3) # 8 addresses, but only 21 s of trying, not 56
|
||||
self.assertLessEqual(clock[0] - 1000.0, _web.CONNECT_BUDGET + 7)
|
||||
deadlines.clear()
|
||||
clock[0] = 1000.0
|
||||
with patch.object(_web.time, 'monotonic', lambda: clock[0]), patch.object(_web, '_connect', slow):
|
||||
with self.assertRaises(OSError):
|
||||
_web._public_connection('many.example', 5) # the caller's shorter timeout wins
|
||||
self.assertEqual(deadlines, [1005.0])
|
||||
|
||||
def test_a_stalled_handshake_stops_at_the_deadline(self):
|
||||
# A local server that accepts and then says nothing: the TLS handshake never completes.
|
||||
server = socket.socket()
|
||||
server.bind(('127.0.0.1', 0))
|
||||
server.listen(1)
|
||||
self.addCleanup(server.close)
|
||||
timeouts = []
|
||||
real_settimeout = socket.socket.settimeout
|
||||
|
||||
def settimeout(sock, value):
|
||||
timeouts.append(value)
|
||||
return real_settimeout(sock, value)
|
||||
start = time.monotonic()
|
||||
# No thread is left behind to shut the connection later: the deadline is only socket timeouts.
|
||||
with patch.object(_web.threading, 'Timer', side_effect=AssertionError('watchdog thread')), \
|
||||
patch.object(socket.socket, 'settimeout', settimeout):
|
||||
with self.assertRaises(OSError):
|
||||
REAL_CONNECT('storage.itch-cdn.example', server.getsockname(), 60, start + 0.5)
|
||||
self.assertLess(time.monotonic() - start, 3)
|
||||
self.assertTrue(timeouts and all(0 < t <= 0.5 for t in timeouts), timeouts) # what's left, each step
|
||||
with patch.object(_web.socket, 'create_connection', side_effect=AssertionError('connected')):
|
||||
with self.assertRaises(socket.timeout): # a deadline already gone: no connection attempt at all
|
||||
REAL_CONNECT('storage.itch-cdn.example', server.getsockname(), 60, time.monotonic() - 1)
|
||||
|
||||
def test_an_unreachable_address_falls_back_to_the_next_checked_one(self):
|
||||
self.connect()
|
||||
self.storage('https://dual.example/upload/503/OrbitGarden-Quest.apk')
|
||||
self.web.route('dual.example/upload/503/OrbitGarden-Quest.apk', Reply('https://dual.example/', 200, apk_bytes()))
|
||||
self.unreachable.add('2a04:4e42::81')
|
||||
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
|
||||
self.assertEqual(self.connected, [('dual.example', ('2a04:4e42::81', 443)), ('dual.example', ('151.101.1.1', 443))])
|
||||
self.unreachable.add('151.101.1.1')
|
||||
with self.assertRaisesRegex(SourceError, '^Could not download APK: Network is unreachable$'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
|
||||
def test_storage_refusals_are_download_errors_not_rate_limits(self):
|
||||
self.connect()
|
||||
self.storage()
|
||||
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk',
|
||||
Reply('https://storage.itch-cdn.example/x', 403, b'denied'))
|
||||
with self.assertRaisesRegex(SourceError, r"itch.io's download server refused the file \(HTTP 403\)") as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertNotIsInstance(caught.exception, SourceLimited)
|
||||
self.assertTrue(account.connected())
|
||||
self.assertEqual(_web.wait_time('https://api.itch.io/'), 0)
|
||||
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk',
|
||||
Reply('https://storage.itch-cdn.example/x', 429, b'', {'Retry-After': '60'}))
|
||||
with self.assertRaises(SourceLimited):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
|
||||
def test_a_key_revoked_after_details_were_cached_is_removed_at_download(self):
|
||||
self.connect()
|
||||
itch.details(self.owned_source(), '341190') # uploads and owned keys now cached
|
||||
for code in (401, 403):
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/uploads/503/download', Reply('https://api.itch.io/uploads/503/download', code, b''))
|
||||
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', code, b''))
|
||||
with self.assertRaisesRegex(account.BadKey, 'no longer accepts the saved API key'):
|
||||
account.download(503, 9001)
|
||||
self.assertFalse(account.path().exists())
|
||||
self.web.route('api.itch.io/profile', {'user': {'username': 'player'}})
|
||||
# The key still works, only this download is refused: keep the key, say so.
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/uploads/503/download', Reply('https://api.itch.io/uploads/503/download', 403, b''))
|
||||
with self.assertRaisesRegex(SourceError, r'refused this request \(HTTP 403\)') as caught:
|
||||
account.download(503, 9001)
|
||||
self.assertNotIsInstance(caught.exception, (account.BadKey, SourceLimited))
|
||||
self.assertTrue(account.connected())
|
||||
|
||||
def test_a_disconnect_during_the_key_check_wins(self):
|
||||
checking, release = threading.Event(), threading.Event()
|
||||
|
||||
def slow_profile(req, query):
|
||||
checking.set()
|
||||
release.wait(5)
|
||||
return {'user': {'username': 'player'}}
|
||||
self.web.route('api.itch.io/profile', slow_profile)
|
||||
outcome = {}
|
||||
|
||||
def run():
|
||||
try:
|
||||
outcome['result'] = account.connect(KEY)
|
||||
except SourceError as e:
|
||||
outcome['error'] = e
|
||||
worker = threading.Thread(target=run)
|
||||
worker.start()
|
||||
self.assertTrue(checking.wait(5))
|
||||
account.disconnect()
|
||||
release.set()
|
||||
worker.join(5)
|
||||
self.assertRegex(str(outcome.get('error')), 'changed while the key was being checked; nothing was saved')
|
||||
self.assertFalse(account.path().exists())
|
||||
self.assertFalse(account.connected())
|
||||
|
||||
def test_an_old_refusal_does_not_undo_a_reconnect_with_the_same_key(self):
|
||||
self.connect()
|
||||
listing, release, profile_ok = threading.Event(), threading.Event(), [True]
|
||||
|
||||
def slow_owned(req, query):
|
||||
listing.set()
|
||||
release.wait(5)
|
||||
return Reply(req.full_url, 401, b'')
|
||||
self.web.route('api.itch.io/profile/owned-keys', slow_owned)
|
||||
self.web.route('api.itch.io/profile', lambda req, q: {'user': {'username': 'player'}} if profile_ok[0]
|
||||
else Reply(req.full_url, 401, b''))
|
||||
outcome = {}
|
||||
|
||||
def run():
|
||||
try:
|
||||
account.owned_keys()
|
||||
except SourceError as e:
|
||||
outcome['error'] = e
|
||||
worker = threading.Thread(target=run)
|
||||
worker.start()
|
||||
self.assertTrue(listing.wait(5))
|
||||
account.disconnect()
|
||||
account.connect(KEY) # same key, connected again while the old listing waits
|
||||
profile_ok[0] = False # the old listing's confirmation now sees a refusal
|
||||
release.set()
|
||||
worker.join(5)
|
||||
self.assertIsInstance(outcome.get('error'), account.BadKey)
|
||||
self.assertTrue(account.path().exists()) # the fresh connection survives
|
||||
self.assertEqual(json.loads(account.path().read_text())['key'], KEY)
|
||||
|
||||
def test_redacted_text_is_not_mistaken_for_a_key_error(self):
|
||||
self.connect()
|
||||
before = len(self.web.urls('api.itch.io/profile'))
|
||||
self.web.route('api.itch.io/profile/owned-keys', {'errors': ['Invalid request: ' + KEY]})
|
||||
with self.assertRaisesRegex(SourceError, r'^itch.io: Invalid request: \[API key\]$') as caught:
|
||||
account.owned_keys()
|
||||
self.assertNotIsInstance(caught.exception, account.BadKey)
|
||||
self.assertTrue(account.connected())
|
||||
self.assertEqual(len(self.web.urls('api.itch.io/profile')), before) # not even a key check needed
|
||||
|
||||
def test_an_invalid_key_error_elsewhere_is_confirmed_before_deleting(self):
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/profile/owned-keys', {'errors': ['invalid key']})
|
||||
with self.assertRaisesRegex(SourceError, '^itch.io: invalid key$') as caught:
|
||||
account.owned_keys() # /profile still accepts the key: keep it
|
||||
self.assertNotIsInstance(caught.exception, account.BadKey)
|
||||
self.assertTrue(account.connected())
|
||||
account._forget()
|
||||
self.web.route('api.itch.io/profile', {'errors': ['invalid key']})
|
||||
with self.assertRaisesRegex(account.BadKey, 'no longer accepts'):
|
||||
account.owned_keys()
|
||||
self.assertFalse(account.connected())
|
||||
|
||||
def test_concurrent_connects_never_share_a_temporary_file(self):
|
||||
made = []
|
||||
real = account.tempfile.mkstemp
|
||||
|
||||
def mkstemp(*args, **kwargs):
|
||||
fd, name = real(*args, **kwargs)
|
||||
made.append(name)
|
||||
if os.name == 'posix':
|
||||
self.assertEqual(os.stat(name).st_mode & 0o777, 0o600)
|
||||
return fd, name
|
||||
with patch.object(account.tempfile, 'mkstemp', mkstemp):
|
||||
workers = [threading.Thread(target=lambda k=k: self.swallow(account.connect, k)) for k in (KEY, OTHER) * 3]
|
||||
for w in workers:
|
||||
w.start()
|
||||
for w in workers:
|
||||
w.join(5)
|
||||
self.assertEqual(len(made), len(set(made)))
|
||||
self.assertIn(json.loads(account.path().read_text())['key'], (KEY, OTHER))
|
||||
self.assertEqual([p.name for p in account.path().parent.iterdir()], ['itch-account.json'])
|
||||
|
||||
@staticmethod
|
||||
def swallow(fn, *args):
|
||||
try:
|
||||
fn(*args)
|
||||
except SourceError:
|
||||
pass # a connect overtaken by another one refuses to save: fine here
|
||||
|
||||
def test_existing_hosts_still_strip_authorization_on_redirect(self):
|
||||
req = urllib.request.Request('https://api.github.com/a', headers={'Authorization': 'Bearer x'})
|
||||
nxt = _web.Redirect(('api.github.com',)).redirect_request(req, None, 302, 'Found', {}, 'https://api.github.com/b')
|
||||
self.assertFalse(nxt.has_header('Authorization'))
|
||||
with self.assertRaises(SourceError):
|
||||
_web.Redirect(('api.github.com',)).redirect_request(req, None, 302, 'Found', {}, 'https://evil.example/b')
|
||||
|
||||
def test_install_uses_the_normal_apk_install_path(self):
|
||||
import frame_android
|
||||
self.connect()
|
||||
self.storage()
|
||||
calls = []
|
||||
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
|
||||
with zipfile.ZipFile(apk_path) as z:
|
||||
self.assertIn('AndroidManifest.xml', z.namelist())
|
||||
calls.append((name, source))
|
||||
return {'package': 'com.maker.orbit', 'message': 'Installed'}
|
||||
with patch.object(frame_android, 'install', install):
|
||||
result = search.install(itch.OWNED, '341190')
|
||||
self.assertEqual(calls, [('Orbit Garden VR', 'itch.io (my purchases)')])
|
||||
self.assertEqual(result['package'], 'com.maker.orbit')
|
||||
|
||||
# -- errors never carry the key ------------------------------------------------
|
||||
|
||||
def test_errors_are_readable_and_never_contain_the_key(self):
|
||||
self.connect()
|
||||
cases = [
|
||||
(Reply('https://api.itch.io/profile/owned-keys', 429, b'', {'Retry-After': '120'}),
|
||||
SourceLimited, '^itch.io is limiting requests; try again in 2 minutes$'),
|
||||
(Reply('https://api.itch.io/profile/owned-keys', 503, b''), SourceError, 'HTTP error 503; try again later'),
|
||||
(urllib.error.URLError('connect to api.itch.io with ' + KEY + ' failed'), SourceError,
|
||||
r'^Could not reach itch.io: connect to api.itch.io with \[API key\] failed$'),
|
||||
(OSError('timed out'), SourceError, '^Could not reach itch.io: timed out$'),
|
||||
(Reply('https://api.itch.io/profile/owned-keys', 200, b'<html>'), SourceError, 'invalid response'),
|
||||
({'errors': ['echo ' + KEY]}, SourceError, r'^itch.io: echo \[API key\]$'),
|
||||
# The key straddles the 200-character cut: no piece of it may survive.
|
||||
({'errors': ['x' * 180, KEY]}, SourceError, r'^itch.io: x{180}; \[API key\]$'),
|
||||
({'errors': ['x' * 190 + KEY]}, SourceError, r'^itch.io: x{190}\[API key\]$'),
|
||||
]
|
||||
self.assertEqual(account._clean('cut at ' + KEY[:9], KEY), 'cut at [API key]') # a truncated key's start
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
for reply, kind, message in cases:
|
||||
account._forget()
|
||||
_web._limited.clear()
|
||||
self.web.route('api.itch.io/profile/owned-keys', reply)
|
||||
with self.assertRaisesRegex(kind, message) as caught:
|
||||
account.owned_keys()
|
||||
self.assertNotIn(KEY, str(caught.exception))
|
||||
self.assertIsNone(caught.exception.__cause__)
|
||||
self.assertTrue(account.connected()) # not the key's fault: stays connected
|
||||
# The store's search reports the failure as a non-fatal source status.
|
||||
account._forget()
|
||||
result = search.search('', source=itch.OWNED)
|
||||
self.assertEqual(result['sources'][0]['status'], 'error')
|
||||
self.assertNotIn(KEY, json.dumps(result) + stderr.getvalue())
|
||||
|
||||
def test_server_api_connects_and_never_echoes_the_key(self):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
|
||||
def request(method, path, body=None):
|
||||
c = http.client.HTTPConnection('127.0.0.1', httpd.server_port)
|
||||
c.request(method, path, json.dumps(body) if body is not None else None,
|
||||
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
|
||||
r = c.getresponse()
|
||||
out = r.status, r.read().decode()
|
||||
c.close()
|
||||
return out
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', 401, b''))
|
||||
bad = request('POST', '/api/sources', {'action': 'itch-connect', 'key': KEY})
|
||||
self.web.route('api.itch.io/profile', {'user': {'username': 'player'}})
|
||||
good = request('POST', '/api/sources', {'action': 'itch-connect', 'key': KEY})
|
||||
listed = request('GET', '/api/sources')
|
||||
empty = request('POST', '/api/sources', {'action': 'itch-connect', 'key': ' '})
|
||||
gone = request('POST', '/api/sources', {'action': 'itch-disconnect'})
|
||||
self.assertEqual(bad[0], 400)
|
||||
self.assertIn('did not accept the API key', json.loads(bad[1])['error'])
|
||||
self.assertEqual(good[0], 200)
|
||||
self.assertEqual(json.loads(good[1])['itch'], {'connected': True, 'username': 'player'})
|
||||
self.assertEqual(json.loads(listed[1])['itch'], {'connected': True, 'username': 'player'})
|
||||
self.assertIn(itch.OWNED, [s['id'] for s in json.loads(listed[1])['sources']])
|
||||
self.assertEqual(empty[0], 400)
|
||||
self.assertEqual(json.loads(gone[1])['itch'], {'connected': False, 'username': None})
|
||||
self.assertFalse(account.path().exists())
|
||||
for text in (bad[1], good[1], listed[1], empty[1], gone[1], stderr.getvalue()):
|
||||
self.assertNotIn(KEY, text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -394,7 +394,7 @@ class ServerJobs(unittest.TestCase):
|
||||
places = s._tmp_places()
|
||||
self.assertEqual({p for _, p in places}, {s.WEB_TMP_PREFIX, s.frame_titles.TMP_PREFIX,
|
||||
s.frame_android.TMP_PREFIX, s.frame_agent.TMP_PREFIX,
|
||||
s.apk_fdroid.TMP_PREFIX, s.ANDROID_BACKUP_TMP})
|
||||
s.apk_fdroid.TMP_PREFIX})
|
||||
for folder, prefix in places:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-", dir=folder)
|
||||
|
||||
@@ -32,7 +32,9 @@ Entry dict (missing facts are None, never guessed)
|
||||
Rules
|
||||
-----
|
||||
- Only sources that distribute APKs with the developer's consent: free listings,
|
||||
never paid apps re-hosted, no licence or entitlement workarounds.
|
||||
never paid apps re-hosted, no licence or entitlement workarounds. The one paid
|
||||
exception is a store's own download of a game the user bought there: a source
|
||||
marked 'owned': True whose entries carry 'owned': True (itch.io purchases).
|
||||
- Honour each site's terms and robots rules; if automated download isn't allowed,
|
||||
return entries with 'downloadable': False and a 'page' link instead.
|
||||
- Cache indexes under frame_host.cache_dir('apk-sources'); send a clear
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""The user's own itch.io account: an API key, the games it owns and their Android uploads.
|
||||
|
||||
Opt-in (store > sources > Connect itch.io). The key is kept in
|
||||
frame_host.data_dir('itch-account.json') with mode 0600 and is only ever sent to
|
||||
https://api.itch.io, in an Authorization header. Downloads end on itch.io's storage
|
||||
host, fetched without the key from a checked public address (_web.public_apk).
|
||||
The key never goes into a URL, an error, a log line or an API response. Owned-key
|
||||
listings stay in memory, never in the disk cache.
|
||||
API: https://itch.io/docs/api/serverside. See docs/apk-repos.md.
|
||||
"""
|
||||
import hashlib, http.client, json, os, re, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
|
||||
|
||||
import frame_host
|
||||
from . import SourceError, SourceLimited, _web
|
||||
|
||||
API = 'https://api.itch.io'
|
||||
HOSTS = ('api.itch.io',)
|
||||
NAME = 'itch.io'
|
||||
TTL = 600 # owned keys and uploads are re-read after ten minutes
|
||||
PER_PAGE = 500 # the documented maximum for /profile/owned-keys
|
||||
PAGES = 40 # stop after 20,000 owned keys
|
||||
KEY = re.compile(r'[A-Za-z0-9_.-]{16,256}')
|
||||
BAD_KEY = ('itch.io did not accept the API key. Create one in itch.io settings > '
|
||||
'API keys and connect again')
|
||||
REVOKED = ('itch.io no longer accepts the saved API key, so it was removed. '
|
||||
'Create a new one in itch.io settings > API keys and connect again')
|
||||
REDIRECTS = (301, 302, 303, 307, 308)
|
||||
# Everything a connection or response can raise. http.client's errors (IncompleteRead,
|
||||
# BadStatusLine...) are not OSErrors, and BadStatusLine repeats what the server sent.
|
||||
NETWORK = (OSError, ValueError, http.client.HTTPException)
|
||||
_lock = threading.Lock() # the cache
|
||||
_cache = {} # (generation, key digest, what) -> (time, value)
|
||||
_account = threading.Lock() # saving and deleting the key file
|
||||
_generation = [0] # bumped by every save and delete: a connect that started earlier doesn't save
|
||||
|
||||
|
||||
class BadKey(SourceError):
|
||||
"""itch.io refused the key: wrong, revoked or deleted."""
|
||||
|
||||
|
||||
def path():
|
||||
return frame_host.data_dir('itch-account.json')
|
||||
|
||||
|
||||
def _load():
|
||||
try:
|
||||
data = json.loads(path().read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict) or not isinstance(data.get('key'), str) or not KEY.fullmatch(data['key']):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def status():
|
||||
"""{'connected', 'username'}: what the page may know. Never the key; no network."""
|
||||
data = _load()
|
||||
return {'connected': bool(data), 'username': data.get('username') if data else None}
|
||||
|
||||
|
||||
def connected():
|
||||
return _load() is not None
|
||||
|
||||
|
||||
def _key():
|
||||
data = _load()
|
||||
if not data:
|
||||
raise SourceError('Connect your itch.io account first (store > sources > Connect itch.io)')
|
||||
return data['key']
|
||||
|
||||
|
||||
def _save(key, username):
|
||||
"""Write the key file through a fresh 0600 temporary file (mkstemp), then move it into place."""
|
||||
target = path()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(target.parent), prefix='.itch-account-', suffix='.tmp')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
os.chmod(tmp, 0o600) # mkstemp already does on POSIX; say so for every platform
|
||||
json.dump({'key': key, 'username': username}, f)
|
||||
os.replace(tmp, str(target))
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
|
||||
def _forget():
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
def _clean(message, key):
|
||||
"""message without the key, or any start of it (8+ characters, as truncation leaves), in
|
||||
any letter case, with any of its characters written literally, percent-encoded (%4B or
|
||||
%4b) or percent-encoded twice (%254B, %254b). The only redaction used here: every message
|
||||
that might hold the key goes through it. (The key's characters are letters, digits and
|
||||
_.-, so +/space encoding can't change it.)"""
|
||||
if not key:
|
||||
return message
|
||||
# Each character: itself, or the percent-encoding (once or twice) of either of its letter cases.
|
||||
tokens = ['(?:%s)' % '|'.join([re.escape(c)] + ['%%(?:25)?%02x' % ord(v) for v in sorted({c.lower(), c.upper()})])
|
||||
for c in key]
|
||||
start, rest = re.compile(''.join(tokens[:8]), re.I), [re.compile(t, re.I) for t in tokens[8:]]
|
||||
spans, pos = [], 0
|
||||
while True:
|
||||
found = start.search(message, pos)
|
||||
if not found:
|
||||
break
|
||||
end = found.end()
|
||||
for token in rest: # as much more of the key as follows
|
||||
more = token.match(message, end)
|
||||
if not more:
|
||||
break
|
||||
end = more.end()
|
||||
spans.append((found.start(), end))
|
||||
# Look again from the next character, not the end: a partial match just before the
|
||||
# real key can overlap its start. Overlapping spans merge below.
|
||||
pos = found.start() + 1
|
||||
out, pos = [], 0
|
||||
for a, b in spans: # in order of start; only overlapping spans merge
|
||||
if out and a < pos:
|
||||
pos = max(pos, b)
|
||||
continue
|
||||
out += [message[pos:a], '[API key]']
|
||||
pos = b
|
||||
return ''.join(out) + message[pos:]
|
||||
|
||||
|
||||
class _Stay(urllib.request.HTTPRedirectHandler):
|
||||
"""Don't follow: the caller decides where a redirect may go, and never sends the key there."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def _open(url, key, accept='application/json'):
|
||||
"""GET url on api.itch.io with the key. Redirects come back as HTTPError 30x."""
|
||||
_web.checked_url(url, HOSTS)
|
||||
wait = _web.wait_time(url)
|
||||
if wait:
|
||||
raise _web.limited_error(NAME, wait)
|
||||
return urllib.request.build_opener(_Stay()).open(urllib.request.Request(
|
||||
url, headers={'User-Agent': _web.UA, 'Authorization': 'Bearer ' + key, 'Accept': accept}), timeout=60)
|
||||
|
||||
|
||||
def _refused(code, key, api_path, message=None):
|
||||
"""A refusal that may be about the key (401/403, or an "invalid key" error): BadKey only
|
||||
once /profile confirms the key itself is refused; otherwise it's this request (a game or
|
||||
upload this purchase doesn't cover), and the key stays."""
|
||||
if api_path != '/profile':
|
||||
_get('/profile', key) # raises BadKey if the key is the problem
|
||||
raise SourceError(message or 'itch.io refused this request (HTTP %d); your purchase may not include it' % code)
|
||||
raise BadKey(BAD_KEY)
|
||||
|
||||
|
||||
def _network_error(e, key):
|
||||
reason = getattr(e, 'reason', None) or e
|
||||
return SourceError(_clean('Could not reach itch.io: ' + str(reason), key))
|
||||
|
||||
|
||||
BAD_KEY_TEXT = re.compile(r'\b(invalid|revoked|expired)\b.*\bkey\b|\bkey\b.*\b(invalid|revoked|expired)\b', re.I)
|
||||
|
||||
|
||||
def _checked(raw, key, api_path):
|
||||
"""Decoded JSON object from api.itch.io, or SourceError/BadKey from its errors array."""
|
||||
if len(raw) > 8 * 1024 * 1024:
|
||||
raise SourceError('itch.io response is too large')
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
raise SourceError('itch.io returned an invalid response') from None
|
||||
if not isinstance(data, dict):
|
||||
raise SourceError('itch.io returned an invalid response')
|
||||
errors = data.get('errors')
|
||||
if errors: # documented: errors is an array of strings
|
||||
messages = [str(m) for m in errors if isinstance(m, (str, int))]
|
||||
# Judged on itch.io's own words: redacted text says "[API key]", which would look like a key error.
|
||||
about_key = any(BAD_KEY_TEXT.search(m) and 'download key' not in m.lower() for m in messages)
|
||||
# Each message loses the key before joining and shortening, so no part of it survives the cut.
|
||||
text = _clean('; '.join(_clean(m, key) for m in messages)[:200], key)
|
||||
if about_key:
|
||||
_refused(None, key, api_path, 'itch.io: ' + text)
|
||||
raise SourceError('itch.io: ' + (text or 'request refused'))
|
||||
return data
|
||||
|
||||
|
||||
def _get(api_path, key, params=None):
|
||||
"""JSON from api.itch.io; SourceError with a readable message (never the key) on failure."""
|
||||
url = API + api_path + ('?' + urllib.parse.urlencode(params) if params else '')
|
||||
try:
|
||||
with _open(url, key) as r:
|
||||
raw = r.read(8 * 1024 * 1024 + 1)
|
||||
except urllib.error.HTTPError as e:
|
||||
e.close()
|
||||
if e.code == 429:
|
||||
raise _web.limited_error(NAME, _web.throttle(url, e.headers)) from None
|
||||
if e.code in (401, 403):
|
||||
_refused(e.code, key, api_path)
|
||||
raise SourceError('itch.io returned HTTP error %d; try again later' % e.code) from None
|
||||
except SourceError:
|
||||
raise
|
||||
except NETWORK as e:
|
||||
raise _network_error(e, key) from None
|
||||
return _checked(raw, key, api_path)
|
||||
|
||||
|
||||
def _cached(key, what, load):
|
||||
slot = (_generation[0], hashlib.sha256(key.encode()).hexdigest(), what)
|
||||
with _lock:
|
||||
hit = _cache.get(slot)
|
||||
if hit and time.time() - hit[0] < TTL:
|
||||
return hit[1]
|
||||
value = load()
|
||||
with _lock:
|
||||
_cache[slot] = (time.time(), value)
|
||||
return value
|
||||
|
||||
|
||||
def _use(call):
|
||||
"""Run call(key); a key itch.io now refuses is removed, so the account shows disconnected."""
|
||||
with _account: # the key and the connection it belongs to, read together
|
||||
key, generation = _key(), _generation[0]
|
||||
try:
|
||||
return call(key)
|
||||
except BadKey:
|
||||
with _account:
|
||||
# Only the connection this call started with: a reconnect meanwhile (even with
|
||||
# the same key) has been checked since, and is not undone by this older verdict.
|
||||
if _generation[0] == generation and (_load() or {}).get('key') == key:
|
||||
_delete()
|
||||
raise BadKey(REVOKED) from None
|
||||
|
||||
|
||||
def connect(key):
|
||||
"""Check key with itch.io (GET /profile), then save it. A refused key is never saved, and
|
||||
nor is one whose check finished after a disconnect or another connect."""
|
||||
key = (key or '').strip() if isinstance(key, str) else ''
|
||||
if not KEY.fullmatch(key):
|
||||
raise BadKey('That does not look like an itch.io API key. Copy it from itch.io settings > API keys')
|
||||
with _account:
|
||||
started = _generation[0]
|
||||
user = _get('/profile', key).get('user') # the network, outside the lock
|
||||
if not isinstance(user, dict):
|
||||
raise SourceError('itch.io returned an invalid profile')
|
||||
username = user.get('username') if isinstance(user.get('username'), str) else None
|
||||
with _account:
|
||||
if _generation[0] != started:
|
||||
raise SourceError('The itch.io connection changed while the key was being checked; '
|
||||
'nothing was saved. Try again')
|
||||
_generation[0] += 1
|
||||
_save(key, username)
|
||||
_forget()
|
||||
return {'message': 'Connected to itch.io' + (' as ' + username if username else ''), 'itch': status()}
|
||||
|
||||
|
||||
def _delete():
|
||||
"""Remove the key file; call with _account held."""
|
||||
_generation[0] += 1
|
||||
try:
|
||||
os.remove(str(path()))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
_forget()
|
||||
|
||||
|
||||
def disconnect():
|
||||
with _account:
|
||||
_delete()
|
||||
return {'message': 'Disconnected from itch.io; the API key was deleted from this computer', 'itch': status()}
|
||||
|
||||
|
||||
def owned_keys():
|
||||
"""Every download key the account owns, newest first (GET /profile/owned-keys, all pages)."""
|
||||
def load(key):
|
||||
keys = []
|
||||
for page in range(1, PAGES + 1):
|
||||
batch = _get('/profile/owned-keys', key, {'page': page, 'per_page': PER_PAGE}).get('owned_keys')
|
||||
# Documented: a page can hold fewer than per_page even when more follow; an empty one ends it.
|
||||
if not batch or not isinstance(batch, list):
|
||||
break
|
||||
keys.extend(k for k in batch if isinstance(k, dict))
|
||||
return keys
|
||||
return _use(lambda key: _cached(key, 'owned', lambda: load(key)))
|
||||
|
||||
|
||||
def _number(value):
|
||||
return value if type(value) is int and value > 0 else None
|
||||
|
||||
|
||||
def is_android(upload):
|
||||
"""An upload marked for Android, or an .apk file. The marker's field name is not in the
|
||||
public reference: itch.io's traits use p_<platform> (p_windows...), so p_android is assumed."""
|
||||
traits = upload.get('traits') if isinstance(upload.get('traits'), list) else []
|
||||
platforms = upload.get('platforms') if isinstance(upload.get('platforms'), dict) else {}
|
||||
filename = str(upload.get('filename') or '').lower()
|
||||
return 'p_android' in traits or upload.get('p_android') is True or 'android' in platforms \
|
||||
or filename.endswith('.apk')
|
||||
|
||||
|
||||
def android_uploads(uploads):
|
||||
"""Installable Android uploads, best first: .apk files, then full games, then newest."""
|
||||
chosen = []
|
||||
for u in uploads if isinstance(uploads, list) else []:
|
||||
if not isinstance(u, dict) or not _number(u.get('id')) or not is_android(u):
|
||||
continue
|
||||
traits = u.get('traits') if isinstance(u.get('traits'), list) else []
|
||||
if u.get('type') not in (None, 'default') or u.get('preorder') is True or 'preorder' in traits:
|
||||
continue # soundtracks, books, placeholders
|
||||
if u.get('storage') == 'external':
|
||||
continue # hosted on another site: not something itch.io can hand us
|
||||
chosen.append(u)
|
||||
chosen.sort(key=lambda u: str(u.get('updated_at') or u.get('created_at') or ''), reverse=True)
|
||||
chosen.sort(key=lambda u: (not str(u.get('filename') or '').lower().endswith('.apk'),
|
||||
u.get('demo') is True or 'demo' in (u.get('traits') or [])))
|
||||
return chosen
|
||||
|
||||
|
||||
def uploads(game_id, download_key_id):
|
||||
"""The game's uploads this download key may fetch (GET /games/ID/uploads)."""
|
||||
game_id, download_key_id = _number(game_id), _number(download_key_id)
|
||||
if not game_id or not download_key_id:
|
||||
raise SourceError('Invalid itch.io game')
|
||||
return _use(lambda key: _cached(key, ('uploads', game_id, download_key_id), lambda: _get(
|
||||
'/games/%d/uploads' % game_id, key, {'download_key_id': download_key_id}).get('uploads') or []))
|
||||
|
||||
|
||||
def download_url(upload_id, download_key_id):
|
||||
upload_id, download_key_id = _number(upload_id), _number(download_key_id)
|
||||
if not upload_id or not download_key_id:
|
||||
raise SourceError('Invalid itch.io upload')
|
||||
return API + '/uploads/%d/download?' % upload_id + urllib.parse.urlencode({'download_key_id': download_key_id})
|
||||
|
||||
|
||||
def _fetch(url, key):
|
||||
"""Ask api.itch.io for the file. Not checked against a live account, so this accepts each
|
||||
plausible answer: a redirect to storage, JSON naming the storage URL, or the file itself."""
|
||||
try:
|
||||
r = _open(url, key, 'application/vnd.android.package-archive, application/json;q=0.5, */*;q=0.1')
|
||||
except urllib.error.HTTPError as e:
|
||||
e.close()
|
||||
if e.code in REDIRECTS:
|
||||
target = e.headers.get('Location')
|
||||
elif e.code == 429:
|
||||
raise _web.limited_error(NAME, _web.throttle(url, e.headers)) from None
|
||||
elif e.code in (401, 403):
|
||||
_refused(e.code, key, '/uploads')
|
||||
else:
|
||||
raise SourceError('itch.io returned HTTP error %d for the download; try again later' % e.code) from None
|
||||
except SourceError:
|
||||
raise
|
||||
except NETWORK as e:
|
||||
raise _network_error(e, key) from None
|
||||
else:
|
||||
with r:
|
||||
if 'json' not in (r.headers.get('Content-Type') or '').lower():
|
||||
try:
|
||||
return _web.save_apk(r) # served by api.itch.io itself
|
||||
except NETWORK + (zipfile.BadZipFile,) as e:
|
||||
raise SourceError(_clean('Could not download APK: ' + str(e), key)) from None
|
||||
try:
|
||||
raw = r.read(1024 * 1024 + 1)
|
||||
except NETWORK as e:
|
||||
raise _network_error(e, key) from None
|
||||
target = _checked(raw, key, '/uploads').get('url')
|
||||
if not isinstance(target, str) or not target:
|
||||
raise SourceError('itch.io did not say where to download the file')
|
||||
def refuse(hop): # on the first URL and on every redirect after it, before any lookup
|
||||
if _carries_key(hop, key):
|
||||
raise SourceError('itch.io pointed the download at a URL carrying your API key; refused, '
|
||||
'so the key is not sent to the storage server')
|
||||
try:
|
||||
return _web.public_apk(urllib.parse.urljoin(url, target), NAME, check=refuse) # no key sent
|
||||
except SourceLimited as e:
|
||||
raise SourceLimited(_clean(str(e), key), e.retry_after) from None
|
||||
except SourceError as e:
|
||||
raise type(e)(_clean(str(e), key)) from None
|
||||
|
||||
|
||||
def _carries_key(url, key):
|
||||
"""True if url holds the key (or 8+ characters of its start), however cased or encoded, or
|
||||
an itch.io API-key parameter: go-itchio puts the key in ?api_key=, and storage must never
|
||||
get it. Other parameters (signatures, tokens) are the storage URL's own and are kept."""
|
||||
for form in (url, urllib.parse.unquote(url), urllib.parse.unquote_plus(url)):
|
||||
if _clean(form, key) != form:
|
||||
return True
|
||||
names = {name.lower() for name, _ in urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query,
|
||||
keep_blank_values=True)}
|
||||
return bool(names & {'api_key', 'apikey', 'api-key'})
|
||||
|
||||
|
||||
def download(upload_id, download_key_id):
|
||||
"""The APK: the key goes to api.itch.io only, never to the storage host."""
|
||||
url = download_url(upload_id, download_key_id)
|
||||
return _use(lambda key: _fetch(url, key))
|
||||
+159
-11
@@ -1,5 +1,6 @@
|
||||
"""Small HTTPS cache and APK downloader for public publisher sources."""
|
||||
import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
|
||||
import hashlib, http.client, ipaddress, os, shutil, socket, ssl, tempfile, threading, time
|
||||
import urllib.error, urllib.parse, urllib.request, zipfile
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import frame_host
|
||||
@@ -211,19 +212,16 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
|
||||
raise SourceError('Could not read source: ' + str(e)) from e
|
||||
|
||||
|
||||
def apk(url, hosts, digest=None, name=None):
|
||||
def save_apk(response, digest=None):
|
||||
"""Stream response (anything with read(n)) into the cache as a checked APK."""
|
||||
tmp = None
|
||||
name = name or _host(url)
|
||||
wait = wait_time(url)
|
||||
if wait:
|
||||
raise limited_error(name, wait)
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
|
||||
h = hashlib.sha256()
|
||||
with os.fdopen(fd, 'wb') as f, open_url(url, hosts) as r:
|
||||
with os.fdopen(fd, 'wb') as f:
|
||||
size = 0
|
||||
while True:
|
||||
chunk = r.read(1 << 20)
|
||||
chunk = response.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
@@ -240,12 +238,162 @@ def apk(url, hosts, digest=None, name=None):
|
||||
path = os.path.join(cache(), actual + '.apk')
|
||||
os.replace(tmp, path)
|
||||
return {'apk': path, 'obb': [], 'sha256': actual, 'verified': bool(digest)}
|
||||
finally:
|
||||
if tmp and os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
|
||||
def apk(url, hosts, digest=None, name=None):
|
||||
name = name or _host(url)
|
||||
wait = wait_time(url)
|
||||
if wait:
|
||||
raise limited_error(name, wait)
|
||||
try:
|
||||
with open_url(url, hosts) as r:
|
||||
return save_apk(r, digest)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (403, 429):
|
||||
raise limited_error(name, throttle(url, e.headers)) from e
|
||||
raise SourceError('Could not download APK: HTTP error ' + str(e.code)) from e
|
||||
except (OSError, ValueError, zipfile.BadZipFile) as e:
|
||||
raise SourceError('Could not download APK: ' + str(e)) from e
|
||||
finally:
|
||||
if tmp and os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
|
||||
# IPv6 is an allow-list: only global unicast (2000::/3), which rules out everything else at
|
||||
# once (loopback, unspecified, mapped/compatible IPv4, NAT64 64:ff9b::, site-local fec0::,
|
||||
# unique-local fc00::, link-local, multicast, reserved). Inside it, these special blocks are
|
||||
# refused too: 6to4 and Teredo carry IPv4 that may be private; the rest aren't the internet.
|
||||
_GLOBAL6 = ipaddress.ip_network('2000::/3')
|
||||
_SPECIAL6 = tuple(ipaddress.ip_network(n) for n in (
|
||||
'2001::/32', # Teredo
|
||||
'2001:2::/48', # benchmarking
|
||||
'2001:10::/28', # ORCHID
|
||||
'2001:20::/28', # ORCHIDv2
|
||||
'2001:db8::/32', # documentation
|
||||
'2002::/16', # 6to4
|
||||
'3fff::/20')) # documentation (2024)
|
||||
# IPv4 blocks that are never the public internet, listed in full rather than trusting
|
||||
# is_global (Python 3.9 calls 192.0.0.8, .11 and .255 global).
|
||||
_SPECIAL4 = tuple(ipaddress.ip_network(n) for n in (
|
||||
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
|
||||
'192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15',
|
||||
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32'))
|
||||
|
||||
|
||||
def public_address(ip):
|
||||
"""True for an address on the public internet: never loopback, private, link-local,
|
||||
site-local, shared (CGNAT), reserved, unspecified or multicast, nor an IPv6 wrapper
|
||||
around IPv4. Python 3.9's is_global alone lets several of these through."""
|
||||
try:
|
||||
a = ipaddress.ip_address(ip.split('%', 1)[0])
|
||||
except ValueError:
|
||||
return False
|
||||
if a.version == 6 and (a not in _GLOBAL6 or any(a in n for n in _SPECIAL6)):
|
||||
return False
|
||||
if a.version == 4 and any(a in n for n in _SPECIAL4):
|
||||
return False
|
||||
return a.is_global and not (a.is_reserved or a.is_private or a.is_loopback or a.is_link_local
|
||||
or a.is_multicast or a.is_unspecified)
|
||||
|
||||
|
||||
def resolve_public(host):
|
||||
"""host's addresses for port 443, if every address it has is public; else SourceError."""
|
||||
try:
|
||||
addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
|
||||
except (OSError, UnicodeError) as e:
|
||||
raise SourceError('Could not find the download server ' + host) from e
|
||||
if not addresses or not all(public_address(a[4][0]) for a in addresses):
|
||||
raise SourceError('The download server is on a private network address; refused')
|
||||
found = []
|
||||
for a in addresses:
|
||||
if a[4][:2] not in found:
|
||||
found.append(a[4][:2])
|
||||
return found
|
||||
|
||||
|
||||
CONNECT_BUDGET = 20 # seconds for connecting, all addresses and TLS handshakes together
|
||||
|
||||
|
||||
def _public_connection(host, timeout, budget=CONNECT_BUDGET):
|
||||
"""A verified TLS connection to host:443, made to an address checked to be public.
|
||||
|
||||
Each checked address is tried in turn, all within one deadline (budget, or timeout if
|
||||
shorter) covering both connecting and the handshake. The one connected to is always one
|
||||
that was checked, so a second lookup can't swap in another. timeout is for reads after."""
|
||||
deadline = time.monotonic() + min(budget, timeout)
|
||||
error = None
|
||||
for address in resolve_public(host):
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
try:
|
||||
return _connect(host, address, timeout, deadline)
|
||||
except OSError as e: # unreachable, refused, or its TLS failed: try the next one
|
||||
error = e
|
||||
raise error or socket.timeout('Timed out connecting to ' + host)
|
||||
|
||||
|
||||
def _connect(host, address, timeout, deadline):
|
||||
"""TLS to exactly address, verified as host, connected and handshaken before deadline
|
||||
(a time.monotonic() value); reads then get timeout seconds each."""
|
||||
def remaining():
|
||||
left = deadline - time.monotonic()
|
||||
if left <= 0:
|
||||
raise socket.timeout('Timed out connecting to ' + host)
|
||||
return left
|
||||
sock = socket.create_connection(address, timeout=remaining())
|
||||
try:
|
||||
# Each step gets what is left of the deadline as its socket timeout. A handshake
|
||||
# read as several receives could in theory run past it by one timeout; no thread
|
||||
# is kept to cut it short, as one could fire after the connection was handed over.
|
||||
sock.settimeout(remaining())
|
||||
sock = ssl.create_default_context().wrap_socket(sock, server_hostname=host,
|
||||
do_handshake_on_connect=False)
|
||||
sock.settimeout(remaining())
|
||||
sock.do_handshake()
|
||||
sock.settimeout(timeout)
|
||||
except BaseException:
|
||||
sock.close()
|
||||
raise
|
||||
conn = http.client.HTTPConnection(host, 443, timeout=timeout)
|
||||
conn.sock = sock
|
||||
return conn
|
||||
|
||||
|
||||
def public_apk(url, name, redirects=3, timeout=60, check=None):
|
||||
"""Download an APK from a public HTTPS host that no source lists in advance (itch.io's
|
||||
storage). No credentials are sent. Every hop, redirects included, is re-checked, and
|
||||
check(url), if given, runs on each hop before any lookup or connection (it raises)."""
|
||||
for _ in range(redirects + 1):
|
||||
if check:
|
||||
check(url)
|
||||
try:
|
||||
p = urllib.parse.urlsplit(url)
|
||||
port = p.port
|
||||
except (ValueError, TypeError) as e:
|
||||
raise SourceError(name + ' returned an invalid download URL') from e
|
||||
if p.scheme != 'https' or not p.hostname or p.username or p.password or port not in (None, 443):
|
||||
raise SourceError(name + ' returned an unexpected download URL')
|
||||
wait = wait_time(url)
|
||||
if wait:
|
||||
raise limited_error(name, wait)
|
||||
try:
|
||||
conn = _public_connection(p.hostname, timeout)
|
||||
except OSError as e:
|
||||
raise SourceError('Could not download APK: ' + str(e)) from e
|
||||
try:
|
||||
conn.request('GET', (p.path or '/') + ('?' + p.query if p.query else ''), headers={'User-Agent': UA})
|
||||
r = conn.getresponse()
|
||||
if r.status in (301, 302, 303, 307, 308):
|
||||
url = urllib.parse.urljoin(url, r.getheader('Location') or '')
|
||||
continue
|
||||
if r.status == 429:
|
||||
raise limited_error(name, throttle(url, r.headers))
|
||||
if r.status != 200:
|
||||
raise SourceError("%s's download server refused the file (HTTP %d); try again later"
|
||||
% (name, r.status))
|
||||
return save_apk(r)
|
||||
except (OSError, ValueError, http.client.HTTPException, zipfile.BadZipFile) as e:
|
||||
raise SourceError('Could not download APK: ' + str(e)) from e
|
||||
finally:
|
||||
conn.close()
|
||||
raise SourceError(name + ' redirected the download too many times')
|
||||
+97
-15
@@ -1,17 +1,35 @@
|
||||
"""Free Android VR RSS listings. Download pages stay in the publisher's UI."""
|
||||
"""Free Android VR RSS listings, plus the Android builds of games the user owns.
|
||||
|
||||
The free feeds' download pages stay in the publisher's UI. Owned games appear only
|
||||
after the user connects their itch.io account with an API key (_itch_account.py);
|
||||
they download through itch.io's own API, with the download key of that purchase.
|
||||
"""
|
||||
import html, re, urllib.parse, xml.etree.ElementTree as ET
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
from . import SourceError, _web
|
||||
from . import SourceError, _itch_account, _web
|
||||
|
||||
KIND = 'itch'
|
||||
FEEDS = ('openxr', 'oculus-quest')
|
||||
OWNED = 'itch-owned'
|
||||
OWNED_LABEL = 'Owned on itch.io'
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
|
||||
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
|
||||
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
|
||||
found = [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
|
||||
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
|
||||
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
|
||||
if _itch_account.connected():
|
||||
found.append({'id': OWNED, 'kind': KIND, 'name': 'itch.io (my purchases)',
|
||||
'url': 'https://itch.io/my-purchases', 'owned': True,
|
||||
'description': 'Android builds of games you own on itch.io',
|
||||
'builtin': True, 'enabled': True, 'trust': 'user'})
|
||||
return found
|
||||
|
||||
|
||||
def _itch_page(url):
|
||||
p = urllib.parse.urlsplit(url if isinstance(url, str) else '')
|
||||
return p.scheme == 'https' and (p.hostname or '').endswith('.itch.io') and not p.username and ':' not in p.netloc
|
||||
|
||||
|
||||
def _parse(source, data):
|
||||
@@ -22,8 +40,7 @@ def _parse(source, data):
|
||||
entries = []
|
||||
for item in root.findall('./channel/item'):
|
||||
page = item.findtext('link') or ''
|
||||
p = urllib.parse.urlsplit(page)
|
||||
if p.scheme != 'https' or not (p.hostname or '').endswith('.itch.io') or p.username or ':' in p.netloc:
|
||||
if not _itch_page(page):
|
||||
continue
|
||||
if item.findtext('price') != '$0.00' or item.findtext('platforms/android') != 'yes':
|
||||
continue
|
||||
@@ -45,23 +62,81 @@ def _parse(source, data):
|
||||
return entries
|
||||
|
||||
|
||||
def _owned_entry(source, key):
|
||||
"""Entry for one owned download key, or None if its game isn't for Android."""
|
||||
game = key.get('game') if isinstance(key.get('game'), dict) else {}
|
||||
game_id = key.get('game_id') or game.get('id')
|
||||
if type(game_id) is not int or type(key.get('id')) is not int:
|
||||
return None
|
||||
traits = game.get('traits')
|
||||
if isinstance(traits, list) and 'p_android' not in traits:
|
||||
return None # the game's page offers no Android download
|
||||
cover = game.get('cover_url') if isinstance(game.get('cover_url'), str) else None
|
||||
if cover and not cover.startswith('https://img.itch.zone/'):
|
||||
cover = None
|
||||
user = game.get('user') if isinstance(game.get('user'), dict) else {}
|
||||
price = game.get('min_price')
|
||||
return {'source': source['id'], 'id': str(game_id), 'package': None,
|
||||
'name': str(game.get('title') or 'itch.io game ' + str(game_id)),
|
||||
'summary': str(game.get('short_text') or OWNED_LABEL),
|
||||
'developer': user.get('display_name') or user.get('username'),
|
||||
'icon': cover, 'images': {'icon': cover, 'banner': cover, 'screenshots': []},
|
||||
'page': game['url'] if _itch_page(game.get('url')) else None,
|
||||
'version': None, 'version_code': None, 'min_sdk': None, 'abis': None,
|
||||
'vr': None, 'size': None, 'free': price == 0 if type(price) is int else None,
|
||||
'owned': True, 'owned_label': OWNED_LABEL, 'license': None,
|
||||
'updated': str(game.get('published_at') or '')[:10] or None,
|
||||
'downloadable': None, 'download_key_id': key['id']}
|
||||
|
||||
|
||||
def _owned(source):
|
||||
entries = {}
|
||||
for key in _itch_account.owned_keys():
|
||||
entry = _owned_entry(source, key)
|
||||
if entry:
|
||||
entries.setdefault(entry['id'], entry) # keys come newest first; one entry per game
|
||||
return entries
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
limit = max(0, min(int(limit), 100))
|
||||
if not limit:
|
||||
return []
|
||||
entries = {}
|
||||
tag = source.get('tag', 'openxr')
|
||||
if tag not in FEEDS:
|
||||
raise SourceError('Unsupported itch.io feed')
|
||||
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
|
||||
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
|
||||
entries.setdefault(entry['id'], entry)
|
||||
if source.get('owned'):
|
||||
entries = _owned(source)
|
||||
else:
|
||||
entries = {}
|
||||
tag = source.get('tag', 'openxr')
|
||||
if tag not in FEEDS:
|
||||
raise SourceError('Unsupported itch.io feed')
|
||||
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
|
||||
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
|
||||
entries.setdefault(entry['id'], entry)
|
||||
words = query.lower().split()
|
||||
return [e for e in entries.values() if all(w in (e['name'] + ' ' + e['summary']).lower()
|
||||
for w in words)][:limit]
|
||||
|
||||
|
||||
def _owned_details(source, entry_id):
|
||||
entry = _owned(source).get(str(entry_id))
|
||||
if not entry:
|
||||
raise SourceError('This game is not among the Android games you own on itch.io')
|
||||
uploads = _itch_account.uploads(int(entry['id']), entry['download_key_id'])
|
||||
versions = [{'version': str(u.get('display_name') or u.get('filename') or u['id']), 'version_code': None,
|
||||
'upload_id': u['id'], 'name': u.get('filename'), 'min_sdk': None,
|
||||
'demo': u.get('demo') is True or 'demo' in (u.get('traits') or []),
|
||||
'size': u.get('size') if type(u.get('size')) is int else None,
|
||||
'updated': str(u.get('updated_at') or u.get('created_at') or '')[:10] or None}
|
||||
for u in _itch_account.android_uploads(uploads)]
|
||||
entry = dict(entry, versions=versions, downloadable=bool(versions))
|
||||
if versions:
|
||||
entry.update({k: versions[0][k] for k in ('version', 'size', 'updated')})
|
||||
return entry
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
if source.get('owned'):
|
||||
return _owned_details(source, entry_id)
|
||||
entry = next((e for e in search(source, '', 100) if e['id'] == entry_id), None)
|
||||
if not entry:
|
||||
raise SourceError('Game is not in the current free Android VR feed; open its publisher page')
|
||||
@@ -69,4 +144,11 @@ def details(source, entry_id):
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
|
||||
if not source.get('owned'):
|
||||
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
|
||||
if version_code is not None:
|
||||
raise SourceError('itch.io does not publish Android version codes; download the latest upload')
|
||||
entry = _owned_details(source, entry_id)
|
||||
if not entry['versions']:
|
||||
raise SourceError('This game has no Android download on itch.io')
|
||||
return _itch_account.download(entry['versions'][0]['upload_id'], entry['download_key_id'])
|
||||
@@ -190,7 +190,10 @@ def group(entries, query='', vr=None, installable=False):
|
||||
groups = {}
|
||||
for entry in entries:
|
||||
entry = decorate(entry)
|
||||
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
|
||||
# Unknown counts as flat, except the user's own purchases (itch.io doesn't say which are
|
||||
# VR): those show under either choice rather than vanish from one.
|
||||
if vr is not None and (entry.get('vr') is True) != vr \
|
||||
and not (entry.get('owned') is True and entry.get('vr') is None):
|
||||
continue
|
||||
if installable and entry['fit']['installable'] is not True:
|
||||
continue
|
||||
@@ -223,6 +226,11 @@ def group(entries, query='', vr=None, installable=False):
|
||||
return result
|
||||
|
||||
|
||||
def obtainable(source, entry):
|
||||
"""Free, or a game the user owns, listed by their own account's source (itch.io purchases)."""
|
||||
return entry.get('free') is True or (source.get('owned') is True and entry.get('owned') is True)
|
||||
|
||||
|
||||
def _launch(module, source, query, limit):
|
||||
"""One search per source at a time; the newest different query runs next."""
|
||||
key = source['id']
|
||||
@@ -251,7 +259,7 @@ def _start(key, task):
|
||||
queued = None
|
||||
try:
|
||||
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
|
||||
for e in module.search(source, query, limit=limit) if e.get('free') is True]
|
||||
for e in module.search(source, query, limit=limit) if obtainable(source, e)]
|
||||
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
|
||||
except Exception as e:
|
||||
task['error'] = str(e)
|
||||
@@ -308,7 +316,7 @@ def install(source_id, entry_id, version_code=None, progress=None):
|
||||
if not source['enabled']:
|
||||
raise SourceError('This source is disabled')
|
||||
entry = module.details(source, entry_id)
|
||||
if entry.get('free') is not True or entry.get('downloadable') is not True:
|
||||
if not obtainable(source, entry) or entry.get('downloadable') is not True:
|
||||
raise SourceError('This app must be obtained from its developer page')
|
||||
if progress:
|
||||
progress('Downloading', None)
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Import a local Android backup: one APK and its matching expansion files."""
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import frame_android as android
|
||||
import frame_quest as quest
|
||||
|
||||
OBB = re.compile(r'(main|patch)\.[0-9]+\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)\.obb')
|
||||
|
||||
|
||||
def inspect(paths, discover=False):
|
||||
"""Inspect selected files before installing anything. Desktop APK drops can find
|
||||
sibling OBBs; browser uploads include only files explicitly selected by the user."""
|
||||
if not isinstance(paths, list) or not paths or len(paths) > 100:
|
||||
raise android.FrameError('Choose one game APK and its .obb game-data files together.')
|
||||
files = []
|
||||
for value in paths:
|
||||
path = Path(value).expanduser().absolute()
|
||||
if path.is_dir():
|
||||
files.extend(p for p in sorted(path.iterdir()) if p.suffix.lower() in ('.apk', '.obb'))
|
||||
else:
|
||||
files.append(path)
|
||||
files = list(dict.fromkeys(files))
|
||||
if not files or len(files) > 100:
|
||||
raise android.FrameError('No Android backup found. Choose a folder containing one APK and its game data.')
|
||||
for path in files:
|
||||
if path.suffix.lower() not in ('.apk', '.obb') or not path.is_file() or path.stat().st_size == 0:
|
||||
raise android.FrameError(f'{path.name}: choose a nonempty APK or .obb game-data file.')
|
||||
apks = [p for p in files if p.suffix.lower() == '.apk']
|
||||
if len(apks) > 1:
|
||||
raise android.FrameError('Choose one game at a time. Split APK backups are not supported by this importer.')
|
||||
apk = apks[0] if apks else None
|
||||
if apk:
|
||||
info = android.apk_info(str(apk))
|
||||
try:
|
||||
android.check_installable(info)
|
||||
except android.FrameError as error:
|
||||
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
|
||||
'blocker': str(error)}
|
||||
raise
|
||||
checked = quest.inspect(info['package'], str(apk))
|
||||
pkg = checked['package']
|
||||
if discover and len(files) == 1:
|
||||
files.extend(p for p in sorted(apk.parent.iterdir())
|
||||
if p not in files and OBB.fullmatch(p.name) and OBB.fullmatch(p.name)[2] == pkg)
|
||||
else:
|
||||
packages = {OBB.fullmatch(p.name)[2] for p in files if OBB.fullmatch(p.name)}
|
||||
if len(packages) != 1:
|
||||
raise android.FrameError('Choose the APK and game-data files for one game together.')
|
||||
pkg = packages.pop()
|
||||
if not android.PKG_RE.fullmatch(pkg):
|
||||
raise android.FrameError('The game-data filename has an invalid Android package name.')
|
||||
meta = android.read_meta(pkg)
|
||||
if not meta:
|
||||
raise android.FrameError('This game is not installed. Add its APK alongside these game-data files.')
|
||||
checked = {k: meta.get(k) for k in ('package', 'label', 'version', 'version_code')}
|
||||
checked['version_code'] = quest._installed_code(pkg, meta)
|
||||
if checked['version_code'] is None:
|
||||
raise android.FrameError('Add the matching APK so Frame Control can check which game version this data belongs to.')
|
||||
checked['warnings'] = []
|
||||
obb = [p for p in files if p.suffix.lower() == '.obb']
|
||||
if len(obb) > 99:
|
||||
raise android.FrameError('Choose one APK and at most 99 game-data files.')
|
||||
if obb and checked['version_code'] is None:
|
||||
raise android.FrameError('The APK has no readable version code, so its game-data target cannot be checked.')
|
||||
names = set()
|
||||
for path in obb:
|
||||
match = OBB.fullmatch(path.name)
|
||||
if not match or match[2] != pkg:
|
||||
raise android.FrameError(f'{path.name} does not belong to {checked["label"]}. Choose matching game data.')
|
||||
if not path.is_file() or not path.stat().st_size or path.name in names:
|
||||
raise android.FrameError(f'{path.name}: game-data files must be nonempty and have unique names.')
|
||||
names.add(path.name)
|
||||
return {'apk': apk, 'obb': obb, 'checked': checked,
|
||||
'signatures': {str(p): (p.stat().st_size, p.stat().st_mtime_ns) for p in [*apks, *obb]}}
|
||||
|
||||
|
||||
def public(plan):
|
||||
checked = plan['checked']
|
||||
return {**checked, 'apk': plan['apk'].name if plan['apk'] else None,
|
||||
'obb': [{'name': p.name, 'size': p.stat().st_size} for p in plan['obb']],
|
||||
'size': sum(size for size, _ in plan['signatures'].values())}
|
||||
|
||||
|
||||
def install(plan, display='auto', progress=None):
|
||||
"""The explicit Import button authorizes starting the game to add its data.
|
||||
Reuse the Quest import's instance/version checks, verified transfer and close."""
|
||||
report = progress or (lambda *_: None)
|
||||
if display not in ('auto', 'flat', 'vr'):
|
||||
raise android.FrameError('Invalid Android display mode.')
|
||||
for path, signature in plan['signatures'].items():
|
||||
p = Path(path)
|
||||
if not p.is_file() or (p.stat().st_size, p.stat().st_mtime_ns) != signature:
|
||||
raise android.FrameError('The backup changed after it was read. Choose the files again.')
|
||||
checked = plan['checked']
|
||||
pkg, label = checked['package'], checked['label']
|
||||
with android._install_lock:
|
||||
if plan['apk']:
|
||||
if quest._running(pkg):
|
||||
raise android.FrameError(f'Close {label} on the Frame, then try importing again.')
|
||||
quest._check_downgrade(android.read_meta(pkg), checked, False)
|
||||
report('Installing the game app', 15)
|
||||
meta = android.install(str(plan['apk']), source=plan['apk'].name,
|
||||
flatscreen=None if display == 'auto' else display == 'flat')
|
||||
else:
|
||||
meta = android._meta_or_fail(pkg)
|
||||
if not plan['obb']:
|
||||
report('Done', 100)
|
||||
warnings = list(checked['warnings']) + list(meta.get('vr_issues') or [])
|
||||
return {'message': f'Installed {label}. Open it from your Steam library.', 'app': meta,
|
||||
'needs_game_data': False, 'warnings': warnings}
|
||||
try:
|
||||
if not quest._running(pkg):
|
||||
report('Starting the game to add its data', 55)
|
||||
android.launch(pkg)
|
||||
deadline = time.monotonic() + 90
|
||||
while not quest._running(pkg):
|
||||
if time.monotonic() >= deadline:
|
||||
raise android.FrameError('The game did not start. Check that Steam is open on the Frame.')
|
||||
time.sleep(2)
|
||||
report('Preparing Android for game data', 65)
|
||||
quest._wait_ready(meta)
|
||||
# Android can be booted before Lepton has installed the APK.
|
||||
deadline = time.monotonic() + 90
|
||||
while True:
|
||||
try:
|
||||
quest._check_android_package(meta, checked['version_code'])
|
||||
break
|
||||
except quest.QuestError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(2)
|
||||
result = quest.add_game_data(pkg, checked, plan['obb'], None, report,
|
||||
checked['warnings'], source='your backup')
|
||||
if result['closed']:
|
||||
result['message'] = (f'Imported {label} and {len(plan["obb"])} game-data '
|
||||
f'file{"s" if len(plan["obb"]) != 1 else ""}. '
|
||||
'Ready to open from your Steam library.')
|
||||
return result
|
||||
except Exception as error:
|
||||
closed, why = quest._close(pkg)
|
||||
detail = ('The game is closed.' if closed else f'Close the game on the Frame ({why}).')
|
||||
raise android.FrameError(f'{label} is installed, but its game-data setup did not finish: {error}. '
|
||||
f'{detail} Import the same backup again to retry.') from error
|
||||
+4
-10
@@ -558,21 +558,15 @@ class Registry:
|
||||
self.config = Path(config) if config else None # None: ssh_config() at call time
|
||||
self.lock = threading.RLock()
|
||||
self._depth = 0 # nested _changing() calls
|
||||
self._stamp = None # devices.json as last loaded or saved
|
||||
self._mtime = None # devices.json as last loaded or saved
|
||||
self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}}
|
||||
self.load()
|
||||
|
||||
# -- storage --
|
||||
def _file_stamp(self):
|
||||
stat = self.path.stat()
|
||||
# Saves atomically replace the file. Fast consecutive saves can have the
|
||||
# same timestamp and size; its identity still changes in that case.
|
||||
return stat.st_dev, stat.st_ino, stat.st_mtime_ns, stat.st_size
|
||||
|
||||
def load(self):
|
||||
with self.lock:
|
||||
try:
|
||||
self._stamp = self._file_stamp()
|
||||
self._mtime = self.path.stat().st_mtime_ns
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
@@ -613,14 +607,14 @@ class Registry:
|
||||
tmp = self.path.with_name(self.path.name + ".tmp")
|
||||
tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, self.path)
|
||||
self._stamp = self._file_stamp()
|
||||
self._mtime = self.path.stat().st_mtime_ns
|
||||
|
||||
def _refresh(self):
|
||||
"""Pick up what another Frame Control server saved (the app and a standalone
|
||||
server can share devices.json)."""
|
||||
with self.lock:
|
||||
try:
|
||||
if self._file_stamp() != self._stamp:
|
||||
if self.path.stat().st_mtime_ns != self._mtime:
|
||||
self.load()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
+34
-154
@@ -420,7 +420,7 @@
|
||||
.rep-item .s { white-space: normal; }
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #androidImportDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg { width: min(640px, calc(100vw - 40px)); }
|
||||
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
|
||||
@@ -1088,7 +1088,7 @@
|
||||
<section id="android">
|
||||
<div class="store-tabs" aria-label="Android sections"><button class="on" data-store-tab="browse" aria-pressed="true">Discover</button><button data-store-tab="library" aria-pressed="false">On your Frame <span id="andCount"></span></button><span class="spacer"></span><button id="sourcesOpen" class="small" title="Choose where Discover looks for apps">Sources…</button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
|
||||
<section id="appStore" aria-label="Discover apps">
|
||||
<div class="store-toolbar"><div class="store-heading"><div><h1>Apps and games for the Frame</h1><p>Free Android apps and VR games from open app stores and developers' own pages, each marked with whether it runs on the Frame. Steam games are in <a href="#games">Games</a>.</p><button class="small" id="androidBackupOpen">Import game files…</button></div></div>
|
||||
<div class="store-toolbar"><div class="store-heading"><div><h1>Apps and games for the Frame</h1><p>Free Android apps and VR games from open app stores and developers' own pages, each marked with whether it runs on the Frame. Steam games are in <a href="#games">Games</a>.</p></div></div>
|
||||
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form></div>
|
||||
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">All</button><button data-filter="vr" aria-pressed="false">VR</button><button data-filter="flat" aria-pressed="false">Flat-screen</button><button data-filter="installable" aria-pressed="false">Installs on the Frame</button></div></div>
|
||||
<div id="sourceStatus" class="store-notice" role="status" aria-live="polite" hidden></div>
|
||||
@@ -1110,14 +1110,6 @@
|
||||
<div class="hint">Games from the store, your Quest and your Meta library install one after another.
|
||||
Remove takes a game out before it starts.</div>
|
||||
</section>
|
||||
<section class="panel" id="androidBackup">
|
||||
<div class="shelf-head"><h2>Import Android game</h2></div>
|
||||
<p class="sub">Have a SideQuest backup? Choose the game's APK and all its .obb game-data files together.
|
||||
Frame Control installs the app and puts its data in the right place.</p>
|
||||
<button class="action small" id="androidBackupChoose">Choose game files…</button>
|
||||
<input type="file" id="androidBackupInput" accept=".apk,.obb" multiple hidden>
|
||||
<p class="hint desk-only">In the desktop app, drop the backup folder or its APK. Matching game data in the same folder is found automatically.</p>
|
||||
</section>
|
||||
<section class="panel" id="quest">
|
||||
<div class="shelf-head"><h2>Import from my Quest</h2><span class="count" id="questCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="questScan" title="Look for a Meta Quest plugged into this computer by USB">Look for my Quest</button></div>
|
||||
@@ -1290,8 +1282,7 @@
|
||||
<div class="shelf-head"><h2>Send to Frame</h2></div>
|
||||
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
|
||||
<b><span class="desk-only">Drop files here, or click to choose</span><span class="mobile-only">Tap to choose files</span></b>
|
||||
Android backups: choose the <code>.apk</code> and its <code>.obb</code> game data together.
|
||||
Other files land in <code>~/Downloads</code>;
|
||||
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
|
||||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
|
||||
<span class="desk-only">You can also drop files anywhere in this window.</span>
|
||||
<input type="file" id="fileInput" multiple hidden>
|
||||
@@ -1421,8 +1412,7 @@
|
||||
</main>
|
||||
</div>
|
||||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
||||
<span class="sub">Android backups: drop the <code>.apk</code> and its <code>.obb</code> game data together.
|
||||
Other files go to <code>~/Downloads</code>;
|
||||
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
|
||||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
|
||||
|
||||
<div class="drawer" id="drawer"><div class="log" id="log"></div></div>
|
||||
@@ -1441,22 +1431,10 @@
|
||||
<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>
|
||||
|
||||
<dialog id="androidImportDlg" aria-labelledby="androidImportTitle" style="background:#1e2329;color:var(--text);border:1px solid var(--muted);border-radius:4px;padding:24px;width:480px;max-width:calc(100vw - 32px)">
|
||||
<h2 id="androidImportTitle" style="margin-top:0">Import Android game</h2>
|
||||
<p id="androidImportName"></p>
|
||||
<div id="androidImportFiles" class="list" style="overflow-wrap:anywhere"></div>
|
||||
<p class="sub" id="androidImportNote"></p>
|
||||
<p class="sub" id="androidImportWarnings"></p>
|
||||
<p id="androidImportState" role="status" aria-live="polite"></p>
|
||||
<progress id="androidImportProgress" max="100" hidden style="width:100%" aria-label="Game import progress"></progress>
|
||||
<div class="actions">
|
||||
<button id="androidImportCancel">Cancel</button>
|
||||
<button id="androidImportGo" class="action">Import game</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
|
||||
<h2 id="apkAltTitle">Try another APK version</h2>
|
||||
<p id="apkAltReason"></p>
|
||||
@@ -1761,7 +1739,6 @@ async function api(path, body, device = window.connDevice) {
|
||||
checkGeneration();
|
||||
if (!r.ok) {
|
||||
const err = new Error(data.error || `HTTP ${r.status}`);
|
||||
err.apk = data.apk;
|
||||
err.offline = !!data.offline;
|
||||
if (err.offline) setOnline(false, err.message);
|
||||
throw err;
|
||||
@@ -3049,25 +3026,20 @@ window.addEventListener("drop", e => {
|
||||
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
|
||||
sendFiles([...e.dataTransfer.files], dirs);
|
||||
});
|
||||
function upload(file, mode, dev = window.connDevice, backupToken = null, progress = () => {}, signal = null) {
|
||||
function upload(file, mode, dev = window.connDevice) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(new Error("Import cancelled"));
|
||||
const xhr = new XMLHttpRequest();
|
||||
const abort = () => xhr.abort();
|
||||
const cleanup = () => signal?.removeEventListener("abort", abort);
|
||||
xhr.open("POST", "/api/upload");
|
||||
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
|
||||
if (dev) xhr.setRequestHeader("X-Frame-Device", dev);
|
||||
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
|
||||
xhr.setRequestHeader("X-Mode", mode);
|
||||
if (backupToken) xhr.setRequestHeader("X-Android-Backup", backupToken);
|
||||
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
|
||||
const bar = $("prog").firstElementChild;
|
||||
$("prog").style.display = "block"; bar.style.width = "0";
|
||||
xhr.upload.onprogress = e => { if (e.lengthComputable) { bar.style.width = (100 * e.loaded / e.total) + "%"; progress(100 * e.loaded / e.total); } };
|
||||
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
|
||||
const gen = devGen;
|
||||
xhr.onload = () => {
|
||||
cleanup();
|
||||
$("prog").style.display = "none";
|
||||
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
|
||||
if (gen !== devGen) { // the app switched headsets meanwhile: nothing to offer about this one here
|
||||
@@ -3078,9 +3050,7 @@ function upload(file, mode, dev = window.connDevice, backupToken = null, progres
|
||||
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk, dev);
|
||||
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
|
||||
};
|
||||
xhr.onerror = () => { cleanup(); $("prog").style.display = "none"; reject(new Error("network error")); };
|
||||
xhr.onabort = () => { cleanup(); $("prog").style.display = "none"; reject(new Error("Import cancelled")); };
|
||||
signal?.addEventListener("abort", abort, {once:true});
|
||||
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
@@ -3181,135 +3151,29 @@ document.addEventListener("click", async e => {
|
||||
$("aboutClose").onclick = () => $("aboutDlg").close();
|
||||
|
||||
const TITLE_EXT = /\.(zip|exe)$/i;
|
||||
let fileSendQueue = Promise.resolve();
|
||||
function sendFiles(files, dirs = new Set()) {
|
||||
const dev = window.connDevice;
|
||||
const run = fileSendQueue.then(() => sendFilesOne(files, dirs, dev));
|
||||
fileSendQueue = run.catch(e => toast(e.message, true));
|
||||
return fileSendQueue;
|
||||
}
|
||||
async function sendFilesOne(files, dirs, dev) {
|
||||
const androidFiles = files.filter((f, i) => !dirs.has(i) && /\.(apk|obb)$/i.test(f.name));
|
||||
const grouped = androidFiles.some(f => /\.obb$/i.test(f.name));
|
||||
if (grouped && window.connDevice === dev) await importAndroidFiles(androidFiles, dev);
|
||||
async function sendFiles(files, dirs = new Set()) {
|
||||
const dev = window.connDevice; // the whole batch is for the headset it was dropped on
|
||||
for (const [i, f] of files.entries()) {
|
||||
if (window.connDevice !== dev) {
|
||||
toast(`Switched headsets: didn't send ${files.length - i} remaining file${files.length - i === 1 ? "" : "s"}`, true);
|
||||
break;
|
||||
}
|
||||
if (grouped && androidFiles.includes(f)) continue;
|
||||
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
|
||||
if (dirs.has(i)) {
|
||||
if (path) {
|
||||
if (!await importAndroidFiles([f], dev, true)) await sideload(f, path, true);
|
||||
}
|
||||
if (path) await sideload(f, path, true);
|
||||
else toast(`${f.name}: zip the folder first (folders need the Frame Control app)`, true);
|
||||
continue;
|
||||
}
|
||||
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
|
||||
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
||||
const apk = f.name.toLowerCase().endsWith(".apk");
|
||||
if (apk) await importAndroidFiles([f], dev);
|
||||
else await act(`Copy ${f.name} to ~/Downloads`, () => upload(f, "push", dev));
|
||||
const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push", dev));
|
||||
if (apk && res && res.app) await offerTest(res.app);
|
||||
}
|
||||
$("fileInput").value = "";
|
||||
refresh();
|
||||
}
|
||||
|
||||
$("androidBackupChoose").onclick = () => $("androidBackupInput").click();
|
||||
$("androidBackupOpen").onclick = () => $("androidBackupInput").click();
|
||||
$("androidBackupInput").onchange = () => {
|
||||
sendFiles([...$("androidBackupInput").files]);
|
||||
$("androidBackupInput").value = "";
|
||||
};
|
||||
async function importAndroidFiles(files, dev, probe = false) {
|
||||
const dlg = $("androidImportDlg");
|
||||
let token = null, running = false;
|
||||
const controller = new AbortController();
|
||||
const finished = new Promise(resolve => dlg.addEventListener("close", () => { controller.abort(); resolve(); }, {once:true}));
|
||||
$("androidImportName").textContent = "Reading your backup…";
|
||||
for (const id of ["androidImportFiles", "androidImportNote", "androidImportWarnings", "androidImportState"]) $(id).textContent = "";
|
||||
$("androidImportGo").hidden = false;
|
||||
$("androidImportGo").disabled = true;
|
||||
$("androidImportGo").textContent = "Import game";
|
||||
$("androidImportCancel").disabled = false;
|
||||
$("androidImportCancel").textContent = "Cancel";
|
||||
$("androidImportCancel").onclick = () => dlg.close();
|
||||
dlg.oncancel = e => { if (running) e.preventDefault(); };
|
||||
$("androidImportProgress").hidden = true;
|
||||
dlg.showModal();
|
||||
try {
|
||||
let read;
|
||||
const paths = files.map(f => window.frameApp?.pathForFile?.(f) || "");
|
||||
if (paths.every(Boolean)) {
|
||||
read = await api("/api/android/backup", {action:"inspect", paths, probe}, dev);
|
||||
if (read.android === false) { dlg.close(); await finished; return false; }
|
||||
token = read.token;
|
||||
} else {
|
||||
token = (await api("/api/android/backup", {action:"begin"}, dev)).token;
|
||||
for (const [i, file] of files.entries()) {
|
||||
if (!dlg.open) return true;
|
||||
$("androidImportState").textContent = `Reading file ${i + 1} of ${files.length}: ${file.name}`;
|
||||
$("androidImportProgress").hidden = false;
|
||||
await upload(file, "android-backup", dev, token, percent => { $("androidImportProgress").value = percent; }, controller.signal);
|
||||
}
|
||||
read = await api("/api/android/backup", {action:"inspect", token}, dev);
|
||||
}
|
||||
if (!dlg.open) return true;
|
||||
const plan = read.plan;
|
||||
$("androidImportName").textContent = `${plan.label}${plan.version ? " · " + plan.version : ""}`;
|
||||
$("androidImportFiles").innerHTML = (plan.apk ? `<div class="sub">Game app</div><div>${esc(plan.apk)}</div>`
|
||||
: `<div class="sub">Game app already installed</div>`) +
|
||||
`<div class="sub" style="margin-top:12px">Game data · ${plan.obb.length} file${plan.obb.length === 1 ? "" : "s"}</div>` +
|
||||
plan.obb.map(f => `<div>${esc(f.name)} <span class="sub">${gb(f.size)}</span></div>`).join("");
|
||||
$("androidImportNote").textContent = plan.obb.length
|
||||
? "Frame Control will start the game, copy its data to the right place, and close it. Then you can open it from your Steam library."
|
||||
: "No game-data files selected. If your backup includes .obb files, cancel and choose them with the APK. In the desktop app, matching files beside the APK are found automatically.";
|
||||
$("androidImportWarnings").textContent = (plan.warnings || []).join(" ");
|
||||
$("androidImportState").textContent = "";
|
||||
$("androidImportProgress").hidden = true;
|
||||
$("androidImportGo").textContent = plan.obb.length ? "Import game & data" : "Install game app";
|
||||
$("androidImportGo").disabled = false;
|
||||
const chosen = await Promise.race([
|
||||
new Promise(resolve => { $("androidImportGo").onclick = () => resolve(true); }),
|
||||
finished.then(() => false),
|
||||
]);
|
||||
if (!chosen) return true;
|
||||
if (window.connDevice !== dev) throw new Error("The headset changed. Choose the backup again for this Frame.");
|
||||
running = true;
|
||||
$("androidImportCancel").disabled = true;
|
||||
$("androidImportGo").disabled = true;
|
||||
$("androidImportProgress").hidden = false;
|
||||
$("androidImportProgress").value = 0;
|
||||
const result = await runJob(`Import ${plan.label}`, `backup:${token}`, async () => {
|
||||
try {
|
||||
const started = await api("/api/android/backup", {action:"install", token, display:$("apkDisplay").value}, dev);
|
||||
token = null; // the job owns the uploaded files now
|
||||
return started;
|
||||
} catch (e) { $("androidImportState").textContent = e.message; throw e; }
|
||||
}, j => {
|
||||
$("androidImportState").textContent = j.error || j.message || j.stage || "Importing…";
|
||||
if (j.percent != null) $("androidImportProgress").value = j.percent;
|
||||
});
|
||||
$("androidImportState").textContent = result?.message || $("androidImportState").textContent || "Setup did not finish. Import the backup again to retry.";
|
||||
if (result?.warnings?.length) $("androidImportWarnings").textContent = result.warnings.join(" ");
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) return true;
|
||||
$("androidImportState").textContent = e.message;
|
||||
log(`Import Android game: ${e.message}`, "e");
|
||||
if (e.apk?.blocker) { dlg.close(); checkApkAlternatives(e.apk, dev); }
|
||||
} finally {
|
||||
running = false;
|
||||
$("androidImportProgress").hidden = true;
|
||||
$("androidImportGo").hidden = true;
|
||||
$("androidImportCancel").disabled = false;
|
||||
$("androidImportCancel").textContent = "Close";
|
||||
if (token) api("/api/android/backup", {action:"discard", token}, dev).catch(() => {});
|
||||
}
|
||||
await finished;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- sideloaded titles (a Linux or Windows build as a Steam Devkit Game) ----
|
||||
const RESERVED_IDS = ["steam", "steamdeckard", "steamvr", "steamvrdeckard", "devkit-steam"];
|
||||
function titleId(name) { // mirrors frame_titles.title_id: the name Steam shows
|
||||
@@ -3686,10 +3550,23 @@ function sourceToken(s) {
|
||||
}
|
||||
async function loadSources() {
|
||||
try {
|
||||
const {sources} = await api("/api/sources"); sourceState.sources = sources;
|
||||
const {sources, itch} = await api("/api/sources"); sourceState.sources = sources; showItchAccount(itch);
|
||||
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}${s.login ? " · Uses a login" : ""}</small>${sourceToken(s)}${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div>${s.page_only ? `<a class="source-web" href="${esc(sourcePage(s.url))}" target="_blank" rel="noopener" title="Its apps can't be searched from here">Website ↗</a>` : `<input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}>`}</div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
|
||||
} catch (e) { $("sourceError").textContent = e.message; }
|
||||
}
|
||||
// Owned on itch.io (the user's own purchases) or free: what the store shows instead of a price.
|
||||
const storePrice = o => o.owned ? (o.owned_label || "Owned") : "Free";
|
||||
function showItchAccount(itch) {
|
||||
const on = !!itch?.connected;
|
||||
$("itchAccountNote").textContent = on ? `Connected${itch.username ? " as " + itch.username : ""}. Android builds of games you own appear in the store, marked Owned on itch.io. Disconnecting deletes the API key from this computer.` : "Install the Android builds of games you own on itch.io. Create an API key in itch.io settings › API keys and paste it here. It stays on this computer and is only sent to itch.io.";
|
||||
$("itchKeyField").hidden = on; $("itchConnect").hidden = on; $("itchDisconnect").hidden = !on;
|
||||
$("itchAccount").elements.key.required = !on;
|
||||
}
|
||||
async function itchAccount(body) {
|
||||
$("sourceError").textContent = body.action === 'itch-connect' ? "Checking the key with itch.io…" : "Disconnecting…";
|
||||
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;showItchAccount(result.itch);await loadSources();await searchSources(); }
|
||||
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
|
||||
}
|
||||
// Will it run? Short enough for a card; the full sentence is in its tooltip and the details.
|
||||
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"] };
|
||||
function storeRuns(o) {
|
||||
@@ -3698,8 +3575,8 @@ function storeRuns(o) {
|
||||
}
|
||||
const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app";
|
||||
function storeCard(app) {
|
||||
const i=sourceState.apps.indexOf(app), o=app.offers[0];
|
||||
return `<button class="store-card" data-app="${i}" aria-label="${esc(app.name)}: ${esc(o.verdict?.label || "not yet checked on the Frame")}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "From " + storeName(o.source_name))}</span><span class="store-card-foot"><span>${storeKind(o)}</span>${storeRuns(o)}</span></span></button>`;
|
||||
const i=sourceState.apps.indexOf(app), o=app.offers[0], owned=app.offers.find(x=>x.owned);
|
||||
return `<button class="store-card" data-app="${i}" aria-label="${esc(app.name)}: ${esc(o.verdict?.label || "not yet checked on the Frame")}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "From " + storeName(o.source_name))}</span>${owned ? `<span class="store-summary">${esc(storePrice(owned))}</span>` : ""}<span class="store-card-foot"><span>${storeKind(o)}</span>${storeRuns(o)}</span></span></button>`;
|
||||
}
|
||||
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2>${subtitle ? `<p>${esc(subtitle)}</p>` : ""}</div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
|
||||
function storeEmpty(message) {
|
||||
@@ -3755,7 +3632,7 @@ async function searchSources(quiet) {
|
||||
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
|
||||
}
|
||||
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
|
||||
function detailBadges(o) { return `<span class="store-badge">${storeKind(o)}</span>${storeRuns(o)}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
|
||||
function detailBadges(o) { return `<span class="store-badge">${storeKind(o)}</span>${storeRuns(o)}<span class="store-badge">${esc(storePrice(o))}</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
|
||||
// The store's app page. (Not renderDetail: the Devices page declares that further down,
|
||||
// and a later function declaration silently replaces an earlier one; issue #68.)
|
||||
// True when the page rendered; false when it shows the error instead.
|
||||
@@ -3771,7 +3648,7 @@ function renderAppDetail() {
|
||||
function renderAppDetailBody() {
|
||||
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
|
||||
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
|
||||
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
|
||||
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">${esc(storePrice(o))}</div><p class="detail-small">${o.owned ? "Downloads with your itch.io account." : "No purchase needed."}</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
|
||||
updateInstall();
|
||||
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
|
||||
}
|
||||
@@ -3890,6 +3767,9 @@ $("addSource").onsubmit=e=>{
|
||||
const f=new FormData(e.target), url=f.get('url'), username=f.get('username'), password=f.get('password');
|
||||
addSource({action:'add',url,fingerprint:f.get('fingerprint'),...(username ? {username,password} : {})});
|
||||
};
|
||||
// The key goes to the server once and leaves the page straight away: the field is cleared before the reply.
|
||||
$("itchAccount").onsubmit=e=>{e.preventDefault();const input=e.target.elements.key, key=input.value.trim();input.value='';if(key)itchAccount({action:'itch-connect',key});};
|
||||
$("itchDisconnect").onclick=()=>itchAccount({action:'itch-disconnect'});
|
||||
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
|
||||
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
|
||||
function showAndroidTab(tab) {
|
||||
|
||||
+11
-109
@@ -44,9 +44,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import frame_agent # noqa: E402
|
||||
import frame_assistant # noqa: E402
|
||||
import frame_android # noqa: E402
|
||||
import frame_android_import # 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
|
||||
@@ -1598,93 +1598,6 @@ def apk_installed(info, meta, error, seconds):
|
||||
frame_android.install_hooks.append(apk_installed)
|
||||
|
||||
|
||||
# ---- Local Android backups (one APK and its OBB game data) ---------------
|
||||
|
||||
_android_backups = {}
|
||||
_android_backups_lock = threading.Lock()
|
||||
ANDROID_BACKUP_TMP = 'frame-android-backup-'
|
||||
|
||||
|
||||
def _discard_android_backup(entry):
|
||||
for folder in entry['dirs']:
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
|
||||
|
||||
def _android_backup(token=None):
|
||||
now = time.time()
|
||||
with _android_backups_lock:
|
||||
stale = [_android_backups.pop(t) for t, e in list(_android_backups.items())
|
||||
if now - e['time'] > 3600]
|
||||
entry = _android_backups.get(token)
|
||||
for old in stale:
|
||||
_discard_android_backup(old)
|
||||
if not entry:
|
||||
raise Failure('This import expired. Choose the backup files again.', 400)
|
||||
device = LINK.active_device()['id'] if LINK else None
|
||||
if entry['device'] != device:
|
||||
raise Failure('The headset changed. Choose the backup files again for this Frame.', 409)
|
||||
entry['time'] = now
|
||||
return entry
|
||||
|
||||
|
||||
def android_backup(body):
|
||||
action = body.get('action')
|
||||
if action == 'begin' or (action == 'inspect' and 'paths' in body):
|
||||
paths = body.get('paths', [])
|
||||
if not isinstance(paths, list) or len(paths) > 100 or any(
|
||||
not isinstance(p, str) or not os.path.isabs(p) for p in paths):
|
||||
raise Failure('Choose local Android backup files.', 400)
|
||||
# A dropped PC-game folder continues through the existing title importer.
|
||||
if body.get('probe') and len(paths) == 1 and Path(paths[0]).is_dir():
|
||||
if not any(p.suffix.lower() in ('.apk', '.obb') for p in Path(paths[0]).iterdir()):
|
||||
return {'android': False}
|
||||
token = secrets.token_hex(12)
|
||||
entry = {'paths': paths, 'dirs': [], 'plan': None, 'time': time.time(),
|
||||
'device': LINK.active_device()['id'] if LINK else None}
|
||||
with _android_backups_lock:
|
||||
_android_backups[token] = entry
|
||||
_android_backup(token) # also expire abandoned imports
|
||||
if action == 'begin':
|
||||
return {'token': token}
|
||||
else:
|
||||
token = str(body.get('token') or '')
|
||||
entry = _android_backup(token)
|
||||
if action == 'inspect':
|
||||
try:
|
||||
entry['plan'] = frame_android_import.inspect(entry['paths'], discover=not entry['dirs'])
|
||||
except (frame_android.FrameError, OSError) as error:
|
||||
with _android_backups_lock:
|
||||
_android_backups.pop(token, None)
|
||||
_discard_android_backup(entry)
|
||||
raise Failure(str(error), 400, getattr(error, 'apk', None))
|
||||
return {'android': True, 'token': token, 'plan': frame_android_import.public(entry['plan'])}
|
||||
if action not in ('install', 'discard'):
|
||||
raise Failure('Unknown Android backup action.', 400)
|
||||
if action == 'install' and not entry['plan']:
|
||||
raise Failure('Read the backup before importing it.', 400)
|
||||
display = body.get('display', 'auto')
|
||||
if display not in ('auto', 'flat', 'vr'):
|
||||
raise Failure('Invalid Android display mode.', 400)
|
||||
with _android_backups_lock:
|
||||
if _android_backups.pop(token, None) is not entry:
|
||||
raise Failure('This import was already started or cancelled. Choose the files again.', 409)
|
||||
if action == 'discard':
|
||||
_discard_android_backup(entry)
|
||||
return {'message': 'Import cancelled'}
|
||||
|
||||
def work(report):
|
||||
try:
|
||||
return frame_android_import.install(entry['plan'], display, report)
|
||||
finally:
|
||||
_discard_android_backup(entry)
|
||||
try:
|
||||
ensure_master()
|
||||
return start_job('Import ' + entry['plan']['checked']['label'], work, progress=True)
|
||||
except BaseException:
|
||||
_discard_android_backup(entry)
|
||||
raise
|
||||
|
||||
|
||||
# ---- Sideloaded titles (Linux/Windows builds as Steam Devkit Games) --------
|
||||
#
|
||||
# Installing is two steps: inspect (a dropped file is uploaded and a zip
|
||||
@@ -2300,7 +2213,7 @@ def _pid_alive(pid):
|
||||
def _tmp_places():
|
||||
"""Where each kind of staging folder goes, and its name before the server's PID."""
|
||||
tmp = Path(tempfile.gettempdir())
|
||||
return [(tmp, WEB_TMP_PREFIX), (tmp, ANDROID_BACKUP_TMP), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX),
|
||||
return [(tmp, WEB_TMP_PREFIX), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX),
|
||||
(tmp, frame_agent.TMP_PREFIX), (frame_host.cache_dir("apk-sources"), apk_fdroid.TMP_PREFIX)]
|
||||
|
||||
|
||||
@@ -2581,13 +2494,19 @@ 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)
|
||||
|
||||
|
||||
POST = {
|
||||
"/api/android/backup": android_backup,
|
||||
"/api/vr": vr,
|
||||
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
@@ -2806,7 +2725,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except SourceError as e:
|
||||
raise Failure(str(e), 400)
|
||||
elif path == "/api/sources":
|
||||
self.send_json({"sources": apk_search.sources()})
|
||||
self.send_json({"sources": apk_search.sources(), "itch": itch_account.status()})
|
||||
elif path == "/api/search":
|
||||
self.send_json(source_search(url.query))
|
||||
elif path == "/api/apk-versions":
|
||||
@@ -3026,14 +2945,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure("empty or too-large upload", 400)
|
||||
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
|
||||
raise Failure("APK install needs a .apk file", 400)
|
||||
backup = None
|
||||
if mode == 'android-backup':
|
||||
backup = _android_backup(self.headers.get('X-Android-Backup'))
|
||||
if (not name.lower().endswith(('.apk', '.obb')) or '/' in name or '\\' in name
|
||||
or len(backup['paths']) >= 100 or any(Path(p).name == name for p in backup['paths'])):
|
||||
raise Failure('Choose one APK and its game data, with no duplicate filenames.', 400)
|
||||
prefix = f'{ANDROID_BACKUP_TMP}{os.getpid()}-' if backup is not None else 'frame-ui-'
|
||||
tmp = Path(tempfile.mkdtemp(prefix=prefix))
|
||||
tmp = Path(tempfile.mkdtemp(prefix="frame-ui-"))
|
||||
keep = False
|
||||
try:
|
||||
dest = tmp / name
|
||||
@@ -3045,16 +2957,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure("upload interrupted", 400)
|
||||
f.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
if backup is not None:
|
||||
with _android_backups_lock:
|
||||
if _android_backups.get(self.headers.get('X-Android-Backup')) is not backup:
|
||||
raise Failure('This import was cancelled. Choose the backup files again.', 400)
|
||||
backup['paths'].append(str(dest))
|
||||
backup['dirs'].append(str(tmp))
|
||||
backup['plan'] = None
|
||||
backup['time'] = time.time()
|
||||
keep = True
|
||||
return {'message': f'Read {name}', 'token': self.headers.get('X-Android-Backup')}
|
||||
if mode == "media":
|
||||
return push_media(dest)
|
||||
if mode == "apkinfo":
|
||||
|
||||
Reference in new issue
Block a user