diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 0000000..4f4d046 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1,6 @@ +/proof-cache/ +/amazon.html +/aptoide-terms.html +/itch-game.html +/meta.html +/uptodown-terms.html diff --git a/.claude/NOTES-more-sources.md b/.claude/NOTES-more-sources.md new file mode 100644 index 0000000..b699e4d --- /dev/null +++ b/.claude/NOTES-more-sources.md @@ -0,0 +1,44 @@ +# more-sources + +Scope: only this worktree/branch; no delegation, SSH, installation, push, PR or +issue writes. Parent performs integration and independent review per brief. + +Decisions: implement GitHub curated public APK releases and itch.io RSS page +links. itch robots exclude keyed download pages used by /tmp/vrapk/itchdl.py; +no bypass. Topic results are not automatically trusted. Curated Open Brush and +SuperTux prereleases must be explicitly allowed; discovered during fixture tests. +No shared files changed. Default GitHub search makes no network request. + +Evidence (2026-09-28): +- Read all six prerequisite files and the full brief. +- Fetched itch terms, root/author robots and OpenXR RSS with FrameControl UA. +- Anonymous GitHub returned 403 rate-limit; authenticated gh API reads succeeded. +- GitHub fixtures record upstream Khronos, Open Brush and SuperTux releases. +- `python3 .claude/prove-more-sources.py`: GitHub search/download succeeded, + published SHA-256 matched; `python3 ui/frame_android.py info` exited 0. + Overall script exit 1 because subsequent itch RSS request returned HTTP 429. + A second invocation had the same outcome; no further live itch retries. +- Download: `.claude/proof-cache/f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34.apk`. + Package org.khronos.openxr.hello_xr.vulkan; 1.1.63/1063; API 24; arm64; + OpenXR. No runtime compatibility claim. +- Initial whole suite: 174 tests, 1 failed (prerelease handling). After explicit + curated prerelease support: 174 tests passed, exit 0. Final rerun below. + +Unverified: live itch search completion (429), itch download/info (disallowed +flow), Open Brush/SuperTux downloads, topic live adapter search (API fixture +capture succeeded), headset runtime, UI integration, independent provider +review (explicit implementation brief prohibits delegation and assigns review +to parent). No claim that any reviewer/model participated. + +Research HTML and local downloaded APKs are local evidence only, not committed. +Recorded fixtures retain public source data, not credentials. The proof script +uses gh's token in memory and overrides cache into this worktree. + +Final verification: +- Python version: 3.9.6. +- `python3 -m unittest discover -s tests > .claude/tests-more-sources.log 2>&1`: + 174 tests passed in 5.569s, real exit 0. +- `git diff --check`: exit 0. +- Final artifact: docs/apk-sources.md plus two source modules, a private HTTPS + helper, curated JSON, recorded fixtures and tests. Evidence scripts/logs stay + in .claude; research HTML and APK cache are explicitly ignored there. diff --git a/.claude/proof-more-sources.log b/.claude/proof-more-sources.log new file mode 100644 index 0000000..83e67d0 --- /dev/null +++ b/.claude/proof-more-sources.log @@ -0,0 +1,36 @@ +org.khronos.openxr.hello_xr.vulkan · 1.1.63 (code 1063) +Minimum: Android 7.0 (API 24) +ABIs: arm64-v8a, armeabi-v7a, x86, x86_64 +Lepton can install this APK. Features may still need services Lepton lacks. +VR app +Uses OpenXR (good). +GitHub search: [{"source": "github", "id": "KhronosGroup/OpenXR-SDK-Source", "package": null, "name": "hello_xr", "summary": "Khronos OpenXR sample (Vulkan and OpenGL ES)", "icon": null, "images": {"icon": null, "banner": null, "screenshots": []}, "page": "https://github.com/KhronosGroup/OpenXR-SDK-Source", "version": null, "version_code": null, "min_sdk": null, "abis": null, "vr": true, "size": null, "free": true, "license": "Apache-2.0", "updated": null, "downloadable": true}] +GitHub download: {"apk": "/Users/saphid/projects/steam-frame-moresrc/.claude/proof-cache/f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34.apk", "obb": [], "sha256": "f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34", "verified": true} +info exit: 0 +Traceback (most recent call last): + File "/Users/saphid/projects/steam-frame-moresrc/ui/apk_sources/_web.py", line 47, in read + with open_url(url, hosts, headers) as r: + File "/Users/saphid/projects/steam-frame-moresrc/ui/apk_sources/_web.py", line 37, in open_url + return urllib.request.build_opener(Redirect(hosts)).open( + File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/urllib/request.py", line 523, in open + response = meth(req, response) + File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/urllib/request.py", line 632, in http_response + response = self.parent.error( + File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/urllib/request.py", line 561, in error + return self._call_chain(*args) + File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/urllib/request.py", line 494, in _call_chain + result = func(*args) + File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/urllib/request.py", line 641, in http_error_default + raise HTTPError(req.full_url, code, msg, hdrs, fp) +urllib.error.HTTPError: HTTP Error 429: Too Many Requests + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "/Users/saphid/projects/steam-frame-moresrc/.claude/prove-more-sources.py", line 18, in + entries = itch.search(itch.sources()[0], 'off nominal') + File "/Users/saphid/projects/steam-frame-moresrc/ui/apk_sources/itch.py", line 58, in search + for entry in _parse(source, _web.read(url, ('itch.io',))): + File "/Users/saphid/projects/steam-frame-moresrc/ui/apk_sources/_web.py", line 62, in read + raise SourceError('Source refused access or reached its rate limit; try later (GitHub accepts FRAME_GITHUB_TOKEN)') from e +apk_sources.SourceError: Source refused access or reached its rate limit; try later (GitHub accepts FRAME_GITHUB_TOKEN) diff --git a/.claude/prove-more-sources.py b/.claude/prove-more-sources.py new file mode 100644 index 0000000..85d4e49 --- /dev/null +++ b/.claude/prove-more-sources.py @@ -0,0 +1,24 @@ +import json, os, subprocess, sys +from pathlib import Path +sys.path.insert(0, str(Path.cwd() / 'ui')) +from apk_sources import github, itch, _web +proof = Path.cwd() / '.claude' / 'proof-cache' +proof.mkdir(exist_ok=True) +_web.cache = lambda: str(proof) +token = subprocess.run(['gh', 'auth', 'token'], capture_output=True, text=True, check=True).stdout.strip() +os.environ['FRAME_GITHUB_TOKEN'] = token +s = github.sources()[0] +entries = github.search(s, 'hello') +print('GitHub search:', json.dumps(entries)) +r = github.download(s, entries[0]['id']) +print('GitHub download:', json.dumps(r)) +p = subprocess.run(['python3', 'ui/frame_android.py', 'info', r['apk']]) +print('info exit:', p.returncode) +assert p.returncode == 0 +entries = itch.search(itch.sources()[0], 'off nominal') +print('itch.io search:', json.dumps(entries)) +assert entries and entries[0]['downloadable'] is False +try: + itch.download(itch.sources()[0], entries[0]['id']) +except Exception as e: + print('itch.io download correctly refused:', e) diff --git a/.claude/tests-more-sources.log b/.claude/tests-more-sources.log new file mode 100644 index 0000000..8834d11 --- /dev/null +++ b/.claude/tests-more-sources.log @@ -0,0 +1,5 @@ +.............................................................................................................................................................................. +---------------------------------------------------------------------- +Ran 174 tests in 5.569s + +OK diff --git a/docs/apk-sources.md b/docs/apk-sources.md new file mode 100644 index 0000000..15aa0b5 --- /dev/null +++ b/docs/apk-sources.md @@ -0,0 +1,96 @@ +# Developer-consented APK sources + +Surveyed 2026-09-28. Free access is not proof of redistribution permission or +Frame compatibility. These adapters fetch only public publisher releases or +link to publisher pages. They do not acquire store entitlements, defeat access +checks, install anything, or rehost APKs. See [VR compatibility](vr-apks.md). + +| Source | Developer consent and automated-access position | API/feed; VR coverage | Decision | +|---|---|---|---| +| [itch.io](https://itch.io/docs/legal/terms) | Publishers warrant distribution rights (§4). Users may access content through the service; this is not blanket scraping permission. Main robots excludes `/game/download/`; author subdomains exclude `/*/download/`. No challenge bypass. | Public free Android RSS for `openxr` and `oculus-quest`; substantial indie VR. Server API is mostly authenticated publisher/account functionality, not a general anonymous store-download API. | Implement RSS search, artwork and page links; `downloadable: False`. The supplied free-download script follows keyed download pages excluded by robots, so it is not shipped. | +| [GitHub releases](https://docs.github.com/en/rest/releases/releases) | Maintainers publish assets; curated repositories below establish provenance. Public hosting or an open-source topic alone does not establish rights to every uploaded binary. Use supported REST API under [API terms](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service#h-api-terms), not HTML crawling. | Releases API includes APK assets and sometimes SHA-256. Topic search finds OpenXR/Quest projects. 60 unauthenticated requests/hour; authenticated user limits are generally 5,000/hour, with separate search/secondary limits. | Implement curated downloads and explicit topic discovery. Unreviewed topic results are page-only. | +| [Uptodown](https://www.uptodown.com/aboutus) | Developer distribution program exists, but that does not prove publisher authorization for every catalog item. [Privacy policy](https://www.uptodown.com/aboutus/privacy) explicitly describes protection against automated access. General automation permission was not established. | Broad Android catalog, limited VR focus; no supported public consumer-download API established in this survey. | Page links only; no downloader. Do not infer consent from an unchanged APK signature. | +| [APKPure](https://apkpure.com/terms) | Third-party APK catalog; individual publisher consent and automation rights were not established. Terms request returned HTTP 403; no bypass attempted. | Broad Android coverage, incidental VR; internal endpoints are not permission to automate. | Exclude automatic indexing/downloading; user may open site. | +| [APKMirror](https://www.apkmirror.com/faq/) | Publisher-signed files and a free-app policy are not a blanket developer-consent or automation grant. FAQ request returned HTTP 403, so current terms could not be confirmed. | General Android/version archive; APK bundles often need another installer; little VR focus. No supported consumer-download API established. | Page links only, no scraping or bundle conversion. | +| [Aptoide](https://en.aptoide.com/company/legal) | Terms define an app supplier as developer, owner or authorized distributor; user stores still require per-item provenance. API availability alone does not settle third-party access rights. | API ecosystem and general Android catalog; weak VR focus. | Defer until a publisher-owned store and its API terms can be approved. No blanket community-store downloader. | +| [Amazon Appstore](https://developer.amazon.com/docs/app-submission/understanding-submission.html) | Official developer submissions; store account, device and license rules apply. Publisher submission APIs do not authorize public binary extraction. | Fire-device distribution; Android-device Appstore support ended in 2025; little Quest relevance. | Official product links only; no account or entitlement extraction. | +| [PICO / ByteDance store](https://developer.picoxr.com/document/distribute) | Official publisher channel with store/device entitlements. No public unauthenticated binary-download grant established; documentation request encountered a redirect error. | Strong standalone VR; PICO builds may depend on PICO services/extensions. | Store links only. A developer's independently published GitHub/itch build can qualify separately. | +| [Meta Horizon Store / former App Lab](https://www.meta.com/experiences/) | Official developer submissions. A free store entitlement is still an entitlement; no license bypass or authenticated store extraction. App Lab was folded into the main store in 2024. | Strongest Quest coverage; no supported anonymous APK-download API established. | Store links only; independently distributed free builds use their publisher source. | +| [Khronos samples](https://github.com/KhronosGroup/OpenXR-SDK-Source) | Official upstream, Apache-2.0 sample; developer-published release APKs. GitHub API terms apply. | `hello_xr` Vulkan/OpenGL ES APKs; excellent OpenXR diagnostics. | Included in GitHub curated list, Vulkan variant selected. | +| [Meta OpenXR samples](https://github.com/meta-quest/Meta-OpenXR-SDK) | Official upstream; check each sample's license. Source availability does not imply a published APK, and some samples require Meta extensions/services. | Source/build examples, inconsistent ready-made APK releases. | Link to upstream; add specific free APKs only after release/provenance review. | +| [Godot XR demos](https://github.com/GodotVR/godot-xr-tools) | Official project source and publisher demo pages; licenses and dependencies vary by demo. | OpenXR examples on GitHub/itch. Older Godot builds can fail on Lepton's missing clipboard service. | Covered by source discovery; no compatibility promise from an OpenXR tag. | + +The table distinguishes observed restrictions from unknown permission. An +unverified policy is a reason to defer automation, not a claim that a site is +unlawful. Only the two implemented source kinds are registered by their own +`sources()` functions; the other rows are recommendations, not new UI entries. + +## Adapters + +`ui/apk_sources/github.py` uses `github_curated.json`: Khronos `hello_xr`, +[Open Brush](https://github.com/icosa-foundation/open-brush), and +[SuperTux 3D](https://github.com/SgtBilko76/SuperTux-3D). These have official +OpenXR project/release evidence, not a blanket claim of headset compatibility. +Open Brush's compatibility evidence is recorded in [vr-apks.md](vr-apks.md). +Open Brush and SuperTux publish the selected builds as prereleases; curated +opt-ins preserve that label in version records. Exact APK filename patterns +avoid downloading desktop archives or alternate non-Quest builds. +[OpenSaberPlus](https://github.com/arpruss/OpenSaberPlus) was examined but not +curated: GitHub reports its license as `NOASSERTION`, and current OpenXR APK +provenance was not established in this pass. + +Default GitHub search is offline against this small list. Queries +`topic:openxr`, `topic:oculus-quest`, and `topic:quest` explicitly call repository +search. Results outside the curated list stay page-only, even if a repository +claims an open-source license. This prevents an arbitrary tagged mirror from +becoming a trusted downloader. Extend the curated JSON after provenance review. + +Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API +quota. Tokens are sent only to `api.github.com`, never written to the cache, +never sent to asset hosts, and removed on redirects. The adapter does not +read `gh` credentials automatically. Metadata is cached for one hour under +`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request +fetches at most ten releases. Rate-limit errors are surfaced without retry +loops. Asset IDs and release tags are not Android version codes: metadata +leaves the latter unknown and rejects a requested `version_code` rather than +silently fetching a different build. + +`itch.py` exposes separate OpenXR and Quest feed sources, so one feed's failure +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. +GitHub's release metadata has no standard app artwork field, so artwork stays +empty rather than presenting a repository-owner avatar as an app icon. VR is +based on curated evidence or a VR-specific feed/topic, not a compatibility claim. + +Downloads stream to unique temporary files, require an APK manifest entry, +restrict HTTPS origins and redirects, and enforce a 2 GiB ceiling. `verified` +means the downloaded SHA-256 matches GitHub's published digest. Without such a +digest, the computed SHA-256 is returned with `verified: False`; neither value +claims publisher-signature validation. Installation must inspect the APK as +usual. OBBs, split APKs, paid assets and external release-body download links +are unsupported. + +## Evidence and limits + +On this Mac, Python 3.9 downloaded the real Khronos Vulkan 1.1.63 APK through +the GitHub adapter, matched its published SHA-256 +`f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34`, and +`python3 ui/frame_android.py info ` exited 0: package +`org.khronos.openxr.hello_xr.vulkan`, version code 1063, minimum API 24, +arm64-v8a present, OpenXR detected. No Frame connection or installation occurred. + +The itch OpenXR RSS was fetched successfully and recorded as a fixture. +Subsequent live adapter search encountered HTTP 429; it is not claimed as a +successful live end-to-end search. Fixture search finds Off Nominal and parses +nine Android entries from the ten-item feed (one has only an HTML platform). +A real itch download and APK inspection were deliberately not performed: +robots restrictions take precedence over that requested proof. No current +policy text is claimed verified where the table records failed access. + +Tests use recorded, reduced API/RSS fixtures with network access blocked in +the new test class. They cover selection, prereleases, unknown topic results, +paid/non-Android exclusion, URL restrictions, redirect credential removal, +caching, rate limits, checksum mismatch, non-APK rejection and partial-file +cleanup. See `.claude/NOTES-more-sources.md` for commands and local evidence. diff --git a/tests/fixtures/more_sources/README.md b/tests/fixtures/more_sources/README.md new file mode 100644 index 0000000..be6d3c8 --- /dev/null +++ b/tests/fixtures/more_sources/README.md @@ -0,0 +1,15 @@ +Recorded 2026-09-28 from public publisher endpoints using FrameControl/0.1 or +`gh api`. JSON fixtures are reduced to fields consumed by the adapters; API +values are unchanged. No token, cookies or signed download URL is included. + +- `*-releases.json`: `/repos/{repo}/releases?per_page=10`, first two releases, + for KhronosGroup/OpenXR-SDK-Source, icosa-foundation/open-brush and + SgtBilko76/SuperTux-3D (one release). +- `topic.json`: `/search/repositories?q=topic:openxr+archived:false&sort=stars&per_page=3`. +- `itch-feed.txt`: `https://itch.io/games/free/platform-android/tag-openxr.xml`. +- `itch-robots.txt`: `https://itch.io/robots.txt`. +- `itch-author-robots.txt`: `https://godotvr.itch.io/robots.txt`. + +The synthetic ZIP in tests is only a transport/integrity fixture, not an +installable APK. Actual APK parsing was verified separately on the downloaded +Khronos Vulkan sample; see docs/apk-sources.md. diff --git a/tests/fixtures/more_sources/brush-releases.json b/tests/fixtures/more_sources/brush-releases.json new file mode 100644 index 0000000..c17f800 --- /dev/null +++ b/tests/fixtures/more_sources/brush-releases.json @@ -0,0 +1,88 @@ +[ + { + "tag_name": "2.32.29", + "draft": false, + "prerelease": true, + "published_at": "2026-09-26T17:49:28Z", + "assets": [ + { + "id": 591143285, + "name": "OpenBrush_Android_2.32.29.apk", + "size": 314602794, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Android_2.32.29.apk", + "digest": "sha256:f20361b830803a2bf53e2af648bba59ee17bc4615bde534dbf6c4f0012bbd291" + }, + { + "id": 591143287, + "name": "OpenBrush_Desktop_2.32.29.zip", + "size": 380735034, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Desktop_2.32.29.zip", + "digest": "sha256:3b680b07ca0b8def579fe194916aa7db4d007c3094c4dea818124776098c9b9a" + }, + { + "id": 591143282, + "name": "OpenBrush_Linux_2.32.29.zip", + "size": 364906490, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Linux_2.32.29.zip", + "digest": "sha256:e380934f77d2b1441179424193a75f7b4b5793b34761c04cbf2d87bad9f8fc16" + }, + { + "id": 591143283, + "name": "OpenBrush_Mac_2.32.29.dmg", + "size": 373196471, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Mac_2.32.29.dmg", + "digest": "sha256:5d544d0a64bed1cae65173fcfa7ada069d4f81b42385e806e99cc4427ef3513c" + }, + { + "id": 591143286, + "name": "OpenBrush_Quest_2.32.29.apk", + "size": 314603546, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Quest_2.32.29.apk", + "digest": "sha256:57cd7b9067689060451494e55dc06276f934a992f5cbbd44d965069903937ba6" + } + ] + }, + { + "tag_name": "2.32.28", + "draft": false, + "prerelease": true, + "published_at": "2026-09-26T14:42:27Z", + "assets": [ + { + "id": 590837298, + "name": "OpenBrush_Android_2.32.28.apk", + "size": 314603450, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Android_2.32.28.apk", + "digest": "sha256:1a3af1a194c4348ef67c8ea61d9a8258e2490cdfe1f760cbc3c69f2978a6a082" + }, + { + "id": 590837301, + "name": "OpenBrush_Desktop_2.32.28.zip", + "size": 380738236, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Desktop_2.32.28.zip", + "digest": "sha256:c2de5424bc022951f0e0bdbd652ede549a1e60d9cfbf41c730ea43d16d97262f" + }, + { + "id": 590837297, + "name": "OpenBrush_Linux_2.32.28.zip", + "size": 364907046, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Linux_2.32.28.zip", + "digest": "sha256:29daf410347b3ca36e47808e31172270df8040ba7decc83be2bdcd51de895e06" + }, + { + "id": 590837296, + "name": "OpenBrush_Mac_2.32.28.dmg", + "size": 373195966, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Mac_2.32.28.dmg", + "digest": "sha256:2f352d766450c993fdcae8a8552878a6a976d32d675246b63c81bb1b326fd20d" + }, + { + "id": 590837295, + "name": "OpenBrush_Quest_2.32.28.apk", + "size": 314604234, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Quest_2.32.28.apk", + "digest": "sha256:9a3af6a6e838dd8bd201e549c5570f67db794df940e960390aeeae93235d702e" + } + ] + } +] diff --git a/tests/fixtures/more_sources/itch-author-robots.txt b/tests/fixtures/more_sources/itch-author-robots.txt new file mode 100644 index 0000000..5dacc48 --- /dev/null +++ b/tests/fixtures/more_sources/itch-author-robots.txt @@ -0,0 +1,12 @@ +User-agent: Mediapartners-Google +Disallow: + +User-agent: * +Disallow: /*/download/ +Disallow: /*/rh/ +Disallow: /*/rp/ +Disallow: /-/ + +Sitemap: https://itch.io/sitemap.xml + +# vim: set ft=robots: diff --git a/tests/fixtures/more_sources/itch-feed.txt b/tests/fixtures/more_sources/itch-feed.txt new file mode 100644 index 0000000..87a9e72 --- /dev/null +++ b/tests/fixtures/more_sources/itch-feed.txt @@ -0,0 +1,11 @@ +Top free games for Android tagged openxr - itch.iohttps://itch.io/games/free/platform-android/tag-openxrhttps://leandrodreamer.itch.io/open-saberOpen Saber [Free] [Rhythm] [Windows] [Linux] [Android]Open Saberhttps://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif$0.00USDhttps://leandrodreamer.itch.io/open-saber]]>Thu, 07 Sep 2023 02:11:50 GMTThu, 07 Sep 2023 02:11:50 GMTWed, 08 Jan 2025 02:24:29 GMTyeshttps://absyo.itch.io/off-nominalOff Nominal [Free] [Puzzle] [Windows] [Linux] [Android]Off Nominalhttps://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png$0.00USDhttps://absyo.itch.io/off-nominal]]>Fri, 25 Sep 2026 19:54:48 GMTFri, 25 Sep 2026 19:54:48 GMTSun, 27 Sep 2026 23:25:20 GMTyesyesyeshttps://somar-project.itch.io/somar-projectSomar-project [Free] [Educational] [Android]Somar-projecthttps://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png$0.00USDhttps://somar-project.itch.io/somar-project]]>Wed, 26 Mar 2025 17:17:58 GMTWed, 26 Mar 2025 17:17:58 GMTFri, 28 Mar 2025 15:52:59 GMTyeshttps://5imon.itch.io/buggenesisBug Genesis VR [Free] [Interactive Fiction] [Windows] [Android]Bug Genesis VRhttps://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg$0.00USDhttps://5imon.itch.io/buggenesis]]>Sun, 25 May 2025 20:22:49 GMTSun, 25 May 2025 20:22:49 GMTSun, 25 May 2025 20:37:39 GMTyesyeshttps://benmclean.itch.io/wolfsharpWolfSharp [Free] [Shooter] [Windows] [Linux] [Android]WolfSharphttps://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png$0.00USDhttps://benmclean.itch.io/wolfsharp]]>Sat, 25 Jul 2026 12:16:01 GMTSat, 25 Jul 2026 12:16:01 GMTSat, 25 Jul 2026 13:45:38 GMTyesyesyeshttps://mimekunst.itch.io/winter-solitude-vrWinter Solitude VR [Free] [Simulation] [Windows] [macOS] [Linux] [Android]Winter Solitude VRhttps://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png$0.00USDhttps://mimekunst.itch.io/winter-solitude-vr]]>Tue, 19 Dec 2023 19:19:59 GMTTue, 19 Dec 2023 19:19:59 GMTWed, 20 Dec 2023 22:49:23 GMTyesyesyesyeshttps://salmondev.itch.io/evil-miner-vrEVIL MINER VR [Free] [Other] [Windows] [Android]EVIL MINER VRhttps://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png$0.00USDhttps://salmondev.itch.io/evil-miner-vr]]>Fri, 13 Jun 2025 16:48:01 GMTFri, 13 Jun 2025 16:48:01 GMTSun, 15 Jun 2025 15:19:53 GMTyesyeshttps://robinhuud.itch.io/winter-challenge-north-pole-defenseNorth Pole Defense VR [Free] [Action] [Android]North Pole Defense VRhttps://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png$0.00USDhttps://robinhuud.itch.io/winter-challenge-north-pole-defense]]>Thu, 16 Dec 2021 01:33:33 GMTThu, 16 Dec 2021 01:33:33 GMTThu, 16 Dec 2021 01:51:29 GMTyeshttps://envemos.itch.io/ambly-native-xrAmbly Native XR [Free] [Linux] [Android]Ambly Native XRhttps://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg$0.00USDhttps://envemos.itch.io/ambly-native-xr]]>Wed, 22 Jul 2026 18:38:55 GMTWed, 22 Jul 2026 18:38:55 GMTFri, 07 Aug 2026 16:04:20 GMTyesyeshttps://andyman404.itch.io/glow-up-gardenGlow Up Garden (VR) [Free] [Action] [Windows] [Android]Glow Up Garden (VR)https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg$0.00USDhttps://andyman404.itch.io/glow-up-garden]]>Mon, 03 Jun 2024 20:36:53 GMTMon, 03 Jun 2024 20:36:53 GMTTue, 04 Jun 2024 04:20:37 GMTyesyes \ No newline at end of file diff --git a/tests/fixtures/more_sources/itch-robots.txt b/tests/fixtures/more_sources/itch-robots.txt new file mode 100644 index 0000000..3b8cbfc --- /dev/null +++ b/tests/fixtures/more_sources/itch-robots.txt @@ -0,0 +1,11 @@ +User-agent: * +Disallow: /embed/ +Disallow: /embed-upload/ +Disallow: /search +Disallow: /checkout/ +Disallow: /game/download/ +Disallow: /bundle/download/ +Disallow: /register-for-purchase/ +Disallow: /email-feedback/ + +Sitemap: https://itch.io/sitemap.xml diff --git a/tests/fixtures/more_sources/khronos-releases.json b/tests/fixtures/more_sources/khronos-releases.json new file mode 100644 index 0000000..5de075d --- /dev/null +++ b/tests/fixtures/more_sources/khronos-releases.json @@ -0,0 +1,410 @@ +[ + { + "tag_name": "release-1.1.63", + "draft": false, + "prerelease": false, + "published_at": "2026-09-02T21:22:32Z", + "assets": [ + { + "id": 541779948, + "name": "apilayer_api_dump-1.1.63.aar", + "size": 5120886, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.aar", + "digest": "sha256:9cab975cc8df3a99f6530f47a1fbabfa0527109a138f5a3ef5150672a658c61c" + }, + { + "id": 541779969, + "name": "apilayer_api_dump-1.1.63.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.aar.asc", + "digest": "sha256:419ec65d3f526c617176f272d8a481488181ade017cb05ef42205cb2c5a86f05" + }, + { + "id": 541779983, + "name": "apilayer_api_dump-1.1.63.pom", + "size": 1462, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.pom", + "digest": "sha256:9a5b3e470830ae471fe317bc63f43b33bc063458239238fe27e234232c45ff28" + }, + { + "id": 541780002, + "name": "apilayer_api_dump-1.1.63.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.pom.asc", + "digest": "sha256:7cbf9f1af504ca68fc36e0985dadb74d1fbf4d2bbdcde3e00bfe9ea6168fc4a8" + }, + { + "id": 541780126, + "name": "apilayer_best_practices_validation-1.1.63.aar", + "size": 484596, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.aar", + "digest": "sha256:0c56cb3dc0b093b28f4e5490820d3cb3f7b201b48444134f347455d4ee99abd2" + }, + { + "id": 541780150, + "name": "apilayer_best_practices_validation-1.1.63.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.aar.asc", + "digest": "sha256:7eb9ab3efe939c367e4661d5a75836c1d9e0799ab627e99211253546935defa7" + }, + { + "id": 541780162, + "name": "apilayer_best_practices_validation-1.1.63.pom", + "size": 1487, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.pom", + "digest": "sha256:97d410936f5f051ab2a73cdac196c744ac56b2dde6279a5e46e944ed61316147" + }, + { + "id": 541780192, + "name": "apilayer_best_practices_validation-1.1.63.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.pom.asc", + "digest": "sha256:7f9fa0cd33627c4ecc2a4410e53dedadb5731b3cddae59a3c0d4fcd467b3472d" + }, + { + "id": 541780025, + "name": "apilayer_core_validation-1.1.63.aar", + "size": 6386964, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.aar", + "digest": "sha256:9663ce94a5076b6707502cf5503bac456987ccf1f39a3f7c8f350d4521db8647" + }, + { + "id": 541780057, + "name": "apilayer_core_validation-1.1.63.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.aar.asc", + "digest": "sha256:c6e75df848ca6d436fe24f680bde73a9e69972b67eef46e49aba4b77dec366e9" + }, + { + "id": 541780090, + "name": "apilayer_core_validation-1.1.63.pom", + "size": 1455, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.pom", + "digest": "sha256:6aa9337f5e645baf489c05e562cd074dc696bad87db70a0cdd661dffc63b2c89" + }, + { + "id": 541780108, + "name": "apilayer_core_validation-1.1.63.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.pom.asc", + "digest": "sha256:0fe8ded9fdf0660bf28a544ae405b9b58682a8d9648dacedf4e4ceef2f827869" + }, + { + "id": 541777706, + "name": "hello_xr-OpenGLES-release-1.1.63.apk", + "size": 9557049, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/hello_xr-OpenGLES-release-1.1.63.apk", + "digest": "sha256:7c96022ac002cb0c72e3cd14c11a817767b7b5dbdf5a1d1c85d0c083b5719056" + }, + { + "id": 541777527, + "name": "hello_xr-Vulkan-release-1.1.63.apk", + "size": 9557441, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/hello_xr-Vulkan-release-1.1.63.apk", + "digest": "sha256:f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34" + }, + { + "id": 541800362, + "name": "OpenXR-SDK-Source-release-1.1.63.tar.gz", + "size": 4857593, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR-SDK-Source-release-1.1.63.tar.gz", + "digest": "sha256:a3b97a36f11abe256a7ea1668a0a468aac9b738e94bea6b468f0ae31ad537a46" + }, + { + "id": 541800378, + "name": "OpenXR-SDK-Source-release-1.1.63.tar.gz.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR-SDK-Source-release-1.1.63.tar.gz.asc", + "digest": "sha256:4f97028306ae219f599e9960adbf9bb072e8da2bfbedffd1f0de312516a61f87" + }, + { + "id": 541821806, + "name": "OpenXR.Loader.1.1.63.nupkg", + "size": 1916162, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR.Loader.1.1.63.nupkg", + "digest": "sha256:4e5a50a8807ef66f25180ff224e7d8150b594aa8ee4b07590f9ade55a8e98703" + }, + { + "id": 541821827, + "name": "OpenXR.Loader.1.1.63.nupkg.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR.Loader.1.1.63.nupkg.asc", + "digest": "sha256:9d66a6e958f7c2d5c4a0a4aef8df90a7beecab13547440c9fa2069979eab7ac7" + }, + { + "id": 541779892, + "name": "openxr_loader_for_android-1.1.63-sources.jar", + "size": 1141287, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63-sources.jar", + "digest": "sha256:6f964ad09c4afa3f42f451cada86e61503392b018660b22dd34b61dfbf71a555" + }, + { + "id": 541779920, + "name": "openxr_loader_for_android-1.1.63-sources.jar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63-sources.jar.asc", + "digest": "sha256:b98cba20f5c3b202b887307cb19196f4459f895413e55b68823a606f50d045fa" + }, + { + "id": 541779720, + "name": "openxr_loader_for_android-1.1.63.aar", + "size": 4170279, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.aar", + "digest": "sha256:622419d2f6741c3443a3beb4779af0764318edd01830de967f24c741ebcded73" + }, + { + "id": 541779762, + "name": "openxr_loader_for_android-1.1.63.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.aar.asc", + "digest": "sha256:3bb68b26d7def68b4fe8506bf09f302116290c2de9cf91fdfdba753978bff5ed" + }, + { + "id": 541779849, + "name": "openxr_loader_for_android-1.1.63.pom", + "size": 1598, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.pom", + "digest": "sha256:c98f38fa8acf4cf1bd8bcb40774f9815ae6ed9da94c8a7be2ed9db7815c85404" + }, + { + "id": 541779867, + "name": "openxr_loader_for_android-1.1.63.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.pom.asc", + "digest": "sha256:1c2625f5b889c7ed967c8a6010294ca94bbd96091d879b2fc989c6f40f9a6af1" + }, + { + "id": 541793000, + "name": "openxr_loader_macos-1.1.63.zip", + "size": 826298, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_macos-1.1.63.zip", + "digest": "sha256:b243eebcdfa8683d17ccc8cfbfb9036be94b3f5a22b3eb73c39da0877694a3ab" + }, + { + "id": 541793027, + "name": "openxr_loader_macos-1.1.63.zip.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_macos-1.1.63.zip.asc", + "digest": "sha256:3e95172aabc4bd2537a7125060abbb8efbdd0cee19bdf1bf30cbd9736b7dcbd2" + }, + { + "id": 541784717, + "name": "openxr_loader_windows-1.1.63.zip", + "size": 31961521, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_windows-1.1.63.zip", + "digest": "sha256:01c631aeabbfe0879540f77ef833416c532a20746285b494630160c23588b771" + }, + { + "id": 541784760, + "name": "openxr_loader_windows-1.1.63.zip.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_windows-1.1.63.zip.asc", + "digest": "sha256:e9384e2a94d82c5059d1d83c57d0da585056d95e393255048b0955b0ce69b3fc" + } + ] + }, + { + "tag_name": "release-1.1.62", + "draft": false, + "prerelease": false, + "published_at": "2026-08-01T01:32:30Z", + "assets": [ + { + "id": 500510340, + "name": "apilayer_api_dump-1.1.62.aar", + "size": 4800443, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.aar", + "digest": "sha256:2a7c2d1bb14d94dfed8c1aaf170a9dda649756477fbcba4a143c76d08a50bed8" + }, + { + "id": 500510366, + "name": "apilayer_api_dump-1.1.62.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.aar.asc", + "digest": "sha256:7ab53e3c1fcaabf49561737fe8ed5df9ad9a5a761615f05e1d5eed2799e85c5f" + }, + { + "id": 500510378, + "name": "apilayer_api_dump-1.1.62.pom", + "size": 1462, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.pom", + "digest": "sha256:fcd4358d7582ce0787b96aacb9840afc086a28499767a6aa7491dbb682bcc921" + }, + { + "id": 500510385, + "name": "apilayer_api_dump-1.1.62.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.pom.asc", + "digest": "sha256:4832ce5c8835d1880ae5adbc535b774d50f8c86f9870650a50fbf3b9993ec5fa" + }, + { + "id": 500510464, + "name": "apilayer_best_practices_validation-1.1.62.aar", + "size": 482607, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.aar", + "digest": "sha256:6d1a369ba367049aff23ae554b284ccc17cb3be8832869de0c1d151228a26502" + }, + { + "id": 500510477, + "name": "apilayer_best_practices_validation-1.1.62.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.aar.asc", + "digest": "sha256:658ecbb7f871e97ed0d659ed55b27ca6ff6cc6e1853699498ee7b1d5583d7313" + }, + { + "id": 500510480, + "name": "apilayer_best_practices_validation-1.1.62.pom", + "size": 1487, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.pom", + "digest": "sha256:571d7c5587075e83ca0a4d2382d87515de614ab8c34416a82a1b9b1a7eb5f9c0" + }, + { + "id": 500510489, + "name": "apilayer_best_practices_validation-1.1.62.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.pom.asc", + "digest": "sha256:342d0ed7080e92399dbc8648df4fe9378086718f1abc73f79f3a52de211ed36e" + }, + { + "id": 500510395, + "name": "apilayer_core_validation-1.1.62.aar", + "size": 5910024, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.aar", + "digest": "sha256:5692ccd5563a0963c4d842614af11d7c280960687a221643a46266ef968c4d70" + }, + { + "id": 500510422, + "name": "apilayer_core_validation-1.1.62.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.aar.asc", + "digest": "sha256:1e39ff48d4cd87231d931515968317c717a6811da84585650f0623c49329ec41" + }, + { + "id": 500510432, + "name": "apilayer_core_validation-1.1.62.pom", + "size": 1455, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.pom", + "digest": "sha256:1917e5cee9b979c9032c7819c386ea62e4498c30ffbbcf0c25578ebcd0c1e441" + }, + { + "id": 500510453, + "name": "apilayer_core_validation-1.1.62.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.pom.asc", + "digest": "sha256:8aed84009daa5e388b7d179ab90837e9537b4f762a1676aab922e03dc633ed18" + }, + { + "id": 497398718, + "name": "hello_xr-OpenGLES-release-1.1.62.apk", + "size": 9548745, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/hello_xr-OpenGLES-release-1.1.62.apk", + "digest": "sha256:da5e421795b801684cab50156c572422b73cd98fc8c75aeeb968962b43a2ab46" + }, + { + "id": 497398704, + "name": "hello_xr-Vulkan-release-1.1.62.apk", + "size": 9549137, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/hello_xr-Vulkan-release-1.1.62.apk", + "digest": "sha256:a154b2353983f8c0cb1827ddf51d7e80fc105085253fe524502f35c5ddac3284" + }, + { + "id": 500514717, + "name": "OpenXR-SDK-Source-release-1.1.62.tar.gz", + "size": 4834887, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR-SDK-Source-release-1.1.62.tar.gz", + "digest": "sha256:977073d7f4c0d1af8ab975f57e4b6ffd1c4e9209be66075812b890576e0e1e5f" + }, + { + "id": 500514735, + "name": "OpenXR-SDK-Source-release-1.1.62.tar.gz.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR-SDK-Source-release-1.1.62.tar.gz.asc", + "digest": "sha256:3ea4d6e47da6a8b3480931636af3e85eb2e0cddaf582153ee97973a8b05a5214" + }, + { + "id": 500514501, + "name": "OpenXR.Loader.1.1.62.nupkg", + "size": 1902954, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR.Loader.1.1.62.nupkg", + "digest": "sha256:6bb16b4dbe3c11f29605def2def5b7d1177784c0403dc9a24bc2e13d7eb82604" + }, + { + "id": 500514512, + "name": "OpenXR.Loader.1.1.62.nupkg.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR.Loader.1.1.62.nupkg.asc", + "digest": "sha256:386dfd33e9c2db9c9c37d881b77eec9b74d181fda394b47c473b2bce37fd7005" + }, + { + "id": 500510319, + "name": "openxr_loader_for_android-1.1.62-sources.jar", + "size": 1110593, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62-sources.jar", + "digest": "sha256:b83318394b30bb129b069dd854de2b1e21df4376dda1a7fa342aeaff17a71d3d" + }, + { + "id": 500510327, + "name": "openxr_loader_for_android-1.1.62-sources.jar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62-sources.jar.asc", + "digest": "sha256:838b5f5a23329eb7f0e13afde7a7d6ae73b439c7cbf6d3ec0af3e65efd7d5bd6" + }, + { + "id": 500510263, + "name": "openxr_loader_for_android-1.1.62.aar", + "size": 4158815, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.aar", + "digest": "sha256:c03c689fed9a48f9394af953660982c998b00f6d2d2d8d150bc3f890c75a7465" + }, + { + "id": 500510280, + "name": "openxr_loader_for_android-1.1.62.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.aar.asc", + "digest": "sha256:883ccab775776c65ee35bf3120553f6a3f0f47de2dd661e074469e98d3caea46" + }, + { + "id": 500510292, + "name": "openxr_loader_for_android-1.1.62.pom", + "size": 1598, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.pom", + "digest": "sha256:9b1047158a416984fd6d60c472da09bb324aec74709f83a1516435917fa05064" + }, + { + "id": 500510305, + "name": "openxr_loader_for_android-1.1.62.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.pom.asc", + "digest": "sha256:9dd7d3f79ad76a48f709f55d8c205892ae30f70b10a44c4afbd51f50603c4478" + }, + { + "id": 500514048, + "name": "openxr_loader_macos-1.1.62.zip", + "size": 817438, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_macos-1.1.62.zip", + "digest": "sha256:400bf9ab932d04cf8a315fe8e63d5cd9824015b64ad2e5d72b2ffc086c54313c" + }, + { + "id": 500514061, + "name": "openxr_loader_macos-1.1.62.zip.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_macos-1.1.62.zip.asc", + "digest": "sha256:034a3575bbee545926dc59558aa5d62ea9fc2de9e23c426ac640cbb68bd8db88" + }, + { + "id": 500513308, + "name": "openxr_loader_windows-1.1.62.zip", + "size": 30975472, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_windows-1.1.62.zip", + "digest": "sha256:800ec772e2f9448a26ab9f579f4914d984346dd9d0d7c007841abe21d2c8ff2f" + }, + { + "id": 500513351, + "name": "openxr_loader_windows-1.1.62.zip.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_windows-1.1.62.zip.asc", + "digest": "sha256:8117563bfc5092895e17112366cb954ca78f84964e5c09526dfd69656c1713fd" + } + ] + } +] diff --git a/tests/fixtures/more_sources/topic.json b/tests/fixtures/more_sources/topic.json new file mode 100644 index 0000000..129a59a --- /dev/null +++ b/tests/fixtures/more_sources/topic.json @@ -0,0 +1,19 @@ +{ + "items": [ + { + "full_name": "LWJGL/lwjgl3", + "name": "lwjgl3", + "description": "LWJGL is a Java library that enables cross-platform access to popular native APIs useful in the development of graphics (OpenGL, Vulkan, bgfx), audio (OpenAL, Opus), parallel computing (OpenCL, CUDA) and XR (OpenVR, LibOVR, OpenXR) applications." + }, + { + "full_name": "sahibzada-allahyar/YC-Killer", + "name": "YC-Killer", + "description": "A library of enterprise-grade AI agents designed to democratize artificial intelligence and provide free, open-source alternatives to overvalued Y Combinator startups." + }, + { + "full_name": "bjornbytes/lovr", + "name": "lovr", + "description": "Lua Virtual Reality Framework" + } + ] +} diff --git a/tests/fixtures/more_sources/tux-releases.json b/tests/fixtures/more_sources/tux-releases.json new file mode 100644 index 0000000..2d482fa --- /dev/null +++ b/tests/fixtures/more_sources/tux-releases.json @@ -0,0 +1,17 @@ +[ + { + "tag_name": "Beta0.2", + "draft": false, + "prerelease": true, + "published_at": "2026-09-23T14:51:47Z", + "assets": [ + { + "id": 583977968, + "name": "SuperTux-Beta0.2-quest-pico-arm64-v8a.apk", + "size": 294152462, + "browser_download_url": "https://github.com/SgtBilko76/SuperTux-3D/releases/download/Beta0.2/SuperTux-Beta0.2-quest-pico-arm64-v8a.apk", + "digest": "sha256:63287e5d6f1866193e0d4730bf4a2ba87fd2fbdbe6317bd6bd24b96a8ddc9963" + } + ] + } +] diff --git a/tests/test_apk_more_sources.py b/tests/test_apk_more_sources.py new file mode 100644 index 0000000..a21dd90 --- /dev/null +++ b/tests/test_apk_more_sources.py @@ -0,0 +1,108 @@ +import hashlib, io, json, os, sys, tempfile, unittest, urllib.error, 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, github, itch, _web + +FIX = Path(__file__).parent / 'fixtures' / 'more_sources' + + +class PublisherSources(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.cache = patch.object(_web, 'cache', return_value=self.tmp.name) + self.cache.start() + self.addCleanup(self.cache.stop) + self.network = patch('urllib.request.OpenerDirector.open', side_effect=AssertionError('network in test')) + self.network.start() + self.addCleanup(self.network.stop) + + def test_curated_search_is_offline(self): + self.assertEqual(github.search(github.sources()[0], 'hello')[0]['id'], 'KhronosGroup/OpenXR-SDK-Source') + self.assertEqual(github.search(github.sources()[0], '', 0), []) + + def test_real_releases(self): + for key, repo in [('khronos', 'KhronosGroup/OpenXR-SDK-Source'), + ('brush', 'icosa-foundation/open-brush'), ('tux', 'SgtBilko76/SuperTux-3D')]: + with patch.object(github, '_api', return_value=json.loads((FIX / (key + '-releases.json')).read_text())): + e = github.details(github.sources()[0], repo) + self.assertTrue(e['downloadable']) + self.assertTrue(e['versions'][0]['name'].endswith('.apk')) + self.assertIsNone(e['version_code']) + with self.assertRaises(SourceError): + github.download(github.sources()[0], repo, 123) + + def test_topic_results_need_approval(self): + data = json.loads((FIX / 'topic.json').read_text()) + with patch.object(github, '_api', return_value=data): + entries = github.search(github.sources()[0], 'topic:openxr') + self.assertTrue(entries) + self.assertTrue(any(not e['downloadable'] for e in entries)) + self.assertFalse(github.details(github.sources()[0], 'unknown/project')['downloadable']) + with self.assertRaises(SourceError): + github.search(github.sources()[0], 'topic:piracy') + + def test_feed_free_android_only_and_deduplicated(self): + with patch.object(_web, 'read', return_value=(FIX / 'itch-feed.txt').read_bytes()): + entries = itch.search(itch.sources()[0], '') + self.assertEqual(len(entries), 9) + e = itch.details(itch.sources()[0], entries[0]['id']) + self.assertTrue(e['vr']) + self.assertTrue(e['images']['banner']) + self.assertFalse(e['downloadable']) + self.assertEqual(itch.search(itch.sources()[0], 'off nominal')[0]['name'], 'Off Nominal') + with self.assertRaises(SourceError): + itch.download(itch.sources()[0], entries[0]['id']) + with self.assertRaises(SourceError): + itch._parse(itch.sources()[0], b'not xml') + + def test_paid_and_unsafe_feed(self): + raw = (FIX / 'itch-feed.txt').read_bytes().replace(b'$0.00', b'$1.00') + self.assertEqual(itch._parse(itch.sources()[0], raw), []) + raw = (FIX / 'itch-feed.txt').read_bytes().replace(b'https://absyo.itch.io', b'http://localhost') + self.assertFalse(any(e['name'] == 'Off Nominal' for e in itch._parse(itch.sources()[0], raw))) + + def test_download_hash_and_cleanup(self): + b = io.BytesIO() + with zipfile.ZipFile(b, 'w') as z: + z.writestr('AndroidManifest.xml', b'fixture') + raw = b.getvalue() + digest = hashlib.sha256(raw).hexdigest() + with patch.object(_web, 'open_url', return_value=io.BytesIO(raw)): + result = _web.apk('https://github.com/owner/repo/file.apk', github.HOSTS, digest) + self.assertTrue(result['verified']) + self.assertEqual(Path(result['apk']).read_bytes(), raw) + with patch.object(_web, 'open_url', return_value=io.BytesIO(raw)): + self.assertFalse(_web.apk('https://github.com/file.apk', github.HOSTS)['verified']) + for content, expected in [(raw, '0' * 64), (b'html challenge', None)]: + with patch.object(_web, 'open_url', return_value=io.BytesIO(content)), self.assertRaises(SourceError): + _web.apk('https://github.com/file.apk', github.HOSTS, expected) + self.assertFalse(list(Path(self.tmp.name).glob('*.part'))) + + def test_origin_and_redirect(self): + for url in ['http://github.com/x', 'https://evil.test/x', 'https://user@github.com/x']: + with self.assertRaises(SourceError): + _web.checked_url(url, github.HOSTS) + req = urllib.request.Request('https://api.github.com/x', headers={'Authorization': 'Bearer secret'}) + handler = _web.Redirect(('api.github.com', 'github.com')) + redirected = handler.redirect_request(req, None, 302, '', {}, 'https://github.com/x') + self.assertFalse(redirected.has_header('Authorization')) + with self.assertRaises(SourceError): + handler.redirect_request(req, None, 302, '', {}, 'https://evil.test/x') + + def test_cache_rate_limit_and_invalid_json(self): + with patch.object(_web, 'open_url', return_value=io.BytesIO(b'index')) as op: + self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index') + self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index') + self.assertEqual(op.call_count, 1) + error = urllib.error.HTTPError('https://api.github.com/x', 403, 'limited', {}, None) + with patch.object(_web, 'open_url', side_effect=error), self.assertRaisesRegex(SourceError, 'rate limit'): + _web.read('https://api.github.com/x', ('api.github.com',)) + with patch.object(_web, 'read', return_value=b''), self.assertRaises(SourceError): + github._api('/x') + + +if __name__ == '__main__': + unittest.main() diff --git a/ui/apk_sources/_web.py b/ui/apk_sources/_web.py new file mode 100644 index 0000000..1ed8d4a --- /dev/null +++ b/ui/apk_sources/_web.py @@ -0,0 +1,101 @@ +"""Small HTTPS cache and APK downloader for public publisher sources.""" +import hashlib, os, tempfile, time, urllib.error, urllib.parse, urllib.request, zipfile + +import frame_host +from . import SourceError + +UA = 'FrameControl/0.1' + + +def cache(): + path = frame_host.cache_dir('apk-sources', 'publisher') + os.makedirs(path, exist_ok=True) + return str(path) + + +def checked_url(url, hosts): + try: + p = urllib.parse.urlsplit(url) + port = p.port + except (ValueError, TypeError) as e: + raise SourceError('Source returned an invalid URL') from e + if p.scheme != 'https' or p.username or p.password or port not in (None, 443) or p.hostname not in hosts: + raise SourceError('Source returned an unexpected download URL') + return url + + +class Redirect(urllib.request.HTTPRedirectHandler): + def __init__(self, hosts): + self.hosts = hosts + + def redirect_request(self, req, fp, code, msg, headers, newurl): + checked_url(newurl, self.hosts) + result = super().redirect_request(req, fp, code, msg, headers, newurl) + if result: + result.remove_header('Authorization') + return result + + +def open_url(url, hosts, headers=None): + checked_url(url, hosts) + return urllib.request.build_opener(Redirect(hosts)).open( + urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60) + + +def read(url, hosts, headers=None, ttl=3600): + path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data') + try: + if os.path.isfile(path) and time.time() - os.path.getmtime(path) < ttl: + with open(path, 'rb') as f: + return f.read() + with open_url(url, hosts, headers) as r: + data = r.read(8 * 1024 * 1024 + 1) + if len(data) > 8 * 1024 * 1024: + raise SourceError('Source index is too large') + fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part') + try: + with os.fdopen(fd, 'wb') as f: + f.write(data) + os.replace(tmp, path) + finally: + if os.path.exists(tmp): + os.remove(tmp) + return data + except urllib.error.HTTPError as e: + if e.code in (403, 429): + raise SourceError('Source refused access or reached its rate limit; try later (GitHub accepts FRAME_GITHUB_TOKEN)') from e + raise SourceError('Source HTTP error: ' + str(e.code)) from e + except (OSError, ValueError) as e: + raise SourceError('Could not read source: ' + str(e)) from e + + +def apk(url, hosts, digest=None): + tmp = None + 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: + size = 0 + while True: + chunk = r.read(1 << 20) + if not chunk: + break + size += len(chunk) + if size > 2 * 1024 ** 3: + raise SourceError('APK exceeds the 2 GiB download limit') + h.update(chunk) + f.write(chunk) + actual = h.hexdigest() + if digest and actual != digest: + raise SourceError('SHA-256 mismatch; download discarded') + with zipfile.ZipFile(tmp) as z: + if 'AndroidManifest.xml' not in z.namelist(): + raise SourceError('Download is not an APK') + path = os.path.join(cache(), actual + '.apk') + os.replace(tmp, path) + return {'apk': path, 'obb': [], 'sha256': actual, 'verified': bool(digest)} + 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) diff --git a/ui/apk_sources/github.py b/ui/apk_sources/github.py new file mode 100644 index 0000000..6c7516a --- /dev/null +++ b/ui/apk_sources/github.py @@ -0,0 +1,110 @@ +"""Curated publisher releases; optional topic discovery is page-only.""" +import fnmatch, json, os, re, urllib.parse + +from . import SourceError, _web + +KIND = 'github' +API = 'https://api.github.com' +TOPICS = ('oculus-quest', 'openxr', 'quest') +HOSTS = ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com') + + +def sources(): + return [{'id': KIND, 'kind': KIND, 'name': 'GitHub releases', 'url': 'https://github.com', + 'builtin': True, 'enabled': True, 'trust': 'community'}] + + +def _curated(): + with open(os.path.join(os.path.dirname(__file__), 'github_curated.json')) as f: + return json.load(f) + + +def _api(path): + headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'} + token = os.environ.get('FRAME_GITHUB_TOKEN') + if token: + headers['Authorization'] = 'Bearer ' + token + try: + return json.loads(_web.read(API + path, ('api.github.com',), headers)) + except (ValueError, TypeError) as e: + raise SourceError('Invalid GitHub response') from e + + +def _entry(source, c, approved=True): + return {'source': source['id'], 'id': c['repo'], 'package': None, + 'name': c['name'], 'summary': c.get('summary') or '', 'icon': None, + 'images': {'icon': None, 'banner': None, 'screenshots': []}, + 'page': 'https://github.com/' + c['repo'], 'version': None, 'version_code': None, + 'min_sdk': None, 'abis': None, 'vr': c.get('vr'), 'size': None, + 'free': True if approved else None, 'license': c.get('license'), 'updated': None, + 'downloadable': approved} + + +def search(source, query, limit=50): + limit = max(0, min(int(limit), 100)) + if not limit: + return [] + if query.startswith('topic:'): + topic = query[6:].strip() + if topic not in TOPICS: + raise SourceError('Choose topic:oculus-quest, topic:openxr or topic:quest') + data = _api('/search/repositories?' + urllib.parse.urlencode( + {'q': 'topic:' + topic + ' archived:false', 'sort': 'stars', 'per_page': limit})) + curated = {c['repo'].lower(): c for c in _curated()} + out = [] + for repo in data.get('items', []): + c = curated.get(repo['full_name'].lower()) + entry = _entry(source, c or {'repo': repo['full_name'], 'name': repo['name'], + 'summary': repo.get('description'), 'vr': True}, bool(c)) + # Repository owners' avatars are not app artwork. + out.append(entry) + return out + words = query.lower().split() + return [_entry(source, c) for c in _curated() + if all(w in (c['name'] + ' ' + c['repo'] + ' ' + c['summary']).lower() + for w in words)][:limit] + + +def details(source, entry_id): + c = next((c for c in _curated() if c['repo'].lower() == entry_id.lower()), None) + if not c: + if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', entry_id): + raise SourceError('Invalid GitHub repository') + entry = _entry(source, {'repo': entry_id, 'name': entry_id}, False) + return {**entry, 'versions': []} + entry = _entry(source, c) + releases = _api('/repos/' + c['repo'] + '/releases?per_page=10') + versions = [] + for release in releases: + if release.get('draft') or (release.get('prerelease') and not c.get('allow_prerelease')): + continue + assets = [a for a in release.get('assets', []) if + fnmatch.fnmatch(a['name'].lower(), c['asset_pattern'].lower())] + for asset in assets: + digest = asset.get('digest') or '' + versions.append({'version': release['tag_name'], 'version_code': None, + 'asset_id': asset['id'], 'name': asset['name'], 'min_sdk': None, + 'prerelease': bool(release.get('prerelease')), + 'size': asset.get('size'), 'updated': release.get('published_at'), + 'url': asset['browser_download_url'], + 'sha256': digest[7:] if re.fullmatch(r'sha256:[0-9a-f]{64}', digest) else None}) + entry['versions'] = versions + entry['downloadable'] = bool(versions) + if versions: + entry.update({k: versions[0][k] for k in ('version', 'size', 'updated')}) + return entry + + +def download(source, entry_id, version_code=None): + entry = details(source, entry_id) + versions = entry['versions'] + if not versions: + raise SourceError('No approved APK release; open the publisher page') + # GitHub asset IDs and tags are not Android version codes. + if version_code is not None: + raise SourceError('GitHub does not publish Android version codes; download the latest release') + v = versions[0] + expected = 'https://github.com/' + entry['id'] + '/releases/download/' + if not v['url'].startswith(expected): + raise SourceError('APK URL does not belong to the curated publisher') + return _web.apk(v['url'], HOSTS, v['sha256']) diff --git a/ui/apk_sources/github_curated.json b/ui/apk_sources/github_curated.json new file mode 100644 index 0000000..9f7fc14 --- /dev/null +++ b/ui/apk_sources/github_curated.json @@ -0,0 +1,29 @@ +[ + { + "repo": "KhronosGroup/OpenXR-SDK-Source", + "name": "hello_xr", + "summary": "Khronos OpenXR sample (Vulkan and OpenGL ES)", + "license": "Apache-2.0", + "vr": true, + "asset_pattern": "hello_xr-Vulkan-*.apk", + "allow_prerelease": false + }, + { + "repo": "icosa-foundation/open-brush", + "name": "Open Brush", + "summary": "Open source spatial painting", + "license": "Apache-2.0", + "vr": true, + "asset_pattern": "*Quest*.apk", + "allow_prerelease": true + }, + { + "repo": "SgtBilko76/SuperTux-3D", + "name": "SuperTux 3D", + "summary": "OpenXR stereoscopic platform game for Quest and PICO", + "license": "GPL-3.0", + "vr": true, + "asset_pattern": "*Quest*.apk", + "allow_prerelease": true + } +] diff --git a/ui/apk_sources/itch.py b/ui/apk_sources/itch.py new file mode 100644 index 0000000..3ef904c --- /dev/null +++ b/ui/apk_sources/itch.py @@ -0,0 +1,72 @@ +"""Free Android VR RSS listings. Download pages stay in the publisher's UI.""" +import html, re, urllib.parse, xml.etree.ElementTree as ET +from email.utils import parsedate_to_datetime + +from . import SourceError, _web + +KIND = 'itch' +FEEDS = ('openxr', 'oculus-quest') + + +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] + + +def _parse(source, data): + try: + root = ET.fromstring(data) + except ET.ParseError as e: + raise SourceError('Invalid itch.io RSS feed') from e + 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: + continue + if item.findtext('price') != '$0.00' or item.findtext('platforms/android') != 'yes': + continue + cover = item.findtext('imageurl') or None + if cover and not cover.startswith('https://img.itch.zone/'): + cover = None + updated = None + try: + updated = parsedate_to_datetime(item.findtext('updateDate')).date().isoformat() + except (ValueError, TypeError, AttributeError): + pass + entries.append({'source': source['id'], 'id': page, 'package': None, + 'name': item.findtext('plainTitle') or item.findtext('title') or page, + 'summary': html.unescape(re.sub('<[^>]*>', '', item.findtext('description') or '')).strip(), + 'icon': cover, 'images': {'icon': cover, 'banner': cover, 'screenshots': []}, + 'page': page, 'version': None, 'version_code': None, 'min_sdk': None, + 'abis': None, 'vr': True, 'size': None, 'free': True, 'license': None, + 'updated': updated, 'downloadable': False}) + 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',))): + 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 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') + return {**entry, 'versions': []} + + +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')