mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 17:01:30 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1989acd3a3 | ||
|
|
04faa0be65 | ||
|
|
1399d7c5a3 | ||
|
|
fa5c5b32ce | ||
|
|
0228516a2a | ||
|
|
960da4469b |
No files matched your search
@@ -274,6 +274,15 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
`working-config.yml` issue → maintainer label `catalog-accepted` (a plain issue needs `working-config` added by
|
||||
hand) → `catalog-from-issue.yml` workflow (`scripts/catalog_from_issue.py` validates) opens a catalog PR. App log:
|
||||
`core/applog.py` (`<data>/logs/app.log`, finished GUI jobs in `<data>/logs/jobs/`).
|
||||
- Announcements (2026-10-15, announcements/README.md; app only, the owner dropped the site pop-up): source of
|
||||
truth `announcements/current.md` on `main` (front matter id/title/date + optional image/image_alt/button+url/
|
||||
expires/active, Markdown body; ships `active: false`). `announcements.py` (no Flet; raw GitHub via
|
||||
`cache.cached_text`, 6 h) + `ui/announcer.py` (pop-up 20 s after start / every 6 h when no dialog is open and not
|
||||
on welcome; `C.dialog` with the portal edge, image downloaded + Pillow-checked into `<data>/announcements/`, shown
|
||||
by asset URL). Shown only if the user was around before `date` (setting `announce.since`, set once at the GUI's
|
||||
first start: 0 when `USED_BEFORE` settings exist, else now), once: any close (X, outside click, Escape, button)
|
||||
stores the id (`announce.dismissed`, last 50). New announcement = new id; same id = typo fix.
|
||||
`FRAMEPORT_NO_ANNOUNCEMENTS`; `ui_smoke.py --announcement` renders it.
|
||||
- Self-update (docs/ARCHITECTURE.md "Self-update", docs/INSTALL.md "Updating"): `_version.py` = the only version
|
||||
(`frameport.__version__`; pyproject reads it via hatch `dynamic`; app log, diagnostics, User-Agent, Settings use
|
||||
it — never `importlib.metadata`, bundles have no dist-info). `updates.py` (no Flet): `check()` = GitHub
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Announcements
|
||||
|
||||
`current.md` is the one announcement FramePort shows as a pop-up in the app. Edit it on `main`: the app reads it
|
||||
from GitHub `main` (raw.githubusercontent.com) at most every 6 hours, so a push reaches everyone without a release.
|
||||
|
||||
## Who sees it
|
||||
|
||||
- **Only people who already used FramePort before the announcement's `date`.** A new install never shows one, and
|
||||
nor does an announcement published before FramePort was installed.
|
||||
- **Once.** Closing it in any way (the X, a click outside it, Escape, or its button) dismisses it for good. The app
|
||||
remembers dismissals in its settings (`announce.dismissed`).
|
||||
- **A new announcement = a new `id`.** Fixing a typo with the same `id` doesn't show it again to anyone who closed it.
|
||||
A new `id` shows to everyone who used FramePort before its `date`, whether they closed the earlier one or not.
|
||||
|
||||
## The file
|
||||
|
||||
```markdown
|
||||
---
|
||||
active: true # false = nothing is shown (the fields are still checked)
|
||||
id: meetup-2026-11 # letters, digits, . _ - — change it for every new announcement
|
||||
title: Steam Frame meetup on Discord # required
|
||||
date: 2026-11-02T18:00:00Z # required: when it was published (UTC; a bare date = midnight UTC)
|
||||
image: announcements/meetup.png # optional: a file in this folder, or an https URL (PNG, JPEG, WebP, GIF; ≤ 5 MiB)
|
||||
image_alt: The meetup poster # optional: what the picture shows (screen readers)
|
||||
button: Join the meetup # optional: an action button, with url
|
||||
url: https://example.com/meetup
|
||||
expires: 2026-11-30 # optional: not shown after this day (UTC, inclusive)
|
||||
---
|
||||
The body, in Markdown: **bold**, *italic*, `code`, [links](https://frameport.app/), lists, headings.
|
||||
```
|
||||
|
||||
The body is Markdown (no HTML). A `date` in the future works as a schedule: nobody sees it before then.
|
||||
|
||||
Images look best at about 2:1 (e.g. 1200×600). The pop-up is 600 px wide and shows between 140 and 320 px of the
|
||||
picture's height.
|
||||
|
||||
`pytest tests/test_announcements.py` checks this file, so a broken announcement fails CI instead of silently showing
|
||||
nothing.
|
||||
|
||||
To turn announcements off for testing: `FRAMEPORT_NO_ANNOUNCEMENTS=1`.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
# The announcement FramePort shows once (announcements/README.md). Switched off: set active: true.
|
||||
active: false
|
||||
id: example-2026-10
|
||||
title: Example announcement
|
||||
date: 2026-10-15T12:00:00Z
|
||||
# image: announcements/example.png
|
||||
# image_alt: What the picture shows
|
||||
# button: Read more
|
||||
# url: https://frameport.app/changelog/
|
||||
# expires: 2026-11-30
|
||||
---
|
||||
This is where the text goes, in **Markdown**: *emphasis*, `code`, [links](https://frameport.app/) and lists.
|
||||
|
||||
- Keep it short: one or two paragraphs.
|
||||
- Change `id` for every new announcement.
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.CreaTeam.Sclerosis
|
||||
title: Sclerosis
|
||||
status: works
|
||||
notes: Copy the "Amnesia The Dark Descent" folder from a Windows/Proton install to "FramePort/com.CreaTeam.Sclerosis/lepton-data/external".
|
||||
Default hand positions are rather askew and should be amended via VR Controller Mappings in Steam itself. FOV
|
||||
warping effects are EXTREMELY disorienting in VR, beware. Otherwise seems to work without issue.
|
||||
tested_version: 1.10.7
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: e17421ad9894f5e57fb9b1b8110544715166756ef924b5a86831f6f20e70a6d5
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
issue: 339
|
||||
source_hint: Sclerosis_v1.10.7
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.Curve.LawnMowingSimulatorVR
|
||||
title: Lawn Mowing Simulator VR
|
||||
status: issues
|
||||
notes: Left eye textures flicker only when mowing in specific areas.
|
||||
tested_version: 1.0.50
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
pose_consistency: 1
|
||||
hide_space_warp: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 2653e9b1dfbd11d607b5dd17b9c07ab19d9e98e1004ef9b14cb52867b5f959dc
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 344
|
||||
source_hint: com.Curve.LawnMowingSimulatorVR
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.HypetrainDigitalLtd.Breathedge
|
||||
title: Breathedge VR
|
||||
status: works
|
||||
notes: Loading fade is broken and only shows a small square but the game works!
|
||||
tested_version: 1.0.7
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 4975a32fbf36b4d417142a944bf7d771c18a184dc0320b5937ac2c67bd013575
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 347
|
||||
source_hint: Breathadge
|
||||
@@ -0,0 +1,28 @@
|
||||
package: com.Miragesoft.RealVrFishingQ
|
||||
title: Real VR Fishing
|
||||
status: issues
|
||||
notes: Seems to be almost 100% functional with meta permissions enabled. In the new Mixed Reality mode, the room
|
||||
scan doesn't seem to do anything and I can't seem to manually place anything either.
|
||||
tested_version: '3.06096'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.vivox_audio_route
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
- frame.meta_permissions
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
scene_emul: 1
|
||||
controller_models: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 8b17dd26ef0486618ec006f9d948a60dabea4d7763bbe6061db07ee8aad27c76
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 91
|
||||
issue: 333
|
||||
source_hint: Real VR Fishing
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.SchellGames.LostRecipes
|
||||
title: Lost Recipes
|
||||
status: works
|
||||
tested_version: 1.0.1+273175
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.unreal_skip_obb_check
|
||||
- frame.hw_video_decode
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: f071a1deea3e3b6dce0c3a923127f540a48b88532106536c1838f496e9d3e6c9
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 340
|
||||
source_hint: Lost Recipes
|
||||
@@ -1,22 +1,24 @@
|
||||
package: com.UNIVRS.Freedom
|
||||
title: Freedom
|
||||
status: issues
|
||||
notes: Volume can't be changed in the game.
|
||||
title: 'Attack on Titan VR: Unbreakable'
|
||||
status: works
|
||||
notes: Keep the extra files that come with the APK and OBB next to the OBB (they hold the audio). Cutscenes are
|
||||
detached from the head but still viewable.
|
||||
tested_version: 1.0.2950
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.asset_files
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 24662272c4cbe5dc7c4f3c803bdb9dead9661ac3d59bf80cca2e260256acc4bc
|
||||
app: 0.12.0
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 1b3ced1a24b3b708f66aba6aa879863b667c0e502ef153d8a8cfa03e529d21fd
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 132
|
||||
source_hint: AOT VR
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 337
|
||||
source_hint: Aot
|
||||
@@ -0,0 +1,21 @@
|
||||
package: com.crytek.climb
|
||||
title: The Climb
|
||||
status: works
|
||||
tested_version: '666.0'
|
||||
engine: CryEngine
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.vrapi_bridge
|
||||
device_files:
|
||||
user.cfg: 'r_vr_foveated = 0
|
||||
|
||||
'
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 3899967dd6d3ee0ce3409e94d543405c779e9c282ead29536abe93b3531ca285
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 341
|
||||
source_hint: TheClimb
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.fasttravelgames.mascot
|
||||
title: 'Cities: VR'
|
||||
status: works
|
||||
notes: Loading screen Cities VR Logo doesn't load right.
|
||||
tested_version: 1.00.502
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 5537e4e9333a04eda8c1b2f96cdc9572c1bbed52a9d3af42269ba442ce9ba801
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 345
|
||||
source_hint: CitiesSkylines
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.s3d.ExplodingKittensVR.TestRelease
|
||||
title: Exploding Kittens VR
|
||||
status: issues
|
||||
notes: Requires online for most modes. Practice lobby does work though.
|
||||
tested_version: 1.4.25779
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 4b0a2348a5553851dcec820835e02c93a1a64927a30533d554943a9b81b3baa0
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 343
|
||||
source_hint: com.s3d.ExplodingKittensVR.TestRelease
|
||||
@@ -0,0 +1,24 @@
|
||||
package: jp.co.hit_point.vneko
|
||||
title: Neko Atsume Purrfect
|
||||
status: works
|
||||
tested_version: 2.3.0
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
controller_models: 1
|
||||
haptic_fix: 1
|
||||
scene_emul: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: da0196efa495d6ea7b34be2cea5b279acf23489134bb1dd1acef7cf54aa57da6
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 338
|
||||
source_hint: jp.co.hit_point.vneko
|
||||
@@ -71,6 +71,13 @@ signatures:
|
||||
severity: fatal
|
||||
diagnosis: This app is a website, not a game, and it can't open on the Frame. It is a Trusted Web Activity that opens Meta's browser (com.oculus.browser), which the Frame doesn't have; open the website (TWALauncherActivity's "Using URL" line) in a browser instead.
|
||||
suggest: []
|
||||
- id: gvr-not-supported
|
||||
# GitHub #335 (Blade Runner: Revelations, Daydream, Unity 2017.4): Unity's "Google VR Error [Daydream]", the GVR
|
||||
# library's Java side missing on the 32-bit bridge, or a bind to Daydream's VrCore service
|
||||
pattern: 'Google VR Error \[(Daydream|Cardboard)\]|Google VR had a fatal issue while loading|class com/google/vr/sdk/base/CardboardViewNativeImpl not found|act=com\.google\.vr\.vrcore\.\S+ pkg=com\.google\.vr\.vrcore'
|
||||
severity: fatal
|
||||
diagnosis: This is a Google Daydream / Cardboard VR build (Google VR SDK), and its VR can't reach the Frame's headset. Daydream needs Google's VR services (VrCore) and Cardboard draws for a phone in a viewer; FramePort has no translation for either, so the game runs as a flat window at best, usually without working controls. An Oculus build of the same game (Meta Quest, Oculus Go or Gear VR) goes through FramePort's normal path.
|
||||
suggest: []
|
||||
- id: unity-render-crash
|
||||
pattern: 'CRASH\s*:.*#\d+\s+pc [0-9a-f]+\s+\S*libgallium_dri\.so'
|
||||
severity: fatal
|
||||
|
||||
+10
-1
@@ -13,6 +13,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Arkio | Quest | ✅ Works | |
|
||||
| Asgard's Wrath 2 | Quest | ✅ Works | |
|
||||
| Assassin's Creed Nexus | Quest | ✅ Works | |
|
||||
| Attack on Titan VR: Unbreakable | Quest | ✅ Works | |
|
||||
| Audica | Quest | ✅ Works | |
|
||||
| AugmentedEmpire | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
@@ -29,8 +30,10 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| BlazeRush | Quest | ✅ Works | |
|
||||
| BodyCombat | Quest | ✅ Works | |
|
||||
| BONELAB | Quest | ✅ Works | |
|
||||
| Breathedge VR | Quest | ✅ Works | |
|
||||
| Carve Snowboarding | Quest | ✅ Works | |
|
||||
| Caves | Quest | ✅ Works | |
|
||||
| Cities: VR | Quest | ✅ Works | |
|
||||
| COMPOUND | Quest | ✅ Works | |
|
||||
| Cook-Out | Quest | ✅ Works | |
|
||||
| Cosmodread | Quest | ✅ Works | |
|
||||
@@ -73,6 +76,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Ladder | Quest | ✅ Works | |
|
||||
| Lambda1VR | Quest | ✅ Works | |
|
||||
| LEGO® Bricktales | Quest | ✅ Works | |
|
||||
| Lost Recipes | Quest | ✅ Works | |
|
||||
| Lucky's Tale | Quest | ✅ Works | |
|
||||
| Maestro | Quest | ✅ Works | |
|
||||
| Marvel's Deadpool VR | Quest | ✅ Works | |
|
||||
@@ -86,6 +90,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Moss: Book II | Quest | ✅ Works | |
|
||||
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
|
||||
| Nano | Quest | ✅ Works | |
|
||||
| Neko Atsume Purrfect | Quest | ✅ Works | |
|
||||
| NEX Player | Quest | ✅ Works | |
|
||||
| NFL PRO ERA II | Quest | ✅ Works | |
|
||||
| NOPE CHALLENGE | Quest | ✅ Works | |
|
||||
@@ -108,6 +113,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Riven | Quest | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| RUINSMAGUS | Quest | ✅ Works | |
|
||||
| Sclerosis | Quest | ✅ Works | |
|
||||
| Shadow Point | Quest | ✅ Works | |
|
||||
| Shoot The Zombirds VR | Quest | ✅ Works | |
|
||||
| Shores of Loci | Quest | ✅ Works | |
|
||||
@@ -128,6 +134,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Tea For God | Quest | ✅ Works | |
|
||||
| TetrisEffect | Quest | ✅ Works | |
|
||||
| The Boys VR | Quest | ✅ Works | |
|
||||
| The Climb | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
| The Last Clockwinder | Quest | ✅ Works | |
|
||||
| The Room VR | Quest | ✅ Works | |
|
||||
@@ -158,10 +165,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Does it Stack? | Quest | ⚠️ Works with issues | Mixed reality mode does not work (works on Demeo), everything else seems to be OK |
|
||||
| Dungeons of Eternity | Quest | ⚠️ Works with issues | It can not connect to servers |
|
||||
| End Space | Quest | ⚠️ Works with issues | Controls stop responding after the first mission loads. |
|
||||
| Exploding Kittens VR | Quest | ⚠️ Works with issues | Requires online for most modes. |
|
||||
| ExploreVR | Quest | ⚠️ Works with issues | The 3D world warps and shifts with head movement (wrong perspective). |
|
||||
| Fisherman | Quest | ⚠️ Works with issues | Loading screens have corruption, but everything works otherwise. |
|
||||
| Freedom | Quest | ⚠️ Works with issues | Volume can't be changed in the game. |
|
||||
| Homeworld: Vast Reaches | Quest | ⚠️ Works with issues | The game starts but after selecting to start a new game it seems to crash. |
|
||||
| Lawn Mowing Simulator VR | Quest | ⚠️ Works with issues | Left eye textures flicker only when mowing in specific areas. |
|
||||
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
|
||||
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
|
||||
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
|
||||
@@ -170,6 +178,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
|
||||
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
|
||||
| Ragnarock | Quest | ⚠️ Works with issues | |
|
||||
| Real VR Fishing | Quest | ⚠️ Works with issues | Seems to be almost 100% functional with meta permissions enabled. |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| Spunky Dungeon | Quest | ⚠️ Works with issues | Gear VR / Oculus Go game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
|
||||
| Swarm 2 | Quest | ⚠️ Works with issues | MSAA creates visible seams around edge geometry and also the skybox edge |
|
||||
|
||||
+32
-32
@@ -39,7 +39,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Patch Oculus detection for Unity](#patch_oculus_unity) | `patch_oculus_unity` | Patches from OVRPort | default |
|
||||
| [Patch Oculus detection for Unreal](#patch_oculus_unreal) | `patch_oculus_unreal` | Patches from OVRPort | default |
|
||||
| [Remove localized app names](#patch_remove_localized_names) | `patch_remove_localized_names` | Patches from OVRPort | default |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 38 |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 41 |
|
||||
| [Remove uses-library](#patch_remove_uses_library) | `patch_remove_uses_library` | Patches from OVRPort | default |
|
||||
| [Remove VrApi library](#patch_remove_vrapi) | `patch_remove_vrapi` | Patches from OVRPort | |
|
||||
| [Replace application label and icon](#patch_replace_icon_label) | `patch_replace_icon_label` | Patches from OVRPort | default |
|
||||
@@ -47,7 +47,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [VrApi → OpenXR adapter (OVRPort)](#patch_vrapi_openxr) | `patch_vrapi_openxr` | Patches from OVRPort | |
|
||||
| [32-bit ARM bridge (Gear VR / Go games)](#frame-a32_bridge) | `frame.a32_bridge` | Steam Frame patches | 6 |
|
||||
| [FrameBridge OpenXR adapter](#frame-adapter) | `frame.adapter` | Steam Frame patches | default |
|
||||
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 1 |
|
||||
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 2 |
|
||||
| [Asset-file service bypass](#frame-asset_files_stub) | `frame.asset_files_stub` | Steam Frame patches | |
|
||||
| [Skip Meta avatars](#frame-avatar_stub) | `frame.avatar_stub` | Steam Frame patches | 1 |
|
||||
| [bHaptics: skip the Bluetooth permission request](#frame-bhaptics_permission) | `frame.bhaptics_permission` | Steam Frame patches | 5 |
|
||||
@@ -56,15 +56,15 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [OpenGL ES: multiview shaders on flat panels (HUD, menus)](#frame-gl_multiview_fbo) | `frame.gl_multiview_fbo` | Steam Frame patches | 1 |
|
||||
| [GL shim (Mesa GLSL compatibility)](#frame-gl_shim) | `frame.gl_shim` | Steam Frame patches | 2 |
|
||||
| [Godot apps: start without a clipboard service](#frame-godot_clipboard) | `frame.godot_clipboard` | Steam Frame patches | |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 19 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 22 |
|
||||
| [Language packs from the game's files](#frame-langpacks) | `frame.langpacks` | Steam Frame patches | |
|
||||
| [Launcher category (INFO → LAUNCHER)](#frame-launcher) | `frame.launcher` | Steam Frame patches | default |
|
||||
| [GL wrapper: OpenGL ES depth formats (Minecraft)](#frame-ltw_depth) | `frame.ltw_depth` | Steam Frame patches | default |
|
||||
| [Fix Lua startup crashes on Frame](#frame-luajit_pointers) | `frame.luajit_pointers` | Steam Frame patches | |
|
||||
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 2 |
|
||||
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 3 |
|
||||
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
|
||||
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
|
||||
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 15 |
|
||||
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 17 |
|
||||
| [Fix crash when the game prepares the microphone](#frame-ovr_microphone) | `frame.ovr_microphone` | Steam Frame patches | |
|
||||
| [Keep the same Meta user between starts](#frame-ovr_stable_user) | `frame.ovr_stable_user` | Steam Frame patches | default |
|
||||
| [Trace Meta platform requests (diagnostics)](#frame-ovr_trace) | `frame.ovr_trace` | Steam Frame patches | |
|
||||
@@ -82,7 +82,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
|
||||
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 12 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 28 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 102 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 107 |
|
||||
| [Unity: share thread keys (games that run out of them)](#frame-unity_tls_key_pool) | `frame.unity_tls_key_pool` | Steam Frame patches | 1 |
|
||||
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
|
||||
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
|
||||
@@ -90,21 +90,21 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 2 |
|
||||
| [Unreal: Quest controller bindings (RPOC key map)](#frame-unreal_quest_keymap) | `frame.unreal_quest_keymap` | Steam Frame patches | 3 |
|
||||
| [Unreal: don't wait for the Quest shader precompile](#frame-unreal_quest_precompile) | `frame.unreal_quest_precompile` | Steam Frame patches | 3 |
|
||||
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 5 |
|
||||
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 6 |
|
||||
| [Unreal: skip the OBB delivery check](#frame-unreal_skip_obb_delivery) | `frame.unreal_skip_obb_delivery` | Steam Frame patches | 1 |
|
||||
| [Unreal: bypass the stalled loading splash](#frame-unreal_splash_bypass) | `frame.unreal_splash_bypass` | Steam Frame patches | 1 |
|
||||
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 7 |
|
||||
| [Vermillion: paint canvases in the scene](#frame-vermillion_scene_canvas) | `frame.vermillion_scene_canvas` | Steam Frame patches | 1 |
|
||||
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 1 |
|
||||
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 2 |
|
||||
| [Vulkan: drop invalid render-pass pointers](#frame-vk_sanitize) | `frame.vk_sanitize` | Steam Frame patches | default · 1 |
|
||||
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 3 |
|
||||
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 4 |
|
||||
| [Quiet Meta's VrApi loader (OpenXR games)](#frame-vrapi_stub) | `frame.vrapi_stub` | Steam Frame patches | 2 |
|
||||
| [OpenGL ES: shader fixes (Vulkan layer under Zink)](#frame-zink_shader_fix) | `frame.zink_shader_fix` | Steam Frame patches | 1 |
|
||||
| [Pointer start](#adapter-aim_forward) | `adapter.aim_forward` | Game settings | |
|
||||
| [Pointer angle](#adapter-aim_pitch) | `adapter.aim_pitch` | Game settings | |
|
||||
| [Pointer turn](#adapter-aim_yaw) | `adapter.aim_yaw` | Game settings | |
|
||||
| [Use controllers](#adapter-controller_fix) | `adapter.controller_fix` | Game settings | 1 |
|
||||
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 4 |
|
||||
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 6 |
|
||||
| [Stand in for cube layers](#adapter-cube_standin) | `adapter.cube_standin` | Game settings | |
|
||||
| [Show curved menus and screens](#adapter-cylinder_strips) | `adapter.cylinder_strips` | Game settings | |
|
||||
| [Show 360° backgrounds and videos](#adapter-equirect_emul) | `adapter.equirect_emul` | Game settings | 5 |
|
||||
@@ -127,9 +127,9 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [GL shim: high-precision vertex shaders](#adapter-gl_highp_markers) | `adapter.gl_highp_markers` | Game settings | 1 |
|
||||
| [GL shim: integer vertex inputs](#adapter-gl_int_attribs) | `adapter.gl_int_attribs` | Game settings | 1 |
|
||||
| [Flat panel diagnostics (log)](#adapter-gl_mv_debug) | `adapter.gl_mv_debug` | Game settings | |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 81 |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 88 |
|
||||
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
|
||||
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 4 |
|
||||
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 5 |
|
||||
| [Controller input diagnostics (log)](#adapter-input_diag) | `adapter.input_diag` | Game settings | |
|
||||
| [Extra diagnostics (log)](#adapter-layer_debug) | `adapter.layer_debug` | Game settings | |
|
||||
| [Drop broken layers](#adapter-layer_fix) | `adapter.layer_fix` | Game settings | |
|
||||
@@ -137,7 +137,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unity frame loop: wait for skipped frames](#adapter-ovrp_begin_gate) | `adapter.ovrp_begin_gate` | Game settings | 1 |
|
||||
| [Steadier hands (older Unity)](#adapter-ovrp_hold_physics) | `adapter.ovrp_hold_physics` | Game settings | 1 |
|
||||
| [See your room](#adapter-passthrough_emul) | `adapter.passthrough_emul` | Game settings | |
|
||||
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 1 |
|
||||
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 2 |
|
||||
| [Pose timing diagnostics (log)](#adapter-pose_debug) | `adapter.pose_debug` | Game settings | |
|
||||
| [Fix hands lagging behind controllers](#adapter-pose_time_fix) | `adapter.pose_time_fix` | Game settings | 14 |
|
||||
| [Treat newer Quest controllers as Touch](#adapter-profile_remap) | `adapter.profile_remap` | Game settings | |
|
||||
@@ -148,7 +148,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
|
||||
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 4 |
|
||||
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 9 |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 11 |
|
||||
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
|
||||
| [Room width](#adapter-scene_width) | `adapter.scene_width` | Game settings | |
|
||||
| [Eye-image snapshots (diagnostics)](#adapter-snapshot) | `adapter.snapshot` | Game settings | |
|
||||
@@ -170,12 +170,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [OpenGL ES shader fixes](#adapter-zink_shader_fix) | `adapter.zink_shader_fix` | Game settings | 1 |
|
||||
| [Keep the game's saved resolution in step](#device-config_sync) | `device.config_sync` | Files and settings on the Frame | 1 |
|
||||
| [Show as VR or as a flat window](#device-display_mode) | `device.display_mode` | Files and settings on the Frame | |
|
||||
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 2 |
|
||||
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 3 |
|
||||
| [Foveated rendering (Valve)](#device-foveation) | `device.foveation` | Files and settings on the Frame | 3 |
|
||||
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
|
||||
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
|
||||
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 105 |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 110 |
|
||||
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
|
||||
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
|
||||
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
|
||||
@@ -420,7 +420,7 @@ Stops some Unreal games from closing themselves right after starting.
|
||||
- `patch_remove_unreal_force_quit`
|
||||
- **Changes:** Changes the APK (during the OVRPort conversion).
|
||||
- **Offered (off):** Built as the alternate APK for Unreal games (use it if the game quits itself).
|
||||
- **Catalog games (38):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2, ExploreVR and 30 more.
|
||||
- **Catalog games (41):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2 and 33 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `force-quit` (error): The game quit itself right after starting.
|
||||
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
|
||||
@@ -526,7 +526,7 @@ Lets the game find extra content it keeps next to its data (for example Star War
|
||||
- `frame.asset_files`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The game asks Meta's platform for asset files and its data has … content file(s) (*.pak) that OVRPort's loader doesn't report.
|
||||
- **Catalog games (1):** Star Wars: Tales from the Galaxy's Edge.
|
||||
- **Catalog games (2):** Attack on Titan VR: Unbreakable, Star Wars: Tales from the Galaxy's Edge.
|
||||
- **Source:** [patches/frame/langpack.py](../src/frameport/patches/frame/langpack.py)
|
||||
|
||||
> Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.
|
||||
@@ -645,7 +645,7 @@ Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs s
|
||||
- `frame.hw_video_decode` · also for 2D apps
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** The game plays video through Android's decoders (MediaCodec, ExoPlayer or VLC): decode it on the Frame's hardware.
|
||||
- **Catalog games (19):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Dance Central, Eye of the Temple, Fruit Ninja, Ghost Town and 11 more.
|
||||
- **Catalog games (22):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Breathedge VR, Dance Central, Exploding Kittens VR, Eye of the Temple and 14 more.
|
||||
- **Source:** [patches/frame/hw_video_decode.py](../src/frameport/patches/frame/hw_video_decode.py)
|
||||
|
||||
> Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (for example video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (for example Steam's own hardware video decoding is on) the video is decoded in software as before.
|
||||
@@ -706,7 +706,7 @@ Declares Meta's permissions some mixed-reality games ask for, so they don't stop
|
||||
- `frame.meta_permissions`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Mixed-reality game that needs the room model: its 'use spatial data' permission must be granted (for example Demeter).
|
||||
- **Catalog games (2):** Arkio, Demeter.
|
||||
- **Catalog games (3):** Arkio, Demeter, Real VR Fishing.
|
||||
- **Source:** [patches/frame/manifest_fixes.py](../src/frameport/patches/frame/manifest_fixes.py)
|
||||
|
||||
> Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants them at install (for example the 'use spatial data' permission of mixed-reality games like Demeter).
|
||||
@@ -749,7 +749,7 @@ Provides stand-ins for Quest system calls some games make at start.
|
||||
- **Suggested (on):** The Unreal build of Meta XR Audio looks up com.oculus.os.AnalyticsEvent and aborts without it (for example NOPE Challenge).
|
||||
- **Suggested (on):** Several Meta SDK libraries look up com.oculus.os.AnalyticsEvent (…); games like this abort without the stub (for example Nano).
|
||||
- **Offered (off):** … references com.oculus.os.AnalyticsEvent; usually harmless (for example Batman: Arkham Shadow and LEGO Bricktales run without it). Enable if the game aborts with ClassNotFoundException.
|
||||
- **Catalog games (15):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, NOPE CHALLENGE, RC Pilot Trainer and 7 more.
|
||||
- **Catalog games (17):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, Neko Atsume Purrfect, NOPE CHALLENGE and 9 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `oculus-os-class` (fatal): The game looks up a Quest-only system class and aborts.
|
||||
- **Source:** [patches/frame/telemetry.py](../src/frameport/patches/frame/telemetry.py)
|
||||
@@ -989,7 +989,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
|
||||
- `frame.unity_text_input` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
|
||||
- **Catalog games (102):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica, AugmentedEmpire and 94 more.
|
||||
- **Catalog games (107):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 99 more.
|
||||
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
|
||||
|
||||
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
|
||||
@@ -1092,7 +1092,7 @@ Starts Unreal games without re-checking their data file (they'd seem to hang on
|
||||
- `frame.unreal_skip_obb_check` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unreal checks the OBB's contents before starting (bVerifyOBBOnStartUp); on the Frame a failed or slow check can't be seen and the game seems to hang.
|
||||
- **Catalog games (5):** Dance Central, Kill It With Fire VR, MacacoVR, Maestro, Ragnarock.
|
||||
- **Catalog games (6):** Dance Central, Kill It With Fire VR, Lost Recipes, MacacoVR, Maestro, Ragnarock.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-obb-check-stuck` (fatal): The game stays on Unreal's OBB check screen (DownloaderActivity), which the Frame doesn't show, and never starts VR (for example Contractors, GitHub #159).
|
||||
- **Source:** [patches/frame/unreal_obb_check.py](../src/frameport/patches/frame/unreal_obb_check.py)
|
||||
@@ -1158,7 +1158,7 @@ Lets games with Vivox voice chat start on the Frame (they'd crash on an Android
|
||||
- `frame.vivox_audio_route` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Vivox voice chat: it calls Android 12 audio methods, which Lepton (Android 11) lacks, and crashes.
|
||||
- **Catalog games (1):** Dungeons of Eternity.
|
||||
- **Catalog games (2):** Dungeons of Eternity, Real VR Fishing.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `vivox-api31` (fatal): The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR).
|
||||
- **Source:** [patches/frame/vivox_audio_route.py](../src/frameport/patches/frame/vivox_audio_route.py)
|
||||
@@ -1189,7 +1189,7 @@ Lets games built on Meta's oldest VR interface run on the Frame.
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The engine calls libvrapi.so directly; OVRPort can't translate it.
|
||||
- **Conflicts with:** [`frame.vrapi_stub`](#frame-vrapi_stub)
|
||||
- **Catalog games (3):** BlazeRush, Path of the Warrior, The Climb 2.
|
||||
- **Catalog games (4):** BlazeRush, Path of the Warrior, The Climb, The Climb 2.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `direct-vrapi` (warning): The game uses Meta's VrApi directly and needs the VrApi bridge.
|
||||
- `vrapi-frame-rejected` (fatal): The VrApi bridge rejected the game's frame options, so no picture reaches the headset even though frame pacing can appear in the log.
|
||||
@@ -1295,7 +1295,7 @@ For games that show the headset's own controllers. Takes effect after a reinstal
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Controllers → "Show Steam Frame controllers".
|
||||
- **Catalog games (4):** Ocean Rift, SynthRiders, VR HOT Quest, Walkabout Mini Golf.
|
||||
- **Catalog games (6):** Neko Atsume Purrfect, Ocean Rift, Real VR Fishing, SynthRiders, VR HOT Quest, Walkabout Mini Golf.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `render-model-requested` (info): The game shows Quest controllers (or none) because the Frame doesn't provide controller models.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
@@ -1633,7 +1633,7 @@ For games that freeze the Frame when a controller vibrates (for example Lucky's
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Controllers → "Fix freezes when controllers vibrate" (advanced).
|
||||
- **Catalog games (81):** AgeOfJoy, Ancient_Dungeon, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber, Caves and 73 more.
|
||||
- **Catalog games (88):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber and 80 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.
|
||||
@@ -1662,7 +1662,7 @@ For games whose picture flickers or smears (for example Into The Radius 2).
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Troubleshooting → "Turn off space warp" (advanced).
|
||||
- **Catalog games (4):** Into The Radius 2, Metro Awakening, Pinball FX VR, The Boys VR.
|
||||
- **Catalog games (5):** Into The Radius 2, Lawn Mowing Simulator VR, Metro Awakening, Pinball FX VR, The Boys VR.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `space-warp-used` (info): The game uses Meta's space warp (it renders half of its frames plus motion vectors, and the runtime makes up the rest).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
@@ -1781,7 +1781,7 @@ For games that judder or shimmer while you hold your head still (for example I A
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Picture → "Steadier head tracking" (advanced).
|
||||
- **Catalog games (1):** I Am Cat.
|
||||
- **Catalog games (2):** I Am Cat, Lawn Mowing Simulator VR.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Repeat xrLocateViews queries for the same display time get the first, fully tracked answer again. Games that ask several times per frame got slightly different poses and rendered parts of the frame with different heads: judder (for example I Am Cat; proposed by Klownicle, GitHub #8).
|
||||
@@ -1941,7 +1941,7 @@ Mixed-reality games that build their level from your room get a simple room with
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Mixed reality → "Pretend room layout".
|
||||
- **Catalog games (9):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Ocean Rift, Smash Drums and 1 more.
|
||||
- **Catalog games (11):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Neko Atsume Purrfect, Ocean Rift and 3 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `scene-missing` (warning): The game wants Meta's room data (scene API), which the Frame lacks.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
@@ -2272,7 +2272,7 @@ Puts settings files next to the game on the Frame, for example to turn off an un
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** CryEngine: variable-rate shading isn't supported on the Frame, so user.cfg turns it off (… = 0; The Climb / The Climb 2).
|
||||
- **Options:** `files`: text, default none — path relative to files/ -> content
|
||||
- **Catalog games (2):** SUPERHOT VR, The Climb 2.
|
||||
- **Catalog games (3):** SUPERHOT VR, The Climb, The Climb 2.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Writes config files into the game's Android/data/<package>/files/ on the Frame, to turn off engine features the Frame doesn't support (for example a CryEngine user.cfg with r_variable_rate_shading = 0, as The Climb 2 needs).
|
||||
@@ -2335,7 +2335,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
|
||||
- `device.text_input_window`
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
|
||||
- **Catalog games (105):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica and 97 more.
|
||||
- **Catalog games (110):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 102 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). Pairs with "Make Unity text fields work without a system keyboard".
|
||||
|
||||
@@ -87,6 +87,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| 32-bit Unity game on the bridge: player stands in the floor (Spunky Dungeon) | Unity's floor-level tracking origin: `vrapi_SetTrackingSpace(1)` (LOCAL_FLOOR) was ignored | The VrApi bridge puts the origin on the STAGE floor for LOCAL_FLOOR/STAGE |
|
||||
| Crash at start: `NoClassDefFoundError: … Landroid/window/OnBackInvokedCallback;` or `NoSuchMethodError: No static method storeStoreFence()V in class Ljava/lang/invoke/VarHandle;` (GitHub #71/#72, triage `android-too-new`) | The APK needs Android 13+ (minSdk 33 or more; those were 34) and calls its classes at start; Lepton's Android is 11 (API 30). OVRPort lowers minSdk to 29, so it installs anyway. minSdk ≤ 32 (Quest's Android 12L) is fine: 16 working catalog games declare 32 | None (analysis `extra.min_sdk` > 32 → a warning on the game page/CLI and in the recipe notes; never unsupported from the manifest alone, the launch test's triage decides). Waits for Valve to update Lepton's Android |
|
||||
| Nothing opens; `TWALauncherActivity: Using URL from Manifest (https://…)`, `Creating TwaLauncher for com.oculus.browser`, `NameNotFoundException: com.oculus.browser` (GitHub #86 Mahjong Table VR, triage `web-wrapper`) | The APK is a Trusted Web Activity (Bubblewrap / Meta's PWA packaging): a website that opens in Meta's browser, which Lepton doesn't have | None: open the URL in a browser. Analysis `extra.web_wrapper` (manifest meta-data `android.support.customtabs.trusted.DEFAULT_URL`, resolved through resources.arsc, or androidbrowserhelper's LauncherActivity) → a warning with the URL (not unsupported: the manifest alone never decides) |
|
||||
| A flat window, no VR and no way to interact; `Google VR Error [Daydream]: Google VR had a fatal issue while loading`, `class com/google/vr/sdk/base/CardboardViewNativeImpl not found` (32-bit bridge), `Unable to start service Intent { act=com.google.vr.vrcore.… }` (GitHub #335 Blade Runner: Revelations, triage `gvr-not-supported`) | A Google Daydream / Cardboard build: Google's VR SDK (GVR, `libgvr.so`) or the Cardboard SDK. Daydream needs Google's VrCore service, Cardboard draws a distorted side-by-side image for a phone screen; neither reaches OpenXR, the Frame's only way into the headset | None: get the game's Oculus build (Quest, Go or Gear VR), which goes through the normal path. Analysis `extra.google_vr` (`daydream`/`cardboard`: manifest categories `com.google.intent.category.DAYDREAM`/`CARDBOARD`, `enableVrMode="com.google.vr.vrcore/…"`, `libgvr.so`/Cardboard SDK libraries, GVR Java classes, Unity `vr-device-list`; not `libgvraudio.so`, which Quest games ship too) → a warning on the game page/CLI and in the recipe notes, only when the APK has no Oculus support; never unsupported from the manifest alone. A GVR → OpenXR shim (like the VrApi bridge) is possible in principle but not built |
|
||||
| `UnsatisfiedLinkError` / `cannot locate symbol "ovr_…"` | overport's platform loader lacks Meta platform functions | `frame.ovrstubs` (automatic, generated stubs) |
|
||||
| missing `ovrMessageType_ToString` | same, but the game needs a real string | `frame.ovrplatformcompat` (automatic) |
|
||||
| The game stays in its built-in language or waits at start although its OBB / files hold language packs (`<tag>.lang`, for example `de.lang`) | overport's platform loader answers `ovr_LanguagePack_GetCurrent/SetCurrent` with request id 0, so the game never gets the pack's path | `frame.langpacks` (opt-in, experimental, shown for games whose data has `*.lang`; `native/langpack`, build it with `python native/build.py --only langpack`). Default pack: the one the game applies, else env `FRAMEPORT_LANGPACK=<tag>`, else the only pack; search path override `FRAMEPORT_LANGPACK_DIRS`. Unreal games (Deadpool VR) gray out a pack whose asset `Metadata` differs from the game's version string: the patch writes the APK's versionName as Metadata (env `FRAMEPORT_LANGPACK_META` overrides) and reports the pack under the `/storage/emulated/0/Android/obb/<pkg>/` spelling of its path; with `/sdcard/…` the text switched but the dialogue stayed silent (verified in the headset with Deadpool VR) |
|
||||
|
||||
@@ -101,6 +101,8 @@ def main() -> int:
|
||||
ap.add_argument("--viewport", default="1280x820", help="browser size, e.g. 2560x1440")
|
||||
ap.add_argument("--update", action="store_true", help="pretend a new FramePort release exists (update UI: "
|
||||
"changelog dialog, what's new bar + dialog, Settings changelog)")
|
||||
ap.add_argument("--announcement", action="store_true", help="show the announcement pop-up (with a picture, "
|
||||
"then text only); nothing is fetched")
|
||||
ap.add_argument("--fake-frame", action="store_true", help="pretend a Steam Frame is connected (no device needed)")
|
||||
ap.add_argument("--hover", default=None, help="x,y: move the mouse there before the Library screenshot")
|
||||
ap.add_argument("--install-questions", nargs="+", metavar="PKG", default=None,
|
||||
@@ -421,6 +423,26 @@ def main() -> int:
|
||||
print(f"open dialogs: {len([d for d in a.page._dialogs.controls if d.open])}, queued: {queued}", flush=True)
|
||||
steps = [("library", lambda a: a.navigate(0)), ("questions", open_questions),
|
||||
("after-1st-double", double_click), ("after-2nd-double", double_click), ("end", report)]
|
||||
if args.announcement:
|
||||
import shutil
|
||||
|
||||
from frameport import announcements
|
||||
from frameport.core.paths import user_data_dir
|
||||
|
||||
text = ("---\nid: smoke\ntitle: FramePort 1.3 brings the Monitor to every Frame\ndate: 2026-10-14T12:00:00Z\n"
|
||||
"---\n"
|
||||
"The **Monitor** tab now shows fps, battery and temperatures for any game the Frame runs, including "
|
||||
"Steam's own. Open it from the sidebar's Frame card.\n\n"
|
||||
"- Install links from FrameDrop buttons open in FramePort\n"
|
||||
"- Uploads over the USB cable are about **3× faster**\n\n"
|
||||
"Questions? Ask in [Discussions](https://example.invalid).")
|
||||
news = announcements.parse(text)
|
||||
pic = user_data_dir() / "announcements" / "smoke.png"
|
||||
pic.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(Path(__file__).resolve().parents[1] / "docs" / "images" / "library.png", pic)
|
||||
steps = [("announcement-image", lambda a: (a.navigate(0), a.announcer.show(news, pic))),
|
||||
("announcement-text", lambda a: (a.page.pop_dialog(), time.sleep(1.5), a.announcer.show(
|
||||
announcements.parse(text.replace("id: smoke", "id: smoke2")))))]
|
||||
if args.update:
|
||||
from frameport import __version__, updates
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
# 17: headset (the Meta headset a VR app was made for: Gear VR / Go games run through frame.a32_bridge) (2026-10)
|
||||
# 18: headset "quest" also for 32-bit apps that require 6DoF head tracking (Santa Cruz builds, e.g. Sports
|
||||
# Scramble: Touch controllers on the a32 bridge) (2026-10)
|
||||
ANALYSIS_VERSION = 19
|
||||
# 19: unreal_obb_delivery also for obfuscated DownloaderActivity predicates (GitHub #159) (2026-10)
|
||||
# 20: google_vr (Google Daydream / Cardboard builds: GVR or Cardboard SDK, no path to the Frame's headset,
|
||||
# GitHub #335) (2026-10)
|
||||
ANALYSIS_VERSION = 20
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -197,6 +200,32 @@ def vr_kind(libs: set[str], manifest_strings: list[str]) -> str:
|
||||
return "none"
|
||||
|
||||
|
||||
# Google VR (Daydream / Cardboard): the manifest categories of a Daydream / Cardboard app, Daydream's VR service
|
||||
# (android:enableVrMode="com.google.vr.vrcore/..."), the GVR SDK's library and the Cardboard SDK's
|
||||
# (github.com/googlevr/cardboard), and the GVR SDK's Java classes. Not libgvraudio.so / Unity's
|
||||
# libaudioplugingvrunity.so: Google's spatial audio, which Quest games ship too (e.g. Shadow Point).
|
||||
DAYDREAM_CATEGORY = "com.google.intent.category.DAYDREAM"
|
||||
CARDBOARD_CATEGORY = "com.google.intent.category.CARDBOARD"
|
||||
GOOGLE_VR_LIBS = {"libgvr.so", "libcardboard_api.so", "libGfxPluginCardboard.so"}
|
||||
GVR_DEX_MARKERS = (b"Lcom/google/vr/ndk/base/GvrLayout;", b"Lcom/google/vr/sdk/base/GvrView;",
|
||||
b"Lcom/google/vr/sdk/base/GvrActivity;", b"Lcom/google/cardboard/sdk/")
|
||||
|
||||
|
||||
def google_vr(libs: set[str], cats: set[str], manifest_strings: list[str], gvr_java: bool = False,
|
||||
boot: str = "") -> str:
|
||||
"""A Google VR app: "daydream" (Daydream: needs Google's VR services, VrCore), "cardboard" (Cardboard: draws
|
||||
both eyes side by side with lens distortion onto a phone screen) or "" (neither). Neither has a path to the
|
||||
Frame's headset: a Lepton app only reaches it through OpenXR (VrApi / OVRPlugin games are translated to it).
|
||||
`boot` = Unity's boot.config (vr-device-list=, where Unity builds list their VR devices)."""
|
||||
devices = next((ln.split("=", 1)[1].lower() for ln in boot.splitlines() if ln.startswith("vr-device-list=")), "")
|
||||
if (DAYDREAM_CATEGORY in cats or any(s.startswith("com.google.vr.vrcore") for s in manifest_strings)
|
||||
or "daydream" in devices):
|
||||
return "daydream"
|
||||
if CARDBOARD_CATEGORY in cats or "cardboard" in devices or libs & GOOGLE_VR_LIBS or gvr_java:
|
||||
return "cardboard"
|
||||
return ""
|
||||
|
||||
|
||||
# Kotlin's message for a failed non-null cast to ClipboardManager (Godot 4.2-4.4; 4.1 is Java, 4.5+ uses `as?`)
|
||||
GODOT_CLIPBOARD_CAST = b"null cannot be cast to non-null type android.content.ClipboardManager"
|
||||
|
||||
@@ -249,6 +278,8 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
for d in dexes)
|
||||
# video through Android's decoders (frame.hw_video_decode): Java MediaCodec, ExoPlayer/Media3, or libVLC
|
||||
media_codec = any(m in d for d in dexes for m in MEDIA_CODEC_MARKERS) or "libvlc.so" in libs
|
||||
# Google VR's Java side (GvrLayout / GvrView, the Cardboard SDK): a Daydream / Cardboard app (google_vr)
|
||||
gvr_java = any(m in d for d in dexes for m in GVR_DEX_MARKERS)
|
||||
from ..patches.frame.unreal_obb_check import has_delivery_check
|
||||
|
||||
# Includes obfuscated DownloaderActivity predicates; CRC verification is a separate manifest switch.
|
||||
@@ -372,6 +403,8 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
"xr_layer_exts": sorted(layer_exts), # composition-layer extensions the game's own libraries request
|
||||
"vr_kind": vr_kind(libset, manifest_strings),
|
||||
"headset": headset(meta, abis, vr_kind(libset, manifest_strings), requires_6dof(manifest)),
|
||||
# a Google Daydream / Cardboard build (GVR / Cardboard SDK): no path to the Frame's headset (GitHub #335)
|
||||
"google_vr": google_vr(libset, cats, manifest_strings, gvr_java, boot),
|
||||
# the base of a split APK set (Play "app bundle" installs): the code/libraries live in split APKs
|
||||
"split_apk": bool({"isSplitRequired", "requiredSplitTypes"} & set(manifest_strings)),
|
||||
# Unity (IL2CPP) text fields: they close at once on the Frame (frame.unity_text_input)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Announcements: one short message from the maintainer, shown once as a pop-up in the app.
|
||||
|
||||
Source of truth: `announcements/current.md` on GitHub `main` (front matter + Markdown, announcements/README.md), read
|
||||
through raw.githubusercontent like the catalog (cached, every 6 h), so a push reaches users without a release.
|
||||
|
||||
Who sees it: only people who were already using FramePort before the announcement's `date` (a first start never
|
||||
shows one, and nor does one published before FramePort was installed). Closing it in any way (the X, a click outside,
|
||||
Escape, its button) dismisses that `id` for good; a new announcement = a new id. No Flet here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from . import REPO_URL
|
||||
from .core import cache, library
|
||||
from .core.paths import user_data_dir
|
||||
|
||||
_log = logging.getLogger("frameport.announcements")
|
||||
|
||||
REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
|
||||
PATH = "announcements/current.md"
|
||||
RAW = f"https://raw.githubusercontent.com/{REPO}/main/"
|
||||
URL = RAW + PATH
|
||||
CHECK_EVERY = 6 * 3600
|
||||
KEEP_DISMISSED = 50 # ids remembered (oldest dropped)
|
||||
IMAGE_MAX = 5 * 2**20
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$")
|
||||
# settings only an earlier GUI run writes: one of them when announcements first run = an existing user (not
|
||||
# recipes.app_version: library.load() writes that on the very first load)
|
||||
USED_BEFORE = ("update.last_seen_version", "update.last_check", "ui.welcome_done", "ui.window")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Announcement:
|
||||
id: str
|
||||
title: str
|
||||
body: str # Markdown
|
||||
date: float # unix time it was published
|
||||
image: str | None = None # https URL (repo paths are resolved to raw GitHub)
|
||||
image_alt: str = ""
|
||||
button: str | None = None # optional action: label + url
|
||||
url: str | None = None
|
||||
expires: float | None = None
|
||||
|
||||
|
||||
class AnnouncementError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _when(value, end_of_day: bool = False) -> float:
|
||||
"""A front-matter date (YAML date/datetime or ISO text) as unix time; a bare date is UTC midnight (or the end of
|
||||
that day for `expires`). Naive times are UTC."""
|
||||
if isinstance(value, str):
|
||||
text = value.strip().replace("Z", "+00:00")
|
||||
try:
|
||||
value = dt.datetime.fromisoformat(text) if "T" in text or " " in text else dt.date.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise AnnouncementError(f"not a date: {value!r}") from exc
|
||||
if isinstance(value, dt.datetime):
|
||||
return (value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)).timestamp()
|
||||
if isinstance(value, dt.date):
|
||||
day = dt.datetime(value.year, value.month, value.day, tzinfo=dt.timezone.utc)
|
||||
return (day + dt.timedelta(days=1 if end_of_day else 0)).timestamp()
|
||||
raise AnnouncementError(f"not a date: {value!r}")
|
||||
|
||||
|
||||
def _link(value) -> str | None:
|
||||
"""An image/button target: https URLs as they are, a repo path (announcements/x.png) → its raw GitHub URL."""
|
||||
if value in (None, ""):
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if text.startswith("https://"):
|
||||
return text
|
||||
if "://" in text or text.startswith(("/", "..")) or ".." in text.split("/"):
|
||||
raise AnnouncementError(f"use an https URL or a path in the repo: {text!r}")
|
||||
return RAW + text
|
||||
|
||||
|
||||
def parse(text: str | None) -> Announcement | None:
|
||||
"""The file's announcement; None when there is none (empty file, `active: false`). Raises AnnouncementError for a
|
||||
malformed one (the app then shows nothing; tests/test_announcements.py checks the committed file)."""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
m = re.match(r"\A?---\s*\n(.*?)\n---\s*(?:\n|\Z)(.*)\Z", text, re.S)
|
||||
if not m:
|
||||
raise AnnouncementError("missing the --- front matter")
|
||||
try:
|
||||
meta = yaml.safe_load(m.group(1)) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise AnnouncementError(f"front matter: {exc}") from exc
|
||||
if not isinstance(meta, dict):
|
||||
raise AnnouncementError("front matter must be key: value lines")
|
||||
active = meta.get("active", True)
|
||||
if active is False and not meta.get("id"):
|
||||
return None
|
||||
if not isinstance(active, bool):
|
||||
raise AnnouncementError("active: true or false")
|
||||
unknown = set(meta) - {"id", "title", "date", "image", "image_alt", "button", "url", "expires", "active"}
|
||||
if unknown:
|
||||
raise AnnouncementError(f"unknown fields: {', '.join(sorted(unknown))}")
|
||||
ident, title = str(meta.get("id") or "").strip(), str(meta.get("title") or "").strip()
|
||||
if not ID_RE.match(ident):
|
||||
raise AnnouncementError("id: letters, digits, '.', '_' or '-' (change it to show a new announcement)")
|
||||
if not title:
|
||||
raise AnnouncementError("title is required")
|
||||
if "date" not in meta:
|
||||
raise AnnouncementError("date is required (only people using FramePort before it see the announcement)")
|
||||
button, url = meta.get("button"), meta.get("url")
|
||||
if bool(button) != bool(url):
|
||||
raise AnnouncementError("button and url go together")
|
||||
if not active: # switched off, but still checked (tests catch mistakes before it's switched on)
|
||||
_check_dates(meta)
|
||||
return None
|
||||
return Announcement(id=ident, title=title, body=m.group(2).strip(), date=_when(meta["date"]),
|
||||
image=_link(meta.get("image")), image_alt=str(meta.get("image_alt") or ""),
|
||||
button=str(button).strip() if button else None, url=_link(url),
|
||||
expires=_when(meta["expires"], end_of_day=True) if meta.get("expires") else None)
|
||||
|
||||
|
||||
def _check_dates(meta: dict) -> None:
|
||||
_when(meta["date"])
|
||||
if meta.get("expires"):
|
||||
_when(meta["expires"], end_of_day=True)
|
||||
_link(meta.get("image"))
|
||||
_link(meta.get("url"))
|
||||
|
||||
|
||||
def disabled() -> bool:
|
||||
return bool(os.environ.get("FRAMEPORT_NO_ANNOUNCEMENTS"))
|
||||
|
||||
|
||||
def fetch(force: bool = False) -> Announcement | None:
|
||||
"""The current announcement (cached 6 h; offline → the last copy; never raises)."""
|
||||
try:
|
||||
return parse(cache.cached_text("announcement.md", URL, max_age=0 if force else CHECK_EVERY, fallback=""))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log.warning("announcement skipped: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ who sees it
|
||||
def since() -> float:
|
||||
"""When this user started using FramePort, as far as announcements are concerned (setting announce.since). Set
|
||||
once, the first time it's asked: 0 for someone who used FramePort before announcements existed, else now (a first
|
||||
start). Call it early at start, before anything records this start in the settings."""
|
||||
value = library.setting("announce.since")
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
value = 0.0 if any(library.setting(k) is not None for k in USED_BEFORE) or library.games() else time.time()
|
||||
library.set_setting("announce.since", value)
|
||||
return value
|
||||
|
||||
|
||||
def dismissed() -> list[str]:
|
||||
value = library.setting("announce.dismissed")
|
||||
return [v for v in value if isinstance(v, str)] if isinstance(value, list) else []
|
||||
|
||||
|
||||
def dismiss(ident: str) -> None:
|
||||
"""Never show this announcement again (any way of closing it)."""
|
||||
library.update_setting("announce.dismissed",
|
||||
lambda cur: ([v for v in (cur or []) if v != ident] + [ident])[-KEEP_DISMISSED:], [])
|
||||
|
||||
|
||||
def due(a: Announcement | None, now: float | None = None, user_since: float | None = None) -> bool:
|
||||
"""Whether to show `a` now: published, not expired, not dismissed, and published after this
|
||||
user started using FramePort."""
|
||||
if a is None:
|
||||
return False
|
||||
now = time.time() if now is None else now
|
||||
if a.expires is not None and now >= a.expires:
|
||||
return False
|
||||
if a.date > now: # scheduled for later
|
||||
return False
|
||||
if a.id in dismissed():
|
||||
return False
|
||||
return a.date > (since() if user_since is None else user_since)
|
||||
|
||||
|
||||
def image_file(a: Announcement) -> Path | None:
|
||||
"""The announcement's image, downloaded once into <data>/announcements/ and checked (Pillow); None without one or
|
||||
when it can't be loaded (the pop-up then shows no image). Shown by asset URL like artwork, never as bytes."""
|
||||
if not a.image:
|
||||
return None
|
||||
folder = user_data_dir() / "announcements"
|
||||
stem = hashlib.sha256(a.image.encode()).hexdigest()[:16]
|
||||
for done in folder.glob(f"{stem}.*"):
|
||||
if done.suffix != ".part":
|
||||
return done
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
tmp = folder / f"{stem}.part"
|
||||
cache.download(a.image, tmp)
|
||||
if tmp.stat().st_size > IMAGE_MAX:
|
||||
raise AnnouncementError("image larger than 5 MiB")
|
||||
with Image.open(tmp) as im:
|
||||
im.verify()
|
||||
fmt = (im.format or "").lower()
|
||||
ext = {"jpeg": ".jpg", "png": ".png", "webp": ".webp", "gif": ".gif"}.get(fmt)
|
||||
if not ext:
|
||||
raise AnnouncementError(f"image format {fmt or '?'} (use PNG, JPEG, WebP or GIF)")
|
||||
out = folder / f"{stem}{ext}"
|
||||
tmp.replace(out)
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log.warning("announcement image skipped: %s", exc)
|
||||
(folder / f"{stem}.part").unlink(missing_ok=True)
|
||||
return None
|
||||
@@ -1609,6 +1609,7 @@
|
||||
"This Frame's SSH key changed since FramePort last connected (SteamOS was reset, or another device now has its address). If it's your Frame, choose Trust the new key on the Steam Frame page.": "",
|
||||
"This game can't run on the Steam Frame.": "",
|
||||
"This game is marked \"Can't run\" on the Steam Frame:": "",
|
||||
"This is a Google {platform} VR build. Its VR can't reach the Frame's headset, so it runs as a flat window at best, usually without working controls. An Oculus build of the game (Meta Quest, Oculus Go or Gear VR) works the normal way.": "",
|
||||
"This is a website in an app: it opens in Meta's browser, which the Frame doesn't have. If nothing opens, visit the website in a browser.": "",
|
||||
"This is the example button from frameport.app. Real buttons install real games.": "",
|
||||
"this PC": "",
|
||||
|
||||
@@ -246,10 +246,23 @@ def web_wrapper_note(analysis: Analysis) -> str | None:
|
||||
f"Meta's browser, which the Frame doesn't have. If nothing opens, {where}.")
|
||||
|
||||
|
||||
def google_vr_note(analysis: Analysis) -> str | None:
|
||||
"""A Google Daydream / Cardboard build (analysis.detect.google_vr) that isn't also a Meta build: its VR can't
|
||||
reach the Frame's headset (GitHub #335)."""
|
||||
kind = (analysis.extra or {}).get("google_vr")
|
||||
if not kind or analysis.vr_kind == "quest": # a build with Oculus support too goes the normal way
|
||||
return None
|
||||
name = "Google Daydream" if kind == "daydream" else "Google Cardboard"
|
||||
return (f"This is a {name} VR build (Google VR SDK). Its VR can't reach the Steam Frame's headset: Daydream "
|
||||
"needs Google's VR services and Cardboard draws for a phone in a viewer, and FramePort has no "
|
||||
"translation for either, so it runs as a flat window at best, usually without working controls. An "
|
||||
"Oculus build of the same game (Meta Quest, Oculus Go or Gear VR) goes through FramePort's normal path.")
|
||||
|
||||
|
||||
def blocker_notes(analysis: Analysis) -> list[str]:
|
||||
"""Warnings read from the APK's manifest that it may not run on the Frame (whatever the recipe says). Only
|
||||
warnings: the manifest alone never marks a game unsupported (owner's rule); a launch test's triage decides."""
|
||||
return [n for n in (android_version_note(analysis), web_wrapper_note(analysis)) if n]
|
||||
return [n for n in (android_version_note(analysis), web_wrapper_note(analysis), google_vr_note(analysis)) if n]
|
||||
|
||||
|
||||
def _static_blockers(analysis: Analysis, recipe: Recipe) -> None:
|
||||
@@ -274,7 +287,8 @@ def _non_quest(analysis: Analysis, recipe: Recipe) -> None:
|
||||
recipe.reasons = {pid: v for pid, v in recipe.reasons.items() if pid in recipe.patches}
|
||||
recipe.alt_patches = []
|
||||
recipe.as_is = not any(base.get(pid).stage == "apk" for pid in recipe.patches) # the APK stays unchanged
|
||||
recipe.notes = _add(recipe.notes, "Android app without VR: installed without FramePort's VR translation.")
|
||||
if not (analysis.extra or {}).get("google_vr"): # a Google VR build says so in its own note (blocker_notes)
|
||||
recipe.notes = _add(recipe.notes, "Android app without VR: installed without FramePort's VR translation.")
|
||||
elif kind in ("pico_sdk", "wave"):
|
||||
recipe.status = "unsupported"
|
||||
recipe.notes = _add(recipe.notes, f"Uses {'Pico' if kind == 'pico_sdk' else 'HTC Vive Wave'}'s own VR SDK "
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""The announcement pop-up (frameport.announcements does the fetching and the who-sees-it rules): checked in the
|
||||
background a little after start and every 6 h, shown once when no other dialog is open and the welcome screen is done.
|
||||
Any way of closing it (the X, a click outside, Escape, its button) dismisses that announcement for good."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from .. import announcements
|
||||
from ..core import applog
|
||||
from ..i18n import tr
|
||||
from . import components as C
|
||||
from . import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..announcements import Announcement
|
||||
from .app import FramePortApp
|
||||
|
||||
FIRST_DELAY = 20 # seconds after start: after the start-up work and the update check
|
||||
BUSY_RETRY = 15 # another dialog is open / the welcome screen is shown: look again
|
||||
LONG_BODY = 900 # characters: longer bodies scroll inside the pop-up
|
||||
|
||||
|
||||
class Announcer:
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
self.shown: str | None = None # id on screen (a check while it's open doesn't show it twice)
|
||||
try:
|
||||
announcements.since() # first start → only announcements published after it (before anything is saved)
|
||||
except Exception: # noqa: BLE001
|
||||
applog.log.exception("announcements: recording the first start failed")
|
||||
|
||||
def start(self) -> None:
|
||||
if not announcements.disabled():
|
||||
threading.Thread(target=self._loop, daemon=True).start()
|
||||
|
||||
def _loop(self) -> None:
|
||||
time.sleep(FIRST_DELAY)
|
||||
while True:
|
||||
try:
|
||||
a = announcements.fetch()
|
||||
if announcements.due(a):
|
||||
self._offer(a)
|
||||
except Exception: # noqa: BLE001
|
||||
applog.log.exception("announcement check failed")
|
||||
time.sleep(announcements.CHECK_EVERY)
|
||||
|
||||
def _offer(self, a: Announcement) -> None:
|
||||
"""Wait for a quiet moment (no dialog, not the welcome screen), then show it (background thread)."""
|
||||
image = announcements.image_file(a)
|
||||
while self.app.route[0] == "welcome" or C.dialog_open(self.app.page):
|
||||
time.sleep(BUSY_RETRY)
|
||||
if announcements.due(a) and self.shown != a.id:
|
||||
self.show(a, image)
|
||||
|
||||
# ---------------------------------------------------------------- the pop-up
|
||||
def show(self, a: Announcement, image=None) -> None:
|
||||
page = self.app.page
|
||||
closed = []
|
||||
|
||||
def dismiss(e=None) -> None:
|
||||
if closed:
|
||||
return
|
||||
closed.append(True)
|
||||
self.shown = None
|
||||
try:
|
||||
announcements.dismiss(a.id)
|
||||
except Exception: # noqa: BLE001
|
||||
applog.log.exception("announcement: saving the dismissal failed")
|
||||
|
||||
def close(e=None) -> None:
|
||||
dismiss()
|
||||
page.pop_dialog()
|
||||
|
||||
def act(e=None) -> None:
|
||||
close()
|
||||
self.app.open_url(a.url)
|
||||
|
||||
self.shown = a.id
|
||||
page.show_dialog(C.dialog(None, announcement_card(page, a, image, close, act if a.button else None),
|
||||
content_padding=0, actions_padding=0, inset_padding=self._insets(),
|
||||
clip_behavior=ft.ClipBehavior.ANTI_ALIAS,
|
||||
on_dismiss=dismiss, width=T.DIALOG_M))
|
||||
|
||||
|
||||
def _insets(self) -> ft.Padding:
|
||||
"""Centred over the content area (right of the sidebar, left of an open activity panel), not the whole
|
||||
window; where that area is narrower than the pop-up, the panel and then the sidebar are left out."""
|
||||
gap = T.S5
|
||||
try:
|
||||
left, right = self.app.content_insets()
|
||||
width = float(self.app.page.width or 0)
|
||||
except Exception: # noqa: BLE001
|
||||
return ft.Padding.all(gap)
|
||||
need = T.DIALOG_M + 2 * gap
|
||||
if width and width - left - right < need:
|
||||
right = 0
|
||||
if width and width - left - right < need:
|
||||
left = 0
|
||||
return ft.Padding(left + gap, gap, right + gap, gap)
|
||||
|
||||
|
||||
def announcement_card(page: ft.Page, a: Announcement, image, on_close, on_action=None) -> ft.Control:
|
||||
"""The pop-up's content: the portal edge, the picture (if any) with the close button over it, title, date, the
|
||||
Markdown body and the optional action."""
|
||||
from ..artwork.thumbs import asset_url
|
||||
from .updater import _notes_style, _open_url
|
||||
|
||||
width = T.DIALOG_M
|
||||
close_tip = tr("Dismiss")
|
||||
title = ft.Text(a.title, size=T.px(20), weight=ft.FontWeight.W_700, color=T.TEXT, selectable=True)
|
||||
date = C.meta(time.strftime("%Y-%m-%d", time.localtime(a.date)))
|
||||
body = ft.Markdown(a.body, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||||
md_style_sheet=_notes_style(),
|
||||
on_tap_link=lambda e: _open_url(page, e.data))
|
||||
if len(a.body) > LONG_BODY:
|
||||
body = ft.Column([body], scroll=ft.ScrollMode.AUTO, height=T.px(320))
|
||||
|
||||
head: list[ft.Control] = [ft.Container(height=T.px(3), gradient=C.portal_gradient())]
|
||||
if image is not None:
|
||||
head.append(ft.Stack([
|
||||
ft.Image(src=asset_url(image), width=width, height=_image_height(image, width), fit=ft.BoxFit.COVER,
|
||||
semantics_label=a.image_alt or None),
|
||||
ft.Container(
|
||||
ft.Icon(ft.Icons.CLOSE_ROUNDED, size=T.px(18), color=T.TEXT),
|
||||
width=T.px(34), height=T.px(34), border_radius=T.px(17), alignment=ft.Alignment.CENTER,
|
||||
bgcolor=ft.Colors.with_opacity(0.62, T.BG), border=ft.Border.all(1, T.soft(T.TEXT, 0.18)),
|
||||
tooltip=close_tip, ink=True, on_click=on_close, right=T.S3, top=T.S3),
|
||||
], width=width))
|
||||
title_row: ft.Control = title
|
||||
else:
|
||||
title_row = ft.Row([ft.Container(title, expand=True, padding=ft.Padding(0, T.px(6), 0, 0)),
|
||||
C.icon_btn(ft.Icons.CLOSE_ROUNDED, close_tip, on_click=on_close)],
|
||||
vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S2)
|
||||
|
||||
text = [ft.Column([title_row, date], spacing=T.px(2), tight=True)]
|
||||
if a.body:
|
||||
text.append(body)
|
||||
if on_action is not None:
|
||||
text.append(ft.Row([C.primary(a.button, icon=ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=on_action)],
|
||||
alignment=ft.MainAxisAlignment.END))
|
||||
pad = ft.Padding(T.S5, T.S4 if image is None else T.S5, T.S4 if image is None else T.S5, T.S5)
|
||||
return ft.Container(
|
||||
ft.Column([*head, ft.Container(ft.Column(text, spacing=T.S4, tight=True), padding=pad)], spacing=0, tight=True),
|
||||
width=width)
|
||||
|
||||
|
||||
def _image_height(path, width: float) -> float:
|
||||
"""The picture's height at the pop-up's width, kept between a banner and a third of a 16:9 screen."""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as im:
|
||||
ratio = im.height / im.width
|
||||
except Exception: # noqa: BLE001
|
||||
ratio = 9 / 21
|
||||
return max(T.px(140), min(width * ratio, T.px(320)))
|
||||
+13
-1
@@ -96,6 +96,9 @@ def reduce_motion_setting() -> bool:
|
||||
return bool(library.setting(REDUCE_MOTION, False))
|
||||
|
||||
|
||||
SIDEBAR_WIDTH = 236 # logical px before the UI scale (content_insets)
|
||||
|
||||
|
||||
class FramePortApp:
|
||||
def __init__(self, page: ft.Page):
|
||||
from .views.library import load_filters
|
||||
@@ -145,9 +148,12 @@ class FramePortApp:
|
||||
self.stop_live(), # and stop a live view (it would keep the Frame encoding)
|
||||
self.stop_keyboard(), self.stop_monitor(), self.monitor_hub.close(),
|
||||
_link_state.update(owner=None) if _link_state["owner"] is self else None)
|
||||
from .announcer import Announcer
|
||||
from .updater import Updater
|
||||
|
||||
self.updater = Updater(self) # new FramePort releases (sidebar card, Library bar, one-click update)
|
||||
# maintainer announcements (pop-up once, not on a first start); before updater.start() saves this start
|
||||
self.announcer = Announcer(self)
|
||||
|
||||
page.title = tr("FramePort")
|
||||
for problem in T.load_user_themes(themes_dir()): # installed theme files (Settings → Appearance)
|
||||
@@ -182,6 +188,7 @@ class FramePortApp:
|
||||
self._start_auto_pair()
|
||||
threading.Thread(target=self._backfill_covers, daemon=True).start()
|
||||
self.updater.start()
|
||||
self.announcer.start()
|
||||
self.run_bg(self._refresh_catalog) # confirmed game configs from GitHub main (no release needed)
|
||||
_link_state["owner"] = self
|
||||
if _link_state["thread"] is None:
|
||||
@@ -236,13 +243,18 @@ class FramePortApp:
|
||||
self.activity_card,
|
||||
self.conn_card,
|
||||
self.power_row, # under the Frame card (owner's choice)
|
||||
], spacing=T.S2), width=T.px(236), bgcolor=T.SIDEBAR, padding=T.S4,
|
||||
], spacing=T.S2), width=T.px(SIDEBAR_WIDTH), bgcolor=T.SIDEBAR, padding=T.S4,
|
||||
border=None if T.DUAL else ft.Border(right=ft.BorderSide(1, T.BORDER)))
|
||||
# dual themes: the sidebar's edge is the portal, blue at the top into orange at the bottom
|
||||
edge = [ft.Container(width=2, gradient=C.portal_gradient(vertical=True, opacity=0.7))] if T.DUAL else []
|
||||
self.page.controls = [ft.Row([sidebar, *edge, self.body, self.activity.root], expand=True, spacing=0,
|
||||
vertical_alignment=ft.CrossAxisAlignment.STRETCH)]
|
||||
|
||||
def content_insets(self) -> tuple[float, float]:
|
||||
"""How much of the window's left (the sidebar + its edge) and right (the activity panel, when open) isn't the
|
||||
content area: pop-ups about the content (announcements) centre over what's between."""
|
||||
return T.px(SIDEBAR_WIDTH) + (2 if T.DUAL else 0), float(self.activity.root.width or 0)
|
||||
|
||||
def restyle(self, theme: str) -> None:
|
||||
"""Switch the colour theme while running (Settings → Appearance): new tokens, the window rebuilt, the views
|
||||
FramePort keeps between visits recreated (the files/screenshots views start at their first location again;
|
||||
|
||||
@@ -412,8 +412,17 @@ class GameView:
|
||||
"have. If nothing opens, visit the website in a browser."), T.TEXT)]
|
||||
+ ([C.meta(web["url"], T.TEXT_2, selectable=True)] if web.get("url") else []), spacing=T.px(4)),
|
||||
"warn", ft.Icons.LANGUAGE_ROUNDED))
|
||||
# a Google Daydream / Cardboard build (no Oculus support in it): its VR has no way to the headset (#335)
|
||||
gvr = (extra.get("google_vr") if not self.rift and not self.linux and extra.get("vr_kind") != "quest"
|
||||
else "")
|
||||
if gvr:
|
||||
out.append(C.callout(tr("This is a Google {platform} VR build. Its VR can't reach the Frame's headset, so "
|
||||
"it runs as a flat window at best, usually without working controls. An Oculus "
|
||||
"build of the game (Meta Quest, Oculus Go or Gear VR) works the normal way.")
|
||||
.format(platform="Daydream" if gvr == "daydream" else "Cardboard"),
|
||||
"warn", ft.Icons.VIEW_IN_AR_ROUNDED))
|
||||
static = " ".join(engine.blocker_notes(library.analysis_from_dict(g["analysis"]))) \
|
||||
if (too_new or web) else ""
|
||||
if (too_new or web or gvr) else ""
|
||||
if static and recipe.notes.strip() == static:
|
||||
pass # the recipe's note says the same (shown above)
|
||||
elif recipe.status == "unsupported":
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Announcements: the committed file parses, who sees one (never on a first start), and dismissing is for good."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport import announcements as A
|
||||
from frameport.core import library
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
DOC = """---
|
||||
id: one
|
||||
title: Hello
|
||||
date: 2026-10-10T12:00:00Z
|
||||
---
|
||||
Some **news**.
|
||||
"""
|
||||
|
||||
|
||||
def ann(text=DOC):
|
||||
a = A.parse(text)
|
||||
assert a is not None
|
||||
return a
|
||||
|
||||
|
||||
def test_committed_announcement_is_valid():
|
||||
"""announcements/current.md must parse (an inactive one is checked too): a typo fails here, not on users."""
|
||||
A.parse((ROOT / A.PATH).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_parse_fields():
|
||||
a = A.parse("""---
|
||||
id: frameport-1.3
|
||||
title: "FramePort 1.3: the Monitor"
|
||||
date: 2026-11-02
|
||||
image: announcements/x.png
|
||||
image_alt: A picture
|
||||
button: Read more
|
||||
url: https://frameport.app/changelog/
|
||||
expires: 2026-11-30
|
||||
---
|
||||
Body *here*.""")
|
||||
assert a.id == "frameport-1.3" and a.title == "FramePort 1.3: the Monitor" and a.body == "Body *here*."
|
||||
assert a.image == A.RAW + "announcements/x.png" and a.image_alt == "A picture"
|
||||
assert a.button == "Read more" and a.url == "https://frameport.app/changelog/"
|
||||
assert a.date == 1793577600.0 # 2026-11-02 00:00 UTC
|
||||
assert a.expires == 1795996800.0 + 86400 # end of 2026-11-30 (inclusive)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["", " \n", "---\nactive: false\n---\n"])
|
||||
def test_nothing_to_show(text):
|
||||
assert A.parse(text) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("front, error", [
|
||||
("title: x\ndate: 2026-10-10", "id"),
|
||||
("id: bad id\ntitle: x\ndate: 2026-10-10", "id"),
|
||||
("id: a\ndate: 2026-10-10", "title"),
|
||||
("id: a\ntitle: x", "date"),
|
||||
("id: a\ntitle: x\ndate: soon", "date"),
|
||||
("id: a\ntitle: x\ndate: 2026-10-10\nbutton: Go", "button and url"),
|
||||
("id: a\ntitle: x\ndate: 2026-10-10\nimage: http://example.com/x.png", "https"),
|
||||
("id: a\ntitle: x\ndate: 2026-10-10\nimage: ../secret.png", "https"),
|
||||
("id: a\ntitle: x\ndate: 2026-10-10\nwhere: app", "unknown"), # the site pop-up was dropped
|
||||
("id: a\ntitle: x\ndate: 2026-10-10\ncolour: red", "unknown"),
|
||||
("active: false\nid: a\ntitle: x\ndate: tomorrow", "date"), # switched off, still checked
|
||||
])
|
||||
def test_parse_errors(front, error):
|
||||
with pytest.raises(A.AnnouncementError, match=error):
|
||||
A.parse(f"---\n{front}\n---\nbody")
|
||||
|
||||
|
||||
def test_fetch_never_raises_on_a_broken_file(monkeypatch):
|
||||
monkeypatch.setattr(A.cache, "cached_text", lambda *a, **k: "no front matter")
|
||||
assert A.fetch() is None
|
||||
|
||||
|
||||
def test_first_start_sees_no_older_announcement():
|
||||
"""A new install: `since` = now, so an announcement published before it never shows."""
|
||||
assert A.since() > 1.9e9 - 2e8 # roughly now
|
||||
assert not A.due(ann())
|
||||
|
||||
|
||||
def test_existing_user_sees_it_once():
|
||||
library.set_setting("update.last_seen_version", "1.2.0") # FramePort ran before announcements existed
|
||||
assert A.since() == 0
|
||||
a = ann()
|
||||
assert A.due(a)
|
||||
A.dismiss(a.id)
|
||||
assert not A.due(a)
|
||||
assert not A.due(a) # for good, also at the next check
|
||||
# a typo fixed under the same id stays dismissed; a new id shows again
|
||||
assert not A.due(ann(DOC.replace("Hello", "Hallo")))
|
||||
assert A.due(ann(DOC.replace("id: one", "id: two")))
|
||||
|
||||
|
||||
def test_since_is_recorded_once():
|
||||
first = A.since()
|
||||
library.set_setting("update.last_seen_version", "1.2.0") # written later by this same start
|
||||
assert A.since() == first
|
||||
|
||||
|
||||
def test_new_user_sees_later_announcements():
|
||||
start = 1_800_000_000.0
|
||||
later = ann(DOC.replace("2026-10-10T12:00:00Z", "2027-01-20T00:00:00Z")) # after `start`
|
||||
assert A.due(later, now=later.date + 60, user_since=start)
|
||||
assert not A.due(ann(), now=later.date + 60, user_since=start) # published before the install
|
||||
|
||||
|
||||
def test_schedule_and_expiry():
|
||||
a = ann(DOC.replace("---\nSome", "expires: 2026-10-12\n---\nSome"))
|
||||
assert not A.due(a, now=a.date - 60, user_since=0) # not published yet
|
||||
assert A.due(a, now=a.date + 60, user_since=0)
|
||||
assert not A.due(a, now=a.expires + 1, user_since=0) # expired
|
||||
|
||||
|
||||
def test_dismissed_list_is_bounded():
|
||||
for i in range(A.KEEP_DISMISSED + 5):
|
||||
A.dismiss(f"id{i}")
|
||||
kept = A.dismissed()
|
||||
assert len(kept) == A.KEEP_DISMISSED and kept[-1] == f"id{A.KEEP_DISMISSED + 4}"
|
||||
A.dismiss("id10") # again: moves to the end, no duplicate
|
||||
assert A.dismissed().count("id10") == 1 and A.dismissed()[-1] == "id10"
|
||||
|
||||
|
||||
def test_image_file_checks_the_picture(monkeypatch, tmp_path):
|
||||
from PIL import Image
|
||||
|
||||
src = tmp_path / "pic.png"
|
||||
Image.new("RGB", (40, 20), "orange").save(src)
|
||||
|
||||
def fake_download(url, dest, **kw):
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(src.read_bytes() if url.endswith(".png") else b"not an image")
|
||||
return dest
|
||||
monkeypatch.setattr(A.cache, "download", fake_download)
|
||||
good = A.image_file(ann(DOC.replace("---\nSome", "image: announcements/a.png\n---\nSome")))
|
||||
assert good is not None and good.suffix == ".png" and good.exists()
|
||||
assert A.image_file(ann(DOC.replace("---\nSome", "image: https://example.com/a.jpg\n---\nSome"))) is None
|
||||
assert not list(good.parent.glob("*.part"))
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Google Daydream / Cardboard (GVR SDK) apps: detected, warned about, never marked unsupported (GitHub #335)."""
|
||||
import zipfile
|
||||
|
||||
from conftest import build_axml
|
||||
|
||||
from frameport.analysis import detect
|
||||
from frameport.recommend import engine
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
DAYDREAM_VR_MODE = "com.google.vr.vrcore/com.google.vr.vrcore.common.VrCoreListenerService"
|
||||
|
||||
|
||||
def _manifest(package="com.example.daydream", categories=(), enable_vr_mode=None):
|
||||
activity = [("name", "str", "com.unity3d.player.UnityPlayerActivity")]
|
||||
if enable_vr_mode:
|
||||
activity.append(("enableVrMode", "str", enable_vr_mode))
|
||||
cats = []
|
||||
for c in ("android.intent.category.LAUNCHER", *categories):
|
||||
cats += [("start", "category", [("name", "str", c)]), ("end", "category")]
|
||||
return build_axml([
|
||||
("start", "manifest", [("package", "str", package)]),
|
||||
("start", "uses-sdk", [("minSdkVersion", "int", 24), ("targetSdkVersion", "int", 26)]),
|
||||
("end", "uses-sdk"),
|
||||
("start", "application", []),
|
||||
("start", "activity", activity),
|
||||
("start", "intent-filter", []),
|
||||
("start", "action", [("name", "str", "android.intent.action.MAIN")]),
|
||||
("end", "action"),
|
||||
*cats,
|
||||
("end", "intent-filter"),
|
||||
("end", "activity"),
|
||||
("end", "application"),
|
||||
("end", "manifest"),
|
||||
])
|
||||
|
||||
|
||||
def _apk(tmp_path, manifest, libs=("libmain.so", "libunity.so"), abi="armeabi-v7a", dex=b"dex\n035\0"):
|
||||
p = tmp_path / "game.apk"
|
||||
with zipfile.ZipFile(p, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", manifest)
|
||||
z.writestr("classes.dex", dex)
|
||||
for lib in libs:
|
||||
z.writestr(f"lib/{abi}/{lib}", b"\x7fELF-" + lib.encode())
|
||||
return p
|
||||
|
||||
|
||||
def test_google_vr_signals():
|
||||
assert detect.google_vr(set(), {detect.DAYDREAM_CATEGORY}, []) == "daydream"
|
||||
assert detect.google_vr(set(), set(), [DAYDREAM_VR_MODE]) == "daydream"
|
||||
assert detect.google_vr(set(), {detect.CARDBOARD_CATEGORY}, []) == "cardboard"
|
||||
assert detect.google_vr({"libgvr.so"}, set(), []) == "cardboard"
|
||||
assert detect.google_vr({"libGfxPluginCardboard.so"}, set(), []) == "cardboard"
|
||||
assert detect.google_vr(set(), set(), [], gvr_java=True) == "cardboard"
|
||||
assert detect.google_vr(set(), set(), [], boot="vr-enabled=1\nvr-device-list=Daydream,Cardboard\n") == "daydream"
|
||||
# both: Daydream is the more specific
|
||||
assert detect.google_vr({"libgvr.so"}, {detect.CARDBOARD_CATEGORY, detect.DAYDREAM_CATEGORY}, []) == "daydream"
|
||||
# Google's spatial audio alone isn't Google VR (Quest games ship it, e.g. Shadow Point)
|
||||
assert detect.google_vr({"libgvraudio.so", "libaudioplugingvrunity.so"}, set(), []) == ""
|
||||
assert detect.google_vr(set(), set(), [], boot="vr-enabled=1\nvr-device-list=Oculus\n") == ""
|
||||
|
||||
|
||||
def test_daydream_apk_is_detected(tmp_path):
|
||||
"""Blade Runner: Revelations' shape: Unity 2017, 32-bit, Daydream category + VrCore's VR mode, libgvr.so."""
|
||||
m = _manifest(categories=[detect.DAYDREAM_CATEGORY], enable_vr_mode=DAYDREAM_VR_MODE)
|
||||
a = detect.analyze(_apk(tmp_path, m, ("libmain.so", "libunity.so", "libgvr.so", "libaudioplugingvrunity.so")))
|
||||
assert a.extra["google_vr"] == "daydream"
|
||||
assert a.vr_kind == "none" and a.extra["analysis_version"] == detect.ANALYSIS_VERSION
|
||||
|
||||
|
||||
def test_cardboard_from_java_classes(tmp_path):
|
||||
dex = b"dex\n035\0...Lcom/google/vr/sdk/base/GvrView;..."
|
||||
a = detect.analyze(_apk(tmp_path, _manifest(), dex=dex))
|
||||
assert a.extra["google_vr"] == "cardboard"
|
||||
|
||||
|
||||
def test_quest_game_with_google_audio_is_not_google_vr(tmp_path):
|
||||
a = detect.analyze(_apk(tmp_path, _manifest(), ("libmain.so", "libunity.so", "libvrapi.so", "libgvraudio.so")))
|
||||
assert a.extra["google_vr"] == "" and engine.google_vr_note(a) is None
|
||||
|
||||
|
||||
def test_warning_but_never_unsupported(tmp_path):
|
||||
m = _manifest(categories=[detect.DAYDREAM_CATEGORY], enable_vr_mode=DAYDREAM_VR_MODE)
|
||||
a = detect.analyze(_apk(tmp_path, m, ("libmain.so", "libunity.so", "libgvr.so")))
|
||||
note = engine.google_vr_note(a)
|
||||
assert note and "Daydream" in note and "Oculus build" in note
|
||||
assert note in engine.blocker_notes(a)
|
||||
r = engine.suggest(a, use_catalog=False)
|
||||
assert r.status != "unsupported" # the manifest alone never decides; the launch test's triage does
|
||||
assert note in r.notes and "Android app without VR" not in r.notes
|
||||
|
||||
|
||||
def test_daydream_and_oculus_build_goes_the_normal_way(tmp_path):
|
||||
"""A Unity build with both Daydream and Oculus support: the Oculus path works, so no warning."""
|
||||
m = _manifest(categories=[detect.DAYDREAM_CATEGORY], enable_vr_mode=DAYDREAM_VR_MODE)
|
||||
a = detect.analyze(_apk(tmp_path, m, ("libmain.so", "libunity.so", "libgvr.so", "libvrapi.so")))
|
||||
assert a.extra["google_vr"] == "daydream" and a.vr_kind == "quest"
|
||||
assert engine.google_vr_note(a) is None
|
||||
|
||||
|
||||
def test_triage_gvr_not_supported():
|
||||
"""Lines from #335's launch log (Blade Runner: Revelations on the 32-bit bridge)."""
|
||||
lines = [
|
||||
"10-16 05:27:34.526 1355 1384 W a32bridge: libgvr.so: Java_com_google_vr_sdk_base_CardboardViewNativeImpl_"
|
||||
"nativeDestroy: class com/google/vr/sdk/base/CardboardViewNativeImpl not found",
|
||||
"10-16 05:27:34.560 1355 1384 E Unity : Google VR Error [Daydream]: Google VR had a fatal issue while "
|
||||
"loading. VR will not be available.",
|
||||
"10-16 05:27:43.854 225 1097 W ActivityManager: Unable to start service Intent { "
|
||||
"act=com.google.vr.vrcore.controller.BIND pkg=com.google.vr.vrcore } U=0: not found",
|
||||
]
|
||||
for line in lines:
|
||||
r = triage(line + "\n", "RUNNING")
|
||||
assert [f.id for f in r.findings] == ["gvr-not-supported"] and r.verdict == "fail", line
|
||||
# GVR's own start-up line alone isn't a failure (a Daydream build with Oculus support logs it too)
|
||||
assert not triage("10-16 05:27:34.555 1355 1355 I GVR : [vr/gvr/capi/src/gvr.cc:103] Initialized GVR "
|
||||
"version 1.101.0\n", "RUNNING").findings
|
||||
Reference in new issue
Block a user