diff --git a/.gitattributes b/.gitattributes index 6a5ef04..bad39d7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,8 @@ *.json text eol=lf *.md text eol=lf *.bat text eol=crlf +# Test fixtures are byte-exact (hashes, signatures): never convert line endings. +tests/fixtures/** -text +*.apk binary +*.jar binary +*.obb binary diff --git a/app/package.json b/app/package.json index ca083aa..f59e889 100644 --- a/app/package.json +++ b/app/package.json @@ -48,9 +48,18 @@ "filter": [ "*.py", "*.html", + "*.js", "telemetry.json" ] }, + { + "from": "../ui/apk_sources", + "to": "ui/apk_sources", + "filter": [ + "*.py", + "*.json" + ] + }, { "from": "../scripts", "to": "scripts", @@ -63,7 +72,8 @@ "to": "frame/android", "filter": [ "*.sh", - "*.py" + "*.py", + "*.js" ] }, { diff --git a/docs/apk-repos.md b/docs/apk-repos.md new file mode 100644 index 0000000..25a86dd --- /dev/null +++ b/docs/apk-repos.md @@ -0,0 +1,144 @@ +# APK repositories + +Frame Control supports **F-Droid-format repositories**, including F-Droid, +F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository +indexes are authenticated before their apps appear. Search lists builds with +Android API ≤30 and arm64-v8a or no native libraries, using the same streaming +reducer as the existing catalogue. This does not guarantee an app works in Lepton. + +## Formats considered + +| Format | Users and purpose | Support in this source | +|---|---|---| +| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes | +| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` | +| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index | +| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter | +| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source | +| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid | + +Research references (checked 2026-09-28): + +- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and + [repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/). +- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/) + and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/). +- [Droid-ify](https://github.com/Droid-ify/client) and + [Neo Store](https://github.com/NeoApplications/Neo-Store). +- [Obtainium](https://github.com/ImranR98/Obtainium), its + [configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/), + and [community app configurations](https://apps.obtainium.imranr.dev/). +- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest). + The absence of a specification in these materials is not proof that no + historical or private custom-feed format exists. + +## Add a repository in Frame Control + +From the Frame Control checkout, use its source-management CLI: + +```sh +python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps' +python3 ui/apk_sources/fdroid.py list +python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music' +python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app +python3 ui/apk_sources/fdroid.py remove SOURCE_ID +``` + +Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint` +can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…` +links are accepted and converted to HTTPS. Conflicting fingerprints are refused. +A URL must identify the repository directory, not its website or an index file. + +Adding fetches and validates the complete index **before saving** the source. +Without a fingerprint, Frame Control verifies the JAR signature and remembers +its signer: trust on first use (TOFU). This establishes continuity with the +first server response, not independent publisher identity. Obtain the published +fingerprint through a trusted channel when possible; the store's Add a source +form shows the pinned one ("Trusted on first use: …") so you can compare it. +Re-adding an existing URL preserves its pin; changing it requires deliberately +removing and re-adding it. + +The API for the search/server integration is in `ui/apk_sources/fdroid.py`: +`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`, +`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes +`sources`, `search`, `details`, and `download` from the shared source contract. +This change supplies the CLI and API; the integrated source-management UI is +separate work. Built-in sources can be disabled but cannot be removed. + +Settings and pins live in `frame_host.data_dir('apk-repos.json')` +(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS). +Authenticated reduced indexes and APKs live under +`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. An +expired index is still served (marked stale in the store) while it refreshes in +the background; a failed refresh is retried after 10 minutes. +Only the running Frame Control app prunes cached APKs (at start and after store +downloads); the command-line tools never do. +The existing catalogue's unverified index cache is never treated as authenticated. + +Rollback protection: each repository's newest accepted signed index timestamp +is kept in `apk-repo-state.json` next to the settings, and an older index is +refused. Once a repository has served a v2 `entry.jar`, a missing `entry.jar` +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. + +## Publish your own repository + +Only publish free APKs you own or have the developer's permission to distribute. +Do not publish paid app mirrors or bypass store licences. Check distribution +terms before adding someone else's repository; this module does not infer legal +permission from a signature or automatically audit a repository's terms. + +Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/) +and its documented Android/Java dependencies on the publishing machine, then: + +```sh +mkdir my-fdroid +cd my-fdroid +fdroid init +# Set repo_url in config.yml to https://example.org/fdroid/repo +# Also set repo_name and repo_description; keep the generated signing key safe. +cp /path/to/your-free-app.apk repo/ +fdroid update --create-metadata +# Review the generated metadata (name, summary, licence, source and website). +fdroid update +``` + +Serve the generated **repo directory** at that HTTPS URL, including APKs, +icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the +private signing keystore or configuration passwords. Configure fdroidserver's +`serverwebroot` and run `fdroid deploy` for managed publication, or copy the +public directory with your existing deployment tool. Publish the SHA-256 +repository certificate fingerprint displayed by fdroidserver in a link such as +`https://example.org/fdroid/repo?fingerprint=…`. + +Keep the repository signing key backed up: changing it breaks existing pins. +For updates, add the new APK, edit metadata as needed, run `fdroid update` and +publish again. Test the published URL with Frame Control's `add`, `search` and +`download` commands. The above publisher setup is documented from fdroidserver; +it was not executed as part of this implementation. + +## Verification and limits + +The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of +2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are +recognized. It checks the signer certificate pin, the signature over `.SF`, +the whole-manifest digest, and the manifest's digest of the JSON member. +ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are +rejected. Certificates are pinned identities, not validated as Web PKI chains. +HTTPS certificates are separately checked by Python's normal TLS validation. + +v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature, +fingerprint, index hash, TLS and server errors never trigger an unsigned +fallback. APKs are cached by SHA-256 and checked again before reuse. Here, +`verified: true` means the bytes match the signed repository's APK hash; it +is not an independent APK publisher-signature or runtime compatibility verdict. +There is no repository timestamp rollback/expiry policy or automated signing-key +rotation yet. An old correctly signed index can still validate. + +Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache +reuse, URL rejection and corruption of every signature/hash layer. On the Mac, +the real IzzyOnDroid repository was added with its published pin, searched for +Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256 +`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`. +No headset connection or installation was performed. diff --git a/docs/apk-sources.md b/docs/apk-sources.md new file mode 100644 index 0000000..7caca71 --- /dev/null +++ b/docs/apk-sources.md @@ -0,0 +1,103 @@ +# 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. +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 +preview embedded in the port's README (not a headset capture). The hello_xr +sample has a launcher icon and GitHub social banner; no published screenshot +was found in the inspected repository/README, so its screenshot list is empty. +Uncurated topic results use the owner's avatar and GitHub's repository social +preview. These are repository placeholders, not app screenshots. Itch's recorded +RSS includes only covers, so screenshot lists remain empty without page scraping. 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/docs/apks.md b/docs/apks.md index 56b8c25..4a841d4 100644 --- a/docs/apks.md +++ b/docs/apks.md @@ -331,3 +331,11 @@ because gamescope scales Lepton's surface to fit the same panel. Also unverified whether the settings survive the app or its Lepton instance relaunching. Lepton Development rebuilds its Android data on exit, so there they probably don't. + +## Expansion files and save backups + +SideQuest-inspired CLI helpers install local OBB files into an already-running +app instance and back up/restore a stopped instance's private app data. See +[SideQuest features and limits](sidequest.md) for commands, archive scope and +verification status. These paths have offline coverage; real Frame storage and +permissions remain unverified. They do not change APK install or launch behavior. diff --git a/docs/sidequest.md b/docs/sidequest.md new file mode 100644 index 0000000..eba0f02 --- /dev/null +++ b/docs/sidequest.md @@ -0,0 +1,142 @@ +# SideQuest and Frame Control + +Researched 2026-09-28. SideQuest is both a Quest discovery website and a desktop +sideloading/device-management app. Its Quest labels are **not** evidence that a +game works on Lepton: inspect the APK for arm64/OpenXR, Android API requirements, +VrApi and Meta services (see [VR APKs](vr-apks.md)). + +## Features worth borrowing + +Desktop evidence is the public [SideQuest source at af2ac70](https://github.com/SideQuestVR/SideQuest/tree/af2ac7043db122bca3c8db18f2b58f1660e9befb), +especially [ADB operations](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/adb-client.service.ts), +[drag and drop](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/drag-and-drop.service.ts), +and the [legacy repository index](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/packages/package.service.ts). +Website evidence: [SideQuest](https://sidequestvr.com/) and its public Angular +bundle `main-4MMXZRXL.js`, inspected locally without browser automation. +No SideQuest implementation code was copied. + +| SideQuest feature | Frame Control before this change | Borrow? / effort | +|---|---|---| +| Store descriptions, screenshots, banners, trailers, ratings | F-Droid names, icons, compatibility verdicts and reports; no equivalent rich VR store | Yes, from authorised sources; medium. Search and library workers own presentation/artwork. | +| OBB expansion-file install | APK-only install | **Implemented helper and CLI**, medium. Essential for games whose assets are separate from the APK. | +| App-data backup/restore | Persistent instances and optional keep-data uninstall, no portable save archive | **Implemented private-data helper and CLI**, medium. Back up before updates or experiments. | +| File manager (list, upload, download, remove) | General Send to Frame, no Android file browser | Useful later, medium; requires clear instance selection and scoped paths. | +| 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 | 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. | + +Priority: expansion files, then save backup/restore. Rich discovery and update +notices follow once a permitted metadata source and source/version persistence +are available. This patch deliberately exposes CLI/backend operations, leaving +shared UI, install(), Steam artwork and launch behavior to sibling work. + +## SideQuest as a source: page-only + +[Terms](https://sidequestvr.com/terms), “Prohibited Activities”, (i) prohibits +copying/distributing/disclosing the Service including automated or non-automated +“scraping”; (xi) prohibits content access through means other than those provided +or authorised by the Service; (xii) prohibits bypassing access restrictions. +The terms describe downloading developer-posted games through the Service, but +do not establish permission for this third-party API integration. + +[robots.txt](https://sidequestvr.com/robots.txt) requests a three-second crawl +delay and disallows `/search/`, `/user/*` and `/sideload/*`. Robots permission +would not override the terms. The API host's robots request returned HTTP 403; +a request for the first shared website JS chunk also returned 403. No bypass, +account token, cookies, browser session or private endpoint was used. + +The homepage publishes `https://api.sidequestvr.com` and +`https://cdn.sidequestvr.com`. The website bundle calls `searchApps(...)` and +`getApp(id, null)`; their actual HTTP search/detail routes could not be established +from the retrieved bundle. Do not invent endpoints. The open-source desktop +[install flow](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/electron/app.ts) +POSTs `{token: ...}` to `/install-from-key`. It consumes +`data.apps[].urls[]`, with `provider` values including `APK`, `OBB`, +`Github Release` and `Mod`, and `link_url`. This is a website-issued install-key +flow, not evidence of an anonymous download API. It is not implemented here. + +`ui/apk_sources/sidequest.py` implements the shared interface conservatively: + +- `sources()` marks SideQuest `page_only` and explains why. +- `search()` raises a user-readable `SourceError` with the browse URL (zero + limit returns no rows). It does not invent app results or report a false + “no matching games”. The aggregate search UI should surface this source error. +- `details()` accepts a numeric listing id and returns its canonical page link, + `downloadable: False`, empty versions/tags/headsets and the `images` shape + `{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id; + unknown facts, including free/VR status, stay `None`. +- `download()` refuses with that page link. Paid/external listings cannot be + downloaded by this adapter either. No downloads means no verification claim. + +The JSON fixture records policy evidence, **not a purported live app response**. +No listing metadata, artwork URLs, or OBB download URLs were scraped. +The requested real SideQuest → OpenXR APK → `frame_android.py info` test is +**blocked by the terms**, and was not performed. No alternate source is silently +substituted. A future integration needs SideQuest's permission or an expressly +supported third-party API, plus recorded search/detail/download fixtures, +free/direct-download classification, and size/hash verification. A calculated +local SHA-256 alone must not be called publisher verification. + +## OBB files + +```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 +``` + +Install the APK first. The named instance must already be running; the helper +never launches an app or uses Lepton Development. It requires standard +`main|patch...obb` filenames and nonempty files, validates +the entire batch before transfer, streams each file through SSH into that +instance, checks its SHA-256 **inside Android**, then renames it into +`/sdcard/Android/obb//`. `verified: True` here means transfer integrity +against the local input, not publisher authentication. Publication is atomic per +file, not for the whole batch; retry after a partial batch failure. Existing OBBs +with different version codes remain. The filename version must match the game; +the current install metadata does not expose its version code for comparison. +Restart the game yourself after the transfer if it cached missing expansion data. + +Both read-only SSH attempts to the Frame timed out. Therefore the exact +host-side `/sdcard` mapping and persistence of expansion data were **not verified**. +`compatdata//internal/` is documented as `/data/data/`; +it must not be mistaken for `/sdcard`. Using Android's path avoids guessing a +host layout, but device verification across restart/update is still required. +No OBB file was installed on the Frame during this work. + +## Private app-data backups + +```sh +python3 ui/frame_android.py stop org.example.game +python3 ui/frame_android.py backup-data org.example.game ./game-save.tar.gz +python3 ui/frame_android.py restore-data org.example.game ./game-save.tar.gz +``` + +Keep the instance stopped throughout either operation; do not launch it from +Steam concurrently. The remote guard fails if Podman cannot enumerate containers +or reports that instance running. The helpers use `podman unshare` to read/write +Android's mapped ownership without changing the live data's permissions. + +The archive covers **only** `compatdata//internal/`, not the +APK, external `/sdcard/Android/data`, OBBs, keystore, or the full Android snapshot. +It contains a package/instance manifest and regular files/directories. Backups +are private (0600), validated before publication, and never overwrite an existing +backup. Keep them safe: app data can contain credentials and is not encrypted. + +Restore checks the package and instance, rejects absolute/traversing/duplicate +paths, links and devices, caps files at 100,000 and content at 20 GiB, and validates +again on the Frame. It extracts into a separate directory, preserves numeric +ownership, ordinary modes and timestamps, then swaps the private-data directory. +Setuid/setgid bits are not restored. The previous directory remains beside it as +`..before-restore-`; the returned `previous` path identifies +it. This is an additional recovery copy, not an automatic deletion policy. + +Locally verified: archive round trip including recovery copy, malformed archive +rejection, transfer command construction and failure handling. Not verified: +real Frame UID mappings/permissions, Android app-level recovery, live FUSE OBB +writes or persistence. Backups reject symlinks/special files; an app requiring +those needs a separately designed backup format. These CLI features still need +a real-device acceptance pass before being exposed as a polished UI workflow. diff --git a/docs/vr-apks.md b/docs/vr-apks.md index e57325c..bce6817 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -84,6 +84,132 @@ not being worn, so it did not reach `FOCUSED`). The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a Khronos-style loader and found SteamVR through `/vendor`. +## In the Steam library + +Every successful APK install goes through the same mandatory artwork writer: +CLI (including `scripts/install-apk.sh`), upload, catalogue, version finder, +web download and source modules calling `frame_android.install`. Native +Linux/Windows sideloads also use it, preserving their devkit runtime wiring. +A new shortcut is rolled back if artwork fails; failure is never reported as +an installed app with a blank tile. + +Artwork preference is **SteamGridDB → source images → generated fallback**. +Set the optional free key in Frame Control's **Library artwork settings**, or +`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment +settings override the saved key. Without a key there are no provider calls or +warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never +returned by the settings API or copied to the headset. Exact title matches +(including a trailing “VR” variant) use the highest-scored returned static, +non-NSFW image in each slot. Provider failures use the next source. + +Sources pass `install(apk_path, artwork={...})`: keys are `grid`, `wide`, +`hero`, `logo`, `icon`, `banner`, `feature_graphic`, `screenshot`, or a list +`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB and +4096×4096 pixels maximum; any PNG depth or interlace, since the Frame's +Chromium decodes them). URLs must resolve to public addresses, follow at most +three redirects and share one deadline per install. Any source that fails, +for any reason, becomes a warning and generated art. Banners and feature graphics supply hero/wide art; +screenshots are the next fallback. Source images are cached for refresh. +All images are fitted to 600×900 portrait, 920×430 wide, 3840×1240 hero, +1280×480 logo and 256×256 icon. Explicit logos retain transparency. +Photo-based portrait, wide and hero slots are JPEG: Steam takes at most +12 MiB per slot, and on the Frame (2026-09-28) a noise-heavy 3840×1240 hero +came to more than 12 MiB as PNG, 3.7 MB as JPEG (2.7 s to render); a +landscape photo hero 5.6 MB as PNG, 0.76 MB as JPEG (0.75 s). A render that +still fails is retried once with generated art. Steam keeps a slot's `.png` +and `.jpg` side by side, so each slot is cleared before it is set. + +Generated art uses the APK icon, a dominant-colour gradient, a blurred +backdrop and large foreground icon with shadow. Steam's Chromium canvas and +Motiva Sans render real text consistently regardless of the host OS; no +Pillow, host font installation or bitmap font is needed. The hero has no +title; the generated logo is a transparent title. APKs with no usable icon +get a typographic monogram. The desktop package includes the renderer. + +Backfill installed Android apps without reinstalling or stopping them: + +```sh +python3 ui/frame_android.py refresh-art org.godotengine.open_saber_plus +python3 ui/frame_android.py refresh-art --all +``` + +Devkit titles installed by Frame Control have the same command, +`python3 ui/frame_titles.py refresh-art ID|--all`. The settings panel's +refresh covers both. The API is `POST /api/android` with +`{"action":"refresh-art","all":true}` (apps and titles) or a `package`, and +`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a +background job. Batch results retain per-item errors, and the CLIs exit +nonzero if any failed. Apps and titles without complete artwork show **Add +artwork** and `list` prints the command. Only entries marked `art_pending` at +install (a title Steam registered after an install made while it wasn't +running) are backfilled automatically, when Frame Control lists them with +Steam running (at most every five minutes), and that backfill only fills +slots Steam has no art for: names, icons, flags and any art the user set are +kept. Older installs without the flag are refreshed only on request. + +Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build +20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by +its saved id, or by an executable or start folder inside +`~/devkit-game//`, read from `appDetailsStore`; never by display name. +That the devkit shortcut's exe/start folder sit inside the title folder is +inferred from `docs/sideloading.md` (`proton waitforexitandrun +"/home/steamos/devkit-game//"`), not yet seen in app details. +Devkit titles keep the VR flag Steam gave them. + +**Verified on build 20260925.6191901, SteamVR 2.18.1 (2026-09-28):** both +Open Saber Plus and SuperTux were backfilled. Steam's cached portrait, wide, +hero and logo PNGs have the dimensions above; each shortcut points at its +256×256 icon. This Frame client mishandles custom-art type 4 (documented as +Icon), overwriting the wide capsule; the implementation uses custom types +0–3 and **SetShortcutIcon** separately. + +Steam accepts display name, executable/start directory, icon, VR flag and +sort-as name. Android apps join **Android**, immersive apps also **Android +VR**; native sideloads join **Sideloaded**. Existing collection members and +unrelated collections are preserved (both games retained **Played**). +Dynamic/read-only collection conflicts produce warnings. The native notes +API supports a managed **Installation details** note (package, version and +source) while preserving other notes. Notes are keyed by sanitized shortcut +name, so Steam itself cannot distinguish equal-name shortcut notes. No +supported shortcut description/store-page, developer/publisher, release +metadata or custom achievement API was found; these are not fabricated. + +The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by +stopping its own container and child process group. A lock refuses duplicate launches; +a container still running while the lock is free was orphaned by a killed +launcher and is stopped before the new launch. Lepton doesn't inherit the +lock. Orphan recovery only stops the app's own, deterministically named +container; a Lepton host process whose launcher was killed before it created +the container may linger briefly. Removing an app or title still deletes its files when Steam isn't +running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact +64-bit game ID string. Frame Control's Stop additionally has a direct-container +fallback. The stable instance ID and compatdata paths remain unchanged. + +Lepton normally forwards the instance `SteamAppId` to Android, causing +SteamVR to associate the scene with a different, artwork-less app. The +launcher uses Lepton's supported `LEPTON_ENV_SteamAppId` passthrough to send +the actual shortcut ID to Android while retaining the stable container ID. +**Verified:** Open Saber was alive 22 seconds after Steam Play, SteamVR +identified `steam.app.3346865537`, and its scene appeared in the headset +capture without the previous blank Resume tile. Steam Stop then removed its +tracked process and stopped the container. An earlier 32-second session was +also tracked until Steam Stop. No global standby or dashboard overrides were +installed; wear detection and other user-opened overlays still apply. + +**SuperTux limitation:** Steam launched and tracked it, but SDL crashed during +activity creation because Lepton lacks `ClipboardManager`. Its container +cleaned up on exit after about 17 seconds. Consequently sustained SuperTux +Play/Stop and its VR scene could not be verified. This is an APK/runtime +compatibility failure, separate from library presentation. + +Evidence is under `/tmp/vrlib-evidence/` on the development Mac: final artwork +preview and three design passes, `steam-cache-final.log`, +`steam-details-targets.json`, `opensaber-identity-session.log`, +`opensaber-identity-headset.png`, and `supertux-lepton.log`. The preview is +rendered artwork, not a Steam UI screenshot; CDP screenshot capture timed +out. Authenticated SteamGridDB, Windows/Linux packaged builds and the sibling +source-search endpoint remain unverified (the public install seam is tested). + ## Out of scope - **Meta entitlement.** Apps that call the Oculus Platform SDK diff --git a/frame/android/app-data.py b/frame/android/app-data.py new file mode 100644 index 0000000..15c740b --- /dev/null +++ b/frame/android/app-data.py @@ -0,0 +1,168 @@ +"""Private-data archives, run under podman unshare on the Frame. Stdlib only.""" +import contextlib +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import sys +import tarfile +import tempfile +import time + +MAX_BYTES = 20 * 1024 ** 3 +MAX_FILES = 100000 +MAX_MANIFEST = 1024 * 1024 + + +def inspect_archive(path, package, instance): + names, total, manifest = set(), 0, None + with tarfile.open(path, 'r:gz') as archive: + for member in archive: + name = member.name + parts = PurePosixPath(name).parts + if (not parts or name.startswith('/') or '..' in parts or + name != '/'.join(parts) or name in names or '\\' in name): + raise ValueError('unsafe or duplicate archive path') + if name == 'data' and not member.isdir(): + raise ValueError('data root must be a directory') + names.add(name) + if len(names) > MAX_FILES or not (member.isdir() or member.isfile()): + raise ValueError('archive has too many files, links or special files') + if member.uid < 0 or member.gid < 0 or member.uid > 65535 or member.gid > 65535: + raise ValueError('archive owner outside Android user namespace') + total += member.size + if total > MAX_BYTES: + raise ValueError('archive exceeds 20 GiB') + if name == 'manifest.json' and member.isfile() and member.size <= MAX_MANIFEST: + manifest = json.load(archive.extractfile(member)) + elif parts[0] != 'data': + raise ValueError('unexpected archive member') + if (not isinstance(manifest, dict) or manifest.get('format') != 1 or + manifest.get('package') != package or manifest.get('instance') != instance or + 'data' not in names): + raise ValueError('backup does not match this package and instance') + return {'files': len(names) - 1, 'bytes': total, 'package': package, 'instance': instance, + 'skipped_links': manifest.get('skipped_link_count', 0)} + + +def backup(root, package, instance, output): + import io + source = root / package + if source.is_symlink() or not source.is_dir(): + raise ValueError('private app data does not exist or is a symlink') + count, total, links, skipped = 0, 0, [], 0 + + def checked(member): + nonlocal count, total, skipped + if member.issym(): # never followed or restored; listed in the manifest instead + skipped += 1 + if len(links) < 1000: + links.append({'path': member.name[:512], 'target': member.linkname[:256]}) + return None + if member.islnk(): # a second name for a file already archived: store its content again + member.type, member.linkname = tarfile.REGTYPE, '' + member.size = os.lstat(str(source / member.name[len('data/'):])).st_size + count += 1 + total += member.size + if not (member.isdir() or member.isfile()) or count > MAX_FILES or total > MAX_BYTES: + raise ValueError('private data contains special files or exceeds backup limits') + return member + + with tarfile.open(fileobj=output, mode='w|gz', dereference=False) as archive: + archive.add(str(source), arcname='data', filter=checked) + # Written last so that it can list what was skipped. + manifest = json.dumps({'format': 1, 'package': package, 'instance': instance, + 'skipped_links': links, 'skipped_link_count': skipped}).encode() + member = tarfile.TarInfo('manifest.json') + member.size, member.mode = len(manifest), 0o600 + archive.addfile(member, io.BytesIO(manifest)) + + +def restore(root, package, instance, input_stream): + source = root / package + if source.is_symlink() or not source.is_dir(): + raise ValueError('private app data does not exist or is a symlink') + with tempfile.TemporaryDirectory(prefix='.frame-restore-', dir=str(root)) as work: + work = Path(work) + archive_path = work / 'backup.tar.gz' + with archive_path.open('wb') as output: + size = 0 + while True: + chunk = input_stream.read(1024 * 1024) + if not chunk: + break + size += len(chunk) + if size > MAX_BYTES: + raise ValueError('compressed backup exceeds 20 GiB') + output.write(chunk) + result = inspect_archive(archive_path, package, instance) + stage = work / 'stage' + stage.mkdir(mode=0o700) + with tarfile.open(archive_path, 'r:gz') as archive: + directories = [] + for member in archive: + if member.name == 'manifest.json': + continue + target = stage / member.name + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + directories.append((target, member)) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with archive.extractfile(member) as src, target.open('xb') as dst: + shutil.copyfileobj(src, dst, 1024 * 1024) + apply_metadata(target, member) + for target, member in reversed(directories): + apply_metadata(target, member) + with package_lock(root, package): # another restore of this package must not delete our copy + previous = root / ('.' + package + '.before-restore-' + str(time.time_ns())) + source.rename(previous) + try: + (stage / 'data').rename(source) + except BaseException: + previous.rename(source) + raise + # Keep only the newest pre-restore copy of this package's data. + for old in root.glob('.' + package + '.before-restore-*'): + if old != previous and not old.is_symlink(): + shutil.rmtree(str(old), ignore_errors=True) + result['previous'] = str(previous) + return result + + +@contextlib.contextmanager +def package_lock(root, package): + import fcntl + fd = os.open(str(root / ('.' + package + '.restore.lock')), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + os.close(fd) # releases the lock + + +def apply_metadata(path, member): + os.chown(str(path), member.uid, member.gid) + os.chmod(str(path), member.mode & 0o777) + os.utime(str(path), (member.mtime, member.mtime)) + + +def main(): + action, package, instance = sys.argv[1:] + instance = int(instance) + root = Path.home() / '.local/share/Steam/steamapps/compatdata' / str(instance) / 'internal' + if root.is_symlink() or root.resolve() != root.absolute(): + raise ValueError('private-data directory traverses a symlink') + if action == 'backup': + backup(root, package, instance, sys.stdout.buffer) + elif action == 'restore': + print(json.dumps(restore(root, package, instance, sys.stdin.buffer))) + else: + raise ValueError('unknown app-data action') + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, tarfile.TarError) as error: + sys.exit(str(error)) diff --git a/frame/android/lepton-app.sh b/frame/android/lepton-app.sh index 63b0572..49080cb 100644 --- a/frame/android/lepton-app.sh +++ b/frame/android/lepton-app.sh @@ -19,6 +19,25 @@ done # A number that isn't a real Steam app; it names this app's Lepton context. export SteamAppId="$(cat "$DIR/instance.id")" +[[ "$SteamAppId" =~ ^[0-9]+$ ]] || { echo "invalid instance.id" >&2; exit 1; } +# Keep the stable Lepton context, but identify the Android VR client as its +# actual Steam shortcut. Lepton applies LEPTON_ENV_* after its own passthrough. +if [[ -f "$DIR/shortcut.id" ]]; then + shortcut="$(cat "$DIR/shortcut.id")" + [[ "$shortcut" =~ ^[0-9]+$ ]] || { echo "invalid shortcut.id" >&2; exit 1; } + export LEPTON_ENV_SteamAppId="$shortcut" +fi +exec 9>"$DIR/launch.lock" +flock -n 9 || { echo "Android app is already running" >&2; exit 1; } +CONTAINER="lepton-steamlaunch-$SteamAppId" +# Holding the lock means no launcher owns a running container: it was orphaned +# (this script SIGKILLed), so stop it rather than refuse every later Play. The +# name is this app's alone. A Lepton host process whose launcher was killed +# before it made the container may linger briefly; nothing else is killed. +if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then + echo "Stopping orphaned $CONTAINER" >&2 + podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true +fi export STEAM_COMPAT_INSTALL_PATH="$DIR" # Must be under ~/.local/share/Steam: only that tree is mounted in the container. export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId" @@ -29,4 +48,29 @@ mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH" # Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts # and SSH don't have; give it its own session instead. export IS_PARENT=true -exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" +# Keep this shell in Steam's process tree; setsid alone has no container cleanup. +child="" +cleanup() { + trap '' TERM INT HUP + if [[ -n "$child" ]]; then + kill -TERM -- "-$child" 2>/dev/null || true + kill -TERM "$child" 2>/dev/null || true + fi + podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true + if [[ -n "$child" ]]; then + kill -KILL -- "-$child" 2>/dev/null || true + kill -KILL "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + fi +} +trap cleanup EXIT +trap 'exit 143' TERM +trap 'exit 130' INT +trap 'exit 129' HUP +# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held. +setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- & +child=$! +rc=0 +wait "$child" || rc=$? +child="" +exit "$rc" diff --git a/frame/android/library_artwork.js b/frame/android/library_artwork.js new file mode 100644 index 0000000..89ef0d6 --- /dev/null +++ b/frame/android/library_artwork.js @@ -0,0 +1,146 @@ +// Runs in Steam's Chromium context: identical fonts/rendering from every host OS. +async function renderLibraryArtwork(input) { + const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]}; + const label = String(input.label || 'Untitled').trim().slice(0,180); + const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif'; + await document.fonts.load(`800 120px ${font}`, label); + const images = {}, warnings = []; + for (const [slot, item] of Object.entries(input.images || {})) { + try { + const img = new Image(); + img.src = `data:image/${item[0]};base64,${item[1]}`; + await img.decode(); + if (!img.width || !img.height || img.width*img.height > 16777216) throw Error('dimensions'); + images[slot] = img; + } catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); } + } + let icon = images.icon; + if (icon) { + // Remove only a near-black matte connected to the outside of an opaque icon. + const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height; + const c=cut.getContext('2d');c.drawImage(icon,0,0); + const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height; + const corners=[0,w-1,(h-1)*w,h*w-1]; + if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) { + const seen=new Uint8Array(w*h), queue=corners.slice(); + for(let q=0;q24)continue; + d[i*4+3]=0; + if(i%w)queue.push(i-1);if(i%w=w)queue.push(i-w);if(i220 || hi-lo<25) continue; + const key=rgb.map(v=>Math.round(v/32)*32).join(','); + bins.set(key,(bins.get(key)||0)+1); + } + const ranked=[...bins].sort((a,b)=>b[1]-a[1]); + if (ranked.length) { + colors[0]=ranked[0][0].split(',').map(Number); + colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number); + } + } + // Preserve hue while lifting muted icon colors into a richer background palette. + colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1; + return c.map(v=>45+(v-low)/range*165);}); + const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`; + function image(ctx,img,x,y,w,h,cover=false) { + const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height); + const dw=img.width*scale,dh=img.height*scale; + ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip(); + ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore(); + } + function title(ctx,w,h,top,bottom,maxSize) { + let lines=[],size=maxSize; + const maxWidth=w*.84; + for (;size>=18;size-=2) { + ctx.font=`800 ${size}px ${font}`; + lines=[]; let line=''; + for (const word of label.split(/\s+/)) { + const next=line?line+' '+word:word; + if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next; + } + lines.push(line); + if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break; + } + if(lines.length===2) { + const words=lines[0].split(' '); + if(words.length>1) { + const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1]; + if(ctx.measureText(second).width<=maxWidth && + Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)< + Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second]; + } + } + // A long unbroken label is still fitted, including scripts without spaces. + ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff'; + ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06; + let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54; + for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;} + ctx.shadowBlur=0; ctx.shadowOffsetY=0; + } + const result={}; + for (const [slot,[w,h]] of Object.entries(sizes)) { + const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h; + const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high'; + const direct=images[slot]; + const feature=images.feature_graphic||images.banner; + const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot)); + if (scene) { + if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h); + else image(ctx,scene,0,0,w,h,true); + } else if (slot==='logo') { + title(ctx,w,h,h*.08,h*.92,150); + } else { + const gradient=ctx.createLinearGradient(0,0,w,h); + gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68))); + gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32))); + gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h); + if (icon) { + ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`; + image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore(); + } + const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8); + glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0)); + ctx.fillStyle=glow;ctx.fillRect(0,0,w,h); + const vignette=ctx.createLinearGradient(0,h*.25,0,h); + vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)'); + ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h); + const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]: + slot==='wide'?[w*.36,h*.06,w*.28,h*.59]: + slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84]; + if (icon) { + ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055; + ctx.shadowOffsetY=Math.min(w,h)*.022; + // Opaque square icons read as deliberate app tiles, not pasted rectangles. + const [x,y,bw,bh]=box, side=Math.min(bw,bh); + if(slot!=='hero' && icon===images.icon) { + ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip(); + } + image(ctx,icon,...box);ctx.restore(); + } else if (slot !== 'hero') { + // A typographic monogram when the APK contains no usable image. + ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)'; + ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38); + } + if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66); + if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52); + // Hero intentionally has no title: Steam overlays the transparent logo. + } + // Photos as PNG can pass Steam's 12 MiB limit at hero size; the logo keeps its transparency. + const jpeg=scene && slot!=='logo' && slot!=='icon'; + result[slot]=[jpeg?'jpg':'png', canvas.toDataURL(jpeg?'image/jpeg':'image/png',.9).split(',')[1]]; + } + return {images:result,warnings,font}; +} diff --git a/frame/android/steam_shortcuts.py b/frame/android/steam_shortcuts.py index 6aea8de..bf614e5 100644 --- a/frame/android/steam_shortcuts.py +++ b/frame/android/steam_shortcuts.py @@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - < this`. steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id steam_shortcuts.py list -> JSON [{appid, name, exe}] + steam_shortcuts.py configure APPID NAME EXE START_DIR ICON VR ARTWORK_JSON + steam_shortcuts.py stop APPID steam_shortcuts.py remove APPID """ -import base64, json, os, socket, struct, sys, urllib.request +import base64, glob, json, os, re, socket, struct, sys, urllib.request DEVTOOLS = 'http://127.0.0.1:8080/json' @@ -22,10 +24,10 @@ def target_ws(): class WS: """Just enough RFC 6455 for one CDP request/response on loopback.""" - def __init__(self, url): + def __init__(self, url, timeout=20): host_port, path = url[len('ws://'):].split('/', 1) host, port = host_port.split(':') - self.s = socket.create_connection((host, int(port)), timeout=20) + self.s = socket.create_connection((host, int(port)), timeout=timeout) key = base64.b64encode(os.urandom(16)).decode() self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n' f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n' @@ -69,8 +71,8 @@ class WS: return msg.decode() -def evaluate(js): - ws = WS(target_ws()) +def evaluate(js, timeout=20): + ws = WS(target_ws(), timeout) ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': { 'expression': js, 'awaitPromise': True, 'returnByValue': True}})) while True: @@ -83,6 +85,206 @@ def evaluate(js): return res.get('result', {}).get('value') +# Steam's ELibraryAssetType (Capsule, Hero, Logo, Header, Icon). +ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4} + + +def collections_js(appid, wanted=()): + wanted = list(wanted) + return f'''async function syncCollections() {{ + const wanted = {json.dumps(wanted)}; + if (typeof collectionStore === "undefined" || + typeof collectionStore.GetUserCollectionsByName !== "function" || + typeof collectionStore.NewUnsavedCollection !== "function" || + typeof collectionStore.SaveCollection !== "function") + return ["Steam collections API unavailable"]; + const app = {{appid: {appid}}}; + const warnings = []; + for (const name of ["Android", "Android VR", "Sideloaded"]) {{ + const matches = collectionStore.GetUserCollectionsByName(name); + let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop); + if (wanted.includes(name)) {{ + if (!collection && matches.length) {{ + warnings.push(name + " is an existing dynamic or read-only collection"); + continue; + }} + if (!collection) {{ + collection = collectionStore.NewUnsavedCollection(name, undefined, [app]); + }} else {{ + collection.AsDragDropCollection().AddApps([app]); + }} + await collectionStore.SaveCollection(collection); + }} else if (collection) {{ + collection.AsDragDropCollection().RemoveApps([app]); + await collectionStore.SaveCollection(collection); + }} + }} + return warnings; + }}''' + + + +def notes_js(name, details): + filename = 'notes_shortcut_' + re.sub(r'[!-/:-@ \[\\\]\^`]', '_', name.strip()) + content = '\n'.join(str(details[k]) for k in ('package', 'version', 'source') if details.get(k)) + return f'''if (SteamClient.GameNotes && typeof SteamClient.GameNotes.GetNotes === "function" && + typeof SteamClient.GameNotes.SaveNotes === "function") {{ + try {{ + const file = {json.dumps(filename)}; + const previous = await SteamClient.GameNotes.GetNotes(file, file + "_images/"); + if (previous.result !== 1 && previous.result !== 9) throw Error("read " + previous.result); + const data = previous.result === 1 ? JSON.parse(previous.notes) : {{notes: [], shortcut_name: {json.dumps(name)}}}; + if (!Array.isArray(data.notes)) throw Error("unexpected notes format"); + const id = "frame-control-library", now = Math.floor(Date.now()/1000); + const old = data.notes.find(n => n.id === id); + const note = {{id, shortcut_name: {json.dumps(name)}, title: "Installation details", + content: {json.dumps(content)}, ordinal: old ? old.ordinal : data.notes.length, + time_created: old ? old.time_created : now, time_modified: now}}; + data.notes = data.notes.filter(n => n.id !== id).concat([note]); + const result = await SteamClient.GameNotes.SaveNotes(file, JSON.stringify(data)); + if (result !== 1) throw Error("save " + result); + }} catch (e) {{ warnings.push("Steam notes: " + String(e)); }} + }}''' + + +MAX_ART = 12 * 1024 * 1024 # Steam's custom artwork limit per slot + + +def render(plan): + with open(plan) as f: + source = json.load(f) + images = {} + for slot, path in source['images'].items(): + ext = os.path.splitext(path)[1][1:] + with open(path, 'rb') as f: + data = f.read(MAX_ART + 1) + if len(data) > MAX_ART: + raise ValueError('source artwork too large') + images[slot] = [ext, base64.b64encode(data).decode()] + renderer = globals().get('ART_RENDERER') + if renderer is None: + with open(os.path.join(os.path.dirname(__file__), 'library_artwork.js')) as f: + renderer = f.read() + try: + return _render(plan, renderer, source['label'], images) + except (ValueError, OSError, EOFError, SystemExit) as e: + # Generated art from the icon alone always fits; a photo that didn't must not fail the install. + result = _render(plan, renderer, source['label'], {k: v for k, v in images.items() if k == 'icon'}) + result['warnings'].insert(0, 'Source artwork could not be rendered (' + str(e)[:120] + '); generated art used') + return result + + +def _render(plan, renderer, label, images): + # A 4K photo takes seconds to decode and encode on the Frame; allow well beyond that. + result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': label, 'images': images}) + ')', + timeout=75) + if not isinstance(result, dict) or set(result.get('images', {})) != set(ASSETS): + raise ValueError('incomplete artwork render') + paths = {} + for slot, (ext, encoded) in result['images'].items(): + data = base64.b64decode(encoded, validate=True) + signature = {'png': b'\x89PNG\r\n\x1a\n', 'jpg': b'\xff\xd8\xff'}.get(ext) + if not signature or not data.startswith(signature) or len(data) > MAX_ART: + raise ValueError(slot + ' render is ' + str(len(data)) + ' bytes of ' + str(ext)) + paths[slot] = os.path.join(os.path.dirname(plan), slot + '.' + ext) + with open(paths[slot] + '.tmp', 'wb') as f: + f.write(data) + for slot, path in paths.items(): + os.replace(path + '.tmp', path) + for stale in ('png', 'jpg'): + other = os.path.join(os.path.dirname(plan), slot + '.' + stale) + if other != path and os.path.exists(other): + os.remove(other) + return {'paths': paths, 'warnings': list(result.get('warnings', []))} + + +# Steam's own file for each custom-art type in userdata/*/config/grid/. +GRID_FILES = {0: 'p', 1: '_hero', 2: '_logo', 3: ''} + + +def custom_art(appid): + """The custom-art types this shortcut already has in Steam, for any local user.""" + found = set() + for kind, suffix in GRID_FILES.items(): + pattern = os.path.expanduser(f'~/.local/share/Steam/userdata/*/config/grid/{int(appid)}{suffix}.*') + if any(os.path.splitext(p)[1].lower() in ('.png', '.jpg', '.jpeg') for p in glob.glob(pattern)): + found.add(kind) + return found + + +def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None): + """options: category, details, fill_only (only empty slots and a missing icon; name and flags untouched).""" + options = options or {} + category = options.get('category', 'Android') + fill = bool(options.get('fill_only')) + existing = custom_art(appid) if fill else set() + if set(artwork) != set(ASSETS): + raise ValueError('all five Steam artwork slots are required') + images = [] + for slot, path in artwork.items(): + if slot not in ASSETS: + raise ValueError('unknown artwork slot') + ext = os.path.splitext(path)[1][1:] + if ext not in ('png', 'jpg'): + raise ValueError('artwork must be PNG or JPEG') + with open(path, 'rb') as f: + data = f.read(MAX_ART + 1) + if len(data) > MAX_ART: + raise ValueError('artwork is too large') + # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon. + if slot != 'icon' and ASSETS[slot] not in existing: + images.append([ASSETS[slot], ext, base64.b64encode(data).decode()]) + return evaluate(f'''(async () => {{ + const id = {int(appid)}, warnings = [], fill = {json.dumps(fill)}; + const overview = appStore.GetAppOverviewByAppID(id); + if (!fill) {{ + SteamClient.Apps.SetShortcutName(id, {json.dumps(name)}); + if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)}); + if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)}); + if (typeof SteamClient.Apps.SetShortcutSortAs === "function") + SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)}); + }} + if (!fill || !(overview && overview.icon_data)) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)}); + // null (devkit titles) or filling gaps: leave the VR flag as Steam has it. + if ({json.dumps(vr)} !== null && !fill) {{ + if (typeof SteamClient.Apps.SetShortcutIsVR === "function") + SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)}); + else warnings.push("Steam VR shortcut flag API unavailable"); + }} + if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{ + for (const [type, ext, data] of {json.dumps(images)}) {{ + // Steam keeps a slot's PNG and JPEG side by side; clear it so a stale one can't win. + if (!fill && typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") + try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}} + await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type); + }} + }} else throw new Error("Steam artwork API unavailable; installation is incomplete"); + {collections_js(int(appid), [category] + (['Android VR'] if vr and category == 'Android' else []))} + try {{ warnings.push(...await syncCollections()); }} + catch (e) {{ warnings.push("Steam collections: " + String(e)); }} + {notes_js(name, options.get('details', {}))} + return {{warnings}}; + }})()''', timeout=60) + + +def remove(appid): + # Collections and artwork are tidy-up: only a missing RemoveShortcut may fail the removal. + return evaluate(f'''(async () => {{ + const id = {int(appid)}, warnings = []; + {collections_js(int(appid))} + try {{ warnings.push(...await syncCollections()); }} + catch (e) {{ warnings.push("Steam collections: " + String(e)); }} + if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{ + for (const type of [0, 1, 2, 3]) {{ + try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} + catch (e) {{ warnings.push("Steam artwork " + type + ": " + String(e)); }} + }} + }} else warnings.push("Steam artwork removal API unavailable"); + SteamClient.Apps.RemoveShortcut(id); + return {{warnings}}; + }})()''') + + def main(): cmd, args = sys.argv[1], sys.argv[2:] if cmd == 'add': @@ -97,12 +299,30 @@ def main(): }})()''' print(evaluate(js)) elif cmd == 'list': - js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824) - .map(a => ({appid: a.appid, name: a.display_name})))()''' + # Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered. + js = '''(async () => Promise.all(appStore.allApps.filter(a => a.app_type === 1073741824).map(async a => { + let d = typeof appDetailsStore !== "undefined" && appDetailsStore.GetAppDetails(a.appid); + if (!d && typeof SteamClient.Apps.RegisterForAppDetails === "function") d = await new Promise(ok => { + let reg; + const timer = setTimeout(() => { if (reg) reg.unregister(); ok(null); }, 3000); + reg = SteamClient.Apps.RegisterForAppDetails(a.appid, x => { + clearTimeout(timer); setTimeout(() => reg && reg.unregister()); ok(x); }); + }); + return {appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid, + exe: d ? d.strShortcutExe || "" : "", start_dir: d ? d.strShortcutStartDir || "" : ""}; + })))()''' print(json.dumps(evaluate(js))) + elif cmd == 'render': + print(json.dumps(render(args[0]))) + elif cmd == 'configure': + vr = {'1': True, '0': False}.get(args[5]) # '' leaves Steam's VR flag alone + print(json.dumps(configure(int(args[0]), *args[1:5], vr, json.loads(args[6]), + json.loads(args[7]) if len(args) > 7 else None))) + elif cmd == 'stop': + evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)') + print('stopping') elif cmd == 'remove': - evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})') - print('removed') + print(json.dumps(remove(int(args[0])))) else: sys.exit(__doc__) diff --git a/tests/e2e/test_android.py b/tests/e2e/test_android.py index b2b5373..2b40489 100644 --- a/tests/e2e/test_android.py +++ b/tests/e2e/test_android.py @@ -33,8 +33,8 @@ class AndroidApps(harness.FrameTestCase): self.assertEqual(shortcut['name'], 'App label') self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh') self.assertEqual(shortcut['start_dir'], APP_DIR) - self.assertEqual(shortcut['icon'], f'{APP_DIR}/icon.png') - for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'icon.png', 'lepton-show-flatscreen'): + self.assertEqual(shortcut['icon'], f'{APP_DIR}/artwork/icon.png') + for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'artwork/icon.png', 'lepton-show-flatscreen'): self.assertTrue(exists(f'{APP_DIR}/{f}'), f) self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000) diff --git a/tests/fakeframe/rootfs/usr/local/bin/podman b/tests/fakeframe/rootfs/usr/local/bin/podman index a996bf3..9c10c24 100755 --- a/tests/fakeframe/rootfs/usr/local/bin/podman +++ b/tests/fakeframe/rootfs/usr/local/bin/podman @@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)} if cmd == 'ps': for name, c in sorted(containers.items()): print(f"{name} {c['port']}") +elif cmd == 'inspect': + if args[-1] not in containers: + sys.exit(1) + print('true') elif cmd == 'stop': name = args[-1] c = containers.get(name) diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js new file mode 100644 index 0000000..8a95e86 --- /dev/null +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js @@ -0,0 +1,33 @@ +// Synthetic canvas for API-path tests only. It emits transparent PNGs, not visual proof. +const zlib = require('zlib'); +function crc(data) { + let c=0xffffffff; + for(const b of data) {c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);} + return (c^0xffffffff)>>>0; +} +function chunk(name,data) { + const body=Buffer.concat([Buffer.from(name),data]), n=Buffer.alloc(4), sum=Buffer.alloc(4); + n.writeUInt32BE(data.length);sum.writeUInt32BE(crc(body));return Buffer.concat([n,body,sum]); +} +function png(w,h) { + const header=Buffer.alloc(13);header.writeUInt32BE(w);header.writeUInt32BE(h,4);header[8]=8;header[9]=6; + return Buffer.concat([Buffer.from('89504e470d0a1a0a','hex'),chunk('IHDR',header), + chunk('IDAT',zlib.deflateSync(Buffer.alloc((w*4+1)*h))),chunk('IEND',Buffer.alloc(0))]).toString('base64'); +} +function surface() { + const canvases=[]; + const document={fonts:{load:async()=>[]},createElement(tag) { + if(tag!=='canvas')throw Error('unexpected element'); + const canvas={width:1,height:1,text:[],draws:0}; + const ctx={measureText:t=>({width:String(t).length*30}),fillText(t){canvas.text.push(t);}, + drawImage(){canvas.draws++;},getImageData:()=>({data:new Uint8ClampedArray(canvas.width*canvas.height*4)}), + createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}})}; + for(const method of ['save','restore','beginPath','rect','roundRect','clip','fillRect','putImageData','arc','fill','stroke'])ctx[method]=()=>{}; + canvas.getContext=()=>ctx;canvas.toDataURL=type=>type==='image/jpeg'?'data:image/jpeg;base64,'+Buffer.from('ffd8ffe000104a464946','hex').toString('base64'): + 'data:image/png;base64,'+png(canvas.width,canvas.height); + canvases.push(canvas);return canvas; + }}; + class Image {constructor(){this.width=2;this.height=2;} async decode(){if(this.src.includes('YmFk'))throw Error('bad image');}} + return {document,Image,canvases}; +} +module.exports={surface}; diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js index d2ecd57..c4fa0dc 100644 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js @@ -37,7 +37,8 @@ function build(steam) { }, }); const shortcutOverview = s => ({ - appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, + appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid, + icon_data: s.icon ? 'fake-icon' : undefined, local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 }, }); const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)]; @@ -54,7 +55,30 @@ function build(steam) { } }; + // Library API shapes from SteamTracking / decky-frontend-lib (2026-09-28). + // Not yet verified on this Frame build: Steam was unavailable during testing. + steam.collections ||= []; + const collection = value => ({ + ...value, displayName: value.name, bIsDynamic: !!value.dynamic, bAllowsDragAndDrop: true, + AsDragDropCollection() { return this; }, + AddApps(apps) { value.apps = [...new Set([...value.apps, ...apps.map(a => a.appid)])]; }, + RemoveApps(apps) { value.apps = value.apps.filter(id => !apps.some(a => a.appid === id)); }, + value, + }); return { + ...require('./canvas_stub').surface(), + collectionStore: { + GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); }, + NewUnsavedCollection(name, filter, apps) { return collection({name, apps: apps.map(a => a.appid)}); }, + async SaveCollection(c) { if (!steam.collections.includes(c.value)) steam.collections.push(c.value); }, + }, + // Shortcut exe/start folder live in app details, not overviews (Frame, 2026-09-28). + appDetailsStore: { + GetAppDetails(id) { + const s = findShortcut(id); + return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null; + }, + }, appStore: { get allApps() { return allApps(); }, GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; }, @@ -75,6 +99,17 @@ function build(steam) { SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); }, SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); }, SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); }, + SetShortcutIsVR(id, vr) { const s = findShortcut(id); if (s) s.vr = vr; }, + async SetCustomArtworkForApp(id, data, ext, type) { + const s = findShortcut(id); + if (s) { s.artwork ||= {}; s.artwork[type] = {data, ext}; } + }, + async ClearCustomArtworkForApp(id, type) { + const s = findShortcut(id); + if (s?.artwork) delete s.artwork[type]; + }, + // Container fallback in frame_android.stop performs the simulated stop. + TerminateApp(gameid) { steam.last_terminate = gameid; }, RemoveShortcut(id) { steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id)); delete steam.compat_tools[String(id)]; diff --git a/tests/fixtures/apk-search/artwork/README.md b/tests/fixtures/apk-search/artwork/README.md new file mode 100644 index 0000000..6c67814 --- /dev/null +++ b/tests/fixtures/apk-search/artwork/README.md @@ -0,0 +1,19 @@ +# Store preview artwork + +Recorded public artwork for offline UI verification, fetched 2026-09-28. +`urls.json` records each original URL. No unit test downloads these files. + +- Open Brush banner, icon and screenshots: Icosa Foundation's public + `icosa-foundation/openbrush.app` website assets. Artwork remains credited to + its creators; used here to preview the Open Brush listing. +- Mindustry, AntennaPod and NewPipe icons/screenshots: their public F-Droid + listings. Corresponding projects use GPL licences; these images represent + those same apps in the store preview. +- Luanti, SuperTuxKart and other social previews: public GitHub-generated + repository preview images. Project names/logos belong to their owners. + +`../store.json` contains illustrative listing metadata, including mock package +names, popularity, dates, version/size and compatibility fields. It is not a +catalogue or evidence that a particular release works on the Frame. `_demo.py` +is opt-in and cannot download APKs. `tests/search_preview.py` preloads these +recordings into the image cache and simulates installation without a headset. diff --git a/tests/fixtures/apk-search/artwork/brush-banner.jpg b/tests/fixtures/apk-search/artwork/brush-banner.jpg new file mode 100644 index 0000000..b7bd790 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-banner.jpg differ diff --git a/tests/fixtures/apk-search/artwork/brush-icon.png b/tests/fixtures/apk-search/artwork/brush-icon.png new file mode 100644 index 0000000..32cfb0a Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/brush-shot1.png b/tests/fixtures/apk-search/artwork/brush-shot1.png new file mode 100644 index 0000000..3cd6b99 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-shot1.png differ diff --git a/tests/fixtures/apk-search/artwork/brush-shot2.webp b/tests/fixtures/apk-search/artwork/brush-shot2.webp new file mode 100644 index 0000000..c22ad8c Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-shot2.webp differ diff --git a/tests/fixtures/apk-search/artwork/brush-shot3.webp b/tests/fixtures/apk-search/artwork/brush-shot3.webp new file mode 100644 index 0000000..13b255e Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-shot3.webp differ diff --git a/tests/fixtures/apk-search/artwork/kart.png b/tests/fixtures/apk-search/artwork/kart.png new file mode 100644 index 0000000..6d90ef5 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/kart.png differ diff --git a/tests/fixtures/apk-search/artwork/luanti-icon.png b/tests/fixtures/apk-search/artwork/luanti-icon.png new file mode 100644 index 0000000..47d7345 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/luanti-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/luanti.png b/tests/fixtures/apk-search/artwork/luanti.png new file mode 100644 index 0000000..c8fa35d Binary files /dev/null and b/tests/fixtures/apk-search/artwork/luanti.png differ diff --git a/tests/fixtures/apk-search/artwork/mindustry-icon.png b/tests/fixtures/apk-search/artwork/mindustry-icon.png new file mode 100644 index 0000000..db8b4f2 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/mindustry-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/mindustry-shot.png b/tests/fixtures/apk-search/artwork/mindustry-shot.png new file mode 100644 index 0000000..b9c5874 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/mindustry-shot.png differ diff --git a/tests/fixtures/apk-search/artwork/newpipe-icon.png b/tests/fixtures/apk-search/artwork/newpipe-icon.png new file mode 100644 index 0000000..561ea34 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/newpipe-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/newpipe-shot.png b/tests/fixtures/apk-search/artwork/newpipe-shot.png new file mode 100644 index 0000000..3ecd0de Binary files /dev/null and b/tests/fixtures/apk-search/artwork/newpipe-shot.png differ diff --git a/tests/fixtures/apk-search/artwork/pod-icon.png b/tests/fixtures/apk-search/artwork/pod-icon.png new file mode 100644 index 0000000..9b54286 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/pod-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/pod-shot.png b/tests/fixtures/apk-search/artwork/pod-shot.png new file mode 100644 index 0000000..a189a10 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/pod-shot.png differ diff --git a/tests/fixtures/apk-search/artwork/urls.json b/tests/fixtures/apk-search/artwork/urls.json new file mode 100644 index 0000000..7574e46 --- /dev/null +++ b/tests/fixtures/apk-search/artwork/urls.json @@ -0,0 +1,16 @@ +{ + "brush-banner.jpg": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg", + "brush-icon.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png", + "brush-shot1.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png", + "brush-shot2.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp", + "brush-shot3.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp", + "luanti.png": "https://opengraph.githubassets.com/1/luanti-org/luanti", + "kart.png": "https://opengraph.githubassets.com/1/supertuxkart/stk-code", + "luanti-icon.png": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png", + "pod-icon.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png", + "mindustry-icon.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png", + "mindustry-shot.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png", + "pod-shot.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png", + "newpipe-icon.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png", + "newpipe-shot.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png" +} diff --git a/tests/fixtures/apk-search/entries.json b/tests/fixtures/apk-search/entries.json new file mode 100644 index 0000000..1b1f236 --- /dev/null +++ b/tests/fixtures/apk-search/entries.json @@ -0,0 +1,7 @@ +[ + {"source":"one","id":"brush","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":true,"version":"1","version_code":1,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2025-01-01"}, + {"source":"two","id":"brush2","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":false,"version":"2","version_code":2,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2026-01-01"}, + {"source":"one","id":"other","package":"org.other","name":"Open Brush","free":true,"downloadable":true,"min_sdk":31,"abis":["arm64-v8a"],"vr":true}, + {"source":"one","id":"unknown","package":null,"name":"Pocket Radio!","free":true,"downloadable":false,"vr":false}, + {"source":"two","id":"unknown2","package":null,"name":"pocket radio","free":true,"downloadable":false,"vr":false} +] diff --git a/tests/fixtures/apk-search/store.json b/tests/fixtures/apk-search/store.json new file mode 100644 index 0000000..26bf886 --- /dev/null +++ b/tests/fixtures/apk-search/store.json @@ -0,0 +1,182 @@ +[ + { + "id": "brush", + "package": "org.preview.brush", + "name": "Open Brush", + "summary": "Make the world your canvas. Paint, sculpt and create in a space without limits.", + "description": "Your imagination deserves more room. Open Brush turns the space around you into a canvas, with expressive brushes, vivid colors and light you can paint with.\n\nCreate something small, build something extraordinary, or just enjoy making your first mark in VR. This community-led painting app is free and open source.", + "developer": "Icosa Foundation", + "license": "Apache-2.0", + "free": true, + "downloadable": true, + "version": "2.32.29", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": true, + "updated": "2026-09-27", + "size": 85000000, + "popularity": 100, + "images": { + "banner": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg", + "icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png", + "screenshots": [ + "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png", + "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp", + "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp" + ] + }, + "engine": "Unity OpenXR", + "frame_tested": true, + "icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png", + "page": "https://openbrush.app" + }, + { + "id": "mindustry", + "package": "org.preview.mindustry", + "name": "Mindustry", + "summary": "Build a factory. Defend your world.", + "description": "Build a factory. Defend your world.", + "developer": "Anuken", + "license": "GPL-3.0", + "free": true, + "downloadable": true, + "version": "1.2", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-26", + "size": 85000000, + "popularity": 92, + "images": { + "banner": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png", + "icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png", + "screenshots": [ + "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png" + ] + }, + "icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png" + }, + { + "id": "luanti", + "package": "org.preview.luanti", + "name": "Luanti", + "summary": "A world of blocks. Endless possibilities.", + "description": "A world of blocks. Endless possibilities.", + "developer": "Luanti contributors", + "license": "LGPL-2.1", + "free": true, + "downloadable": true, + "version": "1.3", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-25", + "size": 85000000, + "popularity": 84, + "images": { + "banner": "https://opengraph.githubassets.com/1/luanti-org/luanti", + "icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png", + "screenshots": [ + "https://opengraph.githubassets.com/1/luanti-org/luanti" + ] + }, + "icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png" + }, + { + "id": "pod", + "package": "org.preview.pod", + "name": "AntennaPod", + "summary": "Your favorite stories, wherever you listen.", + "description": "Your favorite stories, wherever you listen.", + "developer": "AntennaPod contributors", + "license": "GPL-3.0", + "free": true, + "downloadable": true, + "version": "1.4", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-24", + "size": 85000000, + "popularity": 76, + "images": { + "banner": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png", + "icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png", + "screenshots": [ + "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png" + ] + }, + "icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png" + }, + { + "id": "newpipe", + "package": "org.preview.newpipe", + "name": "NewPipe", + "summary": "Your videos and music, without the distractions.", + "description": "Your videos and music, without the distractions.", + "developer": "Team NewPipe", + "license": "GPL-3.0", + "free": true, + "downloadable": true, + "version": "1.5", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-23", + "size": 85000000, + "popularity": 68, + "images": { + "banner": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png", + "icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png", + "screenshots": [ + "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png" + ] + }, + "icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png" + }, + { + "id": "kart", + "package": "org.preview.kart", + "name": "SuperTuxKart", + "summary": "A little friendly competition. A lot of colorful chaos.", + "description": "A little friendly competition. A lot of colorful chaos.", + "developer": "SuperTuxKart Team", + "license": "GPL-3.0", + "free": true, + "downloadable": false, + "version": "1.6", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-22", + "size": 85000000, + "popularity": 60, + "images": { + "banner": "https://opengraph.githubassets.com/1/supertuxkart/stk-code", + "icon": null, + "screenshots": [ + "https://opengraph.githubassets.com/1/supertuxkart/stk-code" + ] + }, + "icon": null, + "page": "https://supertuxkart.net" + } +] diff --git a/tests/fixtures/fdroid/README.md b/tests/fixtures/fdroid/README.md new file mode 100644 index 0000000..c50721a --- /dev/null +++ b/tests/fixtures/fdroid/README.md @@ -0,0 +1,21 @@ +# F-Droid verification fixtures + +`entry.jar` and `index-v1.jar` are synthetic RSA-2048/SHA-256 signed JARs, +including CMS signed attributes. `fingerprint.txt` identifies their throwaway +certificate. Their JSON describes org.example.app; `example.apk` is deliberately +plain test data, not an installable app. The v2 index includes incompatible +Android-31 and x86-only versions to exercise the shared reducer. + +`izzy-entry.jar` was recorded from +https://apt.izzysoft.de/fdroid/repo/entry.jar on 2026-09-28. Its certificate +fingerprint matches the operator's published fingerprint: +3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A. +It exercises an independent production JAR/CMS encoder without network access. +The index it references is not needed by this signature-only fixture test. + +`artwork-v1.json` and `artwork-v2.json` are unsigned metadata/reducer fixtures +based on the synthetic indexes above. They exercise en-US preference, per-field +locale fallback, v1 artwork paths, phone/tablet ordering, the six-image cap, +author names and HTML/multiline summaries. The signed integrity fixtures remain +unchanged; artwork tests feed these JSON files directly through the reducer and +then round-trip the resulting entries through the source cache. diff --git a/tests/fixtures/fdroid/artwork-v1.json b/tests/fixtures/fdroid/artwork-v1.json new file mode 100644 index 0000000..a156a0c --- /dev/null +++ b/tests/fixtures/fdroid/artwork-v1.json @@ -0,0 +1,54 @@ +{ + "apps": [ + { + "packageName": "org.example.app", + "name": "Example", + "license": "MIT", + "authorName": "Example Developer", + "summary": "Fallback summary", + "localized": { + "de": { + "name": "Beispiel", + "summary": "Deutsch", + "icon": "german.png", + "phoneScreenshots": [ + "german.png" + ] + }, + "en-US": { + "name": "Example", + "summary": "Offline fixture & music.\n One\t line.", + "icon": "icon.png", + "phoneScreenshots": [ + "1.png", + "2.png", + "3.png", + "4.png" + ] + }, + "fr": { + "featureGraphic": "featureGraphic.png", + "sevenInchScreenshots": [ + "1.png", + "2.png", + "3.png", + "4.png" + ] + } + } + } + ], + "packages": { + "org.example.app": [ + { + "versionName": "1", + "versionCode": 1, + "apkName": "example1.apk", + "hash": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "hashType": "sha256", + "size": 51, + "minSdkVersion": 21 + } + ] + } +} diff --git a/tests/fixtures/fdroid/artwork-v2.json b/tests/fixtures/fdroid/artwork-v2.json new file mode 100644 index 0000000..ed7c821 --- /dev/null +++ b/tests/fixtures/fdroid/artwork-v2.json @@ -0,0 +1,143 @@ +{ + "repo": { + "name": { + "en-US": "Fixture" + } + }, + "packages": { + "org.example.app": { + "metadata": { + "name": { + "en-US": "Example" + }, + "summary": { + "en-US": "

