Merge pull request #44 from saphid/apk-store-fixes
Android game store: every source in one search, and proper Steam library entries
No files matched your search
@@ -6,3 +6,8 @@
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.bat text eol=crlf
|
||||
# Test fixtures are byte-exact (hashes, signatures): never convert line endings.
|
||||
tests/fixtures/** -text
|
||||
*.apk binary
|
||||
*.jar binary
|
||||
*.obb binary
|
||||
@@ -48,9 +48,18 @@
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.html",
|
||||
"*.js",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../ui/apk_sources",
|
||||
"to": "ui/apk_sources",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../scripts",
|
||||
"to": "scripts",
|
||||
@@ -63,7 +72,8 @@
|
||||
"to": "frame/android",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"*.py"
|
||||
"*.py",
|
||||
"*.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# APK repositories
|
||||
|
||||
Frame Control supports **F-Droid-format repositories**, including F-Droid,
|
||||
F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository
|
||||
indexes are authenticated before their apps appear. Search lists builds with
|
||||
Android API ≤30 and arm64-v8a or no native libraries, using the same streaming
|
||||
reducer as the existing catalogue. This does not guarantee an app works in Lepton.
|
||||
|
||||
## Formats considered
|
||||
|
||||
| Format | Users and purpose | Support in this source |
|
||||
|---|---|---|
|
||||
| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes |
|
||||
| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` |
|
||||
| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index |
|
||||
| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter |
|
||||
| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source |
|
||||
| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid |
|
||||
|
||||
Research references (checked 2026-09-28):
|
||||
|
||||
- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and
|
||||
[repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/).
|
||||
- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/)
|
||||
and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/).
|
||||
- [Droid-ify](https://github.com/Droid-ify/client) and
|
||||
[Neo Store](https://github.com/NeoApplications/Neo-Store).
|
||||
- [Obtainium](https://github.com/ImranR98/Obtainium), its
|
||||
[configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/),
|
||||
and [community app configurations](https://apps.obtainium.imranr.dev/).
|
||||
- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest).
|
||||
The absence of a specification in these materials is not proof that no
|
||||
historical or private custom-feed format exists.
|
||||
|
||||
## Add a repository in Frame Control
|
||||
|
||||
From the Frame Control checkout, use its source-management CLI:
|
||||
|
||||
```sh
|
||||
python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps'
|
||||
python3 ui/apk_sources/fdroid.py list
|
||||
python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music'
|
||||
python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app
|
||||
python3 ui/apk_sources/fdroid.py remove SOURCE_ID
|
||||
```
|
||||
|
||||
Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint`
|
||||
can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…`
|
||||
links are accepted and converted to HTTPS. Conflicting fingerprints are refused.
|
||||
A URL must identify the repository directory, not its website or an index file.
|
||||
|
||||
Adding fetches and validates the complete index **before saving** the source.
|
||||
Without a fingerprint, Frame Control verifies the JAR signature and remembers
|
||||
its signer: trust on first use (TOFU). This establishes continuity with the
|
||||
first server response, not independent publisher identity. Obtain the published
|
||||
fingerprint through a trusted channel when possible; the store's Add a source
|
||||
form shows the pinned one ("Trusted on first use: …") so you can compare it.
|
||||
Re-adding an existing URL preserves its pin; changing it requires deliberately
|
||||
removing and re-adding it.
|
||||
|
||||
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
|
||||
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
|
||||
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
|
||||
`sources`, `search`, `details`, and `download` from the shared source contract.
|
||||
This change supplies the CLI and API; the integrated source-management UI is
|
||||
separate work. Built-in sources can be disabled but cannot be removed.
|
||||
|
||||
Settings and pins live in `frame_host.data_dir('apk-repos.json')`
|
||||
(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS).
|
||||
Authenticated reduced indexes and APKs live under
|
||||
`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. An
|
||||
expired index is still served (marked stale in the store) while it refreshes in
|
||||
the background; a failed refresh is retried after 10 minutes.
|
||||
Only the running Frame Control app prunes cached APKs (at start and after store
|
||||
downloads); the command-line tools never do.
|
||||
The existing catalogue's unverified index cache is never treated as authenticated.
|
||||
|
||||
Rollback protection: each repository's newest accepted signed index timestamp
|
||||
is kept in `apk-repo-state.json` next to the settings, and an older index is
|
||||
refused. Once a repository has served a v2 `entry.jar`, a missing `entry.jar`
|
||||
is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
|
||||
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
|
||||
Removing a repository clears its state.
|
||||
|
||||
## Publish your own repository
|
||||
|
||||
Only publish free APKs you own or have the developer's permission to distribute.
|
||||
Do not publish paid app mirrors or bypass store licences. Check distribution
|
||||
terms before adding someone else's repository; this module does not infer legal
|
||||
permission from a signature or automatically audit a repository's terms.
|
||||
|
||||
Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/)
|
||||
and its documented Android/Java dependencies on the publishing machine, then:
|
||||
|
||||
```sh
|
||||
mkdir my-fdroid
|
||||
cd my-fdroid
|
||||
fdroid init
|
||||
# Set repo_url in config.yml to https://example.org/fdroid/repo
|
||||
# Also set repo_name and repo_description; keep the generated signing key safe.
|
||||
cp /path/to/your-free-app.apk repo/
|
||||
fdroid update --create-metadata
|
||||
# Review the generated metadata (name, summary, licence, source and website).
|
||||
fdroid update
|
||||
```
|
||||
|
||||
Serve the generated **repo directory** at that HTTPS URL, including APKs,
|
||||
icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the
|
||||
private signing keystore or configuration passwords. Configure fdroidserver's
|
||||
`serverwebroot` and run `fdroid deploy` for managed publication, or copy the
|
||||
public directory with your existing deployment tool. Publish the SHA-256
|
||||
repository certificate fingerprint displayed by fdroidserver in a link such as
|
||||
`https://example.org/fdroid/repo?fingerprint=…`.
|
||||
|
||||
Keep the repository signing key backed up: changing it breaks existing pins.
|
||||
For updates, add the new APK, edit metadata as needed, run `fdroid update` and
|
||||
publish again. Test the published URL with Frame Control's `add`, `search` and
|
||||
`download` commands. The above publisher setup is documented from fdroidserver;
|
||||
it was not executed as part of this implementation.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of
|
||||
2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are
|
||||
recognized. It checks the signer certificate pin, the signature over `.SF`,
|
||||
the whole-manifest digest, and the manifest's digest of the JSON member.
|
||||
ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are
|
||||
rejected. Certificates are pinned identities, not validated as Web PKI chains.
|
||||
HTTPS certificates are separately checked by Python's normal TLS validation.
|
||||
|
||||
v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature,
|
||||
fingerprint, index hash, TLS and server errors never trigger an unsigned
|
||||
fallback. APKs are cached by SHA-256 and checked again before reuse. Here,
|
||||
`verified: true` means the bytes match the signed repository's APK hash; it
|
||||
is not an independent APK publisher-signature or runtime compatibility verdict.
|
||||
There is no repository timestamp rollback/expiry policy or automated signing-key
|
||||
rotation yet. An old correctly signed index can still validate.
|
||||
|
||||
Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache
|
||||
reuse, URL rejection and corruption of every signature/hash layer. On the Mac,
|
||||
the real IzzyOnDroid repository was added with its published pin, searched for
|
||||
Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256
|
||||
`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`.
|
||||
No headset connection or installation was performed.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Developer-consented APK sources
|
||||
|
||||
Surveyed 2026-09-28. Free access is not proof of redistribution permission or
|
||||
Frame compatibility. These adapters fetch only public publisher releases or
|
||||
link to publisher pages. They do not acquire store entitlements, defeat access
|
||||
checks, install anything, or rehost APKs. See [VR compatibility](vr-apks.md).
|
||||
|
||||
| Source | Developer consent and automated-access position | API/feed; VR coverage | Decision |
|
||||
|---|---|---|---|
|
||||
| [itch.io](https://itch.io/docs/legal/terms) | Publishers warrant distribution rights (§4). Users may access content through the service; this is not blanket scraping permission. Main robots excludes `/game/download/`; author subdomains exclude `/*/download/`. No challenge bypass. | Public free Android RSS for `openxr` and `oculus-quest`; substantial indie VR. Server API is mostly authenticated publisher/account functionality, not a general anonymous store-download API. | Implement RSS search, artwork and page links; `downloadable: False`. The supplied free-download script follows keyed download pages excluded by robots, so it is not shipped. |
|
||||
| [GitHub releases](https://docs.github.com/en/rest/releases/releases) | Maintainers publish assets; curated repositories below establish provenance. Public hosting or an open-source topic alone does not establish rights to every uploaded binary. Use supported REST API under [API terms](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service#h-api-terms), not HTML crawling. | Releases API includes APK assets and sometimes SHA-256. Topic search finds OpenXR/Quest projects. 60 unauthenticated requests/hour; authenticated user limits are generally 5,000/hour, with separate search/secondary limits. | Implement curated downloads and explicit topic discovery. Unreviewed topic results are page-only. |
|
||||
| [Uptodown](https://www.uptodown.com/aboutus) | Developer distribution program exists, but that does not prove publisher authorization for every catalog item. [Privacy policy](https://www.uptodown.com/aboutus/privacy) explicitly describes protection against automated access. General automation permission was not established. | Broad Android catalog, limited VR focus; no supported public consumer-download API established in this survey. | Page links only; no downloader. Do not infer consent from an unchanged APK signature. |
|
||||
| [APKPure](https://apkpure.com/terms) | Third-party APK catalog; individual publisher consent and automation rights were not established. Terms request returned HTTP 403; no bypass attempted. | Broad Android coverage, incidental VR; internal endpoints are not permission to automate. | Exclude automatic indexing/downloading; user may open site. |
|
||||
| [APKMirror](https://www.apkmirror.com/faq/) | Publisher-signed files and a free-app policy are not a blanket developer-consent or automation grant. FAQ request returned HTTP 403, so current terms could not be confirmed. | General Android/version archive; APK bundles often need another installer; little VR focus. No supported consumer-download API established. | Page links only, no scraping or bundle conversion. |
|
||||
| [Aptoide](https://en.aptoide.com/company/legal) | Terms define an app supplier as developer, owner or authorized distributor; user stores still require per-item provenance. API availability alone does not settle third-party access rights. | API ecosystem and general Android catalog; weak VR focus. | Defer until a publisher-owned store and its API terms can be approved. No blanket community-store downloader. |
|
||||
| [Amazon Appstore](https://developer.amazon.com/docs/app-submission/understanding-submission.html) | Official developer submissions; store account, device and license rules apply. Publisher submission APIs do not authorize public binary extraction. | Fire-device distribution; Android-device Appstore support ended in 2025; little Quest relevance. | Official product links only; no account or entitlement extraction. |
|
||||
| [PICO / ByteDance store](https://developer.picoxr.com/document/distribute) | Official publisher channel with store/device entitlements. No public unauthenticated binary-download grant established; documentation request encountered a redirect error. | Strong standalone VR; PICO builds may depend on PICO services/extensions. | Store links only. A developer's independently published GitHub/itch build can qualify separately. |
|
||||
| [Meta Horizon Store / former App Lab](https://www.meta.com/experiences/) | Official developer submissions. A free store entitlement is still an entitlement; no license bypass or authenticated store extraction. App Lab was folded into the main store in 2024. | Strongest Quest coverage; no supported anonymous APK-download API established. | Store links only; independently distributed free builds use their publisher source. |
|
||||
| [Khronos samples](https://github.com/KhronosGroup/OpenXR-SDK-Source) | Official upstream, Apache-2.0 sample; developer-published release APKs. GitHub API terms apply. | `hello_xr` Vulkan/OpenGL ES APKs; excellent OpenXR diagnostics. | Included in GitHub curated list, Vulkan variant selected. |
|
||||
| [Meta OpenXR samples](https://github.com/meta-quest/Meta-OpenXR-SDK) | Official upstream; check each sample's license. Source availability does not imply a published APK, and some samples require Meta extensions/services. | Source/build examples, inconsistent ready-made APK releases. | Link to upstream; add specific free APKs only after release/provenance review. |
|
||||
| [Godot XR demos](https://github.com/GodotVR/godot-xr-tools) | Official project source and publisher demo pages; licenses and dependencies vary by demo. | OpenXR examples on GitHub/itch. Older Godot builds can fail on Lepton's missing clipboard service. | Covered by source discovery; no compatibility promise from an OpenXR tag. |
|
||||
|
||||
The table distinguishes observed restrictions from unknown permission. An
|
||||
unverified policy is a reason to defer automation, not a claim that a site is
|
||||
unlawful. Only the two implemented source kinds are registered by their own
|
||||
`sources()` functions; the other rows are recommendations, not new UI entries.
|
||||
|
||||
## Adapters
|
||||
|
||||
`ui/apk_sources/github.py` uses `github_curated.json`: Khronos `hello_xr`,
|
||||
[Open Brush](https://github.com/icosa-foundation/open-brush), and
|
||||
[SuperTux 3D](https://github.com/SgtBilko76/SuperTux-3D). These have official
|
||||
OpenXR project/release evidence, not a blanket claim of headset compatibility.
|
||||
Open Brush's compatibility evidence is recorded in [vr-apks.md](vr-apks.md).
|
||||
Open Brush and SuperTux publish the selected builds as prereleases; curated
|
||||
opt-ins preserve that label in version records. Exact APK filename patterns
|
||||
avoid downloading desktop archives or alternate non-Quest builds.
|
||||
[OpenSaberPlus](https://github.com/arpruss/OpenSaberPlus) was examined but not
|
||||
curated: GitHub reports its license as `NOASSERTION`, and current OpenXR APK
|
||||
provenance was not established in this pass.
|
||||
|
||||
Default GitHub search is offline against this small list. Queries
|
||||
`topic:openxr`, `topic:oculus-quest`, and `topic:quest` explicitly call repository
|
||||
search. Results outside the curated list stay page-only, even if a repository
|
||||
claims an open-source license. This prevents an arbitrary tagged mirror from
|
||||
becoming a trusted downloader. Extend the curated JSON after provenance review.
|
||||
|
||||
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
|
||||
quota. Tokens are sent only to `api.github.com`, never written to the cache,
|
||||
never sent to asset hosts, and removed on redirects. The adapter does not
|
||||
read `gh` credentials automatically. Metadata is cached for one hour under
|
||||
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
|
||||
fetches at most ten releases. Rate-limit errors are surfaced without retry
|
||||
loops. Asset IDs and release tags are not Android version codes: metadata
|
||||
leaves the latter unknown and rejects a requested `version_code` rather than
|
||||
silently fetching a different build.
|
||||
|
||||
`itch.py` exposes separate OpenXR and Quest feed sources, so one feed's failure
|
||||
does not suppress the other at the aggregator level. Queries filter the current
|
||||
feed window locally: this is not an exhaustive historical itch search. Only
|
||||
explicit zero-price Android entries are returned. Covers are exposed in
|
||||
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
|
||||
Curated GitHub entries include publisher artwork and plain-language summaries.
|
||||
Repository image URLs are pinned to inspected commits. Open Brush screenshots
|
||||
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
|
||||
preview embedded in the port's README (not a headset capture). The hello_xr
|
||||
sample has a launcher icon and GitHub social banner; no published screenshot
|
||||
was found in the inspected repository/README, so its screenshot list is empty.
|
||||
Uncurated topic results use the owner's avatar and GitHub's repository social
|
||||
preview. These are repository placeholders, not app screenshots. Itch's recorded
|
||||
RSS includes only covers, so screenshot lists remain empty without page scraping. VR is
|
||||
based on curated evidence or a VR-specific feed/topic, not a compatibility claim.
|
||||
|
||||
Downloads stream to unique temporary files, require an APK manifest entry,
|
||||
restrict HTTPS origins and redirects, and enforce a 2 GiB ceiling. `verified`
|
||||
means the downloaded SHA-256 matches GitHub's published digest. Without such a
|
||||
digest, the computed SHA-256 is returned with `verified: False`; neither value
|
||||
claims publisher-signature validation. Installation must inspect the APK as
|
||||
usual. OBBs, split APKs, paid assets and external release-body download links
|
||||
are unsupported.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
On this Mac, Python 3.9 downloaded the real Khronos Vulkan 1.1.63 APK through
|
||||
the GitHub adapter, matched its published SHA-256
|
||||
`f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34`, and
|
||||
`python3 ui/frame_android.py info <apk>` exited 0: package
|
||||
`org.khronos.openxr.hello_xr.vulkan`, version code 1063, minimum API 24,
|
||||
arm64-v8a present, OpenXR detected. No Frame connection or installation occurred.
|
||||
|
||||
The itch OpenXR RSS was fetched successfully and recorded as a fixture.
|
||||
Subsequent live adapter search encountered HTTP 429; it is not claimed as a
|
||||
successful live end-to-end search. Fixture search finds Off Nominal and parses
|
||||
nine Android entries from the ten-item feed (one has only an HTML platform).
|
||||
A real itch download and APK inspection were deliberately not performed:
|
||||
robots restrictions take precedence over that requested proof. No current
|
||||
policy text is claimed verified where the table records failed access.
|
||||
|
||||
Tests use recorded, reduced API/RSS fixtures with network access blocked in
|
||||
the new test class. They cover selection, prereleases, unknown topic results,
|
||||
paid/non-Android exclusion, URL restrictions, redirect credential removal,
|
||||
caching, rate limits, checksum mismatch, non-APK rejection and partial-file
|
||||
cleanup. See `.claude/NOTES-more-sources.md` for commands and local evidence.
|
||||
@@ -331,3 +331,11 @@ because gamescope scales Lepton's surface to fit the same panel. Also unverified
|
||||
whether the settings survive the app or its Lepton instance relaunching.
|
||||
Lepton Development rebuilds its Android data on exit, so there they probably
|
||||
don't.
|
||||
|
||||
## Expansion files and save backups
|
||||
|
||||
SideQuest-inspired CLI helpers install local OBB files into an already-running
|
||||
app instance and back up/restore a stopped instance's private app data. See
|
||||
[SideQuest features and limits](sidequest.md) for commands, archive scope and
|
||||
verification status. These paths have offline coverage; real Frame storage and
|
||||
permissions remain unverified. They do not change APK install or launch behavior.
|
||||
@@ -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.
|
||||
@@ -84,6 +84,132 @@ not being worn, so it did not reach `FOCUSED`).
|
||||
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
|
||||
Khronos-style loader and found SteamVR through `/vendor`.
|
||||
|
||||
## In the Steam library
|
||||
|
||||
Every successful APK install goes through the same mandatory artwork writer:
|
||||
CLI (including `scripts/install-apk.sh`), upload, catalogue, version finder,
|
||||
web download and source modules calling `frame_android.install`. Native
|
||||
Linux/Windows sideloads also use it, preserving their devkit runtime wiring.
|
||||
A new shortcut is rolled back if artwork fails; failure is never reported as
|
||||
an installed app with a blank tile.
|
||||
|
||||
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
||||
Set the optional free key in Frame Control's **Library artwork settings**, or
|
||||
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
||||
settings override the saved key. Without a key there are no provider calls or
|
||||
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
||||
returned by the settings API or copied to the headset. Exact title matches
|
||||
(including a trailing “VR” variant) use the highest-scored returned static,
|
||||
non-NSFW image in each slot. Provider failures use the next source.
|
||||
|
||||
Sources pass `install(apk_path, artwork={...})`: keys are `grid`, `wide`,
|
||||
`hero`, `logo`, `icon`, `banner`, `feature_graphic`, `screenshot`, or a list
|
||||
`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB and
|
||||
4096×4096 pixels maximum; any PNG depth or interlace, since the Frame's
|
||||
Chromium decodes them). URLs must resolve to public addresses, follow at most
|
||||
three redirects and share one deadline per install. Any source that fails,
|
||||
for any reason, becomes a warning and generated art. Banners and feature graphics supply hero/wide art;
|
||||
screenshots are the next fallback. Source images are cached for refresh.
|
||||
All images are fitted to 600×900 portrait, 920×430 wide, 3840×1240 hero,
|
||||
1280×480 logo and 256×256 icon. Explicit logos retain transparency.
|
||||
Photo-based portrait, wide and hero slots are JPEG: Steam takes at most
|
||||
12 MiB per slot, and on the Frame (2026-09-28) a noise-heavy 3840×1240 hero
|
||||
came to more than 12 MiB as PNG, 3.7 MB as JPEG (2.7 s to render); a
|
||||
landscape photo hero 5.6 MB as PNG, 0.76 MB as JPEG (0.75 s). A render that
|
||||
still fails is retried once with generated art. Steam keeps a slot's `.png`
|
||||
and `.jpg` side by side, so each slot is cleared before it is set.
|
||||
|
||||
Generated art uses the APK icon, a dominant-colour gradient, a blurred
|
||||
backdrop and large foreground icon with shadow. Steam's Chromium canvas and
|
||||
Motiva Sans render real text consistently regardless of the host OS; no
|
||||
Pillow, host font installation or bitmap font is needed. The hero has no
|
||||
title; the generated logo is a transparent title. APKs with no usable icon
|
||||
get a typographic monogram. The desktop package includes the renderer.
|
||||
|
||||
Backfill installed Android apps without reinstalling or stopping them:
|
||||
|
||||
```sh
|
||||
python3 ui/frame_android.py refresh-art org.godotengine.open_saber_plus
|
||||
python3 ui/frame_android.py refresh-art --all
|
||||
```
|
||||
|
||||
Devkit titles installed by Frame Control have the same command,
|
||||
`python3 ui/frame_titles.py refresh-art ID|--all`. The settings panel's
|
||||
refresh covers both. The API is `POST /api/android` with
|
||||
`{"action":"refresh-art","all":true}` (apps and titles) or a `package`, and
|
||||
`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a
|
||||
background job. Batch results retain per-item errors, and the CLIs exit
|
||||
nonzero if any failed. Apps and titles without complete artwork show **Add
|
||||
artwork** and `list` prints the command. Only entries marked `art_pending` at
|
||||
install (a title Steam registered after an install made while it wasn't
|
||||
running) are backfilled automatically, when Frame Control lists them with
|
||||
Steam running (at most every five minutes), and that backfill only fills
|
||||
slots Steam has no art for: names, icons, flags and any art the user set are
|
||||
kept. Older installs without the flag are refreshed only on request.
|
||||
|
||||
Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build
|
||||
20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by
|
||||
its saved id, or by an executable or start folder inside
|
||||
`~/devkit-game/<id>/`, read from `appDetailsStore`; never by display name.
|
||||
That the devkit shortcut's exe/start folder sit inside the title folder is
|
||||
inferred from `docs/sideloading.md` (`proton waitforexitandrun
|
||||
"/home/steamos/devkit-game/<id>/<exe>"`), not yet seen in app details.
|
||||
Devkit titles keep the VR flag Steam gave them.
|
||||
|
||||
**Verified on build 20260925.6191901, SteamVR 2.18.1 (2026-09-28):** both
|
||||
Open Saber Plus and SuperTux were backfilled. Steam's cached portrait, wide,
|
||||
hero and logo PNGs have the dimensions above; each shortcut points at its
|
||||
256×256 icon. This Frame client mishandles custom-art type 4 (documented as
|
||||
Icon), overwriting the wide capsule; the implementation uses custom types
|
||||
0–3 and **SetShortcutIcon** separately.
|
||||
|
||||
Steam accepts display name, executable/start directory, icon, VR flag and
|
||||
sort-as name. Android apps join **Android**, immersive apps also **Android
|
||||
VR**; native sideloads join **Sideloaded**. Existing collection members and
|
||||
unrelated collections are preserved (both games retained **Played**).
|
||||
Dynamic/read-only collection conflicts produce warnings. The native notes
|
||||
API supports a managed **Installation details** note (package, version and
|
||||
source) while preserving other notes. Notes are keyed by sanitized shortcut
|
||||
name, so Steam itself cannot distinguish equal-name shortcut notes. No
|
||||
supported shortcut description/store-page, developer/publisher, release
|
||||
metadata or custom achievement API was found; these are not fabricated.
|
||||
|
||||
The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by
|
||||
stopping its own container and child process group. A lock refuses duplicate launches;
|
||||
a container still running while the lock is free was orphaned by a killed
|
||||
launcher and is stopped before the new launch. Lepton doesn't inherit the
|
||||
lock. Orphan recovery only stops the app's own, deterministically named
|
||||
container; a Lepton host process whose launcher was killed before it created
|
||||
the container may linger briefly. Removing an app or title still deletes its files when Steam isn't
|
||||
running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact
|
||||
64-bit game ID string. Frame Control's Stop additionally has a direct-container
|
||||
fallback. The stable instance ID and compatdata paths remain unchanged.
|
||||
|
||||
Lepton normally forwards the instance `SteamAppId` to Android, causing
|
||||
SteamVR to associate the scene with a different, artwork-less app. The
|
||||
launcher uses Lepton's supported `LEPTON_ENV_SteamAppId` passthrough to send
|
||||
the actual shortcut ID to Android while retaining the stable container ID.
|
||||
**Verified:** Open Saber was alive 22 seconds after Steam Play, SteamVR
|
||||
identified `steam.app.3346865537`, and its scene appeared in the headset
|
||||
capture without the previous blank Resume tile. Steam Stop then removed its
|
||||
tracked process and stopped the container. An earlier 32-second session was
|
||||
also tracked until Steam Stop. No global standby or dashboard overrides were
|
||||
installed; wear detection and other user-opened overlays still apply.
|
||||
|
||||
**SuperTux limitation:** Steam launched and tracked it, but SDL crashed during
|
||||
activity creation because Lepton lacks `ClipboardManager`. Its container
|
||||
cleaned up on exit after about 17 seconds. Consequently sustained SuperTux
|
||||
Play/Stop and its VR scene could not be verified. This is an APK/runtime
|
||||
compatibility failure, separate from library presentation.
|
||||
|
||||
Evidence is under `/tmp/vrlib-evidence/` on the development Mac: final artwork
|
||||
preview and three design passes, `steam-cache-final.log`,
|
||||
`steam-details-targets.json`, `opensaber-identity-session.log`,
|
||||
`opensaber-identity-headset.png`, and `supertux-lepton.log`. The preview is
|
||||
rendered artwork, not a Steam UI screenshot; CDP screenshot capture timed
|
||||
out. Authenticated SteamGridDB, Windows/Linux packaged builds and the sibling
|
||||
source-search endpoint remain unverified (the public install seam is tested).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Private-data archives, run under podman unshare on the Frame. Stdlib only."""
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
MAX_BYTES = 20 * 1024 ** 3
|
||||
MAX_FILES = 100000
|
||||
MAX_MANIFEST = 1024 * 1024
|
||||
|
||||
|
||||
def inspect_archive(path, package, instance):
|
||||
names, total, manifest = set(), 0, None
|
||||
with tarfile.open(path, 'r:gz') as archive:
|
||||
for member in archive:
|
||||
name = member.name
|
||||
parts = PurePosixPath(name).parts
|
||||
if (not parts or name.startswith('/') or '..' in parts or
|
||||
name != '/'.join(parts) or name in names or '\\' in name):
|
||||
raise ValueError('unsafe or duplicate archive path')
|
||||
if name == 'data' and not member.isdir():
|
||||
raise ValueError('data root must be a directory')
|
||||
names.add(name)
|
||||
if len(names) > MAX_FILES or not (member.isdir() or member.isfile()):
|
||||
raise ValueError('archive has too many files, links or special files')
|
||||
if member.uid < 0 or member.gid < 0 or member.uid > 65535 or member.gid > 65535:
|
||||
raise ValueError('archive owner outside Android user namespace')
|
||||
total += member.size
|
||||
if total > MAX_BYTES:
|
||||
raise ValueError('archive exceeds 20 GiB')
|
||||
if name == 'manifest.json' and member.isfile() and member.size <= MAX_MANIFEST:
|
||||
manifest = json.load(archive.extractfile(member))
|
||||
elif parts[0] != 'data':
|
||||
raise ValueError('unexpected archive member')
|
||||
if (not isinstance(manifest, dict) or manifest.get('format') != 1 or
|
||||
manifest.get('package') != package or manifest.get('instance') != instance or
|
||||
'data' not in names):
|
||||
raise ValueError('backup does not match this package and instance')
|
||||
return {'files': len(names) - 1, 'bytes': total, 'package': package, 'instance': instance,
|
||||
'skipped_links': manifest.get('skipped_link_count', 0)}
|
||||
|
||||
|
||||
def backup(root, package, instance, output):
|
||||
import io
|
||||
source = root / package
|
||||
if source.is_symlink() or not source.is_dir():
|
||||
raise ValueError('private app data does not exist or is a symlink')
|
||||
count, total, links, skipped = 0, 0, [], 0
|
||||
|
||||
def checked(member):
|
||||
nonlocal count, total, skipped
|
||||
if member.issym(): # never followed or restored; listed in the manifest instead
|
||||
skipped += 1
|
||||
if len(links) < 1000:
|
||||
links.append({'path': member.name[:512], 'target': member.linkname[:256]})
|
||||
return None
|
||||
if member.islnk(): # a second name for a file already archived: store its content again
|
||||
member.type, member.linkname = tarfile.REGTYPE, ''
|
||||
member.size = os.lstat(str(source / member.name[len('data/'):])).st_size
|
||||
count += 1
|
||||
total += member.size
|
||||
if not (member.isdir() or member.isfile()) or count > MAX_FILES or total > MAX_BYTES:
|
||||
raise ValueError('private data contains special files or exceeds backup limits')
|
||||
return member
|
||||
|
||||
with tarfile.open(fileobj=output, mode='w|gz', dereference=False) as archive:
|
||||
archive.add(str(source), arcname='data', filter=checked)
|
||||
# Written last so that it can list what was skipped.
|
||||
manifest = json.dumps({'format': 1, 'package': package, 'instance': instance,
|
||||
'skipped_links': links, 'skipped_link_count': skipped}).encode()
|
||||
member = tarfile.TarInfo('manifest.json')
|
||||
member.size, member.mode = len(manifest), 0o600
|
||||
archive.addfile(member, io.BytesIO(manifest))
|
||||
|
||||
|
||||
def restore(root, package, instance, input_stream):
|
||||
source = root / package
|
||||
if source.is_symlink() or not source.is_dir():
|
||||
raise ValueError('private app data does not exist or is a symlink')
|
||||
with tempfile.TemporaryDirectory(prefix='.frame-restore-', dir=str(root)) as work:
|
||||
work = Path(work)
|
||||
archive_path = work / 'backup.tar.gz'
|
||||
with archive_path.open('wb') as output:
|
||||
size = 0
|
||||
while True:
|
||||
chunk = input_stream.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > MAX_BYTES:
|
||||
raise ValueError('compressed backup exceeds 20 GiB')
|
||||
output.write(chunk)
|
||||
result = inspect_archive(archive_path, package, instance)
|
||||
stage = work / 'stage'
|
||||
stage.mkdir(mode=0o700)
|
||||
with tarfile.open(archive_path, 'r:gz') as archive:
|
||||
directories = []
|
||||
for member in archive:
|
||||
if member.name == 'manifest.json':
|
||||
continue
|
||||
target = stage / member.name
|
||||
if member.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
directories.append((target, member))
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.extractfile(member) as src, target.open('xb') as dst:
|
||||
shutil.copyfileobj(src, dst, 1024 * 1024)
|
||||
apply_metadata(target, member)
|
||||
for target, member in reversed(directories):
|
||||
apply_metadata(target, member)
|
||||
with package_lock(root, package): # another restore of this package must not delete our copy
|
||||
previous = root / ('.' + package + '.before-restore-' + str(time.time_ns()))
|
||||
source.rename(previous)
|
||||
try:
|
||||
(stage / 'data').rename(source)
|
||||
except BaseException:
|
||||
previous.rename(source)
|
||||
raise
|
||||
# Keep only the newest pre-restore copy of this package's data.
|
||||
for old in root.glob('.' + package + '.before-restore-*'):
|
||||
if old != previous and not old.is_symlink():
|
||||
shutil.rmtree(str(old), ignore_errors=True)
|
||||
result['previous'] = str(previous)
|
||||
return result
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def package_lock(root, package):
|
||||
import fcntl
|
||||
fd = os.open(str(root / ('.' + package + '.restore.lock')), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
os.close(fd) # releases the lock
|
||||
|
||||
|
||||
def apply_metadata(path, member):
|
||||
os.chown(str(path), member.uid, member.gid)
|
||||
os.chmod(str(path), member.mode & 0o777)
|
||||
os.utime(str(path), (member.mtime, member.mtime))
|
||||
|
||||
|
||||
def main():
|
||||
action, package, instance = sys.argv[1:]
|
||||
instance = int(instance)
|
||||
root = Path.home() / '.local/share/Steam/steamapps/compatdata' / str(instance) / 'internal'
|
||||
if root.is_symlink() or root.resolve() != root.absolute():
|
||||
raise ValueError('private-data directory traverses a symlink')
|
||||
if action == 'backup':
|
||||
backup(root, package, instance, sys.stdout.buffer)
|
||||
elif action == 'restore':
|
||||
print(json.dumps(restore(root, package, instance, sys.stdin.buffer)))
|
||||
else:
|
||||
raise ValueError('unknown app-data action')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, tarfile.TarError) as error:
|
||||
sys.exit(str(error))
|
||||
@@ -19,6 +19,25 @@ done
|
||||
|
||||
# A number that isn't a real Steam app; it names this app's Lepton context.
|
||||
export SteamAppId="$(cat "$DIR/instance.id")"
|
||||
[[ "$SteamAppId" =~ ^[0-9]+$ ]] || { echo "invalid instance.id" >&2; exit 1; }
|
||||
# Keep the stable Lepton context, but identify the Android VR client as its
|
||||
# actual Steam shortcut. Lepton applies LEPTON_ENV_* after its own passthrough.
|
||||
if [[ -f "$DIR/shortcut.id" ]]; then
|
||||
shortcut="$(cat "$DIR/shortcut.id")"
|
||||
[[ "$shortcut" =~ ^[0-9]+$ ]] || { echo "invalid shortcut.id" >&2; exit 1; }
|
||||
export LEPTON_ENV_SteamAppId="$shortcut"
|
||||
fi
|
||||
exec 9>"$DIR/launch.lock"
|
||||
flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
|
||||
CONTAINER="lepton-steamlaunch-$SteamAppId"
|
||||
# Holding the lock means no launcher owns a running container: it was orphaned
|
||||
# (this script SIGKILLed), so stop it rather than refuse every later Play. The
|
||||
# name is this app's alone. A Lepton host process whose launcher was killed
|
||||
# before it made the container may linger briefly; nothing else is killed.
|
||||
if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then
|
||||
echo "Stopping orphaned $CONTAINER" >&2
|
||||
podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true
|
||||
fi
|
||||
export STEAM_COMPAT_INSTALL_PATH="$DIR"
|
||||
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
|
||||
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
|
||||
@@ -29,4 +48,29 @@ mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
|
||||
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
|
||||
# and SSH don't have; give it its own session instead.
|
||||
export IS_PARENT=true
|
||||
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk"
|
||||
# Keep this shell in Steam's process tree; setsid alone has no container cleanup.
|
||||
child=""
|
||||
cleanup() {
|
||||
trap '' TERM INT HUP
|
||||
if [[ -n "$child" ]]; then
|
||||
kill -TERM -- "-$child" 2>/dev/null || true
|
||||
kill -TERM "$child" 2>/dev/null || true
|
||||
fi
|
||||
podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true
|
||||
if [[ -n "$child" ]]; then
|
||||
kill -KILL -- "-$child" 2>/dev/null || true
|
||||
kill -KILL "$child" 2>/dev/null || true
|
||||
wait "$child" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 129' HUP
|
||||
# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held.
|
||||
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- &
|
||||
child=$!
|
||||
rc=0
|
||||
wait "$child" || rc=$?
|
||||
child=""
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,146 @@
|
||||
// Runs in Steam's Chromium context: identical fonts/rendering from every host OS.
|
||||
async function renderLibraryArtwork(input) {
|
||||
const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]};
|
||||
const label = String(input.label || 'Untitled').trim().slice(0,180);
|
||||
const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif';
|
||||
await document.fonts.load(`800 120px ${font}`, label);
|
||||
const images = {}, warnings = [];
|
||||
for (const [slot, item] of Object.entries(input.images || {})) {
|
||||
try {
|
||||
const img = new Image();
|
||||
img.src = `data:image/${item[0]};base64,${item[1]}`;
|
||||
await img.decode();
|
||||
if (!img.width || !img.height || img.width*img.height > 16777216) throw Error('dimensions');
|
||||
images[slot] = img;
|
||||
} catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); }
|
||||
}
|
||||
let icon = images.icon;
|
||||
if (icon) {
|
||||
// Remove only a near-black matte connected to the outside of an opaque icon.
|
||||
const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height;
|
||||
const c=cut.getContext('2d');c.drawImage(icon,0,0);
|
||||
const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height;
|
||||
const corners=[0,w-1,(h-1)*w,h*w-1];
|
||||
if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) {
|
||||
const seen=new Uint8Array(w*h), queue=corners.slice();
|
||||
for(let q=0;q<queue.length;q++) {
|
||||
const i=queue[q];if(seen[i])continue;seen[i]=1;
|
||||
if(Math.max(d[i*4],d[i*4+1],d[i*4+2])>24)continue;
|
||||
d[i*4+3]=0;
|
||||
if(i%w)queue.push(i-1);if(i%w<w-1)queue.push(i+1);
|
||||
if(i>=w)queue.push(i-w);if(i<w*(h-1))queue.push(i+w);
|
||||
}
|
||||
c.putImageData(pixels,0,0);icon=cut;
|
||||
}
|
||||
}
|
||||
// Quantized, saturated dominant colors avoid white/black icon margins.
|
||||
let colors = [[48,91,137], [24,36,63]];
|
||||
if (icon) {
|
||||
const sample = document.createElement('canvas'); sample.width=48; sample.height=48;
|
||||
const s=sample.getContext('2d'); s.drawImage(icon,0,0,48,48);
|
||||
const data=s.getImageData(0,0,48,48).data, bins=new Map();
|
||||
for (let i=0;i<data.length;i+=4) {
|
||||
const rgb=[data[i],data[i+1],data[i+2]], hi=Math.max(...rgb), lo=Math.min(...rgb);
|
||||
if (data[i+3]<150 || hi<45 || lo>220 || hi-lo<25) continue;
|
||||
const key=rgb.map(v=>Math.round(v/32)*32).join(',');
|
||||
bins.set(key,(bins.get(key)||0)+1);
|
||||
}
|
||||
const ranked=[...bins].sort((a,b)=>b[1]-a[1]);
|
||||
if (ranked.length) {
|
||||
colors[0]=ranked[0][0].split(',').map(Number);
|
||||
colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number);
|
||||
}
|
||||
}
|
||||
// Preserve hue while lifting muted icon colors into a richer background palette.
|
||||
colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1;
|
||||
return c.map(v=>45+(v-low)/range*165);});
|
||||
const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`;
|
||||
function image(ctx,img,x,y,w,h,cover=false) {
|
||||
const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height);
|
||||
const dw=img.width*scale,dh=img.height*scale;
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip();
|
||||
ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore();
|
||||
}
|
||||
function title(ctx,w,h,top,bottom,maxSize) {
|
||||
let lines=[],size=maxSize;
|
||||
const maxWidth=w*.84;
|
||||
for (;size>=18;size-=2) {
|
||||
ctx.font=`800 ${size}px ${font}`;
|
||||
lines=[]; let line='';
|
||||
for (const word of label.split(/\s+/)) {
|
||||
const next=line?line+' '+word:word;
|
||||
if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next;
|
||||
}
|
||||
lines.push(line);
|
||||
if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break;
|
||||
}
|
||||
if(lines.length===2) {
|
||||
const words=lines[0].split(' ');
|
||||
if(words.length>1) {
|
||||
const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1];
|
||||
if(ctx.measureText(second).width<=maxWidth &&
|
||||
Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)<
|
||||
Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second];
|
||||
}
|
||||
}
|
||||
// A long unbroken label is still fitted, including scripts without spaces.
|
||||
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff';
|
||||
ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06;
|
||||
let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54;
|
||||
for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;}
|
||||
ctx.shadowBlur=0; ctx.shadowOffsetY=0;
|
||||
}
|
||||
const result={};
|
||||
for (const [slot,[w,h]] of Object.entries(sizes)) {
|
||||
const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h;
|
||||
const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high';
|
||||
const direct=images[slot];
|
||||
const feature=images.feature_graphic||images.banner;
|
||||
const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot));
|
||||
if (scene) {
|
||||
if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h);
|
||||
else image(ctx,scene,0,0,w,h,true);
|
||||
} else if (slot==='logo') {
|
||||
title(ctx,w,h,h*.08,h*.92,150);
|
||||
} else {
|
||||
const gradient=ctx.createLinearGradient(0,0,w,h);
|
||||
gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68)));
|
||||
gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32)));
|
||||
gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h);
|
||||
if (icon) {
|
||||
ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`;
|
||||
image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore();
|
||||
}
|
||||
const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8);
|
||||
glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0));
|
||||
ctx.fillStyle=glow;ctx.fillRect(0,0,w,h);
|
||||
const vignette=ctx.createLinearGradient(0,h*.25,0,h);
|
||||
vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)');
|
||||
ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h);
|
||||
const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]:
|
||||
slot==='wide'?[w*.36,h*.06,w*.28,h*.59]:
|
||||
slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84];
|
||||
if (icon) {
|
||||
ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055;
|
||||
ctx.shadowOffsetY=Math.min(w,h)*.022;
|
||||
// Opaque square icons read as deliberate app tiles, not pasted rectangles.
|
||||
const [x,y,bw,bh]=box, side=Math.min(bw,bh);
|
||||
if(slot!=='hero' && icon===images.icon) {
|
||||
ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip();
|
||||
}
|
||||
image(ctx,icon,...box);ctx.restore();
|
||||
} else if (slot !== 'hero') {
|
||||
// A typographic monogram when the APK contains no usable image.
|
||||
ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)';
|
||||
ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38);
|
||||
}
|
||||
if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66);
|
||||
if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52);
|
||||
// Hero intentionally has no title: Steam overlays the transparent logo.
|
||||
}
|
||||
// Photos as PNG can pass Steam's 12 MiB limit at hero size; the logo keeps its transparency.
|
||||
const jpeg=scene && slot!=='logo' && slot!=='icon';
|
||||
result[slot]=[jpeg?'jpg':'png', canvas.toDataURL(jpeg?'image/jpeg':'image/png',.9).split(',')[1]];
|
||||
}
|
||||
return {images:result,warnings,font};
|
||||
}
|
||||
@@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
|
||||
|
||||
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
|
||||
steam_shortcuts.py list -> JSON [{appid, name, exe}]
|
||||
steam_shortcuts.py configure APPID NAME EXE START_DIR ICON VR ARTWORK_JSON
|
||||
steam_shortcuts.py stop APPID
|
||||
steam_shortcuts.py remove APPID
|
||||
"""
|
||||
import base64, json, os, socket, struct, sys, urllib.request
|
||||
import base64, glob, json, os, re, socket, struct, sys, urllib.request
|
||||
|
||||
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
||||
|
||||
@@ -22,10 +24,10 @@ def target_ws():
|
||||
class WS:
|
||||
"""Just enough RFC 6455 for one CDP request/response on loopback."""
|
||||
|
||||
def __init__(self, url):
|
||||
def __init__(self, url, timeout=20):
|
||||
host_port, path = url[len('ws://'):].split('/', 1)
|
||||
host, port = host_port.split(':')
|
||||
self.s = socket.create_connection((host, int(port)), timeout=20)
|
||||
self.s = socket.create_connection((host, int(port)), timeout=timeout)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n'
|
||||
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
|
||||
@@ -69,8 +71,8 @@ class WS:
|
||||
return msg.decode()
|
||||
|
||||
|
||||
def evaluate(js):
|
||||
ws = WS(target_ws())
|
||||
def evaluate(js, timeout=20):
|
||||
ws = WS(target_ws(), timeout)
|
||||
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
||||
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
||||
while True:
|
||||
@@ -83,6 +85,206 @@ def evaluate(js):
|
||||
return res.get('result', {}).get('value')
|
||||
|
||||
|
||||
# Steam's ELibraryAssetType (Capsule, Hero, Logo, Header, Icon).
|
||||
ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}
|
||||
|
||||
|
||||
def collections_js(appid, wanted=()):
|
||||
wanted = list(wanted)
|
||||
return f'''async function syncCollections() {{
|
||||
const wanted = {json.dumps(wanted)};
|
||||
if (typeof collectionStore === "undefined" ||
|
||||
typeof collectionStore.GetUserCollectionsByName !== "function" ||
|
||||
typeof collectionStore.NewUnsavedCollection !== "function" ||
|
||||
typeof collectionStore.SaveCollection !== "function")
|
||||
return ["Steam collections API unavailable"];
|
||||
const app = {{appid: {appid}}};
|
||||
const warnings = [];
|
||||
for (const name of ["Android", "Android VR", "Sideloaded"]) {{
|
||||
const matches = collectionStore.GetUserCollectionsByName(name);
|
||||
let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop);
|
||||
if (wanted.includes(name)) {{
|
||||
if (!collection && matches.length) {{
|
||||
warnings.push(name + " is an existing dynamic or read-only collection");
|
||||
continue;
|
||||
}}
|
||||
if (!collection) {{
|
||||
collection = collectionStore.NewUnsavedCollection(name, undefined, [app]);
|
||||
}} else {{
|
||||
collection.AsDragDropCollection().AddApps([app]);
|
||||
}}
|
||||
await collectionStore.SaveCollection(collection);
|
||||
}} else if (collection) {{
|
||||
collection.AsDragDropCollection().RemoveApps([app]);
|
||||
await collectionStore.SaveCollection(collection);
|
||||
}}
|
||||
}}
|
||||
return warnings;
|
||||
}}'''
|
||||
|
||||
|
||||
|
||||
def notes_js(name, details):
|
||||
filename = 'notes_shortcut_' + re.sub(r'[!-/:-@ \[\\\]\^`]', '_', name.strip())
|
||||
content = '\n'.join(str(details[k]) for k in ('package', 'version', 'source') if details.get(k))
|
||||
return f'''if (SteamClient.GameNotes && typeof SteamClient.GameNotes.GetNotes === "function" &&
|
||||
typeof SteamClient.GameNotes.SaveNotes === "function") {{
|
||||
try {{
|
||||
const file = {json.dumps(filename)};
|
||||
const previous = await SteamClient.GameNotes.GetNotes(file, file + "_images/");
|
||||
if (previous.result !== 1 && previous.result !== 9) throw Error("read " + previous.result);
|
||||
const data = previous.result === 1 ? JSON.parse(previous.notes) : {{notes: [], shortcut_name: {json.dumps(name)}}};
|
||||
if (!Array.isArray(data.notes)) throw Error("unexpected notes format");
|
||||
const id = "frame-control-library", now = Math.floor(Date.now()/1000);
|
||||
const old = data.notes.find(n => n.id === id);
|
||||
const note = {{id, shortcut_name: {json.dumps(name)}, title: "Installation details",
|
||||
content: {json.dumps(content)}, ordinal: old ? old.ordinal : data.notes.length,
|
||||
time_created: old ? old.time_created : now, time_modified: now}};
|
||||
data.notes = data.notes.filter(n => n.id !== id).concat([note]);
|
||||
const result = await SteamClient.GameNotes.SaveNotes(file, JSON.stringify(data));
|
||||
if (result !== 1) throw Error("save " + result);
|
||||
}} catch (e) {{ warnings.push("Steam notes: " + String(e)); }}
|
||||
}}'''
|
||||
|
||||
|
||||
MAX_ART = 12 * 1024 * 1024 # Steam's custom artwork limit per slot
|
||||
|
||||
|
||||
def render(plan):
|
||||
with open(plan) as f:
|
||||
source = json.load(f)
|
||||
images = {}
|
||||
for slot, path in source['images'].items():
|
||||
ext = os.path.splitext(path)[1][1:]
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read(MAX_ART + 1)
|
||||
if len(data) > MAX_ART:
|
||||
raise ValueError('source artwork too large')
|
||||
images[slot] = [ext, base64.b64encode(data).decode()]
|
||||
renderer = globals().get('ART_RENDERER')
|
||||
if renderer is None:
|
||||
with open(os.path.join(os.path.dirname(__file__), 'library_artwork.js')) as f:
|
||||
renderer = f.read()
|
||||
try:
|
||||
return _render(plan, renderer, source['label'], images)
|
||||
except (ValueError, OSError, EOFError, SystemExit) as e:
|
||||
# Generated art from the icon alone always fits; a photo that didn't must not fail the install.
|
||||
result = _render(plan, renderer, source['label'], {k: v for k, v in images.items() if k == 'icon'})
|
||||
result['warnings'].insert(0, 'Source artwork could not be rendered (' + str(e)[:120] + '); generated art used')
|
||||
return result
|
||||
|
||||
|
||||
def _render(plan, renderer, label, images):
|
||||
# A 4K photo takes seconds to decode and encode on the Frame; allow well beyond that.
|
||||
result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': label, 'images': images}) + ')',
|
||||
timeout=75)
|
||||
if not isinstance(result, dict) or set(result.get('images', {})) != set(ASSETS):
|
||||
raise ValueError('incomplete artwork render')
|
||||
paths = {}
|
||||
for slot, (ext, encoded) in result['images'].items():
|
||||
data = base64.b64decode(encoded, validate=True)
|
||||
signature = {'png': b'\x89PNG\r\n\x1a\n', 'jpg': b'\xff\xd8\xff'}.get(ext)
|
||||
if not signature or not data.startswith(signature) or len(data) > MAX_ART:
|
||||
raise ValueError(slot + ' render is ' + str(len(data)) + ' bytes of ' + str(ext))
|
||||
paths[slot] = os.path.join(os.path.dirname(plan), slot + '.' + ext)
|
||||
with open(paths[slot] + '.tmp', 'wb') as f:
|
||||
f.write(data)
|
||||
for slot, path in paths.items():
|
||||
os.replace(path + '.tmp', path)
|
||||
for stale in ('png', 'jpg'):
|
||||
other = os.path.join(os.path.dirname(plan), slot + '.' + stale)
|
||||
if other != path and os.path.exists(other):
|
||||
os.remove(other)
|
||||
return {'paths': paths, 'warnings': list(result.get('warnings', []))}
|
||||
|
||||
|
||||
# Steam's own file for each custom-art type in userdata/*/config/grid/.
|
||||
GRID_FILES = {0: 'p', 1: '_hero', 2: '_logo', 3: ''}
|
||||
|
||||
|
||||
def custom_art(appid):
|
||||
"""The custom-art types this shortcut already has in Steam, for any local user."""
|
||||
found = set()
|
||||
for kind, suffix in GRID_FILES.items():
|
||||
pattern = os.path.expanduser(f'~/.local/share/Steam/userdata/*/config/grid/{int(appid)}{suffix}.*')
|
||||
if any(os.path.splitext(p)[1].lower() in ('.png', '.jpg', '.jpeg') for p in glob.glob(pattern)):
|
||||
found.add(kind)
|
||||
return found
|
||||
|
||||
|
||||
def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
|
||||
"""options: category, details, fill_only (only empty slots and a missing icon; name and flags untouched)."""
|
||||
options = options or {}
|
||||
category = options.get('category', 'Android')
|
||||
fill = bool(options.get('fill_only'))
|
||||
existing = custom_art(appid) if fill else set()
|
||||
if set(artwork) != set(ASSETS):
|
||||
raise ValueError('all five Steam artwork slots are required')
|
||||
images = []
|
||||
for slot, path in artwork.items():
|
||||
if slot not in ASSETS:
|
||||
raise ValueError('unknown artwork slot')
|
||||
ext = os.path.splitext(path)[1][1:]
|
||||
if ext not in ('png', 'jpg'):
|
||||
raise ValueError('artwork must be PNG or JPEG')
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read(MAX_ART + 1)
|
||||
if len(data) > MAX_ART:
|
||||
raise ValueError('artwork is too large')
|
||||
# Frame's custom-art API maps type 4 to Header; use SetShortcutIcon.
|
||||
if slot != 'icon' and ASSETS[slot] not in existing:
|
||||
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
|
||||
return evaluate(f'''(async () => {{
|
||||
const id = {int(appid)}, warnings = [], fill = {json.dumps(fill)};
|
||||
const overview = appStore.GetAppOverviewByAppID(id);
|
||||
if (!fill) {{
|
||||
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
|
||||
if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
|
||||
if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
|
||||
if (typeof SteamClient.Apps.SetShortcutSortAs === "function")
|
||||
SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)});
|
||||
}}
|
||||
if (!fill || !(overview && overview.icon_data)) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
|
||||
// null (devkit titles) or filling gaps: leave the VR flag as Steam has it.
|
||||
if ({json.dumps(vr)} !== null && !fill) {{
|
||||
if (typeof SteamClient.Apps.SetShortcutIsVR === "function")
|
||||
SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)});
|
||||
else warnings.push("Steam VR shortcut flag API unavailable");
|
||||
}}
|
||||
if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{
|
||||
for (const [type, ext, data] of {json.dumps(images)}) {{
|
||||
// Steam keeps a slot's PNG and JPEG side by side; clear it so a stale one can't win.
|
||||
if (!fill && typeof SteamClient.Apps.ClearCustomArtworkForApp === "function")
|
||||
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}}
|
||||
await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
|
||||
}}
|
||||
}} else throw new Error("Steam artwork API unavailable; installation is incomplete");
|
||||
{collections_js(int(appid), [category] + (['Android VR'] if vr and category == 'Android' else []))}
|
||||
try {{ warnings.push(...await syncCollections()); }}
|
||||
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
|
||||
{notes_js(name, options.get('details', {}))}
|
||||
return {{warnings}};
|
||||
}})()''', timeout=60)
|
||||
|
||||
|
||||
def remove(appid):
|
||||
# Collections and artwork are tidy-up: only a missing RemoveShortcut may fail the removal.
|
||||
return evaluate(f'''(async () => {{
|
||||
const id = {int(appid)}, warnings = [];
|
||||
{collections_js(int(appid))}
|
||||
try {{ warnings.push(...await syncCollections()); }}
|
||||
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
|
||||
if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{
|
||||
for (const type of [0, 1, 2, 3]) {{
|
||||
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }}
|
||||
catch (e) {{ warnings.push("Steam artwork " + type + ": " + String(e)); }}
|
||||
}}
|
||||
}} else warnings.push("Steam artwork removal API unavailable");
|
||||
SteamClient.Apps.RemoveShortcut(id);
|
||||
return {{warnings}};
|
||||
}})()''')
|
||||
|
||||
|
||||
def main():
|
||||
cmd, args = sys.argv[1], sys.argv[2:]
|
||||
if cmd == 'add':
|
||||
@@ -97,12 +299,30 @@ def main():
|
||||
}})()'''
|
||||
print(evaluate(js))
|
||||
elif cmd == 'list':
|
||||
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
|
||||
.map(a => ({appid: a.appid, name: a.display_name})))()'''
|
||||
# Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered.
|
||||
js = '''(async () => Promise.all(appStore.allApps.filter(a => a.app_type === 1073741824).map(async a => {
|
||||
let d = typeof appDetailsStore !== "undefined" && appDetailsStore.GetAppDetails(a.appid);
|
||||
if (!d && typeof SteamClient.Apps.RegisterForAppDetails === "function") d = await new Promise(ok => {
|
||||
let reg;
|
||||
const timer = setTimeout(() => { if (reg) reg.unregister(); ok(null); }, 3000);
|
||||
reg = SteamClient.Apps.RegisterForAppDetails(a.appid, x => {
|
||||
clearTimeout(timer); setTimeout(() => reg && reg.unregister()); ok(x); });
|
||||
});
|
||||
return {appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid,
|
||||
exe: d ? d.strShortcutExe || "" : "", start_dir: d ? d.strShortcutStartDir || "" : ""};
|
||||
})))()'''
|
||||
print(json.dumps(evaluate(js)))
|
||||
elif cmd == 'render':
|
||||
print(json.dumps(render(args[0])))
|
||||
elif cmd == 'configure':
|
||||
vr = {'1': True, '0': False}.get(args[5]) # '' leaves Steam's VR flag alone
|
||||
print(json.dumps(configure(int(args[0]), *args[1:5], vr, json.loads(args[6]),
|
||||
json.loads(args[7]) if len(args) > 7 else None)))
|
||||
elif cmd == 'stop':
|
||||
evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)')
|
||||
print('stopping')
|
||||
elif cmd == 'remove':
|
||||
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
|
||||
print('removed')
|
||||
print(json.dumps(remove(int(args[0]))))
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ class AndroidApps(harness.FrameTestCase):
|
||||
self.assertEqual(shortcut['name'], 'App label')
|
||||
self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh')
|
||||
self.assertEqual(shortcut['start_dir'], APP_DIR)
|
||||
self.assertEqual(shortcut['icon'], f'{APP_DIR}/icon.png')
|
||||
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'icon.png', 'lepton-show-flatscreen'):
|
||||
self.assertEqual(shortcut['icon'], f'{APP_DIR}/artwork/icon.png')
|
||||
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'artwork/icon.png', 'lepton-show-flatscreen'):
|
||||
self.assertTrue(exists(f'{APP_DIR}/{f}'), f)
|
||||
self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000)
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
|
||||
if cmd == 'ps':
|
||||
for name, c in sorted(containers.items()):
|
||||
print(f"{name} {c['port']}")
|
||||
elif cmd == 'inspect':
|
||||
if args[-1] not in containers:
|
||||
sys.exit(1)
|
||||
print('true')
|
||||
elif cmd == 'stop':
|
||||
name = args[-1]
|
||||
c = containers.get(name)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Synthetic canvas for API-path tests only. It emits transparent PNGs, not visual proof.
|
||||
const zlib = require('zlib');
|
||||
function crc(data) {
|
||||
let c=0xffffffff;
|
||||
for(const b of data) {c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);}
|
||||
return (c^0xffffffff)>>>0;
|
||||
}
|
||||
function chunk(name,data) {
|
||||
const body=Buffer.concat([Buffer.from(name),data]), n=Buffer.alloc(4), sum=Buffer.alloc(4);
|
||||
n.writeUInt32BE(data.length);sum.writeUInt32BE(crc(body));return Buffer.concat([n,body,sum]);
|
||||
}
|
||||
function png(w,h) {
|
||||
const header=Buffer.alloc(13);header.writeUInt32BE(w);header.writeUInt32BE(h,4);header[8]=8;header[9]=6;
|
||||
return Buffer.concat([Buffer.from('89504e470d0a1a0a','hex'),chunk('IHDR',header),
|
||||
chunk('IDAT',zlib.deflateSync(Buffer.alloc((w*4+1)*h))),chunk('IEND',Buffer.alloc(0))]).toString('base64');
|
||||
}
|
||||
function surface() {
|
||||
const canvases=[];
|
||||
const document={fonts:{load:async()=>[]},createElement(tag) {
|
||||
if(tag!=='canvas')throw Error('unexpected element');
|
||||
const canvas={width:1,height:1,text:[],draws:0};
|
||||
const ctx={measureText:t=>({width:String(t).length*30}),fillText(t){canvas.text.push(t);},
|
||||
drawImage(){canvas.draws++;},getImageData:()=>({data:new Uint8ClampedArray(canvas.width*canvas.height*4)}),
|
||||
createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}})};
|
||||
for(const method of ['save','restore','beginPath','rect','roundRect','clip','fillRect','putImageData','arc','fill','stroke'])ctx[method]=()=>{};
|
||||
canvas.getContext=()=>ctx;canvas.toDataURL=type=>type==='image/jpeg'?'data:image/jpeg;base64,'+Buffer.from('ffd8ffe000104a464946','hex').toString('base64'):
|
||||
'data:image/png;base64,'+png(canvas.width,canvas.height);
|
||||
canvases.push(canvas);return canvas;
|
||||
}};
|
||||
class Image {constructor(){this.width=2;this.height=2;} async decode(){if(this.src.includes('YmFk'))throw Error('bad image');}}
|
||||
return {document,Image,canvases};
|
||||
}
|
||||
module.exports={surface};
|
||||
@@ -37,7 +37,8 @@ function build(steam) {
|
||||
},
|
||||
});
|
||||
const shortcutOverview = s => ({
|
||||
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE,
|
||||
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid,
|
||||
icon_data: s.icon ? 'fake-icon' : undefined,
|
||||
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
||||
});
|
||||
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
||||
@@ -54,7 +55,30 @@ function build(steam) {
|
||||
}
|
||||
};
|
||||
|
||||
// Library API shapes from SteamTracking / decky-frontend-lib (2026-09-28).
|
||||
// Not yet verified on this Frame build: Steam was unavailable during testing.
|
||||
steam.collections ||= [];
|
||||
const collection = value => ({
|
||||
...value, displayName: value.name, bIsDynamic: !!value.dynamic, bAllowsDragAndDrop: true,
|
||||
AsDragDropCollection() { return this; },
|
||||
AddApps(apps) { value.apps = [...new Set([...value.apps, ...apps.map(a => a.appid)])]; },
|
||||
RemoveApps(apps) { value.apps = value.apps.filter(id => !apps.some(a => a.appid === id)); },
|
||||
value,
|
||||
});
|
||||
return {
|
||||
...require('./canvas_stub').surface(),
|
||||
collectionStore: {
|
||||
GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); },
|
||||
NewUnsavedCollection(name, filter, apps) { return collection({name, apps: apps.map(a => a.appid)}); },
|
||||
async SaveCollection(c) { if (!steam.collections.includes(c.value)) steam.collections.push(c.value); },
|
||||
},
|
||||
// Shortcut exe/start folder live in app details, not overviews (Frame, 2026-09-28).
|
||||
appDetailsStore: {
|
||||
GetAppDetails(id) {
|
||||
const s = findShortcut(id);
|
||||
return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null;
|
||||
},
|
||||
},
|
||||
appStore: {
|
||||
get allApps() { return allApps(); },
|
||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
||||
@@ -75,6 +99,17 @@ function build(steam) {
|
||||
SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); },
|
||||
SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); },
|
||||
SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); },
|
||||
SetShortcutIsVR(id, vr) { const s = findShortcut(id); if (s) s.vr = vr; },
|
||||
async SetCustomArtworkForApp(id, data, ext, type) {
|
||||
const s = findShortcut(id);
|
||||
if (s) { s.artwork ||= {}; s.artwork[type] = {data, ext}; }
|
||||
},
|
||||
async ClearCustomArtworkForApp(id, type) {
|
||||
const s = findShortcut(id);
|
||||
if (s?.artwork) delete s.artwork[type];
|
||||
},
|
||||
// Container fallback in frame_android.stop performs the simulated stop.
|
||||
TerminateApp(gameid) { steam.last_terminate = gameid; },
|
||||
RemoveShortcut(id) {
|
||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
||||
delete steam.compat_tools[String(id)];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Store preview artwork
|
||||
|
||||
Recorded public artwork for offline UI verification, fetched 2026-09-28.
|
||||
`urls.json` records each original URL. No unit test downloads these files.
|
||||
|
||||
- Open Brush banner, icon and screenshots: Icosa Foundation's public
|
||||
`icosa-foundation/openbrush.app` website assets. Artwork remains credited to
|
||||
its creators; used here to preview the Open Brush listing.
|
||||
- Mindustry, AntennaPod and NewPipe icons/screenshots: their public F-Droid
|
||||
listings. Corresponding projects use GPL licences; these images represent
|
||||
those same apps in the store preview.
|
||||
- Luanti, SuperTuxKart and other social previews: public GitHub-generated
|
||||
repository preview images. Project names/logos belong to their owners.
|
||||
|
||||
`../store.json` contains illustrative listing metadata, including mock package
|
||||
names, popularity, dates, version/size and compatibility fields. It is not a
|
||||
catalogue or evidence that a particular release works on the Frame. `_demo.py`
|
||||
is opt-in and cannot download APKs. `tests/search_preview.py` preloads these
|
||||
recordings into the image cache and simulates installation without a headset.
|
||||
|
After Width: | Height: | Size: 489 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 821 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"brush-banner.jpg": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg",
|
||||
"brush-icon.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
|
||||
"brush-shot1.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png",
|
||||
"brush-shot2.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp",
|
||||
"brush-shot3.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp",
|
||||
"luanti.png": "https://opengraph.githubassets.com/1/luanti-org/luanti",
|
||||
"kart.png": "https://opengraph.githubassets.com/1/supertuxkart/stk-code",
|
||||
"luanti-icon.png": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png",
|
||||
"pod-icon.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png",
|
||||
"mindustry-icon.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png",
|
||||
"mindustry-shot.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png",
|
||||
"pod-shot.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png",
|
||||
"newpipe-icon.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png",
|
||||
"newpipe-shot.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"source":"one","id":"brush","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":true,"version":"1","version_code":1,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2025-01-01"},
|
||||
{"source":"two","id":"brush2","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":false,"version":"2","version_code":2,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2026-01-01"},
|
||||
{"source":"one","id":"other","package":"org.other","name":"Open Brush","free":true,"downloadable":true,"min_sdk":31,"abis":["arm64-v8a"],"vr":true},
|
||||
{"source":"one","id":"unknown","package":null,"name":"Pocket Radio!","free":true,"downloadable":false,"vr":false},
|
||||
{"source":"two","id":"unknown2","package":null,"name":"pocket radio","free":true,"downloadable":false,"vr":false}
|
||||
]
|
||||
@@ -0,0 +1,182 @@
|
||||
[
|
||||
{
|
||||
"id": "brush",
|
||||
"package": "org.preview.brush",
|
||||
"name": "Open Brush",
|
||||
"summary": "Make the world your canvas. Paint, sculpt and create in a space without limits.",
|
||||
"description": "Your imagination deserves more room. Open Brush turns the space around you into a canvas, with expressive brushes, vivid colors and light you can paint with.\n\nCreate something small, build something extraordinary, or just enjoy making your first mark in VR. This community-led painting app is free and open source.",
|
||||
"developer": "Icosa Foundation",
|
||||
"license": "Apache-2.0",
|
||||
"free": true,
|
||||
"downloadable": true,
|
||||
"version": "2.32.29",
|
||||
"version_code": 1,
|
||||
"min_sdk": 26,
|
||||
"abis": [
|
||||
"arm64-v8a"
|
||||
],
|
||||
"vr": true,
|
||||
"updated": "2026-09-27",
|
||||
"size": 85000000,
|
||||
"popularity": 100,
|
||||
"images": {
|
||||
"banner": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg",
|
||||
"icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png",
|
||||
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp",
|
||||
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp"
|
||||
]
|
||||
},
|
||||
"engine": "Unity OpenXR",
|
||||
"frame_tested": true,
|
||||
"icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
|
||||
"page": "https://openbrush.app"
|
||||
},
|
||||
{
|
||||
"id": "mindustry",
|
||||
"package": "org.preview.mindustry",
|
||||
"name": "Mindustry",
|
||||
"summary": "Build a factory. Defend your world.",
|
||||
"description": "Build a factory. Defend your world.",
|
||||
"developer": "Anuken",
|
||||
"license": "GPL-3.0",
|
||||
"free": true,
|
||||
"downloadable": true,
|
||||
"version": "1.2",
|
||||
"version_code": 1,
|
||||
"min_sdk": 26,
|
||||
"abis": [
|
||||
"arm64-v8a"
|
||||
],
|
||||
"vr": false,
|
||||
"updated": "2026-09-26",
|
||||
"size": 85000000,
|
||||
"popularity": 92,
|
||||
"images": {
|
||||
"banner": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png",
|
||||
"icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png",
|
||||
"screenshots": [
|
||||
"https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png"
|
||||
]
|
||||
},
|
||||
"icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png"
|
||||
},
|
||||
{
|
||||
"id": "luanti",
|
||||
"package": "org.preview.luanti",
|
||||
"name": "Luanti",
|
||||
"summary": "A world of blocks. Endless possibilities.",
|
||||
"description": "A world of blocks. Endless possibilities.",
|
||||
"developer": "Luanti contributors",
|
||||
"license": "LGPL-2.1",
|
||||
"free": true,
|
||||
"downloadable": true,
|
||||
"version": "1.3",
|
||||
"version_code": 1,
|
||||
"min_sdk": 26,
|
||||
"abis": [
|
||||
"arm64-v8a"
|
||||
],
|
||||
"vr": false,
|
||||
"updated": "2026-09-25",
|
||||
"size": 85000000,
|
||||
"popularity": 84,
|
||||
"images": {
|
||||
"banner": "https://opengraph.githubassets.com/1/luanti-org/luanti",
|
||||
"icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png",
|
||||
"screenshots": [
|
||||
"https://opengraph.githubassets.com/1/luanti-org/luanti"
|
||||
]
|
||||
},
|
||||
"icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png"
|
||||
},
|
||||
{
|
||||
"id": "pod",
|
||||
"package": "org.preview.pod",
|
||||
"name": "AntennaPod",
|
||||
"summary": "Your favorite stories, wherever you listen.",
|
||||
"description": "Your favorite stories, wherever you listen.",
|
||||
"developer": "AntennaPod contributors",
|
||||
"license": "GPL-3.0",
|
||||
"free": true,
|
||||
"downloadable": true,
|
||||
"version": "1.4",
|
||||
"version_code": 1,
|
||||
"min_sdk": 26,
|
||||
"abis": [
|
||||
"arm64-v8a"
|
||||
],
|
||||
"vr": false,
|
||||
"updated": "2026-09-24",
|
||||
"size": 85000000,
|
||||
"popularity": 76,
|
||||
"images": {
|
||||
"banner": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png",
|
||||
"icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png",
|
||||
"screenshots": [
|
||||
"https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png"
|
||||
]
|
||||
},
|
||||
"icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png"
|
||||
},
|
||||
{
|
||||
"id": "newpipe",
|
||||
"package": "org.preview.newpipe",
|
||||
"name": "NewPipe",
|
||||
"summary": "Your videos and music, without the distractions.",
|
||||
"description": "Your videos and music, without the distractions.",
|
||||
"developer": "Team NewPipe",
|
||||
"license": "GPL-3.0",
|
||||
"free": true,
|
||||
"downloadable": true,
|
||||
"version": "1.5",
|
||||
"version_code": 1,
|
||||
"min_sdk": 26,
|
||||
"abis": [
|
||||
"arm64-v8a"
|
||||
],
|
||||
"vr": false,
|
||||
"updated": "2026-09-23",
|
||||
"size": 85000000,
|
||||
"popularity": 68,
|
||||
"images": {
|
||||
"banner": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png",
|
||||
"icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png",
|
||||
"screenshots": [
|
||||
"https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png"
|
||||
]
|
||||
},
|
||||
"icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png"
|
||||
},
|
||||
{
|
||||
"id": "kart",
|
||||
"package": "org.preview.kart",
|
||||
"name": "SuperTuxKart",
|
||||
"summary": "A little friendly competition. A lot of colorful chaos.",
|
||||
"description": "A little friendly competition. A lot of colorful chaos.",
|
||||
"developer": "SuperTuxKart Team",
|
||||
"license": "GPL-3.0",
|
||||
"free": true,
|
||||
"downloadable": false,
|
||||
"version": "1.6",
|
||||
"version_code": 1,
|
||||
"min_sdk": 26,
|
||||
"abis": [
|
||||
"arm64-v8a"
|
||||
],
|
||||
"vr": false,
|
||||
"updated": "2026-09-22",
|
||||
"size": 85000000,
|
||||
"popularity": 60,
|
||||
"images": {
|
||||
"banner": "https://opengraph.githubassets.com/1/supertuxkart/stk-code",
|
||||
"icon": null,
|
||||
"screenshots": [
|
||||
"https://opengraph.githubassets.com/1/supertuxkart/stk-code"
|
||||
]
|
||||
},
|
||||
"icon": null,
|
||||
"page": "https://supertuxkart.net"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# F-Droid verification fixtures
|
||||
|
||||
`entry.jar` and `index-v1.jar` are synthetic RSA-2048/SHA-256 signed JARs,
|
||||
including CMS signed attributes. `fingerprint.txt` identifies their throwaway
|
||||
certificate. Their JSON describes org.example.app; `example.apk` is deliberately
|
||||
plain test data, not an installable app. The v2 index includes incompatible
|
||||
Android-31 and x86-only versions to exercise the shared reducer.
|
||||
|
||||
`izzy-entry.jar` was recorded from
|
||||
https://apt.izzysoft.de/fdroid/repo/entry.jar on 2026-09-28. Its certificate
|
||||
fingerprint matches the operator's published fingerprint:
|
||||
3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A.
|
||||
It exercises an independent production JAR/CMS encoder without network access.
|
||||
The index it references is not needed by this signature-only fixture test.
|
||||
|
||||
`artwork-v1.json` and `artwork-v2.json` are unsigned metadata/reducer fixtures
|
||||
based on the synthetic indexes above. They exercise en-US preference, per-field
|
||||
locale fallback, v1 artwork paths, phone/tablet ordering, the six-image cap,
|
||||
author names and HTML/multiline summaries. The signed integrity fixtures remain
|
||||
unchanged; artwork tests feed these JSON files directly through the reducer and
|
||||
then round-trip the resulting entries through the source cache.
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"packageName": "org.example.app",
|
||||
"name": "Example",
|
||||
"license": "MIT",
|
||||
"authorName": "Example Developer",
|
||||
"summary": "Fallback summary",
|
||||
"localized": {
|
||||
"de": {
|
||||
"name": "Beispiel",
|
||||
"summary": "Deutsch",
|
||||
"icon": "german.png",
|
||||
"phoneScreenshots": [
|
||||
"german.png"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"name": "Example",
|
||||
"summary": "Offline <b>fixture</b> & music.\n One\t line.",
|
||||
"icon": "icon.png",
|
||||
"phoneScreenshots": [
|
||||
"1.png",
|
||||
"2.png",
|
||||
"3.png",
|
||||
"4.png"
|
||||
]
|
||||
},
|
||||
"fr": {
|
||||
"featureGraphic": "featureGraphic.png",
|
||||
"sevenInchScreenshots": [
|
||||
"1.png",
|
||||
"2.png",
|
||||
"3.png",
|
||||
"4.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"packages": {
|
||||
"org.example.app": [
|
||||
{
|
||||
"versionName": "1",
|
||||
"versionCode": 1,
|
||||
"apkName": "example1.apk",
|
||||
"hash": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||
"hashType": "sha256",
|
||||
"size": 51,
|
||||
"minSdkVersion": 21
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"repo": {
|
||||
"name": {
|
||||
"en-US": "Fixture"
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"org.example.app": {
|
||||
"metadata": {
|
||||
"name": {
|
||||
"en-US": "Example"
|
||||
},
|
||||
"summary": {
|
||||
"en-US": "<p>Offline <b>fixture</b> & music.</p>\n<p>One\t line.</p><script>hidden()</script>"
|
||||
},
|
||||
"license": "MIT",
|
||||
"authorName": "Example Developer",
|
||||
"icon": {
|
||||
"de": {
|
||||
"name": "/org.example.app/de/icon.png"
|
||||
},
|
||||
"en-US": {
|
||||
"name": "/org.example.app/en-US/icon.png"
|
||||
}
|
||||
},
|
||||
"featureGraphic": {
|
||||
"fr": {
|
||||
"name": "/org.example.app/fr/featureGraphic.png"
|
||||
}
|
||||
},
|
||||
"screenshots": {
|
||||
"phone": {
|
||||
"de": [
|
||||
{
|
||||
"name": "/org.example.app/de/phoneScreenshots/1.png"
|
||||
}
|
||||
],
|
||||
"en-US": [
|
||||
{
|
||||
"name": "/org.example.app/en-US/phoneScreenshots/1.png"
|
||||
},
|
||||
{
|
||||
"name": "/org.example.app/en-US/phoneScreenshots/2.png"
|
||||
},
|
||||
{
|
||||
"name": "/org.example.app/en-US/phoneScreenshots/3.png"
|
||||
},
|
||||
{
|
||||
"name": "/org.example.app/en-US/phoneScreenshots/4.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sevenInch": {
|
||||
"fr": [
|
||||
{
|
||||
"name": "/org.example.app/fr/sevenInchScreenshots/1.png"
|
||||
},
|
||||
{
|
||||
"name": "/org.example.app/fr/sevenInchScreenshots/2.png"
|
||||
},
|
||||
{
|
||||
"name": "/org.example.app/fr/sevenInchScreenshots/3.png"
|
||||
},
|
||||
{
|
||||
"name": "/org.example.app/fr/sevenInchScreenshots/4.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"versions": {
|
||||
"1": {
|
||||
"manifest": {
|
||||
"versionName": "1",
|
||||
"versionCode": 1,
|
||||
"usesSdk": {
|
||||
"minSdkVersion": 21
|
||||
},
|
||||
"nativecode": []
|
||||
},
|
||||
"file": {
|
||||
"name": "/example1.apk",
|
||||
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||
"size": 51
|
||||
},
|
||||
"added": 1700000000000
|
||||
},
|
||||
"2": {
|
||||
"manifest": {
|
||||
"versionName": "2",
|
||||
"versionCode": 2,
|
||||
"usesSdk": {
|
||||
"minSdkVersion": 30
|
||||
},
|
||||
"nativecode": [
|
||||
"arm64-v8a"
|
||||
]
|
||||
},
|
||||
"file": {
|
||||
"name": "/example2.apk",
|
||||
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||
"size": 51
|
||||
},
|
||||
"added": 1700000000000
|
||||
},
|
||||
"3": {
|
||||
"manifest": {
|
||||
"versionName": "3",
|
||||
"versionCode": 3,
|
||||
"usesSdk": {
|
||||
"minSdkVersion": 31
|
||||
},
|
||||
"nativecode": []
|
||||
},
|
||||
"file": {
|
||||
"name": "/example3.apk",
|
||||
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||
"size": 51
|
||||
},
|
||||
"added": 1700000000000
|
||||
},
|
||||
"4": {
|
||||
"manifest": {
|
||||
"versionName": "4",
|
||||
"versionCode": 4,
|
||||
"usesSdk": {
|
||||
"minSdkVersion": 21
|
||||
},
|
||||
"nativecode": [
|
||||
"x86_64"
|
||||
]
|
||||
},
|
||||
"file": {
|
||||
"name": "/example4.apk",
|
||||
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||
"size": 51
|
||||
},
|
||||
"added": 1700000000000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Fixture APK payload, deliberately not installable.
|
||||
@@ -0,0 +1 @@
|
||||
0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a
|
||||
@@ -0,0 +1 @@
|
||||
{"repo": {"name": {"en-US": "Fixture"}}, "packages": {"org.example.app": {"metadata": {"name": {"en-US": "Example"}, "summary": {"en-US": "Offline fixture"}, "license": "MIT"}, "versions": {"1": {"manifest": {"versionName": "1", "versionCode": 1, "usesSdk": {"minSdkVersion": 21}, "nativecode": []}, "file": {"name": "/example1.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "2": {"manifest": {"versionName": "2", "versionCode": 2, "usesSdk": {"minSdkVersion": 30}, "nativecode": ["arm64-v8a"]}, "file": {"name": "/example2.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "3": {"manifest": {"versionName": "3", "versionCode": 3, "usesSdk": {"minSdkVersion": 31}, "nativecode": []}, "file": {"name": "/example3.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "4": {"manifest": {"versionName": "4", "versionCode": 4, "usesSdk": {"minSdkVersion": 21}, "nativecode": ["x86_64"]}, "file": {"name": "/example4.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}}}}}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Library fixtures
|
||||
|
||||
`icon.png` is a synthetic 2×2 RGBA fixture with opaque, half-transparent and
|
||||
transparent pixels. `steam-responses.json` includes the Open Saber Plus
|
||||
shortcut ID/name and home path read from the Frame's existing metadata on
|
||||
2026-09-28; the `configure` response is synthetic, matching our helper's
|
||||
contract. Tests never contact the network.
|
||||
|
||||
The existing fakeframe CEF shim models artwork and collection methods from
|
||||
SteamTracking's `ClientExtracted/steamui/chunk~2dcc5aaf7.js` and
|
||||
SteamDeckHomebrew/decky-frontend-lib's `src/globals/steam-client/App.ts`, read
|
||||
2026-09-28. These methods were not captured from this headset: Steam's client
|
||||
was unavailable. The Node-based test runs the actual generated JavaScript
|
||||
against that fixture; it is skipped when Node is absent.
|
||||
|
||||
`icon.jpg` is the same synthetic icon converted with macOS `sips` to exercise
|
||||
JPEG SOF parsing. `sips` is not used by the product or tests.
|
||||
@@ -0,0 +1,21 @@
|
||||
const fs=require('fs'),vm=require('vm'),assert=require('assert');
|
||||
const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub');
|
||||
(async()=>{
|
||||
const surface=stub.surface(),ctx=vm.createContext(surface);
|
||||
vm.runInContext(fs.readFileSync('frame/android/library_artwork.js','utf8'),ctx);
|
||||
const result=await ctx.renderLibraryArtwork({label:'Example Game',images:{icon:['png','fixture']}});
|
||||
assert.deepEqual(Object.keys(result.images),['grid','wide','hero','logo','icon']);
|
||||
for(const [slot,size] of Object.entries({grid:[600,900],wide:[920,430],hero:[3840,1240],logo:[1280,480],icon:[256,256]})) {
|
||||
assert.equal(result.images[slot][0],'png');
|
||||
const b=Buffer.from(result.images[slot][1],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]);
|
||||
}
|
||||
// A photo scene is JPEG (Steam's 12 MiB limit at hero size); the logo stays transparent PNG.
|
||||
const photo=await ctx.renderLibraryArtwork({label:'Photo',images:{hero:['jpg','fixture'],banner:['jpg','fixture']}});
|
||||
for(const slot of ['wide','hero'])assert.equal(photo.images[slot][0],'jpg');
|
||||
for(const slot of ['grid','logo','icon'])assert.equal(photo.images[slot][0],'png');
|
||||
const hero=surface.canvases.find(c=>c.width===3840);assert.equal(hero.text.length,0);
|
||||
const logo=surface.canvases.find(c=>c.width===1280);assert(logo.text.length);assert.equal(logo.draws,0);
|
||||
const before=surface.canvases.length;await ctx.renderLibraryArtwork({label:'No Icon',images:{}});
|
||||
assert.equal(surface.canvases.slice(before).find(c=>c.width===3840).text.length,0);
|
||||
console.log('five dimensions, textless hero, title logo: OK');
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
|
After Width: | Height: | Size: 834 B |
|
After Width: | Height: | Size: 77 B |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"home": "/home/steamos",
|
||||
"shortcuts": [
|
||||
{
|
||||
"appid": 3346865537,
|
||||
"name": "Open Saber Plus"
|
||||
}
|
||||
],
|
||||
"configure": {
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
Recorded 2026-09-28 from public publisher endpoints using FrameControl/0.1 or
|
||||
`gh api`. JSON fixtures are reduced to fields consumed by the adapters; API
|
||||
values are unchanged. No token, cookies or signed download URL is included.
|
||||
|
||||
- `*-releases.json`: `/repos/{repo}/releases?per_page=10`, first two releases,
|
||||
for KhronosGroup/OpenXR-SDK-Source, icosa-foundation/open-brush and
|
||||
SgtBilko76/SuperTux-3D (one release).
|
||||
- `topic.json`: `/search/repositories?q=topic:openxr+archived:false&sort=stars&per_page=3`.
|
||||
- `itch-feed.txt`: `https://itch.io/games/free/platform-android/tag-openxr.xml`.
|
||||
- `itch-robots.txt`: `https://itch.io/robots.txt`.
|
||||
- `itch-author-robots.txt`: `https://godotvr.itch.io/robots.txt`.
|
||||
|
||||
The synthetic ZIP in tests is only a transport/integrity fixture, not an
|
||||
installable APK. Actual APK parsing was verified separately on the downloaded
|
||||
Khronos Vulkan sample; see docs/apk-sources.md.
|
||||
|
||||
Artwork follow-up: `topic.json` now retains `owner.avatar_url` from authenticated
|
||||
repository API reads. `artwork-check.json` records HTTPS response status,
|
||||
Content-Type and image magic checks for all curated URLs and three topic
|
||||
results. All curated URLs returned real images; LWJGL's social preview returned
|
||||
HTTP 429. This fixture is evidence of a point-in-time check, not an uptime test.
|
||||
Open Brush screenshots came from the Steam appdetails response for app 1634870,
|
||||
linked by its README. SuperTux's README links the recorded upstream screenshot.
|
||||
@@ -0,0 +1,120 @@
|
||||
[
|
||||
{
|
||||
"url": "https://avatars.githubusercontent.com/u/2757344?v=4",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://avatars.githubusercontent.com/u/784805?v=4",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://avatars.githubusercontent.com/u/94376830?v=4",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://opengraph.githubassets.com/1/KhronosGroup/OpenXR-SDK-Source",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://opengraph.githubassets.com/1/LWJGL/lwjgl3",
|
||||
"error": "HTTP Error 429: Too Many Requests"
|
||||
},
|
||||
{
|
||||
"url": "https://opengraph.githubassets.com/1/bjornbytes/lovr",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://opengraph.githubassets.com/1/sahibzada-allahyar/YC-Killer",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/open-brush.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_0a9c208e26a43cf34879c2ca361d4c8f18af8cba.1920x1080.jpg",
|
||||
"status": 200,
|
||||
"content_type": "image/jpeg",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_19b25b86ef55c0d8769a65135d60eaae8fa40553.1920x1080.jpg",
|
||||
"status": 200,
|
||||
"content_type": "image/jpeg",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_785ea37d63378146dfe0f0ffa3f1d5c155ca978f.1920x1080.jpg",
|
||||
"status": 200,
|
||||
"content_type": "image/jpeg",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://www.supertux.org/images/0_7_0/github_preview.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://opengraph.githubassets.com/1/arpruss/OpenSaberPlus",
|
||||
"status": 200,
|
||||
"content_type": "image/png",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif",
|
||||
"status": 200,
|
||||
"content_type": "image/gif",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_2.gif",
|
||||
"status": 200,
|
||||
"content_type": "image/gif",
|
||||
"image": true
|
||||
},
|
||||
{
|
||||
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_3.gif",
|
||||
"status": 200,
|
||||
"content_type": "image/gif",
|
||||
"image": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
[
|
||||
{
|
||||
"tag_name": "2.32.29",
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"published_at": "2026-09-26T17:49:28Z",
|
||||
"assets": [
|
||||
{
|
||||
"id": 591143285,
|
||||
"name": "OpenBrush_Android_2.32.29.apk",
|
||||
"size": 314602794,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Android_2.32.29.apk",
|
||||
"digest": "sha256:f20361b830803a2bf53e2af648bba59ee17bc4615bde534dbf6c4f0012bbd291"
|
||||
},
|
||||
{
|
||||
"id": 591143287,
|
||||
"name": "OpenBrush_Desktop_2.32.29.zip",
|
||||
"size": 380735034,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Desktop_2.32.29.zip",
|
||||
"digest": "sha256:3b680b07ca0b8def579fe194916aa7db4d007c3094c4dea818124776098c9b9a"
|
||||
},
|
||||
{
|
||||
"id": 591143282,
|
||||
"name": "OpenBrush_Linux_2.32.29.zip",
|
||||
"size": 364906490,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Linux_2.32.29.zip",
|
||||
"digest": "sha256:e380934f77d2b1441179424193a75f7b4b5793b34761c04cbf2d87bad9f8fc16"
|
||||
},
|
||||
{
|
||||
"id": 591143283,
|
||||
"name": "OpenBrush_Mac_2.32.29.dmg",
|
||||
"size": 373196471,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Mac_2.32.29.dmg",
|
||||
"digest": "sha256:5d544d0a64bed1cae65173fcfa7ada069d4f81b42385e806e99cc4427ef3513c"
|
||||
},
|
||||
{
|
||||
"id": 591143286,
|
||||
"name": "OpenBrush_Quest_2.32.29.apk",
|
||||
"size": 314603546,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Quest_2.32.29.apk",
|
||||
"digest": "sha256:57cd7b9067689060451494e55dc06276f934a992f5cbbd44d965069903937ba6"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "2.32.28",
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"published_at": "2026-09-26T14:42:27Z",
|
||||
"assets": [
|
||||
{
|
||||
"id": 590837298,
|
||||
"name": "OpenBrush_Android_2.32.28.apk",
|
||||
"size": 314603450,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Android_2.32.28.apk",
|
||||
"digest": "sha256:1a3af1a194c4348ef67c8ea61d9a8258e2490cdfe1f760cbc3c69f2978a6a082"
|
||||
},
|
||||
{
|
||||
"id": 590837301,
|
||||
"name": "OpenBrush_Desktop_2.32.28.zip",
|
||||
"size": 380738236,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Desktop_2.32.28.zip",
|
||||
"digest": "sha256:c2de5424bc022951f0e0bdbd652ede549a1e60d9cfbf41c730ea43d16d97262f"
|
||||
},
|
||||
{
|
||||
"id": 590837297,
|
||||
"name": "OpenBrush_Linux_2.32.28.zip",
|
||||
"size": 364907046,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Linux_2.32.28.zip",
|
||||
"digest": "sha256:29daf410347b3ca36e47808e31172270df8040ba7decc83be2bdcd51de895e06"
|
||||
},
|
||||
{
|
||||
"id": 590837296,
|
||||
"name": "OpenBrush_Mac_2.32.28.dmg",
|
||||
"size": 373195966,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Mac_2.32.28.dmg",
|
||||
"digest": "sha256:2f352d766450c993fdcae8a8552878a6a976d32d675246b63c81bb1b326fd20d"
|
||||
},
|
||||
{
|
||||
"id": 590837295,
|
||||
"name": "OpenBrush_Quest_2.32.28.apk",
|
||||
"size": 314604234,
|
||||
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Quest_2.32.28.apk",
|
||||
"digest": "sha256:9a3af6a6e838dd8bd201e549c5570f67db794df940e960390aeeae93235d702e"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
User-agent: Mediapartners-Google
|
||||
Disallow:
|
||||
|
||||
User-agent: *
|
||||
Disallow: /*/download/
|
||||
Disallow: /*/rh/
|
||||
Disallow: /*/rp/
|
||||
Disallow: /-/
|
||||
|
||||
Sitemap: https://itch.io/sitemap.xml
|
||||
|
||||
# vim: set ft=robots:
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?><rss version="2.0"><channel><title>Top free games for Android tagged openxr - itch.io</title><link>https://itch.io/games/free/platform-android/tag-openxr</link><item><guid>https://leandrodreamer.itch.io/open-saber</guid><title>Open Saber [Free] [Rhythm] [Windows] [Linux] [Android]</title><plainTitle>Open Saber</plainTitle><imageurl>https://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif</imageurl><price>$0.00</price><currency>USD</currency><link>https://leandrodreamer.itch.io/open-saber</link><description><![CDATA[Open Source Rythmic Block Cutting Game :)
|
||||
<img src="https://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif" alt="Open Saber"/>]]></description><pubDate>Thu, 07 Sep 2023 02:11:50 GMT</pubDate><createDate>Thu, 07 Sep 2023 02:11:50 GMT</createDate><updateDate>Wed, 08 Jan 2025 02:24:29 GMT</updateDate><platforms><html>yes</html></platforms></item><item><guid>https://absyo.itch.io/off-nominal</guid><title>Off Nominal [Free] [Puzzle] [Windows] [Linux] [Android]</title><plainTitle>Off Nominal</plainTitle><imageurl>https://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://absyo.itch.io/off-nominal</link><description><![CDATA[Adrift in space. Fix the ship. Return home.
|
||||
<img src="https://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png" alt="Off Nominal"/>]]></description><pubDate>Fri, 25 Sep 2026 19:54:48 GMT</pubDate><createDate>Fri, 25 Sep 2026 19:54:48 GMT</createDate><updateDate>Sun, 27 Sep 2026 23:25:20 GMT</updateDate><platforms><windows>yes</windows><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://somar-project.itch.io/somar-project</guid><title>Somar-project [Free] [Educational] [Android]</title><plainTitle>Somar-project</plainTitle><imageurl>https://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://somar-project.itch.io/somar-project</link><description><![CDATA[Open-Source OpenXR application for raising awareness about negative impacts of underwater noise pollution on marine life
|
||||
<img src="https://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png" alt="Somar-project"/>]]></description><pubDate>Wed, 26 Mar 2025 17:17:58 GMT</pubDate><createDate>Wed, 26 Mar 2025 17:17:58 GMT</createDate><updateDate>Fri, 28 Mar 2025 15:52:59 GMT</updateDate><platforms><android>yes</android></platforms></item><item><guid>https://5imon.itch.io/buggenesis</guid><title>Bug Genesis VR [Free] [Interactive Fiction] [Windows] [Android]</title><plainTitle>Bug Genesis VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://5imon.itch.io/buggenesis</link><description><![CDATA[Use the bug generator to populate the planet
|
||||
<img src="https://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg" alt="Bug Genesis VR"/>]]></description><pubDate>Sun, 25 May 2025 20:22:49 GMT</pubDate><createDate>Sun, 25 May 2025 20:22:49 GMT</createDate><updateDate>Sun, 25 May 2025 20:37:39 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item><item><guid>https://benmclean.itch.io/wolfsharp</guid><title>WolfSharp [Free] [Shooter] [Windows] [Linux] [Android]</title><plainTitle>WolfSharp</plainTitle><imageurl>https://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://benmclean.itch.io/wolfsharp</link><description><![CDATA[Wolfenstein 3-D for VR
|
||||
<img src="https://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png" alt="WolfSharp"/>]]></description><pubDate>Sat, 25 Jul 2026 12:16:01 GMT</pubDate><createDate>Sat, 25 Jul 2026 12:16:01 GMT</createDate><updateDate>Sat, 25 Jul 2026 13:45:38 GMT</updateDate><platforms><windows>yes</windows><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://mimekunst.itch.io/winter-solitude-vr</guid><title>Winter Solitude VR [Free] [Simulation] [Windows] [macOS] [Linux] [Android]</title><plainTitle>Winter Solitude VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://mimekunst.itch.io/winter-solitude-vr</link><description><![CDATA[Step into the Frozen Abyss: FREE VR Game Experience
|
||||
<img src="https://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png" alt="Winter Solitude VR"/>]]></description><pubDate>Tue, 19 Dec 2023 19:19:59 GMT</pubDate><createDate>Tue, 19 Dec 2023 19:19:59 GMT</createDate><updateDate>Wed, 20 Dec 2023 22:49:23 GMT</updateDate><platforms><windows>yes</windows><osx>yes</osx><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://salmondev.itch.io/evil-miner-vr</guid><title>EVIL MINER VR [Free] [Other] [Windows] [Android]</title><plainTitle>EVIL MINER VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://salmondev.itch.io/evil-miner-vr</link><description><![CDATA[
|
||||
<img src="https://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png" alt="EVIL MINER VR"/>]]></description><pubDate>Fri, 13 Jun 2025 16:48:01 GMT</pubDate><createDate>Fri, 13 Jun 2025 16:48:01 GMT</createDate><updateDate>Sun, 15 Jun 2025 15:19:53 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item><item><guid>https://robinhuud.itch.io/winter-challenge-north-pole-defense</guid><title>North Pole Defense VR [Free] [Action] [Android]</title><plainTitle>North Pole Defense VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://robinhuud.itch.io/winter-challenge-north-pole-defense</link><description><![CDATA[Defend the north pole against giant snowmen using VR snowballs
|
||||
<img src="https://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png" alt="North Pole Defense VR"/>]]></description><pubDate>Thu, 16 Dec 2021 01:33:33 GMT</pubDate><createDate>Thu, 16 Dec 2021 01:33:33 GMT</createDate><updateDate>Thu, 16 Dec 2021 01:51:29 GMT</updateDate><platforms><android>yes</android></platforms></item><item><guid>https://envemos.itch.io/ambly-native-xr</guid><title>Ambly Native XR [Free] [Linux] [Android]</title><plainTitle>Ambly Native XR</plainTitle><imageurl>https://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://envemos.itch.io/ambly-native-xr</link><description><![CDATA[OpenXR games for Meta Quest, PICO and Linux.
|
||||
<img src="https://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg" alt="Ambly Native XR"/>]]></description><pubDate>Wed, 22 Jul 2026 18:38:55 GMT</pubDate><createDate>Wed, 22 Jul 2026 18:38:55 GMT</createDate><updateDate>Fri, 07 Aug 2026 16:04:20 GMT</updateDate><platforms><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://andyman404.itch.io/glow-up-garden</guid><title>Glow Up Garden (VR) [Free] [Action] [Windows] [Android]</title><plainTitle>Glow Up Garden (VR)</plainTitle><imageurl>https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://andyman404.itch.io/glow-up-garden</link><description><![CDATA[Speak positive affirmations out loud to grow your garden (voice-driven VR game)
|
||||
<img src="https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg" alt="Glow Up Garden (VR)"/>]]></description><pubDate>Mon, 03 Jun 2024 20:36:53 GMT</pubDate><createDate>Mon, 03 Jun 2024 20:36:53 GMT</createDate><updateDate>Tue, 04 Jun 2024 04:20:37 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item></channel></rss>
|
||||
@@ -0,0 +1,11 @@
|
||||
User-agent: *
|
||||
Disallow: /embed/
|
||||
Disallow: /embed-upload/
|
||||
Disallow: /search
|
||||
Disallow: /checkout/
|
||||
Disallow: /game/download/
|
||||
Disallow: /bundle/download/
|
||||
Disallow: /register-for-purchase/
|
||||
Disallow: /email-feedback/
|
||||
|
||||
Sitemap: https://itch.io/sitemap.xml
|
||||
@@ -0,0 +1,410 @@
|
||||
[
|
||||
{
|
||||
"tag_name": "release-1.1.63",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"published_at": "2026-09-02T21:22:32Z",
|
||||
"assets": [
|
||||
{
|
||||
"id": 541779948,
|
||||
"name": "apilayer_api_dump-1.1.63.aar",
|
||||
"size": 5120886,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.aar",
|
||||
"digest": "sha256:9cab975cc8df3a99f6530f47a1fbabfa0527109a138f5a3ef5150672a658c61c"
|
||||
},
|
||||
{
|
||||
"id": 541779969,
|
||||
"name": "apilayer_api_dump-1.1.63.aar.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.aar.asc",
|
||||
"digest": "sha256:419ec65d3f526c617176f272d8a481488181ade017cb05ef42205cb2c5a86f05"
|
||||
},
|
||||
{
|
||||
"id": 541779983,
|
||||
"name": "apilayer_api_dump-1.1.63.pom",
|
||||
"size": 1462,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.pom",
|
||||
"digest": "sha256:9a5b3e470830ae471fe317bc63f43b33bc063458239238fe27e234232c45ff28"
|
||||
},
|
||||
{
|
||||
"id": 541780002,
|
||||
"name": "apilayer_api_dump-1.1.63.pom.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.pom.asc",
|
||||
"digest": "sha256:7cbf9f1af504ca68fc36e0985dadb74d1fbf4d2bbdcde3e00bfe9ea6168fc4a8"
|
||||
},
|
||||
{
|
||||
"id": 541780126,
|
||||
"name": "apilayer_best_practices_validation-1.1.63.aar",
|
||||
"size": 484596,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.aar",
|
||||
"digest": "sha256:0c56cb3dc0b093b28f4e5490820d3cb3f7b201b48444134f347455d4ee99abd2"
|
||||
},
|
||||
{
|
||||
"id": 541780150,
|
||||
"name": "apilayer_best_practices_validation-1.1.63.aar.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.aar.asc",
|
||||
"digest": "sha256:7eb9ab3efe939c367e4661d5a75836c1d9e0799ab627e99211253546935defa7"
|
||||
},
|
||||
{
|
||||
"id": 541780162,
|
||||
"name": "apilayer_best_practices_validation-1.1.63.pom",
|
||||
"size": 1487,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.pom",
|
||||
"digest": "sha256:97d410936f5f051ab2a73cdac196c744ac56b2dde6279a5e46e944ed61316147"
|
||||
},
|
||||
{
|
||||
"id": 541780192,
|
||||
"name": "apilayer_best_practices_validation-1.1.63.pom.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.pom.asc",
|
||||
"digest": "sha256:7f9fa0cd33627c4ecc2a4410e53dedadb5731b3cddae59a3c0d4fcd467b3472d"
|
||||
},
|
||||
{
|
||||
"id": 541780025,
|
||||
"name": "apilayer_core_validation-1.1.63.aar",
|
||||
"size": 6386964,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.aar",
|
||||
"digest": "sha256:9663ce94a5076b6707502cf5503bac456987ccf1f39a3f7c8f350d4521db8647"
|
||||
},
|
||||
{
|
||||
"id": 541780057,
|
||||
"name": "apilayer_core_validation-1.1.63.aar.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.aar.asc",
|
||||
"digest": "sha256:c6e75df848ca6d436fe24f680bde73a9e69972b67eef46e49aba4b77dec366e9"
|
||||
},
|
||||
{
|
||||
"id": 541780090,
|
||||
"name": "apilayer_core_validation-1.1.63.pom",
|
||||
"size": 1455,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.pom",
|
||||
"digest": "sha256:6aa9337f5e645baf489c05e562cd074dc696bad87db70a0cdd661dffc63b2c89"
|
||||
},
|
||||
{
|
||||
"id": 541780108,
|
||||
"name": "apilayer_core_validation-1.1.63.pom.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.pom.asc",
|
||||
"digest": "sha256:0fe8ded9fdf0660bf28a544ae405b9b58682a8d9648dacedf4e4ceef2f827869"
|
||||
},
|
||||
{
|
||||
"id": 541777706,
|
||||
"name": "hello_xr-OpenGLES-release-1.1.63.apk",
|
||||
"size": 9557049,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/hello_xr-OpenGLES-release-1.1.63.apk",
|
||||
"digest": "sha256:7c96022ac002cb0c72e3cd14c11a817767b7b5dbdf5a1d1c85d0c083b5719056"
|
||||
},
|
||||
{
|
||||
"id": 541777527,
|
||||
"name": "hello_xr-Vulkan-release-1.1.63.apk",
|
||||
"size": 9557441,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/hello_xr-Vulkan-release-1.1.63.apk",
|
||||
"digest": "sha256:f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34"
|
||||
},
|
||||
{
|
||||
"id": 541800362,
|
||||
"name": "OpenXR-SDK-Source-release-1.1.63.tar.gz",
|
||||
"size": 4857593,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR-SDK-Source-release-1.1.63.tar.gz",
|
||||
"digest": "sha256:a3b97a36f11abe256a7ea1668a0a468aac9b738e94bea6b468f0ae31ad537a46"
|
||||
},
|
||||
{
|
||||
"id": 541800378,
|
||||
"name": "OpenXR-SDK-Source-release-1.1.63.tar.gz.asc",
|
||||
"size": 870,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR-SDK-Source-release-1.1.63.tar.gz.asc",
|
||||
"digest": "sha256:4f97028306ae219f599e9960adbf9bb072e8da2bfbedffd1f0de312516a61f87"
|
||||
},
|
||||
{
|
||||
"id": 541821806,
|
||||
"name": "OpenXR.Loader.1.1.63.nupkg",
|
||||
"size": 1916162,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR.Loader.1.1.63.nupkg",
|
||||
"digest": "sha256:4e5a50a8807ef66f25180ff224e7d8150b594aa8ee4b07590f9ade55a8e98703"
|
||||
},
|
||||
{
|
||||
"id": 541821827,
|
||||
"name": "OpenXR.Loader.1.1.63.nupkg.asc",
|
||||
"size": 870,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR.Loader.1.1.63.nupkg.asc",
|
||||
"digest": "sha256:9d66a6e958f7c2d5c4a0a4aef8df90a7beecab13547440c9fa2069979eab7ac7"
|
||||
},
|
||||
{
|
||||
"id": 541779892,
|
||||
"name": "openxr_loader_for_android-1.1.63-sources.jar",
|
||||
"size": 1141287,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63-sources.jar",
|
||||
"digest": "sha256:6f964ad09c4afa3f42f451cada86e61503392b018660b22dd34b61dfbf71a555"
|
||||
},
|
||||
{
|
||||
"id": 541779920,
|
||||
"name": "openxr_loader_for_android-1.1.63-sources.jar.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63-sources.jar.asc",
|
||||
"digest": "sha256:b98cba20f5c3b202b887307cb19196f4459f895413e55b68823a606f50d045fa"
|
||||
},
|
||||
{
|
||||
"id": 541779720,
|
||||
"name": "openxr_loader_for_android-1.1.63.aar",
|
||||
"size": 4170279,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.aar",
|
||||
"digest": "sha256:622419d2f6741c3443a3beb4779af0764318edd01830de967f24c741ebcded73"
|
||||
},
|
||||
{
|
||||
"id": 541779762,
|
||||
"name": "openxr_loader_for_android-1.1.63.aar.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.aar.asc",
|
||||
"digest": "sha256:3bb68b26d7def68b4fe8506bf09f302116290c2de9cf91fdfdba753978bff5ed"
|
||||
},
|
||||
{
|
||||
"id": 541779849,
|
||||
"name": "openxr_loader_for_android-1.1.63.pom",
|
||||
"size": 1598,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.pom",
|
||||
"digest": "sha256:c98f38fa8acf4cf1bd8bcb40774f9815ae6ed9da94c8a7be2ed9db7815c85404"
|
||||
},
|
||||
{
|
||||
"id": 541779867,
|
||||
"name": "openxr_loader_for_android-1.1.63.pom.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.pom.asc",
|
||||
"digest": "sha256:1c2625f5b889c7ed967c8a6010294ca94bbd96091d879b2fc989c6f40f9a6af1"
|
||||
},
|
||||
{
|
||||
"id": 541793000,
|
||||
"name": "openxr_loader_macos-1.1.63.zip",
|
||||
"size": 826298,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_macos-1.1.63.zip",
|
||||
"digest": "sha256:b243eebcdfa8683d17ccc8cfbfb9036be94b3f5a22b3eb73c39da0877694a3ab"
|
||||
},
|
||||
{
|
||||
"id": 541793027,
|
||||
"name": "openxr_loader_macos-1.1.63.zip.asc",
|
||||
"size": 870,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_macos-1.1.63.zip.asc",
|
||||
"digest": "sha256:3e95172aabc4bd2537a7125060abbb8efbdd0cee19bdf1bf30cbd9736b7dcbd2"
|
||||
},
|
||||
{
|
||||
"id": 541784717,
|
||||
"name": "openxr_loader_windows-1.1.63.zip",
|
||||
"size": 31961521,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_windows-1.1.63.zip",
|
||||
"digest": "sha256:01c631aeabbfe0879540f77ef833416c532a20746285b494630160c23588b771"
|
||||
},
|
||||
{
|
||||
"id": 541784760,
|
||||
"name": "openxr_loader_windows-1.1.63.zip.asc",
|
||||
"size": 870,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_windows-1.1.63.zip.asc",
|
||||
"digest": "sha256:e9384e2a94d82c5059d1d83c57d0da585056d95e393255048b0955b0ce69b3fc"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "release-1.1.62",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"published_at": "2026-08-01T01:32:30Z",
|
||||
"assets": [
|
||||
{
|
||||
"id": 500510340,
|
||||
"name": "apilayer_api_dump-1.1.62.aar",
|
||||
"size": 4800443,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.aar",
|
||||
"digest": "sha256:2a7c2d1bb14d94dfed8c1aaf170a9dda649756477fbcba4a143c76d08a50bed8"
|
||||
},
|
||||
{
|
||||
"id": 500510366,
|
||||
"name": "apilayer_api_dump-1.1.62.aar.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.aar.asc",
|
||||
"digest": "sha256:7ab53e3c1fcaabf49561737fe8ed5df9ad9a5a761615f05e1d5eed2799e85c5f"
|
||||
},
|
||||
{
|
||||
"id": 500510378,
|
||||
"name": "apilayer_api_dump-1.1.62.pom",
|
||||
"size": 1462,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.pom",
|
||||
"digest": "sha256:fcd4358d7582ce0787b96aacb9840afc086a28499767a6aa7491dbb682bcc921"
|
||||
},
|
||||
{
|
||||
"id": 500510385,
|
||||
"name": "apilayer_api_dump-1.1.62.pom.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.pom.asc",
|
||||
"digest": "sha256:4832ce5c8835d1880ae5adbc535b774d50f8c86f9870650a50fbf3b9993ec5fa"
|
||||
},
|
||||
{
|
||||
"id": 500510464,
|
||||
"name": "apilayer_best_practices_validation-1.1.62.aar",
|
||||
"size": 482607,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.aar",
|
||||
"digest": "sha256:6d1a369ba367049aff23ae554b284ccc17cb3be8832869de0c1d151228a26502"
|
||||
},
|
||||
{
|
||||
"id": 500510477,
|
||||
"name": "apilayer_best_practices_validation-1.1.62.aar.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.aar.asc",
|
||||
"digest": "sha256:658ecbb7f871e97ed0d659ed55b27ca6ff6cc6e1853699498ee7b1d5583d7313"
|
||||
},
|
||||
{
|
||||
"id": 500510480,
|
||||
"name": "apilayer_best_practices_validation-1.1.62.pom",
|
||||
"size": 1487,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.pom",
|
||||
"digest": "sha256:571d7c5587075e83ca0a4d2382d87515de614ab8c34416a82a1b9b1a7eb5f9c0"
|
||||
},
|
||||
{
|
||||
"id": 500510489,
|
||||
"name": "apilayer_best_practices_validation-1.1.62.pom.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.pom.asc",
|
||||
"digest": "sha256:342d0ed7080e92399dbc8648df4fe9378086718f1abc73f79f3a52de211ed36e"
|
||||
},
|
||||
{
|
||||
"id": 500510395,
|
||||
"name": "apilayer_core_validation-1.1.62.aar",
|
||||
"size": 5910024,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.aar",
|
||||
"digest": "sha256:5692ccd5563a0963c4d842614af11d7c280960687a221643a46266ef968c4d70"
|
||||
},
|
||||
{
|
||||
"id": 500510422,
|
||||
"name": "apilayer_core_validation-1.1.62.aar.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.aar.asc",
|
||||
"digest": "sha256:1e39ff48d4cd87231d931515968317c717a6811da84585650f0623c49329ec41"
|
||||
},
|
||||
{
|
||||
"id": 500510432,
|
||||
"name": "apilayer_core_validation-1.1.62.pom",
|
||||
"size": 1455,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.pom",
|
||||
"digest": "sha256:1917e5cee9b979c9032c7819c386ea62e4498c30ffbbcf0c25578ebcd0c1e441"
|
||||
},
|
||||
{
|
||||
"id": 500510453,
|
||||
"name": "apilayer_core_validation-1.1.62.pom.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.pom.asc",
|
||||
"digest": "sha256:8aed84009daa5e388b7d179ab90837e9537b4f762a1676aab922e03dc633ed18"
|
||||
},
|
||||
{
|
||||
"id": 497398718,
|
||||
"name": "hello_xr-OpenGLES-release-1.1.62.apk",
|
||||
"size": 9548745,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/hello_xr-OpenGLES-release-1.1.62.apk",
|
||||
"digest": "sha256:da5e421795b801684cab50156c572422b73cd98fc8c75aeeb968962b43a2ab46"
|
||||
},
|
||||
{
|
||||
"id": 497398704,
|
||||
"name": "hello_xr-Vulkan-release-1.1.62.apk",
|
||||
"size": 9549137,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/hello_xr-Vulkan-release-1.1.62.apk",
|
||||
"digest": "sha256:a154b2353983f8c0cb1827ddf51d7e80fc105085253fe524502f35c5ddac3284"
|
||||
},
|
||||
{
|
||||
"id": 500514717,
|
||||
"name": "OpenXR-SDK-Source-release-1.1.62.tar.gz",
|
||||
"size": 4834887,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR-SDK-Source-release-1.1.62.tar.gz",
|
||||
"digest": "sha256:977073d7f4c0d1af8ab975f57e4b6ffd1c4e9209be66075812b890576e0e1e5f"
|
||||
},
|
||||
{
|
||||
"id": 500514735,
|
||||
"name": "OpenXR-SDK-Source-release-1.1.62.tar.gz.asc",
|
||||
"size": 870,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR-SDK-Source-release-1.1.62.tar.gz.asc",
|
||||
"digest": "sha256:3ea4d6e47da6a8b3480931636af3e85eb2e0cddaf582153ee97973a8b05a5214"
|
||||
},
|
||||
{
|
||||
"id": 500514501,
|
||||
"name": "OpenXR.Loader.1.1.62.nupkg",
|
||||
"size": 1902954,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR.Loader.1.1.62.nupkg",
|
||||
"digest": "sha256:6bb16b4dbe3c11f29605def2def5b7d1177784c0403dc9a24bc2e13d7eb82604"
|
||||
},
|
||||
{
|
||||
"id": 500514512,
|
||||
"name": "OpenXR.Loader.1.1.62.nupkg.asc",
|
||||
"size": 870,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR.Loader.1.1.62.nupkg.asc",
|
||||
"digest": "sha256:386dfd33e9c2db9c9c37d881b77eec9b74d181fda394b47c473b2bce37fd7005"
|
||||
},
|
||||
{
|
||||
"id": 500510319,
|
||||
"name": "openxr_loader_for_android-1.1.62-sources.jar",
|
||||
"size": 1110593,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62-sources.jar",
|
||||
"digest": "sha256:b83318394b30bb129b069dd854de2b1e21df4376dda1a7fa342aeaff17a71d3d"
|
||||
},
|
||||
{
|
||||
"id": 500510327,
|
||||
"name": "openxr_loader_for_android-1.1.62-sources.jar.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62-sources.jar.asc",
|
||||
"digest": "sha256:838b5f5a23329eb7f0e13afde7a7d6ae73b439c7cbf6d3ec0af3e65efd7d5bd6"
|
||||
},
|
||||
{
|
||||
"id": 500510263,
|
||||
"name": "openxr_loader_for_android-1.1.62.aar",
|
||||
"size": 4158815,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.aar",
|
||||
"digest": "sha256:c03c689fed9a48f9394af953660982c998b00f6d2d2d8d150bc3f890c75a7465"
|
||||
},
|
||||
{
|
||||
"id": 500510280,
|
||||
"name": "openxr_loader_for_android-1.1.62.aar.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.aar.asc",
|
||||
"digest": "sha256:883ccab775776c65ee35bf3120553f6a3f0f47de2dd661e074469e98d3caea46"
|
||||
},
|
||||
{
|
||||
"id": 500510292,
|
||||
"name": "openxr_loader_for_android-1.1.62.pom",
|
||||
"size": 1598,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.pom",
|
||||
"digest": "sha256:9b1047158a416984fd6d60c472da09bb324aec74709f83a1516435917fa05064"
|
||||
},
|
||||
{
|
||||
"id": 500510305,
|
||||
"name": "openxr_loader_for_android-1.1.62.pom.asc",
|
||||
"size": 215,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.pom.asc",
|
||||
"digest": "sha256:9dd7d3f79ad76a48f709f55d8c205892ae30f70b10a44c4afbd51f50603c4478"
|
||||
},
|
||||
{
|
||||
"id": 500514048,
|
||||
"name": "openxr_loader_macos-1.1.62.zip",
|
||||
"size": 817438,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_macos-1.1.62.zip",
|
||||
"digest": "sha256:400bf9ab932d04cf8a315fe8e63d5cd9824015b64ad2e5d72b2ffc086c54313c"
|
||||
},
|
||||
{
|
||||
"id": 500514061,
|
||||
"name": "openxr_loader_macos-1.1.62.zip.asc",
|
||||
"size": 870,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_macos-1.1.62.zip.asc",
|
||||
"digest": "sha256:034a3575bbee545926dc59558aa5d62ea9fc2de9e23c426ac640cbb68bd8db88"
|
||||
},
|
||||
{
|
||||
"id": 500513308,
|
||||
"name": "openxr_loader_windows-1.1.62.zip",
|
||||
"size": 30975472,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_windows-1.1.62.zip",
|
||||
"digest": "sha256:800ec772e2f9448a26ab9f579f4914d984346dd9d0d7c007841abe21d2c8ff2f"
|
||||
},
|
||||
{
|
||||
"id": 500513351,
|
||||
"name": "openxr_loader_windows-1.1.62.zip.asc",
|
||||
"size": 870,
|
||||
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_windows-1.1.62.zip.asc",
|
||||
"digest": "sha256:8117563bfc5092895e17112366cb954ca78f84964e5c09526dfd69656c1713fd"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"full_name": "LWJGL/lwjgl3",
|
||||
"name": "lwjgl3",
|
||||
"description": "LWJGL is a Java library that enables cross-platform access to popular native APIs useful in the development of graphics (OpenGL, Vulkan, bgfx), audio (OpenAL, Opus), parallel computing (OpenCL, CUDA) and XR (OpenVR, LibOVR, OpenXR) applications.",
|
||||
"owner": {
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2757344?v=4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"full_name": "sahibzada-allahyar/YC-Killer",
|
||||
"name": "YC-Killer",
|
||||
"description": "A library of enterprise-grade AI agents designed to democratize artificial intelligence and provide free, open-source alternatives to overvalued Y Combinator startups.",
|
||||
"owner": {
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/94376830?v=4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"full_name": "bjornbytes/lovr",
|
||||
"name": "lovr",
|
||||
"description": "Lua Virtual Reality Framework",
|
||||
"owner": {
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/784805?v=4"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"tag_name": "Beta0.2",
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"published_at": "2026-09-23T14:51:47Z",
|
||||
"assets": [
|
||||
{
|
||||
"id": 583977968,
|
||||
"name": "SuperTux-Beta0.2-quest-pico-arm64-v8a.apk",
|
||||
"size": 294152462,
|
||||
"browser_download_url": "https://github.com/SgtBilko76/SuperTux-3D/releases/download/Beta0.2/SuperTux-Beta0.2-quest-pico-arm64-v8a.apk",
|
||||
"digest": "sha256:63287e5d6f1866193e0d4730bf4a2ba87fd2fbdbe6317bd6bd24b96a8ddc9963"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"recorded": "2026-09-28",
|
||||
"robots": {
|
||||
"url": "https://sidequestvr.com/robots.txt",
|
||||
"user_agent": "*",
|
||||
"crawl_delay": 3,
|
||||
"disallow": ["/search/", "/user/*", "/sideload/*"],
|
||||
"sitemap": "https://sidequestvr.com/sitemap_index.xml"
|
||||
},
|
||||
"api_robots": {"url": "https://api.sidequestvr.com/robots.txt", "status": 403},
|
||||
"terms": {
|
||||
"url": "https://sidequestvr.com/terms",
|
||||
"bundle": "https://sidequestvr.com/main-4MMXZRXL.js",
|
||||
"prohibited_activities_i": "copy, distribute, or disclose any part of the Service in any medium, including without limitation by any automated or non-automated scraping",
|
||||
"prohibited_activities_xi": "access any content on the Service through any technology or means other than those provided or authorized by the Service"
|
||||
},
|
||||
"note": "Policy evidence, not a fabricated API response. No app metadata or download fixture was collected after discovering the restriction."
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
|
||||
|
||||
class ArtworkSettings(unittest.TestCase):
|
||||
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
||||
|
||||
def test_settings_need_and_accept_the_ui_key(self):
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
port = free_port()
|
||||
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
|
||||
"HOME": home, "APPDATA": home, "XDG_DATA_HOME": home}
|
||||
for name in ("STEAMGRIDDB_API_KEY", "FRAME_STEAMGRIDDB_API_KEY", "FRAME_UI_KEY"):
|
||||
env.pop(name, None)
|
||||
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(port)],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
def request(method, path, body=None, headers=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
|
||||
headers=headers or {})
|
||||
r = conn.getresponse()
|
||||
payload = r.read()
|
||||
conn.close()
|
||||
return r.status, payload
|
||||
for _ in range(100):
|
||||
try:
|
||||
if request("GET", "/")[0] == 200:
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
|
||||
self.assertEqual(request("GET", "/api/settings/artwork")[0], 403)
|
||||
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403)
|
||||
status, payload = request("GET", "/api/settings/artwork", headers=key)
|
||||
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
|
||||
status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key)
|
||||
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, True))
|
||||
self.assertNotIn(b"abc_1", payload)
|
||||
status, payload = request("GET", "/api/settings/artwork", headers=key)
|
||||
self.assertTrue(json.loads(payload)["steamgriddb_configured"])
|
||||
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "a b"}, key)[0], 400)
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=10)
|
||||
|
||||
def test_panel_script_uses_the_keyed_api_helper(self):
|
||||
script = (ROOT / "ui" / "artwork-settings.js").read_text(encoding="utf-8")
|
||||
self.assertNotIn("fetch(", script)
|
||||
self.assertIn("api('/api/settings/artwork'", script)
|
||||
page = (ROOT / "ui" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
|
||||
class LocalMode(unittest.TestCase):
|
||||
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
from apk_sources import SourceError, sidequest
|
||||
|
||||
|
||||
class SideQuestTests(unittest.TestCase):
|
||||
def test_policy_is_recorded_and_source_is_page_only(self):
|
||||
fixture = json.loads((Path(__file__).parent / 'fixtures/sidequest-policy.json').read_text())
|
||||
self.assertIn('/search/', fixture['robots']['disallow'])
|
||||
self.assertIn('scraping', fixture['terms']['prohibited_activities_i'])
|
||||
self.assertTrue(sidequest.sources()[0]['page_only'])
|
||||
|
||||
@patch('urllib.request.urlopen', side_effect=AssertionError('network forbidden'))
|
||||
def test_unknown_listing_never_claims_free_or_downloadable(self, _urlopen):
|
||||
source = sidequest.sources()[0]
|
||||
entry = sidequest.details(source, '123')
|
||||
self.assertEqual(entry['page'], 'https://sidequestvr.com/app/123')
|
||||
self.assertFalse(entry['downloadable'])
|
||||
self.assertIsNone(entry['free'])
|
||||
self.assertIsNone(entry['vr'])
|
||||
self.assertEqual(entry['images']['screenshots'], [])
|
||||
with self.assertRaisesRegex(SourceError, 'page-only'):
|
||||
sidequest.download(source, '123')
|
||||
self.assertEqual(sidequest.search(source, 'open saber'), [])
|
||||
self.assertEqual(sidequest.search(source, 'open saber', 0), [])
|
||||
|
||||
def test_invalid_ids(self):
|
||||
for value in ('../123', '1?paid=false', '1', '', '1/2', '1' * 13):
|
||||
with self.assertRaises(SourceError):
|
||||
sidequest.details(sidequest.sources()[0], value)
|
||||
@@ -43,3 +43,11 @@ Rules
|
||||
|
||||
class SourceError(Exception):
|
||||
"""User-readable failure from a source (network, format, verification)."""
|
||||
|
||||
|
||||
class SourceLimited(SourceError):
|
||||
"""The source's host asked us to slow down; retry_after is in seconds."""
|
||||
|
||||
def __init__(self, message, retry_after=None):
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Opt-in local store fixtures: FRAME_APK_SEARCH_DEMO=1. Never downloads."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from apk_sources import SourceError
|
||||
|
||||
KIND = 'demo'
|
||||
FIXTURES = Path(__file__).resolve().parents[2] / 'tests' / 'fixtures' / 'apk-search'
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': 'demo-' + key, 'kind': KIND, 'name': name, 'enabled': True,
|
||||
'builtin': True, 'trust': trust, 'url': 'https://example.invalid'}
|
||||
for key, name, trust in [('github', 'GitHub', 'official'), ('fdroid', 'F-Droid', 'community'),
|
||||
('sidequest', 'SideQuest', 'official'), ('itch', 'itch.io', 'community')]]
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
if source['id'] == 'demo-itch':
|
||||
raise SourceError('Source temporarily unavailable')
|
||||
entries = json.loads((FIXTURES / 'store.json').read_text())
|
||||
if source['id'] == 'demo-sidequest':
|
||||
entries = [e for e in entries if e['vr']]
|
||||
if source['id'] == 'demo-fdroid':
|
||||
entries = [e for e in entries if not e['vr']]
|
||||
return [dict(e, verified=source['id'] in ('demo-github', 'demo-fdroid')) for e in entries
|
||||
if query.lower() in (e['name'] + ' ' + e['summary']).lower()][:limit]
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
return next(e for e in search(source, '') if e['id'] == entry_id)
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
raise SourceError('Preview sources cannot download or install apps')
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Bounded artwork cache. Only source-provided URLs get opaque image handles."""
|
||||
from collections import OrderedDict
|
||||
import http.client
|
||||
import ipaddress
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from apk_sources import SourceError
|
||||
|
||||
_lock = threading.Lock()
|
||||
_urls = OrderedDict()
|
||||
_cache = OrderedDict()
|
||||
MAX_IMAGE = 8 * 1024 * 1024
|
||||
MAX_CACHE = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def valid_url(url):
|
||||
try:
|
||||
p = urlsplit(url)
|
||||
return (p.scheme in ('https', 'http') and bool(p.hostname) and not p.username and not p.password
|
||||
and p.port in (None, 80, 443) and len(url) <= 4096)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def register(url):
|
||||
if not isinstance(url, str) or not valid_url(url):
|
||||
return None
|
||||
with _lock:
|
||||
for token, known in _urls.items():
|
||||
if known == url:
|
||||
_urls.move_to_end(token)
|
||||
return '/source-image/' + token
|
||||
token = secrets.token_urlsafe(24)
|
||||
_urls[token] = url
|
||||
while len(_urls) > 4096:
|
||||
_urls.popitem(last=False)
|
||||
return '/source-image/' + token
|
||||
|
||||
|
||||
def artwork(entry):
|
||||
images = entry.get('images') or {}
|
||||
if not isinstance(images, dict):
|
||||
images = {}
|
||||
return {'icon': register(images.get('icon') or entry.get('icon')),
|
||||
'banner': register(images.get('banner')),
|
||||
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]}
|
||||
|
||||
|
||||
def image_type(data):
|
||||
if data.startswith(b'\x89PNG\r\n\x1a\n'):
|
||||
return 'image/png'
|
||||
if data.startswith(b'\xff\xd8\xff'):
|
||||
return 'image/jpeg'
|
||||
if data.startswith((b'GIF87a', b'GIF89a')):
|
||||
return 'image/gif'
|
||||
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
||||
return 'image/webp'
|
||||
raise SourceError('Artwork is not a supported image')
|
||||
|
||||
|
||||
def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
|
||||
"""deadline: time.monotonic() value by which the whole fetch, redirects included, must finish."""
|
||||
data = get(url, {'Accept': 'image/png,image/jpeg,image/webp,image/gif'}, redirects, deadline, limit)
|
||||
return data, image_type(data)
|
||||
|
||||
|
||||
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
|
||||
_resolvers = threading.BoundedSemaphore(4)
|
||||
|
||||
|
||||
def _resolve(host, port, timeout):
|
||||
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
|
||||
if not _resolvers.acquire(blocking=False):
|
||||
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
|
||||
found = {}
|
||||
|
||||
def run():
|
||||
try:
|
||||
found['addresses'] = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
except OSError as e:
|
||||
found['error'] = e
|
||||
finally:
|
||||
_resolvers.release()
|
||||
try:
|
||||
worker = threading.Thread(target=run, daemon=True)
|
||||
worker.start()
|
||||
except BaseException:
|
||||
_resolvers.release() # the worker never ran, so it can't release its slot
|
||||
raise
|
||||
worker.join(timeout)
|
||||
if 'error' in found:
|
||||
raise found['error']
|
||||
if 'addresses' not in found:
|
||||
raise SourceError('Artwork download took too long')
|
||||
return found['addresses']
|
||||
|
||||
|
||||
def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
|
||||
"""GET a public HTTP(S) URL within an overall deadline (default 60 s), redirects included.
|
||||
|
||||
A watchdog shuts the socket at the deadline, so a server trickling bytes can't outlast it."""
|
||||
deadline = time.monotonic() + 60 if deadline is None else deadline
|
||||
|
||||
def left():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise SourceError('Artwork download took too long')
|
||||
return remaining
|
||||
if not valid_url(url):
|
||||
raise SourceError('Artwork URL is not allowed')
|
||||
p = urlsplit(url)
|
||||
port = p.port or (443 if p.scheme == 'https' else 80)
|
||||
addresses = _resolve(p.hostname, port, left())
|
||||
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
|
||||
raise SourceError('Private network artwork is not allowed')
|
||||
# Connect to the checked IP, never resolve again between validation and use.
|
||||
live = [socket.create_connection((addresses[0][4][0], port), timeout=min(10, left()))]
|
||||
|
||||
def expire():
|
||||
try: # the plain socket method: it also ends a TLS handshake in progress
|
||||
socket.socket.shutdown(live[0], socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
watchdog = threading.Timer(left(), expire)
|
||||
watchdog.daemon = True
|
||||
watchdog.start()
|
||||
conn = http.client.HTTPConnection(p.hostname, port, timeout=10)
|
||||
try:
|
||||
if p.scheme == 'https':
|
||||
# Handshake only once the watchdog can reach the TLS socket, within the remaining time.
|
||||
live[0] = ssl.create_default_context().wrap_socket(live[0], server_hostname=p.hostname,
|
||||
do_handshake_on_connect=False)
|
||||
live[0].settimeout(min(10, left()))
|
||||
live[0].do_handshake()
|
||||
conn.sock = live[0]
|
||||
path = p.path or '/'
|
||||
if p.query:
|
||||
path += '?' + p.query
|
||||
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
|
||||
live[0].settimeout(min(10, left()))
|
||||
response = conn.getresponse()
|
||||
if response.status in (301, 302, 303, 307, 308) and redirects:
|
||||
target = urljoin(url, response.getheader('Location', ''))
|
||||
conn.close()
|
||||
return get(target, headers, redirects - 1, deadline, limit)
|
||||
if response.status != 200:
|
||||
raise SourceError('Artwork is unavailable')
|
||||
data = b''
|
||||
while len(data) <= limit:
|
||||
live[0].settimeout(min(10, left()))
|
||||
chunk = response.read1(min(16384, limit + 1 - len(data))) # one receive at most
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if len(data) > limit:
|
||||
raise SourceError('Artwork is too large')
|
||||
left()
|
||||
return data
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
if time.monotonic() >= deadline:
|
||||
raise SourceError('Artwork download took too long') from e
|
||||
raise
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
conn.close()
|
||||
live[0].close()
|
||||
|
||||
|
||||
def remember(url, data):
|
||||
value = (data, image_type(data))
|
||||
if len(data) > MAX_IMAGE:
|
||||
raise SourceError('Artwork is too large')
|
||||
with _lock:
|
||||
_cache[url] = value
|
||||
_cache.move_to_end(url)
|
||||
while sum(len(v[0]) for v in _cache.values()) > MAX_CACHE:
|
||||
_cache.popitem(last=False)
|
||||
return value
|
||||
|
||||
|
||||
def image(token):
|
||||
with _lock:
|
||||
url = _urls.get(token)
|
||||
if not url:
|
||||
raise SourceError('Unknown artwork')
|
||||
cached = _cache.get(url)
|
||||
if cached:
|
||||
_cache.move_to_end(url)
|
||||
return cached
|
||||
data, _ = fetch(url)
|
||||
return remember(url, data)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Small HTTPS cache and APK downloader for public publisher sources."""
|
||||
import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import frame_host
|
||||
from . import SourceError, SourceLimited
|
||||
|
||||
UA = 'FrameControl/0.1'
|
||||
APK_CAP = 2 * 1024 ** 3 # cached APKs across all sources, least recently used go first
|
||||
RECENT = 3600 # an APK used this recently may be about to be installed; never pruned
|
||||
_use_lock = threading.Lock() # pruning's final check and delete vs touch()/claim()
|
||||
_in_use = {} # APK path -> installs using it
|
||||
|
||||
|
||||
def touch(path):
|
||||
"""Mark a cached APK as just used; False if pruning already removed it."""
|
||||
with _use_lock:
|
||||
try:
|
||||
os.utime(str(path))
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def claim(path):
|
||||
"""Protect a downloaded APK from pruning until release(path)."""
|
||||
path = os.path.abspath(str(path))
|
||||
with _use_lock:
|
||||
os.utime(path) # raises if it has gone
|
||||
_in_use[path] = _in_use.get(path, 0) + 1
|
||||
|
||||
|
||||
def release(path):
|
||||
path = os.path.abspath(str(path))
|
||||
with _use_lock:
|
||||
if _in_use.get(path, 0) > 1:
|
||||
_in_use[path] -= 1
|
||||
else:
|
||||
_in_use.pop(path, None)
|
||||
BACKOFF = 600 # seconds to leave a host alone after 403/429 without Retry-After
|
||||
_limited = {} # host -> time.time() before which we don't contact it
|
||||
_limited_lock = threading.Lock()
|
||||
|
||||
|
||||
def _host(url):
|
||||
return (urllib.parse.urlsplit(url).hostname or '').lower()
|
||||
|
||||
|
||||
def throttle(url, headers=None):
|
||||
"""Remember that url's host asked us to back off; return the delay in seconds."""
|
||||
headers = headers or {}
|
||||
now, delay = time.time(), None
|
||||
value = (headers.get('Retry-After') or '').strip()
|
||||
if value.isdigit():
|
||||
delay = int(value)
|
||||
elif value:
|
||||
try:
|
||||
delay = parsedate_to_datetime(value).timestamp() - now
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
pass
|
||||
reset = headers.get('X-RateLimit-Reset') or ''
|
||||
if delay is None and headers.get('X-RateLimit-Remaining') == '0' and reset.isdigit():
|
||||
delay = int(reset) - now # GitHub
|
||||
delay = min(max(delay if delay is not None else BACKOFF, 1), 6 * 3600)
|
||||
with _limited_lock:
|
||||
_limited[_host(url)] = max(_limited.get(_host(url), 0), now + delay)
|
||||
return delay
|
||||
|
||||
|
||||
def wait_time(url):
|
||||
with _limited_lock:
|
||||
return max(0, _limited.get(_host(url), 0) - time.time())
|
||||
|
||||
|
||||
def limited_error(name, seconds, hint=''):
|
||||
minutes = max(1, int(round(seconds / 60)))
|
||||
return SourceLimited('%s is limiting requests; try again in %d minute%s%s'
|
||||
% (name, minutes, '' if minutes == 1 else 's', hint), seconds)
|
||||
|
||||
|
||||
def cache():
|
||||
path = frame_host.cache_dir('apk-sources', 'publisher')
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return str(path)
|
||||
|
||||
|
||||
def prune():
|
||||
"""Trim the download caches: APKs to APK_CAP by mtime, orphaned .part/temp files, old listings.
|
||||
|
||||
Only the long-running app prunes (at start and after store downloads): claim() is
|
||||
in-process, so the CLIs never prune and so can't delete an APK the app is installing.
|
||||
"""
|
||||
now, apks = time.time(), []
|
||||
for folder in (str(frame_host.cache_dir('apk-sources')), cache()):
|
||||
try:
|
||||
names = os.listdir(folder)
|
||||
except OSError:
|
||||
continue
|
||||
for name in names:
|
||||
path = os.path.join(folder, name)
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
age = now - st.st_mtime
|
||||
if os.path.isdir(path) and not os.path.islink(path):
|
||||
if name.startswith('tmp') and age > 86400: # an interrupted F-Droid index download
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
elif (name.endswith('.part') and age > 86400) or (name.endswith('.data') and age > 7 * 86400):
|
||||
os.remove(path)
|
||||
elif name.endswith('.apk'):
|
||||
apks.append((st.st_mtime, st.st_size, path))
|
||||
except OSError:
|
||||
pass
|
||||
total = sum(size for _, size, _ in apks)
|
||||
for _, size, path in sorted(apks):
|
||||
if total <= APK_CAP:
|
||||
break
|
||||
with _use_lock: # the scan is old news: check again right before deleting
|
||||
try:
|
||||
if os.path.abspath(path) in _in_use or time.time() - os.stat(path).st_mtime < RECENT:
|
||||
continue
|
||||
os.remove(path)
|
||||
total -= size
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def checked_url(url, hosts):
|
||||
try:
|
||||
p = urllib.parse.urlsplit(url)
|
||||
port = p.port
|
||||
except (ValueError, TypeError) as e:
|
||||
raise SourceError('Source returned an invalid URL') from e
|
||||
if p.scheme != 'https' or p.username or p.password or port not in (None, 443) or p.hostname not in hosts:
|
||||
raise SourceError('Source returned an unexpected download URL')
|
||||
return url
|
||||
|
||||
|
||||
class Redirect(urllib.request.HTTPRedirectHandler):
|
||||
def __init__(self, hosts):
|
||||
self.hosts = hosts
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
checked_url(newurl, self.hosts)
|
||||
result = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if result:
|
||||
result.remove_header('Authorization')
|
||||
return result
|
||||
|
||||
|
||||
def open_url(url, hosts, headers=None):
|
||||
checked_url(url, hosts)
|
||||
return urllib.request.build_opener(Redirect(hosts)).open(
|
||||
urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60)
|
||||
|
||||
|
||||
def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
|
||||
path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data')
|
||||
name = name or _host(url) or 'The source'
|
||||
|
||||
def cached():
|
||||
if os.path.isfile(path): # throttled: an older copy beats no results
|
||||
with open(path, 'rb') as f:
|
||||
return f.read()
|
||||
try:
|
||||
if os.path.isfile(path) and time.time() - os.path.getmtime(path) < ttl:
|
||||
with open(path, 'rb') as f:
|
||||
return f.read()
|
||||
wait = wait_time(url)
|
||||
if wait:
|
||||
data = cached()
|
||||
if data is None:
|
||||
raise limited_error(name, wait, hint)
|
||||
return data
|
||||
with open_url(url, hosts, headers) as r:
|
||||
data = r.read(8 * 1024 * 1024 + 1)
|
||||
if len(data) > 8 * 1024 * 1024:
|
||||
raise SourceError('Source index is too large')
|
||||
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as f:
|
||||
f.write(data)
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (403, 429):
|
||||
delay = throttle(url, e.headers)
|
||||
data = cached()
|
||||
if data is None:
|
||||
raise limited_error(name, delay, hint) from e
|
||||
return data
|
||||
raise SourceError('Source HTTP error: ' + str(e.code)) from e
|
||||
except (OSError, ValueError) as e:
|
||||
raise SourceError('Could not read source: ' + str(e)) from e
|
||||
|
||||
|
||||
def apk(url, hosts, digest=None, name=None):
|
||||
tmp = None
|
||||
name = name or _host(url)
|
||||
wait = wait_time(url)
|
||||
if wait:
|
||||
raise limited_error(name, wait)
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
|
||||
h = hashlib.sha256()
|
||||
with os.fdopen(fd, 'wb') as f, open_url(url, hosts) as r:
|
||||
size = 0
|
||||
while True:
|
||||
chunk = r.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > 2 * 1024 ** 3:
|
||||
raise SourceError('APK exceeds the 2 GiB download limit')
|
||||
h.update(chunk)
|
||||
f.write(chunk)
|
||||
actual = h.hexdigest()
|
||||
if digest and actual != digest:
|
||||
raise SourceError('SHA-256 mismatch; download discarded')
|
||||
with zipfile.ZipFile(tmp) as z:
|
||||
if 'AndroidManifest.xml' not in z.namelist():
|
||||
raise SourceError('Download is not an APK')
|
||||
path = os.path.join(cache(), actual + '.apk')
|
||||
os.replace(tmp, path)
|
||||
return {'apk': path, 'obb': [], 'sha256': actual, 'verified': bool(digest)}
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (403, 429):
|
||||
raise limited_error(name, throttle(url, e.headers)) from e
|
||||
raise SourceError('Could not download APK: HTTP error ' + str(e.code)) from e
|
||||
except (OSError, ValueError, zipfile.BadZipFile) as e:
|
||||
raise SourceError('Could not download APK: ' + str(e)) from e
|
||||
finally:
|
||||
if tmp and os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
@@ -0,0 +1,720 @@
|
||||
"""Signed F-Droid repositories. CLI: add|remove|list|search|download."""
|
||||
import argparse
|
||||
import base64
|
||||
import contextlib
|
||||
import errno
|
||||
import hashlib
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
if __package__ in (None, ''):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from apk_sources import SourceError, SourceLimited, _web
|
||||
import frame_host
|
||||
from frame_apk_sign import _der_parts, _cert_key, der
|
||||
from frame_catalog import _IndexReader, _reduce_index, _sha256
|
||||
|
||||
KIND = 'fdroid'
|
||||
CACHE_VERSION = 2
|
||||
_LOCK = threading.RLock() # settings only; never held while downloading
|
||||
_load_locks = {}
|
||||
_refreshing = {} # source id -> background refresh thread
|
||||
_retry_at = {} # source id -> time before which a failed refresh isn't retried
|
||||
_stale = set() # source ids currently served from an expired index
|
||||
MAX_AGE = 86400
|
||||
# Published by the repository operators; a user repository without a pin uses TOFU.
|
||||
FDROID_PIN = '43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab'
|
||||
IZZY_PIN = '3bf0d6abfeae2f401707b6d966be743bf0eee49c2561b9ba39073711f628937a'
|
||||
_DIGESTS = {
|
||||
'608648016503040201': ('sha256', '3031300d060960864801650304020105000420'),
|
||||
'608648016503040202': ('sha384', '3041300d060960864801650304020205000430'),
|
||||
'608648016503040203': ('sha512', '3051300d060960864801650304020305000440'),
|
||||
'2b0e03021a': ('sha1', '3021300906052b0e03021a05000414'),
|
||||
}
|
||||
|
||||
|
||||
def _fingerprint(value):
|
||||
value = re.sub(r'[:\s]', '', value or '').lower()
|
||||
if not re.fullmatch('[0-9a-f]{64}', value):
|
||||
raise SourceError('fingerprint must be a SHA-256 certificate fingerprint (64 hex digits)')
|
||||
return value
|
||||
|
||||
|
||||
def _url(url, fingerprint=None):
|
||||
if url.startswith('fdroidrepos://'):
|
||||
url = 'https://' + url[len('fdroidrepos://'):]
|
||||
p = urllib.parse.urlsplit(url)
|
||||
if p.scheme != 'https' or not p.hostname or p.username or p.password or p.fragment:
|
||||
raise SourceError('repository URL must use HTTPS without credentials or a fragment')
|
||||
params = urllib.parse.parse_qs(p.query)
|
||||
pins = params.pop('fingerprint', [])
|
||||
if params or len(pins) > 1:
|
||||
raise SourceError('only one fingerprint query parameter is supported')
|
||||
pin = _fingerprint(fingerprint) if fingerprint else None
|
||||
if pins:
|
||||
linked = _fingerprint(pins[0])
|
||||
if pin and pin != linked:
|
||||
raise SourceError('conflicting fingerprints')
|
||||
pin = linked
|
||||
return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin
|
||||
|
||||
|
||||
def _child(base, name):
|
||||
name = str(name).lstrip('/')
|
||||
decoded = urllib.parse.unquote(name)
|
||||
if not name or '\\' in decoded or any(x in ('.', '..') for x in decoded.split('/')):
|
||||
raise SourceError('unsafe repository file name')
|
||||
if '?' in decoded or urllib.parse.urlsplit(decoded).scheme:
|
||||
raise SourceError('repository file is outside its repository')
|
||||
url = urllib.parse.urljoin(base, urllib.parse.quote(decoded, safe='/')) # names may contain '#' or spaces
|
||||
if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment:
|
||||
raise SourceError('repository file is outside its repository')
|
||||
return url
|
||||
|
||||
|
||||
class _HTTPSRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if urllib.parse.urlsplit(newurl).scheme != 'https':
|
||||
raise SourceError('refusing non-HTTPS redirect')
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def _fetch(url, path, maximum):
|
||||
host = urllib.parse.urlsplit(url).hostname
|
||||
wait = _web.wait_time(url)
|
||||
if wait:
|
||||
raise _web.limited_error(host, wait)
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'})
|
||||
try:
|
||||
with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f:
|
||||
total = 0
|
||||
while True:
|
||||
chunk = r.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > maximum:
|
||||
raise SourceError('repository file exceeds size limit')
|
||||
f.write(chunk)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (403, 429):
|
||||
raise _web.limited_error(host, _web.throttle(url, e.headers)) from e
|
||||
raise
|
||||
|
||||
|
||||
def _limited(source, error):
|
||||
return _web.limited_error(source['name'], error.retry_after or _web.BACKOFF)
|
||||
|
||||
|
||||
def _children(item):
|
||||
return _der_parts(item[1])
|
||||
|
||||
|
||||
def _cms(data, content, strong=False):
|
||||
outer = _der_parts(data)
|
||||
if len(outer) != 1:
|
||||
raise ValueError('invalid CMS wrapper')
|
||||
wrapper = _children(outer[0])
|
||||
if wrapper[0][1].hex() != '2a864886f70d010702':
|
||||
raise ValueError('not CMS SignedData')
|
||||
fields = _children(_children(wrapper[1])[0])
|
||||
certs = next(_children(f) for f in fields[3:] if f[0] == 0xa0)
|
||||
signers = _children(fields[-1])
|
||||
if len(signers) != 1:
|
||||
raise ValueError('exactly one repository signer required')
|
||||
signer = _children(signers[0])
|
||||
sid = _children(signer[1])
|
||||
matching = []
|
||||
for cert in certs:
|
||||
tbs = _children(_children(cert)[0])
|
||||
offset = 1 if tbs[0][0] == 0xa0 else 0
|
||||
if tbs[offset][1] == sid[1][1] and tbs[offset + 2][2] == sid[0][2]:
|
||||
matching.append(cert[2])
|
||||
if len(matching) != 1:
|
||||
raise ValueError('missing or ambiguous signer certificate')
|
||||
cert = matching[0]
|
||||
digest, prefix = _DIGESTS[_children(signer[2])[0][1].hex()]
|
||||
if strong and digest == 'sha1':
|
||||
raise ValueError('SHA-1 signatures are not accepted for v2 indexes')
|
||||
at, signed = 3, content
|
||||
if signer[at][0] == 0xa0:
|
||||
attrs = {}
|
||||
for attr in _children(signer[at]):
|
||||
pair = _children(attr)
|
||||
oid = pair[0][1].hex()
|
||||
if oid in attrs:
|
||||
raise ValueError('duplicate CMS attribute')
|
||||
attrs[oid] = _children(pair[1])
|
||||
if attrs['2a864886f70d010904'][0][1] != hashlib.new(digest, content).digest():
|
||||
raise ValueError('CMS content digest mismatch')
|
||||
if attrs['2a864886f70d010903'][0][1].hex() != '2a864886f70d010701':
|
||||
raise ValueError('unexpected CMS content type')
|
||||
signed = der(0x31, signer[at][1])
|
||||
at += 1
|
||||
algorithm = _children(signer[at])[0][1].hex()
|
||||
allowed = {'sha1': '2a864886f70d010105', 'sha256': '2a864886f70d01010b',
|
||||
'sha384': '2a864886f70d01010c', 'sha512': '2a864886f70d01010d'}
|
||||
if algorithm not in ('2a864886f70d010101', allowed[digest]):
|
||||
raise ValueError('unsupported repository signature algorithm (RSA PKCS#1 required)')
|
||||
n, e, _ = _cert_key(cert)
|
||||
sig = signer[at + 1][1]
|
||||
size = (n.bit_length() + 7) // 8
|
||||
if not 256 <= size <= 1024 or n % 2 != 1 or not 3 <= e <= 0xffffffff or e % 2 != 1 or len(sig) != size or int.from_bytes(sig, 'big') >= n:
|
||||
raise ValueError('invalid RSA signature/key size')
|
||||
value = bytes.fromhex(prefix) + hashlib.new(digest, signed).digest()
|
||||
expected = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value
|
||||
if pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big') != expected:
|
||||
raise ValueError('repository RSA signature mismatch')
|
||||
return hashlib.sha256(cert).hexdigest()
|
||||
|
||||
|
||||
def _sections(data):
|
||||
sections = []
|
||||
for block in re.split(b'\r?\n\r?\n', data):
|
||||
if not block:
|
||||
continue
|
||||
attrs = {}
|
||||
for line in re.sub(b'\r?\n ', b'', block).splitlines():
|
||||
key, value = line.decode('utf-8').split(': ', 1)
|
||||
key = key.lower()
|
||||
if key in attrs:
|
||||
raise ValueError('duplicate manifest attribute')
|
||||
attrs[key] = value
|
||||
sections.append(attrs)
|
||||
return sections
|
||||
|
||||
|
||||
def _digest_check(attrs, suffix, content, strong=False):
|
||||
for label, digest in (('sha-512', 'sha512'), ('sha-384', 'sha384'), ('sha-256', 'sha256'), ('sha1', 'sha1'), ('sha-1', 'sha1')):
|
||||
if label + suffix in attrs and not (strong and digest == 'sha1'):
|
||||
if base64.b64decode(attrs[label + suffix], validate=True) != hashlib.new(digest, content).digest():
|
||||
raise ValueError('JAR digest mismatch')
|
||||
return
|
||||
raise ValueError('missing supported JAR digest')
|
||||
|
||||
|
||||
def _jar(path, member, pin, strong=False):
|
||||
"""strong: SHA-2 only (v2 entry.jar); index-v1.jar may still be SHA-1 signed."""
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = z.namelist()
|
||||
if len(names) > 64 or len(names) != len(set(names)) or any(
|
||||
i.file_size > (1024 * 1024 if i.filename.upper().startswith('META-INF/') else 256 * 1024 * 1024)
|
||||
for i in z.infolist()):
|
||||
raise ValueError('duplicate or oversized JAR member')
|
||||
blocks = [n for n in names if n.upper().startswith('META-INF/') and n.upper().endswith('.RSA')]
|
||||
if len(blocks) != 1:
|
||||
raise ValueError('exactly one RSA JAR signer required')
|
||||
sf = z.read(blocks[0][:-4] + '.SF')
|
||||
fingerprint = _cms(z.read(blocks[0]), sf, strong)
|
||||
if pin and fingerprint != pin:
|
||||
raise ValueError('repository fingerprint mismatch')
|
||||
manifest = z.read('META-INF/MANIFEST.MF')
|
||||
_digest_check(_sections(sf)[0], '-digest-manifest', manifest, strong)
|
||||
entries = [s for s in _sections(manifest)[1:] if s.get('name') == member]
|
||||
if len(entries) != 1:
|
||||
raise ValueError('index is not uniquely signed')
|
||||
content = z.read(member)
|
||||
_digest_check(entries[0], '-digest', content, strong)
|
||||
return content, fingerprint
|
||||
except (ValueError, KeyError, IndexError, StopIteration, RuntimeError, NotImplementedError, zipfile.BadZipFile) as e:
|
||||
raise SourceError('invalid signed repository: ' + str(e)) from e
|
||||
|
||||
|
||||
def _storage():
|
||||
return frame_host.data_dir('apk-repos.json')
|
||||
|
||||
|
||||
def _read():
|
||||
try:
|
||||
settings = json.loads(_storage().read_text())
|
||||
if not isinstance(settings, dict) or not isinstance(settings.get('repos'), list) or not isinstance(settings.get('enabled'), dict):
|
||||
raise ValueError('invalid settings structure')
|
||||
return settings
|
||||
except FileNotFoundError:
|
||||
return {'repos': [], 'enabled': {}}
|
||||
except (OSError, ValueError) as e:
|
||||
raise SourceError('cannot read repository settings: ' + str(e)) from e
|
||||
|
||||
|
||||
def _write(path, value):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(value, f, separators=(',', ':'))
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
def _state_path():
|
||||
return frame_host.data_dir('apk-repo-state.json')
|
||||
|
||||
|
||||
def _states():
|
||||
try:
|
||||
states = json.loads(_state_path().read_text())
|
||||
if not isinstance(states, dict):
|
||||
raise ValueError('invalid state structure')
|
||||
return states
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (OSError, ValueError) as e:
|
||||
raise SourceError('cannot read repository state: ' + str(e)) from e
|
||||
|
||||
|
||||
def _state(source):
|
||||
"""Newest accepted index timestamp and whether a v2 index was ever accepted (rollback protection)."""
|
||||
with _LOCK:
|
||||
state = _states().get(source['id'])
|
||||
return state if isinstance(state, dict) and state.get('url') == source['url'] else {}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _state_file_lock():
|
||||
"""Inter-process lock: the CLI and the app must not publish indexes out of order."""
|
||||
path = frame_host.data_dir('apk-repo-state.lock')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(str(path), 'a+b') as f:
|
||||
if os.name == 'nt':
|
||||
import msvcrt
|
||||
while True:
|
||||
try:
|
||||
f.seek(0)
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
|
||||
break
|
||||
except OSError as e: # LK_LOCK gives up after ~10 s of contention; keep waiting
|
||||
if e.errno not in (errno.EACCES, errno.EDEADLK):
|
||||
raise
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
f.seek(0)
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _check_timestamp(source, timestamp):
|
||||
last = _state(source).get('timestamp')
|
||||
if last is not None and (type(timestamp) is not int or timestamp < last):
|
||||
raise SourceError('repository index is older than the one already accepted (possible rollback); refused')
|
||||
|
||||
|
||||
def _accept(source, timestamp, v2):
|
||||
with _LOCK:
|
||||
states = _states()
|
||||
state = _state(source)
|
||||
states[source['id']] = {'url': source['url'], 'v2': bool(v2 or state.get('v2')),
|
||||
'timestamp': timestamp if type(timestamp) is int else state.get('timestamp')}
|
||||
_write(_state_path(), states)
|
||||
|
||||
|
||||
def user_repos():
|
||||
with _LOCK:
|
||||
return _read()['repos']
|
||||
|
||||
|
||||
def sources():
|
||||
builtins = [('fdroid', 'F-Droid', 'https://f-droid.org/repo/', FDROID_PIN),
|
||||
('fdroid-archive', 'F-Droid archive', 'https://f-droid.org/archive/', FDROID_PIN),
|
||||
('izzyondroid', 'IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/', IZZY_PIN)]
|
||||
settings = _read()
|
||||
return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True,
|
||||
# The archive only holds superseded versions; it clutters search unless asked for.
|
||||
enabled=settings['enabled'].get(i, i != 'fdroid-archive'), trust='community')
|
||||
for i, n, u, p in builtins] + settings['repos']
|
||||
|
||||
|
||||
def _text(value):
|
||||
if isinstance(value, dict):
|
||||
return value.get('en-US') or next((v for v in value.values() if v), '')
|
||||
return value or ''
|
||||
|
||||
|
||||
class _PlainText(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts, self.hidden = [], 0
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in ('script', 'style'):
|
||||
self.hidden += 1
|
||||
elif tag in ('br', 'p', 'div', 'li'):
|
||||
self.parts.append(' ')
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ('script', 'style'):
|
||||
self.hidden = max(0, self.hidden - 1)
|
||||
elif tag in ('p', 'div', 'li'):
|
||||
self.parts.append(' ')
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self.hidden:
|
||||
self.parts.append(data)
|
||||
|
||||
|
||||
def _summary(value):
|
||||
parser = _PlainText()
|
||||
parser.feed(_text(value))
|
||||
parser.close()
|
||||
return ' '.join(''.join(parser.parts).split())
|
||||
|
||||
|
||||
def _images(meta, base):
|
||||
def url(file):
|
||||
name = file.get('name') if isinstance(file, dict) else file
|
||||
try:
|
||||
return _child(base, name) if isinstance(name, str) and name else None
|
||||
except SourceError:
|
||||
return None # one odd image name mustn't hide the app
|
||||
|
||||
icon = url(_text(meta.get('icon')))
|
||||
banner = url(_text(meta.get('featureGraphic')))
|
||||
screenshots = []
|
||||
groups = meta.get('screenshots') or {}
|
||||
for device, legacy in (('phone', 'phoneScreenshots'), ('sevenInch', 'sevenInchScreenshots')):
|
||||
files = _text(groups.get(device)) or _text(meta.get(legacy)) or []
|
||||
for file in files if isinstance(files, list) else []:
|
||||
image = url(file)
|
||||
if image and image not in screenshots:
|
||||
screenshots.append(image)
|
||||
if len(screenshots) == 6:
|
||||
break
|
||||
if len(screenshots) == 6:
|
||||
break
|
||||
return {'icon': icon, 'banner': banner, 'screenshots': screenshots}
|
||||
|
||||
|
||||
def _reduce(path, source):
|
||||
compatible = _reduce_index(path)
|
||||
result = {}
|
||||
with open(path, encoding='utf-8') as f:
|
||||
reader = _IndexReader(f)
|
||||
for key in reader.members():
|
||||
if key != 'packages':
|
||||
reader.value()
|
||||
continue
|
||||
for pkg in reader.members():
|
||||
item = reader.value()
|
||||
if pkg not in compatible:
|
||||
continue
|
||||
meta = item.get('metadata', {})
|
||||
files = {v['file'].get('name'): v for v in item.get('versions', {}).values()
|
||||
if isinstance(v, dict) and isinstance(v.get('file'), dict)}
|
||||
versions = []
|
||||
for v in compatible[pkg]:
|
||||
original = files[v['name']]
|
||||
versions.append(dict(v, size=original['file'].get('size'), updated=_date(original.get('added'))))
|
||||
versions.sort(key=lambda v: (v['version_code'], v['abis'] == ['arm64-v8a']), reverse=True)
|
||||
latest = versions[0]
|
||||
images = _images(meta, source['url'])
|
||||
result[pkg] = dict(source=source['id'], id=pkg, package=pkg,
|
||||
name=_text(meta.get('name')) or pkg, summary=_summary(meta.get('summary')),
|
||||
icon=images['icon'], images=images, developer=_text(meta.get('authorName')) or None,
|
||||
page=meta.get('webSite') or source['url'], vr=None, free=True,
|
||||
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
|
||||
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
|
||||
return result
|
||||
|
||||
|
||||
def _date(value):
|
||||
return time.strftime('%Y-%m-%d', time.gmtime(value / 1000)) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def _v1(content, path):
|
||||
index = json.loads(content)
|
||||
apps = {a['packageName']: a for a in index['apps']}
|
||||
packages = {}
|
||||
for pkg, builds in index['packages'].items():
|
||||
app = apps.get(pkg, {})
|
||||
localized = app.get('localized', {})
|
||||
meta = {k: _text({locale: fields[k] for locale, fields in localized.items() if fields.get(k)}) or app.get(k)
|
||||
for k in ('name', 'summary', 'license', 'webSite', 'authorName')}
|
||||
for field in ('icon', 'featureGraphic', 'phoneScreenshots', 'sevenInchScreenshots'):
|
||||
images = {}
|
||||
for locale, fields in localized.items():
|
||||
value = fields.get(field)
|
||||
if not value:
|
||||
continue
|
||||
prefix = pkg + '/' + locale + '/'
|
||||
if field.endswith('Screenshots'):
|
||||
images[locale] = [{'name': prefix + field + '/' + name} for name in value[:6]]
|
||||
else:
|
||||
images[locale] = {'name': prefix + value}
|
||||
meta[field] = images
|
||||
if not meta['icon'] and app.get('icon'):
|
||||
meta['icon'] = {'en-US': {'name': 'icons/' + app['icon']}}
|
||||
versions = {}
|
||||
for i, v in enumerate(builds):
|
||||
versions[str(i)] = {'manifest': {'versionName': v.get('versionName'), 'versionCode': v['versionCode'],
|
||||
'usesSdk': {'minSdkVersion': v.get('minSdkVersion', 1)}, 'nativecode': v.get('nativecode', [])},
|
||||
'file': {'name': v['apkName'], 'sha256': v.get('hash') if v.get('hashType') == 'sha256' else None,
|
||||
'size': v.get('size')}, 'added': v.get('added')}
|
||||
packages[pkg] = {'metadata': meta, 'versions': versions}
|
||||
path.write_text(json.dumps({'packages': packages}))
|
||||
return (index.get('repo') or {}).get('timestamp')
|
||||
|
||||
|
||||
def _cached(source, cache):
|
||||
"""(apps, pin, expired) from a cache matching this source, or None."""
|
||||
try:
|
||||
saved = json.loads(cache.read_text())
|
||||
if (saved.get('version') == CACHE_VERSION and saved.get('fingerprint') == source.get('fingerprint')
|
||||
and saved.get('url') == source['url']):
|
||||
return saved['apps'], saved['fingerprint'], time.time() - cache.stat().st_mtime >= MAX_AGE
|
||||
except (OSError, ValueError, KeyError, AttributeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def stale(source):
|
||||
"""True while results come from an expired index that is being (or failed to be) refreshed."""
|
||||
return source['id'] in _stale
|
||||
|
||||
|
||||
def _refresh_later(source):
|
||||
with _LOCK:
|
||||
if source['id'] in _refreshing or time.time() < _retry_at.get(source['id'], 0):
|
||||
return
|
||||
def run():
|
||||
try:
|
||||
_load(source, force=True)
|
||||
except Exception:
|
||||
with _LOCK:
|
||||
_retry_at[source['id']] = time.time() + 600
|
||||
finally:
|
||||
with _LOCK:
|
||||
_refreshing.pop(source['id'], None)
|
||||
_refreshing[source['id']] = thread = threading.Thread(target=run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
def _source_lock(source_id):
|
||||
with _LOCK:
|
||||
return _load_locks.setdefault(source_id, threading.Lock())
|
||||
|
||||
|
||||
def _load(source, force=False):
|
||||
if not re.fullmatch(r'[a-z0-9-]+', source['id']):
|
||||
raise SourceError('invalid source id')
|
||||
_url(source['url'], source.get('fingerprint'))
|
||||
cache = frame_host.cache_dir('apk-sources', source['id'] + '.json')
|
||||
found = None if force else _cached(source, cache)
|
||||
if found:
|
||||
if found[2]: # expired: serve it now, marked stale, and refresh without blocking anyone
|
||||
_stale.add(source['id'])
|
||||
_refresh_later(source)
|
||||
return found[:2]
|
||||
with _source_lock(source['id']):
|
||||
found = None if force else _cached(source, cache) # another caller may have just loaded it
|
||||
if found and not found[2]:
|
||||
return found[:2]
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
|
||||
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
|
||||
v2 = True
|
||||
try:
|
||||
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in (404, 410):
|
||||
raise
|
||||
if _state(source).get('v2'):
|
||||
raise SourceError('repository no longer serves its signed v2 index; '
|
||||
'refusing to fall back to the older v1 index') from e
|
||||
v2 = False
|
||||
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
|
||||
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
|
||||
timestamp = _v1(content, raw)
|
||||
_check_timestamp(source, timestamp)
|
||||
else:
|
||||
content, pin = _jar(jar, 'entry.json', source.get('fingerprint'), strong=True)
|
||||
signed = json.loads(content)
|
||||
timestamp, entry = signed.get('timestamp'), signed['index']
|
||||
_check_timestamp(source, timestamp)
|
||||
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024)
|
||||
if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']):
|
||||
raise SourceError('index SHA-256 or size mismatch')
|
||||
apps = _reduce(raw, source)
|
||||
with _state_file_lock(): # recheck: another process may have accepted a newer index meanwhile
|
||||
_check_timestamp(source, timestamp)
|
||||
if not v2 and _state(source).get('v2'): # a v2 index was accepted while we fetched v1
|
||||
raise SourceError('repository now has a signed v2 index; refusing the older v1 index')
|
||||
_write(cache, {'version': CACHE_VERSION, 'url': source['url'], 'fingerprint': pin, 'apps': apps})
|
||||
_accept(source, timestamp, v2)
|
||||
_stale.discard(source['id'])
|
||||
return apps, pin
|
||||
except SourceLimited as e:
|
||||
raise _limited(source, e) from e
|
||||
except SourceError:
|
||||
raise
|
||||
except (OSError, ValueError, KeyError, TypeError, IndexError) as e:
|
||||
raise SourceError('cannot load repository: ' + str(e)) from e
|
||||
|
||||
|
||||
def add_repo(url, fingerprint=None, name=None):
|
||||
url, pin = _url(url, fingerprint)
|
||||
with _LOCK:
|
||||
existing = next((s for s in _read()['repos'] if s['url'] == url), None)
|
||||
if existing:
|
||||
if pin and pin != existing['fingerprint']:
|
||||
raise SourceError('repository already has a different pinned fingerprint; remove it first')
|
||||
pin = existing['fingerprint']
|
||||
source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND,
|
||||
name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname,
|
||||
url=url, builtin=False, enabled=True, trust='user', fingerprint=pin)
|
||||
_, source['fingerprint'] = _load(source, force=True) # network work outside the settings lock
|
||||
source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None
|
||||
with _LOCK:
|
||||
settings = _read()
|
||||
current = next((s for s in settings['repos'] if s['id'] == source['id']), None)
|
||||
if current and current['fingerprint'] != source['fingerprint']:
|
||||
raise SourceError('repository already has a different pinned fingerprint; remove it first')
|
||||
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
|
||||
_write(_storage(), settings)
|
||||
return source
|
||||
|
||||
|
||||
def remove_repo(source_id):
|
||||
with _LOCK:
|
||||
settings = _read()
|
||||
if not any(s['id'] == source_id for s in settings['repos']):
|
||||
raise SourceError('unknown user repository')
|
||||
settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id]
|
||||
_write(_storage(), settings)
|
||||
with _state_file_lock(), _LOCK: # lock order: state file, then _LOCK
|
||||
states = _states()
|
||||
if states.pop(source_id, None) is not None: # re-adding is a deliberate new trust decision
|
||||
_write(_state_path(), states)
|
||||
|
||||
|
||||
def set_enabled(source_id, enabled):
|
||||
if not isinstance(enabled, bool):
|
||||
raise SourceError('enabled must be a boolean')
|
||||
with _LOCK:
|
||||
settings = _read()
|
||||
source = next((s for s in sources() if s['id'] == source_id), None)
|
||||
if not source:
|
||||
raise SourceError('unknown repository')
|
||||
if source['builtin']:
|
||||
settings['enabled'][source_id] = enabled
|
||||
else:
|
||||
for s in settings['repos']:
|
||||
if s['id'] == source_id:
|
||||
s['enabled'] = enabled
|
||||
_write(_storage(), settings)
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
if not source.get('enabled', True):
|
||||
return []
|
||||
apps, _ = _load(source)
|
||||
words = query.casefold().split()
|
||||
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
|
||||
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
|
||||
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
if not source.get('enabled', True):
|
||||
raise SourceError('repository is disabled')
|
||||
apps, _ = _load(source)
|
||||
if entry_id not in apps:
|
||||
raise SourceError('app has no Lepton-compatible version in this repository')
|
||||
return apps[entry_id]
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
entry = details(source, entry_id)
|
||||
version = next((v for v in entry['versions'] if version_code is None or str(v['version_code']) == str(version_code)), None)
|
||||
if not version or not re.fullmatch('[0-9a-f]{64}', version.get('sha256') or ''):
|
||||
raise SourceError('version is missing or has no SHA-256 digest')
|
||||
sha = version['sha256']
|
||||
path = frame_host.cache_dir('apk-sources', sha + '.apk')
|
||||
try:
|
||||
reuse = False
|
||||
if _web.touch(path): # before hashing: a just-used APK is never pruned
|
||||
try:
|
||||
reuse = _sha256(path) == sha
|
||||
except FileNotFoundError: # removed anyway (e.g. by hand): download again
|
||||
pass
|
||||
if not reuse:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
|
||||
os.close(fd)
|
||||
try:
|
||||
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3)
|
||||
if _sha256(tmp) != sha:
|
||||
raise SourceError('APK SHA-256 mismatch; download discarded')
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
return {'apk': str(path), 'obb': [], 'sha256': sha, 'verified': True}
|
||||
except SourceLimited as e:
|
||||
raise _limited(source, e) from e
|
||||
except OSError as e:
|
||||
raise SourceError('cannot download APK: ' + str(e)) from e
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
add = sub.add_parser('add')
|
||||
add.add_argument('url')
|
||||
add.add_argument('--fingerprint')
|
||||
add.add_argument('--name')
|
||||
sub.add_parser('list')
|
||||
remove = sub.add_parser('remove')
|
||||
remove.add_argument('source')
|
||||
for command in ('search', 'download'):
|
||||
p = sub.add_parser(command)
|
||||
p.add_argument('source')
|
||||
p.add_argument('query' if command == 'search' else 'package')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
_cli(parser, args)
|
||||
finally:
|
||||
for thread in list(_refreshing.values()): # daemon refreshes would die with the CLI, even on errors
|
||||
thread.join()
|
||||
|
||||
|
||||
def _cli(parser, args):
|
||||
try:
|
||||
if args.command == 'add':
|
||||
result = add_repo(args.url, args.fingerprint, args.name)
|
||||
elif args.command == 'list':
|
||||
result = sources()
|
||||
elif args.command == 'remove':
|
||||
result = remove_repo(args.source)
|
||||
else:
|
||||
source = next((s for s in sources() if s['id'] == args.source), None)
|
||||
if not source:
|
||||
raise SourceError('unknown repository id; use list')
|
||||
result = search(source, args.query) if args.command == 'search' else download(source, args.package)
|
||||
print(json.dumps(result, indent=2))
|
||||
except SourceError as e:
|
||||
parser.exit(1, 'error: ' + str(e) + '\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Curated publisher releases; optional topic discovery is page-only."""
|
||||
import fnmatch, json, os, re, urllib.parse
|
||||
|
||||
from . import SourceError, _web
|
||||
|
||||
KIND = 'github'
|
||||
API = 'https://api.github.com'
|
||||
TOPICS = ('oculus-quest', 'openxr', 'quest')
|
||||
HOSTS = ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': KIND, 'kind': KIND, 'name': 'GitHub releases', 'url': 'https://github.com',
|
||||
'builtin': True, 'enabled': True, 'trust': 'community'}]
|
||||
|
||||
|
||||
def _curated():
|
||||
with open(os.path.join(os.path.dirname(__file__), 'github_curated.json')) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _api(path):
|
||||
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
|
||||
token = os.environ.get('FRAME_GITHUB_TOKEN')
|
||||
if token:
|
||||
headers['Authorization'] = 'Bearer ' + token
|
||||
try:
|
||||
return json.loads(_web.read(API + path, ('api.github.com',), headers, name='GitHub',
|
||||
hint='' if token else ' (set FRAME_GITHUB_TOKEN to raise the limit)'))
|
||||
except (ValueError, TypeError) as e:
|
||||
raise SourceError('Invalid GitHub response') from e
|
||||
|
||||
|
||||
def _entry(source, c, approved=True):
|
||||
artwork = c.get('images') or {}
|
||||
icon = artwork.get('icon') or c.get('icon') or 'https://github.com/' + c['repo'].split('/')[0] + '.png'
|
||||
images = {'icon': icon,
|
||||
# No GitHub social-preview card: it's repo text and stats, not art; the UI draws a fallback.
|
||||
'banner': artwork.get('banner'),
|
||||
'screenshots': list(artwork.get('screenshots') or [])}
|
||||
return {'source': source['id'], 'id': c['repo'], 'package': None,
|
||||
'name': c['name'], 'summary': c.get('summary') or '', 'icon': icon,
|
||||
'images': images,
|
||||
'page': 'https://github.com/' + c['repo'], 'version': None, 'version_code': None,
|
||||
'min_sdk': None, 'abis': None, 'vr': c.get('vr'), 'size': None,
|
||||
'free': True if approved else None, 'license': c.get('license'), 'updated': None,
|
||||
'downloadable': approved}
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
limit = max(0, min(int(limit), 100))
|
||||
if not limit:
|
||||
return []
|
||||
if query.startswith('topic:'):
|
||||
topic = query[6:].strip()
|
||||
if topic not in TOPICS:
|
||||
raise SourceError('Choose topic:oculus-quest, topic:openxr or topic:quest')
|
||||
data = _api('/search/repositories?' + urllib.parse.urlencode(
|
||||
{'q': 'topic:' + topic + ' archived:false', 'sort': 'stars', 'per_page': limit}))
|
||||
curated = {c['repo'].lower(): c for c in _curated()}
|
||||
out = []
|
||||
for repo in data.get('items', []):
|
||||
c = curated.get(repo['full_name'].lower())
|
||||
entry = _entry(source, c or {'repo': repo['full_name'], 'name': repo['name'],
|
||||
'summary': repo.get('description'), 'vr': True,
|
||||
'icon': (repo.get('owner') or {}).get('avatar_url')}, bool(c))
|
||||
out.append(entry)
|
||||
return out
|
||||
words = query.lower().split()
|
||||
return [_entry(source, c) for c in _curated()
|
||||
if all(w in (c['name'] + ' ' + c['repo'] + ' ' + c['summary']).lower()
|
||||
for w in words)][:limit]
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
c = next((c for c in _curated() if c['repo'].lower() == entry_id.lower()), None)
|
||||
if not c:
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', entry_id):
|
||||
raise SourceError('Invalid GitHub repository')
|
||||
entry = _entry(source, {'repo': entry_id, 'name': entry_id}, False)
|
||||
return {**entry, 'versions': []}
|
||||
entry = _entry(source, c)
|
||||
releases = _api('/repos/' + c['repo'] + '/releases?per_page=10')
|
||||
versions = []
|
||||
for release in releases:
|
||||
if release.get('draft') or (release.get('prerelease') and not c.get('allow_prerelease')):
|
||||
continue
|
||||
assets = [a for a in release.get('assets', []) if
|
||||
fnmatch.fnmatch(a['name'].lower(), c['asset_pattern'].lower())]
|
||||
for asset in assets:
|
||||
digest = asset.get('digest') or ''
|
||||
versions.append({'version': release['tag_name'], 'version_code': None,
|
||||
'asset_id': asset['id'], 'name': asset['name'], 'min_sdk': None,
|
||||
'prerelease': bool(release.get('prerelease')),
|
||||
'size': asset.get('size'), 'updated': release.get('published_at'),
|
||||
'url': asset['browser_download_url'],
|
||||
'sha256': digest[7:] if re.fullmatch(r'sha256:[0-9a-f]{64}', digest) else None})
|
||||
entry['versions'] = versions
|
||||
entry['downloadable'] = bool(versions)
|
||||
if versions:
|
||||
entry.update({k: versions[0][k] for k in ('version', 'size', 'updated')})
|
||||
return entry
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
entry = details(source, entry_id)
|
||||
versions = entry['versions']
|
||||
if not versions:
|
||||
raise SourceError('No approved APK release; open the publisher page')
|
||||
# GitHub asset IDs and tags are not Android version codes.
|
||||
if version_code is not None:
|
||||
raise SourceError('GitHub does not publish Android version codes; download the latest release')
|
||||
v = versions[0]
|
||||
expected = 'https://github.com/' + entry['id'] + '/releases/download/'
|
||||
if not v['url'].startswith(expected):
|
||||
raise SourceError('APK URL does not belong to the curated publisher')
|
||||
return _web.apk(v['url'], HOSTS, v['sha256'], name='GitHub')
|
||||
@@ -0,0 +1,72 @@
|
||||
[
|
||||
{
|
||||
"repo": "KhronosGroup/OpenXR-SDK-Source",
|
||||
"name": "hello_xr",
|
||||
"summary": "A simple VR scene for checking that OpenXR graphics and controllers work.",
|
||||
"license": "Apache-2.0",
|
||||
"vr": true,
|
||||
"asset_pattern": "hello_xr-Vulkan-*.apk",
|
||||
"allow_prerelease": false,
|
||||
"images": {
|
||||
"icon": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png",
|
||||
"banner": null,
|
||||
"screenshots": []
|
||||
},
|
||||
"icon": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png"
|
||||
},
|
||||
{
|
||||
"repo": "icosa-foundation/open-brush",
|
||||
"name": "Open Brush",
|
||||
"summary": "Paint in the air and create colorful 3D artwork in VR.",
|
||||
"license": "Apache-2.0",
|
||||
"vr": true,
|
||||
"asset_pattern": "*Quest*.apk",
|
||||
"allow_prerelease": true,
|
||||
"images": {
|
||||
"icon": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png",
|
||||
"banner": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/open-brush.png",
|
||||
"screenshots": [
|
||||
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_785ea37d63378146dfe0f0ffa3f1d5c155ca978f.1920x1080.jpg",
|
||||
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_0a9c208e26a43cf34879c2ca361d4c8f18af8cba.1920x1080.jpg",
|
||||
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_19b25b86ef55c0d8769a65135d60eaae8fa40553.1920x1080.jpg"
|
||||
]
|
||||
},
|
||||
"icon": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png"
|
||||
},
|
||||
{
|
||||
"repo": "SgtBilko76/SuperTux-3D",
|
||||
"name": "SuperTux 3D",
|
||||
"summary": "Run and jump through Tux\u2019s platform adventure on a layered 3D screen in VR.",
|
||||
"license": "GPL-3.0",
|
||||
"vr": true,
|
||||
"asset_pattern": "*Quest*.apk",
|
||||
"allow_prerelease": true,
|
||||
"images": {
|
||||
"icon": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png",
|
||||
"banner": "https://www.supertux.org/images/0_7_0/github_preview.png",
|
||||
"screenshots": [
|
||||
"https://www.supertux.org/images/0_7_0/github_preview.png"
|
||||
]
|
||||
},
|
||||
"icon": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png"
|
||||
},
|
||||
{
|
||||
"repo": "arpruss/OpenSaberPlus",
|
||||
"name": "Open Saber Plus",
|
||||
"summary": "Slash glowing blocks to the beat with two lightsabers, with songs from BeatSaver.",
|
||||
"license": "MIT",
|
||||
"vr": true,
|
||||
"asset_pattern": "OpenSaberPlus.apk",
|
||||
"allow_prerelease": false,
|
||||
"images": {
|
||||
"icon": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png",
|
||||
"banner": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif",
|
||||
"screenshots": [
|
||||
"https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif",
|
||||
"https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_2.gif",
|
||||
"https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_3.gif"
|
||||
]
|
||||
},
|
||||
"icon": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Free Android VR RSS listings. Download pages stay in the publisher's UI."""
|
||||
import html, re, urllib.parse, xml.etree.ElementTree as ET
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
from . import SourceError, _web
|
||||
|
||||
KIND = 'itch'
|
||||
FEEDS = ('openxr', 'oculus-quest')
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
|
||||
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
|
||||
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
|
||||
|
||||
|
||||
def _parse(source, data):
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError as e:
|
||||
raise SourceError('Invalid itch.io RSS feed') from e
|
||||
entries = []
|
||||
for item in root.findall('./channel/item'):
|
||||
page = item.findtext('link') or ''
|
||||
p = urllib.parse.urlsplit(page)
|
||||
if p.scheme != 'https' or not (p.hostname or '').endswith('.itch.io') or p.username or ':' in p.netloc:
|
||||
continue
|
||||
if item.findtext('price') != '$0.00' or item.findtext('platforms/android') != 'yes':
|
||||
continue
|
||||
cover = item.findtext('imageurl') or None
|
||||
if cover and not cover.startswith('https://img.itch.zone/'):
|
||||
cover = None
|
||||
updated = None
|
||||
try:
|
||||
updated = parsedate_to_datetime(item.findtext('updateDate')).date().isoformat()
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
entries.append({'source': source['id'], 'id': page, 'package': None,
|
||||
'name': item.findtext('plainTitle') or item.findtext('title') or page,
|
||||
'summary': html.unescape(re.sub('<[^>]*>', '', item.findtext('description') or '')).strip(),
|
||||
'icon': cover, 'images': {'icon': cover, 'banner': cover, 'screenshots': []},
|
||||
'page': page, 'version': None, 'version_code': None, 'min_sdk': None,
|
||||
'abis': None, 'vr': True, 'size': None, 'free': True, 'license': None,
|
||||
'updated': updated, 'downloadable': False})
|
||||
return entries
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
limit = max(0, min(int(limit), 100))
|
||||
if not limit:
|
||||
return []
|
||||
entries = {}
|
||||
tag = source.get('tag', 'openxr')
|
||||
if tag not in FEEDS:
|
||||
raise SourceError('Unsupported itch.io feed')
|
||||
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
|
||||
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
|
||||
entries.setdefault(entry['id'], entry)
|
||||
words = query.lower().split()
|
||||
return [e for e in entries.values() if all(w in (e['name'] + ' ' + e['summary']).lower()
|
||||
for w in words)][:limit]
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
entry = next((e for e in search(source, '', 100) if e['id'] == entry_id), None)
|
||||
if not entry:
|
||||
raise SourceError('Game is not in the current free Android VR feed; open its publisher page')
|
||||
return {**entry, 'versions': []}
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Parallel APK search and source selection. No device access during searches.
|
||||
|
||||
Optional store metadata: images {icon, banner, screenshots}, developer,
|
||||
description, popularity, open_source, requires_meta_services, frame_tested.
|
||||
Only an explicit frame_tested=True produces a working-on-Frame verdict.
|
||||
"""
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import pkgutil
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
|
||||
import frame_host
|
||||
from apk_sources import SourceError, SourceLimited
|
||||
|
||||
_lock = threading.RLock()
|
||||
_running = {}
|
||||
_pending = {} # source id -> newest query waiting for the running one
|
||||
_game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
|
||||
_status = {}
|
||||
TIMEOUT = 12
|
||||
|
||||
|
||||
def modules():
|
||||
import apk_sources
|
||||
found, errors = [], []
|
||||
for item in pkgutil.iter_modules(apk_sources.__path__):
|
||||
if item.name == 'search' or item.name.startswith('_'):
|
||||
continue
|
||||
try:
|
||||
module = importlib.import_module('apk_sources.' + item.name)
|
||||
if getattr(module, 'KIND', None):
|
||||
found.append(module)
|
||||
except Exception as e:
|
||||
errors.append({'id': item.name, 'name': item.name, 'enabled': False,
|
||||
'trust': 'unknown', 'status': 'error', 'error': str(e)})
|
||||
if os.environ.get('FRAME_APK_SEARCH_DEMO') == '1':
|
||||
found.append(importlib.import_module('apk_sources._demo'))
|
||||
return found, errors
|
||||
|
||||
|
||||
def settings_path():
|
||||
return frame_host.data_dir('apk-sources', 'enabled.json')
|
||||
|
||||
|
||||
def overrides():
|
||||
try:
|
||||
return json.loads(settings_path().read_text())
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
|
||||
|
||||
def registry():
|
||||
result = []
|
||||
mods, errors = modules()
|
||||
with _lock:
|
||||
enabled = overrides()
|
||||
for module in mods:
|
||||
try:
|
||||
for source in module.sources():
|
||||
source = dict(source)
|
||||
source['enabled'] = enabled.get(source['id'], source.get('enabled', True))
|
||||
source.update(_status.get(source['id'], {'status': 'not searched'}))
|
||||
result.append((module, source))
|
||||
except Exception as e:
|
||||
errors.append({'id': module.KIND, 'name': module.KIND, 'enabled': False,
|
||||
'trust': 'unknown', 'status': 'error', 'error': str(e)})
|
||||
return result, errors
|
||||
|
||||
|
||||
def sources():
|
||||
items, errors = registry()
|
||||
return [s for _, s in items] + errors
|
||||
|
||||
|
||||
def resolve(source_id):
|
||||
for module, source in registry()[0]:
|
||||
if source['id'] == source_id:
|
||||
return module, source
|
||||
raise SourceError('Unknown source')
|
||||
|
||||
|
||||
def set_enabled(source_id, enabled):
|
||||
module, source = resolve(source_id)
|
||||
if hasattr(module, 'set_enabled'):
|
||||
module.set_enabled(source_id, enabled) # never call into a source while holding _lock
|
||||
with _lock:
|
||||
values = overrides()
|
||||
values[source_id] = enabled
|
||||
path = settings_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(values))
|
||||
tmp.replace(path)
|
||||
return {'message': source['name'] + (' enabled' if enabled else ' disabled')}
|
||||
|
||||
|
||||
def repo_module():
|
||||
module = next((m for m in modules()[0] if m.KIND == 'fdroid'), None)
|
||||
if module is None or not hasattr(module, 'add_repo'):
|
||||
raise SourceError('User repositories are not available in this build')
|
||||
return module
|
||||
|
||||
|
||||
def manage_repo(action, **kwargs):
|
||||
module = repo_module()
|
||||
if not hasattr(module, action):
|
||||
raise SourceError('User repositories are not available in this build')
|
||||
return getattr(module, action)(**kwargs)
|
||||
|
||||
|
||||
def normalise(name):
|
||||
return ' '.join(re.findall(r'\w+', unicodedata.normalize('NFKC', name or '').casefold()))
|
||||
|
||||
|
||||
def fit(entry):
|
||||
sdk, abis = entry.get('min_sdk'), entry.get('abis')
|
||||
reasons = []
|
||||
blocked = (sdk is not None and sdk > 30) or (abis is not None and bool(abis) and 'arm64-v8a' not in abis)
|
||||
if sdk is not None and sdk > 30:
|
||||
reasons.append('Needs Android API %s; Lepton supports 30' % sdk)
|
||||
if abis and 'arm64-v8a' not in abis:
|
||||
reasons.append('No arm64-v8a build')
|
||||
known = sdk is not None and abis is not None
|
||||
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
|
||||
if 'vrapi' in hints:
|
||||
reasons.append('Legacy VrApi requires a translator')
|
||||
if 'godot' in hints:
|
||||
reasons.append('Older Godot builds can crash on the missing clipboard service')
|
||||
if 'openxr' in hints:
|
||||
reasons.append('OpenXR candidate; required extensions still need checking')
|
||||
if entry.get('vr'):
|
||||
reasons.append('VR runtime compatibility is not guaranteed')
|
||||
return {'installable': False if blocked else True if known else None,
|
||||
'verdict': "Won't install" if blocked else 'Installable' if known else 'Compatibility unknown',
|
||||
'reasons': reasons}
|
||||
|
||||
|
||||
def verdict(entry):
|
||||
compatibility = fit(entry)
|
||||
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
|
||||
if entry.get('requires_meta_services') is True:
|
||||
return {'label': "Needs Meta Quest services; won't run", 'tone': 'blocked'}
|
||||
if entry.get('min_sdk') is not None and entry['min_sdk'] > 30:
|
||||
return {'label': 'Might not work: needs a newer Android than the Frame has', 'tone': 'blocked'}
|
||||
if compatibility['installable'] is False:
|
||||
return {'label': "This version isn't made for the Frame", 'tone': 'blocked'}
|
||||
if 'vrapi' in hints:
|
||||
return {'label': "Made for older Quest headsets; won't run on the Frame", 'tone': 'blocked'}
|
||||
if entry.get('frame_tested') is True:
|
||||
return {'label': 'Works on the Frame', 'tone': 'works'}
|
||||
if compatibility['installable'] is True:
|
||||
return {'label': 'Ready to try on the Frame', 'tone': 'ready'}
|
||||
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
|
||||
|
||||
|
||||
def decorate(entry):
|
||||
from apk_sources import _images
|
||||
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry))
|
||||
|
||||
|
||||
def details(source_id, entry_id):
|
||||
module, source = resolve(source_id)
|
||||
return decorate(dict(module.details(source, entry_id), source=source_id,
|
||||
source_name=source['name'], trust=source.get('trust')))
|
||||
|
||||
|
||||
def offer_rank(entry):
|
||||
compatible = entry['fit']['installable'] is True
|
||||
return (entry.get('downloadable') is True and entry['fit']['installable'] is not False,
|
||||
entry.get('verified') is True, compatible,
|
||||
str(entry.get('updated') or '') if compatible else '',
|
||||
(entry.get('version_code') or 0) if compatible else 0,
|
||||
entry.get('trust') == 'official')
|
||||
|
||||
|
||||
def group(entries, query='', vr=None, installable=False):
|
||||
groups = {}
|
||||
for entry in entries:
|
||||
entry = decorate(entry)
|
||||
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
|
||||
continue
|
||||
if installable and entry['fit']['installable'] is not True:
|
||||
continue
|
||||
key = ('package', entry['package']) if entry.get('package') else ('name', normalise(entry.get('name')))
|
||||
if not key[1]:
|
||||
key = ('id', entry['source'], entry['id'])
|
||||
groups.setdefault(key, []).append(entry)
|
||||
result = []
|
||||
for offers in groups.values():
|
||||
offers.sort(key=offer_rank, reverse=True)
|
||||
best = offers[0]
|
||||
result.append({'name': best.get('name'), 'package': best.get('package'),
|
||||
'summary': best.get('summary'), 'offers': offers})
|
||||
q = normalise(query)
|
||||
|
||||
def browse(a): # a headset store: VR first, then apps with artwork, newest first
|
||||
o = a['offers'][0]
|
||||
art = o.get('images') or {}
|
||||
return (o.get('vr') is not True, not art.get('banner'), not art.get('screenshots'),
|
||||
''.join(chr(0x10ffff - ord(c)) for c in str(o.get('updated') or '')))
|
||||
if not q:
|
||||
# Unknown fit stays in the VR-first order; only apps known not to install sink.
|
||||
result.sort(key=lambda a: (all(o['fit']['installable'] is False for o in a['offers']),) + browse(a))
|
||||
return result
|
||||
result.sort(key=lambda a: (not any(normalise(o.get('name')) == q for o in a['offers']),
|
||||
not any(o['fit']['installable'] is True for o in a['offers']),
|
||||
not any(normalise(o.get('name')).startswith(q) for o in a['offers']),
|
||||
a['offers'][0].get('vr') is not True,
|
||||
normalise(a['name'])))
|
||||
return result
|
||||
|
||||
|
||||
def _launch(module, source, query, limit):
|
||||
"""One search per source at a time; the newest different query runs next."""
|
||||
key = source['id']
|
||||
task = {'event': threading.Event(), 'query': (query, limit), 'started': time.monotonic(),
|
||||
'job': (module, source)}
|
||||
with _lock:
|
||||
old = _running.get(key)
|
||||
if old and not old['event'].is_set():
|
||||
if old['query'] == task['query']:
|
||||
return old
|
||||
queued = _pending.get(key)
|
||||
if queued and queued['query'] == task['query']:
|
||||
return queued
|
||||
_pending[key] = task
|
||||
return task
|
||||
_running[key] = task
|
||||
_start(key, task)
|
||||
return task
|
||||
|
||||
|
||||
def _start(key, task):
|
||||
module, source = task['job']
|
||||
query, limit = task['query']
|
||||
|
||||
def run():
|
||||
queued = None
|
||||
try:
|
||||
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
|
||||
for e in module.search(source, query, limit=limit) if e.get('free') is True]
|
||||
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
|
||||
except Exception as e:
|
||||
task['error'] = str(e)
|
||||
task['limited'] = isinstance(e, SourceLimited)
|
||||
finally:
|
||||
with _lock: # completion and queue handover are one step for _launch
|
||||
queued = _pending.pop(key, None)
|
||||
if queued:
|
||||
_running[key] = queued
|
||||
task['event'].set()
|
||||
if queued:
|
||||
_start(key, queued)
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
|
||||
items, errors = registry()
|
||||
if source and source not in [s['id'] for _, s in items]:
|
||||
raise SourceError('Unknown source')
|
||||
chosen = [(m, s) for m, s in items if s['enabled'] and (not source or s['id'] == source)]
|
||||
# Page-only sources (SideQuest) can't be searched; offer a link to browse them instead.
|
||||
elsewhere = [{'name': s['name'], 'url': s['url']} for m, s in chosen if s.get('page_only')]
|
||||
tasks = [(s, _launch(m, s, query, limit)) for m, s in chosen if not s.get('page_only')]
|
||||
entries, statuses = [], list(errors)
|
||||
for s, task in tasks:
|
||||
status = {'id': s['id'], 'name': s['name']}
|
||||
if not task['event'].wait(max(0, task['started'] + timeout - time.monotonic())):
|
||||
# Still working (e.g. first download of a large index); it keeps going and fills the cache.
|
||||
status.update(status='loading')
|
||||
elif 'error' in task:
|
||||
status.update(status='limited' if task.get('limited') else 'error', error=task['error'])
|
||||
else:
|
||||
status.update(status='ok', stale=task['stale'])
|
||||
entries.extend(task['entries'])
|
||||
statuses.append(status)
|
||||
with _lock:
|
||||
_status[s['id']] = {k: v for k, v in status.items() if k not in ('id', 'name')}
|
||||
return {'apps': group(entries, query, vr, installable), 'sources': statuses, 'elsewhere': elsewhere}
|
||||
|
||||
|
||||
def warm():
|
||||
"""Start every enabled source's index download in the background (server start, new repo)."""
|
||||
from apk_sources import _web
|
||||
_web.prune()
|
||||
items, _ = registry()
|
||||
for m, s in items:
|
||||
if s['enabled'] and not s.get('page_only'):
|
||||
_launch(m, s, '', 50) # same as the first browse, so that search reuses it
|
||||
|
||||
|
||||
def install(source_id, entry_id, version_code=None, progress=None):
|
||||
import frame_android
|
||||
module, source = resolve(source_id)
|
||||
if not source['enabled']:
|
||||
raise SourceError('This source is disabled')
|
||||
entry = module.details(source, entry_id)
|
||||
if entry.get('free') is not True or entry.get('downloadable') is not True:
|
||||
raise SourceError('This app must be obtained from its developer page')
|
||||
if progress:
|
||||
progress('Downloading', None)
|
||||
downloaded = module.download(source, entry_id, version_code=version_code)
|
||||
obb = downloaded.get('obb') or entry.get('obb') or []
|
||||
if obb and not hasattr(frame_android, 'install_obb'):
|
||||
raise SourceError('This app needs OBB data; this build cannot install it yet')
|
||||
kwargs = {'name': entry.get('name'), 'icon_png': downloaded.get('icon_png') or entry.get('icon_png'),
|
||||
'source': source['name']}
|
||||
if 'artwork' in inspect.signature(frame_android.install).parameters:
|
||||
# The source's own image URLs (not the UI's /source-image/ proxy paths) become Steam library art.
|
||||
images = entry.get('images') if isinstance(entry.get('images'), dict) else {}
|
||||
art = {'icon': images.get('icon') or entry.get('icon'), 'banner': images.get('banner'),
|
||||
'screenshots': [u for u in images.get('screenshots') or [] if u][:4]}
|
||||
kwargs['artwork'] = downloaded.get('artwork') or {k: v for k, v in art.items() if v} or None
|
||||
if progress:
|
||||
progress('Installing', None)
|
||||
from apk_sources import _web
|
||||
try:
|
||||
_web.claim(downloaded['apk']) # no cache pruning while it installs
|
||||
except OSError as e:
|
||||
raise SourceError('The downloaded APK disappeared before installing; try again') from e
|
||||
try:
|
||||
_web.prune() # the app is the only pruner (see _web.prune)
|
||||
result = frame_android.install(downloaded['apk'], **kwargs)
|
||||
finally:
|
||||
_web.release(downloaded['apk'])
|
||||
if obb:
|
||||
# OBB files go into the app's own instance, which only exists while the app runs.
|
||||
with _lock:
|
||||
_game_data[result['package']] = list(obb)
|
||||
result = dict(result, game_data=True, message='Installed ' + (entry.get('name') or result['package']) +
|
||||
'. It also needs its game data: open it once on the Frame, then choose Add game data.')
|
||||
return result
|
||||
|
||||
|
||||
def add_game_data(package):
|
||||
import frame_android
|
||||
with _lock:
|
||||
paths = _game_data.get(package)
|
||||
if not paths:
|
||||
raise SourceError('No downloaded game data is waiting for this app; install it again from the store')
|
||||
result = frame_android.install_obb(package, paths)
|
||||
with _lock:
|
||||
_game_data.pop(package, None)
|
||||
return dict(result, message='Game data added')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""SideQuest page links only: its terms do not authorise third-party scraping.
|
||||
|
||||
No API calls, cached listings or automated downloads. See docs/sidequest.md.
|
||||
"""
|
||||
from . import SourceError
|
||||
|
||||
KIND = 'sidequest'
|
||||
URL = 'https://sidequestvr.com'
|
||||
REASON = ('SideQuest is page-only: its terms restrict scraping and unauthorised '
|
||||
'access. Browse and download with SideQuest, then import a developer-provided APK.')
|
||||
|
||||
|
||||
def sources():
|
||||
return [{'id': KIND, 'kind': KIND, 'name': 'SideQuest', 'url': URL,
|
||||
'builtin': True, 'enabled': True, 'trust': 'community',
|
||||
'page_only': True, 'reason': REASON}]
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
# Do not invent catalogue results or interpret a query as a verified free app.
|
||||
return []
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
entry_id = str(entry_id)
|
||||
if not entry_id.isascii() or not entry_id.isdecimal() or len(entry_id) > 12:
|
||||
raise SourceError('SideQuest listing ids must be numeric')
|
||||
return {'source': source['id'], 'id': entry_id, 'package': None,
|
||||
'name': 'SideQuest listing ' + entry_id, 'summary': REASON,
|
||||
'icon': None, 'page': URL + '/app/' + entry_id, 'version': None,
|
||||
'version_code': None, 'min_sdk': None, 'abis': None, 'vr': None,
|
||||
'size': None, 'free': None, 'license': None, 'updated': None,
|
||||
'downloadable': False, 'versions': [], 'tags': [], 'headsets': [],
|
||||
'images': {'icon': None, 'banner': None, 'screenshots': []}}
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
entry = details(source, entry_id)
|
||||
raise SourceError(REASON + ' ' + entry['page'])
|
||||
@@ -0,0 +1,30 @@
|
||||
// Uses index.html's api(), which sends the X-Frame-UI key every /api call needs.
|
||||
(() => {
|
||||
const get=id=>document.getElementById(id), status=get('steamGridStatus');
|
||||
function show(data) {
|
||||
status.textContent=data.environment?'SteamGridDB key is set by an environment variable.':
|
||||
data.steamgriddb_configured?'SteamGridDB key saved.':'No key configured. Source images and generated art are enabled.';
|
||||
}
|
||||
async function save(value) {
|
||||
try {show(await api('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';}
|
||||
catch(e) {status.textContent=e.message;}
|
||||
}
|
||||
get('saveSteamGridKey').onclick=()=>save(get('steamGridKey').value.trim());
|
||||
get('clearSteamGridKey').onclick=()=>save('');
|
||||
get('refreshAndroidArt').onclick=async()=>{
|
||||
const button=get('refreshAndroidArt');button.disabled=true;
|
||||
try {
|
||||
const result=await runJob('Refresh library artwork','library-artwork',()=>api('/api/android',{action:'refresh-art',all:true}));
|
||||
if (result) {
|
||||
const items=[...(result.apps||[]),...(result.titles||[])];
|
||||
const failed=items.filter(a=>a.error);
|
||||
status.textContent=failed.length?`${failed.length} of ${items.length} failed: ${failed.map(a=>(a.package||a.id)+': '+a.error).join('; ')}`:
|
||||
`Refreshed artwork for ${items.length} apps and titles.`;
|
||||
if (typeof loadAndroid==='function') loadAndroid();
|
||||
if (typeof loadTitles==='function') loadTitles();
|
||||
}
|
||||
} catch(e) {status.textContent=e.message;}
|
||||
finally {button.disabled=false;}
|
||||
};
|
||||
api('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
|
||||
})();
|
||||
@@ -8,11 +8,13 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
||||
patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
|
||||
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
"""
|
||||
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
|
||||
import frame_apk
|
||||
import frame_artwork
|
||||
import frame_host
|
||||
import tempfile
|
||||
import zipfile
|
||||
@@ -56,8 +58,12 @@ def ssh(cmd, input=None, timeout=120):
|
||||
|
||||
def shortcut_tool(*args, timeout=60):
|
||||
with open(SHORTCUTS) as f:
|
||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
|
||||
timeout=timeout).strip()
|
||||
script = f.read()
|
||||
if args and args[0] == 'render':
|
||||
with open(os.path.join(ROOT, 'frame/android/library_artwork.js')) as f:
|
||||
script = 'ART_RENDERER = ' + repr(f.read()) + '\n' + script
|
||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=script,
|
||||
timeout=timeout).strip()
|
||||
|
||||
|
||||
def instance_id(pkg):
|
||||
@@ -140,7 +146,7 @@ def _write_meta(d, meta):
|
||||
install_hooks = []
|
||||
|
||||
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None):
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
|
||||
start, info = time.time(), None
|
||||
try:
|
||||
info = apk_info(apk_path)
|
||||
@@ -161,9 +167,9 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
|
||||
patched = os.path.join(tmp, 'app.apk')
|
||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||
info['launchable'] = True
|
||||
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
|
||||
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
|
||||
else:
|
||||
meta = _install(apk_path, info, pkg, flatscreen, name, source)
|
||||
meta = _install(apk_path, info, pkg, flatscreen, name, source, artwork)
|
||||
except FrameError as e:
|
||||
_after_install(info, None, e, start)
|
||||
raise
|
||||
@@ -179,39 +185,53 @@ def _after_install(info, meta, error, start):
|
||||
pass # reporting must never change an install's outcome
|
||||
|
||||
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
try:
|
||||
images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
|
||||
except (ValueError, OSError) as e:
|
||||
raise FrameError(f'could not prepare artwork: {e}') from e
|
||||
iid = instance_id(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
existing = read_meta(pkg)
|
||||
ok = False
|
||||
ok, created = False, None
|
||||
try:
|
||||
ssh(f'mkdir -p {d}')
|
||||
_copy(apk_path, f'{d}/app.apk.part')
|
||||
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
||||
icon = ''
|
||||
if info['icon_png']:
|
||||
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
|
||||
icon = f'$HOME/{d}/icon.png'
|
||||
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
|
||||
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
|
||||
home = ssh('echo $HOME').strip()
|
||||
shortcut = _int((existing or {}).get('shortcut'))
|
||||
if not shortcut or shortcut not in _shortcut_ids():
|
||||
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
|
||||
icon.replace('$HOME', home))
|
||||
'')
|
||||
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
|
||||
if not shortcut:
|
||||
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
|
||||
created = shortcut
|
||||
presentation = apply_library(shortcut, name or info['label'], d, images,
|
||||
vr=not flatscreen, home=home,
|
||||
exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}',
|
||||
details={'package': pkg, 'version': info['version'],
|
||||
'source': source or os.path.basename(apk_path)})
|
||||
presentation['warnings'] = art_warnings + presentation.get('warnings', [])
|
||||
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
|
||||
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
||||
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
|
||||
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
|
||||
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'source': source or os.path.basename(apk_path)}
|
||||
'source': source or os.path.basename(apk_path),
|
||||
'library_warnings': presentation.get('warnings', []),
|
||||
'artwork': presentation.get('artwork', {}), 'library_version': 2}
|
||||
_write_meta(d, meta)
|
||||
ok = True
|
||||
return meta
|
||||
finally:
|
||||
if not ok and created:
|
||||
try:
|
||||
shortcut_tool('remove', str(created))
|
||||
except FrameError:
|
||||
pass
|
||||
if not ok and not existing:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
try:
|
||||
@@ -220,6 +240,121 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def apply_library(shortcut, label, directory, images, vr=None, home=None, exe='', start_dir='', details=None,
|
||||
category='Android', fill_only=False):
|
||||
"""Mandatory for every sideload: render all five slots before reporting success.
|
||||
|
||||
vr None leaves Steam's VR flag as it is (devkit titles declare their own). fill_only (automatic
|
||||
backfill) sets only slots Steam has no art for, and never the name, exe or flags."""
|
||||
home = home or ssh('echo $HOME').strip()
|
||||
d = directory
|
||||
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
|
||||
paths = {}
|
||||
for slot, (ext, data) in images.items():
|
||||
path = f'{d}/artwork/source-{slot}.{ext}'
|
||||
ssh(f'cat > {shlex.quote(path)}', input=data)
|
||||
paths[slot] = f'{home}/{path}' if not path.startswith('/') else path
|
||||
manifest = {'label': label, 'images': paths}
|
||||
plan = f'{d}/artwork/input.json'
|
||||
ssh(f'cat > {shlex.quote(plan)}', input=json.dumps(manifest))
|
||||
absolute = f'{home}/{plan}' if not plan.startswith('/') else plan
|
||||
# The Frame retries once with generated art, each attempt allowed 75 s.
|
||||
rendered = json.loads(shortcut_tool('render', absolute, timeout=200))
|
||||
art = rendered['paths']
|
||||
if set(art) != set(frame_artwork.SLOTS):
|
||||
raise FrameError('Steam artwork renderer did not produce every slot')
|
||||
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
|
||||
'' if vr is None else '1' if vr else '0', json.dumps(art),
|
||||
json.dumps({'category': category, 'details': details or {},
|
||||
'fill_only': fill_only}), timeout=120))
|
||||
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
|
||||
result['artwork'] = art
|
||||
if category == 'Android':
|
||||
ssh(f'cat > {shlex.quote(d)}/shortcut.id', input=str(int(shortcut)))
|
||||
return result
|
||||
|
||||
|
||||
def cached_art_script(d):
|
||||
"""Frame-side Python that loads the source images kept beside d's last render into `cached`."""
|
||||
return f"""import base64, json, os
|
||||
cached = {{}}
|
||||
directory = os.path.realpath({d!r})
|
||||
try:
|
||||
with open(os.path.join(directory, 'artwork/input.json')) as f:
|
||||
plan = json.load(f)
|
||||
for slot, path in plan.get('images', {{}}).items():
|
||||
if os.path.commonpath([os.path.realpath(path), directory]) != directory:
|
||||
continue
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read(12 * 1024 * 1024 + 1)
|
||||
if len(data) <= 12 * 1024 * 1024:
|
||||
cached[slot] = base64.b64encode(data).decode()
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
"""
|
||||
|
||||
|
||||
def art_missing(m):
|
||||
"""True when a Frame Control install has no complete Steam artwork on record."""
|
||||
return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
|
||||
|
||||
|
||||
def refresh_art(pkg=None, artwork=None, fill_only=False):
|
||||
"""Refresh existing APK library entries without reinstalling or stopping them.
|
||||
|
||||
fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library)."""
|
||||
if pkg is None:
|
||||
results = []
|
||||
for app in list_apps():
|
||||
try:
|
||||
results.append(refresh_art(app['package'], artwork, fill_only))
|
||||
except Exception as e: # one app's failure must not stop the others
|
||||
results.append({'package': app['package'], 'label': app.get('label'), 'error': str(e) or type(e).__name__})
|
||||
return results
|
||||
with _install_lock: # shared with install and remove: a removed app is never recreated
|
||||
m = _meta_or_fail(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
|
||||
modules = {}
|
||||
for module in ('frame_apk', 'frame_apk_vr'):
|
||||
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
|
||||
modules[module] = f.read()
|
||||
script = 'import sys, types, json, base64\n'
|
||||
for module, source in modules.items():
|
||||
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
|
||||
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
|
||||
script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
|
||||
script += cached_art_script(d) + "info['artwork'] = cached\nprint(json.dumps(info))\n"
|
||||
info = json.loads(ssh('python3 -', input=script))
|
||||
icon = base64.b64decode(info['icon_png'])
|
||||
cached = {k: base64.b64decode(v) for k, v in info.get('artwork', {}).items()}
|
||||
images, warnings = frame_artwork.prepare(m['label'], icon, artwork if artwork is not None else cached)
|
||||
home = ssh('echo $HOME').strip()
|
||||
created = False
|
||||
if not m['shortcut'] or m['shortcut'] not in _shortcut_ids():
|
||||
m['shortcut'] = _int(shortcut_tool('add', m['label'], f'{home}/{d}/launch.sh', f'{home}/{d}'))
|
||||
if not m['shortcut']:
|
||||
raise FrameError('Steam did not create the missing shortcut')
|
||||
m['game_id'] = game_id(m['shortcut'])
|
||||
created = True
|
||||
try:
|
||||
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
|
||||
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m,
|
||||
fill_only=fill_only and not created)
|
||||
except Exception:
|
||||
if created:
|
||||
try:
|
||||
shortcut_tool('remove', str(m['shortcut']))
|
||||
except FrameError:
|
||||
pass # keep the render error, not the cleanup's
|
||||
raise
|
||||
m.update(artwork=result.get('artwork', {}), library_version=2, art_pending=False)
|
||||
m['library_warnings'] = warnings + result.get('warnings', [])
|
||||
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
_write_meta(d, m)
|
||||
return m
|
||||
|
||||
def _int(v):
|
||||
try:
|
||||
n = int(v)
|
||||
@@ -268,6 +403,7 @@ def list_apps():
|
||||
continue
|
||||
if m:
|
||||
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
|
||||
m['art_missing'] = art_missing(m)
|
||||
apps.append(m)
|
||||
return sorted(apps, key=lambda m: m['label'].lower())
|
||||
|
||||
@@ -291,18 +427,30 @@ def launch(pkg):
|
||||
|
||||
def stop(pkg):
|
||||
m = _meta_or_fail(pkg)
|
||||
if m['shortcut']:
|
||||
try:
|
||||
shortcut_tool('stop', str(int(m['shortcut'])))
|
||||
except FrameError:
|
||||
pass # Steam unavailable: the container stop also ends its waiting launcher.
|
||||
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
|
||||
return m
|
||||
|
||||
|
||||
def remove(pkg, keep_data=False):
|
||||
with _install_lock: # never while an install or artwork refresh of any app is writing
|
||||
return _remove(pkg, keep_data)
|
||||
|
||||
|
||||
def _remove(pkg, keep_data):
|
||||
m = _meta_or_fail(pkg)
|
||||
stop(pkg)
|
||||
if m['shortcut']:
|
||||
# Best effort: Steam may not be running, and the files must still go.
|
||||
try:
|
||||
shortcut_tool('remove', str(int(m['shortcut'])))
|
||||
except FrameError:
|
||||
pass # already gone from Steam
|
||||
result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
|
||||
m['library_warnings'] = result.get('warnings', [])
|
||||
except (FrameError, ValueError, AttributeError) as e:
|
||||
m['library_warnings'] = [f'Steam shortcut not removed: {e}']
|
||||
iid = int(m['instance'])
|
||||
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
|
||||
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
|
||||
@@ -354,6 +502,21 @@ def patch(src, dst, add=None):
|
||||
raise FrameError(str(e)) from e
|
||||
|
||||
|
||||
def install_obb(pkg, paths):
|
||||
import frame_android_data
|
||||
return frame_android_data.install_obb(pkg, paths)
|
||||
|
||||
|
||||
def backup_data(pkg, destination):
|
||||
import frame_android_data
|
||||
return frame_android_data.backup_data(pkg, destination)
|
||||
|
||||
|
||||
def restore_data(pkg, archive):
|
||||
import frame_android_data
|
||||
return frame_android_data.restore_data(pkg, archive)
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['help']
|
||||
try:
|
||||
@@ -374,6 +537,13 @@ def main():
|
||||
elif cmd == 'install':
|
||||
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
|
||||
xr_compat=False if '--no-xr-compat' in args else None)
|
||||
elif cmd == 'refresh-art':
|
||||
if len(args) != 1:
|
||||
raise FrameError('usage: refresh-art PACKAGE or refresh-art --all')
|
||||
r = refresh_art(None if args[0] == '--all' else args[0])
|
||||
if isinstance(r, list) and any('error' in item for item in r):
|
||||
print(json.dumps(r, indent=1))
|
||||
raise SystemExit(1)
|
||||
elif cmd == 'patch':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
|
||||
@@ -389,8 +559,18 @@ def main():
|
||||
with open(path, 'rb') as f:
|
||||
additions[entry] = f.read()
|
||||
r = patch(opts.src, opts.dst, additions)
|
||||
elif cmd == 'install-obb':
|
||||
if len(args) < 2:
|
||||
raise FrameError('install-obb requires PACKAGE OBB [OBB ...]')
|
||||
r = install_obb(args[0], args[1:])
|
||||
elif cmd in ('backup-data', 'restore-data'):
|
||||
if len(args) != 2:
|
||||
raise FrameError(cmd + ' requires PACKAGE ARCHIVE.tar.gz')
|
||||
r = (backup_data if cmd == 'backup-data' else restore_data)(*args)
|
||||
elif cmd == 'list':
|
||||
r = list_apps()
|
||||
if any(a['art_missing'] for a in r):
|
||||
print('Some apps have no Steam artwork: python3 ui/frame_android.py refresh-art --all', file=sys.stderr)
|
||||
elif cmd in ('launch', 'stop', 'probe'):
|
||||
r = globals()[cmd](args[0])
|
||||
elif cmd == 'remove':
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Expansion files and stopped-instance private-data backups. Python stdlib only."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
import frame_android as android
|
||||
|
||||
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
||||
|
||||
|
||||
def _stream(command, src=None, dst=None):
|
||||
try:
|
||||
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||
stdin=src if src else subprocess.DEVNULL,
|
||||
stdout=dst if dst else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=1800)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise android.FrameError('app-data transfer timed out')
|
||||
except OSError as error:
|
||||
raise android.FrameError('app-data transfer failed: ' + str(error))
|
||||
if result.returncode:
|
||||
raise android.FrameError(result.stderr.decode(errors='replace').strip()[-600:] or
|
||||
'app-data transfer failed')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, 'rb') as src:
|
||||
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _meta(package):
|
||||
if not android.PKG_RE.fullmatch(package or ''):
|
||||
raise android.FrameError('invalid package name')
|
||||
meta = android._meta_or_fail(package)
|
||||
if meta['package'] != package:
|
||||
raise android.FrameError('installed app metadata has a different package')
|
||||
return meta
|
||||
|
||||
|
||||
def install_obb(package, paths):
|
||||
if not android.PKG_RE.fullmatch(package or ''):
|
||||
raise android.FrameError('invalid package name')
|
||||
paths = [Path(p).resolve() for p in paths]
|
||||
if not paths:
|
||||
raise android.FrameError('select at least one OBB file')
|
||||
names = set()
|
||||
for path in paths:
|
||||
if (not re.fullmatch(r'(main|patch)\.[0-9]+\.' + re.escape(package) + r'\.obb', path.name)
|
||||
or not path.is_file() or path.stat().st_size == 0 or path.name in names):
|
||||
raise android.FrameError('OBB must be a nonempty main/patch.<version>.' + package + '.obb file')
|
||||
names.add(path.name)
|
||||
with android._install_lock:
|
||||
meta = _meta(package)
|
||||
container = 'lepton-steamlaunch-' + str(int(meta['instance']))
|
||||
# No implicit launch. Android resolves /sdcard, never an assumed host path.
|
||||
running = android.ssh('podman ps --format "{{.Names}}"').splitlines()
|
||||
if container not in running:
|
||||
raise android.FrameError('start this app instance before installing OBB data')
|
||||
dest = '/sdcard/Android/obb/' + package
|
||||
results = []
|
||||
for path in paths:
|
||||
digest = _sha256(path)
|
||||
part = dest + '/.frame-' + uuid.uuid4().hex + '.part'
|
||||
target = dest + '/' + path.name
|
||||
script = (f'set -eu; mkdir -p {dest}; umask 002; '
|
||||
f'trap "rm -f {part}" EXIT; cat > {part}; '
|
||||
f'test "$(sha256sum {part} | cut -d " " -f 1)" = {digest}; '
|
||||
f'chmod 664 {part}; mv {part} {target}')
|
||||
command = shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script])
|
||||
with path.open('rb') as src:
|
||||
_stream(command, src=src)
|
||||
results.append({'name': path.name, 'path': target, 'sha256': digest})
|
||||
return {'package': package, 'instance': meta['instance'], 'obb': results,
|
||||
'verified': True}
|
||||
|
||||
|
||||
def _data_command(action, meta):
|
||||
container = 'lepton-steamlaunch-' + str(int(meta['instance']))
|
||||
# Fail closed if podman cannot enumerate containers; don't mistake errors for stopped.
|
||||
guard = ('running=$(podman ps --format "{{.Names}}") || exit 1; '
|
||||
f'if printf "%s\\n" "$running" | grep -Fxq {shlex.quote(container)}; then '
|
||||
'echo "stop the app before backup or restore" >&2; exit 1; fi; ')
|
||||
return guard + shlex.join(['podman', 'unshare', 'python3', '-c', REMOTE.read_text(),
|
||||
action, meta['package'], str(int(meta['instance']))])
|
||||
|
||||
|
||||
def backup_data(package, destination):
|
||||
destination = Path(destination).expanduser().absolute()
|
||||
if destination.exists():
|
||||
raise android.FrameError('backup destination already exists')
|
||||
with android._install_lock:
|
||||
meta = _meta(package)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.frame-backup-', dir=str(destination.parent))
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as dst:
|
||||
_stream(_data_command('backup', meta), dst=dst)
|
||||
result = _inspect(temporary, meta)
|
||||
# Exclusive publication: a concurrently created backup is never overwritten.
|
||||
os.link(temporary, str(destination))
|
||||
return dict(result, path=str(destination), sha256=_sha256(destination))
|
||||
finally:
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def _inspect(path, meta):
|
||||
import tarfile
|
||||
try:
|
||||
return runpy.run_path(str(REMOTE))['inspect_archive'](path, meta['package'], int(meta['instance']))
|
||||
except (OSError, EOFError, ValueError, tarfile.TarError) as error:
|
||||
raise android.FrameError('invalid app-data backup: ' + str(error))
|
||||
|
||||
|
||||
def restore_data(package, archive):
|
||||
with android._install_lock:
|
||||
meta = _meta(package)
|
||||
_inspect(archive, meta)
|
||||
with open(archive, 'rb') as src:
|
||||
result = _stream(_data_command('restore', meta), src=src)
|
||||
try:
|
||||
return json.loads(result)
|
||||
except (ValueError, TypeError):
|
||||
raise android.FrameError('could not read restore result; inspect app data before retrying')
|
||||
@@ -255,7 +255,10 @@ def _icon_png(z, names, icons):
|
||||
# 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),
|
||||
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__':
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
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 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_host
|
||||
@@ -632,7 +632,7 @@ def _rsync():
|
||||
_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.
|
||||
|
||||
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)
|
||||
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:
|
||||
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."""
|
||||
if 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)
|
||||
step = progress or (lambda *a: None)
|
||||
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']
|
||||
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||
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)
|
||||
ensure_utils()
|
||||
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}'):
|
||||
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
|
||||
registered = False
|
||||
library_ready, shortcut, steam_registered = False, None, False
|
||||
try:
|
||||
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
|
||||
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))}',
|
||||
timeout=90), 'steam-client-create-shortcut')
|
||||
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)
|
||||
registered = True # the files stay: Steam registers them once it's running
|
||||
if 'error' in reply:
|
||||
err = str(reply['error']).strip().rstrip('.')
|
||||
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}")
|
||||
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)
|
||||
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
|
||||
return meta
|
||||
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:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
try:
|
||||
@@ -706,6 +738,90 @@ def _install(plan, step):
|
||||
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'''
|
||||
import json, os
|
||||
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'),
|
||||
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
|
||||
'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
|
||||
|
||||
|
||||
@@ -772,9 +890,20 @@ def remove(gid):
|
||||
try:
|
||||
gid = _check_id(gid)
|
||||
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'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}
|
||||
finally:
|
||||
_install_lock.release()
|
||||
@@ -815,8 +944,15 @@ def main():
|
||||
progress=lambda text, _: print(text + '…', file=sys.stderr))
|
||||
elif cmd == 'list':
|
||||
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:
|
||||
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:
|
||||
sys.exit(__doc__)
|
||||
except FrameError as e:
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
.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.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; }
|
||||
textarea { resize: vertical; min-height: 76px; }
|
||||
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-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 input[type=text] { 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 input[type=text], .cat-tools input[type=search] { flex: 1 1 240px; width: auto; }
|
||||
.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; }
|
||||
.vchips { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 14px; }
|
||||
.vchip { height: 26px; font-size: 12px; padding: 0 10px; opacity: .55; }
|
||||
@@ -416,7 +416,7 @@
|
||||
button { height: 38px; }
|
||||
button.small { height: 32px; }
|
||||
.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-keys .seg { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 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. */
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -706,9 +855,16 @@
|
||||
|
||||
<div class="page" data-page="android">
|
||||
<section id="android">
|
||||
<div class="shelf-head"><h2>Android apps</h2><span class="count" id="andCount"></span><span class="spacer"></span>
|
||||
<button class="action small" id="repNew" title="Report how any APK worked on the Frame">+ Report an APK</button></div>
|
||||
<div class="and-grid">
|
||||
<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>
|
||||
<section id="appStore" aria-label="Discover apps">
|
||||
<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="panel">
|
||||
<div class="shelf-head"><h2>On the Frame</h2></div>
|
||||
@@ -759,8 +915,8 @@
|
||||
reported, including ones not on F-Droid.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
|
||||
<div hidden>
|
||||
<div hidden> <div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
|
||||
<div class="cat-tools">
|
||||
<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>
|
||||
@@ -769,7 +925,7 @@
|
||||
<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="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>
|
||||
</section>
|
||||
@@ -839,6 +995,19 @@
|
||||
</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">
|
||||
<div class="shelf-head"><h2>Remote & power</h2></div>
|
||||
<div class="actions">
|
||||
@@ -922,6 +1091,18 @@
|
||||
<span class="last" id="lastLog">Ready</span>
|
||||
<span class="sub" id="drawerHint">Show ▴</span>
|
||||
</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">
|
||||
<h2 id="apkAltTitle">Try another APK version</h2>
|
||||
<p id="apkAltReason"></p>
|
||||
@@ -2166,7 +2347,7 @@ function showJobs() {
|
||||
$("jobs").hidden = !jobsRunning;
|
||||
$("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();
|
||||
log(label + "…");
|
||||
try {
|
||||
@@ -2176,6 +2357,7 @@ async function runJob(label, key, start) {
|
||||
let j;
|
||||
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; }
|
||||
onProgress(j);
|
||||
if (!j.done) continue;
|
||||
if (j.error) throw new Error(j.error);
|
||||
log(j.message, "ok"); toast(j.message);
|
||||
@@ -2538,6 +2720,7 @@ async function loadTitles() {
|
||||
<div class="item">
|
||||
<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>
|
||||
${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="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
|
||||
</div>`).join("") : `<div class="sub">None yet.</div>`;
|
||||
@@ -2549,6 +2732,9 @@ $("titleList").onclick = async e => {
|
||||
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
|
||||
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
|
||||
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);
|
||||
};
|
||||
loadTitles();
|
||||
@@ -2699,6 +2885,157 @@ document.body.addEventListener("click", async e => {
|
||||
});
|
||||
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 ----
|
||||
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: [] };
|
||||
@@ -2719,6 +3056,7 @@ async function loadAndroid() {
|
||||
<div class="row and-btns">
|
||||
${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>`}
|
||||
${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="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>
|
||||
@@ -2802,6 +3140,8 @@ document.body.addEventListener("click", async e => {
|
||||
} else if (action === "remove") {
|
||||
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);
|
||||
} 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") {
|
||||
openReport({ package: pkg, version: b.dataset.ver, label: b.dataset.name, source: b.dataset.src });
|
||||
return;
|
||||
@@ -3691,5 +4031,6 @@ setView("headset");
|
||||
refresh().then(loadShots); // after status, so app names resolve
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||
</script>
|
||||
<script src="/artwork-settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -42,8 +42,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import frame_agent # noqa: E402
|
||||
import frame_assistant # noqa: E402
|
||||
import frame_android # noqa: E402
|
||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_steamgriddb
|
||||
import frame_comfort # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
@@ -162,7 +164,41 @@ _jobs_lock = threading.Lock()
|
||||
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
|
||||
|
||||
|
||||
def start_job(label, work):
|
||||
_backfill = {"running": False, "last": 0.0}
|
||||
_backfill_lock = threading.Lock()
|
||||
|
||||
|
||||
def backfill_art(apps=(), titles=()):
|
||||
"""Apply art Frame Control couldn't at install time (Steam wasn't running), once Steam is up.
|
||||
|
||||
Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or
|
||||
names the user may have customised. Runs in the background, at most once every five minutes."""
|
||||
pkgs = [a["package"] for a in apps if a.get("art_pending")]
|
||||
gids = [t["id"] for t in titles if t.get("art_pending")]
|
||||
with _backfill_lock:
|
||||
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
|
||||
return False
|
||||
_backfill.update(running=True, last=time.time())
|
||||
|
||||
def run():
|
||||
try:
|
||||
frame_android.shortcut_tool("list") # Steam isn't up: try again on a later listing
|
||||
for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \
|
||||
[(frame_titles.refresh_art, g) for g in gids]:
|
||||
try:
|
||||
refresh(key, fill_only=True)
|
||||
except Exception as e:
|
||||
print(f"artwork backfill for {key}: {e}", file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
with _backfill_lock:
|
||||
_backfill["running"] = False
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
def start_job(label, work, progress=False):
|
||||
"""Run work() in the background. It returns a dict with a "message"."""
|
||||
now = time.time()
|
||||
with _jobs_lock:
|
||||
@@ -171,14 +207,20 @@ def start_job(label, work):
|
||||
job = secrets.token_hex(8)
|
||||
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
|
||||
|
||||
def report(stage, percent=None):
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(stage=stage, percent=percent)
|
||||
|
||||
def run():
|
||||
fields = {}
|
||||
try:
|
||||
result = work()
|
||||
result = work(report) if progress else work()
|
||||
fields = {"message": result.get("message") or f"{label}: done", "result": result}
|
||||
except (Failure, frame_android.FrameError) as e:
|
||||
fields = {"error": unreachable(str(e)) or str(e)}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
except SourceError as e: # already user-readable, and about a store, not the Frame
|
||||
fields = {"error": str(e)}
|
||||
except Exception as e:
|
||||
fields = {"error": f"{type(e).__name__}: {e}"}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
@@ -1116,6 +1158,14 @@ def android(body):
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||
"app": m}
|
||||
return start_job(f"Install {pkg}", work)
|
||||
if action == "refresh-art":
|
||||
if not pkg and not body.get("all"):
|
||||
raise Failure('choose a package or all apps', 400)
|
||||
if not body.get('all'):
|
||||
return start_job('Refresh Steam artwork', lambda: {'apps': [frame_android.refresh_art(pkg)]})
|
||||
# Everything Frame Control sideloaded: Android apps and devkit titles.
|
||||
return start_job('Refresh Steam artwork', lambda: {
|
||||
'apps': frame_android.refresh_art(), 'titles': frame_titles.refresh_art()})
|
||||
if action in ("launch", "stop"):
|
||||
m = getattr(frame_android, action)(pkg)
|
||||
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
||||
@@ -1260,12 +1310,14 @@ def titles(body):
|
||||
threading.Thread(target=_run_title_install, daemon=True,
|
||||
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
|
||||
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
||||
if action not in ("launch", "remove"):
|
||||
if action not in ("launch", "remove", "refresh-art"):
|
||||
raise Failure("unknown action", 400)
|
||||
gid = str(body.get("id", ""))
|
||||
if not frame_titles.ID_RE.match(gid):
|
||||
raise Failure("bad title id", 400)
|
||||
ensure_master()
|
||||
if action == "refresh-art":
|
||||
return start_job(f"Steam artwork for {gid}", lambda: {"titles": [frame_titles.refresh_art(gid)]})
|
||||
try:
|
||||
m = getattr(frame_titles, action)(gid)
|
||||
except frame_android.FrameError as e:
|
||||
@@ -1950,9 +2002,84 @@ def agent_approval(body):
|
||||
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
|
||||
|
||||
|
||||
POST = {"/api/vr": vr, "/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
||||
"/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||
def source_text(body, key, optional=False):
|
||||
value = body.get(key)
|
||||
if optional and value in (None, ''):
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > 2000:
|
||||
raise Failure('Provide a valid ' + key, 400)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def source_search(query):
|
||||
args = parse_qs(query)
|
||||
q = args.get('q', [''])[0]
|
||||
vr = args.get('vr', [''])[0]
|
||||
installable = args.get('installable', [''])[0]
|
||||
if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'):
|
||||
raise Failure('Invalid search filters', 400)
|
||||
try:
|
||||
return apk_search.search(q, vr=None if not vr else vr in ('true', '1'),
|
||||
source=args.get('source', [None])[0], installable=installable in ('true', '1'))
|
||||
except SourceError as e:
|
||||
raise Failure(str(e), 400)
|
||||
|
||||
|
||||
def source_install(body):
|
||||
source, entry = source_text(body, 'source'), source_text(body, 'id')
|
||||
version = body.get('version_code')
|
||||
if version is not None and (type(version) is not int or version < 0):
|
||||
raise Failure('version_code must be a non-negative integer', 400)
|
||||
try:
|
||||
_, selected = apk_search.resolve(source)
|
||||
if not selected['enabled']:
|
||||
raise SourceError('This source is disabled')
|
||||
except SourceError as e:
|
||||
raise Failure(str(e), 400)
|
||||
return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
|
||||
|
||||
|
||||
def source_manage(body):
|
||||
action = body.get('action')
|
||||
try:
|
||||
if action == 'enable':
|
||||
if type(body.get('enabled')) is not bool:
|
||||
raise Failure('enabled must be true or false', 400)
|
||||
return apk_search.set_enabled(source_text(body, 'source'), body['enabled'])
|
||||
if action == 'add':
|
||||
url = source_text(body, 'url')
|
||||
fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True)
|
||||
parts = urlparse(url)
|
||||
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username:
|
||||
raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400)
|
||||
apk_search.repo_module() # fail now if this build can't manage repositories
|
||||
|
||||
def add(): # downloads and verifies the whole index: a job, not a request
|
||||
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name)
|
||||
message = 'Added ' + source['name']
|
||||
if source.get('trust_on_first_use'):
|
||||
message += '. Trusted on first use: ' + source['fingerprint'].upper()
|
||||
return {'message': message, 'source': {k: source.get(k) for k in
|
||||
('id', 'name', 'fingerprint', 'trust_on_first_use')}}
|
||||
return start_job('Add repository', add)
|
||||
if action == 'game-data':
|
||||
package = source_text(body, 'package')
|
||||
return start_job('Add game data', lambda: apk_search.add_game_data(package))
|
||||
if action == 'remove':
|
||||
apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source'))
|
||||
return {'message': 'Repository removed'}
|
||||
raise Failure('Unknown source action', 400)
|
||||
except SourceError as e:
|
||||
raise Failure(str(e), 400)
|
||||
|
||||
|
||||
POST = {
|
||||
"/api/vr": vr,
|
||||
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
@@ -2064,6 +2191,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/settings/artwork":
|
||||
self.send_json(frame_steamgriddb.settings())
|
||||
elif path == "/artwork-settings.js":
|
||||
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
|
||||
elif path == "/assistant":
|
||||
page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
|
||||
self.send_bytes(page.encode(), "text/html; charset=utf-8")
|
||||
@@ -2074,14 +2205,35 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||
elif path.startswith("/source-image/"):
|
||||
from apk_sources import _images
|
||||
try:
|
||||
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
||||
except Exception:
|
||||
self.send_json({"error": "Artwork unavailable"}, 404)
|
||||
elif path == "/api/sources/details":
|
||||
args = parse_qs(url.query)
|
||||
try:
|
||||
self.send_json(apk_search.details(source_text({k: v[0] for k, v in args.items()}, "source"),
|
||||
source_text({k: v[0] for k, v in args.items()}, "id")))
|
||||
except SourceError as e:
|
||||
raise Failure(str(e), 400)
|
||||
elif path == "/api/sources":
|
||||
self.send_json({"sources": apk_search.sources()})
|
||||
elif path == "/api/search":
|
||||
self.send_json(source_search(url.query))
|
||||
elif path == "/api/apk-versions":
|
||||
self.send_json(apk_versions(url.query))
|
||||
elif path == "/api/android":
|
||||
ensure_master()
|
||||
self.send_json({"apps": frame_android.list_apps()})
|
||||
apps = frame_android.list_apps()
|
||||
backfill_art(apps=apps)
|
||||
self.send_json({"apps": apps})
|
||||
elif path == "/api/titles":
|
||||
ensure_master()
|
||||
self.send_json({"titles": frame_titles.list_titles()})
|
||||
titles_list = frame_titles.list_titles()
|
||||
backfill_art(titles=titles_list)
|
||||
self.send_json({"titles": titles_list})
|
||||
elif path == "/api/titles/job":
|
||||
self.send_json(title_job(url.query))
|
||||
elif path == "/api/licenses":
|
||||
@@ -2323,6 +2475,7 @@ def main():
|
||||
args = ap.parse_args()
|
||||
httpd = LoopbackServer(("127.0.0.1", args.port), Handler)
|
||||
sweep_tmp()
|
||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||
frame_telemetry.start()
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
|
||||