Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448720d8d6 | ||
|
|
5aea46afd0 | ||
|
|
8fca0fe0a2 | ||
|
|
6d3cde64fe | ||
|
|
3f280ba59a | ||
|
|
83fa5c6c5b | ||
|
|
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 | ||
|
|
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
|
*.json text eol=lf
|
||||||
*.md text eol=lf
|
*.md text eol=lf
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
|
# Test fixtures are byte-exact (hashes, signatures): never convert line endings.
|
||||||
|
tests/fixtures/** -text
|
||||||
|
*.apk binary
|
||||||
|
*.jar binary
|
||||||
|
*.obb binary
|
||||||
@@ -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
|
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
|
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
|
## 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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": [
|
"filter": [
|
||||||
"*.py",
|
"*.py",
|
||||||
"*.html",
|
"*.html",
|
||||||
|
"*.js",
|
||||||
"telemetry.json"
|
"telemetry.json"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "../ui/apk_sources",
|
||||||
|
"to": "ui/apk_sources",
|
||||||
|
"filter": [
|
||||||
|
"*.py",
|
||||||
|
"*.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "../scripts",
|
"from": "../scripts",
|
||||||
"to": "scripts",
|
"to": "scripts",
|
||||||
@@ -63,7 +72,8 @@
|
|||||||
"to": "frame/android",
|
"to": "frame/android",
|
||||||
"filter": [
|
"filter": [
|
||||||
"*.sh",
|
"*.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.
|
whether the settings survive the app or its Lepton instance relaunching.
|
||||||
Lepton Development rebuilds its Android data on exit, so there they probably
|
Lepton Development rebuilds its Android data on exit, so there they probably
|
||||||
don't.
|
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.
|
||||||
@@ -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).
|
||||||
@@ -121,8 +121,13 @@ a limit on the number of floating panels.
|
|||||||
script needed.
|
script needed.
|
||||||
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
||||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
- **Optional overlay tools:** Desktop+, OVR Toolkit and similar software are
|
||||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
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
|
## Frame Control's panel switcher
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- Installing when there's more than one library folder, such as a microSD card.
|
||||||
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
||||||
headset.
|
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.
|
||||||
@@ -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
|
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
|
||||||
Khronos-style loader and found SteamVR through `/vendor`.
|
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
|
## Out of scope
|
||||||
|
|
||||||
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
- **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.
|
# A number that isn't a real Steam app; it names this app's Lepton context.
|
||||||
export SteamAppId="$(cat "$DIR/instance.id")"
|
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"
|
export STEAM_COMPAT_INSTALL_PATH="$DIR"
|
||||||
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
|
# 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"
|
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
|
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
|
||||||
# and SSH don't have; give it its own session instead.
|
# and SSH don't have; give it its own session instead.
|
||||||
export IS_PARENT=true
|
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 add NAME EXE START_DIR [ICON] -> prints the shortcut app id
|
||||||
steam_shortcuts.py list -> JSON [{appid, name, exe}]
|
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
|
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'
|
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
||||||
|
|
||||||
@@ -22,10 +24,10 @@ def target_ws():
|
|||||||
class WS:
|
class WS:
|
||||||
"""Just enough RFC 6455 for one CDP request/response on loopback."""
|
"""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, path = url[len('ws://'):].split('/', 1)
|
||||||
host, port = host_port.split(':')
|
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()
|
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'
|
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'
|
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
|
||||||
@@ -69,8 +71,8 @@ class WS:
|
|||||||
return msg.decode()
|
return msg.decode()
|
||||||
|
|
||||||
|
|
||||||
def evaluate(js):
|
def evaluate(js, timeout=20):
|
||||||
ws = WS(target_ws())
|
ws = WS(target_ws(), timeout)
|
||||||
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
||||||
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
||||||
while True:
|
while True:
|
||||||
@@ -83,6 +85,206 @@ def evaluate(js):
|
|||||||
return res.get('result', {}).get('value')
|
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():
|
def main():
|
||||||
cmd, args = sys.argv[1], sys.argv[2:]
|
cmd, args = sys.argv[1], sys.argv[2:]
|
||||||
if cmd == 'add':
|
if cmd == 'add':
|
||||||
@@ -97,12 +299,30 @@ def main():
|
|||||||
}})()'''
|
}})()'''
|
||||||
print(evaluate(js))
|
print(evaluate(js))
|
||||||
elif cmd == 'list':
|
elif cmd == 'list':
|
||||||
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
|
# Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered.
|
||||||
.map(a => ({appid: a.appid, name: a.display_name})))()'''
|
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)))
|
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':
|
elif cmd == 'remove':
|
||||||
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
|
print(json.dumps(remove(int(args[0]))))
|
||||||
print('removed')
|
|
||||||
else:
|
else:
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ class AndroidApps(harness.FrameTestCase):
|
|||||||
self.assertEqual(shortcut['name'], 'App label')
|
self.assertEqual(shortcut['name'], 'App label')
|
||||||
self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh')
|
self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh')
|
||||||
self.assertEqual(shortcut['start_dir'], APP_DIR)
|
self.assertEqual(shortcut['start_dir'], APP_DIR)
|
||||||
self.assertEqual(shortcut['icon'], f'{APP_DIR}/icon.png')
|
self.assertEqual(shortcut['icon'], f'{APP_DIR}/artwork/icon.png')
|
||||||
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'icon.png', 'lepton-show-flatscreen'):
|
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.assertTrue(exists(f'{APP_DIR}/{f}'), f)
|
||||||
self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000)
|
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')
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
|
|||||||
if cmd == 'ps':
|
if cmd == 'ps':
|
||||||
for name, c in sorted(containers.items()):
|
for name, c in sorted(containers.items()):
|
||||||
print(f"{name} {c['port']}")
|
print(f"{name} {c['port']}")
|
||||||
|
elif cmd == 'inspect':
|
||||||
|
if args[-1] not in containers:
|
||||||
|
sys.exit(1)
|
||||||
|
print('true')
|
||||||
elif cmd == 'stop':
|
elif cmd == 'stop':
|
||||||
name = args[-1]
|
name = args[-1]
|
||||||
c = containers.get(name)
|
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) {
|
function build(steam) {
|
||||||
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
||||||
const gameOverview = a => ({
|
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,
|
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,
|
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
|
||||||
rt_last_time_played: a.last_played || 0,
|
rt_last_time_played: a.last_played || 0,
|
||||||
@@ -37,7 +37,8 @@ function build(steam) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const shortcutOverview = s => ({
|
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 },
|
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
||||||
});
|
});
|
||||||
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
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 {
|
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: {
|
appStore: {
|
||||||
get allApps() { return allApps(); },
|
get allApps() { return allApps(); },
|
||||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
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); },
|
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); },
|
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); },
|
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) {
|
RemoveShortcut(id) {
|
||||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
||||||
delete steam.compat_tools[String(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,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()
|
||||||
@@ -238,6 +238,56 @@ class ServerGuards(unittest.TestCase):
|
|||||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
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")
|
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
|
||||||
class LocalMode(unittest.TestCase):
|
class LocalMode(unittest.TestCase):
|
||||||
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
|
"""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,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):
|
class SourceError(Exception):
|
||||||
"""User-readable failure from a source (network, format, verification)."""
|
"""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
|
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||||
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
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_apk
|
||||||
|
import frame_artwork
|
||||||
import frame_host
|
import frame_host
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -56,7 +58,11 @@ def ssh(cmd, input=None, timeout=120):
|
|||||||
|
|
||||||
def shortcut_tool(*args, timeout=60):
|
def shortcut_tool(*args, timeout=60):
|
||||||
with open(SHORTCUTS) as f:
|
with open(SHORTCUTS) as f:
|
||||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
|
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()
|
timeout=timeout).strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +146,7 @@ def _write_meta(d, meta):
|
|||||||
install_hooks = []
|
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
|
start, info = time.time(), None
|
||||||
try:
|
try:
|
||||||
info = apk_info(apk_path)
|
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')
|
patched = os.path.join(tmp, 'app.apk')
|
||||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||||
info['launchable'] = True
|
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:
|
else:
|
||||||
meta = _install(apk_path, info, pkg, flatscreen, name, source)
|
meta = _install(apk_path, info, pkg, flatscreen, name, source, artwork)
|
||||||
except FrameError as e:
|
except FrameError as e:
|
||||||
_after_install(info, None, e, start)
|
_after_install(info, None, e, start)
|
||||||
raise
|
raise
|
||||||
@@ -179,39 +185,53 @@ def _after_install(info, meta, error, start):
|
|||||||
pass # reporting must never change an install's outcome
|
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)
|
iid = instance_id(pkg)
|
||||||
d = f'{APPS_DIR}/{pkg}'
|
d = f'{APPS_DIR}/{pkg}'
|
||||||
existing = read_meta(pkg)
|
existing = read_meta(pkg)
|
||||||
ok = False
|
ok, created = False, None
|
||||||
try:
|
try:
|
||||||
ssh(f'mkdir -p {d}')
|
ssh(f'mkdir -p {d}')
|
||||||
_copy(apk_path, f'{d}/app.apk.part')
|
_copy(apk_path, f'{d}/app.apk.part')
|
||||||
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
_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'
|
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}')
|
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
|
||||||
home = ssh('echo $HOME').strip()
|
home = ssh('echo $HOME').strip()
|
||||||
shortcut = _int((existing or {}).get('shortcut'))
|
shortcut = _int((existing or {}).get('shortcut'))
|
||||||
if not shortcut or shortcut not in _shortcut_ids():
|
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}',
|
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)
|
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
|
||||||
if not shortcut:
|
if not shortcut:
|
||||||
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
|
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'],
|
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
|
||||||
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
||||||
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
|
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
|
||||||
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
|
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
|
||||||
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
'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)
|
_write_meta(d, meta)
|
||||||
ok = True
|
ok = True
|
||||||
return meta
|
return meta
|
||||||
finally:
|
finally:
|
||||||
|
if not ok and created:
|
||||||
|
try:
|
||||||
|
shortcut_tool('remove', str(created))
|
||||||
|
except FrameError:
|
||||||
|
pass
|
||||||
if not ok and not existing:
|
if not ok and not existing:
|
||||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||||
try:
|
try:
|
||||||
@@ -220,6 +240,121 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
|||||||
pass
|
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):
|
def _int(v):
|
||||||
try:
|
try:
|
||||||
n = int(v)
|
n = int(v)
|
||||||
@@ -268,6 +403,7 @@ def list_apps():
|
|||||||
continue
|
continue
|
||||||
if m:
|
if m:
|
||||||
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
|
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
|
||||||
|
m['art_missing'] = art_missing(m)
|
||||||
apps.append(m)
|
apps.append(m)
|
||||||
return sorted(apps, key=lambda m: m['label'].lower())
|
return sorted(apps, key=lambda m: m['label'].lower())
|
||||||
|
|
||||||
@@ -291,18 +427,30 @@ def launch(pkg):
|
|||||||
|
|
||||||
def stop(pkg):
|
def stop(pkg):
|
||||||
m = _meta_or_fail(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)
|
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
def remove(pkg, keep_data=False):
|
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)
|
m = _meta_or_fail(pkg)
|
||||||
stop(pkg)
|
stop(pkg)
|
||||||
if m['shortcut']:
|
if m['shortcut']:
|
||||||
|
# Best effort: Steam may not be running, and the files must still go.
|
||||||
try:
|
try:
|
||||||
shortcut_tool('remove', str(int(m['shortcut'])))
|
result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
|
||||||
except FrameError:
|
m['library_warnings'] = result.get('warnings', [])
|
||||||
pass # already gone from Steam
|
except (FrameError, ValueError, AttributeError) as e:
|
||||||
|
m['library_warnings'] = [f'Steam shortcut not removed: {e}']
|
||||||
iid = int(m['instance'])
|
iid = int(m['instance'])
|
||||||
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
|
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
|
||||||
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
|
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
|
||||||
@@ -354,6 +502,21 @@ def patch(src, dst, add=None):
|
|||||||
raise FrameError(str(e)) from e
|
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():
|
def main():
|
||||||
cmd, *args = sys.argv[1:] or ['help']
|
cmd, *args = sys.argv[1:] or ['help']
|
||||||
try:
|
try:
|
||||||
@@ -374,6 +537,13 @@ def main():
|
|||||||
elif cmd == 'install':
|
elif cmd == 'install':
|
||||||
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
|
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)
|
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':
|
elif cmd == 'patch':
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
|
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
|
||||||
@@ -389,8 +559,18 @@ def main():
|
|||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
additions[entry] = f.read()
|
additions[entry] = f.read()
|
||||||
r = patch(opts.src, opts.dst, additions)
|
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':
|
elif cmd == 'list':
|
||||||
r = list_apps()
|
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'):
|
elif cmd in ('launch', 'stop', 'probe'):
|
||||||
r = globals()[cmd](args[0])
|
r = globals()[cmd](args[0])
|
||||||
elif cmd == 'remove':
|
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')
|
||||||
@@ -255,7 +255,10 @@ def _icon_png(z, names, icons):
|
|||||||
# Adaptive icons are XML; fall back to the largest launcher PNG.
|
# Adaptive icons are XML; fall back to the largest launcher PNG.
|
||||||
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
|
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
|
||||||
key=lambda n: z.getinfo(n).file_size)
|
key=lambda n: z.getinfo(n).file_size)
|
||||||
return _read(z, pngs[-1], MAX_ICON) if pngs else None
|
if pngs:
|
||||||
|
return _read(z, pngs[-1], MAX_ICON)
|
||||||
|
# Godot exports can keep only an adaptive launcher plus the project icon.
|
||||||
|
return _read(z, 'assets/icon.png', MAX_ICON) if 'assets/icon.png' in names else None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Bounded artwork inputs for Steam library canvas rendering."""
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240),
|
||||||
|
'logo': (1280, 480), 'icon': (256, 256)}
|
||||||
|
MAX_IMAGE = 12 * 1024 * 1024
|
||||||
|
MAX_PIXELS = 4096 * 4096 # a 4K screenshot; Chromium decodes it on the Frame
|
||||||
|
PNG = b'\x89PNG\r\n\x1a\n'
|
||||||
|
|
||||||
|
|
||||||
|
def chunk(kind, data):
|
||||||
|
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
|
||||||
|
|
||||||
|
|
||||||
|
def png_size(data):
|
||||||
|
"""IHDR dimensions of a PNG of any bit depth or interlace; Steam's Chromium decodes the pixels."""
|
||||||
|
if len(data) < 33 or data[12:16] != b'IHDR' or struct.unpack_from('>I', data, 8)[0] != 13:
|
||||||
|
raise ValueError('invalid PNG header')
|
||||||
|
if zlib.crc32(data[12:29]) != struct.unpack_from('>I', data, 29)[0]:
|
||||||
|
raise ValueError('invalid PNG checksum')
|
||||||
|
w, h, depth, color = struct.unpack_from('>IIBB', data, 16)
|
||||||
|
if color not in (0, 2, 3, 4, 6) or depth not in (1, 2, 4, 8, 16):
|
||||||
|
raise ValueError('unsupported PNG encoding')
|
||||||
|
if not w or not h or w * h > MAX_PIXELS or w > 8192 or h > 8192:
|
||||||
|
raise ValueError('PNG dimensions exceed limits')
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
|
def _gif_blocks(data, pos):
|
||||||
|
"""Position after a run of GIF data sub-blocks and its terminator."""
|
||||||
|
while True:
|
||||||
|
if pos >= len(data):
|
||||||
|
raise ValueError('truncated GIF')
|
||||||
|
size = data[pos]
|
||||||
|
pos += 1 + size
|
||||||
|
if not size:
|
||||||
|
return pos
|
||||||
|
|
||||||
|
|
||||||
|
def gif_frame(data):
|
||||||
|
"""A GIF's first frame as a minimal single-frame GIF, so no frame count or oversized frame
|
||||||
|
reaches the Frame's Chromium. Raises ValueError for anything malformed or out of bounds."""
|
||||||
|
if data[:6] not in (b'GIF87a', b'GIF89a') or len(data) < 13:
|
||||||
|
raise ValueError('invalid GIF')
|
||||||
|
sw, sh, flags = struct.unpack_from('<HHB', data, 6)
|
||||||
|
if not sw or not sh or sw * sh > MAX_PIXELS or max(sw, sh) > 4096:
|
||||||
|
raise ValueError('artwork GIF has unsupported dimensions')
|
||||||
|
pos = 13 + (3 << ((flags & 7) + 1) if flags & 0x80 else 0)
|
||||||
|
head, control = data[:pos], b''
|
||||||
|
if len(head) != pos:
|
||||||
|
raise ValueError('truncated GIF')
|
||||||
|
while True:
|
||||||
|
if pos >= len(data):
|
||||||
|
raise ValueError('truncated GIF')
|
||||||
|
if data[pos] == 0x21 and pos + 1 < len(data): # extension: keep the frame's graphic control
|
||||||
|
end = _gif_blocks(data, pos + 2)
|
||||||
|
if data[pos + 1] == 0xf9 and end - pos == 8 and data[pos + 2] == 4: # GIF89a: fixed 4-byte payload
|
||||||
|
control = data[pos:end]
|
||||||
|
pos = end
|
||||||
|
elif data[pos] == 0x2c and pos + 10 <= len(data): # the first image
|
||||||
|
x, y, w, h, local = struct.unpack_from('<HHHHB', data, pos + 1)
|
||||||
|
if not w or not h or x + w > sw or y + h > sh:
|
||||||
|
raise ValueError('artwork GIF frame exceeds its screen')
|
||||||
|
if not flags & 0x80 and not local & 0x80:
|
||||||
|
raise ValueError('GIF has no colour table')
|
||||||
|
start = pos
|
||||||
|
pos += 10 + (3 << ((local & 7) + 1) if local & 0x80 else 0)
|
||||||
|
if pos >= len(data) or not 2 <= data[pos] <= 8: # the LZW minimum code size
|
||||||
|
raise ValueError('invalid GIF image data')
|
||||||
|
end = _gif_blocks(data, pos + 1)
|
||||||
|
if end - pos <= 2: # code size then the terminator: no pixels at all
|
||||||
|
raise ValueError('invalid GIF image data')
|
||||||
|
return head + control + data[start:end] + b'\x3b'
|
||||||
|
else:
|
||||||
|
raise ValueError('invalid GIF block')
|
||||||
|
|
||||||
|
|
||||||
|
def image_type(data):
|
||||||
|
if not isinstance(data, bytes) or len(data) > MAX_IMAGE:
|
||||||
|
raise ValueError('artwork must be image bytes or an HTTP(S) URL, at most 12 MiB')
|
||||||
|
if data.startswith(PNG):
|
||||||
|
png_size(data)
|
||||||
|
return 'png'
|
||||||
|
if data[:6] in (b'GIF87a', b'GIF89a'):
|
||||||
|
gif_frame(data)
|
||||||
|
return 'gif'
|
||||||
|
if data.startswith(b'\xff\xd8'):
|
||||||
|
# Check JPEG SOF dimensions without depending on an image library; trailing padding is fine.
|
||||||
|
pos = 2
|
||||||
|
while pos + 4 <= len(data) and data[pos] == 255:
|
||||||
|
marker = data[pos + 1]
|
||||||
|
pos += 2
|
||||||
|
if marker == 255:
|
||||||
|
pos -= 1
|
||||||
|
continue
|
||||||
|
size = struct.unpack_from('>H', data, pos)[0]
|
||||||
|
if size < 2 or pos + size > len(data):
|
||||||
|
break
|
||||||
|
if 0xc0 <= marker <= 0xcf and marker not in (0xc4, 0xc8, 0xcc) and size >= 8:
|
||||||
|
h, w = struct.unpack_from('>HH', data, pos + 3)
|
||||||
|
if w and h and w * h <= MAX_PIXELS and max(w, h) <= 8192:
|
||||||
|
return 'jpg'
|
||||||
|
break
|
||||||
|
pos += size
|
||||||
|
raise ValueError('artwork must be a supported PNG, JPEG or GIF')
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(value, deadline=None):
|
||||||
|
if isinstance(value, str):
|
||||||
|
from apk_sources import _images
|
||||||
|
# Public addresses only, at most three redirects, within the overall deadline.
|
||||||
|
value = _images.fetch(value, deadline=deadline, limit=MAX_IMAGE)[0]
|
||||||
|
kind = image_type(value)
|
||||||
|
return kind, gif_frame(value) if kind == 'gif' else value
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(label, icon_png=None, artwork=None, budget=90):
|
||||||
|
"""Gather inputs; the Frame's Chromium canvas renders every final slot.
|
||||||
|
|
||||||
|
Every source is optional: any failure falls back to generated art, within budget seconds overall."""
|
||||||
|
import frame_steamgriddb
|
||||||
|
artwork = artwork or {}
|
||||||
|
allowed = set(SLOTS) | {'banner', 'feature_graphic', 'screenshots', 'screenshot'}
|
||||||
|
if not isinstance(artwork, dict) or set(artwork) - allowed:
|
||||||
|
raise ValueError('unknown artwork slot')
|
||||||
|
deadline = time.monotonic() + budget
|
||||||
|
supplied, warnings = {}, []
|
||||||
|
|
||||||
|
def get(value):
|
||||||
|
try:
|
||||||
|
return fetch(value, deadline)
|
||||||
|
except Exception: # an optional source never blocks the install; generated art covers it
|
||||||
|
return None
|
||||||
|
for slot, value in artwork.items():
|
||||||
|
values = value if slot == 'screenshots' and isinstance(value, (list, tuple)) else [value]
|
||||||
|
for candidate in values[:4]:
|
||||||
|
image = get(candidate)
|
||||||
|
if image:
|
||||||
|
supplied['screenshot' if slot == 'screenshots' else slot] = image
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
warnings.append('Source ' + slot + ' unavailable; using fallback art')
|
||||||
|
if 'icon' not in supplied and icon_png:
|
||||||
|
image = get(icon_png)
|
||||||
|
if image:
|
||||||
|
supplied['icon'] = image
|
||||||
|
try:
|
||||||
|
provider, provider_warnings = frame_steamgriddb.lookup(label, deadline)
|
||||||
|
except Exception:
|
||||||
|
provider, provider_warnings = {}, ['SteamGridDB unavailable; using source or generated art']
|
||||||
|
warnings.extend(provider_warnings)
|
||||||
|
for slot, value in provider.items():
|
||||||
|
image = get(value)
|
||||||
|
if image:
|
||||||
|
supplied[slot] = image
|
||||||
|
else:
|
||||||
|
warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art')
|
||||||
|
return supplied, warnings
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object.
|
"""Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object.
|
||||||
|
|
||||||
Read-only. Every probe is best effort: a missing tool or file gives null, not an
|
Read-only. Every probe is best effort: a missing tool or file gives null, not an
|
||||||
error. Paths verified on SteamOS 0.3.0 (vr), build 20260922.
|
error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1
|
||||||
|
(build 20260925.6191901). See docs/vr-utilities.md.
|
||||||
"""
|
"""
|
||||||
|
import ctypes as C
|
||||||
import glob
|
import glob
|
||||||
|
import math
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -183,11 +186,104 @@ def activity_level():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def main():
|
# OpenVR C ABI, ValveSoftware/openvr headers/openvr_capi.h (IVRCompositor_029).
|
||||||
|
# Exact interface version: never guess an index against a different table.
|
||||||
|
class Pose(C.Structure):
|
||||||
|
_fields_ = [('matrix', C.c_float * 12), ('velocity', C.c_float * 3),
|
||||||
|
('angular', C.c_float * 3), ('result', C.c_int),
|
||||||
|
('valid', C.c_bool), ('connected', C.c_bool)]
|
||||||
|
|
||||||
|
|
||||||
|
class Timing(C.Structure):
|
||||||
|
_fields_ = [(n, C.c_uint32) for n in ('size', 'index', 'presents', 'mis', 'dropped', 'flags')] + [
|
||||||
|
('time', C.c_double)] + [(n, C.c_float) for n in (
|
||||||
|
'gpuPre', 'gpuPost', 'gpu', 'compositorGpu', 'compositorCpu', 'idleCpu', 'interval',
|
||||||
|
'presentCpu', 'waitCpu', 'submit', 'posesCalled', 'posesReady', 'frameReady',
|
||||||
|
'updateStart', 'updateEnd', 'renderStart')] + [
|
||||||
|
('pose', Pose), ('ready', C.c_uint32), ('first', C.c_uint32), ('transfer', C.c_float)]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenVR:
|
||||||
|
def __enter__(self):
|
||||||
|
self.lib = C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
|
||||||
|
self.lib.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
|
||||||
|
self.lib.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
|
||||||
|
self.lib.VR_GetGenericInterface.restype = C.c_void_p
|
||||||
|
err = C.c_int()
|
||||||
|
self.lib.VR_InitInternal2(C.byref(err), 3, None) # background: never start or keep SteamVR running
|
||||||
|
if err.value:
|
||||||
|
raise RuntimeError(f'SteamVR unavailable (init {err.value})')
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.lib.VR_ShutdownInternal()
|
||||||
|
|
||||||
|
def function(self, interface, index, result, *args):
|
||||||
|
err = C.c_int()
|
||||||
|
ptr = self.lib.VR_GetGenericInterface(('FnTable:' + interface).encode(), C.byref(err))
|
||||||
|
if err.value or not ptr:
|
||||||
|
raise RuntimeError(f'{interface} unavailable ({err.value})')
|
||||||
|
return C.CFUNCTYPE(result, *args)(C.cast(ptr, C.POINTER(C.c_void_p))[index])
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_ticks():
|
||||||
|
line = (read('/proc/stat') or '').splitlines()
|
||||||
|
if not line or not line[0].startswith('cpu '):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# guest/guest_nice are already included in user/nice.
|
||||||
|
ticks = [int(x) for x in line[0].split()[1:9]]
|
||||||
|
return sum(ticks), ticks[3] + ticks[4]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_percent(before, after):
|
||||||
|
if before is None or after is None or after[0] <= before[0]:
|
||||||
|
return None
|
||||||
|
return round(max(0, min(100, 100 * (1 - (after[1] - before[1]) / (after[0] - before[0])))), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def positive(value):
|
||||||
|
return round(value, 2) if math.isfinite(value) and value > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def timing_values(before, after):
|
||||||
|
dt, frames = after.time - before.time, after.index - before.index
|
||||||
|
if dt <= 0 or frames <= 0 or frames / dt > 1000:
|
||||||
|
return {} # standby or old data; never present stale timings as live
|
||||||
|
return {'compositorFps': positive(frames / dt),
|
||||||
|
'frameMs': positive(1000 * dt / frames),
|
||||||
|
'appFps': positive(1000 / after.interval) if after.interval > 0 else None,
|
||||||
|
'gpuMs': positive(after.gpu), 'compositorCpuMs': positive(after.compositorCpu)}
|
||||||
|
|
||||||
|
|
||||||
|
def performance():
|
||||||
|
out = {'compositorFps': None, 'frameMs': None, 'appFps': None,
|
||||||
|
'gpuMs': None, 'compositorCpuMs': None, 'cpuPercent': None,
|
||||||
|
'gpuMHz': num('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6)}
|
||||||
|
before = cpu_ticks()
|
||||||
|
try:
|
||||||
|
with OpenVR() as vr:
|
||||||
|
get = vr.function('IVRCompositor_029', 10, C.c_bool, C.POINTER(Timing), C.c_uint32)
|
||||||
|
a, b = Timing(), Timing()
|
||||||
|
a.size = b.size = C.sizeof(Timing)
|
||||||
|
first = get(C.byref(a), 0)
|
||||||
|
time.sleep(0.2)
|
||||||
|
if first and get(C.byref(b), 0):
|
||||||
|
out.update(timing_values(a, b))
|
||||||
|
except (OSError, RuntimeError, AttributeError): # AttributeError: a SteamVR build without these exports
|
||||||
|
time.sleep(0.2)
|
||||||
|
out['cpuPercent'] = cpu_percent(before, cpu_ticks())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def status():
|
||||||
uptime = read("/proc/uptime")
|
uptime = read("/proc/uptime")
|
||||||
procs = process_names()
|
procs = process_names()
|
||||||
print(json.dumps({
|
return {
|
||||||
"time": time.time(),
|
"time": time.time(),
|
||||||
|
"performance": performance(),
|
||||||
"hostname": socket.gethostname(),
|
"hostname": socket.gethostname(),
|
||||||
"os": os_release(),
|
"os": os_release(),
|
||||||
"uptime": float(uptime.split()[0]) if uptime else None,
|
"uptime": float(uptime.split()[0]) if uptime else None,
|
||||||
@@ -207,7 +303,11 @@ def main():
|
|||||||
},
|
},
|
||||||
"games": games(),
|
"games": games(),
|
||||||
"flatpaks": flatpaks(),
|
"flatpaks": flatpaks(),
|
||||||
}))
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(json.dumps(status()))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -141,6 +141,21 @@ OWNED_JS = r"""
|
|||||||
})()
|
})()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Software has app_type 2, so utility ownership must not use OWNED_JS's games filter.
|
||||||
|
# Steam prices checked 2026-09-28: OVR Advanced Settings is paid on Steam too.
|
||||||
|
UTILITY_IDS = (1009850, 1173510, 1068820, 908520, 1494460)
|
||||||
|
FREE_UTILITIES = (1494460,)
|
||||||
|
UTILITIES_JS = """(() => {
|
||||||
|
const apps = appStore.allApps;
|
||||||
|
if (!apps || !apps.length) throw new Error("Steam library is not loaded; ownership is unknown");
|
||||||
|
return [1009850,1173510,1068820,908520,1494460].map(id => {
|
||||||
|
const a = apps.find(a => a.appid === id);
|
||||||
|
return {id, owned: !!a, installed: !!a?.local_per_client_data?.installed,
|
||||||
|
frame: a ? (a.steam_hw_compat_category_packed >> 8) & 3 : 0};
|
||||||
|
});
|
||||||
|
})()"""
|
||||||
|
|
||||||
|
|
||||||
WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({
|
WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({
|
||||||
state: i?.eInstallState ?? 0, app: i?.currentAppID ?? 0, need: i?.nDiskSpaceRequired || 0,
|
state: i?.eInstallState ?? 0, app: i?.currentAppID ?? 0, need: i?.nDiskSpaceRequired || 0,
|
||||||
free: i?.nDiskSpaceAvailable || 0, error: i?.eAppError, detail: i?.errorDetail }))"""
|
free: i?.nDiskSpaceAvailable || 0, error: i?.eAppError, detail: i?.errorDetail }))"""
|
||||||
@@ -158,6 +173,10 @@ def owned():
|
|||||||
|
|
||||||
def install(appid):
|
def install(appid):
|
||||||
page = Page()
|
page = Page()
|
||||||
|
if appid in UTILITY_IDS and appid not in FREE_UTILITIES:
|
||||||
|
rows = page.eval(UTILITIES_JS)
|
||||||
|
if not any(r['id'] == appid and r['owned'] for r in rows):
|
||||||
|
raise Fail("This paid utility is not owned by the Frame account. No install or store action was taken.")
|
||||||
app = page.eval(f"(a => a && {{name: a.display_name, installed: !!a.local_per_client_data?.installed}})"
|
app = page.eval(f"(a => a && {{name: a.display_name, installed: !!a.local_per_client_data?.installed}})"
|
||||||
f"(appStore.GetAppOverviewByAppID({appid}))")
|
f"(appStore.GetAppOverviewByAppID({appid}))")
|
||||||
if app and app["installed"]:
|
if app and app["installed"]:
|
||||||
@@ -216,6 +235,8 @@ def main():
|
|||||||
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
if cmd == "owned":
|
if cmd == "owned":
|
||||||
out = owned()
|
out = owned()
|
||||||
|
elif cmd == "utilities":
|
||||||
|
out = {"utilities": Page().eval(UTILITIES_JS)}
|
||||||
elif cmd in ("install", "store") and len(sys.argv) == 3 and sys.argv[2].isdigit():
|
elif cmd in ("install", "store") and len(sys.argv) == 3 and sys.argv[2].isdigit():
|
||||||
out = (install if cmd == "install" else store)(int(sys.argv[2]))
|
out = (install if cmd == "install" else store)(int(sys.argv[2]))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Optional SteamGridDB artwork. Credentials stay on the host, never in app metadata."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unicodedata
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import frame_host
|
||||||
|
|
||||||
|
API = 'https://www.steamgriddb.com/api/v2'
|
||||||
|
MAX_JSON = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def settings_path():
|
||||||
|
return frame_host.data_dir('artwork-settings.json')
|
||||||
|
|
||||||
|
|
||||||
|
def api_key():
|
||||||
|
env = os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY')
|
||||||
|
if env:
|
||||||
|
return env.strip()
|
||||||
|
try:
|
||||||
|
return str(json.loads(settings_path().read_text()).get('steamgriddb_api_key') or '')
|
||||||
|
except (OSError, ValueError, AttributeError):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def settings():
|
||||||
|
return {'steamgriddb_configured': bool(api_key()),
|
||||||
|
'environment': bool(os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY'))}
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(body):
|
||||||
|
key = body.get('steamgriddb_api_key')
|
||||||
|
if not isinstance(key, str) or len(key) > 200 or (key and not re.fullmatch(r'[A-Za-z0-9_-]+', key)):
|
||||||
|
raise ValueError('enter a valid SteamGridDB API key, or an empty value to remove it')
|
||||||
|
path = settings_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, temp = tempfile.mkstemp(prefix='.artwork-', dir=str(path.parent))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w') as f:
|
||||||
|
json.dump({'steamgriddb_api_key': key}, f)
|
||||||
|
os.replace(temp, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp):
|
||||||
|
os.remove(temp)
|
||||||
|
return settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path, key, deadline=None):
|
||||||
|
from apk_sources import _images
|
||||||
|
# No redirects: the credential never goes anywhere but the API. Errors never contain it.
|
||||||
|
data = _images.get(API + path, {'Authorization': 'Bearer ' + key, 'Accept': 'application/json'}, redirects=0,
|
||||||
|
deadline=time.monotonic() + 12 if deadline is None else min(deadline, time.monotonic() + 12),
|
||||||
|
limit=MAX_JSON)
|
||||||
|
result = json.loads(data)
|
||||||
|
if not isinstance(result, dict) or not result.get('success') or not isinstance(result.get('data'), list):
|
||||||
|
raise ValueError('SteamGridDB lookup failed')
|
||||||
|
return result['data']
|
||||||
|
|
||||||
|
|
||||||
|
def _name(value):
|
||||||
|
# Letters and digits of any script, so a CJK title never normalises to ''.
|
||||||
|
return ''.join(c for c in unicodedata.normalize('NFKC', str(value or '')).casefold() if c.isalnum())
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(name, deadline=None):
|
||||||
|
"""Best-voted art per slot for an exact title match; unrelated games are never guessed."""
|
||||||
|
key = api_key()
|
||||||
|
if not key:
|
||||||
|
return {}, []
|
||||||
|
try:
|
||||||
|
names = {_name(name), _name(re.sub(r'\s+VR$', '', name, flags=re.I))} - {''}
|
||||||
|
if not names:
|
||||||
|
return {}, []
|
||||||
|
matches = _get('/search/autocomplete/' + urllib.parse.quote(name, safe=''), key, deadline)
|
||||||
|
game = next((g for g in matches if isinstance(g, dict) and _name(g.get('name')) in names), None)
|
||||||
|
if not game:
|
||||||
|
return {}, []
|
||||||
|
gid = int(game['id'])
|
||||||
|
result, warnings = {}, []
|
||||||
|
for slot, kind, dimensions in [('grid', 'grids', '600x900'), ('wide', 'grids', '920x430'),
|
||||||
|
('hero', 'heroes', ''), ('logo', 'logos', ''), ('icon', 'icons', '')]:
|
||||||
|
try:
|
||||||
|
# Logos and icons only come as PNG (or WebP/ICO); asking for JPEG there is rejected outright.
|
||||||
|
mimes = 'image/png,image/jpeg' if kind in ('grids', 'heroes') else 'image/png'
|
||||||
|
query = {'types': 'static', 'nsfw': 'false', 'humor': 'false', 'mimes': mimes}
|
||||||
|
if dimensions:
|
||||||
|
query['dimensions'] = dimensions
|
||||||
|
records = _get('/' + kind + '/game/' + str(gid) + '?' + urllib.parse.urlencode(query), key, deadline)
|
||||||
|
records = [r for r in records if isinstance(r, dict) and str(r.get('url', '')).startswith('https://')
|
||||||
|
and not r.get('nsfw')]
|
||||||
|
if dimensions:
|
||||||
|
w, h = map(int, dimensions.split('x'))
|
||||||
|
records = [r for r in records if (r.get('width'), r.get('height')) == (w, h)]
|
||||||
|
records.sort(key=lambda r: (int(r.get('score') or 0), int(r.get('upvotes') or 0)), reverse=True)
|
||||||
|
if records:
|
||||||
|
result[slot] = records[0]['url']
|
||||||
|
except Exception: # HTTPException, odd JSON: this slot falls back
|
||||||
|
warnings.append('SteamGridDB ' + slot + ' unavailable; using source or generated art')
|
||||||
|
return result, warnings
|
||||||
|
except Exception:
|
||||||
|
return {}, ['SteamGridDB unavailable; using source or generated art']
|
||||||
@@ -16,9 +16,9 @@ checked on a headset; see docs/sideloading.md.
|
|||||||
Python stdlib only. CLI:
|
Python stdlib only. CLI:
|
||||||
python3 ui/frame_titles.py inspect PATH
|
python3 ui/frame_titles.py inspect PATH
|
||||||
python3 ui/frame_titles.py install PATH [--name N] [--exe REL] [--runtime R]
|
python3 ui/frame_titles.py install PATH [--name N] [--exe REL] [--runtime R]
|
||||||
python3 ui/frame_titles.py list | launch ID | remove ID
|
python3 ui/frame_titles.py list | launch ID | remove ID | refresh-art ID|--all
|
||||||
"""
|
"""
|
||||||
import hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile
|
import base64, hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile
|
||||||
|
|
||||||
import frame_android
|
import frame_android
|
||||||
import frame_host
|
import frame_host
|
||||||
@@ -632,7 +632,7 @@ def _rsync():
|
|||||||
_install_lock = threading.Lock()
|
_install_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def install(path, name=None, exe=None, runtime=None, progress=None):
|
def install(path, name=None, exe=None, runtime=None, progress=None, artwork=None):
|
||||||
"""Sideload a .zip, folder or executable as a Devkit Game; returns the title dict.
|
"""Sideload a .zip, folder or executable as a Devkit Game; returns the title dict.
|
||||||
|
|
||||||
name: the Steam name (sanitised to the title id), default from the file name.
|
name: the Steam name (sanitised to the title id), default from the file name.
|
||||||
@@ -643,12 +643,12 @@ def install(path, name=None, exe=None, runtime=None, progress=None):
|
|||||||
"""
|
"""
|
||||||
plan = inspect(path, name)
|
plan = inspect(path, name)
|
||||||
try:
|
try:
|
||||||
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress)
|
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress, artwork=artwork)
|
||||||
finally:
|
finally:
|
||||||
discard(plan)
|
discard(plan)
|
||||||
|
|
||||||
|
|
||||||
def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
|
def install_plan(plan, name=None, exe=None, runtime=None, progress=None, artwork=None):
|
||||||
"""Install an inspect() plan, optionally with another name, target or runtime."""
|
"""Install an inspect() plan, optionally with another name, target or runtime."""
|
||||||
if name:
|
if name:
|
||||||
plan['name'], plan['id'] = name, title_id(name)
|
plan['name'], plan['id'] = name, title_id(name)
|
||||||
@@ -656,13 +656,21 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
|
|||||||
_choose(plan, exe or plan['target'], runtime)
|
_choose(plan, exe or plan['target'], runtime)
|
||||||
step = progress or (lambda *a: None)
|
step = progress or (lambda *a: None)
|
||||||
with _install_lock:
|
with _install_lock:
|
||||||
return _install(plan, step)
|
return _install(plan, step, artwork)
|
||||||
|
|
||||||
|
|
||||||
def _install(plan, step):
|
def _install(plan, step, artwork=None):
|
||||||
gid = plan['id']
|
gid = plan['id']
|
||||||
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||||
raise FrameError(f'bad title id {gid!r}')
|
raise FrameError(f'bad title id {gid!r}')
|
||||||
|
icon = None
|
||||||
|
for filename in ('icon.png', 'logo.png'):
|
||||||
|
path = os.path.join(plan['root'], filename)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
icon = f.read(frame_android.frame_artwork.MAX_IMAGE + 1)
|
||||||
|
break
|
||||||
|
images, warnings = frame_android.frame_artwork.prepare(plan['name'], icon, artwork)
|
||||||
step("Syncing Valve's devkit tools to the Frame", 0.02)
|
step("Syncing Valve's devkit tools to the Frame", 0.02)
|
||||||
ensure_utils()
|
ensure_utils()
|
||||||
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
|
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
|
||||||
@@ -672,6 +680,7 @@ def _install(plan, step):
|
|||||||
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
|
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
|
||||||
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
|
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
|
||||||
registered = False
|
registered = False
|
||||||
|
library_ready, shortcut, steam_registered = False, None, False
|
||||||
try:
|
try:
|
||||||
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
|
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
|
||||||
if _rsync():
|
if _rsync():
|
||||||
@@ -687,17 +696,40 @@ def _install(plan, step):
|
|||||||
reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}',
|
reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}',
|
||||||
timeout=90), 'steam-client-create-shortcut')
|
timeout=90), 'steam-client-create-shortcut')
|
||||||
meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'],
|
meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'],
|
||||||
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S')}
|
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||||
|
# Until art is applied: the only entries automatic backfill may touch.
|
||||||
|
'art_pending': True}
|
||||||
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||||
registered = True # the files stay: Steam registers them once it's running
|
registered = True # the files stay: Steam registers them once it's running
|
||||||
if 'error' in reply:
|
if 'error' in reply:
|
||||||
err = str(reply['error']).strip().rstrip('.')
|
err = str(reply['error']).strip().rstrip('.')
|
||||||
hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else ''
|
hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else ''
|
||||||
raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}")
|
raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}")
|
||||||
|
steam_registered = True
|
||||||
|
shortcut = _library_shortcut(gid, directory)
|
||||||
|
if not shortcut:
|
||||||
|
raise FrameError('Steam registered the title but its shortcut is not available for mandatory artwork; retry install')
|
||||||
|
result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images,
|
||||||
|
category='Sideloaded', details={'source': plan['source']})
|
||||||
|
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False,
|
||||||
|
library_warnings=warnings + result.get('warnings', []))
|
||||||
|
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||||
|
library_ready = True
|
||||||
step('Done', 1.0)
|
step('Done', 1.0)
|
||||||
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
|
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
|
||||||
return meta
|
return meta
|
||||||
finally:
|
finally:
|
||||||
|
if steam_registered and not library_ready and not existed:
|
||||||
|
# A newly registered title must not remain as a blank library tile. Cleanup
|
||||||
|
# failures are swallowed so the error that got us here is the one reported.
|
||||||
|
try:
|
||||||
|
if shortcut:
|
||||||
|
frame_android.shortcut_tool('remove', str(shortcut))
|
||||||
|
else:
|
||||||
|
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||||
|
except FrameError:
|
||||||
|
pass
|
||||||
|
registered = False
|
||||||
if not registered and not existed:
|
if not registered and not existed:
|
||||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||||
try:
|
try:
|
||||||
@@ -706,6 +738,90 @@ def _install(plan, step):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _library_shortcut(gid, directory):
|
||||||
|
"""The Steam shortcut of title gid, only ever one that is provably this title's.
|
||||||
|
|
||||||
|
Steam's app overviews don't expose devkit_gameid (checked 2026-09-28, build 20260925.6191901),
|
||||||
|
so after the saved id this matches the shortcut's executable or start folder inside directory.
|
||||||
|
Never by display name: another non-Steam shortcut could share it and would be renamed or deleted.
|
||||||
|
"""
|
||||||
|
shortcuts = json.loads(frame_android.shortcut_tool('list'))
|
||||||
|
matches = [s for s in shortcuts if s.get('devkit_gameid') == gid]
|
||||||
|
if not matches:
|
||||||
|
try:
|
||||||
|
meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true') or 'null')
|
||||||
|
matches = [s for s in shortcuts if meta.get('shortcut') and s.get('appid') == meta.get('shortcut')]
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
if not matches:
|
||||||
|
root = posixpath.normpath(directory)
|
||||||
|
|
||||||
|
def inside(path):
|
||||||
|
path = str(path or '').strip().strip('"')
|
||||||
|
return bool(path) and (posixpath.normpath(path) + '/').startswith(root + '/')
|
||||||
|
matches = [s for s in shortcuts if inside(s.get('exe')) or inside(s.get('start_dir'))]
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise FrameError('ambiguous Steam shortcut for ' + gid)
|
||||||
|
return int(matches[0]['appid']) if matches else None
|
||||||
|
|
||||||
|
|
||||||
|
def _home():
|
||||||
|
return ssh('echo $HOME', timeout=30).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_art(gid=None, artwork=None, fill_only=False):
|
||||||
|
"""Render and apply Steam artwork for Frame Control's titles (all of them when gid is None).
|
||||||
|
|
||||||
|
fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library)."""
|
||||||
|
if gid is None:
|
||||||
|
results = []
|
||||||
|
for t in list_titles():
|
||||||
|
if not t['frame_control']:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
results.append(refresh_art(t['id'], artwork, fill_only))
|
||||||
|
except Exception as e: # report each title; one failure doesn't stop the rest
|
||||||
|
results.append({'id': t['id'], 'name': t['name'], 'error': str(e) or type(e).__name__})
|
||||||
|
return results
|
||||||
|
with _install_lock:
|
||||||
|
gid = _check_id(gid)
|
||||||
|
try:
|
||||||
|
meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json', timeout=30))
|
||||||
|
except (FrameError, ValueError):
|
||||||
|
meta = None
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
raise FrameError(f'{gid} was not installed by Frame Control')
|
||||||
|
directory = f'{_home()}/{GAMES}/{gid}'
|
||||||
|
script = frame_android.cached_art_script(directory + '/.frame-artwork') + f"""
|
||||||
|
icon = ''
|
||||||
|
for name in ('icon.png', 'logo.png'):
|
||||||
|
try:
|
||||||
|
with open(os.path.join({directory!r}, name), 'rb') as f:
|
||||||
|
icon = base64.b64encode(f.read(12 * 1024 * 1024 + 1)).decode()
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
print(json.dumps({{'artwork': cached, 'icon': icon}}))
|
||||||
|
"""
|
||||||
|
found = _json_out(ssh('python3 -', input=script, timeout=60), 'the title artwork')
|
||||||
|
cached = {k: base64.b64decode(v) for k, v in (found.get('artwork') or {}).items()}
|
||||||
|
icon = base64.b64decode(found.get('icon') or '') or None
|
||||||
|
name = str(meta.get('name') or gid)
|
||||||
|
images, warnings = frame_android.frame_artwork.prepare(name, icon, artwork if artwork is not None else cached)
|
||||||
|
shortcut = _library_shortcut(gid, directory)
|
||||||
|
if not shortcut:
|
||||||
|
raise FrameError(f"Steam hasn't registered {gid} yet; with Steam running on the Frame, refresh again")
|
||||||
|
result = frame_android.apply_library(shortcut, name, directory + '/.frame-artwork', images,
|
||||||
|
category='Sideloaded', details={'source': meta.get('source')},
|
||||||
|
fill_only=fill_only)
|
||||||
|
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False,
|
||||||
|
library_warnings=warnings + result.get('warnings', []),
|
||||||
|
artwork_refreshed=time.strftime('%Y-%m-%dT%H:%M:%S'))
|
||||||
|
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
LIST_SCRIPT = r'''
|
LIST_SCRIPT = r'''
|
||||||
import json, os
|
import json, os
|
||||||
root = os.path.expanduser('~/devkit-game')
|
root = os.path.expanduser('~/devkit-game')
|
||||||
@@ -741,7 +857,9 @@ def list_titles():
|
|||||||
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
|
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
|
||||||
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
|
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
|
||||||
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
|
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
|
||||||
'frame_control': bool(meta)})
|
'frame_control': bool(meta),
|
||||||
|
# Only a title whose install couldn't apply art; older installs have no flag and keep theirs.
|
||||||
|
'art_pending': bool(meta.get('art_pending')), 'art_missing': bool(meta.get('art_pending'))})
|
||||||
return titles
|
return titles
|
||||||
|
|
||||||
|
|
||||||
@@ -772,9 +890,20 @@ def remove(gid):
|
|||||||
try:
|
try:
|
||||||
gid = _check_id(gid)
|
gid = _check_id(gid)
|
||||||
ensure_utils()
|
ensure_utils()
|
||||||
# steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too.
|
try:
|
||||||
|
shortcut = _library_shortcut(gid, f'{_home()}/{GAMES}/{gid}')
|
||||||
|
except (FrameError, ValueError):
|
||||||
|
shortcut = None
|
||||||
|
# steamos-delete removes the folder, the shortcut and its Proton prefix (found through the
|
||||||
|
# shortcut), so it runs first; its json files stay, so clear them too.
|
||||||
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||||
ssh(f'rm -f {_json_files(gid)}', timeout=30)
|
ssh(f'rm -f {_json_files(gid)}', timeout=30)
|
||||||
|
# Then tidy what it leaves (artwork, collections); best effort.
|
||||||
|
if shortcut:
|
||||||
|
try:
|
||||||
|
frame_android.shortcut_tool('remove', str(shortcut))
|
||||||
|
except (FrameError, ValueError):
|
||||||
|
pass
|
||||||
return {'id': gid}
|
return {'id': gid}
|
||||||
finally:
|
finally:
|
||||||
_install_lock.release()
|
_install_lock.release()
|
||||||
@@ -815,8 +944,15 @@ def main():
|
|||||||
progress=lambda text, _: print(text + '…', file=sys.stderr))
|
progress=lambda text, _: print(text + '…', file=sys.stderr))
|
||||||
elif cmd == 'list':
|
elif cmd == 'list':
|
||||||
r = list_titles()
|
r = list_titles()
|
||||||
|
if any(t['art_missing'] for t in r):
|
||||||
|
print('Some titles have no Steam artwork: python3 ui/frame_titles.py refresh-art --all', file=sys.stderr)
|
||||||
elif cmd in ('launch', 'remove') and args:
|
elif cmd in ('launch', 'remove') and args:
|
||||||
r = globals()[cmd](args[0])
|
r = globals()[cmd](args[0])
|
||||||
|
elif cmd == 'refresh-art' and args:
|
||||||
|
r = refresh_art(None if args[0] == '--all' else args[0])
|
||||||
|
if isinstance(r, list) and any('error' in t for t in r):
|
||||||
|
print(json.dumps(r, indent=1))
|
||||||
|
raise SystemExit(1)
|
||||||
else:
|
else:
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
except FrameError as e:
|
except FrameError as e:
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Optional software, separate from Frame Control's own controls and HUD.
|
||||||
|
|
||||||
|
Public reports are attributed leads, never local verification. Compatibility
|
||||||
|
reports reuse the existing database with steam:<appid> package keys.
|
||||||
|
"""
|
||||||
|
REPORT = 'https://www.roadtovr.com/valve-steam-frame-review/'
|
||||||
|
UTILITIES = (
|
||||||
|
(1009850, 'OVR Advanced Settings', False, 'No verified Frame result. Steam edition is paid; the developer also publishes free source/releases.', 'https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings'),
|
||||||
|
(1173510, 'XSOverlay', False, 'Supplied research reports Proton support, but the linked review did not corroborate it on recheck. Untested.', REPORT),
|
||||||
|
(1068820, 'OVR Toolkit', False, 'Supplied research reports Proton support, but the linked review did not corroborate it on recheck. Untested.', REPORT),
|
||||||
|
(908520, 'fpsVR', False, 'No Frame-specific result established in the supplied public research. Untested here.', 'https://store.steampowered.com/app/908520/'),
|
||||||
|
(1494460, 'Desktop+', True, 'Free, developer-published software. No verified Frame result.', 'https://github.com/elvissteinjr/DesktopPlus'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def catalogue(ownership, reports):
|
||||||
|
owned = {r['id']: r for r in ownership}
|
||||||
|
out = []
|
||||||
|
for appid, name, free, note, source in UTILITIES:
|
||||||
|
local = owned.get(appid, {})
|
||||||
|
matches = [r for r in reports if r.get('package') == f'steam:{appid}' and r.get('rating') in ('works', 'issues', 'broken')]
|
||||||
|
latest = max(matches, key=lambda r: r.get('date') or '') if matches else None
|
||||||
|
out.append({'id': appid, 'name': name, 'free': free, 'owned': local.get('owned'),
|
||||||
|
'installed': local.get('installed', False), 'frame': local.get('frame', 0),
|
||||||
|
'status': latest['rating'] if latest else 'untested',
|
||||||
|
'report': {k: latest.get(k) for k in ('notes', 'date', 'steamos', 'source')} if latest else None,
|
||||||
|
'note': note, 'source': source,
|
||||||
|
'canInstall': bool(free or local.get('owned'))})
|
||||||
|
return {'utilities': out}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Frame Control's optional performance HUD, using Frame platform tools only.
|
||||||
|
|
||||||
|
Controls remain blocked pending idle-headset verification; no playspace writes
|
||||||
|
are exposed. See docs/vr-utilities.md for the probe evidence and follow-up.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from frame_status import battery, max_temp, performance
|
||||||
|
|
||||||
|
ROOT = Path.home() / '.local/share/frame-control/vr'
|
||||||
|
APPID = '2000250025'
|
||||||
|
|
||||||
|
|
||||||
|
def validate(body):
|
||||||
|
action = body.get('action')
|
||||||
|
if action not in ('hud-start', 'hud-stop'):
|
||||||
|
raise ValueError('VR action must be hud-start or hud-stop; playspace controls are not yet verified')
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
def save(path, data):
|
||||||
|
tmp = path.with_suffix('.tmp')
|
||||||
|
tmp.write_text(json.dumps(data))
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def process_identity(pid):
|
||||||
|
try:
|
||||||
|
stat = Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()
|
||||||
|
args = Path(f'/proc/{pid}/cmdline').read_bytes().split(b'\0')
|
||||||
|
if b'frame-control-hud' in args and b'xterm' in Path(f'/proc/{pid}/comm').read_bytes():
|
||||||
|
return stat[19] # field 22, starttime; protects against PID reuse
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def panel(action):
|
||||||
|
path = ROOT / 'hud.json'
|
||||||
|
saved = json.loads(path.read_text()) if path.exists() else {}
|
||||||
|
pid = saved.get('pid')
|
||||||
|
running = bool(pid and saved.get('start') and process_identity(pid) == saved['start'])
|
||||||
|
if action == 'hud-stop':
|
||||||
|
if running:
|
||||||
|
os.kill(pid, signal.SIGTERM) # xterm closes the PTY; child exits on HUP
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
return {'message': 'HUD closed.'}
|
||||||
|
if running:
|
||||||
|
return {'message': 'HUD is already open. Find Frame Control HUD in the SteamVR dashboard.'}
|
||||||
|
env = {**os.environ, 'DISPLAY': ':0'}
|
||||||
|
p = subprocess.Popen(['xterm', '-name', 'frame-control-hud', '-title', 'Frame Control HUD',
|
||||||
|
'-fa', 'Monospace', '-fs', '20', '-geometry', '56x16',
|
||||||
|
'-bg', '#171d25', '-fg', '#d6d7d8', '-e',
|
||||||
|
sys.executable, str(ROOT / 'frame_vr.py'), 'hud'],
|
||||||
|
env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 8
|
||||||
|
while time.monotonic() < deadline and p.poll() is None:
|
||||||
|
tree = subprocess.check_output(['xwininfo', '-display', ':0', '-root', '-tree'],
|
||||||
|
text=True, timeout=2)
|
||||||
|
for w in re.findall(r'(0x[0-9a-f]+).*"frame-control-hud"', tree):
|
||||||
|
owner = subprocess.check_output(['xprop', '-display', ':0', '-id', w, '_NET_WM_PID'],
|
||||||
|
text=True, timeout=2)
|
||||||
|
if owner.strip().endswith('= ' + str(p.pid)):
|
||||||
|
subprocess.run(['xprop', '-display', ':0', '-id', w, '-f', 'STEAM_GAME', '32c',
|
||||||
|
'-set', 'STEAM_GAME', APPID], check=True, timeout=2)
|
||||||
|
identity = process_identity(p.pid)
|
||||||
|
if not identity:
|
||||||
|
raise RuntimeError('HUD process exited before its panel was ready')
|
||||||
|
save(path, {'pid': p.pid, 'start': identity})
|
||||||
|
return {'message': 'HUD opened. In SteamVR, select Frame Control HUD and Float in World or dock it to a controller.'}
|
||||||
|
time.sleep(.1)
|
||||||
|
raise RuntimeError('HUD did not create a window; is gamescope running?')
|
||||||
|
except BaseException:
|
||||||
|
if p.poll() is None:
|
||||||
|
p.terminate()
|
||||||
|
p.wait(timeout=3)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def hud():
|
||||||
|
def fmt(v, unit=''):
|
||||||
|
return 'unavailable' if v is None else f'{v:.1f}{unit}'
|
||||||
|
while True:
|
||||||
|
p, b = performance(), battery() or {}
|
||||||
|
lines = ['FRAME CONTROL HUD', '',
|
||||||
|
'Compositor FPS ' + fmt(p['compositorFps']),
|
||||||
|
'Compositor period ' + fmt(p['frameMs'], ' ms'),
|
||||||
|
'Application FPS ' + fmt(p['appFps']),
|
||||||
|
'Render GPU time ' + fmt(p['gpuMs'], ' ms'),
|
||||||
|
'Compositor CPU ' + fmt(p['compositorCpuMs'], ' ms'),
|
||||||
|
'System CPU ' + fmt(p['cpuPercent'], '%'),
|
||||||
|
'GPU clock ' + fmt(p['gpuMHz'], ' MHz'),
|
||||||
|
'Hottest sensor ' + fmt(max_temp(), ' C'),
|
||||||
|
'Battery ' + fmt(b.get('percent'), '%'), '',
|
||||||
|
time.strftime('Updated %H:%M:%S'), 'Close this window to stop.']
|
||||||
|
print('\033[2J\033[H' + '\n'.join(lines), flush=True)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(body):
|
||||||
|
import fcntl # Frame only; validation is also imported by Windows hosts
|
||||||
|
action = validate(body)
|
||||||
|
ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
with (ROOT / 'control.lock').open('a') as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
return panel(action)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if sys.argv[1:] == ['hud']:
|
||||||
|
hud()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
print(json.dumps(dispatch(json.load(sys.stdin))))
|
||||||
|
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as e:
|
||||||
|
print(json.dumps({'error': str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
|
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
|
||||||
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
|
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
|
||||||
.seg button.on { background: var(--btn-hi); color: var(--bright); }
|
.seg button.on { background: var(--btn-hi); color: var(--bright); }
|
||||||
input[type=text], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
input[type=text], input[type=search], input[type=url], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||||
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
||||||
textarea { resize: vertical; min-height: 76px; }
|
textarea { resize: vertical; min-height: 76px; }
|
||||||
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
||||||
@@ -318,8 +318,8 @@
|
|||||||
.and-btns { flex-basis: 100%; padding-left: 46px; }
|
.and-btns { flex-basis: 100%; padding-left: 46px; }
|
||||||
.and-icon { width: 36px; height: 36px; border-radius: 8px; flex: none; background: rgba(0,0,0,.25); object-fit: cover; }
|
.and-icon { width: 36px; height: 36px; border-radius: 8px; flex: none; background: rgba(0,0,0,.25); object-fit: cover; }
|
||||||
.cat-tools { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
.cat-tools { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
.cat-tools input[type=text] { flex: 1 1 240px; width: auto; }
|
.cat-tools input[type=text], .cat-tools input[type=search] { flex: 1 1 240px; width: auto; }
|
||||||
.cat-tools select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
.cat-tools select, .source-offer select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||||||
padding: 9px 10px; font: inherit; font-size: 13px; max-width: 220px; }
|
padding: 9px 10px; font: inherit; font-size: 13px; max-width: 220px; }
|
||||||
.vchips { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 14px; }
|
.vchips { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 14px; }
|
||||||
.vchip { height: 26px; font-size: 12px; padding: 0 10px; opacity: .55; }
|
.vchip { height: 26px; font-size: 12px; padding: 0 10px; opacity: .55; }
|
||||||
@@ -416,7 +416,7 @@
|
|||||||
button { height: 38px; }
|
button { height: 38px; }
|
||||||
button.small { height: 32px; }
|
button.small { height: 32px; }
|
||||||
.actions button { height: 46px; }
|
.actions button { height: 46px; }
|
||||||
.cat-tools select { max-width: none; flex: 1 1 100%; }
|
.cat-tools select, .source-offer select { max-width: none; flex: 1 1 100%; }
|
||||||
.pad-area { height: 240px; }
|
.pad-area { height: 240px; }
|
||||||
.pad-keys .seg { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 1fr); }
|
.pad-keys .seg { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 1fr); }
|
||||||
.pad-keys .pad-clicks { grid-template-columns: repeat(2, 1fr); }
|
.pad-keys .pad-clicks { grid-template-columns: repeat(2, 1fr); }
|
||||||
@@ -424,6 +424,155 @@
|
|||||||
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
|
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
|
||||||
body.typing nav { display: none; }
|
body.typing nav { display: none; }
|
||||||
}
|
}
|
||||||
|
/* ---- Discover: artwork first, details when you need them ---- */
|
||||||
|
.sr-only { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; }
|
||||||
|
#androidLibrary { grid-template-columns:1fr; }
|
||||||
|
#androidLibrary .and-col { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr)); align-items:start; }
|
||||||
|
.store-tabs { display:flex; align-items:center; gap:8px; margin-bottom:20px; border-bottom:1px solid #ffffff12; padding-bottom:14px; }
|
||||||
|
.store-tabs > button { background:transparent; color:var(--muted); border-radius:6px; }
|
||||||
|
.store-tabs > button.on { color:var(--bright); background:#ffffff0e; }
|
||||||
|
.store-heading h1 { font-size:clamp(28px,3.2vw,38px); letter-spacing:-1.2px; line-height:1.15; margin:8px 0 10px; font-weight:700; color:var(--bright); }
|
||||||
|
.store-heading p, .sources-intro p { color:#a8b5c2; margin:0 0 24px; font-size:15px; }
|
||||||
|
.store-eyebrow { color:var(--link); font-size:11px; text-transform:uppercase; font-weight:700; letter-spacing:1.8px; }
|
||||||
|
.store-toolbar { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,400px); align-items:center; gap:32px; }
|
||||||
|
.store-toolbar .store-heading p { margin-bottom:0; }
|
||||||
|
@media(max-width:900px) { .store-toolbar { display:block; } .store-toolbar .store-heading p { margin-bottom:20px; } }
|
||||||
|
.store-search { display:flex; align-items:center; gap:12px; border:1px solid #ffffff12; background:#0e1723a6; border-radius:8px; padding:6px 8px 6px 16px; }
|
||||||
|
.store-search svg { width:21px; height:21px; color:var(--muted); flex:none; }
|
||||||
|
.store-search input[type=search] { background:transparent; padding:9px 0; font-size:15px; border:0; box-shadow:none; min-width:0; }
|
||||||
|
.store-search:focus-within { border-color:var(--link); }
|
||||||
|
.store-search button { border-radius:5px; }
|
||||||
|
.store-filters { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:16px 0 24px; }
|
||||||
|
.store-chips { display:flex; flex-wrap:wrap; gap:7px; }
|
||||||
|
.store-chips button { border-radius:20px; font-size:12px; height:34px; padding:0 14px; background:#ffffff07; color:#b5c0cb; border:1px solid #ffffff0c; }
|
||||||
|
.store-chips button[aria-pressed=true] { background:#235779; border-color:#5287a5; color:white; }
|
||||||
|
.store-source-select select { max-width:180px; padding:8px 26px 8px 10px; font:inherit; font-size:12px; border:0; border-radius:5px; color:var(--text); background:#1d2b3b; }
|
||||||
|
.store-notice { color:#a8b5c2; font-size:12px; margin:12px 0; }
|
||||||
|
body:has(.page.on #appStore:not([hidden])) #offline { padding:10px 16px; background:#ffffff06; box-shadow:inset 3px 0 0 var(--dim); }
|
||||||
|
body:has(.page.on #appStore:not([hidden])) #offline .s { display:none; }
|
||||||
|
.store-result-count { font-size:13px; color:var(--muted); margin-bottom:14px; }
|
||||||
|
.store-result-count:empty { display:none; }
|
||||||
|
.store-hero { position:relative; display:block; width:100%; height:270px; border:0; padding:0; border-radius:12px; overflow:hidden; text-align:left; margin:0 0 32px; isolation:isolate; }
|
||||||
|
.store-art { position:relative; display:block; overflow:hidden; background:radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%),transparent 70%),linear-gradient(140deg,hsl(var(--hue) 35% 18%),#101827); }
|
||||||
|
.store-art > img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; }
|
||||||
|
.art-initial { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:70px; font-weight:700; color:#ffffff45; letter-spacing:-4px; }
|
||||||
|
.store-hero > .store-art { position:absolute; inset:0; }
|
||||||
|
.store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; }
|
||||||
|
.hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; }
|
||||||
|
.hero-copy .store-eyebrow { color:#c2e7ff; }
|
||||||
|
.hero-copy strong { color:white; font-size:38px; letter-spacing:-.8px; line-height:1.1; }
|
||||||
|
.hero-copy > span:not(.store-eyebrow):not(.hero-cta) { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; }
|
||||||
|
.hero-cta { color:white; display:inline-flex; align-items:center; background:#ffffff20; border:1px solid #ffffff40; border-radius:6px; padding:10px 18px; font-size:13px; margin-top:6px; }
|
||||||
|
.store-row { margin-bottom:34px; }
|
||||||
|
.store-row-head { display:flex; align-items:baseline; gap:12px; margin-bottom:14px; }
|
||||||
|
.store-row-head h2 { text-transform:none; letter-spacing:-.3px; font-size:21px; font-weight:600; color:var(--bright); margin:0; }
|
||||||
|
.store-row-head p { color:var(--muted); font-size:12px; margin:0; }
|
||||||
|
.store-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(225px,1fr)); gap:18px; }
|
||||||
|
.store-card { display:block; width:100%; height:auto; min-width:0; padding:0; border:1px solid #ffffff0b; border-radius:9px; overflow:hidden; text-align:left; background:#1b2938; color:var(--text); transition:transform .18s,box-shadow .18s; font-weight:400; }
|
||||||
|
.store-card:hover { transform:translateY(-4px); background:#223448; box-shadow:0 12px 28px #0004; }
|
||||||
|
.store-card .store-art { aspect-ratio:16/9; width:100%; }
|
||||||
|
.store-card-content { padding:15px 14px; display:block; }
|
||||||
|
.store-card-title { display:flex; align-items:center; gap:9px; margin-bottom:9px; min-width:0; }
|
||||||
|
.store-icon { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:8px; overflow:hidden; flex:none; background:hsl(var(--hue) 35% 28%); color:#fff; font-size:17px; position:relative; }
|
||||||
|
.store-icon img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
|
||||||
|
.store-card-title strong { font-size:15px; color:var(--bright); text-overflow:ellipsis; overflow:hidden; white-space:nowrap; }
|
||||||
|
.store-summary { display:block; color:#a8b5c2; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.store-card-foot { display:flex; justify-content:space-between; align-items:center; gap:6px; margin-top:16px; font-size:11px; color:#a8b5c2; }
|
||||||
|
.store-card-foot b { color:#dbeecf; font-weight:500; }
|
||||||
|
.store-badges { display:flex; flex-wrap:wrap; gap:7px; }
|
||||||
|
.store-badge { font-size:11px; color:#b7d8ec; background:#6ec9ff14; padding:4px 9px; border-radius:5px; }
|
||||||
|
.store-badge.works { color:#b8e2ab; background:#70bb5418; }
|
||||||
|
.store-empty { display:flex; flex-direction:column; align-items:center; text-align:center; padding:58px 20px 70px; }
|
||||||
|
.store-empty svg { width:110px; height:90px; color:var(--link); margin-bottom:14px; }
|
||||||
|
.store-empty h2 { font-size:23px; color:var(--bright); margin:8px 0; text-transform:none; letter-spacing:0; }
|
||||||
|
.store-empty p { max-width:390px; color:var(--muted); margin:0 0 22px; }
|
||||||
|
.store-skeleton { aspect-ratio:1.2; background:linear-gradient(110deg,#233446 30%,#30455b 45%,#233446 60%); background-size:250% 100%; border-radius:9px; animation:store-shimmer 1.8s ease infinite; }
|
||||||
|
@keyframes store-shimmer { to { background-position:-150% 0; } }
|
||||||
|
.store-dialog { padding:0; border:1px solid #ffffff1a; border-radius:14px; background:#172331; color:var(--text); width:min(980px,calc(100vw - 40px)); max-width:none; max-height:calc(100dvh - 48px); overflow:auto; box-shadow:0 30px 100px #0009; }
|
||||||
|
.store-dialog::backdrop { background:#030a13c9; backdrop-filter:blur(7px); }
|
||||||
|
.store-close { position:sticky; float:right; top:14px; margin:14px 14px -54px 0; z-index:6; height:36px; width:36px; padding:0; border-radius:50%; background:#09131dcc; color:white; font-size:25px; line-height:1; border:1px solid #ffffff30; }
|
||||||
|
.detail-banner { height:195px; }
|
||||||
|
.detail-banner::after { content:""; position:absolute; inset:0; z-index:2; background:linear-gradient(0deg,#172331,transparent 70%); }
|
||||||
|
.detail-content { padding:0 30px 30px; position:relative; }
|
||||||
|
.detail-heading { display:flex; gap:16px; align-items:center; margin-top:-24px; position:relative; z-index:3; }
|
||||||
|
.detail-heading .store-icon { width:70px; height:70px; border-radius:15px; box-shadow:0 3px 18px #0004; font-size:32px; }
|
||||||
|
.detail-heading h1 { font-size:30px; color:white; letter-spacing:-.7px; margin:0 0 4px; }
|
||||||
|
.detail-heading p { margin:0; color:#a8b5c2; font-size:13px; }
|
||||||
|
.detail-content > .store-badges { margin:20px 0; }
|
||||||
|
.detail-layout { display:grid; grid-template-columns:minmax(0,1fr) 290px; gap:30px; margin-top:22px; }
|
||||||
|
.detail-layout h2 { text-transform:none; letter-spacing:0; font-size:17px; color:white; margin:0 0 14px; }
|
||||||
|
.detail-description { color:#b5c2ce; font-size:14px; line-height:1.75; white-space:pre-line; }
|
||||||
|
.detail-gallery { display:flex; gap:10px; overflow-x:auto; scroll-snap-type:x mandatory; margin:0 0 10px; }
|
||||||
|
.detail-gallery .store-art { flex:0 0 86%; aspect-ratio:16/9; border-radius:7px; scroll-snap-align:start; }
|
||||||
|
.detail-gallery .store-art img { object-fit:contain; background:#0b1420; }
|
||||||
|
.gallery-controls { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; font-size:11px; color:var(--muted); }
|
||||||
|
.gallery-controls button { height:30px; width:34px; padding:0; border-radius:5px; }
|
||||||
|
.detail-buy { align-self:start; background:#0e1926; padding:18px; border:1px solid #ffffff0a; border-radius:10px; }
|
||||||
|
.detail-buy .store-primary { width:100%; min-height:46px; border-radius:7px; margin:8px 0; font-size:15px; }
|
||||||
|
.detail-verdict { color:#b6d6a8; font-size:12px; line-height:1.55; margin:0 0 15px; }
|
||||||
|
.detail-verdict.blocked { color:#e3b77a; }
|
||||||
|
.detail-verdict.unknown { color:#adbac7; }
|
||||||
|
.detail-price { font-size:23px; color:white; font-weight:600; }
|
||||||
|
.detail-small { font-size:11px; color:var(--muted); margin:4px 0 18px; }
|
||||||
|
.offer-choice { display:flex; align-items:flex-start; gap:9px; padding:11px 9px; margin-top:8px; border:1px solid #ffffff12; border-radius:7px; cursor:pointer; }
|
||||||
|
.offer-choice:has(input:checked) { background:#1e3c52; border-color:#5386a6; }
|
||||||
|
.offer-choice input { margin:4px 0 0; accent-color:var(--blue); }
|
||||||
|
.offer-choice strong { font-size:12px; display:block; color:var(--bright); }
|
||||||
|
.offer-choice small { display:block; font-size:10px; color:#a8bbc9; margin-top:4px; }
|
||||||
|
.offer-mark { display:inline-flex; justify-content:center; align-items:center; height:26px; width:26px; flex:none; border-radius:6px; background:hsl(var(--hue) 30% 30%); color:white; font-size:11px; }
|
||||||
|
.detail-technical { border-top:1px solid #ffffff14; margin-top:25px; padding-top:16px; font-size:12px; color:var(--muted); }
|
||||||
|
.detail-technical summary { cursor:pointer; }
|
||||||
|
.detail-technical dl { display:grid; grid-template-columns:110px minmax(0,1fr); gap:6px; }
|
||||||
|
.detail-technical dd { margin:0; overflow-wrap:anywhere; }
|
||||||
|
.detail-buy .store-primary:disabled { opacity:1; color:#d6e8f7; background:#274f71; cursor:default; }
|
||||||
|
#detailProgress::-webkit-progress-bar { background:#283b4d; border-radius:3px; }
|
||||||
|
#detailProgress::-webkit-progress-value { background:var(--blue); border-radius:3px; }
|
||||||
|
#detailProgress { height:4px; width:100%; accent-color:var(--blue); display:block; margin-bottom:10px; }
|
||||||
|
.sources-dialog { width:min(560px,calc(100vw - 32px)); padding:30px; }
|
||||||
|
.sources-dialog .store-close { margin:-16px -16px -20px 0; top:0; }
|
||||||
|
.sources-intro h1 { font-size:28px; color:white; letter-spacing:-.7px; margin:9px 0; }
|
||||||
|
.source-setting { display:flex; align-items:center; gap:13px; padding:17px 0; border-top:1px solid #ffffff0c; }
|
||||||
|
.source-setting .offer-mark { width:42px; height:42px; font-size:16px; border-radius:10px; }
|
||||||
|
.source-setting .grow { flex:1; min-width:0; }
|
||||||
|
.store-dialog a { color:var(--link); }
|
||||||
|
.source-setting strong { display:block; font-size:14px; color:white; }
|
||||||
|
.source-setting p { color:#a3b4c3; font-size:12px; margin:3px 0; }
|
||||||
|
.source-setting small { color:var(--muted); font-size:10px; }
|
||||||
|
.source-toggle { appearance:none; width:38px; height:23px; flex:none; border-radius:15px; background:#465463; position:relative; cursor:pointer; margin:0; }
|
||||||
|
.source-toggle::after { content:""; position:absolute; top:3px; left:3px; width:17px; height:17px; background:#fff; border-radius:50%; transition:transform .15s; }
|
||||||
|
.source-toggle:checked { background:#208dca; }
|
||||||
|
.source-toggle:checked::after { transform:translateX(15px); }
|
||||||
|
.source-repo-form { display:grid; gap:12px; border-top:1px solid #ffffff14; margin-top:10px; padding-top:24px; }
|
||||||
|
.source-repo-form h2 { font-size:19px; letter-spacing:0; text-transform:none; color:white; margin:0; }
|
||||||
|
.source-repo-form p { color:var(--muted); font-size:12px; margin:0; }
|
||||||
|
.source-repo-form label { font-size:12px; }
|
||||||
|
.source-repo-form input { margin-top:7px; display:block; border-radius:6px; }
|
||||||
|
.source-repo-form details { font-size:11px; color:var(--muted); }
|
||||||
|
.source-repo-form button { justify-self:start; border-radius:6px; }
|
||||||
|
#appStore :focus-visible, .store-dialog :focus-visible, .store-tabs :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
|
||||||
|
.store-search input:focus-visible { outline:0!important; }
|
||||||
|
@media(min-width:1400px) { .store-grid { grid-template-columns:repeat(5,minmax(0,1fr)); } }
|
||||||
|
@media(max-width:1000px) { .store-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } .detail-layout { grid-template-columns:minmax(0,1fr) 260px; gap:20px; } }
|
||||||
|
@media(max-width:680px) {
|
||||||
|
.store-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||||
|
.store-tabs { margin-bottom:24px; gap:2px; } .store-tabs button { padding:0 10px; font-size:12px; }
|
||||||
|
.store-heading p { font-size:13px; max-width:320px; }
|
||||||
|
.store-filters { align-items:flex-start; flex-direction:column; gap:10px; margin-bottom:18px; }
|
||||||
|
.store-chips { gap:5px; } .store-chips button { font-size:11px; padding:0 10px; }
|
||||||
|
.store-hero { height:285px; border-radius:9px; margin-bottom:26px; }
|
||||||
|
.hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; }
|
||||||
|
.hero-copy > span:not(.store-eyebrow):not(.hero-cta) { font-size:13px; max-width:260px; }
|
||||||
|
.store-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; }
|
||||||
|
.store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; }
|
||||||
|
.store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; }
|
||||||
|
.store-card-foot { font-size:10px; margin-top:12px; } .store-summary { font-size:11px; }
|
||||||
|
.store-dialog { width:calc(100vw - 16px); max-height:calc(100dvh - 20px); border-radius:10px; }
|
||||||
|
.detail-banner { height:190px; } .detail-content { padding:0 18px 24px; } .detail-heading h1 { font-size:24px; }
|
||||||
|
.detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; }
|
||||||
|
.sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; }
|
||||||
|
}
|
||||||
|
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -576,6 +725,25 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="panel" id="vrTools">
|
||||||
|
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||||
|
<div class="stats" id="vrMetrics"></div>
|
||||||
|
<div class="hint">Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Temperature is the hottest readable sensor.</div>
|
||||||
|
<div class="chips">
|
||||||
|
<button class="small" id="hudStart">Open HUD in headset</button>
|
||||||
|
<button class="small" id="hudStop">Close HUD</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">The HUD is ours and uses the Frame’s built-in tools. Place its panel with SteamVR’s Float in World or controller docking controls. It updates every two seconds until closed.</div>
|
||||||
|
<h3>Playspace and seated comfort</h3>
|
||||||
|
<p class="sub" id="comfortNote">Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode.</p>
|
||||||
|
<div class="hint">For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.</div>
|
||||||
|
<details style="margin-top:16px"><summary>Optional VR software</summary>
|
||||||
|
<p class="sub">None of Frame Control’s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.</p>
|
||||||
|
<button class="small" id="vrUtilitiesRefresh">Check ownership and reports</button>
|
||||||
|
<div class="list" id="vrUtilities"><div class="sub">Load this optional list to check the Frame account.</div></div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="comfort" style="margin:20px 0;padding:16px">
|
<section class="panel" id="comfort" style="margin:20px 0;padding:16px">
|
||||||
<div class="shelf-head"><h2>Family and comfort</h2><span class="spacer"></span>
|
<div class="shelf-head"><h2>Family and comfort</h2><span class="spacer"></span>
|
||||||
<button class="action small" id="castBtn">Cast headset view</button>
|
<button class="action small" id="castBtn">Cast headset view</button>
|
||||||
@@ -687,9 +855,16 @@
|
|||||||
|
|
||||||
<div class="page" data-page="android">
|
<div class="page" data-page="android">
|
||||||
<section id="android">
|
<section id="android">
|
||||||
<div class="shelf-head"><h2>Android apps</h2><span class="count" id="andCount"></span><span class="spacer"></span>
|
<div class="store-tabs" aria-label="Android sections"><button class="on" data-store-tab="browse" aria-pressed="true">Discover</button><button data-store-tab="library" aria-pressed="false">On your Frame <span id="andCount"></span></button><span class="spacer"></span><button id="sourcesOpen" class="small">Sources <span aria-hidden="true">⚙</span></button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
|
||||||
<button class="action small" id="repNew" title="Report how any APK worked on the Frame">+ Report an APK</button></div>
|
<section id="appStore" aria-label="Discover apps">
|
||||||
<div class="and-grid">
|
<div class="store-toolbar"><div class="store-heading"><div><div class="store-eyebrow">A little more possibility</div><h1>Find your next favorite.</h1><p>Great games and useful apps. All in one place, all free to explore.</p></div></div>
|
||||||
|
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form></div>
|
||||||
|
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">For you</button><button data-filter="vr" aria-pressed="false">VR experiences</button><button data-filter="flat" aria-pressed="false">Flat-screen apps</button><button data-filter="installable" aria-pressed="false">Fits your Frame</button></div><label class="store-source-select"><span class="sr-only">Filter by source</span><select id="sourceFilter"><option value="">All sources</option></select></label></div>
|
||||||
|
<div id="sourceStatus" class="store-notice" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div id="searchCount" class="store-result-count" aria-live="polite"></div>
|
||||||
|
<div id="searchGrid" aria-busy="false"></div>
|
||||||
|
</section>
|
||||||
|
<div class="and-grid" id="androidLibrary" hidden>
|
||||||
<div class="and-col">
|
<div class="and-col">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="shelf-head"><h2>On the Frame</h2></div>
|
<div class="shelf-head"><h2>On the Frame</h2></div>
|
||||||
@@ -740,8 +915,8 @@
|
|||||||
reported, including ones not on F-Droid.</div>
|
reported, including ones not on F-Droid.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div hidden>
|
||||||
<div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
|
<div hidden> <div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
|
||||||
<div class="cat-tools">
|
<div class="cat-tools">
|
||||||
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
|
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
|
||||||
<select id="catCat" aria-label="Category"><option value="">All categories</option></select>
|
<select id="catCat" aria-label="Category"><option value="">All categories</option></select>
|
||||||
@@ -750,7 +925,7 @@
|
|||||||
<div class="cat-grid" id="catGrid"><div class="sub">Loading the catalogue…</div></div>
|
<div class="cat-grid" id="catGrid"><div class="sub">Loading the catalogue…</div></div>
|
||||||
<div class="row" style="margin-top:12px"><button class="small" id="catMore" hidden>Show more</button></div>
|
<div class="row" style="margin-top:12px"><button class="small" id="catMore" hidden>Show more</button></div>
|
||||||
<div class="hint">Verdicts come from checks of each APK against what Lepton supports (Android 11,
|
<div class="hint">Verdicts come from checks of each APK against what Lepton supports (Android 11,
|
||||||
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div>
|
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -820,6 +995,19 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="panel" id="artworkSettings">
|
||||||
|
<div class="shelf-head"><h2>Library artwork settings</h2></div>
|
||||||
|
<p class="sub">SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.</p>
|
||||||
|
<label for="steamGridKey">SteamGridDB API key</label>
|
||||||
|
<input type="password" id="steamGridKey" autocomplete="off" spellcheck="false" placeholder="Optional API key">
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
<button id="saveSteamGridKey" class="action small">Save key</button>
|
||||||
|
<button id="clearSteamGridKey" class="small">Remove saved key</button>
|
||||||
|
<a href="https://www.steamgriddb.com/profile/preferences/api" target="_blank" rel="noreferrer">Get a free key</a>
|
||||||
|
</div>
|
||||||
|
<div id="steamGridStatus" class="hint" role="status"></div>
|
||||||
|
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh artwork for installed apps and titles</button>
|
||||||
|
</section>
|
||||||
<section class="panel" id="power">
|
<section class="panel" id="power">
|
||||||
<div class="shelf-head"><h2>Remote & power</h2></div>
|
<div class="shelf-head"><h2>Remote & power</h2></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -903,6 +1091,18 @@
|
|||||||
<span class="last" id="lastLog">Ready</span>
|
<span class="last" id="lastLog">Ready</span>
|
||||||
<span class="sub" id="drawerHint">Show ▴</span>
|
<span class="sub" id="drawerHint">Show ▴</span>
|
||||||
</div>
|
</div>
|
||||||
|
<dialog id="appDetail" class="store-dialog" aria-labelledby="detailTitle">
|
||||||
|
<button class="store-close" data-close="appDetail" aria-label="Close app details">×</button>
|
||||||
|
<div id="detailBody"></div>
|
||||||
|
</dialog>
|
||||||
|
<dialog id="sourcesDialog" class="store-dialog sources-dialog" aria-labelledby="sourcesTitle">
|
||||||
|
<button class="store-close" data-close="sourcesDialog" aria-label="Close source settings">×</button>
|
||||||
|
<div class="sources-intro"><div class="store-eyebrow">Your app shelves</div><h1 id="sourcesTitle">A world of apps.</h1><p>Choose where you discover games and apps. We bring them together for you.</p></div>
|
||||||
|
<div id="sourcesList"></div>
|
||||||
|
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><button class="action">Add source</button></form>
|
||||||
|
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
|
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
|
||||||
<h2 id="apkAltTitle">Try another APK version</h2>
|
<h2 id="apkAltTitle">Try another APK version</h2>
|
||||||
<p id="apkAltReason"></p>
|
<p id="apkAltReason"></p>
|
||||||
@@ -1165,6 +1365,7 @@ function refresh() {
|
|||||||
setConn(true, state.hostname);
|
setConn(true, state.hostname);
|
||||||
setOnline(true);
|
setOnline(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
renderPerformance(null);
|
||||||
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
|
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
|
||||||
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
|
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1175,6 +1376,38 @@ function refresh() {
|
|||||||
return refreshing;
|
return refreshing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- our VR HUD and optional third-party catalogue ----
|
||||||
|
function renderPerformance(s) {
|
||||||
|
const p = s?.performance || {};
|
||||||
|
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
|
||||||
|
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
|
||||||
|
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
|
||||||
|
["GPU clock", p.gpuMHz, " MHz"], ["Hottest sensor", s?.temp, "°C"], ["Battery", s?.battery?.percent, "%"]];
|
||||||
|
$("vrMetrics").innerHTML = values.map(([label, v, unit]) => `<div class="stat"><div class="k">${label}</div><div class="v">${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}</div></div>`).join("");
|
||||||
|
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
|
||||||
|
}
|
||||||
|
$("hudStart").onclick = () => act("Open HUD", () => api("/api/vr", {action: "hud-start"}), $("hudStart"));
|
||||||
|
$("hudStop").onclick = () => act("Close HUD", () => api("/api/vr", {action: "hud-stop"}), $("hudStop"));
|
||||||
|
async function loadUtilities() {
|
||||||
|
$("vrUtilitiesRefresh").disabled = true;
|
||||||
|
try {
|
||||||
|
const data = await api("/api/vr/utilities");
|
||||||
|
$("vrUtilities").innerHTML = data.utilities.map(u => `<div class="item">
|
||||||
|
<div class="grow"><strong>${esc(u.name)}</strong> · ${esc(u.status)} · ${u.free ? "Free" : "Paid"} · ${u.owned == null ? "Ownership unknown" : u.owned ? "Owned" : "Not in library"}
|
||||||
|
<div class="sub">${esc(u.note)} <a href="${esc(u.source)}" target="_blank" rel="noopener">Source</a></div>
|
||||||
|
${u.report ? `<div class="sub">Report ${esc(u.report.date || "")}: ${esc(u.report.notes || "")} · SteamOS ${esc(u.report.steamos || "unspecified")}</div>` : ""}</div>
|
||||||
|
<button class="small" data-utility="${u.id}" ${u.installed || !u.canInstall ? "disabled" : ""}>${u.installed ? "Installed" : "Install (optional)"}</button></div>`).join("");
|
||||||
|
} catch(e) { failed($("vrUtilities"), e); }
|
||||||
|
finally { $("vrUtilitiesRefresh").disabled = false; }
|
||||||
|
}
|
||||||
|
$("vrUtilitiesRefresh").onclick = loadUtilities;
|
||||||
|
$("vrUtilities").onclick = async e => {
|
||||||
|
const b = e.target.closest("[data-utility]");
|
||||||
|
if (!b) return;
|
||||||
|
await act("Install optional utility", () => api("/api/steam", {action:"install", appid:b.dataset.utility}), b);
|
||||||
|
await loadUtilities();
|
||||||
|
};
|
||||||
|
|
||||||
// The Frame owns the clock. Poll independently of status and the selected tab.
|
// The Frame owns the clock. Poll independently of status and the selected tab.
|
||||||
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
|
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
|
||||||
async function localNotification(message, request = false) {
|
async function localNotification(message, request = false) {
|
||||||
@@ -1314,6 +1547,7 @@ function battery(b, power) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render(s) {
|
function render(s) {
|
||||||
|
renderPerformance(s);
|
||||||
battery(s.battery, s.power);
|
battery(s.battery, s.power);
|
||||||
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
||||||
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
||||||
@@ -2113,7 +2347,7 @@ function showJobs() {
|
|||||||
$("jobs").hidden = !jobsRunning;
|
$("jobs").hidden = !jobsRunning;
|
||||||
$("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`;
|
$("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`;
|
||||||
}
|
}
|
||||||
async function runJob(label, key, start) {
|
async function runJob(label, key, start, onProgress = () => {}) {
|
||||||
installing.add(key); jobsRunning++; showJobs();
|
installing.add(key); jobsRunning++; showJobs();
|
||||||
log(label + "…");
|
log(label + "…");
|
||||||
try {
|
try {
|
||||||
@@ -2123,6 +2357,7 @@ async function runJob(label, key, start) {
|
|||||||
let j;
|
let j;
|
||||||
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
|
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
|
||||||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||||||
|
onProgress(j);
|
||||||
if (!j.done) continue;
|
if (!j.done) continue;
|
||||||
if (j.error) throw new Error(j.error);
|
if (j.error) throw new Error(j.error);
|
||||||
log(j.message, "ok"); toast(j.message);
|
log(j.message, "ok"); toast(j.message);
|
||||||
@@ -2485,6 +2720,7 @@ async function loadTitles() {
|
|||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="grow"><div class="t">${esc(t.id)}</div>
|
<div class="grow"><div class="t">${esc(t.id)}</div>
|
||||||
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
|
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
|
||||||
|
${t.art_missing ? `<button class="small" data-title="refresh-art" data-id="${esc(t.id)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
|
||||||
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
|
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
|
||||||
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
|
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
|
||||||
</div>`).join("") : `<div class="sub">None yet.</div>`;
|
</div>`).join("") : `<div class="sub">None yet.</div>`;
|
||||||
@@ -2496,6 +2732,9 @@ $("titleList").onclick = async e => {
|
|||||||
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
|
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
|
||||||
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
|
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
|
||||||
loadTitles();
|
loadTitles();
|
||||||
|
} else if (b.dataset.title === "refresh-art") {
|
||||||
|
await runJob(`Add Steam artwork for ${id}`, "art-" + id, () => api("/api/titles", { action: "refresh-art", id }));
|
||||||
|
loadTitles();
|
||||||
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
|
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
|
||||||
};
|
};
|
||||||
loadTitles();
|
loadTitles();
|
||||||
@@ -2646,6 +2885,157 @@ document.body.addEventListener("click", async e => {
|
|||||||
});
|
});
|
||||||
loadOwned();
|
loadOwned();
|
||||||
|
|
||||||
|
// ---- Discover apps ----
|
||||||
|
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
|
||||||
|
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
|
||||||
|
const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
|
||||||
|
const storeInitial = name => [...String(name || "?")][0].toUpperCase();
|
||||||
|
const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/ /g, " ");
|
||||||
|
const storeOpenSource = o => o.open_source === true || /^(MIT|Apache|[AL]?GPL|BSD|MPL|ISC|CC0|Unlicense)/i.test(o.license || "");
|
||||||
|
function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } }
|
||||||
|
function storeImage(path, alt, eager=false) { return /^\/source-image\/[\w-]+$/.test(path || "") ? `<img src="${esc(path)}" alt="${esc(alt)}" loading="${eager ? "eager" : "lazy"}" decoding="async">` : ""; }
|
||||||
|
function storeArt(o, cls="", eager=false, path=null, alt=null) {
|
||||||
|
const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0];
|
||||||
|
return `<span class="store-art ${cls}" style="--hue:${storeHue(o.name)}"><span class="art-initial" aria-hidden="true">${esc(storeInitial(o.name))}</span>${storeImage(image, alt || `${o.name} artwork`, eager)}</span>`;
|
||||||
|
}
|
||||||
|
function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue(o.name)}" aria-hidden="true">${esc(storeInitial(o.name))}${storeImage(o.artwork?.icon, "")}</span>`; }
|
||||||
|
document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true);
|
||||||
|
function sourceMark(s) { const name=storeName(s); return `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
|
||||||
|
function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); }
|
||||||
|
async function loadSources() {
|
||||||
|
try {
|
||||||
|
const {sources} = await api("/api/sources"); sourceState.sources = sources;
|
||||||
|
$("sourceFilter").innerHTML = `<option value="">All sources</option>` + sources.filter(s=>s.enabled).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
|
||||||
|
$("sourceFilter").value = sourceState.source;
|
||||||
|
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</small>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
|
||||||
|
} catch (e) { $("sourceError").textContent = e.message; }
|
||||||
|
}
|
||||||
|
function storeCard(app) {
|
||||||
|
const i=sourceState.apps.indexOf(app), o=app.offers[0];
|
||||||
|
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span>${o.vr ? "VR experience" : o.vr === false ? "Flat-screen app" : "Android app"}</span><b>Free</b></span></span></button>`;
|
||||||
|
}
|
||||||
|
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p></div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
|
||||||
|
function storeEmpty() {
|
||||||
|
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>A little further to explore.</h2><p>No apps found this time. Try a different name, clear your filters, or explore another source.</p><button class="action" id="storeReset">Explore all apps</button></div>`;
|
||||||
|
}
|
||||||
|
function renderStore() {
|
||||||
|
const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable && !sourceState.source;
|
||||||
|
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`;
|
||||||
|
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
|
||||||
|
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
|
||||||
|
const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0];
|
||||||
|
const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works'));
|
||||||
|
const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked'));
|
||||||
|
const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0));
|
||||||
|
const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||'')));
|
||||||
|
$("searchGrid").innerHTML=`<button class="store-hero" data-app="${apps.indexOf(featured)}" aria-label="Explore ${esc(featured.name)}">${storeArt(o,"",true)}<span class="hero-copy"><span class="store-eyebrow">Step into something new</span><strong>${esc(featured.name)}</strong><span>${esc(storeText(featured.summary))}</span><span class="hero-cta">Explore ${esc(featured.name)} <span aria-hidden="true"> ↗</span></span></span></button>` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent);
|
||||||
|
}
|
||||||
|
function storeChips() { $("searchFilters").querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",b.dataset.filter==='all' ? sourceState.vr===null && !sourceState.installable : b.dataset.filter==='installable' ? sourceState.installable : sourceState.vr===(b.dataset.filter==='vr'))); }
|
||||||
|
async function searchSources(quiet) {
|
||||||
|
const request=++sourceState.request;
|
||||||
|
$("searchGrid").setAttribute("aria-busy","true");
|
||||||
|
if(!quiet)$("searchGrid").innerHTML=`<div class="store-grid" role="status" aria-label="Finding apps">${Array.from({length:8},()=>'<div class="store-skeleton" aria-hidden="true"></div>').join("")}</div>`;
|
||||||
|
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable)});
|
||||||
|
if(sourceState.vr!==null)params.set("vr",String(sourceState.vr));
|
||||||
|
try {
|
||||||
|
const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
|
||||||
|
sourceState.apps=result.apps;
|
||||||
|
const unavailable=result.sources.filter(s=>s.status==='error'||s.status==='timed out').map(s=>storeName(s.name));
|
||||||
|
const loading=result.sources.filter(s=>s.status==='loading').map(s=>storeName(s.name));
|
||||||
|
const notes=[];
|
||||||
|
if(loading.length)notes.push(`Still fetching ${loading.join(" and ")}; more results will appear in a moment.`);
|
||||||
|
if(unavailable.length)notes.push(`${unavailable.join(" and ")} ${unavailable.length===1 ? "isn't" : "aren't"} responding right now. You can still explore the other sources.`);
|
||||||
|
result.sources.filter(s=>s.status==='limited').forEach(s=>notes.push(`${s.error}.`));
|
||||||
|
const stale=result.sources.filter(s=>s.stale).map(s=>storeName(s.name));
|
||||||
|
if(stale.length)notes.push(`Showing saved listings from ${stale.join(" and ")} while ${stale.length===1 ? "it refreshes" : "they refresh"}.`);
|
||||||
|
$("sourceStatus").innerHTML=notes.map(esc).join(" ")+(result.elsewhere||[]).map(x=>` <a href="${esc(x.url)}" target="_blank" rel="noopener">Browse ${esc(storeName(x.name))} ↗</a>`).join("");
|
||||||
|
$("sourceStatus").hidden=!notes.length && !(result.elsewhere||[]).length;
|
||||||
|
clearTimeout(sourceState.retry);
|
||||||
|
if(loading.length)sourceState.retry=setTimeout(()=>{ if(request===sourceState.request)searchSources(true); },6000);
|
||||||
|
renderStore();await loadSources();
|
||||||
|
} catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML=storeEmpty();$("sourceStatus").hidden=false;$("sourceStatus").textContent="We couldn't reach the app shelves. Try again in a moment."; } }
|
||||||
|
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
|
||||||
|
}
|
||||||
|
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
|
||||||
|
function detailBadges(o) { return `<span class="store-badge">${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}</span>${o.verdict?.tone==='works' ? '<span class="store-badge works">✓ Works on Frame</span>' : ''}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
|
||||||
|
function renderDetail() {
|
||||||
|
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
|
||||||
|
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
|
||||||
|
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Meet the creators ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
|
||||||
|
updateInstall();
|
||||||
|
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
|
||||||
|
}
|
||||||
|
function updateInstall() {
|
||||||
|
const o=currentOffer();if(!o || !$("detailInstall"))return;
|
||||||
|
const job=sourceState.jobs.get(`${o.source}:${o.id}`), blocked=o.verdict?.tone==='blocked', page=sourcePage(o.page), b=$("detailInstall");
|
||||||
|
b.disabled=!!(job && !job.done) || !!job?.addingData || (blocked && !job?.result) || (!o.downloadable && !page);
|
||||||
|
b.textContent=job?.result?.game_data ? (job.addingData ? 'Adding game data…' : 'Add game data') : job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on Frame') : 'Get from the creator ↗';
|
||||||
|
$("detailProgress").hidden=!job || job.done;
|
||||||
|
if(job?.percent!=null)$("detailProgress").value=job.percent;else $("detailProgress").removeAttribute("value");
|
||||||
|
$("detailInstallNote").textContent=job?.result?.game_data ? (job.dataError || 'Installed. It also needs its game data: open it once on the Frame, then choose Add game data.') : job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Keep exploring. We’ll take care of the installation.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.');
|
||||||
|
$("detailOffers").querySelectorAll('input').forEach(input=>input.disabled=!!job&&!job.done);
|
||||||
|
}
|
||||||
|
async function openStoreApp(index) {
|
||||||
|
const app=sourceState.apps[index];if(!app)return;
|
||||||
|
sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0};
|
||||||
|
renderDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
|
||||||
|
await loadOfferDetails();
|
||||||
|
}
|
||||||
|
async function loadOfferDetails() {
|
||||||
|
const d=sourceState.detail,o=currentOffer(), request=++sourceState.detailRequest;
|
||||||
|
try {
|
||||||
|
const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id}));
|
||||||
|
if(request!==sourceState.detailRequest || d!==sourceState.detail)return;
|
||||||
|
const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]};
|
||||||
|
d.app.offers[d.choice]={...o,...detail,artwork};renderDetail();
|
||||||
|
} catch(e) { if(d===sourceState.detail)$("detailInstallNote").textContent="Some app details couldn't be refreshed. You can still view this listing."; }
|
||||||
|
}
|
||||||
|
async function installStoreApp() {
|
||||||
|
const o=currentOffer(), key=`${o.source}:${o.id}`, existing=sourceState.jobs.get(key);
|
||||||
|
if(existing?.result?.game_data) {
|
||||||
|
let error="";existing.addingData=true;existing.dataError="";updateInstall();
|
||||||
|
const added=await runJob(`Add game data for ${o.name}`,`${key}:data`,()=>api('/api/sources',{action:'game-data',package:existing.result.package}),j=>{error=j.error||"";});
|
||||||
|
Object.assign(existing,{addingData:false,dataError:added?"":error||"We couldn't add the game data. Try again."});
|
||||||
|
if(added)existing.result={...existing.result,game_data:false};
|
||||||
|
updateInstall();return;
|
||||||
|
}
|
||||||
|
if(existing?.result) { await act(`Open ${o.name}`,()=>api('/api/android',{action:'launch',package:existing.result.package}),$("detailInstall"));return; }
|
||||||
|
if(!o.downloadable) { const url=sourcePage(o.page);if(url)window.open(url,'_blank','noopener,noreferrer');return; }
|
||||||
|
if(existing&&!existing.done)return;
|
||||||
|
const state={stage:'Downloading',percent:null,done:false};sourceState.jobs.set(key,state);updateInstall();
|
||||||
|
const result=await runJob(`Install ${o.name}`,key,()=>api('/api/sources/install',{source:o.source,id:o.id,version_code:o.version_code??null}),j=>{Object.assign(state,{stage:j.stage||'Downloading',percent:j.percent??null,error:j.error});updateInstall();});
|
||||||
|
Object.assign(state,{done:true,result,error:result?null:state.error||"We couldn't finish the installation. Try again."});updateInstall();
|
||||||
|
if(result)loadAndroid();
|
||||||
|
}
|
||||||
|
async function changeSource(body) {
|
||||||
|
$("sourceError").textContent="Saving your sources…";
|
||||||
|
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;sourceState.source='';await loadSources();await searchSources(); }
|
||||||
|
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
|
||||||
|
}
|
||||||
|
let storeSearchTimer;
|
||||||
|
$("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);};
|
||||||
|
$("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer);searchSources();};
|
||||||
|
$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();};
|
||||||
|
$("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();};
|
||||||
|
$("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}};
|
||||||
|
$("detailBody").onclick=e=>{if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
|
||||||
|
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderDetail();loadOfferDetails();}};
|
||||||
|
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
|
||||||
|
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
|
||||||
|
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
|
||||||
|
async function addSource(body) {
|
||||||
|
$("sourceError").textContent="Checking the repository. Large ones can take a minute…";
|
||||||
|
let error="";
|
||||||
|
const result=await runJob('Add repository',`repo:${body.url}`,()=>api('/api/sources',body).catch(e=>{error=e.message;throw e;}),j=>{error=j.error||"";});
|
||||||
|
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
|
||||||
|
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
|
||||||
|
}
|
||||||
|
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
|
||||||
|
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
|
||||||
|
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
|
||||||
|
document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();});
|
||||||
|
loadSources().then(()=>searchSources());
|
||||||
|
|
||||||
// ---- Android apps (own Lepton instance each) + catalogue ----
|
// ---- Android apps (own Lepton instance each) + catalogue ----
|
||||||
const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might work", unlikely: "Probably crashes", no: "Won't work" };
|
const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might work", unlikely: "Probably crashes", no: "Won't work" };
|
||||||
const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] };
|
const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] };
|
||||||
@@ -2666,6 +3056,7 @@ async function loadAndroid() {
|
|||||||
<div class="row and-btns">
|
<div class="row and-btns">
|
||||||
${a.running ? `<button class="small" data-and="stop" data-pkg="${esc(a.package)}">Stop</button>`
|
${a.running ? `<button class="small" data-and="stop" data-pkg="${esc(a.package)}">Stop</button>`
|
||||||
: `<button class="play small" data-and="launch" data-pkg="${esc(a.package)}">▶</button>`}
|
: `<button class="play small" data-and="launch" data-pkg="${esc(a.package)}">▶</button>`}
|
||||||
|
${a.art_missing ? `<button class="small" data-and="refresh-art" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
|
||||||
<button class="small" data-and="probe" data-pkg="${esc(a.package)}" title="Launch it and record whether it stays up">Test</button>
|
<button class="small" data-and="probe" data-pkg="${esc(a.package)}" title="Launch it and record whether it stays up">Test</button>
|
||||||
<button class="small" data-and="rate" data-pkg="${esc(a.package)}" data-ver="${esc(a.version)}" data-name="${esc(a.label)}" data-src="${esc(a.source || "")}" title="Report how it worked">Report</button>
|
<button class="small" data-and="rate" data-pkg="${esc(a.package)}" data-ver="${esc(a.version)}" data-name="${esc(a.label)}" data-src="${esc(a.source || "")}" title="Report how it worked">Report</button>
|
||||||
<button class="small danger" data-and="remove" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}">Remove</button>
|
<button class="small danger" data-and="remove" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}">Remove</button>
|
||||||
@@ -2749,6 +3140,8 @@ document.body.addEventListener("click", async e => {
|
|||||||
} else if (action === "remove") {
|
} else if (action === "remove") {
|
||||||
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
|
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
|
||||||
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||||||
|
} else if (action === "refresh-art") {
|
||||||
|
await runJob(`Add Steam artwork for ${b.dataset.name}`, "art-" + pkg, () => api("/api/android", { action, package: pkg }));
|
||||||
} else if (action === "rate") {
|
} else if (action === "rate") {
|
||||||
openReport({ package: pkg, version: b.dataset.ver, label: b.dataset.name, source: b.dataset.src });
|
openReport({ package: pkg, version: b.dataset.ver, label: b.dataset.name, source: b.dataset.src });
|
||||||
return;
|
return;
|
||||||
@@ -3638,5 +4031,6 @@ setView("headset");
|
|||||||
refresh().then(loadShots); // after status, so app names resolve
|
refresh().then(loadShots); // after status, so app names resolve
|
||||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/artwork-settings.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -42,8 +42,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|||||||
import frame_agent # noqa: E402
|
import frame_agent # noqa: E402
|
||||||
import frame_assistant # noqa: E402
|
import frame_assistant # noqa: E402
|
||||||
import frame_android # noqa: E402
|
import frame_android # noqa: E402
|
||||||
|
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||||
import frame_apk_versions # noqa: E402
|
import frame_apk_versions # noqa: E402
|
||||||
import frame_catalog # noqa: E402
|
import frame_catalog # noqa: E402
|
||||||
|
import frame_steamgriddb
|
||||||
import frame_comfort # noqa: E402
|
import frame_comfort # noqa: E402
|
||||||
import frame_host # noqa: E402
|
import frame_host # noqa: E402
|
||||||
import frame_macview # noqa: E402
|
import frame_macview # noqa: E402
|
||||||
@@ -54,6 +56,9 @@ import frame_store # noqa: E402
|
|||||||
import frame_telemetry # noqa: E402
|
import frame_telemetry # noqa: E402
|
||||||
import frame_titles # noqa: E402
|
import frame_titles # noqa: E402
|
||||||
import frame_webinstall # noqa: E402
|
import frame_webinstall # noqa: E402
|
||||||
|
import frame_vr # noqa: E402
|
||||||
|
import frame_utilities # noqa: E402
|
||||||
|
import frame_compat_db # noqa: E402
|
||||||
|
|
||||||
frame_host.trust_bundled_cas()
|
frame_host.trust_bundled_cas()
|
||||||
|
|
||||||
@@ -159,7 +164,41 @@ _jobs_lock = threading.Lock()
|
|||||||
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
|
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
|
||||||
|
|
||||||
|
|
||||||
def start_job(label, work):
|
_backfill = {"running": False, "last": 0.0}
|
||||||
|
_backfill_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_art(apps=(), titles=()):
|
||||||
|
"""Apply art Frame Control couldn't at install time (Steam wasn't running), once Steam is up.
|
||||||
|
|
||||||
|
Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or
|
||||||
|
names the user may have customised. Runs in the background, at most once every five minutes."""
|
||||||
|
pkgs = [a["package"] for a in apps if a.get("art_pending")]
|
||||||
|
gids = [t["id"] for t in titles if t.get("art_pending")]
|
||||||
|
with _backfill_lock:
|
||||||
|
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
|
||||||
|
return False
|
||||||
|
_backfill.update(running=True, last=time.time())
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
frame_android.shortcut_tool("list") # Steam isn't up: try again on a later listing
|
||||||
|
for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \
|
||||||
|
[(frame_titles.refresh_art, g) for g in gids]:
|
||||||
|
try:
|
||||||
|
refresh(key, fill_only=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"artwork backfill for {key}: {e}", file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
with _backfill_lock:
|
||||||
|
_backfill["running"] = False
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def start_job(label, work, progress=False):
|
||||||
"""Run work() in the background. It returns a dict with a "message"."""
|
"""Run work() in the background. It returns a dict with a "message"."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
@@ -168,14 +207,20 @@ def start_job(label, work):
|
|||||||
job = secrets.token_hex(8)
|
job = secrets.token_hex(8)
|
||||||
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
|
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
|
||||||
|
|
||||||
|
def report(stage, percent=None):
|
||||||
|
with _jobs_lock:
|
||||||
|
_jobs[job].update(stage=stage, percent=percent)
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
fields = {}
|
fields = {}
|
||||||
try:
|
try:
|
||||||
result = work()
|
result = work(report) if progress else work()
|
||||||
fields = {"message": result.get("message") or f"{label}: done", "result": result}
|
fields = {"message": result.get("message") or f"{label}: done", "result": result}
|
||||||
except (Failure, frame_android.FrameError) as e:
|
except (Failure, frame_android.FrameError) as e:
|
||||||
fields = {"error": unreachable(str(e)) or str(e)}
|
fields = {"error": unreachable(str(e)) or str(e)}
|
||||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||||
|
except SourceError as e: # already user-readable, and about a store, not the Frame
|
||||||
|
fields = {"error": str(e)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
fields = {"error": f"{type(e).__name__}: {e}"}
|
fields = {"error": f"{type(e).__name__}: {e}"}
|
||||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||||
@@ -505,6 +550,27 @@ def steam(body):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def vr(body):
|
||||||
|
try:
|
||||||
|
action = frame_vr.validate(body)
|
||||||
|
except ValueError as e:
|
||||||
|
raise Failure(str(e), 400)
|
||||||
|
sources = {name: (HERE / (name + '.py')).read_text() for name in ('frame_status', 'frame_vr')}
|
||||||
|
script = "import sys, types, json, os\n"
|
||||||
|
for name, source in sources.items():
|
||||||
|
script += f"m = types.ModuleType({name!r}); sys.modules[{name!r}] = m\nexec({source!r}, m.__dict__)\n"
|
||||||
|
# Only the optional HUD needs files: its terminal child survives this SSH call.
|
||||||
|
if action == 'hud-start':
|
||||||
|
script += "m.ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)\n"
|
||||||
|
for name, source in sources.items():
|
||||||
|
script += f"p = m.ROOT / {name + '.py'!r}; t = p.with_suffix('.' + str(os.getpid()) + '.tmp')\nt.write_text({source!r}); os.replace(t, p)\n"
|
||||||
|
script += f"try:\n print(json.dumps(m.dispatch({body!r})))\nexcept Exception as e:\n print(json.dumps({{'error': str(e)}}))\n"
|
||||||
|
result = json.loads(ssh('python3 -', stdin=script, timeout=20))
|
||||||
|
if result.get('error'):
|
||||||
|
raise Failure(result['error'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def steam_search(query):
|
def steam_search(query):
|
||||||
q = parse_qs(query)
|
q = parse_qs(query)
|
||||||
cc = (q.get("cc") or [""])[0].upper()
|
cc = (q.get("cc") or [""])[0].upper()
|
||||||
@@ -1092,6 +1158,14 @@ def android(body):
|
|||||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||||
"app": m}
|
"app": m}
|
||||||
return start_job(f"Install {pkg}", work)
|
return start_job(f"Install {pkg}", work)
|
||||||
|
if action == "refresh-art":
|
||||||
|
if not pkg and not body.get("all"):
|
||||||
|
raise Failure('choose a package or all apps', 400)
|
||||||
|
if not body.get('all'):
|
||||||
|
return start_job('Refresh Steam artwork', lambda: {'apps': [frame_android.refresh_art(pkg)]})
|
||||||
|
# Everything Frame Control sideloaded: Android apps and devkit titles.
|
||||||
|
return start_job('Refresh Steam artwork', lambda: {
|
||||||
|
'apps': frame_android.refresh_art(), 'titles': frame_titles.refresh_art()})
|
||||||
if action in ("launch", "stop"):
|
if action in ("launch", "stop"):
|
||||||
m = getattr(frame_android, action)(pkg)
|
m = getattr(frame_android, action)(pkg)
|
||||||
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
||||||
@@ -1236,12 +1310,14 @@ def titles(body):
|
|||||||
threading.Thread(target=_run_title_install, daemon=True,
|
threading.Thread(target=_run_title_install, daemon=True,
|
||||||
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
|
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
|
||||||
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
||||||
if action not in ("launch", "remove"):
|
if action not in ("launch", "remove", "refresh-art"):
|
||||||
raise Failure("unknown action", 400)
|
raise Failure("unknown action", 400)
|
||||||
gid = str(body.get("id", ""))
|
gid = str(body.get("id", ""))
|
||||||
if not frame_titles.ID_RE.match(gid):
|
if not frame_titles.ID_RE.match(gid):
|
||||||
raise Failure("bad title id", 400)
|
raise Failure("bad title id", 400)
|
||||||
ensure_master()
|
ensure_master()
|
||||||
|
if action == "refresh-art":
|
||||||
|
return start_job(f"Steam artwork for {gid}", lambda: {"titles": [frame_titles.refresh_art(gid)]})
|
||||||
try:
|
try:
|
||||||
m = getattr(frame_titles, action)(gid)
|
m = getattr(frame_titles, action)(gid)
|
||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
@@ -1926,9 +2002,84 @@ def agent_approval(body):
|
|||||||
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
|
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
|
||||||
|
|
||||||
|
|
||||||
POST = {"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
def source_text(body, key, optional=False):
|
||||||
"/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
value = body.get(key)
|
||||||
|
if optional and value in (None, ''):
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str) or not value.strip() or len(value) > 2000:
|
||||||
|
raise Failure('Provide a valid ' + key, 400)
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def source_search(query):
|
||||||
|
args = parse_qs(query)
|
||||||
|
q = args.get('q', [''])[0]
|
||||||
|
vr = args.get('vr', [''])[0]
|
||||||
|
installable = args.get('installable', [''])[0]
|
||||||
|
if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'):
|
||||||
|
raise Failure('Invalid search filters', 400)
|
||||||
|
try:
|
||||||
|
return apk_search.search(q, vr=None if not vr else vr in ('true', '1'),
|
||||||
|
source=args.get('source', [None])[0], installable=installable in ('true', '1'))
|
||||||
|
except SourceError as e:
|
||||||
|
raise Failure(str(e), 400)
|
||||||
|
|
||||||
|
|
||||||
|
def source_install(body):
|
||||||
|
source, entry = source_text(body, 'source'), source_text(body, 'id')
|
||||||
|
version = body.get('version_code')
|
||||||
|
if version is not None and (type(version) is not int or version < 0):
|
||||||
|
raise Failure('version_code must be a non-negative integer', 400)
|
||||||
|
try:
|
||||||
|
_, selected = apk_search.resolve(source)
|
||||||
|
if not selected['enabled']:
|
||||||
|
raise SourceError('This source is disabled')
|
||||||
|
except SourceError as e:
|
||||||
|
raise Failure(str(e), 400)
|
||||||
|
return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
|
||||||
|
|
||||||
|
|
||||||
|
def source_manage(body):
|
||||||
|
action = body.get('action')
|
||||||
|
try:
|
||||||
|
if action == 'enable':
|
||||||
|
if type(body.get('enabled')) is not bool:
|
||||||
|
raise Failure('enabled must be true or false', 400)
|
||||||
|
return apk_search.set_enabled(source_text(body, 'source'), body['enabled'])
|
||||||
|
if action == 'add':
|
||||||
|
url = source_text(body, 'url')
|
||||||
|
fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True)
|
||||||
|
parts = urlparse(url)
|
||||||
|
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username:
|
||||||
|
raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400)
|
||||||
|
apk_search.repo_module() # fail now if this build can't manage repositories
|
||||||
|
|
||||||
|
def add(): # downloads and verifies the whole index: a job, not a request
|
||||||
|
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name)
|
||||||
|
message = 'Added ' + source['name']
|
||||||
|
if source.get('trust_on_first_use'):
|
||||||
|
message += '. Trusted on first use: ' + source['fingerprint'].upper()
|
||||||
|
return {'message': message, 'source': {k: source.get(k) for k in
|
||||||
|
('id', 'name', 'fingerprint', 'trust_on_first_use')}}
|
||||||
|
return start_job('Add repository', add)
|
||||||
|
if action == 'game-data':
|
||||||
|
package = source_text(body, 'package')
|
||||||
|
return start_job('Add game data', lambda: apk_search.add_game_data(package))
|
||||||
|
if action == 'remove':
|
||||||
|
apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source'))
|
||||||
|
return {'message': 'Repository removed'}
|
||||||
|
raise Failure('Unknown source action', 400)
|
||||||
|
except SourceError as e:
|
||||||
|
raise Failure(str(e), 400)
|
||||||
|
|
||||||
|
|
||||||
|
POST = {
|
||||||
|
"/api/vr": vr,
|
||||||
|
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||||
|
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||||
|
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||||
|
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||||
"/api/webinstall/cancel": webinstall_cancel,
|
"/api/webinstall/cancel": webinstall_cancel,
|
||||||
@@ -2040,6 +2191,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
if path in ("/", "/index.html"):
|
if path in ("/", "/index.html"):
|
||||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||||
|
elif path == "/api/settings/artwork":
|
||||||
|
self.send_json(frame_steamgriddb.settings())
|
||||||
|
elif path == "/artwork-settings.js":
|
||||||
|
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
|
||||||
elif path == "/assistant":
|
elif path == "/assistant":
|
||||||
page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
|
page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
|
||||||
self.send_bytes(page.encode(), "text/html; charset=utf-8")
|
self.send_bytes(page.encode(), "text/html; charset=utf-8")
|
||||||
@@ -2050,14 +2205,35 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||||
|
elif path.startswith("/source-image/"):
|
||||||
|
from apk_sources import _images
|
||||||
|
try:
|
||||||
|
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
||||||
|
except Exception:
|
||||||
|
self.send_json({"error": "Artwork unavailable"}, 404)
|
||||||
|
elif path == "/api/sources/details":
|
||||||
|
args = parse_qs(url.query)
|
||||||
|
try:
|
||||||
|
self.send_json(apk_search.details(source_text({k: v[0] for k, v in args.items()}, "source"),
|
||||||
|
source_text({k: v[0] for k, v in args.items()}, "id")))
|
||||||
|
except SourceError as e:
|
||||||
|
raise Failure(str(e), 400)
|
||||||
|
elif path == "/api/sources":
|
||||||
|
self.send_json({"sources": apk_search.sources()})
|
||||||
|
elif path == "/api/search":
|
||||||
|
self.send_json(source_search(url.query))
|
||||||
elif path == "/api/apk-versions":
|
elif path == "/api/apk-versions":
|
||||||
self.send_json(apk_versions(url.query))
|
self.send_json(apk_versions(url.query))
|
||||||
elif path == "/api/android":
|
elif path == "/api/android":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
self.send_json({"apps": frame_android.list_apps()})
|
apps = frame_android.list_apps()
|
||||||
|
backfill_art(apps=apps)
|
||||||
|
self.send_json({"apps": apps})
|
||||||
elif path == "/api/titles":
|
elif path == "/api/titles":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
self.send_json({"titles": frame_titles.list_titles()})
|
titles_list = frame_titles.list_titles()
|
||||||
|
backfill_art(titles=titles_list)
|
||||||
|
self.send_json({"titles": titles_list})
|
||||||
elif path == "/api/titles/job":
|
elif path == "/api/titles/job":
|
||||||
self.send_json(title_job(url.query))
|
self.send_json(title_job(url.query))
|
||||||
elif path == "/api/licenses":
|
elif path == "/api/licenses":
|
||||||
@@ -2077,6 +2253,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"shared": frame_catalog.compat_db.shared()})
|
"shared": frame_catalog.compat_db.shared()})
|
||||||
elif path == "/api/android/catalog":
|
elif path == "/api/android/catalog":
|
||||||
self.send_json({"apps": frame_catalog.catalog()})
|
self.send_json({"apps": frame_catalog.catalog()})
|
||||||
|
elif path == "/api/vr/utilities":
|
||||||
|
self.send_json(frame_utilities.catalogue(steam_frame("utilities")["utilities"], frame_compat_db.load()))
|
||||||
elif path == "/api/macview":
|
elif path == "/api/macview":
|
||||||
self.send_json(macview_state(parse_qs(url.query)))
|
self.send_json(macview_state(parse_qs(url.query)))
|
||||||
elif path == "/api/telemetry":
|
elif path == "/api/telemetry":
|
||||||
@@ -2297,6 +2475,7 @@ def main():
|
|||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
httpd = LoopbackServer(("127.0.0.1", args.port), Handler)
|
httpd = LoopbackServer(("127.0.0.1", args.port), Handler)
|
||||||
sweep_tmp()
|
sweep_tmp()
|
||||||
|
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||||
frame_telemetry.start()
|
frame_telemetry.start()
|
||||||
if not frame_host.WINDOWS:
|
if not frame_host.WINDOWS:
|
||||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||||
|
|||||||