Offline fixture & music.

\n

One\t line.

" + }, + "license": "MIT", + "authorName": "Example Developer", + "icon": { + "de": { + "name": "/org.example.app/de/icon.png" + }, + "en-US": { + "name": "/org.example.app/en-US/icon.png" + } + }, + "featureGraphic": { + "fr": { + "name": "/org.example.app/fr/featureGraphic.png" + } + }, + "screenshots": { + "phone": { + "de": [ + { + "name": "/org.example.app/de/phoneScreenshots/1.png" + } + ], + "en-US": [ + { + "name": "/org.example.app/en-US/phoneScreenshots/1.png" + }, + { + "name": "/org.example.app/en-US/phoneScreenshots/2.png" + }, + { + "name": "/org.example.app/en-US/phoneScreenshots/3.png" + }, + { + "name": "/org.example.app/en-US/phoneScreenshots/4.png" + } + ] + }, + "sevenInch": { + "fr": [ + { + "name": "/org.example.app/fr/sevenInchScreenshots/1.png" + }, + { + "name": "/org.example.app/fr/sevenInchScreenshots/2.png" + }, + { + "name": "/org.example.app/fr/sevenInchScreenshots/3.png" + }, + { + "name": "/org.example.app/fr/sevenInchScreenshots/4.png" + } + ] + } + } + }, + "versions": { + "1": { + "manifest": { + "versionName": "1", + "versionCode": 1, + "usesSdk": { + "minSdkVersion": 21 + }, + "nativecode": [] + }, + "file": { + "name": "/example1.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + }, + "2": { + "manifest": { + "versionName": "2", + "versionCode": 2, + "usesSdk": { + "minSdkVersion": 30 + }, + "nativecode": [ + "arm64-v8a" + ] + }, + "file": { + "name": "/example2.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + }, + "3": { + "manifest": { + "versionName": "3", + "versionCode": 3, + "usesSdk": { + "minSdkVersion": 31 + }, + "nativecode": [] + }, + "file": { + "name": "/example3.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + }, + "4": { + "manifest": { + "versionName": "4", + "versionCode": 4, + "usesSdk": { + "minSdkVersion": 21 + }, + "nativecode": [ + "x86_64" + ] + }, + "file": { + "name": "/example4.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + } + } + } + } +} diff --git a/tests/fixtures/fdroid/entry.jar b/tests/fixtures/fdroid/entry.jar new file mode 100644 index 0000000..b73864a Binary files /dev/null and b/tests/fixtures/fdroid/entry.jar differ diff --git a/tests/fixtures/fdroid/example.apk b/tests/fixtures/fdroid/example.apk new file mode 100644 index 0000000..e53f5b7 --- /dev/null +++ b/tests/fixtures/fdroid/example.apk @@ -0,0 +1 @@ +Fixture APK payload, deliberately not installable. diff --git a/tests/fixtures/fdroid/fingerprint.txt b/tests/fixtures/fdroid/fingerprint.txt new file mode 100644 index 0000000..494e9b0 --- /dev/null +++ b/tests/fixtures/fdroid/fingerprint.txt @@ -0,0 +1 @@ +0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a diff --git a/tests/fixtures/fdroid/index-v1.jar b/tests/fixtures/fdroid/index-v1.jar new file mode 100644 index 0000000..6ea58d2 Binary files /dev/null and b/tests/fixtures/fdroid/index-v1.jar differ diff --git a/tests/fixtures/fdroid/index-v2.json b/tests/fixtures/fdroid/index-v2.json new file mode 100644 index 0000000..6d443c6 --- /dev/null +++ b/tests/fixtures/fdroid/index-v2.json @@ -0,0 +1 @@ +{"repo": {"name": {"en-US": "Fixture"}}, "packages": {"org.example.app": {"metadata": {"name": {"en-US": "Example"}, "summary": {"en-US": "Offline fixture"}, "license": "MIT"}, "versions": {"1": {"manifest": {"versionName": "1", "versionCode": 1, "usesSdk": {"minSdkVersion": 21}, "nativecode": []}, "file": {"name": "/example1.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "2": {"manifest": {"versionName": "2", "versionCode": 2, "usesSdk": {"minSdkVersion": 30}, "nativecode": ["arm64-v8a"]}, "file": {"name": "/example2.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "3": {"manifest": {"versionName": "3", "versionCode": 3, "usesSdk": {"minSdkVersion": 31}, "nativecode": []}, "file": {"name": "/example3.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "4": {"manifest": {"versionName": "4", "versionCode": 4, "usesSdk": {"minSdkVersion": 21}, "nativecode": ["x86_64"]}, "file": {"name": "/example4.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}}}}} \ No newline at end of file diff --git a/tests/fixtures/fdroid/izzy-entry.jar b/tests/fixtures/fdroid/izzy-entry.jar new file mode 100644 index 0000000..c518648 Binary files /dev/null and b/tests/fixtures/fdroid/izzy-entry.jar differ diff --git a/tests/fixtures/library/README.md b/tests/fixtures/library/README.md new file mode 100644 index 0000000..04d4f03 --- /dev/null +++ b/tests/fixtures/library/README.md @@ -0,0 +1,17 @@ +# Library fixtures + +`icon.png` is a synthetic 2×2 RGBA fixture with opaque, half-transparent and +transparent pixels. `steam-responses.json` includes the Open Saber Plus +shortcut ID/name and home path read from the Frame's existing metadata on +2026-09-28; the `configure` response is synthetic, matching our helper's +contract. Tests never contact the network. + +The existing fakeframe CEF shim models artwork and collection methods from +SteamTracking's `ClientExtracted/steamui/chunk~2dcc5aaf7.js` and +SteamDeckHomebrew/decky-frontend-lib's `src/globals/steam-client/App.ts`, read +2026-09-28. These methods were not captured from this headset: Steam's client +was unavailable. The Node-based test runs the actual generated JavaScript +against that fixture; it is skipped when Node is absent. + +`icon.jpg` is the same synthetic icon converted with macOS `sips` to exercise +JPEG SOF parsing. `sips` is not used by the product or tests. diff --git a/tests/fixtures/library/check-renderer.js b/tests/fixtures/library/check-renderer.js new file mode 100644 index 0000000..1e2e06f --- /dev/null +++ b/tests/fixtures/library/check-renderer.js @@ -0,0 +1,21 @@ +const fs=require('fs'),vm=require('vm'),assert=require('assert'); +const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub'); +(async()=>{ + const surface=stub.surface(),ctx=vm.createContext(surface); + vm.runInContext(fs.readFileSync('frame/android/library_artwork.js','utf8'),ctx); + const result=await ctx.renderLibraryArtwork({label:'Example Game',images:{icon:['png','fixture']}}); + assert.deepEqual(Object.keys(result.images),['grid','wide','hero','logo','icon']); + for(const [slot,size] of Object.entries({grid:[600,900],wide:[920,430],hero:[3840,1240],logo:[1280,480],icon:[256,256]})) { + assert.equal(result.images[slot][0],'png'); + const b=Buffer.from(result.images[slot][1],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]); + } + // A photo scene is JPEG (Steam's 12 MiB limit at hero size); the logo stays transparent PNG. + const photo=await ctx.renderLibraryArtwork({label:'Photo',images:{hero:['jpg','fixture'],banner:['jpg','fixture']}}); + for(const slot of ['wide','hero'])assert.equal(photo.images[slot][0],'jpg'); + for(const slot of ['grid','logo','icon'])assert.equal(photo.images[slot][0],'png'); + const hero=surface.canvases.find(c=>c.width===3840);assert.equal(hero.text.length,0); + const logo=surface.canvases.find(c=>c.width===1280);assert(logo.text.length);assert.equal(logo.draws,0); + const before=surface.canvases.length;await ctx.renderLibraryArtwork({label:'No Icon',images:{}}); + assert.equal(surface.canvases.slice(before).find(c=>c.width===3840).text.length,0); + console.log('five dimensions, textless hero, title logo: OK'); +})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/fixtures/library/icon.jpg b/tests/fixtures/library/icon.jpg new file mode 100644 index 0000000..ea4d67c Binary files /dev/null and b/tests/fixtures/library/icon.jpg differ diff --git a/tests/fixtures/library/icon.png b/tests/fixtures/library/icon.png new file mode 100644 index 0000000..2e2af85 Binary files /dev/null and b/tests/fixtures/library/icon.png differ diff --git a/tests/fixtures/library/steam-responses.json b/tests/fixtures/library/steam-responses.json new file mode 100644 index 0000000..8b2c2c7 --- /dev/null +++ b/tests/fixtures/library/steam-responses.json @@ -0,0 +1,12 @@ +{ + "home": "/home/steamos", + "shortcuts": [ + { + "appid": 3346865537, + "name": "Open Saber Plus" + } + ], + "configure": { + "warnings": [] + } +} diff --git a/tests/fixtures/more_sources/README.md b/tests/fixtures/more_sources/README.md new file mode 100644 index 0000000..8f33165 --- /dev/null +++ b/tests/fixtures/more_sources/README.md @@ -0,0 +1,23 @@ +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. + +Artwork follow-up: `topic.json` now retains `owner.avatar_url` from authenticated +repository API reads. `artwork-check.json` records HTTPS response status, +Content-Type and image magic checks for all curated URLs and three topic +results. All curated URLs returned real images; LWJGL's social preview returned +HTTP 429. This fixture is evidence of a point-in-time check, not an uptime test. +Open Brush screenshots came from the Steam appdetails response for app 1634870, +linked by its README. SuperTux's README links the recorded upstream screenshot. diff --git a/tests/fixtures/more_sources/artwork-check.json b/tests/fixtures/more_sources/artwork-check.json new file mode 100644 index 0000000..d16be22 --- /dev/null +++ b/tests/fixtures/more_sources/artwork-check.json @@ -0,0 +1,120 @@ +[ + { + "url": "https://avatars.githubusercontent.com/u/2757344?v=4", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://avatars.githubusercontent.com/u/784805?v=4", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://avatars.githubusercontent.com/u/94376830?v=4", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://opengraph.githubassets.com/1/KhronosGroup/OpenXR-SDK-Source", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://opengraph.githubassets.com/1/LWJGL/lwjgl3", + "error": "HTTP Error 429: Too Many Requests" + }, + { + "url": "https://opengraph.githubassets.com/1/bjornbytes/lovr", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://opengraph.githubassets.com/1/sahibzada-allahyar/YC-Killer", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/open-brush.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_0a9c208e26a43cf34879c2ca361d4c8f18af8cba.1920x1080.jpg", + "status": 200, + "content_type": "image/jpeg", + "image": true + }, + { + "url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_19b25b86ef55c0d8769a65135d60eaae8fa40553.1920x1080.jpg", + "status": 200, + "content_type": "image/jpeg", + "image": true + }, + { + "url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_785ea37d63378146dfe0f0ffa3f1d5c155ca978f.1920x1080.jpg", + "status": 200, + "content_type": "image/jpeg", + "image": true + }, + { + "url": "https://www.supertux.org/images/0_7_0/github_preview.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://opengraph.githubassets.com/1/arpruss/OpenSaberPlus", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif", + "status": 200, + "content_type": "image/gif", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_2.gif", + "status": 200, + "content_type": "image/gif", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_3.gif", + "status": 200, + "content_type": "image/gif", + "image": true + } +] \ No newline at end of file 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..2a87d01 --- /dev/null +++ b/tests/fixtures/more_sources/topic.json @@ -0,0 +1,28 @@ +{ + "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.", + "owner": { + "avatar_url": "https://avatars.githubusercontent.com/u/2757344?v=4" + } + }, + { + "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.", + "owner": { + "avatar_url": "https://avatars.githubusercontent.com/u/94376830?v=4" + } + }, + { + "full_name": "bjornbytes/lovr", + "name": "lovr", + "description": "Lua Virtual Reality Framework", + "owner": { + "avatar_url": "https://avatars.githubusercontent.com/u/784805?v=4" + } + } + ] +} 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/fixtures/sidequest-policy.json b/tests/fixtures/sidequest-policy.json new file mode 100644 index 0000000..dd08588 --- /dev/null +++ b/tests/fixtures/sidequest-policy.json @@ -0,0 +1,18 @@ +{ + "recorded": "2026-09-28", + "robots": { + "url": "https://sidequestvr.com/robots.txt", + "user_agent": "*", + "crawl_delay": 3, + "disallow": ["/search/", "/user/*", "/sideload/*"], + "sitemap": "https://sidequestvr.com/sitemap_index.xml" + }, + "api_robots": {"url": "https://api.sidequestvr.com/robots.txt", "status": 403}, + "terms": { + "url": "https://sidequestvr.com/terms", + "bundle": "https://sidequestvr.com/main-4MMXZRXL.js", + "prohibited_activities_i": "copy, distribute, or disclose any part of the Service in any medium, including without limitation by any automated or non-automated scraping", + "prohibited_activities_xi": "access any content on the Service through any technology or means other than those provided or authorized by the Service" + }, + "note": "Policy evidence, not a fabricated API response. No app metadata or download fixture was collected after discovering the restriction." +} diff --git a/tests/search_preview.py b/tests/search_preview.py new file mode 100644 index 0000000..cdd6bec --- /dev/null +++ b/tests/search_preview.py @@ -0,0 +1,58 @@ +"""Local store preview. No device access; installation progress is simulated. + +FRAME_APK_SEARCH_DEMO=1 python3 tests/search_preview.py +""" +import json +import os +from pathlib import Path +import sys +import tempfile +import time +from urllib.parse import urlparse +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import server +from apk_sources import _demo, _images, search + + +class Preview(server.Handler): + def do_GET(self): + path = urlparse(self.path).path + if path == '/api/android': + self.send_json({'apps': []}) + return + if path == '/api/android/reports': + self.send_json({'reports': [], 'shared': False}) + return + if path not in ('/', '/index.html', '/api/search', '/api/sources', '/api/sources/details', '/api/job', '/api/host') and not path.startswith('/source-image/'): + self.send_json({'error': 'Headset disconnected', 'offline': True}, 503) + return + super().do_GET() + + def do_POST(self): + if not self.local_request(): + return + if urlparse(self.path).path == '/api/sources/install': + body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0)))) + def work(report): + for percent in (12, 28, 43, 67, 89): + report('Downloading', percent) + time.sleep(2) + report('Installing', None) + time.sleep(3) + return {'package': 'org.preview.' + body['id'], 'message': 'Preview installation complete'} + self.send_json(server.start_job('Preview installation', work, progress=True)) + return + if urlparse(self.path).path == '/api/sources': + super().do_POST() + return + self.send_json({'error': 'Device access disabled in store preview'}, 403) + + +if __name__ == '__main__': + if os.environ.get('FRAME_APK_SEARCH_DEMO') != '1': + sys.exit('Set FRAME_APK_SEARCH_DEMO=1') + for name, url in json.loads((_demo.FIXTURES / 'artwork' / 'urls.json').read_text()).items(): + _images.remember(url, (_demo.FIXTURES / 'artwork' / name).read_bytes()) + with tempfile.TemporaryDirectory(prefix='frame-store-preview-') as tmp: + search.settings_path = lambda: Path(tmp) / 'enabled.json' + server.ThreadingHTTPServer(('127.0.0.1', 8795), Preview).serve_forever() diff --git a/tests/test_apk_artwork.py b/tests/test_apk_artwork.py new file mode 100644 index 0000000..b036f90 --- /dev/null +++ b/tests/test_apk_artwork.py @@ -0,0 +1,101 @@ +import http.client +import json +from pathlib import Path +import socket +import sys +import threading +import unittest +from unittest.mock import patch, Mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +from apk_sources import _images, search, SourceError +import server + +PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').read_bytes() + + +class ArtworkTests(unittest.TestCase): + def setUp(self): + with _images._lock: + _images._urls.clear() + _images._cache.clear() + + def test_only_registered_source_images_are_fetchable(self): + with patch.object(_images, 'fetch') as fetch: + with self.assertRaisesRegex(SourceError, 'Unknown artwork'): + _images.image('https://example.com/arbitrary.png') + fetch.assert_not_called() + entry = {'images': {'icon': 'https://example.com/icon.png', 'banner': 'file:///tmp/private', + 'screenshots': ['https://example.com/shot.png', 'javascript:alert(1)']}} + art = _images.artwork(entry) + self.assertTrue(art['icon'].startswith('/source-image/')) + self.assertIsNone(art['banner']) + self.assertEqual(len(art['screenshots']), 1) + with patch.object(_images, 'fetch', return_value=(PNG, 'image/png')) as fetch: + self.assertEqual(_images.image(art['icon'].split('/')[-1]), (PNG, 'image/png')) + _images.image(art['icon'].split('/')[-1]) + fetch.assert_called_once_with('https://example.com/icon.png') + + def test_rejects_credentials_ports_and_non_http(self): + for url in ['file:///tmp/a.png', 'data:image/png;base64,AAAA', 'http://user:pass@example.com/a.png', + 'http://example.com:22/a.png', 'https://example.com:bad/a.png', '//example.com/a.png']: + self.assertIsNone(_images.register(url), url) + + def test_blocks_private_loopback_and_mixed_dns_answers(self): + for ip in ['127.0.0.1', '10.0.0.1', '169.254.169.254', '::1', '192.168.1.1']: + with patch.object(socket, 'getaddrinfo', return_value=[(2,1,6,'',(ip,443))]), \ + patch.object(socket, 'create_connection') as connect: + with self.assertRaisesRegex(SourceError, 'Private network'): + _images.fetch('https://example.com/private.png') + connect.assert_not_called() + + def test_redirect_to_private_network_is_rejected(self): + response = Mock(status=302) + response.getheader.return_value = 'http://127.0.0.1/secret' + conn = Mock() + conn.getresponse.return_value = response + public = [(2,1,6,'',('93.184.216.34',80))] + private = [(2,1,6,'',('127.0.0.1',80))] + with patch.object(socket, 'getaddrinfo', side_effect=[public,private]), \ + patch.object(socket, 'create_connection') as connect, \ + patch.object(http.client, 'HTTPConnection', return_value=conn): + with self.assertRaisesRegex(SourceError, 'Private network'): + _images.fetch('http://example.com/a.png') + connect.assert_called_once_with(('93.184.216.34',80),timeout=10) + + def test_non_images_and_oversized_images_are_rejected(self): + with self.assertRaises(SourceError): + _images.remember('https://example.com/a.svg', b'') + with self.assertRaisesRegex(SourceError, 'too large'): + _images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE) + + def test_handles_are_bounded(self): + for i in range(4100): + _images.register('https://example.com/%d.png' % i) + self.assertEqual(len(_images._urls),4096) + + def test_plain_language_verdict_is_evidence_based(self): + self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame') + self.assertEqual(search.verdict({'min_sdk':24,'abis':[]})['label'], 'Ready to try on the Frame') + self.assertEqual(search.verdict({'min_sdk':24,'abis':[],'frame_tested':True})['label'], 'Works on the Frame') + self.assertIn('newer Android', search.verdict({'min_sdk':31})['label']) + self.assertIn('Meta Quest services', search.verdict({'requires_meta_services':True})['label']) + self.assertEqual(search.verdict({'engine':'VrApi'})['tone'],'blocked') + self.assertNotEqual(search.verdict({'frame_tested':True,'min_sdk':31})['tone'],'works') + + def test_image_endpoint_does_not_allow_arbitrary_urls(self): + httpd = server.ThreadingHTTPServer(('127.0.0.1',0),server.Handler) + threading.Thread(target=httpd.serve_forever,daemon=True).start() + try: + path = _images.register('https://example.com/app.png') + _images.remember('https://example.com/app.png',PNG) + for url, expected in [(path,200),('/source-image/unknown',404)]: + c=http.client.HTTPConnection('127.0.0.1',httpd.server_port) + c.request('GET',url) + r=c.getresponse();data=r.read();c.close() + self.assertEqual(r.status,expected) + if expected==200: + self.assertEqual(data,PNG) + self.assertEqual(r.getheader('Content-Type'),'image/png') + finally: + httpd.shutdown();httpd.server_close() diff --git a/tests/test_apk_more_sources.py b/tests/test_apk_more_sources.py new file mode 100644 index 0000000..f1879b9 --- /dev/null +++ b/tests/test_apk_more_sources.py @@ -0,0 +1,235 @@ +import hashlib, io, json, os, sys, tempfile, time, 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) + _web._limited.clear() + self.addCleanup(_web._limited.clear) + self.root = Path(self.tmp.name) / 'caches' / 'apk-sources' + roots = patch.object(_web.frame_host, 'cache_dir', lambda *p: self.root.parent.joinpath(*p)) + roots.start() + self.addCleanup(roots.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_curated_artwork_has_recorded_image_evidence(self): + evidence = {r['url']: r for r in json.loads((FIX / 'artwork-check.json').read_text())} + entries = github.search(github.sources()[0], '') + self.assertEqual(len(entries), 4) + for entry in entries: + self.assertTrue(entry['summary']) + images = entry['images'] + self.assertEqual(entry['icon'], images['icon']) + for url in [u for u in [images['icon'], images['banner']] if u] + images['screenshots']: + self.assertTrue(url.startswith('https://')) + self.assertEqual(evidence[url]['status'], 200) + self.assertTrue(evidence[url]['image']) + self.assertTrue(entries[1]['images']['screenshots']) + self.assertTrue(entries[2]['images']['screenshots']) + + def test_topic_keeps_curated_artwork(self): + curated = github._curated()[1] + repo = {'full_name': curated['repo'], 'name': 'open-brush', + 'owner': {'avatar_url': 'https://avatars.githubusercontent.com/u/1'}} + with patch.object(github, '_api', return_value={'items': [repo]}): + entry = github.search(github.sources()[0], 'topic:openxr')[0] + self.assertEqual(entry['images'], curated['images']) + unknown = github.details(github.sources()[0], 'unknown/project') + self.assertEqual(unknown['icon'], 'https://github.com/unknown.png') + self.assertIsNone(unknown['images']['banner']) + + 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']) + search_entry = github.search(github.sources()[0], repo)[0] + self.assertEqual(e['images'], search_entry['images']) + self.assertEqual(e['icon'], e['images']['icon']) + 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)) + for entry, repo in zip(entries, data['items']): + self.assertEqual(entry['icon'], repo['owner']['avatar_url']) + self.assertEqual(entry['images']['icon'], entry['icon']) + self.assertIsNone(entry['images']['banner']) + self.assertEqual(entry['images']['screenshots'], []) + 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']) + for item in entries: + self.assertEqual(item['icon'], item['images']['icon']) + self.assertEqual(item['icon'], item['images']['banner']) + self.assertEqual(item['images']['screenshots'], []) + 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), patch.dict(os.environ, {'FRAME_GITHUB_TOKEN': ''}), \ + self.assertRaisesRegex(SourceError, 'FRAME_GITHUB_TOKEN'): + github._api('/x') + with patch.object(_web, 'open_url', side_effect=error), patch.object(_web.time, 'time', return_value=1e12): + self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index') # throttled: stale copy + with patch.object(_web, 'read', return_value=b''), self.assertRaises(SourceError): + github._api('/x') + + def test_prune_caps_apks_by_age_and_removes_orphans(self): + now = 1e9 + self.root.mkdir(parents=True) + def make(folder, name, size, age): + path = Path(folder) / name + path.mkdir() if size is None else path.write_bytes(b'x' * size) + os.utime(str(path), (now - age, now - age)) + return path + pub = self.tmp.name + oldest = make(self.root, 'a.apk', 40, 9000) + old = make(pub, 'b.apk', 40, 8000) + kept = make(self.root, 'c.apk', 40, 7200) + recent = make(pub, 'd.apk', 40, 60) # just downloaded: never pruned + orphan, busy = make(self.root, 'x.part', 5, 90000), make(pub, 'y.part', 5, 60) + listing, fresh = make(pub, 'l.data', 5, 8 * 86400), make(pub, 'm.data', 5, 3600) + tmpdir = make(self.root, 'tmpabc', None, 90000) + with patch.object(_web, 'APK_CAP', 100), patch.object(_web.time, 'time', return_value=now): + _web.prune() + self.assertEqual([p.exists() for p in (oldest, old, kept, recent)], [False, False, True, True]) + self.assertEqual([p.exists() for p in (orphan, busy, listing, fresh, tmpdir)], [False, True, False, True, False]) + + def test_prune_rechecks_before_deleting_and_spares_apks_in_use(self): + self.root.mkdir(parents=True) + old = time.time() - 7200 + reused, claimed, stale = self.root / 'a.apk', self.root / 'b.apk', self.root / 'c.apk' + for path in (reused, claimed, stale): + path.write_bytes(b'x' * 40) + _web.claim(claimed) + self.addCleanup(_web.release, claimed) + for path in (reused, claimed, stale): + os.utime(str(path), (old, old)) + real = os.listdir + def listdir(folder): + if folder == self.tmp.name: # scanning the second folder: a download reuses a.apk meanwhile + self.assertTrue(_web.touch(reused)) + return real(folder) + with patch.object(_web, 'APK_CAP', 10), patch.object(_web.os, 'listdir', listdir): + _web.prune() + self.assertEqual([p.exists() for p in (reused, claimed, stale)], [True, True, False]) + _web.release(claimed) + with patch.object(_web, 'APK_CAP', 10): + _web.prune() + self.assertFalse(claimed.exists()) + self.assertFalse(_web.touch(stale)) # a pruned APK is reported gone, so it's downloaded again + + def test_backoff_honours_retry_after_per_host(self): + from apk_sources import SourceLimited + from email.utils import formatdate + now = [1e9] + clock = patch.object(_web.time, 'time', side_effect=lambda: now[0]) + clock.start() + self.addCleanup(clock.stop) + error = urllib.error.HTTPError('https://itch.io/a', 429, 'slow down', {'Retry-After': '120'}, None) + with patch.object(_web, 'open_url', side_effect=error) as op: + with self.assertRaisesRegex(SourceLimited, '^itch.io is limiting requests; try again in 2 minutes$'): + itch.search(itch.sources()[0], '') + with self.assertRaises(SourceLimited) as caught: # other URLs on the host wait too + _web.read('https://itch.io/b', ('itch.io',), name='itch.io') + self.assertEqual(op.call_count, 1) + self.assertAlmostEqual(caught.exception.retry_after, 120) + with self.assertRaises(SourceLimited): + _web.apk('https://itch.io/c.apk', ('itch.io',)) + self.assertEqual(op.call_count, 1) + with patch.object(_web, 'open_url', return_value=io.BytesIO(b'fresh')): + self.assertEqual(_web.read('https://api.github.com/x', ('api.github.com',)), b'fresh') # other hosts unaffected + now[0] += 121 + with patch.object(_web, 'open_url', return_value=io.BytesIO(b'feed')): + self.assertEqual(_web.read('https://itch.io/b', ('itch.io',)), b'feed') + cases = [({}, 600), ({'Retry-After': formatdate(now[0] + 300, usegmt=True)}, 300), + ({'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(now[0]) + 60)}, 60)] + for headers, expected in cases: + with self.subTest(headers=headers): + _web._limited.clear() + self.assertAlmostEqual(_web.throttle('https://h.test/x', headers), expected, delta=1) + self.assertAlmostEqual(_web.wait_time('https://h.test/y'), expected, delta=1) + error = urllib.error.HTTPError('https://api.github.com/x', 403, 'limited', {}, None) + with patch.object(_web, 'open_url', side_effect=error), patch.dict(os.environ, {'FRAME_GITHUB_TOKEN': ''}), \ + self.assertRaisesRegex(SourceLimited, '^GitHub is limiting requests; try again in 10 minutes .*TOKEN'): + github._api('/y') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py new file mode 100644 index 0000000..20cd57e --- /dev/null +++ b/tests/test_apk_search.py @@ -0,0 +1,350 @@ +import http.client +import json +from pathlib import Path +import sys +import tempfile +import threading +import time +import types +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +from apk_sources import search, SourceError +import server + +ENTRIES = json.loads((Path(__file__).parent / 'fixtures/apk-search/entries.json').read_text()) + + +def fake(source_id='one', fn=None): + return types.SimpleNamespace(KIND=source_id, sources=lambda: [dict(id=source_id, name=source_id, + enabled=True, trust='official', builtin=True)], + search=fn or (lambda s, q, limit=50: [e for e in ENTRIES if e['source'] == source_id]), + details=lambda s, i: ENTRIES[0], + download=Mock(return_value={'apk': '/fake.apk', 'obb': []})) + + +class SettingsTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json') + p.start() + self.addCleanup(p.stop) + for state in (search._running, search._pending, search._status, search._game_data): + state.clear() + from apk_sources import _web + self.claims = [] + for name in ('claim', 'release'): # fake downloads aren't real files + p = patch.object(_web, name, side_effect=lambda path, name=name: self.claims.append((name, path))) + p.start() + self.addCleanup(p.stop) + + +class SearchTests(SettingsTest): + def test_group_does_not_merge_distinct_or_unknown_packages(self): + result = search.group(ENTRIES) + self.assertEqual(len(result), 3) + self.assertEqual(sorted(len(a['offers']) for a in result), [1, 2, 2]) + same_name = dict(ENTRIES[0], package=None) + self.assertEqual(len(search.group(ENTRIES[:1] + [same_name])), 2) + + def test_rank_exact_and_installable_and_verified(self): + result = search.group(ENTRIES, 'Open Brush') + self.assertEqual(result[0]['package'], 'org.brush') + self.assertEqual(result[0]['offers'][0]['source'], 'one') + self.assertEqual(result[1]['package'], 'org.other') + self.assertEqual(len(search.group(ENTRIES, vr=False)), 1) + self.assertEqual(len(search.group(ENTRIES, installable=True)), 1) + + def test_unknown_vr_counts_as_flat(self): + entries = [dict(ENTRIES[3], id='a', name='Flat', vr=False), dict(ENTRIES[3], id='b', name='Unknown', vr=None), + dict(ENTRIES[3], id='c', name='Headset', vr=True)] + self.assertEqual(sorted(a['name'] for a in search.group(entries, vr=False)), ['Flat', 'Unknown']) + self.assertEqual([a['name'] for a in search.group(entries, vr=True)], ['Headset']) + + def test_browse_puts_unknown_fit_vr_first_and_blocked_last(self): + entries = [dict(source='s', id='flat', name='Flat', package='a.flat', vr=False, min_sdk=21, abis=[]), + dict(source='s', id='vr', name='Headset', package='a.vr', vr=True), + dict(source='s', id='bad', name='Blocked', package='a.bad', vr=True, min_sdk=34, abis=[])] + self.assertEqual([a['name'] for a in search.group(entries)], ['Headset', 'Flat', 'Blocked']) + + def test_fit_unknown_and_native_free_and_vr_hints(self): + self.assertIsNone(search.fit({})['installable']) + self.assertTrue(search.fit({'min_sdk': 23, 'abis': []})['installable']) + self.assertFalse(search.fit({'min_sdk': 23, 'abis': ['x86']})['installable']) + self.assertIn('Legacy VrApi', search.fit({'engine': 'VrApi'})['reasons'][0]) + + def test_timeout_and_failure_leave_other_results(self): + release = threading.Event() + calls = [] + def slow(s, q, limit=50): + calls.append(q) + release.wait(2) + return [] + mods = [fake(), fake('slow', slow), fake('broken', Mock(side_effect=SourceError('offline')))] + try: + with patch.object(search, 'modules', return_value=(mods, [])): + started = time.monotonic() + result = search.search(timeout=.03) + self.assertLess(time.monotonic() - started, .3) + self.assertTrue(result['apps']) + self.assertEqual([s['status'] for s in result['sources']], ['ok', 'loading', 'error']) + self.assertEqual(search.search('other', timeout=.03)['sources'][1]['status'], 'loading') + search.search('newest', timeout=.03) + self.assertEqual(calls, ['']) + queued = search._pending['slow'] + release.set() + self.assertTrue(queued['event'].wait(2)) + self.assertEqual(queued['entries'], []) + self.assertEqual(calls, ['', 'newest']) # 'other' was superseded, never run + finally: + release.set() + + def test_query_arriving_as_a_search_finishes_is_not_stranded(self): + mod = fake() + source = mod.sources()[0] + arrived = [] + + class Event(threading.Event): + def set(self): + if not arrived: # a request lands just as the first search completes + arrived.append(None) + t = threading.Thread(target=lambda: arrived.append(search._launch(mod, source, 'second', 50))) + t.start() + t.join(.3) # blocks on search._lock if completion is published atomically + super().set() + with patch.object(search, 'threading', types.SimpleNamespace(Event=Event, Thread=threading.Thread)): + search._launch(mod, source, 'first', 50) + for _ in range(200): + if len(arrived) == 2: + break + time.sleep(.01) + self.assertTrue(arrived[1]['event'].wait(2)) + self.assertEqual(arrived[1]['query'], ('second', 50)) + + def test_set_enabled_does_not_hold_search_lock_in_source(self): + free = [] + def set_enabled(source_id, enabled): + t = threading.Thread(target=lambda: free.append(search._lock.acquire(timeout=1) and not search._lock.release())) + t.start() + t.join() + mod = fake() + mod.set_enabled = set_enabled + with patch.object(search, 'modules', return_value=([mod], [])): + search.set_enabled('one', False) + self.assertEqual(free, [True]) + + def test_stale_source_status(self): + mod = fake() + mod.stale = lambda source: True + with patch.object(search, 'modules', return_value=([mod], [])): + status = search.search(timeout=1)['sources'][0] + self.assertEqual((status['status'], status['stale']), ('ok', True)) + + def test_limited_source_status(self): + from apk_sources import SourceLimited + mods = [fake('busy', Mock(side_effect=SourceLimited('busy is limiting requests', 60)))] + with patch.object(search, 'modules', return_value=(mods, [])): + status = search.search(timeout=1)['sources'][0] + self.assertEqual((status['status'], status['error']), ('limited', 'busy is limiting requests')) + + def test_disable_persists_and_prevents_queries_and_installs(self): + mod = fake() + with patch.object(search, 'modules', return_value=([mod], [])): + search.set_enabled('one', False) + self.assertFalse(search.sources()[0]['enabled']) + self.assertEqual(search.search()['apps'], []) + with self.assertRaisesRegex(SourceError, 'disabled'): + search.install('one', 'brush') + + def test_install_passes_metadata_artwork_and_obb(self): + mod = fake() + mod.download.return_value.update(obb=['main.obb'], artwork={'hero': '/hero.png'}, icon_png=b'png') + def install(apk, name=None, icon_png=None, source=None, artwork=None): + self.assertEqual((apk, name, icon_png, source, artwork), + ('/fake.apk', 'Open Brush', b'png', 'one', {'hero': '/hero.png'})) + return {'package': 'org.brush'} + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(server.frame_android, 'install_obb', create=True) as obb: + # An actual function exposes the future signature for inspection. + with patch.object(server.frame_android, 'install', install): + result = search.install('one', 'brush', 1) + # The app's instance isn't running right after install, so game data is a follow-up step. + obb.assert_not_called() + self.assertTrue(result['game_data']) + self.assertIn('Add game data', result['message']) + obb.return_value = {'package': 'org.brush', 'obb': []} + self.assertEqual(search.add_game_data('org.brush')['message'], 'Game data added') + obb.assert_called_once_with('org.brush', ['main.obb']) + with self.assertRaisesRegex(SourceError, 'install it again'): + search.add_game_data('org.brush') + mod.download.assert_called_once_with(mod.sources()[0] | {'status': 'not searched'}, 'brush', version_code=1) + + def test_install_uses_source_image_urls_as_steam_artwork(self): + mod = fake() + plain = mod.details + mod.details = lambda source, entry_id: dict(plain(source, entry_id), images={ + 'icon': 'https://img.example/icon.png', 'banner': 'https://img.example/banner.png', + 'screenshots': ['https://img.example/1.png', None]}) + seen = {} + def install(apk, name=None, icon_png=None, source=None, artwork=None): + seen['artwork'] = artwork + return {'package': 'org.brush'} + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(server.frame_android, 'install', install): + search.install('one', 'brush') + self.assertEqual(seen['artwork'], {'icon': 'https://img.example/icon.png', + 'banner': 'https://img.example/banner.png', + 'screenshots': ['https://img.example/1.png']}) + + def test_discovery_and_demo_are_opt_in(self): + module = fake() + with patch.object(search.pkgutil, 'iter_modules', return_value=[types.SimpleNamespace(name='example')]), \ + patch.object(search.importlib, 'import_module', return_value=module), \ + patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}): + self.assertEqual(search.modules(), ([module], [])) + with patch.object(search.pkgutil, 'iter_modules', return_value=[]), \ + patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}): + self.assertEqual(search.modules(), ([], [])) + + def test_newest_compatible_then_official_offer(self): + first = dict(ENTRIES[0], verified=False, trust='community') + newer = dict(first, source='new', version_code=2, updated='2026-01-01') + official = dict(newer, source='official', trust='official') + incompatible = dict(newer, source='blocked', verified=True, min_sdk=40) + offers = search.group([first, incompatible, newer, official])[0]['offers'] + self.assertEqual([e['source'] for e in offers], ['official', 'new', 'one', 'blocked']) + + def test_missing_obb_support_stops_before_install(self): + mod = fake() + mod.download.return_value['obb'] = ['main.obb'] + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(server.frame_android, 'install') as install, \ + patch.dict(server.frame_android.__dict__): + server.frame_android.__dict__.pop('install_obb', None) + with self.assertRaisesRegex(SourceError, 'OBB'): + search.install('one', 'brush') + install.assert_not_called() + + def test_downloaded_apk_is_protected_from_pruning_while_installing(self): + mod = fake() + def install(apk, **kwargs): + self.assertEqual(self.claims, [('claim', '/fake.apk')]) + raise server.frame_android.FrameError('adb failed') + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(server.frame_android, 'install', install): + with self.assertRaises(server.frame_android.FrameError): + search.install('one', 'brush') + self.assertEqual(self.claims, [('claim', '/fake.apk'), ('release', '/fake.apk')]) + + def test_listing_cannot_download(self): + mod = fake() + mod.details = lambda s, i: dict(ENTRIES[0], downloadable=False) + with patch.object(search, 'modules', return_value=([mod], [])): + with self.assertRaisesRegex(SourceError, 'developer page'): + search.install('one', 'brush') + mod.download.assert_not_called() + + +class EndpointTests(SettingsTest): + def setUp(self): + super().setUp() + self.mod = fake() + p = patch.object(search, 'modules', return_value=([self.mod], [])) + p.start() + self.addCleanup(p.stop) + self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler) + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + self.addCleanup(self.httpd.server_close) + self.addCleanup(self.httpd.shutdown) + + def request(self, method, path, body=None): + c = http.client.HTTPConnection('127.0.0.1', self.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() + result = r.status, json.loads(r.read()) + c.close() + return result + + def test_http_search_and_validation(self): + self.assertEqual(self.request('GET', '/api/sources')[1]['sources'][0]['id'], 'one') + self.assertTrue(self.request('GET', '/api/search?q=Brush&vr=true')[1]['apps']) + self.assertEqual(self.request('GET', '/api/search?vr=invalid')[0], 400) + self.assertEqual(self.request('GET', '/api/search?source=missing')[0], 400) + for body in ({'source': 'one'}, {'source': 'one', 'id': 'brush', 'version_code': True}): + self.assertEqual(self.request('POST', '/api/sources/install', body)[0], 400) + + def test_http_install_background_job(self): + with patch.object(server.frame_android, 'install', return_value={'package': 'org.brush'}) as install: + status, reply = self.request('POST', '/api/sources/install', {'source': 'one', 'id': 'brush'}) + self.assertEqual(status, 200) + for _ in range(100): + job = self.request('GET', '/api/job?id=' + reply['job'])[1] + if job['done']: + break + time.sleep(.01) + self.assertTrue(job['done']) + self.assertIsNone(job['error']) + install.assert_called_once_with('/fake.apk', name='Open Brush', icon_png=None, source='one') + + def test_details_endpoint_and_real_install_stages(self): + code, entry = self.request('GET', '/api/sources/details?source=one&id=brush') + self.assertEqual(code, 200) + self.assertEqual(entry['name'], 'Open Brush') + self.assertEqual(entry['verdict']['label'], 'Ready to try on the Frame') + self.assertIn('artwork', entry) + self.assertEqual(self.request('GET', '/api/sources/details?source=one')[0], 400) + stages = [] + with patch.object(server.frame_android, 'install', return_value={'package':'org.brush'}): + search.install('one', 'brush', progress=lambda stage, percent: stages.append((stage,percent))) + self.assertEqual(stages, [('Downloading',None),('Installing',None)]) + + def test_http_repository_management(self): + self.assertEqual(self.request('POST', '/api/sources', {'action': 'enable', 'source': 'one', 'enabled': False})[0], 200) + code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'}) + self.assertEqual(code, 400) + self.assertIn('not available', reply['error']) + mod = fake('fdroid') + added = {'id': 'fdroid-user-1', 'name': 'repo.example', 'fingerprint': 'ab' * 32, 'trust_on_first_use': True} + mod.add_repo, mod.remove_repo, mod.set_enabled = Mock(return_value=added), Mock(), Mock() + with patch.object(search, 'modules', return_value=([mod], [])): + code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'}) + self.assertEqual(code, 200) + job = self.wait(reply['job']) + self.assertEqual(job['message'], 'Added repo.example. Trusted on first use: ' + 'AB' * 32) + self.assertEqual(job['result']['source']['fingerprint'], 'ab' * 32) + mod.add_repo.assert_called_once_with(url='https://repo.example/repo', fingerprint=None, name=None) + link = 'fdroidrepos://repo.example/repo?fingerprint=' + 'ab' * 32 + mod.add_repo.return_value = dict(added, trust_on_first_use=False) + job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job']) + self.assertEqual(job['message'], 'Added repo.example') + mod.add_repo.assert_called_with(url=link, fingerprint=None, name=None) + mod.add_repo.side_effect = SourceError('repository fingerprint mismatch') + job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job']) + self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix + for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'): + self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400) + self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200) + mod.remove_repo.assert_called_once_with(source_id='fdroid') + + def test_http_add_game_data_job(self): + search._game_data['org.brush'] = ['/cache/main.1.org.brush.obb'] + self.addCleanup(search._game_data.clear) + with patch.object(server.frame_android, 'install_obb', create=True, + side_effect=server.frame_android.FrameError('start this app instance before installing OBB data')): + job = self.wait(self.request('POST', '/api/sources', {'action': 'game-data', 'package': 'org.brush'})[1]['job']) + self.assertEqual(job['error'], 'start this app instance before installing OBB data') + with patch.object(server.frame_android, 'install_obb', create=True, return_value={'package': 'org.brush'}) as obb: + job = self.wait(self.request('POST', '/api/sources', {'action': 'game-data', 'package': 'org.brush'})[1]['job']) + self.assertEqual(job['message'], 'Game data added') + obb.assert_called_once_with('org.brush', ['/cache/main.1.org.brush.obb']) + + def wait(self, job_id): + for _ in range(200): + job = self.request('GET', '/api/job?id=' + job_id)[1] + if job['done']: + return job + time.sleep(.01) + self.fail('job did not finish') diff --git a/tests/test_fdroid_sources.py b/tests/test_fdroid_sources.py new file mode 100644 index 0000000..4285721 --- /dev/null +++ b/tests/test_fdroid_sources.py @@ -0,0 +1,537 @@ +"""Offline authenticated repository fixtures; no tests contact a server.""" +import io +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +import urllib.error +import zipfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +from apk_sources import SourceError, SourceLimited, _web, fdroid + +FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid' +PIN = (FIXTURES / 'fingerprint.txt').read_text().strip() +URL = 'https://example.org/repo/' +_KEY = [] + + +def signed_jar(member, content, digest='sha256'): + """A JAR signed like fdroidserver's (no CMS signed attributes) with a throwaway test key.""" + import base64, hashlib + from frame_apk_sign import certificate, der, integer, sequence, signing_key + if not _KEY: + with tempfile.TemporaryDirectory() as tmp: + _KEY.append(signing_key(Path(tmp) / 'key.json')) + key = _KEY[0] + label = 'SHA1' if digest == 'sha1' else 'SHA-256' + b64 = lambda data: base64.b64encode(hashlib.new(digest, data).digest()).decode() + manifest = ('Manifest-Version: 1.0\r\n\r\nName: %s\r\n%s-Digest: %s\r\n\r\n' % (member, label, b64(content))).encode() + sf = ('Signature-Version: 1.0\r\n%s-Digest-Manifest: %s\r\n\r\n' % (label, b64(manifest))).encode() + oid, prefix = next((bytes.fromhex(o), bytes.fromhex(p)) for o, (d, p) in fdroid._DIGESTS.items() if d == digest) + alg = sequence(der(6, oid), der(5, b'')) + size = (key['n'].bit_length() + 7) // 8 + value = prefix + hashlib.new(digest, sf).digest() + padded = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value + signature = pow(int.from_bytes(padded, 'big'), key['d'], key['n']).to_bytes(size, 'big') + cert = certificate(key) + issuer = fdroid._der_parts(fdroid._der_parts(fdroid._der_parts(cert)[0][1])[0][1])[3][2] + signer = sequence(integer(1), sequence(issuer, integer(1)), alg, + sequence(der(6, bytes.fromhex('2a864886f70d010101')), der(5, b'')), der(4, signature)) + signed = sequence(integer(1), der(0x31, alg), sequence(der(6, bytes.fromhex('2a864886f70d010701'))), + der(0xa0, cert), der(0x31, signer)) + block = sequence(der(6, bytes.fromhex('2a864886f70d010702')), der(0xa0, signed)) + stream = io.BytesIO() + with zipfile.ZipFile(stream, 'w') as z: + for name, data in (('META-INF/MANIFEST.MF', manifest), ('META-INF/TEST.SF', sf), + ('META-INF/TEST.RSA', block), (member, content)): + z.writestr(name, data) + return stream.getvalue(), fdroid.hashlib.sha256(cert).hexdigest() + + +def entry_jar(timestamp, digest='sha256'): + entry = json.loads(zipfile.ZipFile(FIXTURES / 'entry.jar').read('entry.json')) + return signed_jar('entry.json', json.dumps(dict(entry, timestamp=timestamp)).encode(), digest) + + +class Repositories(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)), + ('cache_dir', lambda *p: self.root.joinpath('cache', *p))]: + mock = patch.object(fdroid.frame_host, name, value) + mock.start() + self.addCleanup(mock.stop) + net = patch.object(fdroid.urllib.request, 'build_opener', side_effect=AssertionError('network forbidden')) + net.start() + self.addCleanup(net.stop) + mock = self.fetch_patch = patch.object(fdroid, '_fetch', side_effect=self.fetch) + self.fetch_mock = mock.start() + self.addCleanup(mock.stop) + self.v1 = False + self.corrupt = None + self.files = {} + for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing): + state.clear() + self.addCleanup(state.clear) + + def fetch(self, url, path, maximum): + name = url.rsplit('/', 1)[-1] + if self.v1 and name == 'entry.jar': + raise urllib.error.HTTPError(url, 404, 'missing', None, None) + payload = self.files.get(name) or (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes() + if name == self.corrupt: + payload += b'tampered' + Path(path).write_bytes(payload) + + def add(self): + return fdroid.add_repo(URL, PIN) + + def test_add_search_details_download_cache(self): + source = self.add() + self.assertEqual(source['fingerprint'], PIN) + self.assertFalse(source['trust_on_first_use']) + result = fdroid.search(source, 'example offline') + self.assertEqual(len(result), 1) + self.assertNotIn('versions', result[0]) + self.assertEqual(result[0]['version_code'], 2) + self.assertEqual([v['version_code'] for v in fdroid.details(source, 'org.example.app')['versions']], [2, 1]) + downloaded = fdroid.download(source, 'org.example.app', 1) + self.assertTrue(downloaded['verified']) + self.assertEqual(Path(downloaded['apk']).read_bytes(), (FIXTURES / 'example.apk').read_bytes()) + count = self.fetch_mock.call_count + os.utime(downloaded['apk'], (1, 1)) + fdroid.download(source, 'org.example.app', 1) + self.assertEqual(self.fetch_mock.call_count, count) + self.assertGreater(Path(downloaded['apk']).stat().st_mtime, 1) # reuse counts as recent use + + def test_wrong_pin_is_not_saved(self): + with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'): + fdroid.add_repo(URL, '0' * 64) + self.assertEqual(fdroid.user_repos(), []) + + def test_tampered_index_is_not_saved(self): + self.corrupt = 'index-v2.json' + with self.assertRaisesRegex(SourceError, 'SHA-256'): + self.add() + self.assertEqual(fdroid.user_repos(), []) + + def test_tampered_apk_is_not_cached(self): + source = self.add() + self.corrupt = 'example2.apk' + with self.assertRaisesRegex(SourceError, 'APK SHA-256'): + fdroid.download(source, 'org.example.app') + self.assertEqual(list(self.root.rglob('*.apk')), []) + self.assertEqual(list(self.root.rglob('*.part')), []) + + def test_v1_fallback(self): + self.v1 = True + source = self.add() + self.assertEqual(fdroid.search(source, 'Example')[0]['version_code'], 1) + self.assertTrue(fdroid.download(source, 'org.example.app')['verified']) + + def test_bad_v2_never_downgrades(self): + self.corrupt = 'index-v2.json' + with self.assertRaises(SourceError): + self.add() + self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list)) + + def test_transient_error_never_downgrades(self): + self.fetch_mock.side_effect = urllib.error.HTTPError(URL, 503, 'unavailable', None, None) + with self.assertRaises(SourceError): + self.add() + self.assertEqual(self.fetch_mock.call_count, 1) + + def test_tofu_preserves_pin_and_settings(self): + source = fdroid.add_repo('fdroidrepos://example.org/repo') + self.assertTrue(source['trust_on_first_use']) + self.assertEqual(source['fingerprint'], PIN) + fdroid.add_repo(URL) + self.assertEqual(len(fdroid.user_repos()), 1) + with self.assertRaisesRegex(SourceError, 'different pinned'): + fdroid.add_repo(URL, '0' * 64) + fdroid.set_enabled(source['id'], False) + self.assertEqual(fdroid.search(fdroid.user_repos()[0], ''), []) + with self.assertRaisesRegex(SourceError, 'disabled'): + fdroid.download(fdroid.user_repos()[0], 'org.example.app') + fdroid.set_enabled('fdroid', False) + self.assertFalse(fdroid.sources()[0]['enabled']) + fdroid.remove_repo(source['id']) + self.assertEqual(fdroid.user_repos(), []) + with self.assertRaises(SourceError): + fdroid.remove_repo('fdroid') + + def test_urls(self): + self.assertEqual(fdroid._url(URL + '?fingerprint=' + PIN.upper()), (URL, PIN)) + for url in ['http://example.org/repo', 'fdroidrepo://example.org', 'https://u:p@example.org', URL+'?other=x']: + with self.subTest(url=url), self.assertRaises(SourceError): + fdroid._url(url) + with self.assertRaisesRegex(SourceError, 'conflicting'): + fdroid._url(URL + '?fingerprint=' + PIN, '0' * 64) + self.assertEqual(fdroid._child(URL, '/app/en-US/phoneScreenshots/#0 a.png'), + URL + 'app/en-US/phoneScreenshots/%230%20a.png') # real F-Droid screenshot name + for name in ['../x.apk', '%2e%2e/x.apk', 'https://evil.org/a.apk', '//evil.org/../x', 'x?token=y', 'x\\y']: + with self.subTest(name=name), self.assertRaises(SourceError): + fdroid._child(URL, name) + + def test_recorded_real_signature(self): + content, fingerprint = fdroid._jar(FIXTURES / 'izzy-entry.jar', 'entry.json', fdroid.IZZY_PIN, strong=True) + self.assertEqual(fingerprint, fdroid.IZZY_PIN) + self.assertIn('index', json.loads(content)) + + def test_tampering_each_signature_layer(self): + for member in ['entry.json', 'META-INF/MANIFEST.MF', 'META-INF/TEST.SF', 'META-INF/TEST.RSA']: + stream = io.BytesIO() + with zipfile.ZipFile(FIXTURES / 'entry.jar') as src, zipfile.ZipFile(stream, 'w') as dst: + for item in src.infolist(): + data = src.read(item.filename) + if item.filename == member: + data = data[:-1] + bytes([data[-1] ^ 1]) + dst.writestr(item.filename, data) + with self.subTest(member=member), self.assertRaises(SourceError): + fdroid._jar(io.BytesIO(stream.getvalue()), 'entry.json', PIN) + + def test_duplicate_jar_member_rejected(self): + stream = io.BytesIO((FIXTURES / 'entry.jar').read_bytes()) + import warnings + with warnings.catch_warnings(): + warnings.simplefilter('ignore', UserWarning) + with zipfile.ZipFile(stream, 'a') as z: + z.writestr('entry.json', '{}') + stream.seek(0) + with self.assertRaisesRegex(SourceError, 'duplicate'): + fdroid._jar(stream, 'entry.json', PIN) + + def test_corrupt_cache_refetches_verified_index(self): + source = self.add() + fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json').write_text('{') + self.assertEqual(len(fdroid.search(source, 'example')), 1) + self.assertEqual(self.fetch_mock.call_count, 4) + + def test_artwork_v1_and_v2_survives_source_cache(self): + source = self.add() + for version in ('v1', 'v2'): + with self.subTest(version=version): + raw = FIXTURES / ('artwork-' + version + '.json') + if version == 'v1': + normalized = self.root / 'normalized.json' + fdroid._v1(raw.read_bytes(), normalized) + raw = normalized + apps = fdroid._reduce(raw, source) + cache = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json') + fdroid._write(cache, {'version': fdroid.CACHE_VERSION, 'url': URL, + 'fingerprint': PIN, 'apps': apps}) + before = self.fetch_mock.call_count + result = fdroid.search(source, 'example offline')[0] + self.assertEqual(result['developer'], 'Example Developer') + self.assertEqual(result['summary'], 'Offline fixture & music. One line.') + self.assertEqual(result['icon'], URL + 'org.example.app/en-US/icon.png') + self.assertEqual(result['images'], { + 'icon': result['icon'], + 'banner': URL + 'org.example.app/fr/featureGraphic.png', + 'screenshots': [URL + 'org.example.app/en-US/phoneScreenshots/' + str(i) + '.png' for i in range(1, 5)] + + [URL + 'org.example.app/fr/sevenInchScreenshots/' + str(i) + '.png' for i in range(1, 3)]}) + self.assertEqual(fdroid.details(source, result['id'])['images'], result['images']) + self.assertEqual(self.fetch_mock.call_count, before) + + def test_missing_artwork_is_not_invented(self): + result = fdroid.search(self.add(), 'example')[0] + self.assertEqual(result['images'], {'icon': None, 'banner': None, 'screenshots': []}) + self.assertIsNone(result['icon']) + self.assertIsNone(result['developer']) + + def test_v1_legacy_icon_and_tablet_fallback(self): + index = json.loads((FIXTURES / 'artwork-v1.json').read_text()) + app = index['apps'][0] + app['localized'] = {'fr': {'sevenInchScreenshots': ['tablet.png']}} + app['icon'] = 'legacy.1.png' + raw = self.root / 'legacy.json' + fdroid._v1(json.dumps(index).encode(), raw) + result = fdroid._reduce(raw, {'id': 'test', 'url': URL})['org.example.app'] + self.assertEqual(result['icon'], URL + 'icons/legacy.1.png') + self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png']) + + def test_v2_legacy_screenshot_keys_and_limit(self): + meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]}, + 'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}} + images = fdroid._images(meta, URL) + self.assertEqual(images['screenshots'], [URL + 'phone/' + str(i) + '.png' for i in range(6)]) + meta.pop('phoneScreenshots') + self.assertEqual(fdroid._images(meta, URL)['screenshots'], [URL + 'tablet.png']) + + def test_old_cache_refreshes_for_artwork(self): + source = self.add() + path = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json') + saved = json.loads(path.read_text()) + saved.pop('version') + for app in saved['apps'].values(): + app.pop('images') + fdroid._write(path, saved) + self.assertIn('images', fdroid.search(source, 'example')[0]) + self.assertEqual(self.fetch_mock.call_count, 4) + + def test_slow_download_blocks_neither_settings_nor_other_repos(self): + import threading + source = self.add() + other = dict(source, id='other-repo') + started, release = threading.Event(), threading.Event() + def fetch(url, path, maximum): + if threading.current_thread().name == 'slow': + started.set() + release.wait(5) + self.fetch(url, path, maximum) + self.fetch_mock.side_effect = fetch + slow = threading.Thread(target=fdroid._load, args=(other, True), name='slow') + slow.start() + try: + self.assertTrue(started.wait(2)) + results = [] + # The settings lock is free and another repo still loads while this one downloads. + check = threading.Thread(target=lambda: results.append( + (fdroid.set_enabled('fdroid', False), len(fdroid._load(source, force=True)[0])))) + check.start() + check.join(2) + self.assertEqual(results, [(None, 1)]) + finally: + release.set() + slow.join() + + def test_rollback_to_older_index_is_refused(self): + self.files['entry.jar'], pin = entry_jar(2000) + source = fdroid.add_repo(URL) + self.assertEqual(source['fingerprint'], pin) + self.files['entry.jar'], _ = entry_jar(1000) + with self.assertRaisesRegex(SourceError, 'older'): + fdroid._load(source, force=True) + for timestamp in (2000, 3000): # unchanged and newer indexes are fine + self.files['entry.jar'], _ = entry_jar(timestamp) + self.assertEqual(len(fdroid._load(source, force=True)[0]), 1) + self.files['entry.jar'], _ = entry_jar(2000) + with self.assertRaisesRegex(SourceError, 'older'): + fdroid._load(source, force=True) + fdroid.remove_repo(source['id']) # a deliberate re-add starts over + self.assertEqual(fdroid.add_repo(URL)['fingerprint'], pin) + + def test_concurrent_processes_cannot_publish_an_older_index_last(self): + # Threads with their own in-memory locks, as separate processes would have; each + # _state_file_lock() opens its own file description, so flock contends for real. + import threading + self.files['entry.jar'], _ = entry_jar(50) + source = fdroid.add_repo(URL) + jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]} + def fetch(url, path, maximum): + name = threading.current_thread().name + if name in jars and url.endswith('entry.jar'): + Path(path).write_bytes(jars[name]) + else: + self.fetch(url, path, maximum) + self.fetch_mock.side_effect = fetch + inside, go, order = threading.Event(), threading.Event(), [] + real_write = fdroid._write + def write(path, value): + if path.name.endswith('.json') and 'apps' in value and threading.current_thread().name == 'older': + inside.set() # the older load has passed its locked recheck; hold it there + go.wait(5) + order.append(threading.current_thread().name) + real_write(path, value) + errors = {} + def load(): + try: + fdroid._load(source, force=True) + except SourceError as e: + errors[threading.current_thread().name] = str(e) + with patch.object(fdroid, '_source_lock', lambda source_id: threading.Lock()), \ + patch.object(fdroid, '_write', write): + older = threading.Thread(target=load, name='older') + older.start() + self.assertTrue(inside.wait(5)) + newer = threading.Thread(target=load, name='newer') + newer.start() + newer.join(.5) + self.assertTrue(newer.is_alive()) # blocked on the file lock, not publishing + go.set() + older.join(5) + newer.join(5) + self.assertEqual(errors, {}) + self.assertEqual(order, ['older', 'older', 'newer', 'newer']) # cache+state, one load at a time + self.assertEqual(fdroid._state(source)['timestamp'], 200) + + def test_overlapping_v1_load_cannot_replace_accepted_v2(self): + import threading + v1 = json.loads(zipfile.ZipFile(FIXTURES / 'index-v1.jar').read('index-v1.json')) + v1['repo'] = {'timestamp': 100} + self.files['index-v1.jar'], pin = signed_jar('index-v1.json', json.dumps(v1).encode()) + self.files['entry.jar'], _ = entry_jar(100) # the same timestamp as the v1 index + source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None) + self.v1 = True + inner = [] + def fetch(url, path, maximum): + if url.endswith('index-v1.jar') and not inner: + inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now + self.v1 = False + t = threading.Thread(target=lambda: inner.append(fdroid._load(source, force=True))) + with patch.object(fdroid, '_source_lock', lambda source_id: threading.Lock()): + t.start() + t.join(5) + self.v1 = True + self.fetch(url, path, maximum) + self.fetch_mock.side_effect = fetch + with self.assertRaisesRegex(SourceError, 'v2'): + fdroid._load(source, force=True) + self.assertEqual(inner[1][1], pin) + self.assertTrue(fdroid._state(source)['v2']) + self.v1 = False + count = self.fetch_mock.call_count + apps, _ = fdroid._load(dict(source, fingerprint=pin)) + self.assertEqual((apps['org.example.app']['version_code'], self.fetch_mock.call_count), (2, count)) # v2 cache stayed + + def test_apk_removed_during_cache_check_is_downloaded_again(self): + source = self.add() + first = fdroid.download(source, 'org.example.app', 1) + count = self.fetch_mock.call_count + real = fdroid._sha256 + def removed_first(path): + if str(path) == first['apk'] and not hashed: + hashed.append(1) + os.remove(first['apk']) # deleted between the existence check and the open + return real(path) + hashed = [] + with patch.object(fdroid, '_sha256', removed_first): + again = fdroid.download(source, 'org.example.app', 1) + self.assertEqual(self.fetch_mock.call_count, count + 1) + self.assertEqual(Path(again['apk']).read_bytes(), (FIXTURES / 'example.apk').read_bytes()) + + def test_cached_apk_is_touched_before_hashing(self): + source = self.add() + first = fdroid.download(source, 'org.example.app', 1) + os.utime(first['apk'], (1, 1)) + count = self.fetch_mock.call_count + real = fdroid._sha256 + def prune_first(path): + if str(path) == first['apk']: + with patch.object(_web, 'APK_CAP', 0): + _web.prune() # a pruner running now sees a just-used APK + return real(path) + with patch.object(fdroid, '_sha256', prune_first): + fdroid.download(source, 'org.example.app', 1) + self.assertEqual(self.fetch_mock.call_count, count) + self.assertTrue(Path(first['apk']).exists()) + + def test_cli_search_waits_for_background_refresh(self): + source = self.add() + self.expire(source) + before = self.fetch_mock.call_count + out = io.StringIO() + with patch.object(sys, 'argv', ['fdroid.py', 'search', source['id'], 'example']), \ + patch('sys.stdout', out): + fdroid.main() + self.assertEqual(json.loads(out.getvalue())[0]['id'], 'org.example.app') + self.assertEqual(self.fetch_mock.call_count, before + 2) # the refresh finished before exit + self.assertFalse(fdroid.stale(source)) + self.assertNotIn(source['id'], fdroid._refreshing) + + def test_cli_error_still_waits_for_background_refresh(self): + import threading + source = self.add() + self.expire(source) + release = threading.Event() + def slow(url, path, maximum): + release.wait(5) + self.fetch(url, path, maximum) + self.fetch_mock.side_effect = slow + threading.Timer(.3, release.set).start() + with patch.object(sys, 'argv', ['fdroid.py', 'download', source['id'], 'org.missing']), \ + patch('sys.stderr', io.StringIO()) as err, self.assertRaises(SystemExit): + fdroid.main() + self.assertIn('no Lepton-compatible version', err.getvalue()) + self.assertTrue(release.is_set()) + self.assertEqual(fdroid._refreshing, {}) # joined before exiting + self.assertFalse(fdroid.stale(source)) + + def test_no_v1_fallback_once_v2_accepted(self): + source = self.add() + self.v1 = True + with self.assertRaisesRegex(SourceError, 'v2'): + fdroid._load(source, force=True) + self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list)) + + def test_v1_then_v2_upgrade_is_allowed(self): + self.v1 = True + source = self.add() + self.v1 = False + self.assertEqual(fdroid._load(source, force=True)[0]['org.example.app']['version_code'], 2) + + def test_sha1_only_entry_jar_rejected(self): + self.files['entry.jar'], _ = entry_jar(1, 'sha1') + with self.assertRaisesRegex(SourceError, 'SHA-1'): + fdroid.add_repo(URL) + self.assertEqual(fdroid.user_repos(), []) + stream = io.BytesIO(self.files['entry.jar']) + self.assertIn(b'index', fdroid._jar(stream, 'entry.json', None)[0]) # index-v1.jar may still use SHA-1 + + def test_rate_limited_host_backs_off(self): + source = dict(self.add(), name='My repo') + self.fetch_patch.stop() + error = urllib.error.HTTPError(URL, 429, 'slow down', {'Retry-After': '300'}, None) + with patch.object(fdroid.urllib.request, 'build_opener') as opener: + opener.return_value.open.side_effect = error + with self.assertRaisesRegex(SourceLimited, '^My repo is limiting requests; try again in 5 minutes$'): + fdroid._load(source, force=True) + with self.assertRaisesRegex(SourceLimited, 'My repo'): + fdroid.download(source, 'org.example.app', 1) + self.assertEqual(opener.return_value.open.call_count, 1) + self.fetch_mock = self.fetch_patch.start() + + def expire(self, source): + cache = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json') + old = cache.stat().st_mtime - fdroid.MAX_AGE - 1 + fdroid.os.utime(str(cache), (old, old)) + + def test_expired_index_served_stale_while_refreshing(self): + source = self.add() + self.expire(source) + before = self.fetch_mock.call_count + release = __import__('threading').Event() + def slow(url, path, maximum): + release.wait(5) + self.fetch(url, path, maximum) + self.fetch_mock.side_effect = slow + self.assertEqual(len(fdroid.search(source, 'example')), 1) # immediately, from the old index + self.assertTrue(fdroid.stale(source)) + refresh = fdroid._refreshing[source['id']] + fdroid.search(source, 'example') + self.assertIs(fdroid._refreshing.get(source['id']), refresh) # one refresh at a time + release.set() + refresh.join(5) + self.assertEqual(self.fetch_mock.call_count, before + 2) + self.assertFalse(fdroid.stale(source)) + + def test_failed_refresh_keeps_serving_stale_index(self): + source = self.add() + self.expire(source) + self.fetch_mock.side_effect = urllib.error.HTTPError(URL, 503, 'unavailable', None, None) + self.assertEqual(len(fdroid.search(source, 'example')), 1) + # A fast failed refresh may already have removed itself from the registry. + refresh = fdroid._refreshing.get(source['id']) + if refresh is not None: + refresh.join(5) + calls = self.fetch_mock.call_count + self.assertEqual(fdroid.details(source, 'org.example.app')['version_code'], 2) + self.assertTrue(fdroid.stale(source)) + self.assertNotIn(source['id'], fdroid._refreshing) # failed refresh waits before retrying + self.assertEqual(self.fetch_mock.call_count, calls) + + def test_cached_index_does_not_cross_pins(self): + source = self.add() + source['fingerprint'] = '0' * 64 + with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'): + fdroid.search(source, '') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_frame_android_data.py b/tests/test_frame_android_data.py new file mode 100644 index 0000000..175762c --- /dev/null +++ b/tests/test_frame_android_data.py @@ -0,0 +1,293 @@ +import io +import json +import os +from pathlib import Path +import runpy +import shlex +import shutil +import subprocess +import sys +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'ui')) +import frame_android as android +import frame_android_data as data + +REMOTE = runpy.run_path(str(data.REMOTE)) +PKG = 'org.example.game' +META = {'package': PKG, 'instance': 2800000001} + + +class ObbTests(unittest.TestCase): + def test_invalid_files_never_contact_frame(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(android, 'ssh') as ssh: + for name in ('game.obb', 'main.1.org.other.game.obb', 'main.x.' + PKG + '.obb'): + path = Path(tmp) / name + path.write_bytes(b'content') + with self.assertRaises(android.FrameError): + data.install_obb(PKG, [path]) + with self.assertRaises(android.FrameError): + data.install_obb('../game', []) + with self.assertRaises(android.FrameError): + data.install_obb(PKG, []) + ssh.assert_not_called() + + def test_streams_to_correct_instance_and_checks_hash_before_rename(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ('main.7.' + PKG + '.obb') + path.write_bytes(b'expansion payload') + calls = [] + def stream(command, src=None, dst=None): + calls.append(command) + self.assertEqual(src.read(), b'expansion payload') + with patch.object(android, '_meta_or_fail', return_value=META), \ + patch.object(android, 'ssh', return_value='lepton-steamlaunch-2800000001\n'), \ + patch.object(data, '_stream', side_effect=stream): + result = data.install_obb(PKG, [path]) + self.assertTrue(result['verified']) + self.assertIn('podman exec -i lepton-steamlaunch-2800000001', calls[0]) + self.assertIn('/sdcard/Android/obb/' + PKG, calls[0]) + self.assertLess(calls[0].index('sha256sum'), calls[0].index('; mv')) + self.assertIn(result['obb'][0]['sha256'], calls[0]) + + def test_stopped_instance_and_failed_transfer(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ('patch.7.' + PKG + '.obb') + path.write_bytes(b'patch') + with patch.object(android, '_meta_or_fail', return_value=META), \ + patch.object(android, 'ssh', return_value=''), patch.object(data, '_stream') as stream: + with self.assertRaisesRegex(android.FrameError, 'start this app'): + data.install_obb(PKG, [path]) + stream.assert_not_called() + with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')): + with self.assertRaisesRegex(android.FrameError, 'bad hash'): + data._stream('command') + + + @unittest.skipUnless(os.name == 'posix' and shutil.which("sh") and shutil.which("shasum"), + "the OBB script runs on the Frame (Linux shell)") + def test_android_shell_publish_and_hash_failure(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / ('main.7.' + PKG + '.obb') + source.write_bytes(b'good expansion') + output = Path(tmp) / 'sdcard/Android/obb' / PKG / source.name + tools_dir = Path(tmp) / 'bin' + tools_dir.mkdir() + checksum = tools_dir / 'sha256sum' + checksum.write_text('#!/bin/sh\nexec shasum -a 256 "$@"\n') + checksum.chmod(0o700) + corrupt = False + def stream(command, src=None, dst=None): + script = shlex.split(command)[-1].replace('/sdcard/', tmp + '/sdcard/') + if corrupt: + source.write_bytes(b'corrupt expansion') + result = subprocess.run(['sh', '-c', script], stdin=src, capture_output=True, + env=dict(os.environ, PATH=str(tools_dir) + ':' + os.environ['PATH'])) + if result.returncode: + raise android.FrameError('checksum failed') + with patch.object(android, '_meta_or_fail', return_value=META), \ + patch.object(android, 'ssh', return_value='lepton-steamlaunch-2800000001'), \ + patch.object(data, '_stream', side_effect=stream): + data.install_obb(PKG, [source]) + self.assertEqual(output.read_bytes(), b'good expansion') + corrupt = True + with self.assertRaises(android.FrameError): + data.install_obb(PKG, [source]) + self.assertEqual(output.read_bytes(), b'good expansion') + self.assertEqual(list(output.parent.glob('*.part')), []) + + +@unittest.skipUnless(os.name == 'posix', 'app-data backups run on the Frame (Linux ownership and modes)') +class BackupTests(unittest.TestCase): + def test_roundtrip_and_retains_previous_data(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / PKG + (source / 'files').mkdir(parents=True) + (source / 'files/save').write_bytes(b'original save') + archive = io.BytesIO() + REMOTE['backup'](root, PKG, META['instance'], archive) + (source / 'files/save').write_bytes(b'new save') + archive.seek(0) + # Current user's uid/gid in this local test; no elevated execution. + result = REMOTE['restore'](root, PKG, META['instance'], archive) + self.assertEqual((source / 'files/save').read_bytes(), b'original save') + self.assertEqual((Path(result['previous']) / 'files/save').read_bytes(), b'new save') + + def test_symlinks_skipped_and_recorded_hardlinks_copied(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / PKG + (source / 'files').mkdir(parents=True) + (source / 'files/save').write_bytes(b'save') + os.link(str(source / 'files/save'), str(source / 'files/save-link')) + os.symlink('/data/app/lib', str(source / 'lib')) + os.symlink('save', str(source / 'files/alias')) + archive = root / 'backup.tar.gz' + with archive.open('wb') as output: + REMOTE['backup'](root, PKG, META['instance'], output) + result = REMOTE['inspect_archive'](archive, PKG, META['instance']) + self.assertEqual(result['skipped_links'], 2) + with tarfile.open(archive) as tar: + manifest = json.load(tar.extractfile('manifest.json')) + self.assertEqual(tar.extractfile('data/files/save-link').read(), b'save') + self.assertEqual(sorted((l['path'], l['target']) for l in manifest['skipped_links']), + [('data/files/alias', 'save'), ('data/lib', '/data/app/lib')]) + with archive.open('rb') as src: + REMOTE['restore'](root, PKG, META['instance'], src) + self.assertFalse((source / 'lib').exists() or (source / 'lib').is_symlink()) + self.assertEqual((source / 'files/save-link').read_bytes(), b'save') + + @unittest.skipUnless(os.name == 'posix', 'restores run on the Frame (Linux flock)') + def test_overlapping_restores_keep_a_recovery_copy(self): + import threading + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / PKG).mkdir() + (root / PKG / 'save').write_bytes(b'backup') + archive = io.BytesIO() + REMOTE['backup'](root, PKG, META['instance'], archive) + (root / PKG / 'save').write_bytes(b'current') + REMOTE['restore'](root, PKG, META['instance'], io.BytesIO(archive.getvalue())) # an old copy to clean up + restore = REMOTE['restore'] + real_rmtree, inside, go = shutil.rmtree, threading.Event(), threading.Event() + def rmtree(path, **kwargs): + if threading.current_thread().name == 'first': + inside.set() # swapped, now cleaning up; hold it here + go.wait(5) + real_rmtree(path, **kwargs) + results = {} + def run(): + results[threading.current_thread().name] = restore( + root, PKG, META['instance'], io.BytesIO(archive.getvalue()))['previous'] + with patch.dict(restore.__globals__, {'shutil': type('S', (), {'rmtree': staticmethod(rmtree), + 'copyfileobj': shutil.copyfileobj})}): + first = threading.Thread(target=run, name='first') + first.start() + self.assertTrue(inside.wait(5)) + second = threading.Thread(target=run, name='second') + second.start() + second.join(.5) + self.assertTrue(second.is_alive()) # can't swap or clean up during the first's cleanup + go.set() + first.join(5) + second.join(5) + copies = sorted(root.glob('.' + PKG + '.before-restore-*')) + self.assertEqual(copies, [Path(results['second'])]) # the second restore's recovery copy survives + self.assertEqual((copies[0] / 'save').read_bytes(), b'backup') + + def test_restore_keeps_only_latest_previous_copy(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / PKG).mkdir() + (root / PKG / 'save').write_bytes(b'one') + other = root / '.org.example.gameplus.before-restore-1' # another package's copy is left alone + other.mkdir() + archive = io.BytesIO() + REMOTE['backup'](root, PKG, META['instance'], archive) + previous = [] + for _ in range(3): + archive.seek(0) + previous.append(REMOTE['restore'](root, PKG, META['instance'], archive)['previous']) + self.assertEqual(sorted(root.glob('.' + PKG + '.before-restore-*')), [Path(previous[-1])]) + self.assertTrue(other.exists()) + + def make_archive(self, path, members, package=PKG): + with tarfile.open(path, 'w:gz') as archive: + payload = json.dumps({'format': 1, 'package': package, 'instance': META['instance']}).encode() + member = tarfile.TarInfo('manifest.json') + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + root = tarfile.TarInfo('data') + root.type = tarfile.DIRTYPE + archive.addfile(root) + for name, kind in members: + member = tarfile.TarInfo(name) + member.type = kind + member.linkname = '/tmp/escape' + archive.addfile(member) + + def test_rejects_wrong_package_traversal_links_devices_duplicates(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'bad.tar.gz' + cases = [('../escape', tarfile.REGTYPE), ('/absolute', tarfile.REGTYPE), + ('data/link', tarfile.SYMTYPE), ('data/link', tarfile.LNKTYPE), + ('data/device', tarfile.CHRTYPE), ('data', tarfile.DIRTYPE), + ('other/file', tarfile.REGTYPE), ('data/../escape', tarfile.REGTYPE)] + for member in cases: + self.make_archive(path, [member]) + with self.assertRaises(ValueError, msg=str(member)): + REMOTE['inspect_archive'](path, PKG, META['instance']) + self.make_archive(path, [], package='org.other.game') + with self.assertRaisesRegex(ValueError, 'does not match'): + REMOTE['inspect_archive'](path, PKG, META['instance']) + + def test_failed_backup_leaves_no_archive_and_existing_is_preserved(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'backup.tar.gz' + with patch.object(android, '_meta_or_fail', return_value=META), \ + patch.object(data, '_stream', side_effect=android.FrameError('offline')): + with self.assertRaises(android.FrameError): + data.backup_data(PKG, path) + self.assertEqual(list(Path(tmp).iterdir()), []) + path.write_bytes(b'keep') + with self.assertRaisesRegex(android.FrameError, 'already exists'): + data.backup_data(PKG, path) + self.assertEqual(path.read_bytes(), b'keep') + + def test_guard_does_not_hide_podman_failure(self): + command = data._data_command('backup', META) + self.assertIn('|| exit 1', command) + self.assertIn('stop the app', command) + self.assertIn('podman unshare python3', command) + self.assertNotIn('|| true', command) + + def test_bad_restore_is_rejected_before_transfer(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'bad.tar.gz' + self.make_archive(path, [('../escape', tarfile.REGTYPE)]) + with patch.object(android, '_meta_or_fail', return_value=META), patch.object(data, '_stream') as stream: + with self.assertRaises(android.FrameError): + data.restore_data(PKG, path) + stream.assert_not_called() + + + def test_successful_backup_is_private_and_inspectable(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / PKG).mkdir() + (root / PKG / 'save').write_bytes(b'checkpoint') + destination = root / 'backup.tar.gz' + def stream(command, src=None, dst=None): + REMOTE['backup'](root, PKG, META['instance'], dst) + with patch.object(android, '_meta_or_fail', return_value=META), \ + patch.object(data, '_stream', side_effect=stream): + result = data.backup_data(PKG, destination) + self.assertEqual(destination.stat().st_mode & 0o777, 0o600) + self.assertEqual(result['sha256'], data._sha256(destination)) + self.assertEqual(result['files'], 2) + + def test_rejected_restore_keeps_existing_data(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / PKG).mkdir() + (root / PKG / 'save').write_bytes(b'keep') + archive = root / 'bad.tar.gz' + self.make_archive(archive, [('data/link', tarfile.SYMTYPE)]) + with archive.open('rb') as source, self.assertRaises(ValueError): + REMOTE['restore'](root, PKG, META['instance'], source) + self.assertEqual((root / PKG / 'save').read_bytes(), b'keep') + self.assertFalse(list(root.glob('.frame-restore-*'))) + self.assertFalse(list(root.glob('.*.before-restore-*'))) + + def test_archive_root_must_be_a_directory(self): + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) / 'bad.tar.gz' + with tarfile.open(archive, 'w:gz') as target: + target.addfile(tarfile.TarInfo('data')) + with self.assertRaisesRegex(ValueError, 'directory'): + REMOTE['inspect_archive'](archive, PKG, META['instance']) diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py new file mode 100644 index 0000000..cdf51c0 --- /dev/null +++ b/tests/test_frame_android_library.py @@ -0,0 +1,644 @@ +"""Offline artwork, Steam API and launcher supervision regressions.""" +import importlib.util +import json +import os +from pathlib import Path +import signal +import struct +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'ui')) +import frame_android as android +import frame_artwork as art + +spec = importlib.util.spec_from_file_location('steam_shortcuts', ROOT / 'frame/android/steam_shortcuts.py') +shortcuts = importlib.util.module_from_spec(spec) +spec.loader.exec_module(shortcuts) + + +@unittest.skipIf(os.name == 'nt', 'POSIX launcher') +class LauncherTests(unittest.TestCase): + def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False): + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + app = d / 'Applications/Android/org.test.app' + app.mkdir(parents=True) + (app / 'launch.sh').write_bytes((ROOT / 'frame/android/lepton-app.sh').read_bytes()) + (app / 'app.apk').touch() + (app / 'instance.id').write_text('2800000001') + (app / 'shortcut.id').write_text('3346865537') + bin_dir = d / 'bin' + bin_dir.mkdir() + lepton = d / '.local/share/Steam/steamapps/common/Lepton/lepton' + lepton.parent.mkdir(parents=True) + def script(path, body): + path.write_text('#!' + sys.executable + '\n' + body) + path.chmod(0o755) + script(lepton, 'import os,time\nfrom pathlib import Path\n' + 'assert os.environ["SteamAppId"] == "2800000001"\n' + 'assert os.environ["LEPTON_ENV_SteamAppId"] == "3346865537"\n' + 'Path(os.environ["HOME"],"started").write_text(str(os.getpid()))\n' + 'try:\n os.fstat(9); Path(os.environ["HOME"],"inherited-lock").touch()\nexcept OSError: pass\n' + + ('time.sleep(30)\n' if terminate else 'raise SystemExit(23)\n')) + script(bin_dir / 'setsid', 'import os,sys\nos.setsid()\nos.execv(sys.argv[2],sys.argv[2:])\n') + script(bin_dir / 'flock', 'import os\nraise SystemExit(1 if os.environ.get("TEST_LOCKED") else 0)\n') # lock semantics belong to Linux; no flock on macOS + script(bin_dir / 'podman', 'import os,sys\nfrom pathlib import Path\n' + 'p=Path(os.environ["HOME"],"podman-calls")\n' + 'with p.open("a") as f: f.write(" ".join(sys.argv[1:])+"\\n")\n' + 'if sys.argv[1:2]==["inspect"] and os.environ.get("TEST_RUNNING"): print("true")\n') + env = {**os.environ, 'HOME': str(d), 'PATH': str(bin_dir) + os.pathsep + os.environ['PATH']} + if blocked: + env['TEST_' + blocked] = '1' + if orphan: + env['TEST_RUNNING'] = '1' + saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save' + saved.parent.mkdir(parents=True) + saved.write_text('saved game') + proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + if blocked: + proc.communicate(timeout=5) + self.assertEqual(proc.returncode, 1) + self.assertFalse((d / 'started').exists()) + calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else '' + self.assertNotIn('stop ', calls) + return + deadline = time.monotonic() + 5 + while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline: + time.sleep(.02) + self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton') + self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock') + if terminate: + self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session') + proc.send_signal(sig) + _, err = proc.communicate(timeout=5) + calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else '' + self.assertIn('stop -t 5 lepton-steamlaunch-2800000001', calls, err.decode()) + self.assertEqual(calls.count('stop -t 5'), 2 if orphan else 1) + self.assertEqual(proc.returncode, 128 + sig if terminate else 23) + self.assertEqual(saved.read_text(), 'saved game') + self.assertTrue((app / 'app.apk').exists()) + finally: + if proc.poll() is None: + proc.kill() + proc.communicate() + if (d / 'started').exists(): + try: + os.kill(int((d / 'started').read_text()), signal.SIGKILL) + except ProcessLookupError: + pass + + def test_steam_stop_cleans_container(self): + self.exercise(True) + + def test_hangup_and_interrupt_cleanup(self): + # macOS's stock bash 3.2 doesn't run a SIGINT trap while blocked in `wait`; + # the Frame's bash (5.x) does, and that's where the launcher runs. + major = subprocess.run(['bash', '-c', 'echo ${BASH_VERSINFO[0]}'], capture_output=True, text=True).stdout.strip() + sigs = (signal.SIGHUP, signal.SIGINT) if major.isdigit() and int(major) >= 4 else (signal.SIGHUP,) + for sig in sigs: + with self.subTest(sig=sig): + self.exercise(True, sig) + + def test_duplicate_launch_leaves_existing_session_alone(self): + self.exercise(False, blocked='LOCKED') + + def test_orphaned_container_is_stopped_and_play_proceeds(self): + # Container running but the lock free: its launcher was SIGKILLed. + self.exercise(False, orphan=True) + + def test_normal_exit_cleans_container_and_keeps_exit_code(self): + self.exercise(False) + + +FIXTURES = ROOT / 'tests/fixtures/library' + + +class ArtworkTests(unittest.TestCase): + def test_source_inputs_and_url(self): + from apk_sources import _images + data = (FIXTURES / 'icon.png').read_bytes() + with patch('frame_steamgriddb.lookup', return_value=({}, [])), \ + patch.object(_images, 'fetch', return_value=(data, 'image/png')) as fetch: + images, warnings = art.prepare('Game', artwork={'banner': data, 'icon': 'https://example.org/icon.png'}) + self.assertEqual(images['banner'], ('png', data)) + self.assertEqual(images['icon'], ('png', data)) + self.assertEqual(warnings, []) + self.assertEqual(fetch.call_args.args[0], 'https://example.org/icon.png') + self.assertIsNotNone(fetch.call_args.kwargs['deadline']) + + def test_provider_precedence_and_bad_source_fallback(self): + data = (FIXTURES / 'icon.png').read_bytes() + jpg = (FIXTURES / 'icon.jpg').read_bytes() + with patch('frame_steamgriddb.lookup', return_value=({'hero': jpg}, [])): + images, warnings = art.prepare('Game', data, {'hero': data, 'wide': b'bad', 'screenshots': [b'bad', data]}) + self.assertEqual(images['hero'], ('jpg', jpg)) + self.assertEqual(images['icon'], ('png', data)) + self.assertEqual(images['screenshot'], ('png', data)) + self.assertNotIn('wide', images) + self.assertEqual(warnings, ['Source wide unavailable; using fallback art']) # one per slot, not per candidate + + def test_any_source_failure_falls_back_to_generated_art(self): + import http.client + from apk_sources import _images + data = (FIXTURES / 'icon.png').read_bytes() + for error in (http.client.RemoteDisconnected('gone'), http.client.IncompleteRead(b''), AttributeError('x')): + with self.subTest(error=type(error).__name__), \ + patch.object(_images, 'fetch', side_effect=error), \ + patch('frame_steamgriddb.lookup', side_effect=error): + images, warnings = art.prepare('Game', data, {'banner': 'https://example.org/b.png'}) + self.assertEqual(set(images), {'icon'}) + self.assertEqual(len(warnings), 2) + + def test_url_fetch_refuses_private_hosts_and_honours_deadline(self): + from apk_sources import _images, SourceError + local = [(2, 1, 6, '', ('127.0.0.1', 443))] + with patch.object(_images.socket, 'getaddrinfo', return_value=local), \ + self.assertRaisesRegex(SourceError, 'Private'): + art.fetch('https://example.org/icon.png') + public = [(2, 1, 6, '', ('93.184.216.34', 443))] + with patch.object(_images.socket, 'getaddrinfo', return_value=public), \ + patch.object(_images.socket, 'create_connection') as connect, \ + self.assertRaisesRegex(SourceError, 'too long'): + art.fetch('https://example.org/icon.png', deadline=time.monotonic() - 1) + connect.assert_not_called() + with self.assertRaises(SourceError): + art.fetch('file:///etc/passwd') + + def trickle(self, head, seconds): + # A server that answers one byte every 20 ms, over a socketpair standing in for the network. + import socket + import threading + from apk_sources import _images + client, server = socket.socketpair() + def serve(): + try: + server.recv(65536) + for byte in head + b'x' * 1000: + server.sendall(bytes([byte])) + time.sleep(0.02) + except OSError: + pass + finally: + server.close() + threading.Thread(target=serve, daemon=True).start() + public = [(2, 1, 6, '', ('93.184.216.34', 80))] + with patch.object(_images.socket, 'getaddrinfo', return_value=public), \ + patch.object(_images.socket, 'create_connection', return_value=client): + start = time.monotonic() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('http://example.org/a.png', deadline=start + seconds) + return time.monotonic() - start + + def test_deadline_bounds_trickling_headers_and_body(self): + self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n', 0.15), 0.4) + self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\n', 0.15), 0.4) # headers never finish + + def test_deadline_covers_a_stalled_tls_handshake(self): + import socket + from apk_sources import _images + client, server = socket.socketpair() + public = [(2, 1, 6, '', ('93.184.216.34', 443))] + try: + with patch.object(_images.socket, 'getaddrinfo', return_value=public), \ + patch.object(_images.socket, 'create_connection', return_value=client): + start = time.monotonic() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('https://example.org/a.png', deadline=start + 0.25) # server never answers + self.assertLess(time.monotonic() - start, 0.45) + finally: + server.close() + + def test_timed_out_lookups_are_capped(self): + import threading + from apk_sources import _images + gate = threading.Event() + try: + with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []): + errors = [] + for _ in range(6): + try: + _images.get('https://example.org/a.png', deadline=time.monotonic() + 0.05) + except _images.SourceError as e: + errors.append(str(e)) + self.assertEqual(sum('too long' in e for e in errors), 4) + self.assertEqual(sum('Too many' in e for e in errors), 2) + finally: + gate.set() + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False): + time.sleep(0.01) + _images._resolvers.release() # the stuck lookups finished and gave their slots back + + def test_resolver_slot_released_when_thread_cannot_start(self): + from apk_sources import _images + with patch.object(_images.threading.Thread, 'start', side_effect=RuntimeError("can't start new thread")): + for _ in range(6): + with self.assertRaises(RuntimeError): + _images.get('https://example.org/a.png', deadline=time.monotonic() + 1) + for _ in range(4): # every slot came back + self.assertTrue(_images._resolvers.acquire(blocking=False)) + for _ in range(4): + _images._resolvers.release() + + def test_deadline_covers_name_resolution(self): + import threading + from apk_sources import _images + gate = threading.Event() + with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []): + start = time.monotonic() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('https://example.org/a.png', deadline=start + 0.1) + self.assertLess(time.monotonic() - start, 0.4) + gate.set() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('https://example.org/a.png', deadline=time.monotonic() - 1) + + def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self): + import frame_steamgriddb as sgdb + from apk_sources import _images + with patch.object(_images, 'get', return_value=b'{"success": true, "data": [1]}') as get: + self.assertEqual(sgdb._get('/search/x', 'secret', time.monotonic() + 5), [1]) + self.assertEqual(get.call_args.kwargs['redirects'], 0) + self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret') + self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5) + + def test_supplied_jpeg(self): + data = (FIXTURES / 'icon.jpg').read_bytes() + self.assertEqual(art.image_type(data), 'jpg') + self.assertEqual(art.image_type(data + b'\0' * 64), 'jpg') # trailing padding after EOI + with self.assertRaises(ValueError): + art.image_type(data[:30]) + + FRAME = b'\x21\xf9\x04\x01\x00\x00\x00\x00' + b'\x2c' + struct.pack('IIBBBBB', w, h, depth, color, 0, 0, interlace)) + \ + art.chunk(b'IEND', b'') + self.assertEqual(art.image_type(png(3840, 1240, 16, 6, 0)), 'png') + self.assertEqual(art.image_type(png(3840, 2160, 8, 2, 1)), 'png') + for bad, message in ((png(10000, 10, 8, 6, 0), 'dimensions'), (png(5000, 5000, 8, 6, 0), 'dimensions'), + (png(10, 10, 3, 6, 0), 'encoding'), (png(10, 10, 8, 5, 0), 'encoding')): + with self.subTest(message=message), self.assertRaisesRegex(ValueError, message): + art.image_type(bad) + broken = bytearray(png(10, 10, 8, 6, 0)) + broken[20] ^= 1 + with self.assertRaisesRegex(ValueError, 'checksum'): + art.image_type(bytes(broken)) + with self.assertRaises(ValueError): + art.image_type(art.PNG + b'junk') + + def test_bad_artwork_arguments(self): + for value in ({'bad': b'bad'}, ['hero']): + with self.subTest(value=value), self.assertRaises(ValueError): + art.prepare('Game', artwork=value) + + def test_godot_project_icon(self): + import io + import zipfile + data = (FIXTURES / 'icon.png').read_bytes() + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w') as archive: + archive.writestr('assets/icon.png', data) + with zipfile.ZipFile(buffer) as archive: + self.assertEqual(android.frame_apk._icon_png(archive, set(archive.namelist()), []), data) + + +class InstallTests(unittest.TestCase): + def setUp(self): + self.responses = json.loads((FIXTURES / 'steam-responses.json').read_text()) + self.info = {'package': 'org.test.vr', 'label': 'VR', 'version': '1', 'icon_png': None, + 'vr': True, 'launchable': True, 'repairable': False, 'abis': [], 'min_sdk': 24} + self.images = {slot: ('png', b'PNG ' + slot.encode()) for slot in art.SLOTS} + self.existing = {'package': 'org.test.vr', 'instance': 2800000001, + 'shortcut': 3346865537, 'label': 'Old name'} + + def install(self, existing, tool=None): + def shortcut(*args, **kwargs): + if args[0] == 'add': + return '3346865537' + if args[0] == 'render': + return json.dumps({'paths': {slot: '/home/steamos/Applications/Android/org.test.vr/artwork/' + slot + '.png' for slot in art.SLOTS}}) + if args[0] == 'list': + return json.dumps(self.responses['shortcuts']) + return json.dumps(self.responses['configure']) + with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \ + patch.object(android, 'read_meta', return_value=existing), \ + patch.object(android, '_copy') as copy, \ + patch.object(android, 'ssh', return_value=self.responses['home']) as ssh, \ + patch.object(android, 'shortcut_tool', side_effect=tool or shortcut) as api, \ + patch.object(android, '_write_meta') as meta: + result = android._install('game.apk', self.info, self.info['package'], False, 'New name', 'test') + return result, ssh, api, meta + + def test_existing_shortcut_refreshes_name_vr_and_every_slot(self): + result, ssh, api, meta = self.install(self.existing) + calls = [c.args for c in api.call_args_list] + self.assertNotIn('add', [c[0] for c in calls]) + configure = next(c for c in calls if c[0] == 'configure') + self.assertEqual(configure[1:3], ('3346865537', 'New name')) + self.assertEqual(configure[6], '1') + self.assertEqual(set(json.loads(configure[7])), set(art.SLOTS)) + self.assertTrue(configure[5].endswith('/org.test.vr/artwork/icon.png')) + self.assertEqual(result['shortcut'], self.existing['shortcut']) + self.assertEqual(result['label'], 'New name') + self.assertEqual(result['library_warnings'], []) + self.assertEqual(len([c for c in ssh.call_args_list if isinstance(c.kwargs.get('input'), bytes)]), 5) + meta.assert_called_once() + + def test_first_install_adds_shortcut(self): + _, _, api, _ = self.install(None) + self.assertEqual([c.args[0] for c in api.call_args_list], ['add', 'render', 'configure']) + + def test_artwork_forwarded_through_patch(self): + artwork = {'hero': b'provided'} + with patch.object(android, 'apk_info', return_value={**self.info, 'repairable': True}), \ + patch.object(android, 'xr_compat_files', return_value={}), \ + patch.object(android, 'patch', return_value={'patched': ['launcher']}), \ + patch.object(android, '_install', return_value={}) as install: + android.install('x.apk', artwork=artwork) + self.assertIs(install.call_args.args[-1], artwork) + + def test_failed_new_install_removes_shortcut(self): + def tool(*args, **kwargs): + if args[0] == 'add': + return '3346865537' + if args[0] == 'render': + return json.dumps({'paths': {slot: '/tmp/' + slot + '.png' for slot in art.SLOTS}}) + if args[0] == 'configure': + raise android.FrameError('write failed') + return '{}' + with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \ + patch.object(android, 'read_meta', return_value=None), \ + patch.object(android, '_copy'), patch.object(android, 'ssh', return_value='/home/steamos') as ssh, \ + patch.object(android, 'shortcut_tool', side_effect=tool) as api: + with self.assertRaisesRegex(android.FrameError, 'write failed'): + android._install('x.apk', self.info, 'org.test.vr', False, None, None) + self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list]) + self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list)) + + def test_remove_keeps_data_when_requested_and_survives_steam_failure(self): + with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \ + patch.object(android, 'stop'), \ + patch.object(android, 'shortcut_tool', return_value='{"warnings": []}') as api, \ + patch.object(android, 'ssh') as ssh: + android.remove('org.test.vr', keep_data=True) + api.assert_called_once_with('remove', '3346865537') + ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr') + api.side_effect = android.FrameError('SharedJSContext not found: is the Steam client running?') + ssh.reset_mock() + result = android.remove('org.test.vr') + ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr ' + '.local/share/Steam/steamapps/compatdata/2800000001 ' + '.local/share/Steam/steamapps/shadercache/2800000001') + self.assertIn('Steam client running', result['library_warnings'][0]) + + def test_remove_waits_for_refresh_and_is_never_undone(self): + import threading + state = {'meta': dict(self.existing, flatscreen=False), 'shortcuts': {3346865537}} + in_refresh, release, added = threading.Event(), threading.Event(), [] + def meta(pkg): + if not state['meta']: + raise android.FrameError(pkg + ' is not installed') + return dict(state['meta']) + def ssh(cmd, input=None, **kw): + if cmd.startswith('rm -rf Applications/Android/org.test.vr'): + state['meta'] = None + return json.dumps({'icon_png': ''}) if cmd == 'python3 -' else '/home/steamos' + def tool(*args, **kw): + if args[0] == 'list': return json.dumps([{'appid': a} for a in state['shortcuts']]) + if args[0] == 'remove': state['shortcuts'].discard(int(args[1])); return '{"warnings": []}' + if args[0] == 'add': added.append(args); return '99' + if args[0] == 'render': return json.dumps({'paths': {s: '/tmp/' + s + '.png' for s in art.SLOTS}}) + return '{"warnings": []}' + def prepare(*args, **kw): + in_refresh.set(); release.wait(5) + return self.images, [] + with patch.object(android, '_meta_or_fail', side_effect=meta), patch.object(android, 'ssh', side_effect=ssh), \ + patch.object(android, 'shortcut_tool', side_effect=tool), patch.object(android, 'stop'), \ + patch.object(android, '_write_meta', side_effect=lambda d, m: state.__setitem__('meta', m)), \ + patch.object(android.frame_artwork, 'prepare', side_effect=prepare): + refresh = threading.Thread(target=android.refresh_art, args=('org.test.vr',), kwargs={'fill_only': True}) + refresh.start() + self.assertTrue(in_refresh.wait(5)) + remove = threading.Thread(target=android.remove, args=('org.test.vr',)) + remove.start() + remove.join(0.3) + self.assertTrue(remove.is_alive(), 'remove ran while a refresh was writing') + release.set(); refresh.join(5); remove.join(5) + self.assertIsNone(state['meta']); self.assertEqual(state['shortcuts'], set()) + with self.assertRaisesRegex(android.FrameError, 'not installed'): + android.refresh_art('org.test.vr', fill_only=True) # a queued backfill after removal + self.assertEqual(added, []) + + def test_stop_requests_steam_and_has_container_fallback(self): + with patch.object(android, '_meta_or_fail', return_value=self.existing), \ + patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \ + patch.object(android, 'ssh') as ssh: + android.stop('org.test.vr') + api.assert_called_once_with('stop', '3346865537') + self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0]) + + +class SteamAPITests(unittest.TestCase): + def test_artwork_api_enums_and_safe_serialization(self): + with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate: + shortcuts.configure(42, 'A "name"\n', '/path', '/start', '/icon', True, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) + js = evaluate.call_args.args[0] + self.assertIn('SetShortcutIsVR(id, true)', js) + self.assertIn('SetShortcutName(id, "A \\"name\\"\\n")', js) + self.assertIn('SetCustomArtworkForApp(id, data, ext, type)', js) + self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('SetCustomArtworkForApp(id, data, ext, type)')) + self.assertEqual(shortcuts.ASSETS, {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}) + self.assertIn('NewUnsavedCollection(name, undefined, [app])', js) + + def test_remove_clears_every_slot_before_shortcut(self): + with patch.object(shortcuts, 'evaluate', return_value={}) as evaluate: + shortcuts.remove(42) + js = evaluate.call_args.args[0] + self.assertIn('[0, 1, 2, 3]', js) + self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('RemoveShortcut(id)')) + self.assertIn('const wanted = []', js) + self.assertNotIn('throw', js) # tidy-up failures are warnings; RemoveShortcut always runs + + def test_devkit_configure_leaves_vr_flag_and_uses_sideloaded(self): + slots = {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS} + with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate: + shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'}) + js = evaluate.call_args.args[0] + self.assertIn('if (null !== null && !fill)', js) + self.assertIn('const wanted = ["Sideloaded"]', js) + with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '', + json.dumps(slots), '{}']), \ + patch.object(shortcuts, 'configure', return_value={}) as configure, patch('builtins.print'): + shortcuts.main() + self.assertIsNone(configure.call_args.args[5]) + + def test_render_writes_jpeg_and_retries_oversized_photo_with_generated_art(self): + import base64 + png = base64.b64encode((FIXTURES / 'icon.png').read_bytes()).decode() + jpg = base64.b64encode((FIXTURES / 'icon.jpg').read_bytes()).decode() + huge = base64.b64encode(b'\xff\xd8\xff' + b'\0' * (12 * 1024 * 1024)).decode() + calls = [] + def evaluate(js, timeout=20): + calls.append((json.loads(js[js.rindex('renderLibraryArtwork(') + 21:-1]), timeout)) + hero = ['jpg', huge] if len(calls) == 1 else ['png', png] + return {'images': {'grid': ['jpg', jpg], 'wide': ['jpg', jpg], 'hero': hero, + 'logo': ['png', png], 'icon': ['png', png]}, 'warnings': []} + with tempfile.TemporaryDirectory() as tmp: + for name in ('icon.png', 'hero.jpg'): + Path(tmp, 'source-' + name).write_bytes((FIXTURES / ('icon.jpg' if name.endswith('jpg') else 'icon.png')).read_bytes()) + Path(tmp, 'hero.png').write_bytes(b'stale') + plan = Path(tmp, 'input.json') + plan.write_text(json.dumps({'label': 'Game', 'images': {'icon': str(Path(tmp, 'source-icon.png')), + 'hero': str(Path(tmp, 'source-hero.jpg'))}})) + with patch.object(shortcuts, 'evaluate', side_effect=evaluate): + result = shortcuts.render(str(plan)) + self.assertEqual(set(calls[0][0]['images']), {'icon', 'hero'}) + self.assertEqual(set(calls[1][0]['images']), {'icon'}) + self.assertEqual([c[1] for c in calls], [75, 75]) + self.assertTrue(result['paths']['grid'].endswith('grid.jpg')) + self.assertTrue(result['paths']['hero'].endswith('hero.png')) + self.assertIn('generated art used', result['warnings'][0]) + self.assertFalse(Path(tmp, 'hero.jpg').exists()) + self.assertEqual(Path(tmp, 'grid.jpg').read_bytes(), (FIXTURES / 'icon.jpg').read_bytes()) + + def test_stop_uses_exact_64_bit_game_id_string(self): + with patch.object(sys, 'argv', ['steam_shortcuts.py', 'stop', '3346865537']), \ + patch.object(shortcuts, 'evaluate') as evaluate, patch('builtins.print'): + shortcuts.main() + self.assertEqual(evaluate.call_args.args[0], 'SteamClient.Apps.TerminateApp("14374678025558032384", false)') + + +class SteamContextTests(unittest.TestCase): + @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') + def test_collection_lifecycle_and_native_artwork_calls(self): + steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Before'}], 'compat_tools': {}, + 'collections': [{'name': 'Android', 'apps': [999]}]} + def evaluate(expression, timeout=20): + nonlocal steam + proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')], + input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True, + 'steam': steam}) + '\n', + text=True, capture_output=True, timeout=10, check=True) + reply = json.loads(proc.stdout) + self.assertNotIn('exceptionDetails', reply['result']) + steam = reply['steam'] + return reply['result']['result'].get('value') + with patch.object(shortcuts, 'evaluate', side_effect=evaluate): + result = shortcuts.configure(42, 'Game', '/exe', '/dir', '/icon', True, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) + self.assertEqual(result['warnings'], []) + self.assertTrue(steam['shortcuts'][0]['vr']) + self.assertEqual(set(steam['shortcuts'][0]['artwork']), {'0', '1', '2', '3'}) + self.assertEqual(steam['collections'], [{'name': 'Android', 'apps': [999, 42]}, + {'name': 'Android VR', 'apps': [42]}]) + shortcuts.configure(42, 'Renamed', '/exe', '/dir', '/icon', False, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) + self.assertEqual(steam['shortcuts'][0]['name'], 'Renamed') + self.assertEqual(steam['collections'][1]['apps'], []) + with patch.object(sys, 'argv', ['steam_shortcuts.py', 'list']), patch('builtins.print') as out: + shortcuts.main() + self.assertEqual(json.loads(out.call_args.args[0]), + [{'appid': 42, 'name': 'Renamed', 'exe': '/exe', 'start_dir': '/dir'}]) + shortcuts.remove(42) + self.assertEqual(steam['shortcuts'], []) + self.assertEqual(steam['collections'][0]['apps'], [999]) + + @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') + def test_fill_only_keeps_customised_name_icon_and_art(self): + custom = {'0': {'data': 'mine-grid', 'ext': 'png'}, '1': {'data': 'mine-hero', 'ext': 'jpg'}} + steam = {'apps': [], 'compat_tools': {}, 'collections': [], + 'shortcuts': [{'appid': 42, 'name': 'My Name', 'icon': '/mine.png', 'vr': True, 'artwork': dict(custom)}]} + def evaluate(expression, timeout=20): + nonlocal steam + proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')], + input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True, + 'steam': steam}) + '\n', + text=True, capture_output=True, timeout=10, check=True) + reply = json.loads(proc.stdout) + self.assertNotIn('exceptionDetails', reply['result']) + steam = reply['steam'] + return reply['result']['result'].get('value') + with tempfile.TemporaryDirectory() as home: + grid = Path(home, '.local/share/Steam/userdata/1/config/grid') + grid.mkdir(parents=True) + (grid / '42p.png').write_bytes(b'mine') + (grid / '42_hero.jpg').write_bytes(b'mine') + with patch.dict(os.environ, {'HOME': home, 'USERPROFILE': home}), patch.object(shortcuts, 'evaluate', side_effect=evaluate): + self.assertEqual(shortcuts.custom_art(42), {0, 1}) + shortcuts.configure(42, 'Generated', '/exe', '/dir', '/generated.png', False, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}, + {'category': 'Sideloaded', 'fill_only': True}) + s = steam['shortcuts'][0] + self.assertEqual((s['name'], s['icon'], s['vr']), ('My Name', '/mine.png', True)) + self.assertEqual({k: s['artwork'][k] for k in ('0', '1')}, custom) + self.assertEqual(set(s['artwork']), {'0', '1', '2', '3'}) + + @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') + def test_remove_without_collections_or_artwork_api_still_removes(self): + steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Game', 'exe': '"/home/steamos/devkit-game/G/g"', + 'start_dir': '/home/steamos/devkit-game/G'}], 'compat_tools': {}} + def evaluate(expression, timeout=20): + nonlocal steam + expression = ('delete globalThis.collectionStore;' + 'SteamClient.Apps.ClearCustomArtworkForApp = async () => { throw Error("busy"); };' + expression) + proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')], + input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True, + 'steam': steam}) + '\n', + text=True, capture_output=True, timeout=10, check=True) + reply = json.loads(proc.stdout) + self.assertNotIn('exceptionDetails', reply['result']) + steam = reply['steam'] + return reply['result']['result'].get('value') + with patch.object(shortcuts, 'evaluate', side_effect=evaluate): + result = shortcuts.remove(42) + self.assertEqual(steam['shortcuts'], []) + self.assertEqual(len(result['warnings']), 5) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_library_entrypoints.py b/tests/test_library_entrypoints.py new file mode 100644 index 0000000..2796353 --- /dev/null +++ b/tests/test_library_entrypoints.py @@ -0,0 +1,368 @@ +"""Every public sideload entry point reaches the mandatory five-slot writer.""" +import contextlib +import io +import json +import shutil +import subprocess +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'ui')) +import frame_android as android +import frame_artwork as artwork +import frame_catalog as catalog +import frame_apk_versions as versions +import frame_titles as titles +import frame_steamgriddb as sgdb +import server +import frame_webinstall as webinstall + + +class EntryPoints(unittest.TestCase): + def setUp(self): + self.stack = contextlib.ExitStack() + self.addCleanup(self.stack.close) + self.info = {'package':'org.example.game', 'label':'Example', 'version':'1', 'version_code':1, + 'vr':False, 'repairable':False, 'launchable':True, 'min_sdk':24, 'abis':[], 'icon_png':None} + self.stack.enter_context(patch.object(android, 'apk_info', return_value=self.info)) + self.stack.enter_context(patch.object(android, 'read_meta', return_value=None)) + self.stack.enter_context(patch.object(android, '_copy')) + self.stack.enter_context(patch.object(android, 'ssh', return_value='/home/steamos')) + self.stack.enter_context(patch.object(artwork, 'prepare', return_value=({}, []))) + self.api = self.stack.enter_context(patch.object(android, 'shortcut_tool', side_effect=self.steam)) + + def steam(self, *args, **kwargs): + if args[0] == 'add': return '3346865537' + if args[0] == 'render': return json.dumps({'paths': {s:'/tmp/'+s+'.png' for s in artwork.SLOTS}}) + if args[0] == 'list': return json.dumps([{'appid':3346865537,'name':'Example','devkit_gameid':'Example'}]) + return '{"warnings":[]}' + + def assert_art(self): + calls = [c.args for c in self.api.call_args_list] + self.assertEqual(sum(c[0] == 'render' for c in calls), 1) + configs = [c for c in calls if c[0] == 'configure'] + self.assertEqual(len(configs), 1) + self.assertEqual(set(json.loads(configs[0][7])), set(artwork.SLOTS)) + + def test_cli_install(self): + with patch.object(sys, 'argv', ['frame_android.py', 'install', 'game.apk']), patch('builtins.print'): + android.main() + self.assert_art() + + def test_file_upload(self): + class Request: + headers = {'X-Filename':'game.apk','X-Mode':'apk','Content-Length':'3'} + rfile = io.BytesIO(b'apk') + with patch.object(server, 'ensure_master'): + result = server.Handler.upload(Request()) + self.assertEqual(result['app']['package'], self.info['package']) + self.assert_art() + + def test_catalogue_install(self): + with patch.object(catalog, 'app', return_value={'r':'yes','t':False,'n':'Example'}), \ + patch.object(catalog, 'fetch_apk', return_value='game.apk'), \ + patch.object(catalog, 'fetch_icon', return_value=None): + catalog.install(self.info['package']) + self.assert_art() + + def test_version_finder_install(self): + record = {'url':'https://example.org/app.apk','sha256':'fixture','version_code':1,'source':'F-Droid'} + with patch.object(versions, '_versions', return_value=([record], [])), \ + patch.object(catalog, 'fetch_apk', return_value='game.apk'): + versions.install(self.info['package'], record['url']) + self.assert_art() + + def test_web_download_install(self): + result = webinstall.dispatch('game.apk', source='https://example.org/game.apk') + self.assertEqual(result['kind'], 'apk') + self.assert_art() + + def test_refresh_api_covers_android_apps_and_titles(self): + with patch.object(server, 'ensure_master'), \ + patch.object(server, 'start_job', side_effect=lambda label, work: work()), \ + patch.object(android, 'refresh_art', return_value=[]) as refresh, \ + patch.object(titles, 'refresh_art', return_value=[{'id':'G','error':'x'}]) as title_refresh: + self.assertEqual(server.android({'action':'refresh-art', 'all':True}), + {'apps':[], 'titles':[{'id':'G','error':'x'}]}) + refresh.assert_called_once_with(); title_refresh.assert_called_once_with() + server.titles({'action':'refresh-art', 'id':'G'}) + title_refresh.assert_called_with('G') + + def test_backfill_fills_only_entries_pending_since_install(self): + import threading + ran = threading.Event() + with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \ + patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \ + patch.object(titles, 'refresh_art', side_effect=lambda gid, **kw: ran.set()) as title_refresh: + apps = [{'package':'org.a.x','art_pending':True}, {'package':'org.b.x','art_pending':True}, + {'package':'org.c.x','art_missing':True}] # legacy: no record of art, but not pending + with contextlib.redirect_stderr(io.StringIO()): + self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_pending':True}])) + self.assertTrue(ran.wait(5)) + self.assertFalse(server.backfill_art(apps=apps)) # throttled + self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)]) + self.assertTrue(all(c.kwargs == {'fill_only': True} for c in refresh.call_args_list)) + title_refresh.assert_called_once_with('G', fill_only=True) + + def test_bulk_fill_only_refresh_keeps_names_and_art(self): + meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False} + with patch.object(android, 'list_apps', return_value=[{'package':self.info['package']}]), \ + patch.object(android, '_meta_or_fail', return_value=meta), \ + patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \ + patch.object(android, '_write_meta'): + android.refresh_art(fill_only=True) + options = json.loads(next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')[8]) + self.assertTrue(options['fill_only']) + self.api.reset_mock() + with patch.object(titles, 'list_titles', return_value=[{'id':'Game','name':'Game','frame_control':True}]), \ + patch.object(titles, '_check_id', side_effect=lambda gid: gid), \ + patch.object(titles, '_library_shortcut', return_value=7), \ + patch.object(titles, 'ssh', side_effect=lambda cmd, **kw: '{"name":"Game"}' if cmd.startswith('cat devkit') + else '{"artwork":{},"icon":""}' if cmd == 'python3 -' else '/home/steamos'): + titles.refresh_art(fill_only=True) + options = json.loads(next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')[8]) + self.assertTrue(options['fill_only']) + + def test_upgrade_leaves_legacy_customised_titles_alone(self): + # A title installed before art_pending existed, whose art the user has customised in Steam. + legacy = [{'id':'Game','settings':{'compat_tool':'proton-experimental'},'argv':['game.exe'], + 'meta':{'name':'Game','target':'game.exe','source':'game.zip'}}] + with patch.object(titles, 'ssh', return_value=json.dumps(legacy)): + listed = titles.list_titles() + self.assertEqual((listed[0]['art_pending'], listed[0]['art_missing']), (False, False)) + with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \ + patch.object(titles, 'refresh_art') as refresh, patch.object(android, 'refresh_art') as app_refresh: + self.assertFalse(server.backfill_art(apps=[{'package':'org.old.app','library_version':1}], titles=listed)) + refresh.assert_not_called(); app_refresh.assert_not_called() + self.api.assert_not_called() + + def test_art_missing_flags(self): + self.assertTrue(android.art_missing({'artwork': {}})) + self.assertTrue(android.art_missing({'artwork': {'grid': 'x'}})) + self.assertFalse(android.art_missing({'artwork': {s: 'x' for s in artwork.SLOTS}})) + + def test_source_search_shared_installer_contract(self): + # Source workers hand their download and optional images to this public seam. + android.install('game.apk', name='Example', source='source-search', artwork={'banner': b'fixture'}) + self.assert_art() + + def test_native_title_install(self): + with tempfile.TemporaryDirectory() as root: + plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe', + 'runtime':'proton-experimental','source':'example.zip'} + def ssh(cmd, **kwargs): + if 'test -d' in cmd: return '' + if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}' + if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}' + return '' + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True): + result = titles._install(plan, lambda *args: None) + self.assertEqual(result['shortcut'], 3346865537) + self.assert_art() + config = next(c.args for c in self.api.call_args_list if c.args[0] == 'configure') + self.assertEqual(json.loads(config[8])['category'], 'Sideloaded') + self.assertEqual(config[3:5], ('','')) # Never replace devkit's executable/runtime wiring. + self.assertEqual(config[6], '') # nor the VR flag the title declares + self.assertEqual(set(result['artwork']), set(artwork.SLOTS)) + + def test_native_renamed_shortcut_uses_saved_identity(self): + self.api.side_effect = lambda *args, **kw: '[{"appid":42,"name":"Renamed"}]' + with patch.object(titles, 'ssh', return_value='{"shortcut":42}'): + self.assertEqual(titles._library_shortcut('Original', '/home/steamos/devkit-game/Original'), 42) + + def test_native_shortcut_never_matched_by_name_alone(self): + d = '/home/steamos/devkit-game/Game' + shortcuts = [{'appid':1,'name':'Game','exe':'"/home/steamos/.local/bin/game"','start_dir':'/home/steamos'}, + {'appid':2,'name':'Other','exe':'"/home/steamos/devkit-game/Game2/g.exe"','start_dir':''}] + self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts) + with patch.object(titles, 'ssh', return_value=''): + self.assertIsNone(titles._library_shortcut('Game', d)) + shortcuts.append({'appid':3,'name':'Renamed','exe':'"/home/steamos/devkit-game/Game/bin/g.exe"','start_dir':''}) + self.assertEqual(titles._library_shortcut('Game', d), 3) + shortcuts.append({'appid':4,'name':'Copy','exe':'','start_dir':d}) + with self.assertRaisesRegex(android.FrameError, 'ambiguous'): + titles._library_shortcut('Game', d) + + def test_native_cleanup_failure_keeps_original_error(self): + with tempfile.TemporaryDirectory() as root: + plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe', + 'runtime':'proton-experimental','source':'example.zip'} + def ssh(cmd, **kwargs): + if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}' + if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}' + return '' + def steam(*args, **kwargs): + if args[0] == 'remove': raise android.FrameError('Steam went away') + return self.steam(*args) + self.api.side_effect = steam + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \ + patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')): + with self.assertRaisesRegex(android.FrameError, 'render failed'): + titles._install(plan, lambda *args: None) + + def test_native_remove_survives_steam_being_down(self): + cmds = [] + def ssh(cmd, **kwargs): + cmds.append(cmd) + return 'yes' if 'test -d' in cmd else '/home/steamos' if 'HOME' in cmd else '' + self.api.side_effect = android.FrameError('SharedJSContext not found') + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'): + titles.remove('Game') + self.assertTrue(any('steamos-delete --delete-title Game' in c for c in cmds)) + + def test_native_remove_deletes_before_tidying_the_shortcut(self): + # steamos-delete finds the Proton prefix through the shortcut, so the shortcut must still exist. + order = [] + def ssh(cmd, **kwargs): + if 'steamos-delete' in cmd: + order.append('delete') + return 'yes' if 'test -d' in cmd else '/home/steamos' if 'HOME' in cmd else '' + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_library_shortcut', return_value=42), \ + patch.object(titles.frame_android, 'shortcut_tool', side_effect=lambda *a: order.append(a)): + titles.remove('Game') + self.assertEqual(order, ['delete', ('remove', '42')]) + + def test_native_refresh_art_backfills_registered_title(self): + meta = {'id':'Game','name':'My Game','source':'game.zip'} + writes = [] + def ssh(cmd, input=None, **kwargs): + if 'test -d' in cmd: return 'yes' + if 'HOME' in cmd: return '/home/steamos' + if cmd.startswith('cat devkit-game/Game-framecontrol.json'): return json.dumps(meta) + if cmd == 'python3 -': + self.assertIn("/home/steamos/devkit-game/Game/.frame-artwork", input) + return json.dumps({'artwork': {'banner': 'YmFubmVy'}, 'icon': ''}) + if cmd.startswith('cat > devkit-game/Game-framecontrol.json'): writes.append(json.loads(input)) + return '' + shortcuts = [{'appid':7,'name':'My Game','exe':'','start_dir':'/home/steamos/devkit-game/Game'}] + self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts) if args[0] == 'list' else self.steam(*args) + with patch.object(titles, 'ssh', side_effect=ssh), \ + patch.object(artwork, 'prepare', return_value=({}, [])) as prepare: + result = titles.refresh_art('Game') + self.assertEqual(prepare.call_args.args[2], {'banner': b'banner'}) + self.assertEqual(result['shortcut'], 7) + self.assertEqual(set(writes[-1]['artwork']), set(artwork.SLOTS)) + self.assert_art() + shortcuts.clear() + with patch.object(titles, 'ssh', side_effect=ssh), \ + patch.object(titles, 'list_titles', return_value=[{'id':'Game','name':'My Game','frame_control':True}, + {'id':'Valve','name':'V','frame_control':False}]): + results = titles.refresh_art() + self.assertEqual(len(results), 1) + self.assertIn("hasn't registered", results[0]['error']) + + def test_native_failure_removes_new_blank_shortcut(self): + with tempfile.TemporaryDirectory() as root: + plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe', + 'runtime':'proton-experimental','source':'example.zip'} + def ssh(cmd, **kwargs): + if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}' + if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}' + return '' + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \ + patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')): + with self.assertRaisesRegex(android.FrameError, 'render failed'): + titles._install(plan, lambda *args: None) + self.assertIn(('remove', '3346865537'), [c.args for c in self.api.call_args_list]) + + def test_refresh_does_not_reinstall_or_stop(self): + meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False} + with patch.object(android, '_meta_or_fail', return_value=meta), \ + patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \ + patch.object(android, 'stop') as stop, patch.object(android, '_copy') as copy: + android.refresh_art(self.info['package']) + stop.assert_not_called(); copy.assert_not_called(); self.assert_art() + + def test_all_refresh_reports_partial_failures(self): + import http.client + with patch.object(android, 'list_apps', return_value=[{'package':'org.a.game'},{'package':'org.b.game'}]), \ + patch.object(android, '_meta_or_fail', side_effect=[http.client.RemoteDisconnected('gone'), + AttributeError('odd')]): + result=android.refresh_art() + self.assertEqual(len(result),2) + self.assertTrue(all('error' in a for a in result)) + + def test_render_failure_cannot_report_success(self): + self.api.side_effect = lambda *args, **kw: '3346865537' if args[0]=='add' else '{"paths":{}}' + with self.assertRaisesRegex(android.FrameError,'every slot'): + android.install('game.apk') + + +class Renderer(unittest.TestCase): + @unittest.skipUnless(shutil.which('node'), 'Node is needed for the canvas contract fixture') + def test_canvas_slots_and_title_placement(self): + subprocess.run(['node', str(ROOT / 'tests/fixtures/library/check-renderer.js')], + cwd=str(ROOT), check=True, capture_output=True, timeout=30) + + def test_desktop_packages_include_renderer_and_settings(self): + config = json.loads((ROOT / 'app/package.json').read_text()) + # Single-file resources (licenses/notices) do not need a filter. + resources = {r['from']: r.get('filter', ['**/*']) for r in config['build']['extraResources']} + self.assertIn('*.js', resources['../ui']) + self.assertIn('*.js', resources['../frame/android']) + + +class SteamGridDB(unittest.TestCase): + def test_no_key_is_silent_and_offline(self): + with patch.object(sgdb,'api_key',return_value=''), patch.object(sgdb,'_get') as get: + self.assertEqual(sgdb.lookup('Game'),({},[])) + get.assert_not_called() + + def test_exact_match_and_top_votes_per_slot(self): + calls=[] + def get(path,key,deadline=None): + calls.append(path) + if 'search' in path: return [{'id':1,'name':'Other Game'},{'id':2,'name':'Game'}] + dims=(600,900) if '600x900' in path else (920,430) + return [{'url':'https://example.org/low.png','score':2,'width':dims[0],'height':dims[1]}, + {'url':'https://example.org/top.png','score':20,'width':dims[0],'height':dims[1]}, + {'url':'https://example.org/nsfw.png','score':99,'nsfw':True,'width':dims[0],'height':dims[1]}] + with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=get): + images,warnings=sgdb.lookup('Game VR') + self.assertEqual(set(images),set(artwork.SLOTS));self.assertEqual(warnings,[]) + self.assertTrue(all(url.endswith('/top.png') for url in images.values())) + self.assertTrue(all('/game/2?' in p for p in calls[1:])) + # SteamGridDB rejects JPEG in logo/icon queries (the live API returned an error for SuperTux). + for p in calls[1:]: + jpeg = 'image%2Fjpeg' in p + self.assertEqual(jpeg, p.startswith(('/grids/', '/heroes/')), p) + + def test_unicode_titles_match_exactly_and_symbols_never_match_all(self): + self.assertEqual(sgdb._name('ビートセイバー VR!'), 'ビートセイバーvr') + with patch.object(sgdb,'api_key',return_value='test-key'), \ + patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'},{'id':2,'name':'!!!'}]) as get: + self.assertEqual(sgdb.lookup('★★★'),({},[])) + get.assert_not_called() + self.assertEqual(sgdb.lookup('ビートセイバー'),({},[])) + with patch.object(sgdb,'api_key',return_value='test-key'), \ + patch.object(sgdb,'_get',side_effect=AttributeError("'list' object has no attribute 'get'")): + self.assertEqual(sgdb.lookup('Game')[0],{}) + + def test_wrong_title_and_failed_lookup_fall_back(self): + with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'}]): + self.assertEqual(sgdb.lookup('Game'),({},[])) + with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=OSError('private-secret')): + result=sgdb.lookup('Game') + self.assertNotIn('private-secret',str(result));self.assertEqual(result[0],{}) + + def test_settings_never_return_key(self): + with tempfile.TemporaryDirectory() as root, patch.object(sgdb,'settings_path',return_value=Path(root)/'settings.json'), \ + patch.dict(sgdb.os.environ,{},clear=True): + result=sgdb.save_settings({'steamgriddb_api_key':'secret-fixture'}) + self.assertTrue(result['steamgriddb_configured']) + self.assertNotIn('secret-fixture',json.dumps(result)) + self.assertEqual(sgdb.api_key(),'secret-fixture') + if sys.platform!='win32':self.assertEqual((Path(root)/'settings.json').stat().st_mode&0o777,0o600) + sgdb.save_settings({'steamgriddb_api_key':''}) + self.assertFalse(sgdb.settings()['steamgriddb_configured']) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py index 2487768..35c2852 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -238,6 +238,56 @@ class ServerGuards(unittest.TestCase): self.assertEqual(self.post("/api/nope", {})[0], 404) +class ArtworkSettings(unittest.TestCase): + """The settings panel's endpoints, with and without the page's X-Frame-UI key.""" + + def test_settings_need_and_accept_the_ui_key(self): + with tempfile.TemporaryDirectory() as home: + port = free_port() + env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1", + "HOME": home, "APPDATA": home, "XDG_DATA_HOME": home} + for name in ("STEAMGRIDDB_API_KEY", "FRAME_STEAMGRIDDB_API_KEY", "FRAME_UI_KEY"): + env.pop(name, None) + proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(port)], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + def request(method, path, body=None, headers=None): + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + conn.request(method, path, body=json.dumps(body).encode() if body is not None else None, + headers=headers or {}) + r = conn.getresponse() + payload = r.read() + conn.close() + return r.status, payload + for _ in range(100): + try: + if request("GET", "/")[0] == 200: + break + except OSError: + time.sleep(0.05) + key = {"X-Frame-UI": "1", "Content-Type": "application/json"} + self.assertEqual(request("GET", "/api/settings/artwork")[0], 403) + self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403) + status, payload = request("GET", "/api/settings/artwork", headers=key) + self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False)) + status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key) + self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, True)) + self.assertNotIn(b"abc_1", payload) + status, payload = request("GET", "/api/settings/artwork", headers=key) + self.assertTrue(json.loads(payload)["steamgriddb_configured"]) + self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "a b"}, key)[0], 400) + finally: + proc.terminate() + proc.wait(timeout=10) + + def test_panel_script_uses_the_keyed_api_helper(self): + script = (ROOT / "ui" / "artwork-settings.js").read_text(encoding="utf-8") + self.assertNotIn("fetch(", script) + self.assertIn("api('/api/settings/artwork'", script) + page = (ROOT / "ui" / "index.html").read_text(encoding="utf-8") + self.assertLess(page.index("async function api("), page.index(' + diff --git a/ui/server.py b/ui/server.py index e637d39..63fc0c2 100755 --- a/ui/server.py +++ b/ui/server.py @@ -42,8 +42,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import frame_agent # noqa: E402 import frame_assistant # noqa: E402 import frame_android # noqa: E402 +from apk_sources import search as apk_search, SourceError # noqa: E402 import frame_apk_versions # noqa: E402 import frame_catalog # noqa: E402 +import frame_steamgriddb import frame_comfort # noqa: E402 import frame_host # noqa: E402 import frame_macview # noqa: E402 @@ -162,7 +164,41 @@ _jobs_lock = threading.Lock() _jobs = {} # id -> {"label", "done", "error", "message", "result", "time"} -def start_job(label, work): +_backfill = {"running": False, "last": 0.0} +_backfill_lock = threading.Lock() + + +def backfill_art(apps=(), titles=()): + """Apply art Frame Control couldn't at install time (Steam wasn't running), once Steam is up. + + Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or + names the user may have customised. Runs in the background, at most once every five minutes.""" + pkgs = [a["package"] for a in apps if a.get("art_pending")] + gids = [t["id"] for t in titles if t.get("art_pending")] + with _backfill_lock: + if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300: + return False + _backfill.update(running=True, last=time.time()) + + def run(): + try: + frame_android.shortcut_tool("list") # Steam isn't up: try again on a later listing + for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \ + [(frame_titles.refresh_art, g) for g in gids]: + try: + refresh(key, fill_only=True) + except Exception as e: + print(f"artwork backfill for {key}: {e}", file=sys.stderr) + except Exception: + pass + finally: + with _backfill_lock: + _backfill["running"] = False + threading.Thread(target=run, daemon=True).start() + return True + + +def start_job(label, work, progress=False): """Run work() in the background. It returns a dict with a "message".""" now = time.time() with _jobs_lock: @@ -171,14 +207,20 @@ def start_job(label, work): job = secrets.token_hex(8) _jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now} + def report(stage, percent=None): + with _jobs_lock: + _jobs[job].update(stage=stage, percent=percent) + def run(): fields = {} try: - result = work() + result = work(report) if progress else work() fields = {"message": result.get("message") or f"{label}: done", "result": result} except (Failure, frame_android.FrameError) as e: fields = {"error": unreachable(str(e)) or str(e)} frame_telemetry.diagnostic(f"job {label.split()[0]}", e) + except SourceError as e: # already user-readable, and about a store, not the Frame + fields = {"error": str(e)} except Exception as e: fields = {"error": f"{type(e).__name__}: {e}"} frame_telemetry.diagnostic(f"job {label.split()[0]}", e) @@ -1116,6 +1158,14 @@ def android(body): return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m} return start_job(f"Install {pkg}", work) + if action == "refresh-art": + if not pkg and not body.get("all"): + raise Failure('choose a package or all apps', 400) + if not body.get('all'): + return start_job('Refresh Steam artwork', lambda: {'apps': [frame_android.refresh_art(pkg)]}) + # Everything Frame Control sideloaded: Android apps and devkit titles. + return start_job('Refresh Steam artwork', lambda: { + 'apps': frame_android.refresh_art(), 'titles': frame_titles.refresh_art()}) if action in ("launch", "stop"): m = getattr(frame_android, action)(pkg) return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"} @@ -1260,12 +1310,14 @@ def titles(body): threading.Thread(target=_run_title_install, daemon=True, args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start() return {"message": f"Installing {entry['plan']['source']}", "job": token} - if action not in ("launch", "remove"): + if action not in ("launch", "remove", "refresh-art"): raise Failure("unknown action", 400) gid = str(body.get("id", "")) if not frame_titles.ID_RE.match(gid): raise Failure("bad title id", 400) ensure_master() + if action == "refresh-art": + return start_job(f"Steam artwork for {gid}", lambda: {"titles": [frame_titles.refresh_art(gid)]}) try: m = getattr(frame_titles, action)(gid) except frame_android.FrameError as e: @@ -1950,9 +2002,84 @@ def agent_approval(body): return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept")) -POST = {"/api/vr": vr, "/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval, - "/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, - "/api/input": remote_input, "/api/touch": remote_touch, +def source_text(body, key, optional=False): + value = body.get(key) + if optional and value in (None, ''): + return None + if not isinstance(value, str) or not value.strip() or len(value) > 2000: + raise Failure('Provide a valid ' + key, 400) + return value.strip() + + +def source_search(query): + args = parse_qs(query) + q = args.get('q', [''])[0] + vr = args.get('vr', [''])[0] + installable = args.get('installable', [''])[0] + if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'): + raise Failure('Invalid search filters', 400) + try: + return apk_search.search(q, vr=None if not vr else vr in ('true', '1'), + source=args.get('source', [None])[0], installable=installable in ('true', '1')) + except SourceError as e: + raise Failure(str(e), 400) + + +def source_install(body): + source, entry = source_text(body, 'source'), source_text(body, 'id') + version = body.get('version_code') + if version is not None and (type(version) is not int or version < 0): + raise Failure('version_code must be a non-negative integer', 400) + try: + _, selected = apk_search.resolve(source) + if not selected['enabled']: + raise SourceError('This source is disabled') + except SourceError as e: + raise Failure(str(e), 400) + return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True) + + +def source_manage(body): + action = body.get('action') + try: + if action == 'enable': + if type(body.get('enabled')) is not bool: + raise Failure('enabled must be true or false', 400) + return apk_search.set_enabled(source_text(body, 'source'), body['enabled']) + if action == 'add': + url = source_text(body, 'url') + fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True) + parts = urlparse(url) + if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username: + raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400) + apk_search.repo_module() # fail now if this build can't manage repositories + + def add(): # downloads and verifies the whole index: a job, not a request + source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name) + message = 'Added ' + source['name'] + if source.get('trust_on_first_use'): + message += '. Trusted on first use: ' + source['fingerprint'].upper() + return {'message': message, 'source': {k: source.get(k) for k in + ('id', 'name', 'fingerprint', 'trust_on_first_use')}} + return start_job('Add repository', add) + if action == 'game-data': + package = source_text(body, 'package') + return start_job('Add game data', lambda: apk_search.add_game_data(package)) + if action == 'remove': + apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source')) + return {'message': 'Repository removed'} + raise Failure('Unknown source action', 400) + except SourceError as e: + raise Failure(str(e), 400) + + +POST = { + "/api/vr": vr, + "/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, + "/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat, + "/api/input": remote_input, "/api/touch": remote_touch, + "/api/settings/artwork": frame_steamgriddb.save_settings, + "/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots, "/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start, "/api/webinstall/cancel": webinstall_cancel, @@ -2064,6 +2191,10 @@ class Handler(BaseHTTPRequestHandler): try: if path in ("/", "/index.html"): self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8") + elif path == "/api/settings/artwork": + self.send_json(frame_steamgriddb.settings()) + elif path == "/artwork-settings.js": + self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8') elif path == "/assistant": page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c")) self.send_bytes(page.encode(), "text/html; charset=utf-8") @@ -2074,14 +2205,35 @@ class Handler(BaseHTTPRequestHandler): self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else {"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER, "computer": "Mac" if frame_host.MAC else "PC"}) + elif path.startswith("/source-image/"): + from apk_sources import _images + try: + self.send_bytes(*_images.image(path.rsplit("/", 1)[-1])) + except Exception: + self.send_json({"error": "Artwork unavailable"}, 404) + elif path == "/api/sources/details": + args = parse_qs(url.query) + try: + self.send_json(apk_search.details(source_text({k: v[0] for k, v in args.items()}, "source"), + source_text({k: v[0] for k, v in args.items()}, "id"))) + except SourceError as e: + raise Failure(str(e), 400) + elif path == "/api/sources": + self.send_json({"sources": apk_search.sources()}) + elif path == "/api/search": + self.send_json(source_search(url.query)) elif path == "/api/apk-versions": self.send_json(apk_versions(url.query)) elif path == "/api/android": ensure_master() - self.send_json({"apps": frame_android.list_apps()}) + apps = frame_android.list_apps() + backfill_art(apps=apps) + self.send_json({"apps": apps}) elif path == "/api/titles": ensure_master() - self.send_json({"titles": frame_titles.list_titles()}) + titles_list = frame_titles.list_titles() + backfill_art(titles=titles_list) + self.send_json({"titles": titles_list}) elif path == "/api/titles/job": self.send_json(title_job(url.query)) elif path == "/api/licenses": @@ -2323,6 +2475,7 @@ def main(): args = ap.parse_args() httpd = LoopbackServer(("127.0.0.1", args.port), Handler) sweep_tmp() + threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search frame_telemetry.start() if not frame_host.WINDOWS: signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))