Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448720d8d6 | ||
|
|
5aea46afd0 | ||
|
|
8fca0fe0a2 | ||
|
|
6d3cde64fe | ||
|
|
3f280ba59a | ||
|
|
83fa5c6c5b | ||
|
|
3ef7312654 | ||
|
|
70e7f7f5e5 | ||
|
|
2bd86207c7 | ||
|
|
a38d0cda6e | ||
|
|
e702adbd77 | ||
|
|
2c8e2fb2a8 | ||
|
|
d18f1655be | ||
|
|
f81c98a87b | ||
|
|
cb05395280 | ||
|
|
3b36feff52 | ||
|
|
1b5f540a66 | ||
|
|
47266afe85 | ||
|
|
b9714fda45 | ||
|
|
a045f5479f | ||
|
|
2f9ddeea76 | ||
|
|
4589221661 | ||
|
|
7af5916378 | ||
|
|
1b39fc1718 | ||
|
|
2df0f0e32a | ||
|
|
d7cb967786 | ||
|
|
f7bc2a8f68 | ||
|
|
b5caee5b86 | ||
|
|
7990553620 | ||
|
|
6c1ece1b62 | ||
|
|
fde2f9620a | ||
|
|
f0db805d4b | ||
|
|
baefa105a9 | ||
|
|
70897cfd1d | ||
|
|
060801c674 | ||
|
|
829897087a | ||
|
|
a97e8a6183 | ||
|
|
08c306c3a6 | ||
|
|
8315c7c3aa | ||
|
|
6a63a5a597 | ||
|
|
3fb541dce7 | ||
|
|
91c5853627 | ||
|
|
72352c5914 | ||
|
|
6c41a341e5 | ||
|
|
a7261b1601 | ||
|
|
4fd8bb79af | ||
|
|
5ac109c381 | ||
|
|
f695f398de | ||
|
|
5000fa4147 | ||
|
|
9b0fedf602 | ||
|
|
a9d78679ec | ||
|
|
ddcf3b2ad2 | ||
|
|
029c92bccb | ||
|
|
7c439fdf28 | ||
|
|
c68afa6c5d | ||
|
|
328b7ed211 | ||
|
|
3149a6e269 | ||
|
|
ea73145fbc | ||
|
|
69f790b83a | ||
|
|
f10b5fe159 | ||
|
|
7e2228b15e | ||
|
|
a24d27013c | ||
|
|
c5b5930582 | ||
|
|
70a0bd0d38 | ||
|
|
7844577be8 | ||
|
|
08037ab3f5 | ||
|
|
41684e2d90 | ||
|
|
c06b3285f2 | ||
|
|
784a48f218 | ||
|
|
f4dbb1180c | ||
|
|
f1b12a6eb6 | ||
|
|
113216cc0e |
No files matched your search
@@ -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
|
||||
@@ -35,8 +35,8 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**👓 Headset view**<br>
|
||||
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
|
||||
**👓 Headset view and Desktop**<br>
|
||||
Live video of what the lenses show, or of the app panel in use, flat and still however the wearer looks around. Turn on Control and tap or click right on it to use the Frame from your phone or computer.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -99,7 +99,9 @@ Frame Control worker into your headset user account.
|
||||
|
||||
For the other features, nothing is installed on the Frame: the app uses what SteamOS
|
||||
already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
|
||||
`~/devkit-utils`, as Valve's Devkit Client does). The optional
|
||||
[performance HUD](docs/vr-utilities.md) copies our own Python helpers into
|
||||
`~/.local/share/frame-control/vr/`. [How each feature works](docs/frame-control.md).
|
||||
|
||||
## Install
|
||||
|
||||
@@ -213,7 +215,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -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 <apk>` 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.
|
||||
@@ -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.
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"tested_head": "be1ab129c9b8663be8d4cd9d5aea0a4c587a16ba",
|
||||
"date": "2026-09-29",
|
||||
"steam_os_build": "20260925.6191901",
|
||||
"battery_percent_start": 100,
|
||||
"battery_percent_end": 100,
|
||||
"activity_level": 3,
|
||||
"warning_to_home_seconds": 60.76079535484314,
|
||||
"after": {
|
||||
"path": "/routes/library/home",
|
||||
"visible": true,
|
||||
"apps": []
|
||||
},
|
||||
"session": {
|
||||
"id": "2f03a8c2eb88482ca28b10246ace496b",
|
||||
"boot": "6c5ddc81-747f-44e5-b8e0-00e0f13fd08e",
|
||||
"active": false,
|
||||
"options": {
|
||||
"minutes": 1,
|
||||
"breakMinutes": 1,
|
||||
"stillMinutes": 1,
|
||||
"batteryAlert": true,
|
||||
"heatAlert": true
|
||||
},
|
||||
"started": 81160.254818568,
|
||||
"deadline": 81220.254818568,
|
||||
"lastSample": 81230.770869338,
|
||||
"used": 0,
|
||||
"nextBreak": 60,
|
||||
"stillSent": false,
|
||||
"warned": 81170.389722976,
|
||||
"events": [
|
||||
{
|
||||
"id": "2f03a8c2eb88482ca28b10246ace496b:1",
|
||||
"kind": "started",
|
||||
"message": "Session started. Steam Home opens at the limit; games are not closed.",
|
||||
"time": 1790676762.9003663
|
||||
},
|
||||
{
|
||||
"id": "2f03a8c2eb88482ca28b10246ace496b:2",
|
||||
"kind": "warning",
|
||||
"message": "One minute left. Save your progress; Steam Home will open.",
|
||||
"time": 1790676773.0352101
|
||||
},
|
||||
{
|
||||
"id": "2f03a8c2eb88482ca28b10246ace496b:3",
|
||||
"kind": "finished",
|
||||
"message": "Session ended: Steam Home opened. Your game is still running.",
|
||||
"time": 1790676833.7960055
|
||||
}
|
||||
],
|
||||
"seq": 3,
|
||||
"latched": [],
|
||||
"error": null,
|
||||
"time": 1790676838.9175165,
|
||||
"remaining": 0,
|
||||
"heartbeat": 81230.770869338,
|
||||
"unavailable": [],
|
||||
"activity": 3
|
||||
},
|
||||
"cleanup": {
|
||||
"prior_page_restored": true,
|
||||
"prior_dashboard_visibility_restored": true,
|
||||
"worker_exited": true,
|
||||
"temporary_simulator_key_removed": true,
|
||||
"phone_server_exited": true,
|
||||
"lock_released": true,
|
||||
"owned_simulator_shutdown": true
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
# Family and comfort: locked real-Frame verification
|
||||
|
||||
**Verified 2026-09-29**, tested feature source at `be1ab12`, SteamOS 0.4.1 build
|
||||
`20260925.6191901`, iOS 26.5 Simulator. The test acquired
|
||||
`/tmp/frame-test.lock` before deploying or launching anything.
|
||||
|
||||
The Simulator installed the bundled server in the Frame user account, connected
|
||||
over its SSH tunnel, requested notification permission, displayed a native test
|
||||
banner, and started a one-minute session through the shared UI API. The real
|
||||
session warning reached the Simulator as a native banner. Steam Home opened
|
||||
**60.76 seconds after the successful warning**. The running-app list was empty
|
||||
before and after; this run does not prove preservation of a running game.
|
||||
|
||||

|
||||
|
||||
[Recorded session and cleanup result](comfort-session-20260929.json).
|
||||
Local raw logs and headset/Simulator captures are retained at
|
||||
`/tmp/frame-comfort-evidence/locked-run/` on the verification Mac.
|
||||
|
||||
**Verified cleanup:** previous page and hidden-dashboard state restored; comfort
|
||||
worker and phone server exited; temporary Simulator SSH key removed; test lock
|
||||
released; only the owned Simulator shut down. Battery stayed at 100%. No global
|
||||
settings, power state, Steam or SteamVR lifecycle changes were made.
|
||||
|
||||
**Unverified while unworn:** SteamVR activity was 3 (standby), so active-use
|
||||
break/check-in clocks stayed at zero. Headset captures returned blank standby
|
||||
frames. The casting shortcut was invoked, but this run does not establish
|
||||
visible output or frame rate. No low-battery or overheating condition was
|
||||
induced. Earlier worn/active-device results remain separately documented in
|
||||
[Family and comfort](../family-comfort.md).
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"os": {
|
||||
"version": "0.4.1",
|
||||
"build": "20260925.6191901",
|
||||
"variant": "vr"
|
||||
},
|
||||
"performance": {
|
||||
"compositorFps": 72.0,
|
||||
"frameMs": 13.89,
|
||||
"appFps": null,
|
||||
"gpuMs": 3.07,
|
||||
"compositorCpuMs": 0.61,
|
||||
"cpuPercent": 40.6,
|
||||
"gpuMHz": 903.0
|
||||
},
|
||||
"batteryPercent": 16,
|
||||
"maxTempC": 73.5,
|
||||
"ownership": [
|
||||
{
|
||||
"id": 1009850,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 1173510,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 1068820,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 908520,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
},
|
||||
{
|
||||
"id": 1494460,
|
||||
"owned": false,
|
||||
"installed": false,
|
||||
"frame": 0
|
||||
}
|
||||
],
|
||||
"hudLifecycle": "open, duplicate-open, close passed before control-test pause; overlay probe removal verified",
|
||||
"comfort": "Initial 1cm seated probe restored exactly. Later commits/readback disagreed while Frame in use; Alex paused control tests. No controls shipped."
|
||||
}
|
||||
|
After Width: | Height: | Size: 269 KiB |
@@ -0,0 +1,58 @@
|
||||
# Independent review attempts — 2026-09-28
|
||||
|
||||
Target: the implementation and evidence in
|
||||
[3fb541d](https://github.com/saphid/frame-control/commit/3fb541d) and
|
||||
[6a63a5a](https://github.com/saphid/frame-control/commit/6a63a5a), supplied as a
|
||||
frozen diff before those commits were made. No executable code changed after
|
||||
the final review snapshot.
|
||||
|
||||
Requested model: **SWE-2 Max**, explicitly selected with `--model swe-2-max`.
|
||||
No completed verdict or model self-identification was returned. This is not a
|
||||
passed review, and there is no "no actionable findings" claim.
|
||||
|
||||
## First attempt
|
||||
|
||||
Launcher:
|
||||
|
||||
```sh
|
||||
devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-prompt.txt
|
||||
```
|
||||
|
||||
The prompt required read-only review, no delegation, no edits and no Frame
|
||||
access. The reviewer inspected surrounding code, then stopped while checking
|
||||
the public OpenVR header. The tool runner reported:
|
||||
|
||||
> warning: rejected a tool call that requires confirmation. Running in non-interactive mode.
|
||||
|
||||
The real launcher exit status was **0**, but no verdict was returned. A zero
|
||||
process status here is not evidence that the review completed.
|
||||
|
||||
## Tool-free retry
|
||||
|
||||
Launcher:
|
||||
|
||||
```sh
|
||||
devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-final-prompt.txt
|
||||
```
|
||||
|
||||
The self-contained prompt supplied the complete frozen changes, surrounding
|
||||
code, standards and locally fetched authoritative OpenVR header excerpts. It
|
||||
explicitly prohibited tools, edits, delegation and device access. This avoided
|
||||
the first attempt's permission boundary without escalating permissions.
|
||||
|
||||
No output or verdict arrived within the fifteen-minute review window. The
|
||||
process was sent SIGTERM at 918 seconds; the shell recorded real exit status
|
||||
**143**. Findings are unavailable. Review must be completed before considering
|
||||
this partial draft ready; playspace feasibility is also still paused.
|
||||
|
||||
## Other validation
|
||||
|
||||
- 166 Python unit tests passed locally.
|
||||
- 8 website tests and UI JavaScript syntax passed locally.
|
||||
- Desktop and phone-width attached-preview checks passed using live telemetry;
|
||||
paid optional install buttons were disabled, and unavailable metrics cleared.
|
||||
- [Fake-Frame CI](https://github.com/saphid/frame-control/actions/runs/36423548487/job/108931878908)
|
||||
passed, as did Windows/Linux server tests and the main checks job. Docker was
|
||||
unavailable locally. macOS/iOS jobs were still queued at this handoff.
|
||||
- Real-device evidence and the paused-control limitation are in
|
||||
[VR utilities](../../vr-utilities.md).
|
||||
@@ -40,6 +40,8 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
|
||||
| **SteamOS updates arrive on their own.** The Frame went from 0.3.0 (build 20260922.6101926) to **0.4.1, build 20260925.6191901**, between 2026-09-27 and 2026-09-28 with no action from us; `~` (keys, user Flatpaks, `~/.local/share`) survived. **Verified 2026-09-28.** | Keep changes in `~` |
|
||||
| **Valve's package repository has more than the image.** `pacman -Si` / `pacman -Sp` work as `steamos` without root and list Valve's own builds, such as `kdeconnect` 24.02.2 and `python-evdev` 1.7.0 in `extra`. Unpacking those packages into `~` runs them without touching the read-only root. The repository URLs say not to share them, so never write them down; Valve also publishes each build's source package there (`sources/packages/`), which is how Frame Control got the complete source for the KDE Connect it ships. **Verified 2026-09-28**, SteamOS 0.4.1. | [streaming.md](streaming.md#input-type-and-point-in-the-frame-from-the-mac-or-iphone) |
|
||||
| **gamescope has its own input injection.** An EIS socket at `/run/user/1000/gamescope-0-ei` (libei 1.4.1 is on the image) offers "Gamescope Virtual Input": relative and absolute pointer, buttons, scroll, keyboard. It drives the panel that has focus in the headset, on either X display. Focus moves only with the controller's laser (or to a new panel when none has it); `gamescopectl focus_info` prints the focus state to the journal. **Verified 2026-09-29.** | [streaming.md](streaming.md#live-view-and-control-watch-a-panel-and-tap-on-it) |
|
||||
| **A panel's own pixels:** `ffmpeg -f x11grab -window_id <window> -i :<display>` captures one window (x11grab of the root is black under gamescope). Panels live on `:0` (Steam's UI, windows tagged by `panel-on-frame.sh`) or `:1` (apps Steam starts). **Verified 2026-09-29.** | Frame Control's Desktop view |
|
||||
| **gamescope runs two Xwayland displays.** `:0` holds Steam's VR bar and menus (`valve.steam.gamepadui.*`) and ignores XTest pointer motion; `:1` holds apps such as Chromium and takes it. There's also a libei socket, `/run/user/1000/gamescope-0-ei`. **Verified 2026-09-28**, SteamOS 0.4.1. | Keyboard and trackpad |
|
||||
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
|
||||
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -468,3 +468,18 @@ versus on, medians of the runs, ms):
|
||||
window to the front first.
|
||||
- Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired
|
||||
with the Frame.
|
||||
|
||||
## Switching panels and workspace limits
|
||||
|
||||
**Tools → Panel switcher** lists open SteamVR panels, including Mac viewers.
|
||||
Use **Show** to request focus or **Open in headset** for Frame Control's own
|
||||
switcher panel. It uses SteamVR/gamescope and Chromium, with no third-party
|
||||
overlay app. [Device checks and limits](panels.md#frame-controls-panel-switcher)
|
||||
include the difference between a panel surviving a scene launch and staying
|
||||
visible over it.
|
||||
|
||||
Saved spatial layouts are blocked on this build: the public OpenVR transform
|
||||
setter denies access to gamescope-owned panels. Reconnecting an existing viewer
|
||||
is supported; restoring its room position after a reboot is not. We do not
|
||||
save short-lived Mac window IDs or viewer access keys as if they were a durable
|
||||
workspace. See [the feasibility evidence](panels.md#saved-spatial-layouts-blocked-on-the-current-panel-route).
|
||||
@@ -121,8 +121,165 @@ a limit on the number of floating panels.
|
||||
script needed.
|
||||
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
||||
- **Optional overlay tools:** Desktop+, OVR Toolkit and similar software are
|
||||
separate from Frame Control. Public reports describe some Proton support;
|
||||
Windows-only does not by itself prove a Frame app cannot run. Local status
|
||||
and sources are in [VR utilities](vr-utilities.md).
|
||||
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
|
||||
headset creates its own gamescope panel using built-in tools. It needs no
|
||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||
|
||||
## Frame Control's panel switcher
|
||||
|
||||
**Verified 2026-09-28**, SteamOS 0.4.1, BUILD_ID `20260925.6191901`,
|
||||
SteamVR 2.18.1: **Tools → Panel switcher** lists SteamVR's open main panels,
|
||||
including panels that are currently hidden. **Show** asks SteamVR to bring one
|
||||
forward. **Open in headset** opens the same switcher as its own panel; choose
|
||||
it again from Steam's dashboard after switching away. Refresh updates the list.
|
||||
This is a list, not thumbnail Exposé.
|
||||
|
||||

|
||||
|
||||
This is our own Python/HTML implementation (`ui/frame_panels.py`), using the
|
||||
Frame's shipped `vrcmd` OpenVR client and gamescope. The headset page uses
|
||||
Chromium (Chromium XR when present, then system Chromium, then the existing
|
||||
Chromium Flatpak). No XSOverlay, OVR Toolkit, WayVR or other overlay application
|
||||
is needed. This dependency boundary also applies to future layout and panel
|
||||
persistence work: platform APIs and bundled libraries are fine; another app
|
||||
must not implement the feature for us.
|
||||
|
||||
The companion runs the helper over SSH. Opening it in the headset installs a
|
||||
copy under `~/.local/share/frame-control/panels/` and starts a loopback HTTP
|
||||
server and an isolated Chromium profile. There is no startup service or global
|
||||
setting change. Close the switcher to stop its server and browser. Other
|
||||
Chromium profiles, Steam and SteamVR are left alone. If the window or runtime
|
||||
closes, use **Open in headset** again.
|
||||
|
||||
The page carries a random, per-process access key in its URL fragment, removes
|
||||
it from the address bar, keeps it in tab session storage for page reloads, and
|
||||
sends it in a header. Panel lists and actions need
|
||||
that key; Host and Origin checks reject other sites. The key permits only
|
||||
listing panels, requesting focus and closing this switcher. Like Mac viewer
|
||||
launch tickets, it is initially readable by another process running as the
|
||||
same Frame user. Panel titles are rendered as text, never HTML. The companion
|
||||
retains its existing request guards. No Mac capture credentials cross this API.
|
||||
|
||||
**Verified:** the real headset page rendered its panel list (image above), its
|
||||
HTTP focus request changed `GAMESCOPE_FOCUSED_APP` to `2000999030`, a request
|
||||
without the key returned HTTP 403, and Close stopped the helper and its browser.
|
||||
Opening an already running switcher requests its focus rather than creating a
|
||||
second one. The companion uses the same list/focus helper. **Unverified:** laser
|
||||
selection while wearing the headset, physical placement, and non-XR Chromium.
|
||||
The API reports that focus was *requested*: another action can take focus before
|
||||
we observe the result. Closed panels are rejected after re-enumeration.
|
||||
|
||||
### Shared-device recheck, 2026-09-29
|
||||
|
||||
**Verified:** the follow-up's atomic `mkdir /tmp/frame-test.lock` attempts
|
||||
failed because another thread held the lock. The existing lock was left alone;
|
||||
no applications were installed, launched or stopped in this follow-up. The last
|
||||
read-only battery check showed 62%, charging. The 180 Python and 8 website tests
|
||||
passed again locally.
|
||||
|
||||
**Unverified in this follow-up:** the prepared browser-button test (Refresh,
|
||||
selection, reload and Close) and repeated OpenXR transition could not run under
|
||||
the shared lock. The device results elsewhere in this page are the earlier
|
||||
2026-09-28 observations, not results from this blocked recheck. In particular,
|
||||
HTTP focus is not evidence of worn-headset laser input. Follow the
|
||||
[shared-device test procedure](testing.md#headset-smoke-test) for the next run.
|
||||
|
||||
## Saved spatial layouts: blocked on the current panel route
|
||||
|
||||
**Verified 2026-09-28**, same build, using a temporary xterm panel with
|
||||
`STEAM_GAME=2000999031` and `FnTable:IVROverlay_028` from
|
||||
`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`:
|
||||
|
||||
| OpenVR call | Result |
|
||||
|---|---|
|
||||
| `FindOverlay("valve.steam.desktopgame.2000999031")` | Success |
|
||||
| `GetOverlayWidthInMeters` | Success, 2.67 m |
|
||||
| `SetOverlayWidthInMeters` (same width) | Success |
|
||||
| `GetOverlayTransformType` | Success, type 5 (`VROverlayTransform_DashboardTab`) |
|
||||
| `GetOverlayTransformAbsolute` | 18 (`WrongTransformType`) |
|
||||
| `SetOverlayTransformAbsolute` (identity rotation, 1.2 m up, 1.5 m forward) | 12 (`PermissionDenied`); type remained 5 |
|
||||
|
||||
The public interface names type 5 **DashboardTab**; SteamVR's dashboard code
|
||||
places these panels through its scene graph. It owns the frame/docking
|
||||
transforms. A successful width setter does not grant permission to restore the
|
||||
position. `vrcmd --dock-overlay world <key>` dispatched a docking request but
|
||||
the dashboard logged `Failed to get SGTransform in setInitialTransformForLocation.
|
||||
Invalid transform ID`. This does not establish working world placement.
|
||||
|
||||
**Inferred:** saving X11 pixel rectangles or Mac window IDs would not restore
|
||||
this spatial arrangement. Mac window IDs also change when an application
|
||||
reopens; viewer tickets and reconnect keys must not go into a layout file.
|
||||
The base Mac stream reconnects after a network break, but that is different
|
||||
from recreating windows and their room positions after a reboot.
|
||||
|
||||
There is consequently no Save/Restore control yet. A durable layout needs a
|
||||
working transform restore path, stable source identity, and a fresh capture
|
||||
permission/ticket flow. The tested gamescope-owned overlay route denies that
|
||||
transform operation. A future Frame Control-owned overlay renderer, or a
|
||||
supported platform API for dashboard frame transforms, needs its own device
|
||||
proof before building layout UI. This is a blocker for the current approach,
|
||||
not a claim that all possible implementations are impossible. Reboot recovery
|
||||
was not tested: the shared headset was not rebooted.
|
||||
|
||||
## Panels during an immersive session
|
||||
|
||||
**Verified 2026-09-28**, same build: our Chromium switcher panel remained in
|
||||
OpenVR's overlay list before, during and after the Frame's shipped `helloxr -g
|
||||
Vulkan` sample. During the test `vrcmd --stats` identified
|
||||
`system.generated.openxr.helloxr.helloxr`, with 242 frame submissions. The test
|
||||
ended only its own sample process; no SteamVR, Steam, power or global settings
|
||||
were changed. The switcher was still selectable afterwards.
|
||||
|
||||
This proves survival of that panel across an OpenXR scene session, **not** that
|
||||
it stayed visibly composited over the scene: OpenVR reported it `not_visible`
|
||||
before, during and after. **Verified:** calling `ShowOverlay` on our
|
||||
*gamescope-owned* switcher overlay returns 12 (`PermissionDenied`). A helper
|
||||
cannot force that panel visible using the public overlay call. Use the
|
||||
switcher/dashboard to request access to it; we do not fight the runtime with a
|
||||
repeated force-focus loop.
|
||||
|
||||
**Verified in a second controlled run:** a live H.264 test-pattern stream from
|
||||
this checkout's Mac helper, through its own SSH tunnel and a temporary Chromium
|
||||
profile, survived the same OpenXR sample (257 scene-frame submissions). Its
|
||||
panel `2000999032` changed from `visible` before launch to `not_visible` during
|
||||
and after the scene. The Mac helper still reported the same `test` stream;
|
||||
captured frames increased from 35 to 232, with 29.5 decoded/drawn fps afterwards.
|
||||
The test did not capture personal Mac windows or inject Mac input. The sample,
|
||||
viewer, temporary profile, tunnel and Mac helper were cleaned up. This proves
|
||||
stream survival, and also shows why it must not be advertised as always visible.
|
||||
|
||||
**Unverified:** persistent visible placement while playing a Steam-launched VR
|
||||
game, Plasma desktop and real Mac-window behavior during that launch, and worn
|
||||
headset input. Other threads were launching games and changing the runtime on
|
||||
the shared device, so those transitions were not treated as controlled evidence.
|
||||
A runtime/X-server restart can destroy the viewer windows; a network reconnect
|
||||
cannot recreate them. No “always visible during games” guarantee is shipped.
|
||||
|
||||
## Keyboard passthrough feasibility
|
||||
|
||||
**Verified 2026-09-28**, same build, using `FnTable:IVRTrackedCamera_006`:
|
||||
`HasCamera(0)` returned success and true. `GetCameraFrameSize` returned 100
|
||||
(`OperationFailed`), with zero dimensions, for all three public frame types
|
||||
(distorted, undistorted and maximum-undistorted), including after acquiring the
|
||||
video service. Acquisition returned success and a handle; release returned 101
|
||||
(`InvalidHandle`). The probe shut down its OpenVR client afterwards. No camera
|
||||
frames were captured and no camera settings were changed.
|
||||
|
||||
**Documented:** the public OpenVR camera interface provides camera frame sizes,
|
||||
intrinsics, projections and streaming handles; these are prerequisites for a
|
||||
spatially aligned camera cutout. See Valve's
|
||||
[OpenVR C API](https://github.com/ValveSoftware/openvr/blob/master/headers/openvr_capi.h).
|
||||
|
||||
**Inferred:** camera presence alone does not establish access to camera pixels.
|
||||
The failed frame-size path blocks a keyboard cutout in our current panel
|
||||
implementation. We have not established a keyboard detector or a calibrated
|
||||
camera-to-panel mapping. Built-in full-room passthrough is not proof of a
|
||||
public, selectively masked camera stream. No keyboard cutout is offered, and
|
||||
no third-party camera/overlay app is substituted for it.
|
||||
|
||||
## Frame Control's media theatre
|
||||
|
||||
|
||||
@@ -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.<versionCode>.<package>.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/<package>/`. `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/<instance>/internal/<package>` is documented as `/data/data/<package>`;
|
||||
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/<instance>/internal/<package>`, 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
|
||||
`.<package>.before-restore-<timestamp>`; 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.
|
||||
@@ -110,3 +110,11 @@ returns nothing without `cc`, so Frame Control takes the country from
|
||||
- Installing when there's more than one library folder, such as a microSD card.
|
||||
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
||||
headset.
|
||||
|
||||
## Optional VR software
|
||||
|
||||
The [VR utilities list](vr-utilities.md#optional-software) is separate from our
|
||||
controls and HUD. It checks software ownership as well as games; paid utilities
|
||||
are installable only when already in the loaded Frame account library. No
|
||||
purchase flow is added. Public reports, local results and ownership are shown
|
||||
separately, and untested tools remain untested.
|
||||
@@ -8,6 +8,7 @@ This covers three directions, plus input:
|
||||
- **PC VR from Linux**: [feasibility and options](linux-vr-streaming.md),
|
||||
including Valve's streaming and USB support. No Linux host tested yet.
|
||||
- **Input**: type and point in the Frame from the Mac or iPhone.
|
||||
- **Live view and Control**: watch a panel flat and tap on it to use it.
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
@@ -184,13 +185,72 @@ on the Frame, which talks KDE Connect's own LAN protocol to the Frame's
|
||||
and ignores injected pointer motion; `:1` holds apps such as Chromium and
|
||||
takes it. KDE Connect runs on `:1`, so it reaches apps, not Steam's own menus.
|
||||
There's also a `gamescope-0-ei` (libei) socket.
|
||||
- **Not yet tested:** typing and clicking as seen in the headset, and whether
|
||||
it reaches the KDE desktop panel (Plasma is its own session).
|
||||
- Typing through KDE Connect lands in a Chromium panel on `:1` (seen in the
|
||||
panel's own capture, 2026-09-29). It **can't reach panels on `:0`** (Frame
|
||||
Control's own panels, Steam's UI) and, since XTest positions are clamped to
|
||||
`:1`'s 1280×720 root, can't reach beyond that in a bigger window. Control on
|
||||
the live view (below) has neither limit.
|
||||
- **Not yet tested:** whether it reaches the KDE desktop panel (Plasma is its
|
||||
own session).
|
||||
- **Known limit:** keys and clicks typed while the link is reconnecting wait
|
||||
and are sent once it's back, but anything sent in the moment the Wi-Fi
|
||||
drops, before SSH notices, can be lost. Confirming every event would add a
|
||||
round trip to each pointer move.
|
||||
|
||||
## Live view and Control: watch a panel and tap on it
|
||||
|
||||
**Built: Home → Desktop / Headset view → Control.** The live view has two
|
||||
sources:
|
||||
|
||||
- **Headset view**: what the lenses show (SteamVR's mirror, `/dev/video99`). It
|
||||
moves with the wearer's head, so Control makes the view a trackpad: drag to
|
||||
move the pointer, tap to click, press and hold to right-click, two fingers to
|
||||
scroll. With a mouse, moving over the view moves the pointer.
|
||||
- **Desktop**: the app panel in use in the headset, from its own window, so it
|
||||
stays still however the wearer looks around. Control makes taps and clicks
|
||||
land exactly where you put them. Dragging is a mouse drag, press and hold is a
|
||||
right-click, two fingers scroll, and on a computer the mouse, wheel and
|
||||
keyboard work directly on it (⌘ is sent as Ctrl on a Mac). A picker shows any
|
||||
other panel, view only.
|
||||
|
||||
Below the view, a text field and key buttons type on the Frame from a phone.
|
||||
|
||||
How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
|
||||
|
||||
- **Input goes through gamescope's own injection.** gamescope serves an EIS
|
||||
socket (`/run/user/1000/gamescope-0-ei`; Steam feeds Remote Play input through
|
||||
it), and `libei` 1.4.1 is on the image. [`ui/frame_touch.py`](../ui/frame_touch.py)
|
||||
talks to it with `ctypes`: nothing to install. gamescope offers one device,
|
||||
"Gamescope Virtual Input", with relative and absolute pointer, buttons,
|
||||
scroll and keyboard (Linux key codes; no text capability, so the text field
|
||||
types printable ASCII on a US layout). Its absolute region is unbounded; the
|
||||
pointer uses the focused panel's display coordinates, and gamescope fits each
|
||||
window to its display, so a 1920×1080 window on the 1280×720 `:1` takes
|
||||
positions at two thirds scale. Taps on a 1280×720 page landed on the exact
|
||||
pixel.
|
||||
- **It reaches the panel that has focus** (`GAMESCOPE_FOCUSED_WINDOW` on `:0`'s
|
||||
root), on either X display. In the OpenVR backend focus moves only on SteamVR
|
||||
overlay events (the controller's laser entering or clicking a panel), or to a
|
||||
new panel when none holds it (read from gamescope's `OpenVRBackend.cpp`, seen
|
||||
with `gamescopectl focus_info`, which writes to the journal). Neither
|
||||
`GAMESCOPECTRL_BASELAYER_WINDOW`/`_APPID` nor X focus moves it, and no
|
||||
gamescope command does. So Control follows the wearer: whatever they last
|
||||
used is what your taps reach. A window without a Steam app id (`STEAM_GAME`)
|
||||
gets a connector of its own and doesn't hold focus.
|
||||
- **Keys in a burst can arrive out of order**, so the helper paces them (8 ms
|
||||
apart).
|
||||
- **Known limit:** if focus moves to another panel in the middle of a drag, the
|
||||
release goes to the panel that has focus then. Whether gamescope hands it to
|
||||
the window that got the press isn't known yet. When the session ends, the
|
||||
helper lets go of every button and key it still holds.
|
||||
- **The Desktop picture is the window's own pixels**: `ffmpeg -f x11grab
|
||||
-window_id <window> -i :<display>` works on gamescope's redirected windows,
|
||||
while grabbing the root gives black. It streams as H.264 like the headset view
|
||||
(about 30 fps at 720p).
|
||||
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
|
||||
text box in the panel and the text field typed into it; a trackpad move went
|
||||
exactly (+40, +25).
|
||||
|
||||
Our own `uinput` keyboard and mouse would also work (`steamos` is in the
|
||||
`input` group and `/dev/uinput` is group-writable, verified 2026-09-27), and
|
||||
remains the fallback if the bundled KDE Connect ever stops working on a new SteamOS.
|
||||
|
||||
@@ -104,6 +104,20 @@ switch while SSH is down:
|
||||
|
||||
## Headset smoke test
|
||||
|
||||
**Documented shared-device procedure:** before a test installs, launches or
|
||||
stops an application, acquire `ssh frame 'mkdir /tmp/frame-test.lock'`. If it
|
||||
fails, leave that lock alone and continue offline work. Only the thread that
|
||||
acquired it releases it with `ssh frame 'rmdir /tmp/frame-test.lock'`, after
|
||||
cleanup. Keep each device session to a few minutes.
|
||||
|
||||
Check battery capacity and charging state under `/sys/class/power_supply`
|
||||
before and after; keep capacity above 20%. Stop only processes started by the
|
||||
test, remove temporary installs and profiles, and restore the prior dashboard
|
||||
state. Leave Steam and SteamVR running. Do not reboot or change global settings.
|
||||
Record the build, actual interaction results, cleanup and any unworn-headset
|
||||
limits alongside screenshots or logs. These are caller responsibilities; the
|
||||
smoke script below does not acquire this shared lock itself.
|
||||
|
||||
```sh
|
||||
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
|
||||
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
|
||||
@@ -179,3 +193,20 @@ late/suspended sessions, cancellation, failed actions, duplicate alerts, reboot
|
||||
invalidation, per-zone thermal trips and shared on-headset state. The server
|
||||
guards reject invalid session settings before SSH. See
|
||||
[real-device evidence and limits](family-comfort.md#verification).
|
||||
|
||||
## Panel switcher
|
||||
|
||||
`tests/test_panels.py` supplies fake-Frame `vrcmd --overlays` output, checks
|
||||
main-panel filtering (including hidden panels), revalidates closed panels before
|
||||
focus, and drives the headset helper's real loopback HTTP server to test access
|
||||
keys, Host/Origin guards, malformed requests, offline errors and Close. It runs
|
||||
in the normal unit suite without OpenVR or a headset. The fixture format comes
|
||||
from SteamVR 2.18.1, BUILD_ID `20260925.6191901`; it does not simulate rendering.
|
||||
|
||||
On the Frame, run `python3 -` over SSH with `ui/frame_panels.py` on stdin to
|
||||
list panels. `--focus <key>` rechecks the list and requests focus. In Frame
|
||||
Control, **Tools → Panel switcher → Open in headset** exercises installation,
|
||||
Chromium rendering and the same helper through HTTP. Close the switcher after
|
||||
testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
|
||||
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
|
||||
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
|
||||
@@ -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/<id>/`, 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/<id>/<exe>"`), 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
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# VR comfort and performance
|
||||
|
||||
Frame Control owns its HUD and telemetry. They use SteamVR/OpenVR, gamescope,
|
||||
Python and xterm already on the Frame, plus the Frame's sensors. No feature
|
||||
requires fpsVR, OVR Advanced Settings, XSOverlay or another third-party app.
|
||||
The software list is a separate, optional convenience.
|
||||
|
||||
This is **part of [#25](https://github.com/saphid/frame-control/issues/25)**,
|
||||
not completion of the issue. Playspace controls remain blocked on the device
|
||||
checks below. The PR stays draft.
|
||||
|
||||
## Our performance HUD
|
||||
|
||||
On **Home → VR comfort and performance**, the app shows a timestamped sample
|
||||
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
||||
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
||||
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
||||
it to a controller. **Close HUD**, or closing its terminal, ends it. Opening
|
||||
it twice reuses the existing process.
|
||||
|
||||
| Value | Meaning and source |
|
||||
|---|---|
|
||||
| Compositor FPS / period | Differences between two `IVRCompositor_029::GetFrameTiming` frame indices and monotonic compositor timestamps, sampled 200 ms apart. Output cadence, not game FPS or a long-term average. |
|
||||
| Application FPS | Reciprocal of OpenVR's client frame interval. Unavailable if there is no positive interval; not inferred from refresh rate. |
|
||||
| Render GPU time | OpenVR total render GPU milliseconds, not GPU utilisation. |
|
||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
||||
|
||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||
it running. This mode also returned live timing in a read-only device probe.
|
||||
|
||||
Missing sensors, a stopped or incompatible SteamVR runtime, and non-advancing
|
||||
frame indices display **Unavailable**, never invented zero FPS. Failed status
|
||||
refreshes clear the HUD card rather than keeping a stale live-looking sample.
|
||||
The HUD itself adds CPU/GPU work; it is a diagnostic, not a zero-overhead benchmark.
|
||||
|
||||
**Verified 2026-09-28**, SteamOS 0.4.1, build `20260925.6191901`, SteamVR
|
||||
2.18.1: the exact OpenVR interface and 192-byte timing layout returned advancing
|
||||
frame indices and live GPU/CPU timing. Sensor reads, creation of overlay
|
||||
`valve.steam.desktopgame.2000250025`, duplicate-open handling, and closing the
|
||||
HUD passed. The temporary probe overlay disappeared after its process closed.
|
||||
[Sanitized device sample](evidence/vr-utilities/device.json).
|
||||
|
||||
**Unverified:** visual placement while wearing the headset, controller docking,
|
||||
and overhead during gameplay. The companion card was checked in the attached
|
||||
preview using live Frame data. Creating an overlay does not establish that it
|
||||
was visible to the wearer.
|
||||
|
||||
The optional HUD copies only `frame_status.py` and `frame_vr.py` into
|
||||
`~/.local/share/frame-control/vr/`. It tags only the window whose X11 PID matches
|
||||
its own xterm, avoiding other threads' windows. Stop checks both PID and Linux
|
||||
process start time before sending SIGTERM. It does not stop Steam or SteamVR,
|
||||
edit their settings, install a service, or need sudo.
|
||||
|
||||
## Playspace, seated height and recenter: paused
|
||||
|
||||
**Verified 2026-09-28:** SteamVR exposes `IVRChaperoneSetup_006` and
|
||||
`IVRChaperone_004`. An initial 1 cm seated zero-pose translation committed,
|
||||
read back and restored numerically. A later trial, while the shared Frame was
|
||||
in use, changed universe IDs after commits and returned transforms that did
|
||||
not match the requested write or restore. Journal entries reported
|
||||
`CommitWorkingCopy`, `VREvent_ChaperoneUniverseHasChanged` and
|
||||
`VREvent_ChaperoneRoomSetupCommitted`. The collision-bound arrays and play-area
|
||||
size matched in the saved before/after records, but the origin matrices did not.
|
||||
|
||||
Alex confirmed the headset was in use and asked to pause control tests. No
|
||||
further playspace writes were made. The exploratory control implementation was
|
||||
removed from the shipping API; `recenter`, `adjust` and `restore` are rejected.
|
||||
This is an **unresolved feasibility check**, not evidence that OpenVR controls
|
||||
cannot work. Concurrent use and the Frame driver's coordinate-system handling
|
||||
still need to be separated.
|
||||
|
||||
The probe's original and last-read poses remain in the Frame's
|
||||
`~/.local/share/frame-control/vr/comfort.json` for investigation. This draft
|
||||
neither reads nor applies that baseline. Do not blindly replay it into a room
|
||||
that may have changed. No recenter test was reached in the later trial.
|
||||
|
||||
Before adding controls, on an idle Frame:
|
||||
|
||||
1. Establish current room and tracking state, and inspect the retained probe
|
||||
evidence before considering any restoration.
|
||||
2. Prove seated and standing height/move operations in the Frame driver's
|
||||
current coordinates, including delayed readback, coordinate rebasing and
|
||||
recovery. Show that the physical safety boundary stays correct.
|
||||
3. Verify recenter independently, and test a full apply/restore cycle plus a
|
||||
concurrent room-change refusal. Add a fake OpenVR test for those contracts.
|
||||
4. Check the apparent result in a seated and a standing app before exposing UI.
|
||||
|
||||
**Documented:** seated and standing are tracking origins selected by an app;
|
||||
changing a seated origin cannot force every game to support seated play.
|
||||
|
||||
**Inferred from the installed SteamVR defaults:** there is no generic snap-turn
|
||||
or locomotion-vignette setting. `dashboard.verticalOffsetCm_2` and
|
||||
`steamvr.panelMaskVignette` affect panels, not the player's height or game
|
||||
locomotion. The app gives game-setting hints for snap-turn, teleport movement
|
||||
and movement vignette instead of writing these unrelated settings.
|
||||
|
||||
## Optional software
|
||||
|
||||
**Verified 2026-09-28 (same build):** a read-only query of Steam's loaded
|
||||
`appStore.allApps` found none of these five apps on the Frame account. The query
|
||||
includes software, which the existing games-only library filter excludes.
|
||||
Steam's public app-details API listed only Desktop+ as free. No software was
|
||||
purchased, installed or launched during these ownership checks.
|
||||
|
||||
| Utility | Local Frame status | Public evidence / optional source |
|
||||
|---|---|---|
|
||||
| OVR Advanced Settings (1009850) | **Untested**, not owned | Steam edition is paid. Developer's [free source and releases](https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings). No verified Frame result in this work. |
|
||||
| XSOverlay (1173510) | **Untested**, not owned | Supplied research attributes Proton support with tweaks to [Road to VR](https://www.roadtovr.com/valve-steam-frame-review/). This is a public report, not our verification. |
|
||||
| OVR Toolkit (1068820) | **Untested**, not owned | Same [public Frame report](https://www.roadtovr.com/valve-steam-frame-review/); no local verification. |
|
||||
| fpsVR (908520) | **Untested**, not owned | [Steam listing](https://store.steampowered.com/app/908520/). No Frame-specific result established in the supplied research. General PC VR reviews do not verify Frame support. |
|
||||
| Desktop+ (1494460) | **Untested**, free | [Developer source](https://github.com/elvissteinjr/DesktopPlus). No verified Frame result in this work. |
|
||||
|
||||
**Documented (supplied research):** the XSOverlay/OVR Toolkit claims are kept as
|
||||
attributed leads. **Verified source check 2026-09-28:** fetching the linked
|
||||
review returned HTTP 200 and the expected review title, but neither utility name
|
||||
appeared in the fetched HTML or extracted text. The claims could not be
|
||||
corroborated from that page; this is not proof of incompatibility. They do not make those apps dependencies or mark them
|
||||
locally verified. No paid utility is auto-acquired. A server-side check blocks
|
||||
installation through the Steam endpoint if the paid utility is absent from the
|
||||
loaded Frame library; an empty/unavailable library fails closed. Desktop+ uses
|
||||
the existing free Steam install flow, which may need a license confirmation in
|
||||
the headset. None is advertised as known-good without local evidence.
|
||||
|
||||
Compatibility reports reuse the existing `compat-db` storage and validation
|
||||
with `package: "steam:<appid>"`, rather than colliding with Android package IDs.
|
||||
The optional list shows the latest `works`, `issues` or `broken` report with its
|
||||
date, build and notes, separately from public sources and ownership. With no
|
||||
report the status stays **untested**. No shared database schema change or
|
||||
production deployment is needed; no reports were published by this work.
|
||||
|
||||
## Validation and review
|
||||
|
||||
166 unit tests and 8 website tests passed locally. The new fake-Frame cases
|
||||
passed in GitHub CI (Docker was unavailable locally). Desktop and phone-width
|
||||
preview checks passed. The two independent review attempts did not produce a
|
||||
verdict; [commands, real exit statuses and limitations](evidence/vr-utilities/review.md).
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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;q<queue.length;q++) {
|
||||
const i=queue[q];if(seen[i])continue;seen[i]=1;
|
||||
if(Math.max(d[i*4],d[i*4+1],d[i*4+2])>24)continue;
|
||||
d[i*4+3]=0;
|
||||
if(i%w)queue.push(i-1);if(i%w<w-1)queue.push(i+1);
|
||||
if(i>=w)queue.push(i-w);if(i<w*(h-1))queue.push(i+w);
|
||||
}
|
||||
c.putImageData(pixels,0,0);icon=cut;
|
||||
}
|
||||
}
|
||||
// Quantized, saturated dominant colors avoid white/black icon margins.
|
||||
let colors = [[48,91,137], [24,36,63]];
|
||||
if (icon) {
|
||||
const sample = document.createElement('canvas'); sample.width=48; sample.height=48;
|
||||
const s=sample.getContext('2d'); s.drawImage(icon,0,0,48,48);
|
||||
const data=s.getImageData(0,0,48,48).data, bins=new Map();
|
||||
for (let i=0;i<data.length;i+=4) {
|
||||
const rgb=[data[i],data[i+1],data[i+2]], hi=Math.max(...rgb), lo=Math.min(...rgb);
|
||||
if (data[i+3]<150 || hi<45 || lo>220 || 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};
|
||||
}
|
||||
@@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < 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__)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -83,5 +83,27 @@ class Device(harness.FrameTestCase):
|
||||
self.assertEqual(state()['steam']['pages'][0]['title'], 'Hades on Steam')
|
||||
|
||||
|
||||
class VRUtilities(harness.FrameTestCase):
|
||||
def test_missing_vr_runtime_is_unavailable_not_zero_fps(self):
|
||||
data = ok('GET', '/api/status')
|
||||
self.assertIsNone(data['performance']['compositorFps'])
|
||||
self.assertIsNone(data['performance']['appFps'])
|
||||
self.assertEqual(data['temp'], 41.5)
|
||||
self.assertEqual(data['battery']['percent'], 76)
|
||||
|
||||
def test_optional_paid_utilities_are_not_installed(self):
|
||||
# The fake library contains games but none of these paid software titles.
|
||||
for appid in (1009850, 1173510, 1068820, 908520):
|
||||
code, body, _ = api('POST', '/api/steam', {'action': 'install', 'appid': appid})
|
||||
self.assertEqual(code, 502, body)
|
||||
self.assertIn('not owned', body['error'])
|
||||
self.assertEqual(launches('install'), [])
|
||||
|
||||
def test_unverified_controls_are_not_exposed(self):
|
||||
for action in ('recenter', 'adjust', 'restore'):
|
||||
code, body, _ = api('POST', '/api/vr', {'action': action, 'origin': 'seated', 'y': .1})
|
||||
self.assertEqual(code, 400, body)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
@@ -27,7 +27,7 @@ function newShortcutId(steam) {
|
||||
function build(steam) {
|
||||
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
||||
const gameOverview = a => ({
|
||||
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: 1,
|
||||
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: a.app_type ?? 1,
|
||||
steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only,
|
||||
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
|
||||
rt_last_time_played: a.last_played || 0,
|
||||
@@ -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)];
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 489 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 821 KiB |
@@ -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"
|
||||
}
|
||||
@@ -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}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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 <b>fixture</b> & 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"repo": {
|
||||
"name": {
|
||||
"en-US": "Fixture"
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"org.example.app": {
|
||||
"metadata": {
|
||||
"name": {
|
||||
"en-US": "Example"
|
||||
},
|
||||
"summary": {
|
||||
"en-US": "<p>Offline <b>fixture</b> & music.</p>\n<p>One\t line.</p><script>hidden()</script>"
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Fixture APK payload, deliberately not installable.
|
||||
@@ -0,0 +1 @@
|
||||
0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a
|
||||
@@ -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}}}}}
|
||||
@@ -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.
|
||||
@@ -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)});
|
||||
|
After Width: | Height: | Size: 834 B |
|
After Width: | Height: | Size: 77 B |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"home": "/home/steamos",
|
||||
"shortcuts": [
|
||||
{
|
||||
"appid": 3346865537,
|
||||
"name": "Open Saber Plus"
|
||||
}
|
||||
],
|
||||
"configure": {
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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:
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?><rss version="2.0"><channel><title>Top free games for Android tagged openxr - itch.io</title><link>https://itch.io/games/free/platform-android/tag-openxr</link><item><guid>https://leandrodreamer.itch.io/open-saber</guid><title>Open Saber [Free] [Rhythm] [Windows] [Linux] [Android]</title><plainTitle>Open Saber</plainTitle><imageurl>https://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif</imageurl><price>$0.00</price><currency>USD</currency><link>https://leandrodreamer.itch.io/open-saber</link><description><![CDATA[Open Source Rythmic Block Cutting Game :)
|
||||
<img src="https://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif" alt="Open Saber"/>]]></description><pubDate>Thu, 07 Sep 2023 02:11:50 GMT</pubDate><createDate>Thu, 07 Sep 2023 02:11:50 GMT</createDate><updateDate>Wed, 08 Jan 2025 02:24:29 GMT</updateDate><platforms><html>yes</html></platforms></item><item><guid>https://absyo.itch.io/off-nominal</guid><title>Off Nominal [Free] [Puzzle] [Windows] [Linux] [Android]</title><plainTitle>Off Nominal</plainTitle><imageurl>https://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://absyo.itch.io/off-nominal</link><description><![CDATA[Adrift in space. Fix the ship. Return home.
|
||||
<img src="https://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png" alt="Off Nominal"/>]]></description><pubDate>Fri, 25 Sep 2026 19:54:48 GMT</pubDate><createDate>Fri, 25 Sep 2026 19:54:48 GMT</createDate><updateDate>Sun, 27 Sep 2026 23:25:20 GMT</updateDate><platforms><windows>yes</windows><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://somar-project.itch.io/somar-project</guid><title>Somar-project [Free] [Educational] [Android]</title><plainTitle>Somar-project</plainTitle><imageurl>https://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://somar-project.itch.io/somar-project</link><description><![CDATA[Open-Source OpenXR application for raising awareness about negative impacts of underwater noise pollution on marine life
|
||||
<img src="https://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png" alt="Somar-project"/>]]></description><pubDate>Wed, 26 Mar 2025 17:17:58 GMT</pubDate><createDate>Wed, 26 Mar 2025 17:17:58 GMT</createDate><updateDate>Fri, 28 Mar 2025 15:52:59 GMT</updateDate><platforms><android>yes</android></platforms></item><item><guid>https://5imon.itch.io/buggenesis</guid><title>Bug Genesis VR [Free] [Interactive Fiction] [Windows] [Android]</title><plainTitle>Bug Genesis VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://5imon.itch.io/buggenesis</link><description><![CDATA[Use the bug generator to populate the planet
|
||||
<img src="https://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg" alt="Bug Genesis VR"/>]]></description><pubDate>Sun, 25 May 2025 20:22:49 GMT</pubDate><createDate>Sun, 25 May 2025 20:22:49 GMT</createDate><updateDate>Sun, 25 May 2025 20:37:39 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item><item><guid>https://benmclean.itch.io/wolfsharp</guid><title>WolfSharp [Free] [Shooter] [Windows] [Linux] [Android]</title><plainTitle>WolfSharp</plainTitle><imageurl>https://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://benmclean.itch.io/wolfsharp</link><description><![CDATA[Wolfenstein 3-D for VR
|
||||
<img src="https://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png" alt="WolfSharp"/>]]></description><pubDate>Sat, 25 Jul 2026 12:16:01 GMT</pubDate><createDate>Sat, 25 Jul 2026 12:16:01 GMT</createDate><updateDate>Sat, 25 Jul 2026 13:45:38 GMT</updateDate><platforms><windows>yes</windows><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://mimekunst.itch.io/winter-solitude-vr</guid><title>Winter Solitude VR [Free] [Simulation] [Windows] [macOS] [Linux] [Android]</title><plainTitle>Winter Solitude VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://mimekunst.itch.io/winter-solitude-vr</link><description><![CDATA[Step into the Frozen Abyss: FREE VR Game Experience
|
||||
<img src="https://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png" alt="Winter Solitude VR"/>]]></description><pubDate>Tue, 19 Dec 2023 19:19:59 GMT</pubDate><createDate>Tue, 19 Dec 2023 19:19:59 GMT</createDate><updateDate>Wed, 20 Dec 2023 22:49:23 GMT</updateDate><platforms><windows>yes</windows><osx>yes</osx><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://salmondev.itch.io/evil-miner-vr</guid><title>EVIL MINER VR [Free] [Other] [Windows] [Android]</title><plainTitle>EVIL MINER VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://salmondev.itch.io/evil-miner-vr</link><description><![CDATA[
|
||||
<img src="https://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png" alt="EVIL MINER VR"/>]]></description><pubDate>Fri, 13 Jun 2025 16:48:01 GMT</pubDate><createDate>Fri, 13 Jun 2025 16:48:01 GMT</createDate><updateDate>Sun, 15 Jun 2025 15:19:53 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item><item><guid>https://robinhuud.itch.io/winter-challenge-north-pole-defense</guid><title>North Pole Defense VR [Free] [Action] [Android]</title><plainTitle>North Pole Defense VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://robinhuud.itch.io/winter-challenge-north-pole-defense</link><description><![CDATA[Defend the north pole against giant snowmen using VR snowballs
|
||||
<img src="https://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png" alt="North Pole Defense VR"/>]]></description><pubDate>Thu, 16 Dec 2021 01:33:33 GMT</pubDate><createDate>Thu, 16 Dec 2021 01:33:33 GMT</createDate><updateDate>Thu, 16 Dec 2021 01:51:29 GMT</updateDate><platforms><android>yes</android></platforms></item><item><guid>https://envemos.itch.io/ambly-native-xr</guid><title>Ambly Native XR [Free] [Linux] [Android]</title><plainTitle>Ambly Native XR</plainTitle><imageurl>https://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://envemos.itch.io/ambly-native-xr</link><description><![CDATA[OpenXR games for Meta Quest, PICO and Linux.
|
||||
<img src="https://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg" alt="Ambly Native XR"/>]]></description><pubDate>Wed, 22 Jul 2026 18:38:55 GMT</pubDate><createDate>Wed, 22 Jul 2026 18:38:55 GMT</createDate><updateDate>Fri, 07 Aug 2026 16:04:20 GMT</updateDate><platforms><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://andyman404.itch.io/glow-up-garden</guid><title>Glow Up Garden (VR) [Free] [Action] [Windows] [Android]</title><plainTitle>Glow Up Garden (VR)</plainTitle><imageurl>https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://andyman404.itch.io/glow-up-garden</link><description><![CDATA[Speak positive affirmations out loud to grow your garden (voice-driven VR game)
|
||||
<img src="https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg" alt="Glow Up Garden (VR)"/>]]></description><pubDate>Mon, 03 Jun 2024 20:36:53 GMT</pubDate><createDate>Mon, 03 Jun 2024 20:36:53 GMT</createDate><updateDate>Tue, 04 Jun 2024 04:20:37 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item></channel></rss>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Control on the live view (tap, drag, hold, scroll, type), run in node against the real
|
||||
// functions from ui/index.html with a fake canvas and a fake server.
|
||||
import { readFileSync } from "fs";
|
||||
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
|
||||
const grab = name => {
|
||||
const one = src.match(new RegExp(`\\n((?:async )?function ${name}\\(.*\\}\\n)`)); // one-line function
|
||||
if (one) return one[1];
|
||||
const m = src.match(new RegExp(`(?:\\nconst ${name} = [^\\n]*\\n)|((?:async )?function ${name}\\([\\s\\S]*?\\n}\\n)`));
|
||||
if (!m) throw new Error("not found: " + name);
|
||||
return m[0];
|
||||
};
|
||||
const NAMES = ["panelKey", "ctrlAimedAt", "ctrlSameTarget", "isMoveEvent", "isRelease", "ctrlKeepable", "TAP_MOVE", "ctrlAim", "ctrlKeyEvent", "ctrlTouchCancel", "ctrlSend", "ctrlFlush", "ctrlMoveTo", "ctrlMoveBy", "ctrlSchedule",
|
||||
"ctrlFlushMoves", "ctrlButton", "ctrlClick", "ctrlRelease", "ctrlFraction", "ctrlTouchDown", "centroid",
|
||||
"ctrlTouchMove", "ctrlTouchUp", "ctrlTap", "ctrlText"];
|
||||
const code = NAMES.map(grab).join("");
|
||||
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
|
||||
const tick = (ms = 0) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function page({ mode = "abs", rect = { left: 0, top: 0, width: 640, height: 360 }, api } = {}) {
|
||||
const target = { panel: { window: 42, display: ":1" } };
|
||||
const ctrl = { on: true, queue: [], sending: false, state: "ready", message: "", move: null, rel: [0, 0], raf: 0,
|
||||
held: new Set(), keys: new Set(), pointers: new Map(), g: null, retry: null };
|
||||
const sent = [];
|
||||
const canvas = { width: 1280, height: 720, getBoundingClientRect: () => rect };
|
||||
const env = {
|
||||
ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
|
||||
ctrlTarget: () => (mode !== "abs" ? { ok: true } : target.panel ? { ok: true, panel: target.panel } : { why: "gone" }),
|
||||
api: api || (async (path, body) => { sent.push(...body.events); return { state: "ready", sent: true }; }),
|
||||
requestAnimationFrame: cb => { setTimeout(cb, 0); return 1; },
|
||||
navigator: {},
|
||||
};
|
||||
const fns = new Function(...Object.keys(env), `let ctrlWarned = false;\n${code}
|
||||
return { ctrlTouchDown, ctrlTouchMove, ctrlTouchUp, ctrlTouchCancel, ctrlSend, ctrlText, ctrlButton, ctrlKeyEvent, ctrlRelease, ctrlFlushMoves };`)(...Object.values(env));
|
||||
const at = (id, x, y) => ({ pointerId: id, clientX: x, clientY: y });
|
||||
return { ctrl, sent, target, ...fns, at };
|
||||
}
|
||||
|
||||
// A tap lands where it was tapped: pointer there first, then the click.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 320, 90)); p.ctrlTouchUp(p.at(1, 320, 90));
|
||||
await tick(10);
|
||||
const [move, down, up] = p.sent;
|
||||
if (!(move.fx === 0.5 && Math.abs(move.fy - 0.25) < 1e-9 && move.window === 42 && move.display === ":1")) fail("tap position " + JSON.stringify(move));
|
||||
if (!(down.window === 42 && down.display === ":1")) fail("a press names its panel " + JSON.stringify(down));
|
||||
if (!(down.button === "left" && down.down && up.button === "left" && up.down === false && p.sent.length === 3))
|
||||
fail("tap click " + JSON.stringify(p.sent));
|
||||
}
|
||||
// The panel is letterboxed in a taller view: taps map inside it, taps on the bars do nothing.
|
||||
{
|
||||
const p = page({ rect: { left: 0, top: 0, width: 640, height: 480 } }); // 640x360 picture, 60px bars
|
||||
p.ctrlTouchDown(p.at(1, 320, 150)); p.ctrlTouchUp(p.at(1, 320, 150));
|
||||
p.ctrlTouchDown(p.at(2, 320, 10)); p.ctrlTouchUp(p.at(2, 320, 10));
|
||||
await tick(10);
|
||||
if (!(p.sent.length === 3 && Math.abs(p.sent[0].fy - 0.25) < 1e-9)) fail("letterbox " + JSON.stringify(p.sent));
|
||||
}
|
||||
// A drag is a mouse drag: down where it started, moves, up at the end.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 100, 100));
|
||||
p.ctrlTouchMove(p.at(1, 140, 100)); await tick(5);
|
||||
p.ctrlTouchMove(p.at(1, 200, 120)); await tick(5);
|
||||
p.ctrlTouchUp(p.at(1, 200, 120)); await tick(10);
|
||||
const kinds = p.sent.map(e => "fx" in e ? "move" : `${e.button}-${e.down ? "down" : "up"}`);
|
||||
if (!(kinds[0] === "move" && kinds[1] === "left-down" && kinds.at(-1) === "left-up" && kinds.includes("move", 2)))
|
||||
fail("drag " + kinds.join(","));
|
||||
if (Math.abs(p.sent[0].fx - 100 / 640) > 1e-9) fail("drag starts where the finger went down");
|
||||
if (p.ctrl.held.size) fail("drag left the button held");
|
||||
}
|
||||
// Press and hold is a right-click where the finger is.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 64, 36));
|
||||
await tick(620);
|
||||
p.ctrlTouchUp(p.at(1, 64, 36)); await tick(10);
|
||||
const buttons = p.sent.filter(e => "button" in e).map(e => `${e.button}-${e.down}`);
|
||||
if (buttons.join() !== "right-true,right-false") fail("hold " + buttons.join());
|
||||
if (!(p.sent[0].fx === 0.1)) fail("hold position " + JSON.stringify(p.sent[0]));
|
||||
}
|
||||
// Two fingers scroll, and the content follows them (fingers up scrolls down).
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 100, 200)); p.ctrlTouchDown(p.at(2, 200, 200));
|
||||
p.ctrlTouchMove(p.at(1, 100, 180)); p.ctrlTouchMove(p.at(2, 200, 180));
|
||||
p.ctrlTouchUp(p.at(1, 100, 180)); p.ctrlTouchUp(p.at(2, 200, 180));
|
||||
await tick(10);
|
||||
const dy = p.sent.filter(e => e.scroll).reduce((a, e) => a + e.scroll[1], 0);
|
||||
if (!(dy === 40 && !p.sent.some(e => "button" in e))) fail("scroll " + JSON.stringify(p.sent));
|
||||
}
|
||||
// On the headset view it's a trackpad: drags move the pointer, taps click where it is.
|
||||
{
|
||||
const p = page({ mode: "rel" });
|
||||
p.ctrlTouchDown(p.at(1, 100, 100));
|
||||
p.ctrlTouchMove(p.at(1, 110, 100)); p.ctrlTouchMove(p.at(1, 120, 105));
|
||||
p.ctrlTouchUp(p.at(1, 120, 105)); await tick(10);
|
||||
p.ctrlTouchDown(p.at(1, 50, 50)); p.ctrlTouchUp(p.at(1, 50, 50)); await tick(10);
|
||||
const moved = p.sent.filter(e => "dx" in e).reduce((a, e) => [a[0] + e.dx, a[1] + e.dy], [0, 0]);
|
||||
if (!(moved[0] === 32 && moved[1] === 8)) fail("trackpad move " + JSON.stringify(moved));
|
||||
if (p.sent.some(e => "fx" in e)) fail("trackpad sent an absolute position");
|
||||
if (p.sent.filter(e => e.button === "left").length !== 2) fail("trackpad tap " + JSON.stringify(p.sent));
|
||||
}
|
||||
// Text: plain ASCII only, in chunks the server takes.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlText("héllo " + "x".repeat(600));
|
||||
await tick(10);
|
||||
const text = p.sent.map(e => e.text).join("");
|
||||
if (!(text === "hllo " + "x".repeat(600) && p.sent.every(e => e.text.length <= 500))) fail("text " + text.length);
|
||||
}
|
||||
// Not connected yet: clicks and keys wait with their position. The request fails: only releases wait.
|
||||
{
|
||||
let calls = 0;
|
||||
const p = page({ api: async () => { calls++; if (calls === 1) return { state: "starting", sent: false }; throw new Error("offline"); } });
|
||||
p.ctrlSend([{ fx: 0.1, fy: 0.1, window: 42 }, { button: "left", down: true }, { key: 30, down: true }]);
|
||||
await tick(5);
|
||||
const q = p.ctrl.queue;
|
||||
if (!(q.length === 3 && "fx" in q[0] && q[1].button === "left" && q[2].key === 30)) fail("kept while starting " + JSON.stringify(q));
|
||||
clearTimeout(p.ctrl.retry);
|
||||
p.ctrl.queue = []; p.ctrl.retryAt = 0;
|
||||
p.ctrlSend([{ button: "left", down: false }, { key: 31, down: true }, { dx: 3, dy: 1 }]);
|
||||
await tick(5);
|
||||
if (!(p.ctrl.queue.length === 1 && p.ctrl.queue[0].button === "left" && p.ctrl.queue[0].down === false))
|
||||
fail("release kept on failure " + JSON.stringify(p.ctrl.queue));
|
||||
if (calls !== 2) fail("retried in a tight loop: " + calls + " requests");
|
||||
clearTimeout(p.ctrl.retry);
|
||||
}
|
||||
// Turning Control off (or the view losing focus) lets go of anything held.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlButton("left", true);
|
||||
p.ctrlRelease(); await tick(10);
|
||||
if (!(p.sent.at(-1).button === "left" && p.sent.at(-1).down === false && !p.ctrl.held.size)) fail("release " + JSON.stringify(p.sent));
|
||||
}
|
||||
// Keys held on the Frame are let go too.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlKeyEvent(42, true);
|
||||
p.ctrlRelease(); await tick(10);
|
||||
if (!(p.sent.at(-1).key === 42 && p.sent.at(-1).down === false && !p.ctrl.keys.size)) fail("key release " + JSON.stringify(p.sent));
|
||||
}
|
||||
// A long queue sheds moves and old scrolls, never a release.
|
||||
{
|
||||
const p = page({ api: () => new Promise(() => {}) }); // stuck request
|
||||
p.ctrlSend([{ dx: 1, dy: 1 }]);
|
||||
p.ctrlSend([{ button: "left", down: false }]);
|
||||
for (let i = 0; i < 320; i++) p.ctrlSend([{ scroll: [0, 1] }]);
|
||||
if (!p.ctrl.queue.some(e => e.button === "left" && e.down === false)) fail("trim dropped a release");
|
||||
if (p.ctrl.queue.length > 300) fail("trim kept " + p.ctrl.queue.length);
|
||||
}
|
||||
// Broken on the Frame side: no hammering, and only releases wait.
|
||||
{
|
||||
let calls = 0;
|
||||
const p = page({ api: async () => { calls++; return { state: "error", sent: false }; } });
|
||||
p.ctrlSend([{ button: "left", down: true }, { button: "left", down: false }]);
|
||||
await tick(50);
|
||||
if (calls !== 1) fail("error response reposted " + calls + " times");
|
||||
if (!(p.ctrl.queue.length === 1 && p.ctrl.queue[0].down === false)) fail("error kept " + JSON.stringify(p.ctrl.queue));
|
||||
clearTimeout(p.ctrl.retry);
|
||||
}
|
||||
// Connecting: a tap keeps its position, so it lands where it was made.
|
||||
{
|
||||
const p = page({ api: async () => ({ state: "starting", sent: false }) });
|
||||
p.ctrlTouchDown(p.at(1, 320, 90)); p.ctrlTouchUp(p.at(1, 320, 90));
|
||||
await tick(10);
|
||||
const q = p.ctrl.queue;
|
||||
if (!("fx" in q[0] && q[1].button === "left")) fail("connecting tap " + JSON.stringify(q));
|
||||
clearTimeout(p.ctrl.retry);
|
||||
}
|
||||
// Lifting one of two scrolling fingers doesn't jump the scroll or start a drag.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 100, 200)); p.ctrlTouchDown(p.at(2, 300, 200));
|
||||
p.ctrlTouchUp(p.at(1, 100, 200));
|
||||
p.ctrlTouchMove(p.at(2, 300, 199));
|
||||
p.ctrlTouchUp(p.at(2, 300, 199)); await tick(10);
|
||||
if (p.sent.length) fail("one finger left after scrolling " + JSON.stringify(p.sent));
|
||||
}
|
||||
// A cancelled touch isn't a tap.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 100, 100)); p.ctrlTouchCancel(p.at(1, 100, 100)); await tick(10);
|
||||
if (p.sent.length) fail("cancel clicked " + JSON.stringify(p.sent));
|
||||
}
|
||||
// Press and hold on the bars around the picture does nothing.
|
||||
{
|
||||
const p = page({ rect: { left: 0, top: 0, width: 640, height: 480 } });
|
||||
p.ctrlTouchDown(p.at(1, 320, 10)); await tick(620); p.ctrlTouchUp(p.at(1, 320, 10)); await tick(10);
|
||||
if (p.sent.length) fail("hold on the bars " + JSON.stringify(p.sent));
|
||||
}
|
||||
// Taps only reach the panel in use, and only once its picture is the one on screen.
|
||||
{
|
||||
const fns = ["panelKey", "deskPanel", "ctrlTarget"].map(grab).join("");
|
||||
const check = (desk, live = true) => new Function("desk", "live", "ctrlMode", fns + "; return ctrlTarget();")(desk, live, () => "abs");
|
||||
const a = { display: ":1", window: 5 }, b = { display: ":0", window: 5 };
|
||||
const base = { panels: [a, b], loaded: true, pick: "", focus: ":1/5", shown: ":1/5" };
|
||||
if (!check(base).ok) fail("target: shown and in use");
|
||||
if (check({ ...base, shown: ":0/5" }).ok) fail("target: the picture is another panel with the same id");
|
||||
if (!/Capture or Live/.test(check({ ...base, shown: null }, false).why)) fail("target: stale picture message");
|
||||
if (check({ ...base, pick: ":0/5", shown: ":0/5" }).ok) fail("target: a watched panel that isn't in use");
|
||||
if (check({ ...base, focus: null }).ok) fail("target: nothing in use");
|
||||
}
|
||||
// Focus moves to another panel mid-gesture: the tap or hold does nothing.
|
||||
{
|
||||
const p = page();
|
||||
p.ctrlTouchDown(p.at(1, 100, 100));
|
||||
p.target.panel = { window: 43, display: ":1" };
|
||||
p.ctrlTouchUp(p.at(1, 100, 100)); await tick(10);
|
||||
p.target.panel = { window: 42, display: ":1" };
|
||||
p.ctrlTouchDown(p.at(1, 100, 100));
|
||||
p.target.panel = null;
|
||||
await tick(620); p.ctrlTouchUp(p.at(1, 100, 100)); await tick(10);
|
||||
if (p.sent.some(e => "button" in e)) fail("gesture outlived its panel " + JSON.stringify(p.sent));
|
||||
}
|
||||
// Trimming keeps a click together with its position.
|
||||
{
|
||||
const p = page({ api: () => new Promise(() => {}) });
|
||||
p.ctrlSend([{ dx: 1, dy: 0 }]); // in flight forever
|
||||
for (let i = 0; i < 100; i++) p.ctrlSend([{ scroll: [0, 1] }]);
|
||||
p.ctrlSend([{ fx: 0.5, fy: 0.5, window: 42, display: ":1" }, { button: "left", down: true }, { button: "left", down: false }]);
|
||||
for (let i = 0; i < 198; i++) p.ctrlSend([{ scroll: [0, 1] }]);
|
||||
const q = p.ctrl.queue, i = q.findIndex(e => e.button === "left" && e.down);
|
||||
if (i < 1 || !("fx" in q[i - 1])) fail("trim split a click from its position");
|
||||
}
|
||||
// Backing off holds for new input too.
|
||||
{
|
||||
let calls = 0;
|
||||
const p = page({ api: async () => { calls++; return { state: "error", sent: false }; } });
|
||||
p.ctrlSend([{ button: "left", down: false }]);
|
||||
await tick(5);
|
||||
for (let i = 0; i < 10; i++) { p.ctrlSend([{ key: 30, down: false }]); await tick(2); }
|
||||
if (calls !== 1) fail("new input bypassed the backoff: " + calls + " requests");
|
||||
clearTimeout(p.ctrl.retry);
|
||||
}
|
||||
// A long paste goes in several requests, so a release never waits behind all of it.
|
||||
{
|
||||
const batches = [];
|
||||
const p = page({ api: async (path, body) => { batches.push(body.events); return { state: "ready", sent: true }; } });
|
||||
p.ctrlText("y".repeat(450));
|
||||
p.ctrlButton("left", false);
|
||||
await tick(30);
|
||||
if (!batches.every(b => b.reduce((a, e) => a + (e.text?.length || 0), 0) <= 100)) fail("a batch carried too much text");
|
||||
if (batches.length < 5) fail("paste went in " + batches.length + " requests");
|
||||
}
|
||||
console.log("control gestures ok");
|
||||
@@ -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()
|
||||
@@ -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'<svg onload="evil()"/>')
|
||||
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()
|
||||
@@ -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'<html>'), 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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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'])
|
||||
@@ -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('<HHHHB', 0, 0, 1, 1, 0) + b'\x02\x02\x44\x01\x00'
|
||||
|
||||
def gif(self, frames=1, screen=(1, 1), frame=None):
|
||||
head = b'GIF89a' + struct.pack('<HHBBB', *screen, 0x80, 0, 0) + b'\xff\xff\xff\x00\x00\x00'
|
||||
return head + (frame or self.FRAME) * frames + b'\x3b'
|
||||
|
||||
def test_gif_first_frame_is_bounded_and_re_emitted(self):
|
||||
one = self.gif()
|
||||
self.assertEqual(art.image_type(one), 'gif')
|
||||
self.assertEqual(art.gif_frame(one), one) # already minimal: unchanged
|
||||
self.assertEqual(art.fetch(self.gif(frames=3)), ('gif', one)) # animation: first frame only
|
||||
start = time.monotonic()
|
||||
self.assertEqual(art.gif_frame(self.gif(frames=500000)), one) # ~10 MB of frames, never parsed
|
||||
self.assertLess(time.monotonic() - start, 1)
|
||||
big = b'\x2c' + struct.pack('<HHHHB', 0, 0, 8192, 8192, 0) + b'\x02\x02\x44\x01\x00'
|
||||
for bomb in (self.gif(frame=big), self.gif(screen=(8192, 8192), frame=big), self.gif(screen=(5000, 10)),
|
||||
self.gif(frame=b'\x2c' + struct.pack('<HHHHB', 1, 0, 1, 1, 0) + b'\x02\x02\x44\x01\x00'),
|
||||
b'GIF89a' + struct.pack('<HHBBB', 1, 1, 0, 0, 0) + self.FRAME + b'\x3b', # no colour table
|
||||
self.gif(frame=b'\x2c' + struct.pack('<HHHHB', 0, 0, 1, 1, 0) + b'\x0c\x02\x44\x01\x00'),
|
||||
self.gif(frame=b'\x99')):
|
||||
with self.subTest(bomb=bomb[:40]), self.assertRaises(ValueError):
|
||||
art.image_type(bomb)
|
||||
for cut in range(len(one) - 1): # every truncation before the image's last block
|
||||
with self.subTest(cut=cut), self.assertRaises(ValueError):
|
||||
art.gif_frame(one[:cut])
|
||||
|
||||
def test_gif_control_block_and_empty_image(self):
|
||||
image = self.FRAME[8:] # the image without its graphic control block
|
||||
head = b'GIF89a' + struct.pack('<HHBBB', 1, 1, 0x80, 0, 0) + b'\xff\xff\xff\x00\x00\x00'
|
||||
good = b'\x21\xf9\x04\x00\x00\x00\x00\x00'
|
||||
self.assertEqual(art.gif_frame(head + good + image + b'\x3b'), head + good + image + b'\x3b')
|
||||
bad = b'\x21\xf9\x02\x00\x00\x00' # wrong payload size: dropped, not passed on
|
||||
self.assertEqual(art.gif_frame(head + bad + image + b'\x3b'), head + image + b'\x3b')
|
||||
empty = b'\x2c' + struct.pack('<HHHHB', 0, 0, 1, 1, 0) + b'\x02\x00'
|
||||
with self.assertRaises(ValueError):
|
||||
art.gif_frame(head + empty + b'\x3b')
|
||||
|
||||
def test_png_variants_left_to_chromium_and_limits(self):
|
||||
def png(w, h, depth, color, interlace):
|
||||
return art.PNG + art.chunk(b'IHDR', 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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Fake-Frame panel responses and the headset page's real HTTP guards."""
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_panels as panels
|
||||
|
||||
# Shape verified with vrcmd on SteamVR 2.18.1 / BUILD_ID 20260925.6191901.
|
||||
OVERLAYS = """---- OVERLAYS ----
|
||||
'valve.steam.desktopgame.12' -- 'Alex's <notes>', 1920x1080 visible VROverlayType_Dashboard_Main
|
||||
'valve.steam.desktopgame.12.thumb' -- 'Thumb', not_visible VROverlayType_Dashboard_Thumbnail
|
||||
'valve.steam.desktopgame.12.layer1' -- 'Layer', visible VROverlayType_Subview
|
||||
'system.pointer' -- 'Pointer', visible VROverlayType_Basic
|
||||
'valve.steam.desktopgame.13' -- 'Other', not_visible VROverlayType_Dashboard_Main
|
||||
"""
|
||||
|
||||
|
||||
class Panels(unittest.TestCase):
|
||||
def test_enumerates_only_main_panels_including_hidden(self):
|
||||
rows = panels.parse_overlays(OVERLAYS)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0]['title'], "Alex's <notes>")
|
||||
self.assertTrue(rows[0]['visible'])
|
||||
self.assertFalse(rows[1]['visible'])
|
||||
|
||||
def test_unavailable_runtime_is_not_an_empty_workspace(self):
|
||||
with self.assertRaises(panels.PanelError):
|
||||
panels.parse_overlays('SteamVR not running')
|
||||
self.assertEqual(panels.parse_overlays('---- OVERLAYS ----'), [])
|
||||
|
||||
@patch.object(panels, 'run', return_value=OVERLAYS)
|
||||
def test_focus_revalidates_and_dispatches_without_shell(self, run):
|
||||
key = 'valve.steam.desktopgame.12'
|
||||
self.assertEqual(panels.focus(key)['requested'], key)
|
||||
self.assertEqual(run.call_args.args[0], [panels.VRCMD, '--showdashboard', key])
|
||||
|
||||
@patch.object(panels, 'run', return_value=OVERLAYS)
|
||||
def test_closed_or_injected_panel_never_dispatches(self, run):
|
||||
for key in (None, 12, 'x;touch /tmp/bad', '../other', 'missing'):
|
||||
with self.assertRaises(panels.PanelError):
|
||||
panels.focus(key)
|
||||
self.assertEqual(run.call_count, 1) # only valid-looking 'missing' enumerates
|
||||
|
||||
|
||||
class HeadsetHTTP(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.server = panels.HTTPServer(('127.0.0.1', 0), panels.Handler)
|
||||
self.server.key = 'test-key'
|
||||
self.server.closing = False
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.thread.join()
|
||||
self.server.server_close()
|
||||
|
||||
def request(self, path, body=None, headers=None):
|
||||
c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
|
||||
try:
|
||||
c.request('POST' if body is not None else 'GET', path, body=body, headers=headers or {})
|
||||
r = c.getresponse()
|
||||
return r.status, r.read().decode()
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def test_page_is_public_but_contains_no_key_or_private_titles(self):
|
||||
status, page = self.request('/')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertNotIn('test-key', page)
|
||||
self.assertIn('textContent=p.title', page) # titles never become HTML
|
||||
self.assertEqual(self.request('/panels')[0], 403)
|
||||
|
||||
@patch.object(panels, 'state', return_value={'panels': []})
|
||||
def test_auth_host_and_origin_checks(self, state):
|
||||
auth = {'X-Panel-Key': 'test-key'}
|
||||
self.assertEqual(self.request('/panels', headers=auth)[0], 200)
|
||||
for extra in ({'Host': 'evil.test'}, {'Origin': 'https://evil.test'}, {'X-Panel-Key': 'wrong'}):
|
||||
self.assertEqual(self.request('/panels', headers={**auth, **extra})[0], 403)
|
||||
self.assertEqual(state.call_count, 1)
|
||||
|
||||
@patch.object(panels, 'focus', return_value={'requested': 'panel'})
|
||||
def test_post_validation_and_close(self, focus):
|
||||
auth = {'X-Panel-Key': 'test-key'}
|
||||
for body in ('[]', '{broken', '0', '"text"', 'x' * 1025):
|
||||
self.assertEqual(self.request('/focus', body, auth)[0], 400)
|
||||
self.assertEqual(focus.call_count, 0)
|
||||
self.assertEqual(self.request('/focus', '{"key":"panel"}', auth)[0], 200)
|
||||
self.assertEqual(focus.call_args.args, ('panel',))
|
||||
self.assertEqual(self.request('/close', '{}', auth)[0], 200)
|
||||
self.assertTrue(self.server.closing)
|
||||
|
||||
@patch.object(panels, 'state', side_effect=panels.PanelError('offline'))
|
||||
def test_offline_is_an_error_not_a_successful_empty_list(self, state):
|
||||
status, body = self.request('/panels', headers={'X-Panel-Key': 'test-key'})
|
||||
self.assertEqual(status, 502)
|
||||
self.assertEqual(json.loads(body), {'error': 'offline'})
|
||||
@@ -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('<script src="/artwork-settings.js">'))
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
|
||||
class LocalMode(unittest.TestCase):
|
||||
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
from apk_sources import SourceError, sidequest
|
||||
|
||||
|
||||
class SideQuestTests(unittest.TestCase):
|
||||
def test_policy_is_recorded_and_source_is_page_only(self):
|
||||
fixture = json.loads((Path(__file__).parent / 'fixtures/sidequest-policy.json').read_text())
|
||||
self.assertIn('/search/', fixture['robots']['disallow'])
|
||||
self.assertIn('scraping', fixture['terms']['prohibited_activities_i'])
|
||||
self.assertTrue(sidequest.sources()[0]['page_only'])
|
||||
|
||||
@patch('urllib.request.urlopen', side_effect=AssertionError('network forbidden'))
|
||||
def test_unknown_listing_never_claims_free_or_downloadable(self, _urlopen):
|
||||
source = sidequest.sources()[0]
|
||||
entry = sidequest.details(source, '123')
|
||||
self.assertEqual(entry['page'], 'https://sidequestvr.com/app/123')
|
||||
self.assertFalse(entry['downloadable'])
|
||||
self.assertIsNone(entry['free'])
|
||||
self.assertIsNone(entry['vr'])
|
||||
self.assertEqual(entry['images']['screenshots'], [])
|
||||
with self.assertRaisesRegex(SourceError, 'page-only'):
|
||||
sidequest.download(source, '123')
|
||||
self.assertEqual(sidequest.search(source, 'open saber'), [])
|
||||
self.assertEqual(sidequest.search(source, 'open saber', 0), [])
|
||||
|
||||
def test_invalid_ids(self):
|
||||
for value in ('../123', '1?paid=false', '1', '', '1/2', '1' * 13):
|
||||
with self.assertRaises(SourceError):
|
||||
sidequest.details(sidequest.sources()[0], value)
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Touch: the live view's Control (ui/frame_touch.py on the Frame, and the server's checks).
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
|
||||
class Mapping(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import frame_touch
|
||||
cls.t = frame_touch
|
||||
|
||||
def test_panel_bigger_than_its_display_is_scaled(self):
|
||||
# Verified 2026-09-29: a 1920x1080 window on :1 (1280x720) took Accept at
|
||||
# window (1828, 1020) as pointer (1219, 680).
|
||||
panel = {"root": [1280, 720], "width": 1920, "height": 1080}
|
||||
x, y = self.t.to_root(panel, 1828 / 1920, 1020 / 1080)
|
||||
self.assertAlmostEqual(x, 1218.7, delta=1)
|
||||
self.assertAlmostEqual(y, 679.4, delta=1)
|
||||
|
||||
def test_same_size_is_one_to_one_and_clamped(self):
|
||||
panel = {"root": [1280, 720], "width": 1280, "height": 720}
|
||||
self.assertEqual(self.t.to_root(panel, 0, 0), (0, 0))
|
||||
self.assertEqual(self.t.to_root(panel, 1, 1), (1279, 719))
|
||||
self.assertEqual(self.t.to_root(panel, -3, 7), (0, 719))
|
||||
|
||||
def test_other_shapes_are_letterboxed(self):
|
||||
panel = {"root": [1280, 720], "width": 800, "height": 800} # square: bars left and right
|
||||
x, y = self.t.to_root(panel, 0, 0.5)
|
||||
self.assertAlmostEqual(x, 280, delta=0.5)
|
||||
self.assertAlmostEqual(y, 359.5, delta=0.5)
|
||||
|
||||
def test_same_id_on_both_displays_is_told_apart_by_pid(self):
|
||||
t = self.t
|
||||
saved = t.displays, t.window_info, t.window_pid
|
||||
self.addCleanup(lambda: (setattr(t, "displays", saved[0]), setattr(t, "window_info", saved[1]),
|
||||
setattr(t, "window_pid", saved[2])))
|
||||
t.displays = lambda: [":0", ":1"]
|
||||
t.window_info = lambda d, w: {"name": f"on {d}", "width": 1280 if w != "root" else 1920, "height": 720}
|
||||
t.window_pid = lambda d, w: {":0": 111, ":1": 222}[d]
|
||||
self.assertEqual(t.locate(5, 222)["display"], ":1")
|
||||
self.assertEqual(t.locate(5, 111)["display"], ":0")
|
||||
self.assertEqual(t.locate(5, None)["display"], ":0")
|
||||
|
||||
def test_focus_display_is_decoded(self):
|
||||
t = self.t
|
||||
saved = t.xprop_root
|
||||
self.addCleanup(lambda: setattr(t, "xprop_root", saved))
|
||||
for values, want in (([12602, 0, 58], ":1"), ([12346], ":0"), ([], None), ([0x41], None)):
|
||||
t.xprop_root = lambda d, n, v=values: v
|
||||
self.assertEqual(t.focus_display(), want)
|
||||
|
||||
def test_ascii_table_covers_printable_characters(self):
|
||||
for code in range(0x20, 0x7F):
|
||||
self.assertIn(chr(code), self.t.ASCII, chr(code))
|
||||
self.assertEqual(self.t.ASCII["a"], (30, False))
|
||||
self.assertEqual(self.t.ASCII["A"], (30, True))
|
||||
self.assertEqual(self.t.ASCII["?"], (53, True))
|
||||
self.assertEqual(self.t.ASCII["1"], (2, False))
|
||||
self.assertEqual(self.t.ASCII["0"], (11, False))
|
||||
|
||||
def test_events_parsing(self):
|
||||
self.assertEqual(self.t.events(b'{"dx": 1}'), [{"dx": 1}])
|
||||
self.assertEqual(self.t.events(b'[{"dx": 1}, 5]'), [{"dx": 1}])
|
||||
self.assertEqual(self.t.events(b"nope"), [])
|
||||
|
||||
|
||||
class FakeGamescope:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __getattr__(self, name):
|
||||
return lambda *a: self.calls.append((name, *a))
|
||||
|
||||
|
||||
class Apply(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import frame_touch
|
||||
cls.t = frame_touch
|
||||
|
||||
def setUp(self):
|
||||
self.focused = {"window": 7, "display": ":1", "root": [1280, 720], "width": 1280, "height": 720, "name": "x"}
|
||||
saved = self.t.focus, self.t.say, self.t.focus_now
|
||||
self.t.STALE[0] = False
|
||||
self.said = []
|
||||
self.t.focus = lambda: dict(self.focused)
|
||||
self.t.focus_now = lambda: (self.focused["window"], self.focused["display"])
|
||||
self.t.say = lambda state, **more: self.said.append((state, more))
|
||||
self.addCleanup(lambda: (setattr(self.t, "focus", saved[0]), setattr(self.t, "say", saved[1]),
|
||||
setattr(self.t, "focus_now", saved[2])))
|
||||
|
||||
def test_tap_moves_then_clicks_in_order(self):
|
||||
gs, panel = FakeGamescope(), None
|
||||
for e in ({"fx": 0.5, "fy": 0.5, "window": 7, "display": ":1"}, {"button": "left", "down": True, "window": 7, "display": ":1"},
|
||||
{"button": "left", "down": False}):
|
||||
panel = self.t.apply(gs, e, panel)
|
||||
self.assertEqual([c[0] for c in gs.calls], ["move_to", "button", "button"])
|
||||
self.assertEqual(gs.calls[1][1:], ("left", True))
|
||||
|
||||
def test_a_tap_meant_for_another_panel_goes_nowhere(self):
|
||||
# Focus moved on: the position and the press are dropped; the release still goes.
|
||||
gs, panel = FakeGamescope(), None
|
||||
for e in ({"fx": 0.5, "fy": 0.5, "window": 99, "display": ":1"}, {"button": "left", "down": True, "window": 99, "display": ":1"},
|
||||
{"key": 30, "down": True, "window": 7, "display": ":0"}, {"button": "left", "down": False, "window": 99, "display": ":1"}):
|
||||
panel = self.t.apply(gs, e, panel)
|
||||
self.assertEqual(gs.calls, [("button", "left", False)])
|
||||
self.assertEqual(self.said[0], ("ready", {"focus": 7, "display": ":1", "stale": True}))
|
||||
|
||||
def test_presses_read_focus_afresh(self):
|
||||
# A move may use a recent reading; a press checks again, so a panel that just
|
||||
# took focus doesn't get a click meant for another.
|
||||
gs, calls = FakeGamescope(), []
|
||||
self.t.focus = lambda: calls.append(1) or dict(self.focused)
|
||||
panel = self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":1"}, None)
|
||||
panel = self.t.apply(gs, {"fx": 0.6, "fy": 0.5, "window": 7, "display": ":1"}, panel)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.t.apply(gs, {"button": "left", "down": True, "window": 7, "display": ":1"}, panel)
|
||||
self.assertEqual(len(calls), 1) # same panel still: the quick check was enough
|
||||
self.focused["window"] = 8
|
||||
self.t.apply(gs, {"button": "left", "down": True, "window": 7, "display": ":1"}, panel)
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertEqual([c[0] for c in gs.calls], ["move_to", "move_to", "button"])
|
||||
|
||||
def test_stale_is_said_once_and_cleared(self):
|
||||
gs, panel = FakeGamescope(), None
|
||||
for e in ({"fx": 0.5, "fy": 0.5, "window": 99, "display": ":1"}, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":1"},
|
||||
{"fx": 0.6, "fy": 0.5, "window": 7, "display": ":1"}):
|
||||
panel = self.t.apply(gs, e, panel)
|
||||
self.assertEqual(self.said, [("ready", {"focus": 7, "display": ":1", "stale": True}),
|
||||
("ready", {"focus": 7, "display": ":1"})])
|
||||
|
||||
def test_stale_clears_on_a_trackpad_move_but_not_on_a_stale_release(self):
|
||||
gs = FakeGamescope()
|
||||
panel = self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 99, "display": ":1"}, None)
|
||||
panel = self.t.apply(gs, {"button": "left", "down": False, "window": 99, "display": ":1"}, panel)
|
||||
self.assertTrue(self.t.STALE[0]) # that release was still aimed at the old panel
|
||||
self.t.apply(gs, {"dx": 3, "dy": 0}, panel)
|
||||
self.assertFalse(self.t.STALE[0])
|
||||
self.assertEqual(self.said[-1][1].get("stale"), None)
|
||||
|
||||
def test_same_window_id_on_the_other_display_is_another_panel(self):
|
||||
gs = FakeGamescope()
|
||||
self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None)
|
||||
self.assertEqual(gs.calls, [])
|
||||
|
||||
def test_relative_scroll_keys_text(self):
|
||||
gs = FakeGamescope()
|
||||
for e in ({"dx": 5, "dy": -3}, {"scroll": [0, 120]}, {"key": 30, "down": True}, {"text": "hi"},
|
||||
{"key": True}, {"button": "sideways"}):
|
||||
self.t.apply(gs, e, None)
|
||||
self.assertEqual([c[0] for c in gs.calls], ["move_by", "scroll", "key", "text"])
|
||||
|
||||
def test_everything_held_is_let_go(self):
|
||||
class Held(self.t.Gamescope):
|
||||
def __init__(self):
|
||||
self.held, self.keys, self.log = set(), set(), []
|
||||
|
||||
def frame(self):
|
||||
pass
|
||||
|
||||
g = Held()
|
||||
g.L = type("L", (), {"ei_device_button_button": lambda *a: g.log.append(("button",) + a[2:]),
|
||||
"ei_device_keyboard_key": lambda *a: g.log.append(("key",) + a[2:])})()
|
||||
g.device = object()
|
||||
g.button("left", True)
|
||||
g.key(42, True)
|
||||
g.release_all()
|
||||
self.assertEqual(g.log[-2:], [("button", 0x110, False), ("key", 42, False)])
|
||||
self.assertEqual((g.held, g.keys), (set(), set()))
|
||||
|
||||
def test_resuming_after_a_pause_lets_go_of_everything(self):
|
||||
t = self.t
|
||||
log, queue = [], [1]
|
||||
|
||||
class L:
|
||||
def __getattr__(self, name):
|
||||
if name == "ei_get_event":
|
||||
return lambda ei: queue.pop(0) if queue else None
|
||||
return {"ei_event_get_type": lambda ev: t.EV_DEVICE_RESUMED, "ei_event_get_device": lambda ev: "dev",
|
||||
"ei_device_has_capability": lambda d, c: True,
|
||||
"ei_device_button_button": lambda d, c, down: log.append(("button", c, down)),
|
||||
"ei_device_keyboard_key": lambda d, c, down: log.append(("key", c, down))}.get(name, lambda *a: 0)
|
||||
|
||||
g = t.Gamescope.__new__(t.Gamescope)
|
||||
g.L, g.ei, g.fd, g.device, g.sequence, g.alive = L(), None, None, None, 0, True
|
||||
g.held, g.keys = {0x110}, {42}
|
||||
g.frame = lambda: None
|
||||
old = t.select.select
|
||||
t.select.select = lambda *a: ([], [], [])
|
||||
try:
|
||||
g.pump()
|
||||
finally:
|
||||
t.select.select = old
|
||||
self.assertEqual(sorted(log), [("button", 0x110, False), ("key", 42, False)])
|
||||
self.assertEqual(g.device, "dev")
|
||||
|
||||
def test_text_uses_shift_for_capitals(self):
|
||||
class Keys(self.t.Gamescope):
|
||||
def __init__(self):
|
||||
self.pressed = []
|
||||
|
||||
def key(self, code, down):
|
||||
self.pressed.append((code, down))
|
||||
k = Keys()
|
||||
k.text("Hié") # the é has no key on a US layout and is left out
|
||||
self.assertEqual(k.pressed, [(42, True), (35, True), (35, False), (42, False), (23, True), (23, False)])
|
||||
|
||||
|
||||
class ServerChecks(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import server
|
||||
cls.s = server
|
||||
|
||||
def test_touch_event_keeps_known_fields(self):
|
||||
ev = self.s.touch_event
|
||||
self.assertEqual(ev({"fx": 0.5, "fy": 2, "window": 5, "display": ":1"}), {"fx": 0.5, "fy": 1.0, "window": 5, "display": ":1"})
|
||||
self.assertEqual(ev({"button": "left", "window": 5, "display": ":0"}), {"button": "left", "down": True, "window": 5, "display": ":0"})
|
||||
self.assertEqual(ev({"button": "right"}), {"button": "right", "down": True})
|
||||
self.assertEqual(ev({"key": 30, "down": False}), {"key": 30, "down": False})
|
||||
self.assertEqual(ev({"scroll": [0, 1e9]}), {"scroll": [0.0, 5000.0]})
|
||||
self.assertEqual(ev({"dx": 3, "other": 1}), {"dx": 3.0})
|
||||
|
||||
def test_touch_event_rejects_bad_ones(self):
|
||||
for bad in (None, {}, {"fx": 0.5, "fy": 0.5}, {"fx": "1", "fy": 0, "window": 1, "display": ":1"}, {"button": "side"},
|
||||
{"fx": 0.5, "fy": 0.5, "window": 1}, {"fx": 0.5, "fy": 0.5, "window": 1, "display": ":1;x"},
|
||||
{"window": 1, "display": ":1"},
|
||||
{"key": 0}, {"key": 999}, {"key": True}, {"scroll": [1]}, {"text": ""}, {"text": "x" * 501},
|
||||
{"fx": 0.1, "fy": 0.1, "window": True, "display": ":1"}):
|
||||
with self.assertRaises(self.s.Failure, msg=repr(bad)):
|
||||
self.s.touch_event(bad)
|
||||
|
||||
def test_panel_stream_is_the_window_and_checked(self):
|
||||
cmd = self.s.stream_command("src=panel&window=10485777&display=:1&h=720&fps=30")
|
||||
self.assertIn("DISPLAY=:1 ffmpeg", cmd)
|
||||
self.assertIn("-window_id 10485777 -i :1", cmd)
|
||||
for bad in ("src=panel&window=1;rm&display=:1", "src=panel&window=1&display=:1;x", "src=panel&display=:1"):
|
||||
with self.assertRaises(self.s.Failure):
|
||||
self.s.stream_command(bad + "&h=720&fps=30")
|
||||
|
||||
def test_touch_agent_runs_the_helper_with_nothing_to_copy(self):
|
||||
agent = self.s.TouchAgent()
|
||||
self.assertEqual(agent.deliver(lambda m: None), "")
|
||||
cmd = agent.command()
|
||||
self.assertTrue(cmd.startswith("python3 -u -c '"))
|
||||
self.assertIn("frame_touch", cmd)
|
||||
|
||||
def test_batch_limit(self):
|
||||
with self.assertRaises(self.s.Failure):
|
||||
self.s.remote_touch({"events": [{"dx": 1}] * (self.s.INPUT_BATCH_LIMIT + 1)})
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "needs node")
|
||||
class PageGestures(unittest.TestCase):
|
||||
"""Control's gestures and queue (tests/page/ctrl_gestures.mjs runs the real functions from index.html)."""
|
||||
|
||||
def test_gestures(self):
|
||||
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "ctrl_gestures.mjs")], capture_output=True, text=True,
|
||||
timeout=60)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
|
||||
|
||||
class Script(unittest.TestCase):
|
||||
def test_helper_compiles_on_the_frames_python(self):
|
||||
# The Frame runs it with its own python3 (3.13 on SteamOS 0.4.1); stdlib and ctypes only.
|
||||
src = (ROOT / "ui/frame_touch.py").read_text()
|
||||
compile(src, "frame_touch.py", "exec")
|
||||
for mod in ("import ctypes", "import json", "import select"):
|
||||
self.assertIn(mod, src)
|
||||
self.assertNotIn("import requests", src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Own VR telemetry, optional catalogue and entitlement guards; no headset needed."""
|
||||
import ctypes
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_status as status
|
||||
import frame_steam as steam
|
||||
import frame_utilities as utilities
|
||||
import frame_vr as vr
|
||||
import server
|
||||
|
||||
|
||||
class Telemetry(unittest.TestCase):
|
||||
def test_background_client_does_not_request_steamvr_start(self):
|
||||
with mock.patch.object(status.C, 'CDLL') as load:
|
||||
with status.OpenVR():
|
||||
pass
|
||||
self.assertEqual(load.return_value.VR_InitInternal2.call_args.args[1], 3)
|
||||
load.return_value.VR_ShutdownInternal.assert_called_once()
|
||||
|
||||
def test_linux_arm64_timing_layout(self):
|
||||
self.assertEqual(ctypes.sizeof(status.Timing), 192)
|
||||
self.assertEqual(status.Timing.pose.offset, 96)
|
||||
|
||||
def test_cpu_excludes_guest_and_counts_iowait_idle(self):
|
||||
with mock.patch.object(status, 'read', return_value='cpu 20 0 10 50 20 0 0 0 7 1\n'):
|
||||
self.assertEqual(status.cpu_ticks(), (100, 70))
|
||||
self.assertEqual(status.cpu_percent((100, 70), (200, 110)), 60)
|
||||
self.assertIsNone(status.cpu_percent((100, 70), (100, 70)))
|
||||
self.assertIsNone(status.cpu_percent(None, (100, 70)))
|
||||
|
||||
def test_compositor_and_app_fps_are_distinct(self):
|
||||
a, b = status.Timing(), status.Timing()
|
||||
a.index, a.time = 100, 10
|
||||
b.index, b.time, b.interval = 118, 10.2, 22.2222
|
||||
b.gpu, b.compositorCpu = 4, 1
|
||||
result = status.timing_values(a, b)
|
||||
self.assertEqual(result['compositorFps'], 90)
|
||||
self.assertEqual(result['appFps'], 45)
|
||||
self.assertEqual(result['frameMs'], 11.11)
|
||||
b.interval = 0
|
||||
self.assertIsNone(status.timing_values(a, b)['appFps'])
|
||||
b.index = a.index
|
||||
self.assertEqual(status.timing_values(a, b), {})
|
||||
b.index = 5 # compositor restart / wrap
|
||||
self.assertEqual(status.timing_values(a, b), {})
|
||||
|
||||
def test_missing_openvr_keeps_sensor_values(self):
|
||||
with mock.patch.object(status, 'OpenVR', side_effect=OSError('missing')), \
|
||||
mock.patch.object(status, 'num', return_value=629), \
|
||||
mock.patch.object(status, 'cpu_ticks', side_effect=[(100, 50), (200, 100)]), \
|
||||
mock.patch.object(status.time, 'sleep'):
|
||||
data = status.performance()
|
||||
self.assertEqual(data['gpuMHz'], 629)
|
||||
self.assertEqual(data['cpuPercent'], 50)
|
||||
self.assertIsNone(data['compositorFps'])
|
||||
self.assertIsNone(data['appFps'])
|
||||
|
||||
def test_import_has_no_device_side_effect(self):
|
||||
out = subprocess.run([sys.executable, '-c', 'import frame_status'],
|
||||
cwd=Path(__file__).resolve().parents[1] / 'ui', capture_output=True, text=True)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
self.assertEqual(out.stdout, '')
|
||||
|
||||
|
||||
class UtilityGuards(unittest.TestCase):
|
||||
def test_all_paid_utilities_refused_before_steam_url(self):
|
||||
with mock.patch.object(steam, 'Page') as page, mock.patch.object(steam, 'steam_url') as url:
|
||||
page.return_value.eval.return_value = [{'id': i, 'owned': False} for i in steam.UTILITY_IDS]
|
||||
for appid in set(steam.UTILITY_IDS) - set(steam.FREE_UTILITIES):
|
||||
with self.assertRaisesRegex(steam.Fail, 'not owned'):
|
||||
steam.install(appid)
|
||||
url.assert_not_called()
|
||||
|
||||
def test_unknown_ownership_fails_closed(self):
|
||||
with mock.patch.object(steam, 'Page') as page, mock.patch.object(steam, 'steam_url') as url:
|
||||
page.return_value.eval.side_effect = steam.Fail('library unavailable')
|
||||
with self.assertRaises(steam.Fail):
|
||||
steam.install(908520)
|
||||
url.assert_not_called()
|
||||
|
||||
def test_owned_software_uses_existing_install_flow(self):
|
||||
with mock.patch.object(steam, 'Page') as page, mock.patch.object(steam, 'steam_url') as url:
|
||||
page.return_value.eval.side_effect = [[{'id': 908520, 'owned': True}], {'name': 'fpsVR', 'installed': True}]
|
||||
self.assertEqual(steam.install(908520)['state'], 'installed')
|
||||
url.assert_not_called()
|
||||
|
||||
def test_catalogue_separates_public_claims_ownership_and_reports(self):
|
||||
data = utilities.catalogue([{'id': 1173510, 'owned': True}], [
|
||||
{'package': 'com.android.app', 'rating': 'works', 'date': '2026-09-28'},
|
||||
{'package': 'steam:908520', 'rating': 'broken', 'date': '2026-09-28', 'notes': 'test report'}])
|
||||
rows = {r['id']: r for r in data['utilities']}
|
||||
self.assertEqual(rows[1173510]['status'], 'untested')
|
||||
self.assertTrue(rows[1173510]['canInstall'])
|
||||
self.assertFalse(rows[908520]['canInstall'])
|
||||
self.assertEqual(rows[908520]['status'], 'broken')
|
||||
self.assertTrue(rows[1494460]['canInstall'])
|
||||
self.assertFalse(rows[1009850]['free'])
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node needed to evaluate Steam JavaScript')
|
||||
def test_ownership_js_includes_software_and_rejects_empty_library(self):
|
||||
script = 'let appStore = {allApps: [{appid:908520,app_type:2,local_per_client_data:{installed:true}}]};\n'
|
||||
script += 'console.log(JSON.stringify(' + steam.UTILITIES_JS + '));'
|
||||
out = subprocess.run(['node', '-e', script], capture_output=True, text=True, check=True)
|
||||
rows = {r['id']: r for r in json.loads(out.stdout)}
|
||||
self.assertTrue(rows[908520]['owned'])
|
||||
self.assertTrue(rows[908520]['installed'])
|
||||
self.assertFalse(rows[1173510]['owned'])
|
||||
out = subprocess.run(['node', '-e', 'let appStore={allApps:[]};' + steam.UTILITIES_JS], capture_output=True)
|
||||
self.assertNotEqual(out.returncode, 0)
|
||||
|
||||
|
||||
class HUD(unittest.TestCase):
|
||||
def test_invalid_action_never_reaches_frame(self):
|
||||
with mock.patch.object(server, 'ssh') as ssh:
|
||||
for action in ('hud-start; reboot', 'adjust', 'recenter', 'restore'):
|
||||
with self.assertRaises(server.Failure) as err:
|
||||
server.vr({'action': action})
|
||||
self.assertEqual(err.exception.status, 400)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_stop_wont_kill_reused_pid(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(vr, 'ROOT', Path(tmp)), \
|
||||
mock.patch.object(vr, 'process_identity', return_value='different'), mock.patch.object(vr.os, 'kill') as kill:
|
||||
(Path(tmp) / 'hud.json').write_text(json.dumps({'pid': 1234, 'start': 'old'}))
|
||||
vr.panel('hud-stop')
|
||||
kill.assert_not_called()
|
||||
self.assertFalse((Path(tmp) / 'hud.json').exists())
|
||||
|
||||
def test_duplicate_start_does_not_spawn(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(vr, 'ROOT', Path(tmp)), \
|
||||
mock.patch.object(vr, 'process_identity', return_value='same'), mock.patch.object(vr.subprocess, 'Popen') as popen:
|
||||
(Path(tmp) / 'hud.json').write_text(json.dumps({'pid': 1234, 'start': 'same'}))
|
||||
self.assertIn('already open', vr.panel('hud-start')['message'])
|
||||
popen.assert_not_called()
|
||||
|
||||
def test_start_failure_cleans_up_process(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(vr, 'ROOT', Path(tmp)), \
|
||||
mock.patch.object(vr.subprocess, 'Popen') as popen, \
|
||||
mock.patch.object(vr.subprocess, 'check_output', side_effect=OSError('no display')):
|
||||
popen.return_value.poll.return_value = None
|
||||
with self.assertRaises(OSError):
|
||||
vr.panel('hud-start')
|
||||
popen.return_value.terminate.assert_called_once()
|
||||
popen.return_value.wait.assert_called_once()
|
||||
@@ -43,3 +43,11 @@ Rules
|
||||
|
||||
class SourceError(Exception):
|
||||
"""User-readable failure from a source (network, format, verification)."""
|
||||
|
||||
|
||||
class SourceLimited(SourceError):
|
||||
"""The source's host asked us to slow down; retry_after is in seconds."""
|
||||
|
||||
def __init__(self, message, retry_after=None):
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Opt-in local store fixtures: FRAME_APK_SEARCH_DEMO=1. Never downloads."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from apk_sources import SourceError
|
||||
|
||||
KIND = 'demo'
|
||||
FIXTURES = Path(__file__).resolve().parents[2] / 'tests' / 'fixtures' / 'apk-search'
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': 'demo-' + key, 'kind': KIND, 'name': name, 'enabled': True,
|
||||
'builtin': True, 'trust': trust, 'url': 'https://example.invalid'}
|
||||
for key, name, trust in [('github', 'GitHub', 'official'), ('fdroid', 'F-Droid', 'community'),
|
||||
('sidequest', 'SideQuest', 'official'), ('itch', 'itch.io', 'community')]]
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
if source['id'] == 'demo-itch':
|
||||
raise SourceError('Source temporarily unavailable')
|
||||
entries = json.loads((FIXTURES / 'store.json').read_text())
|
||||
if source['id'] == 'demo-sidequest':
|
||||
entries = [e for e in entries if e['vr']]
|
||||
if source['id'] == 'demo-fdroid':
|
||||
entries = [e for e in entries if not e['vr']]
|
||||
return [dict(e, verified=source['id'] in ('demo-github', 'demo-fdroid')) for e in entries
|
||||
if query.lower() in (e['name'] + ' ' + e['summary']).lower()][:limit]
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
return next(e for e in search(source, '') if e['id'] == entry_id)
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
raise SourceError('Preview sources cannot download or install apps')
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Bounded artwork cache. Only source-provided URLs get opaque image handles."""
|
||||
from collections import OrderedDict
|
||||
import http.client
|
||||
import ipaddress
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from apk_sources import SourceError
|
||||
|
||||
_lock = threading.Lock()
|
||||
_urls = OrderedDict()
|
||||
_cache = OrderedDict()
|
||||
MAX_IMAGE = 8 * 1024 * 1024
|
||||
MAX_CACHE = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def valid_url(url):
|
||||
try:
|
||||
p = urlsplit(url)
|
||||
return (p.scheme in ('https', 'http') and bool(p.hostname) and not p.username and not p.password
|
||||
and p.port in (None, 80, 443) and len(url) <= 4096)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def register(url):
|
||||
if not isinstance(url, str) or not valid_url(url):
|
||||
return None
|
||||
with _lock:
|
||||
for token, known in _urls.items():
|
||||
if known == url:
|
||||
_urls.move_to_end(token)
|
||||
return '/source-image/' + token
|
||||
token = secrets.token_urlsafe(24)
|
||||
_urls[token] = url
|
||||
while len(_urls) > 4096:
|
||||
_urls.popitem(last=False)
|
||||
return '/source-image/' + token
|
||||
|
||||
|
||||
def artwork(entry):
|
||||
images = entry.get('images') or {}
|
||||
if not isinstance(images, dict):
|
||||
images = {}
|
||||
return {'icon': register(images.get('icon') or entry.get('icon')),
|
||||
'banner': register(images.get('banner')),
|
||||
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]}
|
||||
|
||||
|
||||
def image_type(data):
|
||||
if data.startswith(b'\x89PNG\r\n\x1a\n'):
|
||||
return 'image/png'
|
||||
if data.startswith(b'\xff\xd8\xff'):
|
||||
return 'image/jpeg'
|
||||
if data.startswith((b'GIF87a', b'GIF89a')):
|
||||
return 'image/gif'
|
||||
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
||||
return 'image/webp'
|
||||
raise SourceError('Artwork is not a supported image')
|
||||
|
||||
|
||||
def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
|
||||
"""deadline: time.monotonic() value by which the whole fetch, redirects included, must finish."""
|
||||
data = get(url, {'Accept': 'image/png,image/jpeg,image/webp,image/gif'}, redirects, deadline, limit)
|
||||
return data, image_type(data)
|
||||
|
||||
|
||||
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
|
||||
_resolvers = threading.BoundedSemaphore(4)
|
||||
|
||||
|
||||
def _resolve(host, port, timeout):
|
||||
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
|
||||
if not _resolvers.acquire(blocking=False):
|
||||
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
|
||||
found = {}
|
||||
|
||||
def run():
|
||||
try:
|
||||
found['addresses'] = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
except OSError as e:
|
||||
found['error'] = e
|
||||
finally:
|
||||
_resolvers.release()
|
||||
try:
|
||||
worker = threading.Thread(target=run, daemon=True)
|
||||
worker.start()
|
||||
except BaseException:
|
||||
_resolvers.release() # the worker never ran, so it can't release its slot
|
||||
raise
|
||||
worker.join(timeout)
|
||||
if 'error' in found:
|
||||
raise found['error']
|
||||
if 'addresses' not in found:
|
||||
raise SourceError('Artwork download took too long')
|
||||
return found['addresses']
|
||||
|
||||
|
||||
def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
|
||||
"""GET a public HTTP(S) URL within an overall deadline (default 60 s), redirects included.
|
||||
|
||||
A watchdog shuts the socket at the deadline, so a server trickling bytes can't outlast it."""
|
||||
deadline = time.monotonic() + 60 if deadline is None else deadline
|
||||
|
||||
def left():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise SourceError('Artwork download took too long')
|
||||
return remaining
|
||||
if not valid_url(url):
|
||||
raise SourceError('Artwork URL is not allowed')
|
||||
p = urlsplit(url)
|
||||
port = p.port or (443 if p.scheme == 'https' else 80)
|
||||
addresses = _resolve(p.hostname, port, left())
|
||||
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
|
||||
raise SourceError('Private network artwork is not allowed')
|
||||
# Connect to the checked IP, never resolve again between validation and use.
|
||||
live = [socket.create_connection((addresses[0][4][0], port), timeout=min(10, left()))]
|
||||
|
||||
def expire():
|
||||
try: # the plain socket method: it also ends a TLS handshake in progress
|
||||
socket.socket.shutdown(live[0], socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
watchdog = threading.Timer(left(), expire)
|
||||
watchdog.daemon = True
|
||||
watchdog.start()
|
||||
conn = http.client.HTTPConnection(p.hostname, port, timeout=10)
|
||||
try:
|
||||
if p.scheme == 'https':
|
||||
# Handshake only once the watchdog can reach the TLS socket, within the remaining time.
|
||||
live[0] = ssl.create_default_context().wrap_socket(live[0], server_hostname=p.hostname,
|
||||
do_handshake_on_connect=False)
|
||||
live[0].settimeout(min(10, left()))
|
||||
live[0].do_handshake()
|
||||
conn.sock = live[0]
|
||||
path = p.path or '/'
|
||||
if p.query:
|
||||
path += '?' + p.query
|
||||
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
|
||||
live[0].settimeout(min(10, left()))
|
||||
response = conn.getresponse()
|
||||
if response.status in (301, 302, 303, 307, 308) and redirects:
|
||||
target = urljoin(url, response.getheader('Location', ''))
|
||||
conn.close()
|
||||
return get(target, headers, redirects - 1, deadline, limit)
|
||||
if response.status != 200:
|
||||
raise SourceError('Artwork is unavailable')
|
||||
data = b''
|
||||
while len(data) <= limit:
|
||||
live[0].settimeout(min(10, left()))
|
||||
chunk = response.read1(min(16384, limit + 1 - len(data))) # one receive at most
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if len(data) > limit:
|
||||
raise SourceError('Artwork is too large')
|
||||
left()
|
||||
return data
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
if time.monotonic() >= deadline:
|
||||
raise SourceError('Artwork download took too long') from e
|
||||
raise
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
conn.close()
|
||||
live[0].close()
|
||||
|
||||
|
||||
def remember(url, data):
|
||||
value = (data, image_type(data))
|
||||
if len(data) > MAX_IMAGE:
|
||||
raise SourceError('Artwork is too large')
|
||||
with _lock:
|
||||
_cache[url] = value
|
||||
_cache.move_to_end(url)
|
||||
while sum(len(v[0]) for v in _cache.values()) > MAX_CACHE:
|
||||
_cache.popitem(last=False)
|
||||
return value
|
||||
|
||||
|
||||
def image(token):
|
||||
with _lock:
|
||||
url = _urls.get(token)
|
||||
if not url:
|
||||
raise SourceError('Unknown artwork')
|
||||
cached = _cache.get(url)
|
||||
if cached:
|
||||
_cache.move_to_end(url)
|
||||
return cached
|
||||
data, _ = fetch(url)
|
||||
return remember(url, data)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Small HTTPS cache and APK downloader for public publisher sources."""
|
||||
import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import frame_host
|
||||
from . import SourceError, SourceLimited
|
||||
|
||||
UA = 'FrameControl/0.1'
|
||||
APK_CAP = 2 * 1024 ** 3 # cached APKs across all sources, least recently used go first
|
||||
RECENT = 3600 # an APK used this recently may be about to be installed; never pruned
|
||||
_use_lock = threading.Lock() # pruning's final check and delete vs touch()/claim()
|
||||
_in_use = {} # APK path -> installs using it
|
||||
|
||||
|
||||
def touch(path):
|
||||
"""Mark a cached APK as just used; False if pruning already removed it."""
|
||||
with _use_lock:
|
||||
try:
|
||||
os.utime(str(path))
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def claim(path):
|
||||
"""Protect a downloaded APK from pruning until release(path)."""
|
||||
path = os.path.abspath(str(path))
|
||||
with _use_lock:
|
||||
os.utime(path) # raises if it has gone
|
||||
_in_use[path] = _in_use.get(path, 0) + 1
|
||||
|
||||
|
||||
def release(path):
|
||||
path = os.path.abspath(str(path))
|
||||
with _use_lock:
|
||||
if _in_use.get(path, 0) > 1:
|
||||
_in_use[path] -= 1
|
||||
else:
|
||||
_in_use.pop(path, None)
|
||||
BACKOFF = 600 # seconds to leave a host alone after 403/429 without Retry-After
|
||||
_limited = {} # host -> time.time() before which we don't contact it
|
||||
_limited_lock = threading.Lock()
|
||||
|
||||
|
||||
def _host(url):
|
||||
return (urllib.parse.urlsplit(url).hostname or '').lower()
|
||||
|
||||
|
||||
def throttle(url, headers=None):
|
||||
"""Remember that url's host asked us to back off; return the delay in seconds."""
|
||||
headers = headers or {}
|
||||
now, delay = time.time(), None
|
||||
value = (headers.get('Retry-After') or '').strip()
|
||||
if value.isdigit():
|
||||
delay = int(value)
|
||||
elif value:
|
||||
try:
|
||||
delay = parsedate_to_datetime(value).timestamp() - now
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
pass
|
||||
reset = headers.get('X-RateLimit-Reset') or ''
|
||||
if delay is None and headers.get('X-RateLimit-Remaining') == '0' and reset.isdigit():
|
||||
delay = int(reset) - now # GitHub
|
||||
delay = min(max(delay if delay is not None else BACKOFF, 1), 6 * 3600)
|
||||
with _limited_lock:
|
||||
_limited[_host(url)] = max(_limited.get(_host(url), 0), now + delay)
|
||||
return delay
|
||||
|
||||
|
||||
def wait_time(url):
|
||||
with _limited_lock:
|
||||
return max(0, _limited.get(_host(url), 0) - time.time())
|
||||
|
||||
|
||||
def limited_error(name, seconds, hint=''):
|
||||
minutes = max(1, int(round(seconds / 60)))
|
||||
return SourceLimited('%s is limiting requests; try again in %d minute%s%s'
|
||||
% (name, minutes, '' if minutes == 1 else 's', hint), seconds)
|
||||
|
||||
|
||||
def cache():
|
||||
path = frame_host.cache_dir('apk-sources', 'publisher')
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return str(path)
|
||||
|
||||
|
||||
def prune():
|
||||
"""Trim the download caches: APKs to APK_CAP by mtime, orphaned .part/temp files, old listings.
|
||||
|
||||
Only the long-running app prunes (at start and after store downloads): claim() is
|
||||
in-process, so the CLIs never prune and so can't delete an APK the app is installing.
|
||||
"""
|
||||
now, apks = time.time(), []
|
||||
for folder in (str(frame_host.cache_dir('apk-sources')), cache()):
|
||||
try:
|
||||
names = os.listdir(folder)
|
||||
except OSError:
|
||||
continue
|
||||
for name in names:
|
||||
path = os.path.join(folder, name)
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
age = now - st.st_mtime
|
||||
if os.path.isdir(path) and not os.path.islink(path):
|
||||
if name.startswith('tmp') and age > 86400: # an interrupted F-Droid index download
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
elif (name.endswith('.part') and age > 86400) or (name.endswith('.data') and age > 7 * 86400):
|
||||
os.remove(path)
|
||||
elif name.endswith('.apk'):
|
||||
apks.append((st.st_mtime, st.st_size, path))
|
||||
except OSError:
|
||||
pass
|
||||
total = sum(size for _, size, _ in apks)
|
||||
for _, size, path in sorted(apks):
|
||||
if total <= APK_CAP:
|
||||
break
|
||||
with _use_lock: # the scan is old news: check again right before deleting
|
||||
try:
|
||||
if os.path.abspath(path) in _in_use or time.time() - os.stat(path).st_mtime < RECENT:
|
||||
continue
|
||||
os.remove(path)
|
||||
total -= size
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def checked_url(url, hosts):
|
||||
try:
|
||||
p = urllib.parse.urlsplit(url)
|
||||
port = p.port
|
||||
except (ValueError, TypeError) as e:
|
||||
raise SourceError('Source returned an invalid URL') from e
|
||||
if p.scheme != 'https' or p.username or p.password or port not in (None, 443) or p.hostname not in hosts:
|
||||
raise SourceError('Source returned an unexpected download URL')
|
||||
return url
|
||||
|
||||
|
||||
class Redirect(urllib.request.HTTPRedirectHandler):
|
||||
def __init__(self, hosts):
|
||||
self.hosts = hosts
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
checked_url(newurl, self.hosts)
|
||||
result = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if result:
|
||||
result.remove_header('Authorization')
|
||||
return result
|
||||
|
||||
|
||||
def open_url(url, hosts, headers=None):
|
||||
checked_url(url, hosts)
|
||||
return urllib.request.build_opener(Redirect(hosts)).open(
|
||||
urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60)
|
||||
|
||||
|
||||
def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
|
||||
path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data')
|
||||
name = name or _host(url) or 'The source'
|
||||
|
||||
def cached():
|
||||
if os.path.isfile(path): # throttled: an older copy beats no results
|
||||
with open(path, 'rb') as f:
|
||||
return f.read()
|
||||
try:
|
||||
if os.path.isfile(path) and time.time() - os.path.getmtime(path) < ttl:
|
||||
with open(path, 'rb') as f:
|
||||
return f.read()
|
||||
wait = wait_time(url)
|
||||
if wait:
|
||||
data = cached()
|
||||
if data is None:
|
||||
raise limited_error(name, wait, hint)
|
||||
return data
|
||||
with open_url(url, hosts, headers) as r:
|
||||
data = r.read(8 * 1024 * 1024 + 1)
|
||||
if len(data) > 8 * 1024 * 1024:
|
||||
raise SourceError('Source index is too large')
|
||||
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as f:
|
||||
f.write(data)
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (403, 429):
|
||||
delay = throttle(url, e.headers)
|
||||
data = cached()
|
||||
if data is None:
|
||||
raise limited_error(name, delay, hint) from e
|
||||
return data
|
||||
raise SourceError('Source HTTP error: ' + str(e.code)) from e
|
||||
except (OSError, ValueError) as e:
|
||||
raise SourceError('Could not read source: ' + str(e)) from e
|
||||
|
||||
|
||||
def apk(url, hosts, digest=None, name=None):
|
||||
tmp = None
|
||||
name = name or _host(url)
|
||||
wait = wait_time(url)
|
||||
if wait:
|
||||
raise limited_error(name, wait)
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
|
||||
h = hashlib.sha256()
|
||||
with os.fdopen(fd, 'wb') as f, open_url(url, hosts) as r:
|
||||
size = 0
|
||||
while True:
|
||||
chunk = r.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > 2 * 1024 ** 3:
|
||||
raise SourceError('APK exceeds the 2 GiB download limit')
|
||||
h.update(chunk)
|
||||
f.write(chunk)
|
||||
actual = h.hexdigest()
|
||||
if digest and actual != digest:
|
||||
raise SourceError('SHA-256 mismatch; download discarded')
|
||||
with zipfile.ZipFile(tmp) as z:
|
||||
if 'AndroidManifest.xml' not in z.namelist():
|
||||
raise SourceError('Download is not an APK')
|
||||
path = os.path.join(cache(), actual + '.apk')
|
||||
os.replace(tmp, path)
|
||||
return {'apk': path, 'obb': [], 'sha256': actual, 'verified': bool(digest)}
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (403, 429):
|
||||
raise limited_error(name, throttle(url, e.headers)) from e
|
||||
raise SourceError('Could not download APK: HTTP error ' + str(e.code)) from e
|
||||
except (OSError, ValueError, zipfile.BadZipFile) as e:
|
||||
raise SourceError('Could not download APK: ' + str(e)) from e
|
||||
finally:
|
||||
if tmp and os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
@@ -0,0 +1,720 @@
|
||||
"""Signed F-Droid repositories. CLI: add|remove|list|search|download."""
|
||||
import argparse
|
||||
import base64
|
||||
import contextlib
|
||||
import errno
|
||||
import hashlib
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
if __package__ in (None, ''):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from apk_sources import SourceError, SourceLimited, _web
|
||||
import frame_host
|
||||
from frame_apk_sign import _der_parts, _cert_key, der
|
||||
from frame_catalog import _IndexReader, _reduce_index, _sha256
|
||||
|
||||
KIND = 'fdroid'
|
||||
CACHE_VERSION = 2
|
||||
_LOCK = threading.RLock() # settings only; never held while downloading
|
||||
_load_locks = {}
|
||||
_refreshing = {} # source id -> background refresh thread
|
||||
_retry_at = {} # source id -> time before which a failed refresh isn't retried
|
||||
_stale = set() # source ids currently served from an expired index
|
||||
MAX_AGE = 86400
|
||||
# Published by the repository operators; a user repository without a pin uses TOFU.
|
||||
FDROID_PIN = '43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab'
|
||||
IZZY_PIN = '3bf0d6abfeae2f401707b6d966be743bf0eee49c2561b9ba39073711f628937a'
|
||||
_DIGESTS = {
|
||||
'608648016503040201': ('sha256', '3031300d060960864801650304020105000420'),
|
||||
'608648016503040202': ('sha384', '3041300d060960864801650304020205000430'),
|
||||
'608648016503040203': ('sha512', '3051300d060960864801650304020305000440'),
|
||||
'2b0e03021a': ('sha1', '3021300906052b0e03021a05000414'),
|
||||
}
|
||||
|
||||
|
||||
def _fingerprint(value):
|
||||
value = re.sub(r'[:\s]', '', value or '').lower()
|
||||
if not re.fullmatch('[0-9a-f]{64}', value):
|
||||
raise SourceError('fingerprint must be a SHA-256 certificate fingerprint (64 hex digits)')
|
||||
return value
|
||||
|
||||
|
||||
def _url(url, fingerprint=None):
|
||||
if url.startswith('fdroidrepos://'):
|
||||
url = 'https://' + url[len('fdroidrepos://'):]
|
||||
p = urllib.parse.urlsplit(url)
|
||||
if p.scheme != 'https' or not p.hostname or p.username or p.password or p.fragment:
|
||||
raise SourceError('repository URL must use HTTPS without credentials or a fragment')
|
||||
params = urllib.parse.parse_qs(p.query)
|
||||
pins = params.pop('fingerprint', [])
|
||||
if params or len(pins) > 1:
|
||||
raise SourceError('only one fingerprint query parameter is supported')
|
||||
pin = _fingerprint(fingerprint) if fingerprint else None
|
||||
if pins:
|
||||
linked = _fingerprint(pins[0])
|
||||
if pin and pin != linked:
|
||||
raise SourceError('conflicting fingerprints')
|
||||
pin = linked
|
||||
return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin
|
||||
|
||||
|
||||
def _child(base, name):
|
||||
name = str(name).lstrip('/')
|
||||
decoded = urllib.parse.unquote(name)
|
||||
if not name or '\\' in decoded or any(x in ('.', '..') for x in decoded.split('/')):
|
||||
raise SourceError('unsafe repository file name')
|
||||
if '?' in decoded or urllib.parse.urlsplit(decoded).scheme:
|
||||
raise SourceError('repository file is outside its repository')
|
||||
url = urllib.parse.urljoin(base, urllib.parse.quote(decoded, safe='/')) # names may contain '#' or spaces
|
||||
if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment:
|
||||
raise SourceError('repository file is outside its repository')
|
||||
return url
|
||||
|
||||
|
||||
class _HTTPSRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if urllib.parse.urlsplit(newurl).scheme != 'https':
|
||||
raise SourceError('refusing non-HTTPS redirect')
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def _fetch(url, path, maximum):
|
||||
host = urllib.parse.urlsplit(url).hostname
|
||||
wait = _web.wait_time(url)
|
||||
if wait:
|
||||
raise _web.limited_error(host, wait)
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'})
|
||||
try:
|
||||
with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f:
|
||||
total = 0
|
||||
while True:
|
||||
chunk = r.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > maximum:
|
||||
raise SourceError('repository file exceeds size limit')
|
||||
f.write(chunk)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (403, 429):
|
||||
raise _web.limited_error(host, _web.throttle(url, e.headers)) from e
|
||||
raise
|
||||
|
||||
|
||||
def _limited(source, error):
|
||||
return _web.limited_error(source['name'], error.retry_after or _web.BACKOFF)
|
||||
|
||||
|
||||
def _children(item):
|
||||
return _der_parts(item[1])
|
||||
|
||||
|
||||
def _cms(data, content, strong=False):
|
||||
outer = _der_parts(data)
|
||||
if len(outer) != 1:
|
||||
raise ValueError('invalid CMS wrapper')
|
||||
wrapper = _children(outer[0])
|
||||
if wrapper[0][1].hex() != '2a864886f70d010702':
|
||||
raise ValueError('not CMS SignedData')
|
||||
fields = _children(_children(wrapper[1])[0])
|
||||
certs = next(_children(f) for f in fields[3:] if f[0] == 0xa0)
|
||||
signers = _children(fields[-1])
|
||||
if len(signers) != 1:
|
||||
raise ValueError('exactly one repository signer required')
|
||||
signer = _children(signers[0])
|
||||
sid = _children(signer[1])
|
||||
matching = []
|
||||
for cert in certs:
|
||||
tbs = _children(_children(cert)[0])
|
||||
offset = 1 if tbs[0][0] == 0xa0 else 0
|
||||
if tbs[offset][1] == sid[1][1] and tbs[offset + 2][2] == sid[0][2]:
|
||||
matching.append(cert[2])
|
||||
if len(matching) != 1:
|
||||
raise ValueError('missing or ambiguous signer certificate')
|
||||
cert = matching[0]
|
||||
digest, prefix = _DIGESTS[_children(signer[2])[0][1].hex()]
|
||||
if strong and digest == 'sha1':
|
||||
raise ValueError('SHA-1 signatures are not accepted for v2 indexes')
|
||||
at, signed = 3, content
|
||||
if signer[at][0] == 0xa0:
|
||||
attrs = {}
|
||||
for attr in _children(signer[at]):
|
||||
pair = _children(attr)
|
||||
oid = pair[0][1].hex()
|
||||
if oid in attrs:
|
||||
raise ValueError('duplicate CMS attribute')
|
||||
attrs[oid] = _children(pair[1])
|
||||
if attrs['2a864886f70d010904'][0][1] != hashlib.new(digest, content).digest():
|
||||
raise ValueError('CMS content digest mismatch')
|
||||
if attrs['2a864886f70d010903'][0][1].hex() != '2a864886f70d010701':
|
||||
raise ValueError('unexpected CMS content type')
|
||||
signed = der(0x31, signer[at][1])
|
||||
at += 1
|
||||
algorithm = _children(signer[at])[0][1].hex()
|
||||
allowed = {'sha1': '2a864886f70d010105', 'sha256': '2a864886f70d01010b',
|
||||
'sha384': '2a864886f70d01010c', 'sha512': '2a864886f70d01010d'}
|
||||
if algorithm not in ('2a864886f70d010101', allowed[digest]):
|
||||
raise ValueError('unsupported repository signature algorithm (RSA PKCS#1 required)')
|
||||
n, e, _ = _cert_key(cert)
|
||||
sig = signer[at + 1][1]
|
||||
size = (n.bit_length() + 7) // 8
|
||||
if not 256 <= size <= 1024 or n % 2 != 1 or not 3 <= e <= 0xffffffff or e % 2 != 1 or len(sig) != size or int.from_bytes(sig, 'big') >= n:
|
||||
raise ValueError('invalid RSA signature/key size')
|
||||
value = bytes.fromhex(prefix) + hashlib.new(digest, signed).digest()
|
||||
expected = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value
|
||||
if pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big') != expected:
|
||||
raise ValueError('repository RSA signature mismatch')
|
||||
return hashlib.sha256(cert).hexdigest()
|
||||
|
||||
|
||||
def _sections(data):
|
||||
sections = []
|
||||
for block in re.split(b'\r?\n\r?\n', data):
|
||||
if not block:
|
||||
continue
|
||||
attrs = {}
|
||||
for line in re.sub(b'\r?\n ', b'', block).splitlines():
|
||||
key, value = line.decode('utf-8').split(': ', 1)
|
||||
key = key.lower()
|
||||
if key in attrs:
|
||||
raise ValueError('duplicate manifest attribute')
|
||||
attrs[key] = value
|
||||
sections.append(attrs)
|
||||
return sections
|
||||
|
||||
|
||||
def _digest_check(attrs, suffix, content, strong=False):
|
||||
for label, digest in (('sha-512', 'sha512'), ('sha-384', 'sha384'), ('sha-256', 'sha256'), ('sha1', 'sha1'), ('sha-1', 'sha1')):
|
||||
if label + suffix in attrs and not (strong and digest == 'sha1'):
|
||||
if base64.b64decode(attrs[label + suffix], validate=True) != hashlib.new(digest, content).digest():
|
||||
raise ValueError('JAR digest mismatch')
|
||||
return
|
||||
raise ValueError('missing supported JAR digest')
|
||||
|
||||
|
||||
def _jar(path, member, pin, strong=False):
|
||||
"""strong: SHA-2 only (v2 entry.jar); index-v1.jar may still be SHA-1 signed."""
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = z.namelist()
|
||||
if len(names) > 64 or len(names) != len(set(names)) or any(
|
||||
i.file_size > (1024 * 1024 if i.filename.upper().startswith('META-INF/') else 256 * 1024 * 1024)
|
||||
for i in z.infolist()):
|
||||
raise ValueError('duplicate or oversized JAR member')
|
||||
blocks = [n for n in names if n.upper().startswith('META-INF/') and n.upper().endswith('.RSA')]
|
||||
if len(blocks) != 1:
|
||||
raise ValueError('exactly one RSA JAR signer required')
|
||||
sf = z.read(blocks[0][:-4] + '.SF')
|
||||
fingerprint = _cms(z.read(blocks[0]), sf, strong)
|
||||
if pin and fingerprint != pin:
|
||||
raise ValueError('repository fingerprint mismatch')
|
||||
manifest = z.read('META-INF/MANIFEST.MF')
|
||||
_digest_check(_sections(sf)[0], '-digest-manifest', manifest, strong)
|
||||
entries = [s for s in _sections(manifest)[1:] if s.get('name') == member]
|
||||
if len(entries) != 1:
|
||||
raise ValueError('index is not uniquely signed')
|
||||
content = z.read(member)
|
||||
_digest_check(entries[0], '-digest', content, strong)
|
||||
return content, fingerprint
|
||||
except (ValueError, KeyError, IndexError, StopIteration, RuntimeError, NotImplementedError, zipfile.BadZipFile) as e:
|
||||
raise SourceError('invalid signed repository: ' + str(e)) from e
|
||||
|
||||
|
||||
def _storage():
|
||||
return frame_host.data_dir('apk-repos.json')
|
||||
|
||||
|
||||
def _read():
|
||||
try:
|
||||
settings = json.loads(_storage().read_text())
|
||||
if not isinstance(settings, dict) or not isinstance(settings.get('repos'), list) or not isinstance(settings.get('enabled'), dict):
|
||||
raise ValueError('invalid settings structure')
|
||||
return settings
|
||||
except FileNotFoundError:
|
||||
return {'repos': [], 'enabled': {}}
|
||||
except (OSError, ValueError) as e:
|
||||
raise SourceError('cannot read repository settings: ' + str(e)) from e
|
||||
|
||||
|
||||
def _write(path, value):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(value, f, separators=(',', ':'))
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
def _state_path():
|
||||
return frame_host.data_dir('apk-repo-state.json')
|
||||
|
||||
|
||||
def _states():
|
||||
try:
|
||||
states = json.loads(_state_path().read_text())
|
||||
if not isinstance(states, dict):
|
||||
raise ValueError('invalid state structure')
|
||||
return states
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (OSError, ValueError) as e:
|
||||
raise SourceError('cannot read repository state: ' + str(e)) from e
|
||||
|
||||
|
||||
def _state(source):
|
||||
"""Newest accepted index timestamp and whether a v2 index was ever accepted (rollback protection)."""
|
||||
with _LOCK:
|
||||
state = _states().get(source['id'])
|
||||
return state if isinstance(state, dict) and state.get('url') == source['url'] else {}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _state_file_lock():
|
||||
"""Inter-process lock: the CLI and the app must not publish indexes out of order."""
|
||||
path = frame_host.data_dir('apk-repo-state.lock')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(str(path), 'a+b') as f:
|
||||
if os.name == 'nt':
|
||||
import msvcrt
|
||||
while True:
|
||||
try:
|
||||
f.seek(0)
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
|
||||
break
|
||||
except OSError as e: # LK_LOCK gives up after ~10 s of contention; keep waiting
|
||||
if e.errno not in (errno.EACCES, errno.EDEADLK):
|
||||
raise
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
f.seek(0)
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _check_timestamp(source, timestamp):
|
||||
last = _state(source).get('timestamp')
|
||||
if last is not None and (type(timestamp) is not int or timestamp < last):
|
||||
raise SourceError('repository index is older than the one already accepted (possible rollback); refused')
|
||||
|
||||
|
||||
def _accept(source, timestamp, v2):
|
||||
with _LOCK:
|
||||
states = _states()
|
||||
state = _state(source)
|
||||
states[source['id']] = {'url': source['url'], 'v2': bool(v2 or state.get('v2')),
|
||||
'timestamp': timestamp if type(timestamp) is int else state.get('timestamp')}
|
||||
_write(_state_path(), states)
|
||||
|
||||
|
||||
def user_repos():
|
||||
with _LOCK:
|
||||
return _read()['repos']
|
||||
|
||||
|
||||
def sources():
|
||||
builtins = [('fdroid', 'F-Droid', 'https://f-droid.org/repo/', FDROID_PIN),
|
||||
('fdroid-archive', 'F-Droid archive', 'https://f-droid.org/archive/', FDROID_PIN),
|
||||
('izzyondroid', 'IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/', IZZY_PIN)]
|
||||
settings = _read()
|
||||
return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True,
|
||||
# The archive only holds superseded versions; it clutters search unless asked for.
|
||||
enabled=settings['enabled'].get(i, i != 'fdroid-archive'), trust='community')
|
||||
for i, n, u, p in builtins] + settings['repos']
|
||||
|
||||
|
||||
def _text(value):
|
||||
if isinstance(value, dict):
|
||||
return value.get('en-US') or next((v for v in value.values() if v), '')
|
||||
return value or ''
|
||||
|
||||
|
||||
class _PlainText(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts, self.hidden = [], 0
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in ('script', 'style'):
|
||||
self.hidden += 1
|
||||
elif tag in ('br', 'p', 'div', 'li'):
|
||||
self.parts.append(' ')
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ('script', 'style'):
|
||||
self.hidden = max(0, self.hidden - 1)
|
||||
elif tag in ('p', 'div', 'li'):
|
||||
self.parts.append(' ')
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self.hidden:
|
||||
self.parts.append(data)
|
||||
|
||||
|
||||
def _summary(value):
|
||||
parser = _PlainText()
|
||||
parser.feed(_text(value))
|
||||
parser.close()
|
||||
return ' '.join(''.join(parser.parts).split())
|
||||
|
||||
|
||||
def _images(meta, base):
|
||||
def url(file):
|
||||
name = file.get('name') if isinstance(file, dict) else file
|
||||
try:
|
||||
return _child(base, name) if isinstance(name, str) and name else None
|
||||
except SourceError:
|
||||
return None # one odd image name mustn't hide the app
|
||||
|
||||
icon = url(_text(meta.get('icon')))
|
||||
banner = url(_text(meta.get('featureGraphic')))
|
||||
screenshots = []
|
||||
groups = meta.get('screenshots') or {}
|
||||
for device, legacy in (('phone', 'phoneScreenshots'), ('sevenInch', 'sevenInchScreenshots')):
|
||||
files = _text(groups.get(device)) or _text(meta.get(legacy)) or []
|
||||
for file in files if isinstance(files, list) else []:
|
||||
image = url(file)
|
||||
if image and image not in screenshots:
|
||||
screenshots.append(image)
|
||||
if len(screenshots) == 6:
|
||||
break
|
||||
if len(screenshots) == 6:
|
||||
break
|
||||
return {'icon': icon, 'banner': banner, 'screenshots': screenshots}
|
||||
|
||||
|
||||
def _reduce(path, source):
|
||||
compatible = _reduce_index(path)
|
||||
result = {}
|
||||
with open(path, encoding='utf-8') as f:
|
||||
reader = _IndexReader(f)
|
||||
for key in reader.members():
|
||||
if key != 'packages':
|
||||
reader.value()
|
||||
continue
|
||||
for pkg in reader.members():
|
||||
item = reader.value()
|
||||
if pkg not in compatible:
|
||||
continue
|
||||
meta = item.get('metadata', {})
|
||||
files = {v['file'].get('name'): v for v in item.get('versions', {}).values()
|
||||
if isinstance(v, dict) and isinstance(v.get('file'), dict)}
|
||||
versions = []
|
||||
for v in compatible[pkg]:
|
||||
original = files[v['name']]
|
||||
versions.append(dict(v, size=original['file'].get('size'), updated=_date(original.get('added'))))
|
||||
versions.sort(key=lambda v: (v['version_code'], v['abis'] == ['arm64-v8a']), reverse=True)
|
||||
latest = versions[0]
|
||||
images = _images(meta, source['url'])
|
||||
result[pkg] = dict(source=source['id'], id=pkg, package=pkg,
|
||||
name=_text(meta.get('name')) or pkg, summary=_summary(meta.get('summary')),
|
||||
icon=images['icon'], images=images, developer=_text(meta.get('authorName')) or None,
|
||||
page=meta.get('webSite') or source['url'], vr=None, free=True,
|
||||
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
|
||||
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
|
||||
return result
|
||||
|
||||
|
||||
def _date(value):
|
||||
return time.strftime('%Y-%m-%d', time.gmtime(value / 1000)) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def _v1(content, path):
|
||||
index = json.loads(content)
|
||||
apps = {a['packageName']: a for a in index['apps']}
|
||||
packages = {}
|
||||
for pkg, builds in index['packages'].items():
|
||||
app = apps.get(pkg, {})
|
||||
localized = app.get('localized', {})
|
||||
meta = {k: _text({locale: fields[k] for locale, fields in localized.items() if fields.get(k)}) or app.get(k)
|
||||
for k in ('name', 'summary', 'license', 'webSite', 'authorName')}
|
||||
for field in ('icon', 'featureGraphic', 'phoneScreenshots', 'sevenInchScreenshots'):
|
||||
images = {}
|
||||
for locale, fields in localized.items():
|
||||
value = fields.get(field)
|
||||
if not value:
|
||||
continue
|
||||
prefix = pkg + '/' + locale + '/'
|
||||
if field.endswith('Screenshots'):
|
||||
images[locale] = [{'name': prefix + field + '/' + name} for name in value[:6]]
|
||||
else:
|
||||
images[locale] = {'name': prefix + value}
|
||||
meta[field] = images
|
||||
if not meta['icon'] and app.get('icon'):
|
||||
meta['icon'] = {'en-US': {'name': 'icons/' + app['icon']}}
|
||||
versions = {}
|
||||
for i, v in enumerate(builds):
|
||||
versions[str(i)] = {'manifest': {'versionName': v.get('versionName'), 'versionCode': v['versionCode'],
|
||||
'usesSdk': {'minSdkVersion': v.get('minSdkVersion', 1)}, 'nativecode': v.get('nativecode', [])},
|
||||
'file': {'name': v['apkName'], 'sha256': v.get('hash') if v.get('hashType') == 'sha256' else None,
|
||||
'size': v.get('size')}, 'added': v.get('added')}
|
||||
packages[pkg] = {'metadata': meta, 'versions': versions}
|
||||
path.write_text(json.dumps({'packages': packages}))
|
||||
return (index.get('repo') or {}).get('timestamp')
|
||||
|
||||
|
||||
def _cached(source, cache):
|
||||
"""(apps, pin, expired) from a cache matching this source, or None."""
|
||||
try:
|
||||
saved = json.loads(cache.read_text())
|
||||
if (saved.get('version') == CACHE_VERSION and saved.get('fingerprint') == source.get('fingerprint')
|
||||
and saved.get('url') == source['url']):
|
||||
return saved['apps'], saved['fingerprint'], time.time() - cache.stat().st_mtime >= MAX_AGE
|
||||
except (OSError, ValueError, KeyError, AttributeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def stale(source):
|
||||
"""True while results come from an expired index that is being (or failed to be) refreshed."""
|
||||
return source['id'] in _stale
|
||||
|
||||
|
||||
def _refresh_later(source):
|
||||
with _LOCK:
|
||||
if source['id'] in _refreshing or time.time() < _retry_at.get(source['id'], 0):
|
||||
return
|
||||
def run():
|
||||
try:
|
||||
_load(source, force=True)
|
||||
except Exception:
|
||||
with _LOCK:
|
||||
_retry_at[source['id']] = time.time() + 600
|
||||
finally:
|
||||
with _LOCK:
|
||||
_refreshing.pop(source['id'], None)
|
||||
_refreshing[source['id']] = thread = threading.Thread(target=run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
def _source_lock(source_id):
|
||||
with _LOCK:
|
||||
return _load_locks.setdefault(source_id, threading.Lock())
|
||||
|
||||
|
||||
def _load(source, force=False):
|
||||
if not re.fullmatch(r'[a-z0-9-]+', source['id']):
|
||||
raise SourceError('invalid source id')
|
||||
_url(source['url'], source.get('fingerprint'))
|
||||
cache = frame_host.cache_dir('apk-sources', source['id'] + '.json')
|
||||
found = None if force else _cached(source, cache)
|
||||
if found:
|
||||
if found[2]: # expired: serve it now, marked stale, and refresh without blocking anyone
|
||||
_stale.add(source['id'])
|
||||
_refresh_later(source)
|
||||
return found[:2]
|
||||
with _source_lock(source['id']):
|
||||
found = None if force else _cached(source, cache) # another caller may have just loaded it
|
||||
if found and not found[2]:
|
||||
return found[:2]
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
|
||||
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
|
||||
v2 = True
|
||||
try:
|
||||
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in (404, 410):
|
||||
raise
|
||||
if _state(source).get('v2'):
|
||||
raise SourceError('repository no longer serves its signed v2 index; '
|
||||
'refusing to fall back to the older v1 index') from e
|
||||
v2 = False
|
||||
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
|
||||
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
|
||||
timestamp = _v1(content, raw)
|
||||
_check_timestamp(source, timestamp)
|
||||
else:
|
||||
content, pin = _jar(jar, 'entry.json', source.get('fingerprint'), strong=True)
|
||||
signed = json.loads(content)
|
||||
timestamp, entry = signed.get('timestamp'), signed['index']
|
||||
_check_timestamp(source, timestamp)
|
||||
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024)
|
||||
if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']):
|
||||
raise SourceError('index SHA-256 or size mismatch')
|
||||
apps = _reduce(raw, source)
|
||||
with _state_file_lock(): # recheck: another process may have accepted a newer index meanwhile
|
||||
_check_timestamp(source, timestamp)
|
||||
if not v2 and _state(source).get('v2'): # a v2 index was accepted while we fetched v1
|
||||
raise SourceError('repository now has a signed v2 index; refusing the older v1 index')
|
||||
_write(cache, {'version': CACHE_VERSION, 'url': source['url'], 'fingerprint': pin, 'apps': apps})
|
||||
_accept(source, timestamp, v2)
|
||||
_stale.discard(source['id'])
|
||||
return apps, pin
|
||||
except SourceLimited as e:
|
||||
raise _limited(source, e) from e
|
||||
except SourceError:
|
||||
raise
|
||||
except (OSError, ValueError, KeyError, TypeError, IndexError) as e:
|
||||
raise SourceError('cannot load repository: ' + str(e)) from e
|
||||
|
||||
|
||||
def add_repo(url, fingerprint=None, name=None):
|
||||
url, pin = _url(url, fingerprint)
|
||||
with _LOCK:
|
||||
existing = next((s for s in _read()['repos'] if s['url'] == url), None)
|
||||
if existing:
|
||||
if pin and pin != existing['fingerprint']:
|
||||
raise SourceError('repository already has a different pinned fingerprint; remove it first')
|
||||
pin = existing['fingerprint']
|
||||
source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND,
|
||||
name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname,
|
||||
url=url, builtin=False, enabled=True, trust='user', fingerprint=pin)
|
||||
_, source['fingerprint'] = _load(source, force=True) # network work outside the settings lock
|
||||
source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None
|
||||
with _LOCK:
|
||||
settings = _read()
|
||||
current = next((s for s in settings['repos'] if s['id'] == source['id']), None)
|
||||
if current and current['fingerprint'] != source['fingerprint']:
|
||||
raise SourceError('repository already has a different pinned fingerprint; remove it first')
|
||||
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
|
||||
_write(_storage(), settings)
|
||||
return source
|
||||
|
||||
|
||||
def remove_repo(source_id):
|
||||
with _LOCK:
|
||||
settings = _read()
|
||||
if not any(s['id'] == source_id for s in settings['repos']):
|
||||
raise SourceError('unknown user repository')
|
||||
settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id]
|
||||
_write(_storage(), settings)
|
||||
with _state_file_lock(), _LOCK: # lock order: state file, then _LOCK
|
||||
states = _states()
|
||||
if states.pop(source_id, None) is not None: # re-adding is a deliberate new trust decision
|
||||
_write(_state_path(), states)
|
||||
|
||||
|
||||
def set_enabled(source_id, enabled):
|
||||
if not isinstance(enabled, bool):
|
||||
raise SourceError('enabled must be a boolean')
|
||||
with _LOCK:
|
||||
settings = _read()
|
||||
source = next((s for s in sources() if s['id'] == source_id), None)
|
||||
if not source:
|
||||
raise SourceError('unknown repository')
|
||||
if source['builtin']:
|
||||
settings['enabled'][source_id] = enabled
|
||||
else:
|
||||
for s in settings['repos']:
|
||||
if s['id'] == source_id:
|
||||
s['enabled'] = enabled
|
||||
_write(_storage(), settings)
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
if not source.get('enabled', True):
|
||||
return []
|
||||
apps, _ = _load(source)
|
||||
words = query.casefold().split()
|
||||
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
|
||||
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
|
||||
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
if not source.get('enabled', True):
|
||||
raise SourceError('repository is disabled')
|
||||
apps, _ = _load(source)
|
||||
if entry_id not in apps:
|
||||
raise SourceError('app has no Lepton-compatible version in this repository')
|
||||
return apps[entry_id]
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
entry = details(source, entry_id)
|
||||
version = next((v for v in entry['versions'] if version_code is None or str(v['version_code']) == str(version_code)), None)
|
||||
if not version or not re.fullmatch('[0-9a-f]{64}', version.get('sha256') or ''):
|
||||
raise SourceError('version is missing or has no SHA-256 digest')
|
||||
sha = version['sha256']
|
||||
path = frame_host.cache_dir('apk-sources', sha + '.apk')
|
||||
try:
|
||||
reuse = False
|
||||
if _web.touch(path): # before hashing: a just-used APK is never pruned
|
||||
try:
|
||||
reuse = _sha256(path) == sha
|
||||
except FileNotFoundError: # removed anyway (e.g. by hand): download again
|
||||
pass
|
||||
if not reuse:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
|
||||
os.close(fd)
|
||||
try:
|
||||
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3)
|
||||
if _sha256(tmp) != sha:
|
||||
raise SourceError('APK SHA-256 mismatch; download discarded')
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
return {'apk': str(path), 'obb': [], 'sha256': sha, 'verified': True}
|
||||
except SourceLimited as e:
|
||||
raise _limited(source, e) from e
|
||||
except OSError as e:
|
||||
raise SourceError('cannot download APK: ' + str(e)) from e
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
add = sub.add_parser('add')
|
||||
add.add_argument('url')
|
||||
add.add_argument('--fingerprint')
|
||||
add.add_argument('--name')
|
||||
sub.add_parser('list')
|
||||
remove = sub.add_parser('remove')
|
||||
remove.add_argument('source')
|
||||
for command in ('search', 'download'):
|
||||
p = sub.add_parser(command)
|
||||
p.add_argument('source')
|
||||
p.add_argument('query' if command == 'search' else 'package')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
_cli(parser, args)
|
||||
finally:
|
||||
for thread in list(_refreshing.values()): # daemon refreshes would die with the CLI, even on errors
|
||||
thread.join()
|
||||
|
||||
|
||||
def _cli(parser, args):
|
||||
try:
|
||||
if args.command == 'add':
|
||||
result = add_repo(args.url, args.fingerprint, args.name)
|
||||
elif args.command == 'list':
|
||||
result = sources()
|
||||
elif args.command == 'remove':
|
||||
result = remove_repo(args.source)
|
||||
else:
|
||||
source = next((s for s in sources() if s['id'] == args.source), None)
|
||||
if not source:
|
||||
raise SourceError('unknown repository id; use list')
|
||||
result = search(source, args.query) if args.command == 'search' else download(source, args.package)
|
||||
print(json.dumps(result, indent=2))
|
||||
except SourceError as e:
|
||||
parser.exit(1, 'error: ' + str(e) + '\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Curated publisher releases; optional topic discovery is page-only."""
|
||||
import fnmatch, json, os, re, urllib.parse
|
||||
|
||||
from . import SourceError, _web
|
||||
|
||||
KIND = 'github'
|
||||
API = 'https://api.github.com'
|
||||
TOPICS = ('oculus-quest', 'openxr', 'quest')
|
||||
HOSTS = ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': KIND, 'kind': KIND, 'name': 'GitHub releases', 'url': 'https://github.com',
|
||||
'builtin': True, 'enabled': True, 'trust': 'community'}]
|
||||
|
||||
|
||||
def _curated():
|
||||
with open(os.path.join(os.path.dirname(__file__), 'github_curated.json')) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _api(path):
|
||||
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
|
||||
token = os.environ.get('FRAME_GITHUB_TOKEN')
|
||||
if token:
|
||||
headers['Authorization'] = 'Bearer ' + token
|
||||
try:
|
||||
return json.loads(_web.read(API + path, ('api.github.com',), headers, name='GitHub',
|
||||
hint='' if token else ' (set FRAME_GITHUB_TOKEN to raise the limit)'))
|
||||
except (ValueError, TypeError) as e:
|
||||
raise SourceError('Invalid GitHub response') from e
|
||||
|
||||
|
||||
def _entry(source, c, approved=True):
|
||||
artwork = c.get('images') or {}
|
||||
icon = artwork.get('icon') or c.get('icon') or 'https://github.com/' + c['repo'].split('/')[0] + '.png'
|
||||
images = {'icon': icon,
|
||||
# No GitHub social-preview card: it's repo text and stats, not art; the UI draws a fallback.
|
||||
'banner': artwork.get('banner'),
|
||||
'screenshots': list(artwork.get('screenshots') or [])}
|
||||
return {'source': source['id'], 'id': c['repo'], 'package': None,
|
||||
'name': c['name'], 'summary': c.get('summary') or '', 'icon': icon,
|
||||
'images': images,
|
||||
'page': 'https://github.com/' + c['repo'], 'version': None, 'version_code': None,
|
||||
'min_sdk': None, 'abis': None, 'vr': c.get('vr'), 'size': None,
|
||||
'free': True if approved else None, 'license': c.get('license'), 'updated': None,
|
||||
'downloadable': approved}
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
limit = max(0, min(int(limit), 100))
|
||||
if not limit:
|
||||
return []
|
||||
if query.startswith('topic:'):
|
||||
topic = query[6:].strip()
|
||||
if topic not in TOPICS:
|
||||
raise SourceError('Choose topic:oculus-quest, topic:openxr or topic:quest')
|
||||
data = _api('/search/repositories?' + urllib.parse.urlencode(
|
||||
{'q': 'topic:' + topic + ' archived:false', 'sort': 'stars', 'per_page': limit}))
|
||||
curated = {c['repo'].lower(): c for c in _curated()}
|
||||
out = []
|
||||
for repo in data.get('items', []):
|
||||
c = curated.get(repo['full_name'].lower())
|
||||
entry = _entry(source, c or {'repo': repo['full_name'], 'name': repo['name'],
|
||||
'summary': repo.get('description'), 'vr': True,
|
||||
'icon': (repo.get('owner') or {}).get('avatar_url')}, bool(c))
|
||||
out.append(entry)
|
||||
return out
|
||||
words = query.lower().split()
|
||||
return [_entry(source, c) for c in _curated()
|
||||
if all(w in (c['name'] + ' ' + c['repo'] + ' ' + c['summary']).lower()
|
||||
for w in words)][:limit]
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
c = next((c for c in _curated() if c['repo'].lower() == entry_id.lower()), None)
|
||||
if not c:
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', entry_id):
|
||||
raise SourceError('Invalid GitHub repository')
|
||||
entry = _entry(source, {'repo': entry_id, 'name': entry_id}, False)
|
||||
return {**entry, 'versions': []}
|
||||
entry = _entry(source, c)
|
||||
releases = _api('/repos/' + c['repo'] + '/releases?per_page=10')
|
||||
versions = []
|
||||
for release in releases:
|
||||
if release.get('draft') or (release.get('prerelease') and not c.get('allow_prerelease')):
|
||||
continue
|
||||
assets = [a for a in release.get('assets', []) if
|
||||
fnmatch.fnmatch(a['name'].lower(), c['asset_pattern'].lower())]
|
||||
for asset in assets:
|
||||
digest = asset.get('digest') or ''
|
||||
versions.append({'version': release['tag_name'], 'version_code': None,
|
||||
'asset_id': asset['id'], 'name': asset['name'], 'min_sdk': None,
|
||||
'prerelease': bool(release.get('prerelease')),
|
||||
'size': asset.get('size'), 'updated': release.get('published_at'),
|
||||
'url': asset['browser_download_url'],
|
||||
'sha256': digest[7:] if re.fullmatch(r'sha256:[0-9a-f]{64}', digest) else None})
|
||||
entry['versions'] = versions
|
||||
entry['downloadable'] = bool(versions)
|
||||
if versions:
|
||||
entry.update({k: versions[0][k] for k in ('version', 'size', 'updated')})
|
||||
return entry
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
entry = details(source, entry_id)
|
||||
versions = entry['versions']
|
||||
if not versions:
|
||||
raise SourceError('No approved APK release; open the publisher page')
|
||||
# GitHub asset IDs and tags are not Android version codes.
|
||||
if version_code is not None:
|
||||
raise SourceError('GitHub does not publish Android version codes; download the latest release')
|
||||
v = versions[0]
|
||||
expected = 'https://github.com/' + entry['id'] + '/releases/download/'
|
||||
if not v['url'].startswith(expected):
|
||||
raise SourceError('APK URL does not belong to the curated publisher')
|
||||
return _web.apk(v['url'], HOSTS, v['sha256'], name='GitHub')
|
||||
@@ -0,0 +1,72 @@
|
||||
[
|
||||
{
|
||||
"repo": "KhronosGroup/OpenXR-SDK-Source",
|
||||
"name": "hello_xr",
|
||||
"summary": "A simple VR scene for checking that OpenXR graphics and controllers work.",
|
||||
"license": "Apache-2.0",
|
||||
"vr": true,
|
||||
"asset_pattern": "hello_xr-Vulkan-*.apk",
|
||||
"allow_prerelease": false,
|
||||
"images": {
|
||||
"icon": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png",
|
||||
"banner": null,
|
||||
"screenshots": []
|
||||
},
|
||||
"icon": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png"
|
||||
},
|
||||
{
|
||||
"repo": "icosa-foundation/open-brush",
|
||||
"name": "Open Brush",
|
||||
"summary": "Paint in the air and create colorful 3D artwork in VR.",
|
||||
"license": "Apache-2.0",
|
||||
"vr": true,
|
||||
"asset_pattern": "*Quest*.apk",
|
||||
"allow_prerelease": true,
|
||||
"images": {
|
||||
"icon": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png",
|
||||
"banner": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/open-brush.png",
|
||||
"screenshots": [
|
||||
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_785ea37d63378146dfe0f0ffa3f1d5c155ca978f.1920x1080.jpg",
|
||||
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_0a9c208e26a43cf34879c2ca361d4c8f18af8cba.1920x1080.jpg",
|
||||
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_19b25b86ef55c0d8769a65135d60eaae8fa40553.1920x1080.jpg"
|
||||
]
|
||||
},
|
||||
"icon": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png"
|
||||
},
|
||||
{
|
||||
"repo": "SgtBilko76/SuperTux-3D",
|
||||
"name": "SuperTux 3D",
|
||||
"summary": "Run and jump through Tux\u2019s platform adventure on a layered 3D screen in VR.",
|
||||
"license": "GPL-3.0",
|
||||
"vr": true,
|
||||
"asset_pattern": "*Quest*.apk",
|
||||
"allow_prerelease": true,
|
||||
"images": {
|
||||
"icon": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png",
|
||||
"banner": "https://www.supertux.org/images/0_7_0/github_preview.png",
|
||||
"screenshots": [
|
||||
"https://www.supertux.org/images/0_7_0/github_preview.png"
|
||||
]
|
||||
},
|
||||
"icon": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png"
|
||||
},
|
||||
{
|
||||
"repo": "arpruss/OpenSaberPlus",
|
||||
"name": "Open Saber Plus",
|
||||
"summary": "Slash glowing blocks to the beat with two lightsabers, with songs from BeatSaver.",
|
||||
"license": "MIT",
|
||||
"vr": true,
|
||||
"asset_pattern": "OpenSaberPlus.apk",
|
||||
"allow_prerelease": false,
|
||||
"images": {
|
||||
"icon": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png",
|
||||
"banner": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif",
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif",
|
||||
"https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_2.gif",
|
||||
"https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_3.gif"
|
||||
]
|
||||
},
|
||||
"icon": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Free Android VR RSS listings. Download pages stay in the publisher's UI."""
|
||||
import html, re, urllib.parse, xml.etree.ElementTree as ET
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
from . import SourceError, _web
|
||||
|
||||
KIND = 'itch'
|
||||
FEEDS = ('openxr', 'oculus-quest')
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
|
||||
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
|
||||
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
|
||||
|
||||
|
||||
def _parse(source, data):
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError as e:
|
||||
raise SourceError('Invalid itch.io RSS feed') from e
|
||||
entries = []
|
||||
for item in root.findall('./channel/item'):
|
||||
page = item.findtext('link') or ''
|
||||
p = urllib.parse.urlsplit(page)
|
||||
if p.scheme != 'https' or not (p.hostname or '').endswith('.itch.io') or p.username or ':' in p.netloc:
|
||||
continue
|
||||
if item.findtext('price') != '$0.00' or item.findtext('platforms/android') != 'yes':
|
||||
continue
|
||||
cover = item.findtext('imageurl') or None
|
||||
if cover and not cover.startswith('https://img.itch.zone/'):
|
||||
cover = None
|
||||
updated = None
|
||||
try:
|
||||
updated = parsedate_to_datetime(item.findtext('updateDate')).date().isoformat()
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
entries.append({'source': source['id'], 'id': page, 'package': None,
|
||||
'name': item.findtext('plainTitle') or item.findtext('title') or page,
|
||||
'summary': html.unescape(re.sub('<[^>]*>', '', item.findtext('description') or '')).strip(),
|
||||
'icon': cover, 'images': {'icon': cover, 'banner': cover, 'screenshots': []},
|
||||
'page': page, 'version': None, 'version_code': None, 'min_sdk': None,
|
||||
'abis': None, 'vr': True, 'size': None, 'free': True, 'license': None,
|
||||
'updated': updated, 'downloadable': False})
|
||||
return entries
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
limit = max(0, min(int(limit), 100))
|
||||
if not limit:
|
||||
return []
|
||||
entries = {}
|
||||
tag = source.get('tag', 'openxr')
|
||||
if tag not in FEEDS:
|
||||
raise SourceError('Unsupported itch.io feed')
|
||||
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
|
||||
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
|
||||
entries.setdefault(entry['id'], entry)
|
||||
words = query.lower().split()
|
||||
return [e for e in entries.values() if all(w in (e['name'] + ' ' + e['summary']).lower()
|
||||
for w in words)][:limit]
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
entry = next((e for e in search(source, '', 100) if e['id'] == entry_id), None)
|
||||
if not entry:
|
||||
raise SourceError('Game is not in the current free Android VR feed; open its publisher page')
|
||||
return {**entry, 'versions': []}
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Parallel APK search and source selection. No device access during searches.
|
||||
|
||||
Optional store metadata: images {icon, banner, screenshots}, developer,
|
||||
description, popularity, open_source, requires_meta_services, frame_tested.
|
||||
Only an explicit frame_tested=True produces a working-on-Frame verdict.
|
||||
"""
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import pkgutil
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
|
||||
import frame_host
|
||||
from apk_sources import SourceError, SourceLimited
|
||||
|
||||
_lock = threading.RLock()
|
||||
_running = {}
|
||||
_pending = {} # source id -> newest query waiting for the running one
|
||||
_game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
|
||||
_status = {}
|
||||
TIMEOUT = 12
|
||||
|
||||
|
||||
def modules():
|
||||
import apk_sources
|
||||
found, errors = [], []
|
||||
for item in pkgutil.iter_modules(apk_sources.__path__):
|
||||
if item.name == 'search' or item.name.startswith('_'):
|
||||
continue
|
||||
try:
|
||||
module = importlib.import_module('apk_sources.' + item.name)
|
||||
if getattr(module, 'KIND', None):
|
||||
found.append(module)
|
||||
except Exception as e:
|
||||
errors.append({'id': item.name, 'name': item.name, 'enabled': False,
|
||||
'trust': 'unknown', 'status': 'error', 'error': str(e)})
|
||||
if os.environ.get('FRAME_APK_SEARCH_DEMO') == '1':
|
||||
found.append(importlib.import_module('apk_sources._demo'))
|
||||
return found, errors
|
||||
|
||||
|
||||
def settings_path():
|
||||
return frame_host.data_dir('apk-sources', 'enabled.json')
|
||||
|
||||
|
||||
def overrides():
|
||||
try:
|
||||
return json.loads(settings_path().read_text())
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
|
||||
|
||||
def registry():
|
||||
result = []
|
||||
mods, errors = modules()
|
||||
with _lock:
|
||||
enabled = overrides()
|
||||
for module in mods:
|
||||
try:
|
||||
for source in module.sources():
|
||||
source = dict(source)
|
||||
source['enabled'] = enabled.get(source['id'], source.get('enabled', True))
|
||||
source.update(_status.get(source['id'], {'status': 'not searched'}))
|
||||
result.append((module, source))
|
||||
except Exception as e:
|
||||
errors.append({'id': module.KIND, 'name': module.KIND, 'enabled': False,
|
||||
'trust': 'unknown', 'status': 'error', 'error': str(e)})
|
||||
return result, errors
|
||||
|
||||
|
||||
def sources():
|
||||
items, errors = registry()
|
||||
return [s for _, s in items] + errors
|
||||
|
||||
|
||||
def resolve(source_id):
|
||||
for module, source in registry()[0]:
|
||||
if source['id'] == source_id:
|
||||
return module, source
|
||||
raise SourceError('Unknown source')
|
||||
|
||||
|
||||
def set_enabled(source_id, enabled):
|
||||
module, source = resolve(source_id)
|
||||
if hasattr(module, 'set_enabled'):
|
||||
module.set_enabled(source_id, enabled) # never call into a source while holding _lock
|
||||
with _lock:
|
||||
values = overrides()
|
||||
values[source_id] = enabled
|
||||
path = settings_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(values))
|
||||
tmp.replace(path)
|
||||
return {'message': source['name'] + (' enabled' if enabled else ' disabled')}
|
||||
|
||||
|
||||
def repo_module():
|
||||
module = next((m for m in modules()[0] if m.KIND == 'fdroid'), None)
|
||||
if module is None or not hasattr(module, 'add_repo'):
|
||||
raise SourceError('User repositories are not available in this build')
|
||||
return module
|
||||
|
||||
|
||||
def manage_repo(action, **kwargs):
|
||||
module = repo_module()
|
||||
if not hasattr(module, action):
|
||||
raise SourceError('User repositories are not available in this build')
|
||||
return getattr(module, action)(**kwargs)
|
||||
|
||||
|
||||
def normalise(name):
|
||||
return ' '.join(re.findall(r'\w+', unicodedata.normalize('NFKC', name or '').casefold()))
|
||||
|
||||
|
||||
def fit(entry):
|
||||
sdk, abis = entry.get('min_sdk'), entry.get('abis')
|
||||
reasons = []
|
||||
blocked = (sdk is not None and sdk > 30) or (abis is not None and bool(abis) and 'arm64-v8a' not in abis)
|
||||
if sdk is not None and sdk > 30:
|
||||
reasons.append('Needs Android API %s; Lepton supports 30' % sdk)
|
||||
if abis and 'arm64-v8a' not in abis:
|
||||
reasons.append('No arm64-v8a build')
|
||||
known = sdk is not None and abis is not None
|
||||
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
|
||||
if 'vrapi' in hints:
|
||||
reasons.append('Legacy VrApi requires a translator')
|
||||
if 'godot' in hints:
|
||||
reasons.append('Older Godot builds can crash on the missing clipboard service')
|
||||
if 'openxr' in hints:
|
||||
reasons.append('OpenXR candidate; required extensions still need checking')
|
||||
if entry.get('vr'):
|
||||
reasons.append('VR runtime compatibility is not guaranteed')
|
||||
return {'installable': False if blocked else True if known else None,
|
||||
'verdict': "Won't install" if blocked else 'Installable' if known else 'Compatibility unknown',
|
||||
'reasons': reasons}
|
||||
|
||||
|
||||
def verdict(entry):
|
||||
compatibility = fit(entry)
|
||||
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
|
||||
if entry.get('requires_meta_services') is True:
|
||||
return {'label': "Needs Meta Quest services; won't run", 'tone': 'blocked'}
|
||||
if entry.get('min_sdk') is not None and entry['min_sdk'] > 30:
|
||||
return {'label': 'Might not work: needs a newer Android than the Frame has', 'tone': 'blocked'}
|
||||
if compatibility['installable'] is False:
|
||||
return {'label': "This version isn't made for the Frame", 'tone': 'blocked'}
|
||||
if 'vrapi' in hints:
|
||||
return {'label': "Made for older Quest headsets; won't run on the Frame", 'tone': 'blocked'}
|
||||
if entry.get('frame_tested') is True:
|
||||
return {'label': 'Works on the Frame', 'tone': 'works'}
|
||||
if compatibility['installable'] is True:
|
||||
return {'label': 'Ready to try on the Frame', 'tone': 'ready'}
|
||||
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
|
||||
|
||||
|
||||
def decorate(entry):
|
||||
from apk_sources import _images
|
||||
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry))
|
||||
|
||||
|
||||
def details(source_id, entry_id):
|
||||
module, source = resolve(source_id)
|
||||
return decorate(dict(module.details(source, entry_id), source=source_id,
|
||||
source_name=source['name'], trust=source.get('trust')))
|
||||
|
||||
|
||||
def offer_rank(entry):
|
||||
compatible = entry['fit']['installable'] is True
|
||||
return (entry.get('downloadable') is True and entry['fit']['installable'] is not False,
|
||||
entry.get('verified') is True, compatible,
|
||||
str(entry.get('updated') or '') if compatible else '',
|
||||
(entry.get('version_code') or 0) if compatible else 0,
|
||||
entry.get('trust') == 'official')
|
||||
|
||||
|
||||
def group(entries, query='', vr=None, installable=False):
|
||||
groups = {}
|
||||
for entry in entries:
|
||||
entry = decorate(entry)
|
||||
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
|
||||
continue
|
||||
if installable and entry['fit']['installable'] is not True:
|
||||
continue
|
||||
key = ('package', entry['package']) if entry.get('package') else ('name', normalise(entry.get('name')))
|
||||
if not key[1]:
|
||||
key = ('id', entry['source'], entry['id'])
|
||||
groups.setdefault(key, []).append(entry)
|
||||
result = []
|
||||
for offers in groups.values():
|
||||
offers.sort(key=offer_rank, reverse=True)
|
||||
best = offers[0]
|
||||
result.append({'name': best.get('name'), 'package': best.get('package'),
|
||||
'summary': best.get('summary'), 'offers': offers})
|
||||
q = normalise(query)
|
||||
|
||||
def browse(a): # a headset store: VR first, then apps with artwork, newest first
|
||||
o = a['offers'][0]
|
||||
art = o.get('images') or {}
|
||||
return (o.get('vr') is not True, not art.get('banner'), not art.get('screenshots'),
|
||||
''.join(chr(0x10ffff - ord(c)) for c in str(o.get('updated') or '')))
|
||||
if not q:
|
||||
# Unknown fit stays in the VR-first order; only apps known not to install sink.
|
||||
result.sort(key=lambda a: (all(o['fit']['installable'] is False for o in a['offers']),) + browse(a))
|
||||
return result
|
||||
result.sort(key=lambda a: (not any(normalise(o.get('name')) == q for o in a['offers']),
|
||||
not any(o['fit']['installable'] is True for o in a['offers']),
|
||||
not any(normalise(o.get('name')).startswith(q) for o in a['offers']),
|
||||
a['offers'][0].get('vr') is not True,
|
||||
normalise(a['name'])))
|
||||
return result
|
||||
|
||||
|
||||
def _launch(module, source, query, limit):
|
||||
"""One search per source at a time; the newest different query runs next."""
|
||||
key = source['id']
|
||||
task = {'event': threading.Event(), 'query': (query, limit), 'started': time.monotonic(),
|
||||
'job': (module, source)}
|
||||
with _lock:
|
||||
old = _running.get(key)
|
||||
if old and not old['event'].is_set():
|
||||
if old['query'] == task['query']:
|
||||
return old
|
||||
queued = _pending.get(key)
|
||||
if queued and queued['query'] == task['query']:
|
||||
return queued
|
||||
_pending[key] = task
|
||||
return task
|
||||
_running[key] = task
|
||||
_start(key, task)
|
||||
return task
|
||||
|
||||
|
||||
def _start(key, task):
|
||||
module, source = task['job']
|
||||
query, limit = task['query']
|
||||
|
||||
def run():
|
||||
queued = None
|
||||
try:
|
||||
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
|
||||
for e in module.search(source, query, limit=limit) if e.get('free') is True]
|
||||
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
|
||||
except Exception as e:
|
||||
task['error'] = str(e)
|
||||
task['limited'] = isinstance(e, SourceLimited)
|
||||
finally:
|
||||
with _lock: # completion and queue handover are one step for _launch
|
||||
queued = _pending.pop(key, None)
|
||||
if queued:
|
||||
_running[key] = queued
|
||||
task['event'].set()
|
||||
if queued:
|
||||
_start(key, queued)
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
|
||||
items, errors = registry()
|
||||
if source and source not in [s['id'] for _, s in items]:
|
||||
raise SourceError('Unknown source')
|
||||
chosen = [(m, s) for m, s in items if s['enabled'] and (not source or s['id'] == source)]
|
||||
# Page-only sources (SideQuest) can't be searched; offer a link to browse them instead.
|
||||
elsewhere = [{'name': s['name'], 'url': s['url']} for m, s in chosen if s.get('page_only')]
|
||||
tasks = [(s, _launch(m, s, query, limit)) for m, s in chosen if not s.get('page_only')]
|
||||
entries, statuses = [], list(errors)
|
||||
for s, task in tasks:
|
||||
status = {'id': s['id'], 'name': s['name']}
|
||||
if not task['event'].wait(max(0, task['started'] + timeout - time.monotonic())):
|
||||
# Still working (e.g. first download of a large index); it keeps going and fills the cache.
|
||||
status.update(status='loading')
|
||||
elif 'error' in task:
|
||||
status.update(status='limited' if task.get('limited') else 'error', error=task['error'])
|
||||
else:
|
||||
status.update(status='ok', stale=task['stale'])
|
||||
entries.extend(task['entries'])
|
||||
statuses.append(status)
|
||||
with _lock:
|
||||
_status[s['id']] = {k: v for k, v in status.items() if k not in ('id', 'name')}
|
||||
return {'apps': group(entries, query, vr, installable), 'sources': statuses, 'elsewhere': elsewhere}
|
||||
|
||||
|
||||
def warm():
|
||||
"""Start every enabled source's index download in the background (server start, new repo)."""
|
||||
from apk_sources import _web
|
||||
_web.prune()
|
||||
items, _ = registry()
|
||||
for m, s in items:
|
||||
if s['enabled'] and not s.get('page_only'):
|
||||
_launch(m, s, '', 50) # same as the first browse, so that search reuses it
|
||||
|
||||
|
||||
def install(source_id, entry_id, version_code=None, progress=None):
|
||||
import frame_android
|
||||
module, source = resolve(source_id)
|
||||
if not source['enabled']:
|
||||
raise SourceError('This source is disabled')
|
||||
entry = module.details(source, entry_id)
|
||||
if entry.get('free') is not True or entry.get('downloadable') is not True:
|
||||
raise SourceError('This app must be obtained from its developer page')
|
||||
if progress:
|
||||
progress('Downloading', None)
|
||||
downloaded = module.download(source, entry_id, version_code=version_code)
|
||||
obb = downloaded.get('obb') or entry.get('obb') or []
|
||||
if obb and not hasattr(frame_android, 'install_obb'):
|
||||
raise SourceError('This app needs OBB data; this build cannot install it yet')
|
||||
kwargs = {'name': entry.get('name'), 'icon_png': downloaded.get('icon_png') or entry.get('icon_png'),
|
||||
'source': source['name']}
|
||||
if 'artwork' in inspect.signature(frame_android.install).parameters:
|
||||
# The source's own image URLs (not the UI's /source-image/ proxy paths) become Steam library art.
|
||||
images = entry.get('images') if isinstance(entry.get('images'), dict) else {}
|
||||
art = {'icon': images.get('icon') or entry.get('icon'), 'banner': images.get('banner'),
|
||||
'screenshots': [u for u in images.get('screenshots') or [] if u][:4]}
|
||||
kwargs['artwork'] = downloaded.get('artwork') or {k: v for k, v in art.items() if v} or None
|
||||
if progress:
|
||||
progress('Installing', None)
|
||||
from apk_sources import _web
|
||||
try:
|
||||
_web.claim(downloaded['apk']) # no cache pruning while it installs
|
||||
except OSError as e:
|
||||
raise SourceError('The downloaded APK disappeared before installing; try again') from e
|
||||
try:
|
||||
_web.prune() # the app is the only pruner (see _web.prune)
|
||||
result = frame_android.install(downloaded['apk'], **kwargs)
|
||||
finally:
|
||||
_web.release(downloaded['apk'])
|
||||
if obb:
|
||||
# OBB files go into the app's own instance, which only exists while the app runs.
|
||||
with _lock:
|
||||
_game_data[result['package']] = list(obb)
|
||||
result = dict(result, game_data=True, message='Installed ' + (entry.get('name') or result['package']) +
|
||||
'. It also needs its game data: open it once on the Frame, then choose Add game data.')
|
||||
return result
|
||||
|
||||
|
||||
def add_game_data(package):
|
||||
import frame_android
|
||||
with _lock:
|
||||
paths = _game_data.get(package)
|
||||
if not paths:
|
||||
raise SourceError('No downloaded game data is waiting for this app; install it again from the store')
|
||||
result = frame_android.install_obb(package, paths)
|
||||
with _lock:
|
||||
_game_data.pop(package, None)
|
||||
return dict(result, message='Game data added')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""SideQuest page links only: its terms do not authorise third-party scraping.
|
||||
|
||||
No API calls, cached listings or automated downloads. See docs/sidequest.md.
|
||||
"""
|
||||
from . import SourceError
|
||||
|
||||
KIND = 'sidequest'
|
||||
URL = 'https://sidequestvr.com'
|
||||
REASON = ('SideQuest is page-only: its terms restrict scraping and unauthorised '
|
||||
'access. Browse and download with SideQuest, then import a developer-provided APK.')
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': KIND, 'kind': KIND, 'name': 'SideQuest', 'url': URL,
|
||||
'builtin': True, 'enabled': True, 'trust': 'community',
|
||||
'page_only': True, 'reason': REASON}]
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
# Do not invent catalogue results or interpret a query as a verified free app.
|
||||
return []
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
entry_id = str(entry_id)
|
||||
if not entry_id.isascii() or not entry_id.isdecimal() or len(entry_id) > 12:
|
||||
raise SourceError('SideQuest listing ids must be numeric')
|
||||
return {'source': source['id'], 'id': entry_id, 'package': None,
|
||||
'name': 'SideQuest listing ' + entry_id, 'summary': REASON,
|
||||
'icon': None, 'page': URL + '/app/' + entry_id, 'version': None,
|
||||
'version_code': None, 'min_sdk': None, 'abis': None, 'vr': None,
|
||||
'size': None, 'free': None, 'license': None, 'updated': None,
|
||||
'downloadable': False, 'versions': [], 'tags': [], 'headsets': [],
|
||||
'images': {'icon': None, 'banner': None, 'screenshots': []}}
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
entry = details(source, entry_id)
|
||||
raise SourceError(REASON + ' ' + entry['page'])
|
||||
@@ -0,0 +1,30 @@
|
||||
// Uses index.html's api(), which sends the X-Frame-UI key every /api call needs.
|
||||
(() => {
|
||||
const get=id=>document.getElementById(id), status=get('steamGridStatus');
|
||||
function show(data) {
|
||||
status.textContent=data.environment?'SteamGridDB key is set by an environment variable.':
|
||||
data.steamgriddb_configured?'SteamGridDB key saved.':'No key configured. Source images and generated art are enabled.';
|
||||
}
|
||||
async function save(value) {
|
||||
try {show(await api('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';}
|
||||
catch(e) {status.textContent=e.message;}
|
||||
}
|
||||
get('saveSteamGridKey').onclick=()=>save(get('steamGridKey').value.trim());
|
||||
get('clearSteamGridKey').onclick=()=>save('');
|
||||
get('refreshAndroidArt').onclick=async()=>{
|
||||
const button=get('refreshAndroidArt');button.disabled=true;
|
||||
try {
|
||||
const result=await runJob('Refresh library artwork','library-artwork',()=>api('/api/android',{action:'refresh-art',all:true}));
|
||||
if (result) {
|
||||
const items=[...(result.apps||[]),...(result.titles||[])];
|
||||
const failed=items.filter(a=>a.error);
|
||||
status.textContent=failed.length?`${failed.length} of ${items.length} failed: ${failed.map(a=>(a.package||a.id)+': '+a.error).join('; ')}`:
|
||||
`Refreshed artwork for ${items.length} apps and titles.`;
|
||||
if (typeof loadAndroid==='function') loadAndroid();
|
||||
if (typeof loadTitles==='function') loadTitles();
|
||||
}
|
||||
} catch(e) {status.textContent=e.message;}
|
||||
finally {button.disabled=false;}
|
||||
};
|
||||
api('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
|
||||
})();
|
||||
@@ -8,11 +8,13 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
||||
patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
|
||||
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
"""
|
||||
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
|
||||
import frame_apk
|
||||
import frame_artwork
|
||||
import frame_host
|
||||
import tempfile
|
||||
import zipfile
|
||||
@@ -56,8 +58,12 @@ def ssh(cmd, input=None, timeout=120):
|
||||
|
||||
def shortcut_tool(*args, timeout=60):
|
||||
with open(SHORTCUTS) as f:
|
||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
|
||||
timeout=timeout).strip()
|
||||
script = f.read()
|
||||
if args and args[0] == 'render':
|
||||
with open(os.path.join(ROOT, 'frame/android/library_artwork.js')) as f:
|
||||
script = 'ART_RENDERER = ' + repr(f.read()) + '\n' + script
|
||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=script,
|
||||
timeout=timeout).strip()
|
||||
|
||||
|
||||
def instance_id(pkg):
|
||||
@@ -140,7 +146,7 @@ def _write_meta(d, meta):
|
||||
install_hooks = []
|
||||
|
||||
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None):
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
|
||||
start, info = time.time(), None
|
||||
try:
|
||||
info = apk_info(apk_path)
|
||||
@@ -161,9 +167,9 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
|
||||
patched = os.path.join(tmp, 'app.apk')
|
||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||
info['launchable'] = True
|
||||
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
|
||||
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
|
||||
else:
|
||||
meta = _install(apk_path, info, pkg, flatscreen, name, source)
|
||||
meta = _install(apk_path, info, pkg, flatscreen, name, source, artwork)
|
||||
except FrameError as e:
|
||||
_after_install(info, None, e, start)
|
||||
raise
|
||||
@@ -179,39 +185,53 @@ def _after_install(info, meta, error, start):
|
||||
pass # reporting must never change an install's outcome
|
||||
|
||||
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
try:
|
||||
images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
|
||||
except (ValueError, OSError) as e:
|
||||
raise FrameError(f'could not prepare artwork: {e}') from e
|
||||
iid = instance_id(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
existing = read_meta(pkg)
|
||||
ok = False
|
||||
ok, created = False, None
|
||||
try:
|
||||
ssh(f'mkdir -p {d}')
|
||||
_copy(apk_path, f'{d}/app.apk.part')
|
||||
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
||||
icon = ''
|
||||
if info['icon_png']:
|
||||
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
|
||||
icon = f'$HOME/{d}/icon.png'
|
||||
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
|
||||
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
|
||||
home = ssh('echo $HOME').strip()
|
||||
shortcut = _int((existing or {}).get('shortcut'))
|
||||
if not shortcut or shortcut not in _shortcut_ids():
|
||||
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
|
||||
icon.replace('$HOME', home))
|
||||
'')
|
||||
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
|
||||
if not shortcut:
|
||||
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
|
||||
created = shortcut
|
||||
presentation = apply_library(shortcut, name or info['label'], d, images,
|
||||
vr=not flatscreen, home=home,
|
||||
exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}',
|
||||
details={'package': pkg, 'version': info['version'],
|
||||
'source': source or os.path.basename(apk_path)})
|
||||
presentation['warnings'] = art_warnings + presentation.get('warnings', [])
|
||||
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
|
||||
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
||||
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
|
||||
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
|
||||
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'source': source or os.path.basename(apk_path)}
|
||||
'source': source or os.path.basename(apk_path),
|
||||
'library_warnings': presentation.get('warnings', []),
|
||||
'artwork': presentation.get('artwork', {}), 'library_version': 2}
|
||||
_write_meta(d, meta)
|
||||
ok = True
|
||||
return meta
|
||||
finally:
|
||||
if not ok and created:
|
||||
try:
|
||||
shortcut_tool('remove', str(created))
|
||||
except FrameError:
|
||||
pass
|
||||
if not ok and not existing:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
try:
|
||||
@@ -220,6 +240,121 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def apply_library(shortcut, label, directory, images, vr=None, home=None, exe='', start_dir='', details=None,
|
||||
category='Android', fill_only=False):
|
||||
"""Mandatory for every sideload: render all five slots before reporting success.
|
||||
|
||||
vr None leaves Steam's VR flag as it is (devkit titles declare their own). fill_only (automatic
|
||||
backfill) sets only slots Steam has no art for, and never the name, exe or flags."""
|
||||
home = home or ssh('echo $HOME').strip()
|
||||
d = directory
|
||||
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
|
||||
paths = {}
|
||||
for slot, (ext, data) in images.items():
|
||||
path = f'{d}/artwork/source-{slot}.{ext}'
|
||||
ssh(f'cat > {shlex.quote(path)}', input=data)
|
||||
paths[slot] = f'{home}/{path}' if not path.startswith('/') else path
|
||||
manifest = {'label': label, 'images': paths}
|
||||
plan = f'{d}/artwork/input.json'
|
||||
ssh(f'cat > {shlex.quote(plan)}', input=json.dumps(manifest))
|
||||
absolute = f'{home}/{plan}' if not plan.startswith('/') else plan
|
||||
# The Frame retries once with generated art, each attempt allowed 75 s.
|
||||
rendered = json.loads(shortcut_tool('render', absolute, timeout=200))
|
||||
art = rendered['paths']
|
||||
if set(art) != set(frame_artwork.SLOTS):
|
||||
raise FrameError('Steam artwork renderer did not produce every slot')
|
||||
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
|
||||
'' if vr is None else '1' if vr else '0', json.dumps(art),
|
||||
json.dumps({'category': category, 'details': details or {},
|
||||
'fill_only': fill_only}), timeout=120))
|
||||
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
|
||||
result['artwork'] = art
|
||||
if category == 'Android':
|
||||
ssh(f'cat > {shlex.quote(d)}/shortcut.id', input=str(int(shortcut)))
|
||||
return result
|
||||
|
||||
|
||||
def cached_art_script(d):
|
||||
"""Frame-side Python that loads the source images kept beside d's last render into `cached`."""
|
||||
return f"""import base64, json, os
|
||||
cached = {{}}
|
||||
directory = os.path.realpath({d!r})
|
||||
try:
|
||||
with open(os.path.join(directory, 'artwork/input.json')) as f:
|
||||
plan = json.load(f)
|
||||
for slot, path in plan.get('images', {{}}).items():
|
||||
if os.path.commonpath([os.path.realpath(path), directory]) != directory:
|
||||
continue
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read(12 * 1024 * 1024 + 1)
|
||||
if len(data) <= 12 * 1024 * 1024:
|
||||
cached[slot] = base64.b64encode(data).decode()
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
"""
|
||||
|
||||
|
||||
def art_missing(m):
|
||||
"""True when a Frame Control install has no complete Steam artwork on record."""
|
||||
return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
|
||||
|
||||
|
||||
def refresh_art(pkg=None, artwork=None, fill_only=False):
|
||||
"""Refresh existing APK library entries without reinstalling or stopping them.
|
||||
|
||||
fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library)."""
|
||||
if pkg is None:
|
||||
results = []
|
||||
for app in list_apps():
|
||||
try:
|
||||
results.append(refresh_art(app['package'], artwork, fill_only))
|
||||
except Exception as e: # one app's failure must not stop the others
|
||||
results.append({'package': app['package'], 'label': app.get('label'), 'error': str(e) or type(e).__name__})
|
||||
return results
|
||||
with _install_lock: # shared with install and remove: a removed app is never recreated
|
||||
m = _meta_or_fail(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
|
||||
modules = {}
|
||||
for module in ('frame_apk', 'frame_apk_vr'):
|
||||
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
|
||||
modules[module] = f.read()
|
||||
script = 'import sys, types, json, base64\n'
|
||||
for module, source in modules.items():
|
||||
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
|
||||
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
|
||||
script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
|
||||
script += cached_art_script(d) + "info['artwork'] = cached\nprint(json.dumps(info))\n"
|
||||
info = json.loads(ssh('python3 -', input=script))
|
||||
icon = base64.b64decode(info['icon_png'])
|
||||
cached = {k: base64.b64decode(v) for k, v in info.get('artwork', {}).items()}
|
||||
images, warnings = frame_artwork.prepare(m['label'], icon, artwork if artwork is not None else cached)
|
||||
home = ssh('echo $HOME').strip()
|
||||
created = False
|
||||
if not m['shortcut'] or m['shortcut'] not in _shortcut_ids():
|
||||
m['shortcut'] = _int(shortcut_tool('add', m['label'], f'{home}/{d}/launch.sh', f'{home}/{d}'))
|
||||
if not m['shortcut']:
|
||||
raise FrameError('Steam did not create the missing shortcut')
|
||||
m['game_id'] = game_id(m['shortcut'])
|
||||
created = True
|
||||
try:
|
||||
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
|
||||
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m,
|
||||
fill_only=fill_only and not created)
|
||||
except Exception:
|
||||
if created:
|
||||
try:
|
||||
shortcut_tool('remove', str(m['shortcut']))
|
||||
except FrameError:
|
||||
pass # keep the render error, not the cleanup's
|
||||
raise
|
||||
m.update(artwork=result.get('artwork', {}), library_version=2, art_pending=False)
|
||||
m['library_warnings'] = warnings + result.get('warnings', [])
|
||||
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
_write_meta(d, m)
|
||||
return m
|
||||
|
||||
def _int(v):
|
||||
try:
|
||||
n = int(v)
|
||||
@@ -268,6 +403,7 @@ def list_apps():
|
||||
continue
|
||||
if m:
|
||||
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
|
||||
m['art_missing'] = art_missing(m)
|
||||
apps.append(m)
|
||||
return sorted(apps, key=lambda m: m['label'].lower())
|
||||
|
||||
@@ -291,18 +427,30 @@ def launch(pkg):
|
||||
|
||||
def stop(pkg):
|
||||
m = _meta_or_fail(pkg)
|
||||
if m['shortcut']:
|
||||
try:
|
||||
shortcut_tool('stop', str(int(m['shortcut'])))
|
||||
except FrameError:
|
||||
pass # Steam unavailable: the container stop also ends its waiting launcher.
|
||||
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
|
||||
return m
|
||||
|
||||
|
||||
def remove(pkg, keep_data=False):
|
||||
with _install_lock: # never while an install or artwork refresh of any app is writing
|
||||
return _remove(pkg, keep_data)
|
||||
|
||||
|
||||
def _remove(pkg, keep_data):
|
||||
m = _meta_or_fail(pkg)
|
||||
stop(pkg)
|
||||
if m['shortcut']:
|
||||
# Best effort: Steam may not be running, and the files must still go.
|
||||
try:
|
||||
shortcut_tool('remove', str(int(m['shortcut'])))
|
||||
except FrameError:
|
||||
pass # already gone from Steam
|
||||
result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
|
||||
m['library_warnings'] = result.get('warnings', [])
|
||||
except (FrameError, ValueError, AttributeError) as e:
|
||||
m['library_warnings'] = [f'Steam shortcut not removed: {e}']
|
||||
iid = int(m['instance'])
|
||||
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
|
||||
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
|
||||
@@ -354,6 +502,21 @@ def patch(src, dst, add=None):
|
||||
raise FrameError(str(e)) from e
|
||||
|
||||
|
||||
def install_obb(pkg, paths):
|
||||
import frame_android_data
|
||||
return frame_android_data.install_obb(pkg, paths)
|
||||
|
||||
|
||||
def backup_data(pkg, destination):
|
||||
import frame_android_data
|
||||
return frame_android_data.backup_data(pkg, destination)
|
||||
|
||||
|
||||
def restore_data(pkg, archive):
|
||||
import frame_android_data
|
||||
return frame_android_data.restore_data(pkg, archive)
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['help']
|
||||
try:
|
||||
@@ -374,6 +537,13 @@ def main():
|
||||
elif cmd == 'install':
|
||||
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
|
||||
xr_compat=False if '--no-xr-compat' in args else None)
|
||||
elif cmd == 'refresh-art':
|
||||
if len(args) != 1:
|
||||
raise FrameError('usage: refresh-art PACKAGE or refresh-art --all')
|
||||
r = refresh_art(None if args[0] == '--all' else args[0])
|
||||
if isinstance(r, list) and any('error' in item for item in r):
|
||||
print(json.dumps(r, indent=1))
|
||||
raise SystemExit(1)
|
||||
elif cmd == 'patch':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
|
||||
@@ -389,8 +559,18 @@ def main():
|
||||
with open(path, 'rb') as f:
|
||||
additions[entry] = f.read()
|
||||
r = patch(opts.src, opts.dst, additions)
|
||||
elif cmd == 'install-obb':
|
||||
if len(args) < 2:
|
||||
raise FrameError('install-obb requires PACKAGE OBB [OBB ...]')
|
||||
r = install_obb(args[0], args[1:])
|
||||
elif cmd in ('backup-data', 'restore-data'):
|
||||
if len(args) != 2:
|
||||
raise FrameError(cmd + ' requires PACKAGE ARCHIVE.tar.gz')
|
||||
r = (backup_data if cmd == 'backup-data' else restore_data)(*args)
|
||||
elif cmd == 'list':
|
||||
r = list_apps()
|
||||
if any(a['art_missing'] for a in r):
|
||||
print('Some apps have no Steam artwork: python3 ui/frame_android.py refresh-art --all', file=sys.stderr)
|
||||
elif cmd in ('launch', 'stop', 'probe'):
|
||||
r = globals()[cmd](args[0])
|
||||
elif cmd == 'remove':
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Expansion files and stopped-instance private-data backups. Python stdlib only."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
import frame_android as android
|
||||
|
||||
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
||||
|
||||
|
||||
def _stream(command, src=None, dst=None):
|
||||
try:
|
||||
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||
stdin=src if src else subprocess.DEVNULL,
|
||||
stdout=dst if dst else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=1800)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise android.FrameError('app-data transfer timed out')
|
||||
except OSError as error:
|
||||
raise android.FrameError('app-data transfer failed: ' + str(error))
|
||||
if result.returncode:
|
||||
raise android.FrameError(result.stderr.decode(errors='replace').strip()[-600:] or
|
||||
'app-data transfer failed')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, 'rb') as src:
|
||||
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _meta(package):
|
||||
if not android.PKG_RE.fullmatch(package or ''):
|
||||
raise android.FrameError('invalid package name')
|
||||
meta = android._meta_or_fail(package)
|
||||
if meta['package'] != package:
|
||||
raise android.FrameError('installed app metadata has a different package')
|
||||
return meta
|
||||
|
||||
|
||||
def install_obb(package, paths):
|
||||
if not android.PKG_RE.fullmatch(package or ''):
|
||||
raise android.FrameError('invalid package name')
|
||||
paths = [Path(p).resolve() for p in paths]
|
||||
if not paths:
|
||||
raise android.FrameError('select at least one OBB file')
|
||||
names = set()
|
||||
for path in paths:
|
||||
if (not re.fullmatch(r'(main|patch)\.[0-9]+\.' + re.escape(package) + r'\.obb', path.name)
|
||||
or not path.is_file() or path.stat().st_size == 0 or path.name in names):
|
||||
raise android.FrameError('OBB must be a nonempty main/patch.<version>.' + package + '.obb file')
|
||||
names.add(path.name)
|
||||
with android._install_lock:
|
||||
meta = _meta(package)
|
||||
container = 'lepton-steamlaunch-' + str(int(meta['instance']))
|
||||
# No implicit launch. Android resolves /sdcard, never an assumed host path.
|
||||
running = android.ssh('podman ps --format "{{.Names}}"').splitlines()
|
||||
if container not in running:
|
||||
raise android.FrameError('start this app instance before installing OBB data')
|
||||
dest = '/sdcard/Android/obb/' + package
|
||||
results = []
|
||||
for path in paths:
|
||||
digest = _sha256(path)
|
||||
part = dest + '/.frame-' + uuid.uuid4().hex + '.part'
|
||||
target = dest + '/' + path.name
|
||||
script = (f'set -eu; mkdir -p {dest}; umask 002; '
|
||||
f'trap "rm -f {part}" EXIT; cat > {part}; '
|
||||
f'test "$(sha256sum {part} | cut -d " " -f 1)" = {digest}; '
|
||||
f'chmod 664 {part}; mv {part} {target}')
|
||||
command = shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script])
|
||||
with path.open('rb') as src:
|
||||
_stream(command, src=src)
|
||||
results.append({'name': path.name, 'path': target, 'sha256': digest})
|
||||
return {'package': package, 'instance': meta['instance'], 'obb': results,
|
||||
'verified': True}
|
||||
|
||||
|
||||
def _data_command(action, meta):
|
||||
container = 'lepton-steamlaunch-' + str(int(meta['instance']))
|
||||
# Fail closed if podman cannot enumerate containers; don't mistake errors for stopped.
|
||||
guard = ('running=$(podman ps --format "{{.Names}}") || exit 1; '
|
||||
f'if printf "%s\\n" "$running" | grep -Fxq {shlex.quote(container)}; then '
|
||||
'echo "stop the app before backup or restore" >&2; exit 1; fi; ')
|
||||
return guard + shlex.join(['podman', 'unshare', 'python3', '-c', REMOTE.read_text(),
|
||||
action, meta['package'], str(int(meta['instance']))])
|
||||
|
||||
|
||||
def backup_data(package, destination):
|
||||
destination = Path(destination).expanduser().absolute()
|
||||
if destination.exists():
|
||||
raise android.FrameError('backup destination already exists')
|
||||
with android._install_lock:
|
||||
meta = _meta(package)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.frame-backup-', dir=str(destination.parent))
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as dst:
|
||||
_stream(_data_command('backup', meta), dst=dst)
|
||||
result = _inspect(temporary, meta)
|
||||
# Exclusive publication: a concurrently created backup is never overwritten.
|
||||
os.link(temporary, str(destination))
|
||||
return dict(result, path=str(destination), sha256=_sha256(destination))
|
||||
finally:
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def _inspect(path, meta):
|
||||
import tarfile
|
||||
try:
|
||||
return runpy.run_path(str(REMOTE))['inspect_archive'](path, meta['package'], int(meta['instance']))
|
||||
except (OSError, EOFError, ValueError, tarfile.TarError) as error:
|
||||
raise android.FrameError('invalid app-data backup: ' + str(error))
|
||||
|
||||
|
||||
def restore_data(package, archive):
|
||||
with android._install_lock:
|
||||
meta = _meta(package)
|
||||
_inspect(archive, meta)
|
||||
with open(archive, 'rb') as src:
|
||||
result = _stream(_data_command('restore', meta), src=src)
|
||||
try:
|
||||
return json.loads(result)
|
||||
except (ValueError, TypeError):
|
||||
raise android.FrameError('could not read restore result; inspect app data before retrying')
|
||||