Compare commits

...
4 Commits
Author SHA1 Message Date
spoopyghosty0 bc40325c2d Updater: start the Windows swap script in a hidden console and check it runs; Linux bundle built on Ubuntu 22.04
Found in the end-to-end test (0.3.1 -> 0.3.2): PowerShell started with DETACHED_PROCESS from the packaged app exits
without running the script, so the app quit and nothing was updated. apply() now waits until the script logs that it
runs and raises otherwise. The smoke test uses the same non-waiting launch as the app. The Linux bundle built on
24.04 needed GLib 2.80.
2026-10-02 01:01:37 -04:00
spoopyghosty0 82c2209b6f OVRPort 1.2.5, documentation with screenshots, store description cleanup
- The overport CLI now comes from Android-XR-Bridge/OVRPort (fallback ovrport/app); its four new patches are
  described and shown only where they apply; parity classifies the 1.2.5 differences (VR metadata, config version,
  CLI-added platform compat) as expected: 34/34 at parity.
- README rewritten (purpose, getting started, what works, credits, legal), INSTALL tightened, screenshots in
  docs/images (scripts/scrub_library.py + ui_smoke --docs --fake-frame), Quest2Frame references removed.
- Game page: store descriptions without Markdown/[media] markup; themed labels on all switches.
- `frameport install --apk` installs a given build (headset A/B tests).
2026-10-02 00:32:14 -04:00
spoopyghosty0 e645ca86d1 FramePort 0.3.1 2026-10-01 23:20:19 -04:00
spoopyghosty0 75c7b2bf20 Updater: run Windows PowerShell 5.1 by full path without pwsh's PSModulePath; log why a signature can't be read 2026-10-01 23:10:50 -04:00
32 changed files with 645 additions and 170 deletions

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
archive: FramePort-windows-x64.zip
- os: macos-latest
archive: FramePort-macos-arm64.zip
- os: ubuntu-latest
- os: ubuntu-22.04 # oldest supported: the bundle needs the build host's GLib/glibc or newer
archive: FramePort-linux-x64.tar.gz
runs-on: ${{ matrix.os }}
steps:
+31 -1
View File
@@ -22,6 +22,7 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
job/connection events call `app.refresh_view()` (targeted), not `render()`; no I/O in render paths.
**Never recreate clickable controls on progress ticks** (sidebar, activity tiles): update their properties —
replacing them 5×/s swallowed clicks (couldn't leave the Library during an upload).
Labelled switches: `C.switch(label, …)` (Material's default label colour is dark on our dark theme).
Help hints: wording for non-obvious terms lives in `ui/help.py` (`HELP`); show it with `C.help_icon(key)` or the
`help=` argument of `section`/`status_row`/`kv`, tooltips via `C.tip()` (wraps). Game actions for the Library
right-click menu (one `ft.ContextMenu` around the grid, filled on right-click) and the game page's "…" menu come
@@ -96,6 +97,8 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
- `apk/` — `axml.py` (binary manifest editor), `workspace.py` (staged zip edits), `sign.py` (apksigner; it aligns too).
- `recommend/` — `catalog.py` (known-good recipes: user > remote `FRAMEPORT_CATALOG_URL` > bundled), `engine.py`.
- `tools/` — portable toolchain (Temurin JRE, overport jar, apksigner) downloaded dynamically into the user data dir.
The overport CLI comes from the downstream fork **Android-XR-Bridge/OVRPort** (stable `vX.Y.Z` releases,
`OVRPort-<ver>-stable-cli.jar`; fallback ovrport/app `cli-jar.zip`), see "overport" below.
- `frame/` — SSH (paramiko), mDNS discovery, pairing server; `install/installer.py`; `validate/` (static, device, triage).
- `targets/` — `Target` interface; `frame_lepton.py` (Quest via Lepton + Rift via Proton), `pc_revive.py` (Rift games
on this Windows/WSL PC via Revive + local Steam shortcut; `core/winhost.py` = Windows/WSL helpers).
@@ -156,6 +159,13 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
artwork, frames.json, the app's SSH key which the dev Frame authorizes).
- Device checks: `frameport test <pkg>` / `frameport parity-device --results <parity.json> --baseline <launch.txt>
[--test-only]`; the pre-FramePort baseline is `PATCHED/_known-good-2026-09-28/_frame-state/baseline-launch.txt`.
- Docs screenshots (`docs/images/`): `python scripts/scrub_library.py ~/.local/share/frameport <dir>` (copies
library + artwork only; titles replace folder names, local paths → `D:/Games/...`, sort by size) then
`FRAMEPORT_HOME=<dir> python scripts/ui_smoke.py --out <shots> --docs --fake-frame --game <pkg>` (set
FRAMEPORT_JAVA/_OVERPORT_JAR/_APKSIGNER_JAR so no tool download toast appears; `--viewport 1280x2600` + crop for the
patch list). Check every PNG for paths, IPs, user names and repack/scene names before committing.
README rules (owner): states the project is a proof of concept, provides no piracy tools, credits the wrapped
projects (most functionality is theirs); neutral technical wording; no Quest2Frame mentions anywhere.
- GUI smoke test: `uv pip install flet-web playwright && playwright install chromium`, then
`FRAMEPORT_HOME=<test dir> python scripts/ui_smoke.py --out <dir> [--game <pkg>] [--frame steamos@<host>] [--update]` (`--update` = fake release: banner, dialog, Settings → Updates) and look
at the PNGs. Flet 1.0 notes: `ft.run` must own the main thread; background work via `page.run_thread`; FilePicker is
@@ -256,6 +266,16 @@ stopping Steam kills them → always run that work via `systemd-run --user` (the
**overport:** always `--version=latest`; `--workspace` holds runtimes and **per-package keystores (password
"password", alias "key") — never lose them**: updates must be signed with the same key or saves are lost on reinstall.
Output is deterministic (same input + runtime → same bytes), which is what makes parity testing possible.
**OVRPort 1.2.5 (2026-10-01, the fork's first release; CLI-only):** same commands (`patches [--json]`, `patch`, `help`,
`install`), but `patch` rejects unknown/duplicate args, unknown patch ids and an empty `--patches=`
(`tools/overport.patch` refuses empty lists). After patching it adds `libovrplatformcompat.so` itself when the platform
loader lacks `ovrMessageType_ToString` (our `frame.ovrplatformcompat` then skips: same library). New patches (all off;
`patches/overport.py`): `patch_ac_nexus_no_appsw_72/_90` (AC Nexus build 207706 only, exclusive),
`patch_disable_meta_xr_audio_telemetry` (x86_64 emulators: hidden), `patch_vrapi_openxr` = OVRPort's VrApi adapter =
the **unpatched** upstream of our `frame.vrapi_bridge` (`native/vrapi` unchanged since our 5e7df52), only usable with an
experimental CLI built with `-PwithVrApi=true` (stable jars list it but fail). The owner prefers OVRPort's fixes over
ours where they work as well (less to maintain): compare in the headset before switching a default.
`frameport install <pkg> --apk <file>` installs a specific (test) build signed with the game's key.
**Patching gotchas:**
- UnityPy re-serialization breaks scene loading → patch QualitySettings ints in place.
@@ -343,6 +363,16 @@ Installed apps find the release themselves (self-update), so the notes are what
Windows console) plus PYTHONUTF8; macOS builds need `--python-version 3.12 --arch arm64` (cryptography has no wheels
for flet's default Python / x86_64 cross-build), with a PyInstaller fallback step; `astral-sh/setup-uv` has no
floating major tags after v7 → pin the exact version; force-moving a tag starts duplicate runs (cancel one).
- Windows PowerShell calls from Python (`updates._powershell()`): use `%SystemRoot%\System32\WindowsPowerShell\v1.0\
powershell.exe` with `PSModulePath` removed from the environment — started under PowerShell 7 (CI's default shell,
or a user's pwsh terminal) it couldn't run Get-AuthenticodeSignature (v0.3.0's Windows update smoke failed on it).
`v0.3.0` is a tag without a release (that failed build); the updater shipped first in v0.3.1.
- **Never start PowerShell with `DETACHED_PROCESS` from the packaged app**: it exits 0 without running anything (found
in the 0.3.1→0.3.2 end-to-end test: the app quit, nothing updated). `updates.spawn_hidden` = CREATE_NEW_CONSOLE +
hidden window; `apply()` waits until the script has logged that it runs and raises otherwise (the app then stays
open). Windows installs of 0.3.1/0.3.2 can't update themselves: they need one manual download.
- The Linux bundle is built on ubuntu-22.04: a 24.04-built Flutter bundle needs GLib 2.80 (`undefined symbol:
g_once_init_enter_pointer` on 22.04).
- **This project's GitHub identity is `spoopyghosty0`** (a dedicated account; the machine's default gh/git login is a
different, personal account that must never touch this repo). `gh` (`~/.local/bin/gh`) uses it through
`GH_CONFIG_DIR=~/.config/gh-spoopyghosty0` (set for Claude Code in the git-ignored `.claude/settings.local.json`);
@@ -379,7 +409,7 @@ issues (Arcsmith/Time Stall eye distortion, AC Nexus some flipped launch text, P
6 can't run (Sniper Elite VR, Espire 1, HITMAN, and the 32-bit Journey of the Gods / Shadow Point / Sports Scramble).
Parity: all 34 rebuilt from the dumps match the known-good builds (`docs/parity-report.md`) and were reinstalled +
launch-tested with 0 regressions (`docs/parity-device-report.md`). `PATCHED/` holds exactly the installed builds.
Owner preferences: manual installs (no FrameDrop, no Quest2Frame app), Python + Flet, dynamic data over hardcoding,
Owner preferences: manual installs (no third-party installer apps), Python + Flet, dynamic data over hardcoding,
free tooling only, public repo scrubbed of personal data, keep the known-good backups.
Rift/PC VR support (2026-09-29): implemented + unit-tested, not yet tried with a real Rift game on the PC or Frame
(needs a Rift dump, and Proton installed on the Frame). Open ideas: exe-icon artwork for Rift games, macOS x86_64 bundle, USB-cable connection (Frame `usb0`, untested),
+98 -29
View File
@@ -1,38 +1,107 @@
# FramePort
Play your Meta Quest standalone games on the **Valve Steam Frame**. FramePort patches a Quest APK so it runs in the
Frame's Lepton Android runtime, validates the result, installs it over Wi-Fi and adds it to your Steam library with
artwork. Think Morphe/ReVanced Manager, for Quest → Steam Frame:
FramePort installs Meta Quest standalone games and Oculus Rift PC VR games on the **Valve Steam Frame**. It patches a
game so it runs on the Frame's runtimes, copies it to the headset over Wi-Fi and adds it to the Frame's Steam library
with artwork.
1. **Connect your Frame** once: the app shows one command to paste into the Frame's desktop terminal (it turns on SSH,
trusts the app, and installs Lepton). After that the app finds the Frame on your network by itself.
2. **Add games**: scan a folder of Quest dumps (APK + OBB) or add an APK.
3. **Review patches**: every game gets suggested patches (overport's plus Steam Frame fixes), each with a reason.
34 games have verified recipes; unknown games get heuristics.
4. **Patch, validate, install**: signature/alignment/dependency checks, upload (resumable, skips data already on the
Frame), Steam shortcut + artwork, and a headless launch test with log triage that suggests fixes.
![Library](docs/images/library.png)
FramePort downloads and manages its own tools (Java runtime, [overport](https://github.com/ovrport/app) CLI,
apksigner). Nothing is installed system-wide, and no Android SDK/NDK is needed.
## Purpose
## Install
- **App bundles**: download from [Releases](https://github.com/spoopyghosty0/frameport/releases) (Windows x64, macOS
Apple Silicon, Linux x64); see `docs/INSTALL.md` for the first-launch warning (self-signed / ad-hoc signed) and
how to verify a download. Built by CI (`.github/workflows/build.yml`, `flet build`). FramePort **updates itself**:
when a new release is out, click **Update now** in the Library (or run `frameport update`).
- **Command line only**: `uv tool install <the release's frameport-*.whl link>` (or pipx / pip), then `frameport --help`.
- **From source**: `uv sync && uv run frameport-gui` (CLI: `uv run frameport --help`).
FramePort is a **proof of concept**: it shows that VR software built for other platforms (Meta Quest, Oculus Rift) can
run on the Steam Frame with a translation layer and a few targeted fixes. Most of the work is done by the projects
FramePort wraps (see [Built on](#built-on)); FramePort selects and applies their fixes per game, adds Steam
Frame-specific patches, and handles installing, testing and launching.
## Status of tested games
See `catalog/games/` (and the Library screen): 23 work, 5 work with issues, 6 can't run on the Frame (3 are 32-bit
only; PC VR alternatives are listed). Details and how problems were solved: `docs/PLAYBOOK.md`.
All 34 recipes were verified by rebuilding from the original dumps (`docs/parity-report.md`: 34/34 match the
hand-made known-good builds) and reinstalling + launch-testing on a Frame (`docs/parity-device-report.md`: 0 regressions).
FramePort is **not a piracy tool**. It does not download, share or unlock games, and it does not remove DRM, licence or
entitlement checks. Use it only with games you own. Games that check their licence through the Oculus Platform SDK are
marked as not runnable on the Frame.
## Legal
Use only with games you own. FramePort does not download games. It is GPL-3.0-only because it bundles code derived
from GPL-3.0 projects (Quest2Frame's hook technique in the adapter, the Android-XR-Bridge/OVRPort VrApi bridge).
overport is downloaded from its official releases and is not redistributed.
## Getting started
1. **Download** the archive for your computer from the
[latest release](https://github.com/spoopyghosty0/frameport/releases/latest) and extract it anywhere:
| Computer | File | Start |
|---|---|---|
| Windows 10/11 (x64) | `FramePort-windows-x64.zip` | `FramePort.exe` |
| macOS (Apple Silicon) | `FramePort-macos-arm64.zip` | `FramePort.app` |
| Linux (x64) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
The builds are self-signed, so the first start shows a warning: Windows → **More info → Run anyway**;
macOS → right-click the app → **Open**. Details: [docs/INSTALL.md](docs/INSTALL.md).
2. **Get the tools**: on first start FramePort downloads what it needs (Java runtime, OVRPort, apksigner) into its own
folder. Nothing is installed system-wide.
3. **Connect the Frame**: turn on Developer Mode on the Frame (Settings → System → Developer). In FramePort open
**Steam Frame**; if the Frame isn't listed, switch it to Desktop mode, open Konsole and paste the one command
FramePort shows. This is needed once.
4. **Add games**: **Add games → Scan a folder** with your Quest game backups (APK + OBB) or Rift game folders.
FramePort identifies each game and fetches its artwork and store details.
5. **Install**: open a game and click **Install on Frame**. FramePort patches, checks, uploads and adds the game to the
Frame's Steam library, then runs a short launch test.
6. **Play**: put the headset on and start the game from the Steam library (or click **Play on Frame**).
![Game page](docs/images/game.png)
FramePort updates itself: when a new version is released, the Library shows **Update now**.
## What works
The built-in catalog has tested settings for 38 games (27 work, 5 work with known issues, 6 can't run on the Frame).
Other games get suggested patches from detection rules; each suggestion states its reason, and every patch can be
switched on or off under **Customize**.
![Patches](docs/images/patches.png)
- Quest games run in Valve's Android runtime (Lepton). 32-bit-only Quest games can't run (the Frame has no 32-bit ARM
support).
- Rift games run through Proton on the Frame (experimental) or on a Windows PC with SteamVR, streamed to the Frame.
Games that use the Oculus Platform SDK need the Oculus app and a licence, so they run on the PC only.
- Automated launch tests confirm a game starts; visuals can only be checked in the headset.
![Steam Frame](docs/images/frame.png)
## Built on
FramePort is a front end for other projects; most of the functionality comes from them:
| Project | Used for |
|---|---|
| [OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (overport, originally [ovrport/app](https://github.com/ovrport/app)) | Converts Quest games to OpenXR: its CLI applies the game patches and supplies the OpenXR loader; FramePort also includes its VrApi→OpenXR adapter |
| Valve Lepton, Proton and SteamVR | Run Android games, Windows games and OpenXR on the Frame |
| [Revive](https://github.com/LibreVR/Revive) (LibreVR) | Runs Oculus Rift games on OpenXR / SteamVR |
| Mesa (Zink) | OpenGL ES on Vulkan on the Frame |
| [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK) | OpenXR headers for the native layers |
| Eclipse Temurin, Android apksigner, Android NDK | Java runtime, APK signing, building the native layers |
| [Flet](https://flet.dev) | The desktop app |
| OculusDB, Steam store | Game descriptions, genres and artwork |
FramePort's own parts: game detection and recipes, the Steam Frame OpenXR adapter (FrameBridge) and the other native
fixes in `native/`, the installer agent that runs on the Frame, and the desktop/command-line app.
## Command line
The same functions are available as `frameport` (included in the source tree; also released as a Python wheel):
`frameport scan`, `frameport build`, `frameport install`, `frameport test`, `frameport update`. Run
`frameport --help` for the full list.
## Documentation
- [docs/INSTALL.md](docs/INSTALL.md): installing, first launch, updating, Rift games, sending files, reporting problems.
- [docs/PLAYBOOK.md](docs/PLAYBOOK.md): symptoms and fixes per game.
- [docs/FRAME_RUNTIME.md](docs/FRAME_RUNTIME.md): Steam Frame runtime facts.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): how the code is organised.
## Development
See `CLAUDE.md` (architecture, commands, findings) and `docs/ARCHITECTURE.md`.
```
uv sync --extra dev
uv run frameport-gui # the app
uv run pytest # tests
```
See `CLAUDE.md` for project notes and `native/README.md` for the native components.
## License
GPL-3.0-only (it includes GPL-3.0 code from OVRPort). Not affiliated with Valve or Meta.
+5 -4
View File
@@ -22,8 +22,9 @@
direct-VrApi, GLAD/eglGetProcAddress, Unity MSAA levels, Meta permissions, telemetry references.
3. **Recipe** (`recommend/engine.py`): every patch's `detect()` suggests itself with a reason; a catalog entry (exact
known-good recipe) overrides heuristics. The UI shows toggles; the user confirms.
4. **Build** (`build.py`): overport (defaults + extras) → apk-stage patches in `order` on an `ApkWorkspace` → apksigner
(with the package's own keystore) → static validation. Optional alternate build (e.g. without Unreal ForceQuit).
4. **Build** (`build.py`): overport CLI (OVRPort; defaults + extras) → apk-stage patches in `order` on an
`ApkWorkspace` → apksigner (with the package's own keystore) → static validation. Optional alternate build (e.g.
without Unreal ForceQuit).
5. **Install** (`install/installer.py` + `agent/frameport_agent.py`): `prepare` (paths, what's already there) → SFTP
uploads with resume → `finalize` (move into place, settings.conf/framebridge.conf, device files, launch.sh,
deployment.json, artwork) → `shortcuts` (detached systemd unit stops Steam, writes shortcuts.vdf + grid art,
@@ -55,7 +56,7 @@ apart. "Build" = `pipeline.prepare_rift` (checks + Revive). Revive is a portable
stale files removed), `launch.sh` running the ARM64 Proton chain (built from toolmanifest.vdf; with
`pcvr.oculus_unreal` the injector runs through `helpers/fp_oculushmd.exe`, which provides Unreal's
`OculusHMDConnected` event); `proton_status`/`install_proton` manage Proton from Valve's ARM64 compat list.
- **Native binaries** (`artifacts/`): edit `native/…`, run `native/build.sh`, commit the new artifacts + SHA256SUMS,
- **Native binaries** (`artifacts/`): edit `native/…`, run `python native/build.py`, commit the new artifacts + SHA256SUMS,
run `frameport parity` to see which games change.
## Self-update (`updates.py`, `ui/updater.py`, `cli.py update`)
@@ -80,6 +81,6 @@ GUI: background check 10 s after start + every 6 h → sidebar card + Library ba
update [--check] [--yes]`. CI runs `scripts/update_smoke.py` on every OS with the archive it just built.
## Dynamic data (fetched live, cached, bundled fallback)
overport release + patch list + patch titles (GitHub), Temurin JRE (Adoptium API), apksigner (Google repository index),
overport CLI release (Android-XR-Bridge/OVRPort, fallback ovrport/app) + patch list + titles, Temurin JRE (Adoptium API), apksigner (Google repository index),
store artwork/titles (overport image API), catalog (optional remote), Lepton location/appid (Frame appmanifests),
Steam user (Frame userdata).
+63 -81
View File
@@ -1,107 +1,89 @@
# Installing FramePort
Download the archive for your OS from the [latest release](https://github.com/spoopyghosty0/frameport/releases/latest)
and extract it anywhere. No installer and no admin rights are needed. On first start, open **Tools → Install
missing** once: FramePort downloads its own Java runtime, the overport CLI and apksigner into its data folder.
Download the archive for your computer from the [latest release](https://github.com/spoopyghosty0/frameport/releases/latest)
and extract it anywhere. No installer or admin rights are needed. On first start FramePort downloads its Java
runtime, the OVRPort CLI and apksigner into its data folder (Settings → Tools shows them).
| OS | Archive | Start |
| Computer | Archive | Start |
|---|---|---|
| Windows 10/11 (x64) | `FramePort-windows-x64.zip` | `FramePort.exe` |
| macOS (Apple Silicon) | `FramePort-macos-arm64.zip` | `FramePort.app` |
| Linux (x64) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` (needs GTK 3) |
| Command line only (any OS with Python 3.11+) | `frameport-<version>-py3-none-any.whl` | `frameport --help` |
| Linux (x64, GTK 3; Ubuntu 22.04 or newer) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
| Command line only (Python 3.11+) | `frameport-<version>-py3-none-any.whl` | `frameport --help` |
The command-line version installs with [uv](https://docs.astral.sh/uv/) (or pipx / pip) straight from a release, e.g.
`uv tool install https://github.com/spoopyghosty0/frameport/releases/download/v0.3.0/frameport-0.3.0-py3-none-any.whl`
(copy the wheel's link from the release page). `frameport --version` shows the installed version.
The command-line version installs from the wheel's release link with `uv tool install <link>` (or pipx / pip).
## First launch
FramePort is free and signed with its own **self-signed** certificate (Windows) and **ad-hoc** signature (macOS),
not a paid one, so your OS warns the first time:
The builds are signed with a free self-signed certificate (Windows) and an ad-hoc signature (macOS), so the first
start shows a warning:
- **Windows:** SmartScreen shows "Windows protected your PC" → click **More info** → **Run anyway**.
Optional, to make Windows show "FramePort" as a verified publisher: import `FramePort-selfsigned.cer` (attached
to each release) into **Trusted Root Certification Authorities** (right-click → Install Certificate → Current User →
"Place all certificates in the following store"). The certificate is limited to code signing and cannot issue other
certificates. SmartScreen is reputation-based, so it may still warn for new versions. Only do this if you trust the
builds from this repository; remove it any time with `certmgr.msc`.
- **macOS:** right-click `FramePort.app` → **Open** → **Open** (only needed once), or run
`xattr -dr com.apple.quarantine FramePort.app`.
- **Windows:** "Windows protected your PC" → **More info** → **Run anyway**. Optional: import
`FramePort-selfsigned.cer` (attached to each release) into *Trusted Root Certification Authorities* (Current User) to
show FramePort as the publisher; the certificate can only sign code. Remove it with `certmgr.msc`.
- **macOS:** right-click `FramePort.app` → **Open** → **Open** (once), or `xattr -dr com.apple.quarantine FramePort.app`.
- **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort`.
## Connecting the Steam Frame
1. On the Frame: Settings → System → Developer → turn on **Developer Mode**. The Frame and the computer must be on the
same network.
2. In FramePort open **Steam Frame**. A Frame in Developer Mode appears in the list.
3. First time only: on the Frame switch to Desktop mode (Steam button → Power → Switch to Desktop), open Konsole and
run the command FramePort shows. It enables SSH, authorises this computer and installs Valve's Android runtime
(Lepton) if needed.
## Updating
FramePort checks GitHub for a new release when it starts and every 6 hours (only the release information, nothing about
you is sent). When there is one, a **Update available** card appears in the sidebar and a bar at the top of the Library:
click **Update now** (or the card → **Update and restart**). FramePort downloads the new version, checks it against the
release's `SHA256SUMS.txt` (on Windows also that it carries the same signature as the running copy), closes, replaces
its program folder / `FramePort.app` and opens again as the new version. Your games, settings, signing keys and Frame
connection live in FramePort's data folder and are kept. If installs or uploads are running, the update waits until
they finish. **Later** hides that version (Settings → Updates → **Check for updates** still finds it).
Settings → **Updates**: switch off the automatic check, or turn on **Install updates automatically** (downloads new
versions in the background and installs them the next time FramePort starts — no clicks at all). If FramePort's folder
isn't writable (e.g. under Program Files), Update now opens the release page instead; download and extract it as
above. The update's log is `logs/update.log` in FramePort's data folder; the replaced files of a Windows update are kept
in `updates/<version>/previous` until the next update.
FramePort checks for a new release at start and every 6 hours (it only downloads the release information). When one
exists, the Library shows **Update now**: FramePort downloads the new version, verifies it against the release's
`SHA256SUMS.txt` (on Windows also the signature), restarts and opens as the new version. Games, settings, signing keys
and the Frame connection are kept. Running installs finish first. **Later** skips that version.
Command line: commands mention a new version at most once a day (on stderr). `frameport update` shows what's new and
updates (a bundle the same way as the app; a `uv tool`/pipx/pip install by reinstalling the release's wheel; a source
checkout with `git pull` + `uv sync`). `frameport update --check` only checks (exit code 10 = an update exists),
`--yes` skips the question. `FRAMEPORT_NO_UPDATE_CHECK=1` turns all checks off.
Settings → **Updates**: turn the check off, or turn on **Install updates automatically** (downloads in the background,
installs at the next start). If FramePort's folder isn't writable, **Update now** opens the release page instead. The
update log is `logs/update.log` in the data folder.
Command line: `frameport update` (`--check` only checks, exit code 10 = update available; `--yes` doesn't ask).
`FRAMEPORT_NO_UPDATE_CHECK=1` turns all checks off.
## Oculus Rift (PC VR) games
Scan a folder of Rift games (one folder per game; the game may sit a few levels down, next to installers and archives)
the same way as Quest dumps, or use **Add games → Add one game folder…**. FramePort finds the program that starts
each game; when it isn't sure (e.g. an Oculus and a Steam build side by side) it asks you. It also fetches artwork, descriptions, genres and
(for games also sold on Steam) screenshots, and gives each Steam library entry full artwork plus tags for how it
runs, its original platform (Meta Quest / Oculus Rift) and its genres. If your copies are already patched, switch on **Already patched** on the game page:
Quest APKs are installed unchanged, Rift games start without Revive. If you installed Revive
yourself (official installer), FramePort uses that one; otherwise it downloads its own portable copy (Tools shows which;
nothing is installed system-wide).
- **Play from this PC:** needs Windows with Steam and SteamVR. "Install on this PC" adds the game to your Steam
library (Steam closes and reopens once); stream it to the Frame with Steam Link / SteamVR. If a game stutters
because it can't keep up with the headset's refresh rate, FramePort notices from SteamVR's records and, the next
time you press Play, lowers that game's refresh rate (e.g. 96 → 80 Hz) and turns on motion smoothing in SteamVR's
per-game settings (patch "Automatic SteamVR performance settings" on the game page, where you can also pick a
rate yourself).
- **Play on the Frame (experimental):** Frame → "Install Proton" (confirm the download in the headset), then "Install
on Frame (Proton)" on the game page.
- Games that use the Oculus Platform SDK (FramePort shows a note) check your Oculus license: they need the Oculus app
installed on the PC with a license you own, so they can't run on the headset.
## Videos, documents and mods for Quest games
Frame → **Send files** (or a game's menu → **Send files to this game…**) copies files or whole folders from this PC to
the Frame. **Videos**, **Downloads** and **Documents** are shared by every Quest game: they appear inside each game as
`/sdcard/Movies`, `/sdcard/Download` and `/sdcard/Documents` (they are the Frame's own `~/Videos`, `~/Downloads`,
`~/Documents`). A game's own storage (`/sdcard` of that game only) is for mods and saves. Apps find the files by
**browsing folders** (e.g. in a video player: local storage → Movies); their "all videos" lists stay empty because
Android's media index doesn't work on the Frame. Command line: `frameport frame send <files> --to videos` (see
`frameport frame storage`).
Scan a folder of Rift games (one folder per game) or use **Add games → Add one game folder…**. FramePort finds the
game's program and asks when there is more than one candidate. **Already patched** on a game page installs a copy
unchanged. FramePort uses an installed Revive, or downloads a portable copy.
- **Play from this PC:** Windows with Steam and SteamVR. **Install on this PC** adds the game to Steam; stream it to the
Frame with Steam Link. If a game can't keep up with the refresh rate, FramePort lowers the rate and enables motion
smoothing in SteamVR's per-game settings the next time you press Play.
- **Play on the Frame (experimental):** Steam Frame → *PC VR games (Proton)* → **Install**, then **Install on Frame**
on the game page.
- Games that use the Oculus Platform SDK check the licence through the Oculus app, so they run on the PC only.
## Videos, documents and mods
Steam Frame → **Send files** (or a game's menu → **Add videos & files…**) copies files to the Frame. **Videos**,
**Downloads** and **Documents** appear inside every Quest game as `/sdcard/Movies`, `/sdcard/Download` and
`/sdcard/Documents`. Apps find them by browsing folders; Android's media index doesn't work on the Frame. Command
line: `frameport frame send <files> --to videos`.
## Sharing a working game, reporting a problem
- **A game works?** Game menu (right-click in the Library, or "…" on its page) → **Share working config…**. FramePort
opens a prefilled GitHub issue with the game's recipe (patches and settings); check it and submit. Once accepted it
becomes a built-in recipe for everyone. No GitHub token and no game files are involved.
- **Something doesn't work?** Game menu → **Report a problem…** (or Settings → Problems & feedback for app issues).
FramePort saves a diagnostics zip to your Documents folder (logs, recipe, device details; IP addresses, user and
host names, home folders and Steam ids replaced by placeholders; no game files), shows it in a folder window and
opens a prefilled GitHub issue: drag the zip into the "Diagnostics" box and submit. **Collect logs** only saves the
zip. Command line: `frameport diag report <game>`, `frameport diag collect <game>`, `frameport share-recipe <game>`.
- **Share working config…** (game menu): opens a prefilled GitHub issue with the game's patches and settings. Accepted
configs become built-in recipes.
- **Report a problem…** (game menu, or Settings → Problems & feedback): saves a diagnostics zip to Documents (logs,
recipe, device details; IP addresses, user names, home folders and Steam ids replaced) and opens a prefilled GitHub
issue to attach it to. Command line: `frameport diag report <game>`, `frameport share-recipe <game>`.
## Uninstalling
Settings → **Uninstall FramePort…** (or `frameport uninstall-app`) removes everything FramePort created: its data
folder (tools, library, artwork, cache, builds), the Steam shortcuts it added on this PC and, optionally, its games and
files on the Frame (saves can be kept). It first saves a zip of your signing keys to Documents (game updates must be
signed with the same key). Then delete the FramePort program folder.
Settings → **Uninstall FramePort…** (or `frameport uninstall-app`) removes its data folder, the Steam shortcuts it
added on this computer and, optionally, its games on the Frame (saves can be kept). It first saves your signing keys
to Documents: game updates must be signed with the same key. Then delete the program folder.
## Verify a download
Every archive has a GitHub build attestation proving it was built by this repository's CI from a tagged commit:
```
gh attestation verify FramePort-windows-x64.zip -R spoopyghosty0/frameport
```
and `SHA256SUMS.txt` lists the checksums (`sha256sum -c SHA256SUMS.txt`). The Windows certificate's SHA-256
fingerprint is `4E:12:98:91:62:C0:E4:50:FB:65:1D:34:BB:73:00:09:7B:78:BE:88:5C:A7:6C:42:23:46:9B:92:A1:59:A7:6E`.
Each archive has a GitHub build attestation:
`gh attestation verify FramePort-windows-x64.zip -R spoopyghosty0/frameport`. `SHA256SUMS.txt` lists the checksums
(`sha256sum -c SHA256SUMS.txt`). Windows certificate SHA-256 fingerprint:
`4E:12:98:91:62:C0:E4:50:FB:65:1D:34:BB:73:00:09:7B:78:BE:88:5C:A7:6C:42:23:46:9B:92:A1:59:A7:6E`.
+1 -1
View File
@@ -1,6 +1,6 @@
# Porting playbook: symptom → cause → fix
Everything here was hit for real while porting 34 Quest games to the Steam Frame (Sept 2026). The machine-readable
Everything here was hit while porting Quest and Rift games to the Steam Frame (Sept–Oct 2026; 38 catalog recipes). The machine-readable
version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); keep both in sync.
## Fast path for a new game
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+2 -2
View File
@@ -7,8 +7,8 @@ embeds debug paths (`-g`) and the dex depends on the d8 version, so those differ
| Dir | Artifact | What / why |
|---|---|---|
| `adapter/` | `{arm64-v8a,armeabi-v7a}/libopenxr_loader_generic.so` | **FrameBridge**: replaces overport's generic loader (which is renamed `libopenxr_loader_original.so`) and fixes Steam Frame runtime gaps. Hooks xrCreateInstance, swapchains, xrEndFrame, spaces, xrPollEvent, … everything else is forwarded by `gen_forwarders.py`-generated tail calls. `scene_emu.c` = Meta scene/spatial-entity emulation; `flip_vk.c` = Vulkan blit for VERTICAL_FLIP quads. `session_fixes.c` = per-game session fixes, all off by default and hooked only when on (`layer_debug` diagnostics, `stable_local`, `focus_hold`, aim correction `aim_pitch/aim_yaw/aim_forward`, `refresh_rate`); `layer_emul_gl.c` = `equirect_emul` (GLES sessions only): 360° equirect/equirect2 layers become one adapter projection layer, drawn by a worker thread with its own EGL context shared with the app's: each 360° image is converted to a cube map only when it changes, and the view is redrawn only after a 4° head turn (GL/EGL resolved with dlopen, so Vulkan games never load them); `layer_math.h` = their pose/face math, tested on the host by `tests/test_layer_emul.py`. `render_model.c` = XR_FB_render_model emulation (setting `controller_models`, off by default): serves `files/framebridge/controller_{left,right}.glb`, which the agent converts on the Frame from SteamVR's own Frame controller render models (never shipped by FramePort); tested on the host by `tests/test_render_model.py`. Settings: `libframe_settings.so` in the APK + `settings.conf`/`framebridge.conf` on the Frame. Hook technique from Quest2Frame's `frame_bridge.c` (GPL-3.0). |
| `vrapi-bridge/` | `arm64-v8a/libvrapi.so` | VrApi → OpenXR bridge from [Android-XR-Bridge/OVRPort](https://github.com/Android-XR-Bridge/OVRPort) `native/vrapi` at 5e7df52 (GPL-3.0, `LICENSE.upstream`), with our changes in `upstream-patches/`: GLES sessions + GL texture swapchains, cylinder→quad layers, GL vertical flip, UNORM↔sRGB format twins, 30 s VR-mode deadline, VALID-only recenter, loading-icon layers skipped, diagnostics behind `-DOVP_GL_DIAG`. |
| `adapter/` | `{arm64-v8a,armeabi-v7a}/libopenxr_loader_generic.so` | **FrameBridge**: replaces overport's generic loader (which is renamed `libopenxr_loader_original.so`) and fixes Steam Frame runtime gaps. Hooks xrCreateInstance, swapchains, xrEndFrame, spaces, xrPollEvent, … everything else is forwarded by `gen_forwarders.py`-generated tail calls. `scene_emu.c` = Meta scene/spatial-entity emulation; `flip_vk.c` = Vulkan blit for VERTICAL_FLIP quads. `session_fixes.c` = per-game session fixes, all off by default and hooked only when on (`layer_debug` diagnostics, `stable_local`, `focus_hold`, aim correction `aim_pitch/aim_yaw/aim_forward`, `refresh_rate`); `layer_emul_gl.c` = `equirect_emul` (GLES sessions only): 360° equirect/equirect2 layers become one adapter projection layer, drawn by a worker thread with its own EGL context shared with the app's: each 360° image is converted to a cube map only when it changes, and the view is redrawn only after a 4° head turn (GL/EGL resolved with dlopen, so Vulkan games never load them); `layer_math.h` = their pose/face math, tested on the host by `tests/test_layer_emul.py`. `render_model.c` = XR_FB_render_model emulation (setting `controller_models`, off by default): serves `files/framebridge/controller_{left,right}.glb`, which the agent converts on the Frame from SteamVR's own Frame controller render models (never shipped by FramePort); tested on the host by `tests/test_render_model.py`. Settings: `libframe_settings.so` in the APK + `settings.conf`/`framebridge.conf` on the Frame. |
| `vrapi-bridge/` | `arm64-v8a/libvrapi.so` | VrApi → OpenXR bridge from [Android-XR-Bridge/OVRPort](https://github.com/Android-XR-Bridge/OVRPort) `native/vrapi` at 5e7df52 (GPL-3.0, `LICENSE.upstream`; unchanged upstream as of OVRPort v1.2.5, which ships the unpatched code as its opt-in `patch_vrapi_openxr` in experimental CLI builds), with our changes in `upstream-patches/`: GLES sessions + GL texture swapchains, cylinder→quad layers, GL vertical flip, UNORM↔sRGB format twins, 30 s VR-mode deadline, VALID-only recenter, loading-icon layers skipped, diagnostics behind `-DOVP_GL_DIAG`. |
| `platformcompat/` | `arm64-v8a/libovrplatformcompat.so` | Real `ovrMessageType_ToString` (same fork, `native/platform`). |
| `glshim/` | `arm64-v8a/libglshim.so` | Mesa GLSL compatibility for GLAD engines (hooks eglGetProcAddress): comments out `#pragma` before `#extension`, enables `GL_EXT_shader_implicit_conversions`, hides GL_OVR_multiview (`gl_hide_multiview`, default 1) and logs failed shaders. `-DGLSHIM_TRACE` adds per-FBO draw/error tracing. |
| `xrlayer/` | `linux-arm64/libxr_frameport_timefix.so` + `XR_APILAYER_FRAMEPORT_timefix.json` | OpenXR API layer for Windows PC VR games under Proton on the Frame (glibc aarch64, built freestanding with the NDK's clang), on by default (patch `pcvr.xr_timefix`). Retries `xrCreateInstance` as an OpenXR 1.0 app when the runtime rejects 1.1 (the Frame's SteamVR runtime does, and Proton 11's VR helper asks for 1.1). Also emulates `xrConvertTimespecTimeToTimeKHR`/`xrConvertTimeToTimespecTimeKHR` if the runtime refuses them (the Frame's Android runtime does; its Linux runtime, used by Proton, supports them as of 2026-09-29 — so this part is only a fallback; Proton's wineopenxr needs them for `XR_KHR_win32_convert_performance_counter_time`, which Revive requires) with the adapter's xrWaitFrame-calibrated offset, and drops the extension from xrCreateInstance if the runtime rejects it. Enabled per game by the Proton launch.sh (`XR_API_LAYER_PATH`, `XR_ENABLE_API_LAYERS`). `tests/test_xrlayer.py` builds it for x86_64 and drives it via ctypes. |
-2
View File
@@ -3,8 +3,6 @@
//
// Installed as lib/arm64-v8a/libopenxr_loader_generic.so; overport's real generic
// loader is renamed to libopenxr_loader_original.so and loaded from the same dir.
// Hook technique follows Quest2Frame's frame_bridge.c (GPL-3.0-only,
// github.com/MichaelScottsman/Quest2Frame).
//
// Fixes applied:
// * adds XR_KHR_android_create_instance when the app chains
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Make a screenshot-safe copy of a FramePort data folder (for docs/images):
python scripts/scrub_library.py <data dir> <new dir> [--sort size]
Copies library.json + artwork/ only (no Frame pairing, SSH key, tools, logs or builds). In the copy every game's
folder name becomes its store title, every local path becomes D:/Games/<Quest|PC VR>/<title>/..., settings other than
the Library view are dropped, and install/launch history is removed. Fails if any original path is left.
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
from pathlib import Path
PATHLIKE = re.compile(r"^(/|[A-Za-z]:[\\/]|\\\\)")
DEVICE = ("/data/", "/sdcard", "/storage/", "/system/", "/vendor/", "/apex/", "/proc/", "/dev/") # inside the headset
DROP_KEYS = {"installs", "log_path", "last_played", "played"}
def safe(title: str) -> str:
return re.sub(r'[<>:"/\\|?*]+', "", title).strip() or "Game"
def scrub_game(g: dict) -> dict:
title = g.get("title") or g.get("package")
base = f"D:/Games/{'PC VR' if g.get('kind') == 'rift' else 'Quest'}/{safe(title)}"
originals: set[str] = set()
def collect(o):
if isinstance(o, dict):
for v in o.values():
collect(v)
elif isinstance(o, list):
for v in o:
collect(v)
elif isinstance(o, str) and PATHLIKE.match(o) and not o.startswith(DEVICE):
originals.add(o)
collect(g)
roots = sorted({str(Path(p).parent) for p in originals} | originals, key=len, reverse=True)
def fix(o, key=""):
if isinstance(o, dict):
return {k: fix(v, k) for k, v in o.items() if k not in DROP_KEYS}
if isinstance(o, list):
return [fix(v, key) for v in o]
if isinstance(o, str):
if PATHLIKE.match(o) and not o.startswith(DEVICE):
name = Path(o.replace("\\", "/")).name
return base if key in ("origin", "folder", "game_dir", "data_dir", "base") else f"{base}/{name}"
for r in roots:
if len(r) > 3 and r in o:
o = o.replace(r, base)
o = re.sub(r"/(?:mnt|home|Users)/[^)\"]*", "D:/Games", o) # paths inside messages (e.g. a tool's folder)
return o
out = fix(g)
out["name"] = title
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("src", type=Path)
ap.add_argument("dest", type=Path)
ap.add_argument("--sort", default="size", choices=["name", "recent", "played", "size", "status"])
args = ap.parse_args()
lib = json.loads((args.src / "library.json").read_text())
games = {pkg: scrub_game(g) for pkg, g in lib.get("games", {}).items()}
view = {**(lib.get("settings", {}).get("ui.library") or {}), "sort": args.sort, "platform": "all",
"where": "all", "status": "all", "tags": []}
out = {"games": games, "settings": {"ui.library": view, "migrations": lib.get("settings", {}).get("migrations", [])}}
text = json.dumps(out, indent=1)
leftovers = [o for o in re.findall(r'"((?:/|[A-Za-z]:\\\\)[^"]*)"', text) if not o.startswith(("D:/Games/",) + DEVICE)]
if leftovers:
print("not scrubbed:", leftovers[:10], file=sys.stderr)
return 1
args.dest.mkdir(parents=True, exist_ok=True)
(args.dest / "library.json").write_text(text)
if (args.src / "artwork").is_dir():
shutil.copytree(args.src / "artwork", args.dest / "artwork", dirs_exist_ok=True)
print(f"{len(games)} games -> {args.dest}")
return 0
if __name__ == "__main__":
sys.exit(main())
+39
View File
@@ -25,6 +25,35 @@ PORT = 8557
ERRORS: list[str] = []
class FakeTarget:
"""--fake-frame: a pretend Steam Frame with the library's working Quest builds installed (no network, no device),
for documentation screenshots."""
def __init__(self, count: int = 8):
from frameport.frame.connection import parse_target
self.label = "steamframe"
self.target = parse_target("steamos@steamframe.local")
works = [g for g in library.games() if g.get("kind") != "rift" and (g.get("build") or {}).get("sha256")
and (g.get("recipe") or {}).get("status") in ("works", "issues")]
works.sort(key=lambda g: -(g.get("data_bytes") or 0))
self.games = [{"package": g["package"], "kind": "quest", "title": g.get("title"),
"apk_size": (g.get("data_bytes") or 0) + 2**28, "sha256": g["build"]["sha256"],
"recipe": {"patches": (g.get("recipe") or {}).get("patches", [])}} for g in works[:count]]
def describe(self) -> dict:
return {"hostname": "steamframe", "os": "SteamOS", "os_version": "3.8", "build_id": "20260922",
"free_bytes": 312 * 2**30, "installed": self.games, "lepton": True,
"proton": {"ready": {"display_name": "Proton 11 (ARM64)"}, "openxr": {"name": "SteamVR"}},
"kernel_keys": {"keys": 31, "max_keys": 200}}
def installed(self) -> list[dict]:
return self.games
def close(self) -> None:
pass
def driver(app: FramePortApp, steps: list[tuple[str, callable]], ready: threading.Event, done_step: list):
for name, action in steps:
try:
@@ -45,6 +74,8 @@ def main() -> int:
ap.add_argument("--scale", type=float, default=1.0, help="UI scale to render at (e.g. 1.5)")
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)")
ap.add_argument("--fake-frame", action="store_true", help="pretend a Steam Frame is connected (no device needed)")
ap.add_argument("--docs", action="store_true", help="only the screens used in the docs (Library, --game, Frame)")
args = ap.parse_args()
from frameport.ui import theme
@@ -61,6 +92,11 @@ def main() -> int:
# last: the right-click menu stays open over whatever comes next
steps.append(("library-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
a.library_view.open_menu(game))))
if args.docs:
steps = [("library", lambda a: a.navigate(0))]
if game:
steps += [("game", lambda a: a.open_game(game)), ("game-customize", lambda a: a.open_game(game, advanced=True))]
steps.append(("frame", lambda a: a.navigate(1)))
if args.update:
from frameport import updates
@@ -97,6 +133,9 @@ def main() -> int:
def app_main(page: ft.Page):
try:
app = FramePortApp(page)
if args.fake_frame:
app.target = FakeTarget()
app.frame_info, app.frame_state = app.target.describe(), "connected"
except Exception: # noqa: BLE001
ERRORS.append("startup: " + traceback.format_exc())
return
+12 -2
View File
@@ -14,12 +14,16 @@ import os
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
def main() -> int:
import logging
logging.basicConfig(level=logging.INFO)
archive = Path(sys.argv[1]).resolve()
home = Path(tempfile.mkdtemp(prefix="fp-update-smoke-"))
os.environ["FRAMEPORT_HOME"] = str(home / "data")
@@ -43,8 +47,14 @@ def main() -> int:
a, b = updates._signer_thumbprint(installed / "FramePort.exe"), updates._signer_thumbprint(new / "FramePort.exe")
print(f"signer thumbprints: installed {a} new {b}")
assert a and a == b, "signature check failed"
script = updates.apply(new, installed, relaunch=False, pid=999999, platform=platform, wait=True)
log = (home / "data/logs/update.log").read_text()
# the same launch as the app (detached; apply() checks the script started), then wait for it to finish
script = updates.apply(new, installed, relaunch=False, pid=999999, platform=platform)
log_file = home / "data/logs/update.log"
for _ in range(240):
log = log_file.read_text() if log_file.exists() else ""
if "not relaunching" in log:
break
time.sleep(0.5)
print(script.read_text()[:400], "...\n--- update.log ---\n" + log)
installed_marker = installed / marker.relative_to(new)
assert installed_marker.read_text() == "new", "the new files weren't installed"
+1 -1
View File
@@ -1,2 +1,2 @@
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
__version__ = "0.3.0"
__version__ = "0.3.3"
+11
View File
@@ -125,6 +125,17 @@ def screenshot_files(package: str) -> list:
return sorted(fetch.artwork_dir(package).glob("shot_*.jpg"), key=lambda p: int(re.sub(r"\D", "", p.stem) or 0))
def plain_description(text: str) -> str:
"""Store descriptions as plain text: OculusDB's carry Markdown and "[media]" placeholders for embedded videos."""
import re
text = re.sub(r"\[media\]", "", text or "")
text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M) # headings
text = re.sub(r"(\*\*|__)(.+?)\1", r"\2", text) # bold
text = re.sub(r"\[([^\]]+)\]\((https?://[^)]+)\)", r"\1", text) # links
return re.sub(r"\n{3,}", "\n\n", text).strip()
def store_links(details: dict) -> list[tuple[str, str]]:
out = []
if details.get("store_url"):
+7 -4
View File
@@ -121,8 +121,7 @@ def update_cmd(check: bool = typer.Option(False, "--check", help="Only say wheth
if kind == "wheel" and sys.platform == "win32":
# the running frameport.exe can't be replaced while it runs: finish the upgrade right after this exits
line = " && ".join(subprocess.list2cmdline(c) for c in cmds)
subprocess.Popen(f'cmd /c "timeout /t 2 /nobreak >nul && {line}"', creationflags=0x00000008 | 0x00000200,
close_fds=True)
updates.spawn_hidden(["cmd", "/c", f"ping -n 3 127.0.0.1 >nul && {line}"])
typer.echo(f"Updating to FramePort {up.version} in the background: run `frameport --version` in a few seconds.")
return
for cmd in cmds:
@@ -310,11 +309,15 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
frame: Optional[str] = typer.Option(None, help="steamos@host"), password: Optional[str] = None,
apk_only: bool = typer.Option(False, help="reuse game data already on the Frame"),
no_library: bool = typer.Option(False, help="don't add to the Steam library now"),
to: str = typer.Option("frame", help="frame, or pc (Oculus Rift games only: run on this PC via Revive)")):
to: str = typer.Option("frame", help="frame, or pc (Oculus Rift games only: run on this PC via Revive)"),
apk: Optional[Path] = typer.Option(None, help="install this APK instead of the last build (one game; e.g. "
"a test build signed with the game's key)")):
target = _target(frame, password, to)
pkgs = _pkgs(package, all_)
if apk and len(pkgs) != 1:
raise typer.BadParameter("--apk needs exactly one game")
for pkg in pkgs:
pipeline.install_game(pkg, target, printing_reporter(False), apk_only, add_to_library=False)
pipeline.install_game(pkg, target, printing_reporter(False), apk_only, add_to_library=False, apk=apk)
if not no_library:
target.add_to_library(pkgs, printing_reporter(False))
+67
View File
@@ -64,8 +64,73 @@ def _text_bytes(data: bytes) -> bytes:
return b"".join(s.data() for s in e.iter_sections() if s["sh_flags"] & 0x4 and s["sh_type"] == "SHT_PROGBITS")
# OVRPort 1.2.5+ (the CLI, not FramePort): VR metadata now also declares optional hand/eye tracking + these permissions
OVRPORT_METADATA = ("android.hardware.xr.input.hand_tracking", "android.hardware.xr.input.eye_tracking",
"android.permission.EYE_TRACKING_COARSE", "android.permission.EYE_TRACKING_FINE",
"android.permission.FACE_TRACKING", "android.permission.HAND_TRACKING",
"android.permission.SCENE_UNDERSTANDING_COARSE", "android.permission.SCENE_UNDERSTANDING_FINE")
COMPAT = "libovrplatformcompat.so"
def _manifest_lines(data: bytes) -> list[str]:
import re
from pyaxmlparser.axmlprinter import AXMLPrinter
xml = AXMLPrinter(data).get_xml().decode()
return [line.strip() for line in re.sub(r">\s*<", ">\n<", xml).splitlines()]
def _ovrport_manifest(new: bytes, old: bytes) -> str | None:
"""The reason, if the new manifest differs only by OVRPort 1.2.5's added metadata lines."""
a, b = _manifest_lines(new), _manifest_lines(old)
added = [line for line in a if line not in b]
if not added or any(line not in a for line in b):
return None
if all(any(f'"{n}"' in line for n in OVRPORT_METADATA) for line in added):
return f"overport CLI (OVRPort 1.2.5+) VR metadata: {len(added)} optional tracking feature/permission entries"
return None
def _overport_config(new: bytes, old: bytes) -> str | None:
"""liboverport.config.so whose JSON differs only in the CLI version that wrote it."""
import re
def cfg(data: bytes) -> dict | None:
m = re.search(rb'\{"version".*\}', data)
try:
return json.loads(m[0]) if m else None
except ValueError:
return None
a, b = cfg(new), cfg(old)
if not a or not b:
return None
by_a, by_b = a.get("patched", {}).pop("by", None), b.get("patched", {}).pop("by", None)
return f"written by overport CLI {by_a} (known-good: {by_b}); same settings" if a == b else None
def _compat_export(data: bytes) -> bool:
return elf.is_elf(data) and set(elf.dyn_symbols(data, True)) == {"ovrMessageType_ToString"}
def classify(name: str, new: bytes, old: bytes) -> tuple[str, str]:
base = name.rsplit("/", 1)[-1]
if base == COMPAT and _compat_export(new) and _compat_export(old):
current = (artifacts_dir() / name.split("/")[1] / base) if name.startswith("lib/") else None
if not (current and current.exists() and current.read_bytes() == new):
return "expected", "platform compat now added by the overport CLI (OVRPort 1.2.5+): same single export"
if base == "AndroidManifest.xml":
why = _ovrport_manifest(new, old)
if why:
return "expected", why
if base == "liboverport.config.so":
why = _overport_config(new, old)
if why:
return "expected", why
if base == "libovrplatformloader.so" and elf.is_elf(new) and elf.is_elf(old) and _text_bytes(new) == _text_bytes(old):
ignore = {"libovrstubs.so", COMPAT}
if set(elf.needed(new)) - ignore == set(elf.needed(old)) - ignore and COMPAT in elf.needed(new):
return "expected", "same loader, linked to the platform compat library by the overport CLI (OVRPort 1.2.5+)"
if base in OWNED:
current = (artifacts_dir() / name.split("/")[1] / base) if name.startswith("lib/") else None
is_current = current is not None and current.exists() and current.read_bytes() == new
@@ -124,6 +189,8 @@ def compare(new_apk: Path, old_apk: Path) -> dict:
current = artifacts_dir() / n.split("/")[1] / base if n.startswith("lib/") and base in OWNED else None
if current is not None and current.exists() and current.read_bytes() == _read(new_apk, n):
rows.append((n, "expected", f"{OWNED[base]} added (current build)"))
elif base == COMPAT and _compat_export(_read(new_apk, n)):
rows.append((n, "expected", "platform compat added by the overport CLI (OVRPort 1.2.5+)"))
else:
rows.append((n, "UNEXPLAINED", "only in the new build"))
elif n not in a:
+3 -2
View File
@@ -24,8 +24,9 @@ def _lib_bytes(ws) -> dict[str, bytes]:
class PlatformCompat(Patch):
id = "frame.ovrplatformcompat"
title = "Platform compat (ovrMessageType_ToString)"
description = ("Adds a real ovrMessageType_ToString (from the Android-XR-Bridge fork) for games whose platform "
"loader lacks it (e.g. The Climb 2).")
description = ("Adds a real ovrMessageType_ToString (OVRPort's platform compat library) for games whose platform "
"loader lacks it (e.g. The Climb 2). OVRPort 1.2.5+ adds the same library itself; then this is "
"skipped.")
order = 30
default_on = True
+24 -3
View File
@@ -1,7 +1,8 @@
"""overport CLI patches (https://github.com/ovrport/app). These run inside overport; we only choose which ones.
"""overport CLI patches. These run inside overport; we only choose which ones. The CLI comes from the downstream fork
github.com/Android-XR-Bridge/OVRPort (1.2.5+), originally github.com/ovrport/app.
The patch list is discovered dynamically (`overport patches`), and titles are fetched from the overport app's
strings.xml on GitHub (both cached). The table below is the offline fallback and adds what we learned on the
The patch list is discovered dynamically (`overport patches`), and titles are fetched from ovrport/app's
strings.xml on GitHub (both cached; the fork dropped that file, so its new patches are described here). The table below is the offline fallback and adds what we learned on the
Steam Frame; `default` mirrors overport's recommended set (Patch(..., true) in its sources).
"""
from __future__ import annotations
@@ -43,7 +44,22 @@ OVERPORT_PATCHES = [
"Removes overport's controller pose offset if controllers look misplaced."),
("patch_remove_vrapi", "Remove VrApi library", False,
"Not recommended: breaks games that load VrApi through OVRPlugin."),
("patch_vrapi_openxr", "VrApi → OpenXR adapter (OVRPort)", False,
"OVRPort's own VrApi→OpenXR adapter for engines that call libvrapi.so directly: the same upstream code as "
"FramePort's 'VrApi → OpenXR bridge' without its Frame-specific changes. Only in OVRPort's experimental CLI builds "
"(the stable CLI lists it but can't apply it)."),
("patch_disable_meta_xr_audio_telemetry", "Disable Meta XR Audio telemetry", False,
"Skips Meta XR Audio's telemetry under x86_64 ARM translation (emulators). Not needed on the Frame, which runs "
"games natively."),
("patch_ac_nexus_no_appsw_72", "AC Nexus: no AppSW at 72 Hz", False,
"Assassin's Creed Nexus (build 207706 only): turns off application space warp and runs at 72 Hz."),
("patch_ac_nexus_no_appsw_90", "AC Nexus: no AppSW at 90 Hz", False,
"Assassin's Creed Nexus (build 207706 only): turns off application space warp and runs at 90 Hz."),
]
CONFLICTS = {"patch_vrapi_openxr": ("patch_remove_vrapi", "frame.vrapi_bridge"),
"patch_ac_nexus_no_appsw_72": ("patch_ac_nexus_no_appsw_90",),
"patch_ac_nexus_no_appsw_90": ("patch_ac_nexus_no_appsw_72",)}
AC_NEXUS = "com.Ubisoft.ACNexusVR"
DEFAULT_OVERPORT = [pid for pid, _, default, _ in OVERPORT_PATCHES if default]
@@ -53,6 +69,7 @@ class OverportPatch(Patch):
def __init__(self, pid: str, title: str, default: bool, detail: str):
self.id, self.title, self.default_on, self.description = pid, title, default, detail
self.conflicts = CONFLICTS.get(pid, ())
def detect(self, analysis: Analysis) -> Suggestion | None:
from . import applicability as ap
@@ -81,6 +98,10 @@ class OverportPatch(Patch):
"patch_copy_ovrplugin_vrapi": ap.has_vrapi, "patch_remove_vrapi": ap.has_vrapi,
"patch_meta_xr_audio": lambda a: bool(ap.meta_audio_libs(a)),
"patch_disable_space_warp": lambda a: "libOVRPlugin.so" in a.libs,
"patch_vrapi_openxr": lambda a: a.direct_vrapi and "arm64-v8a" in a.abis,
"patch_disable_meta_xr_audio_telemetry": lambda a: False, # emulators only; the Frame is arm64
"patch_ac_nexus_no_appsw_72": lambda a: a.package == AC_NEXUS,
"patch_ac_nexus_no_appsw_90": lambda a: a.package == AC_NEXUS,
}
rule = rules.get(self.id)
return rule(analysis) if rule else True
+3 -2
View File
@@ -384,13 +384,14 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
def install_game(package: str, target: Target, reporter: Reporter, apk_only: bool = False,
add_to_library: bool = True) -> dict:
add_to_library: bool = True, apk: Path | None = None) -> dict:
"""Install the game's last build (or `apk`, e.g. a test build of the same package signed with the same key)."""
entry = library.game(package)
if is_rift(entry):
return install_rift(package, target, reporter, add_to_library)
b = entry.get("build") or {}
recipe = library.recipe_from_dict(entry["recipe"])
apk = Path(b["alt_apk"] if recipe.use_alt and b.get("alt_apk") else b["apk"])
apk = Path(apk) if apk else Path(b["alt_apk"] if recipe.use_alt and b.get("alt_apk") else b["apk"])
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
title = steam_title(entry)
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only)
+2
View File
@@ -59,6 +59,8 @@ def runtime_versions() -> str:
def patch(apk: Path, outdir: Path, name: str, patches: list[str], reporter: Reporter,
version: str = "latest") -> Path:
"""Run `overport patch`; returns the output APK path. version='latest' always uses the newest runtime."""
if not patches: # overport 1.2.5+ refuses an empty --patches= (and without patches it only re-signs)
raise ValueError("no overport patches selected")
outdir.mkdir(parents=True, exist_ok=True)
target = outdir / name
target.unlink(missing_ok=True)
+34 -13
View File
@@ -22,7 +22,11 @@ from ..core import cache
from ..core.paths import tools_dir
ADOPTIUM = "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture={arch}&image_type=jre&os={os}"
OVERPORT_RELEASE = "https://api.github.com/repos/ovrport/app/releases/latest"
# overport's CLI: maintained in the downstream fork Android-XR-Bridge/OVRPort since 1.2.5 (stable channel = plain
# vX.Y.Z tags with an `OVRPort-<ver>-stable-cli.jar`); the original ovrport/app (`cli-jar.zip`) is the fallback.
OVERPORT_RELEASES = [("overport-release-ovrport.json",
"https://api.github.com/repos/Android-XR-Bridge/OVRPort/releases/latest"),
("overport-release.json", "https://api.github.com/repos/ovrport/app/releases/latest")]
ANDROID_REPO = "https://dl.google.com/android/repository/"
@@ -115,12 +119,24 @@ def overport_jar() -> Path | None:
return None
def _overport_asset(rel: dict) -> dict | None:
"""The CLI asset of a release: a stable `*-cli.jar` (fork) or `cli-jar.zip` (ovrport/app)."""
assets = rel.get("assets", [])
return (next((a for a in assets if a["name"].endswith("-stable-cli.jar")), None)
or next((a for a in assets if a["name"].endswith("-cli.jar")), None)
or next((a for a in assets if a["name"] == "cli-jar.zip"), None))
def latest_overport() -> tuple[str, str] | None:
rel = cache.cached_json("overport-release.json", OVERPORT_RELEASE, max_age=6 * 3600)
if not rel:
return None
asset = next((a for a in rel.get("assets", []) if a["name"] == "cli-jar.zip"), None)
return (rel["tag_name"], asset["browser_download_url"]) if asset else None
"""(version, download url) of the newest overport CLI, from the first source that has one."""
for cache_name, url in OVERPORT_RELEASES:
rel = cache.cached_json(cache_name, url, max_age=6 * 3600)
if not rel or rel.get("draft") or rel.get("prerelease"):
continue
asset = _overport_asset(rel)
if asset:
return rel["tag_name"].removeprefix("v"), asset["browser_download_url"]
return None
def install_overport(progress=None) -> ToolStatus:
@@ -131,14 +147,19 @@ def install_overport(progress=None) -> ToolStatus:
dest = tools_dir() / f"overport-{version}"
jar = next(dest.glob("*.jar"), None) if dest.exists() else None
if not jar:
archive = cache.download(url, tools_dir() / f"overport-{version}.zip", progress)
with zipfile.ZipFile(archive) as z:
members = [m for m in z.namelist() if m.endswith(".jar")]
name = url.rsplit("/", 1)[1]
if name.endswith(".jar"):
dest.mkdir(parents=True, exist_ok=True)
for m in members:
(dest / Path(m).name).write_bytes(z.read(m))
archive.unlink(missing_ok=True)
jar = next(dest.glob("*.jar"))
jar = cache.download(url, dest / name, progress)
else:
archive = cache.download(url, tools_dir() / f"overport-{version}.zip", progress)
with zipfile.ZipFile(archive) as z:
members = [m for m in z.namelist() if m.endswith(".jar")]
dest.mkdir(parents=True, exist_ok=True)
for m in members:
(dest / Path(m).name).write_bytes(z.read(m))
archive.unlink(missing_ok=True)
jar = next(dest.glob("*.jar"))
state = _state()
state["overport"] = {"version": version, "path": str(jar)}
_save_state(state)
+6
View File
@@ -87,6 +87,12 @@ def h2(text: str) -> ft.Text:
return ft.Text(text, size=T.T_H2, weight=ft.FontWeight.W_600, color=T.TEXT)
def switch(label: str, **kw) -> ft.Switch:
"""A labelled switch in the theme's text colour (Material's default label is dark text)."""
kw.setdefault("active_color", T.ACCENT)
return ft.Switch(label=label, label_text_style=ft.TextStyle(color=T.TEXT, size=T.px(14)), **kw)
def body(text: str, color: str = T.TEXT_2, size: int | None = None, **kw) -> ft.Text:
size = size or T.T_BODY
return ft.Text(text, size=size, color=color, **kw)
+4 -4
View File
@@ -234,7 +234,7 @@ class GameView:
for k, v in facts], spacing=T.S6, wrap=True))
if d.get("genres"):
parts.append(ft.Row([C.pill(g, T.TEXT_2) for g in d["genres"][:8]], spacing=T.px(6), wrap=True))
text = d.get("description") or d.get("short") or ""
text = det.plain_description(d.get("description") or d.get("short") or "")
if text:
long = len(text) > 480
body = C.body(text if not long else text[:480].rsplit(" ", 1)[0] + "…", T.TEXT, selectable=True)
@@ -362,7 +362,7 @@ class GameView:
r.source = "user"
pipeline.set_recipe(self.package, r)
self.app.open_game(self.package, advanced=self.advanced)
switch = C.with_help(ft.Switch(value=as_is, active_color=T.PC, on_change=toggle_as_is,
switch = C.with_help(C.switch(value=as_is, active_color=T.PC, on_change=toggle_as_is,
label=("Already patched: don't change the game's files" if self.rift else
"Already patched: install as is (skip patching)")), "as_is")
return C.section(
@@ -470,11 +470,11 @@ class GameView:
save(state["recipe"])
top = []
if recipe.alt_patches:
top.append(C.with_help(ft.Switch(label="Install the alternate build (" + ", ".join(recipe.alt_patches)
top.append(C.with_help(C.switch(label="Install the alternate build (" + ", ".join(recipe.alt_patches)
+ ")", value=recipe.use_alt, on_change=set_alt, active_color=T.ACCENT),
"alt_build"))
if hidden:
top.append(C.with_help(ft.Switch(
top.append(C.with_help(C.switch(
label=f"Show all patches ({len(hidden)} don't apply to this game)", value=self.show_all,
active_color=T.ACCENT, on_change=lambda e: app.open_game(package, advanced=True,
show_all=e.control.value)), "show_all"))
+6 -9
View File
@@ -18,9 +18,6 @@ TOOL_TITLES = {"java": "Java runtime", "overport": "overport", "apksigner": "apk
TOOL_WHY = {"java": "Runs overport and apksigner", "overport": "Converts Quest games to OpenXR",
"apksigner": "Signs rebuilt games", "revive": "Runs Oculus Rift games on OpenXR"}
def switch_label() -> ft.TextStyle:
return ft.TextStyle(color=T.TEXT, size=T.px(14)) # Material's default is dark text
class SettingsView:
def __init__(self, app: "FramePortApp"):
@@ -76,9 +73,9 @@ class SettingsView:
def changed(e):
library.set_setting("install.launch_test", bool(e.control.value))
return ft.Switch(label="Launch test after installing on the Frame (starts the game once without the headset "
return C.switch("Launch test after installing on the Frame (starts the game once without the headset "
"and checks its log)", value=bool(library.setting("install.launch_test", True)),
label_text_style=switch_label(), on_change=changed)
on_change=changed)
def updates_card(self) -> ft.Control:
"""Settings → Updates: FramePort's own updates (ui/updater.py, frameport/updates.py)."""
@@ -109,11 +106,11 @@ class SettingsView:
C.secondary("Check for updates", ft.Icons.REFRESH_ROUNDED, lambda e: app.updater.check_now())],
vertical_alignment=ft.CrossAxisAlignment.CENTER),
status,
ft.Switch(label="Check for new versions automatically", value=bool(library.setting("update.auto_check", True)),
label_text_style=switch_label(), on_change=auto_check),
ft.Switch(label="Install updates automatically (downloads in the background, installs when FramePort "
C.switch("Check for new versions automatically", value=bool(library.setting("update.auto_check", True)),
on_change=auto_check),
C.switch("Install updates automatically (downloads in the background, installs when FramePort "
"next starts)", value=bool(library.setting("update.auto_install", False)),
label_text_style=switch_label(), on_change=auto_install),
on_change=auto_install),
], spacing=T.S3)
def appearance(self) -> ft.Control:
+52 -9
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import shutil
@@ -32,6 +33,8 @@ from .core import cache, library
from .core.events import Reporter
from .core.paths import user_data_dir
_log = logging.getLogger("frameport.updates")
REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
CHECK_EVERY = 6 * 3600 # seconds between automatic checks
@@ -266,15 +269,43 @@ def _extract(archive: Path, dest: Path, platform: str) -> Path:
return folder
def _powershell() -> tuple[str, dict]:
"""Windows PowerShell 5.1 (always installed) and an environment it can load its own modules in: started from
PowerShell 7 (pwsh), PSModulePath points at pwsh's modules and 5.1 fails to load Microsoft.PowerShell.Security /
.Management (Get-AuthenticodeSignature, Copy-Item). Without PSModulePath it rebuilds its default."""
exe = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe"
env = {k: v for k, v in os.environ.items() if k.upper() != "PSMODULEPATH"}
return (str(exe) if exe.exists() else "powershell"), env
START_TIMEOUT = 15 # seconds for the swap script to log that it runs
def spawn_hidden(cmd: list[str], env: dict | None = None) -> subprocess.Popen:
"""Windows: start a console program in its own hidden console, independent of FramePort. DETACHED_PROCESS
doesn't work for PowerShell: started that way from the packaged app it exits 0 without running anything."""
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = 0 # SW_HIDE
return subprocess.Popen(cmd, creationflags=0x00000010 | 0x00000200, # CREATE_NEW_CONSOLE, NEW_PROCESS_GROUP
startupinfo=si, close_fds=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, env=env)
def _signer_thumbprint(exe: Path) -> str | None:
"""Windows: the Authenticode signer certificate's thumbprint ('' if unsigned, None if it can't be read)."""
ps, env = _powershell()
try:
out = subprocess.run(["powershell", "-NoProfile", "-Command",
f"(Get-AuthenticodeSignature -LiteralPath '{exe}').SignerCertificate.Thumbprint"],
capture_output=True, text=True, timeout=60)
return out.stdout.strip() if out.returncode == 0 else None
except (OSError, subprocess.SubprocessError):
out = subprocess.run([ps, "-NoProfile", "-NonInteractive", "-Command",
f"(Get-AuthenticodeSignature -LiteralPath {_ps_quote(exe)}).SignerCertificate.Thumbprint"],
capture_output=True, text=True, timeout=60, env=env)
except (OSError, subprocess.SubprocessError) as exc:
_log.warning("reading the signature of %s failed: %s", exe, exc)
return None
if out.returncode:
_log.warning("reading the signature of %s failed (%s): %s", exe, out.returncode, out.stderr.strip()[-500:])
return None
return out.stdout.strip()
def prepare(up: Update, reporter: Reporter | None = None, platform: str | None = None,
@@ -432,14 +463,26 @@ def apply(app: Path, target: Path | None = None, relaunch: bool = True, pid: int
log.parent.mkdir(parents=True, exist_ok=True)
script = updates_dir() / ("apply.ps1" if platform == "win32" else "apply.sh")
script.write_text(swap_script(app, target, pid, platform, relaunch, log), encoding="utf-8")
started = log.stat().st_size if log.exists() else 0
if platform == "win32":
cmd = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", str(script)]
flags = 0 if wait else (0x00000008 | 0x00000200 | 0x08000000) # DETACHED, NEW_PROCESS_GROUP, NO_WINDOW
proc = subprocess.Popen(cmd, creationflags=flags, close_fds=True, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
ps, env = _powershell()
proc = spawn_hidden([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
"-File", str(script)], env=env)
else:
proc = subprocess.Popen(["/bin/sh", str(script)], start_new_session=not wait, close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if not wait: # make sure the script really runs before FramePort quits (it logs first thing)
deadline = time.time() + START_TIMEOUT
while time.time() < deadline:
if log.exists() and f"(pid {pid})" in log.read_text(errors="replace")[started:]:
break
if proc.poll() not in (None, 0):
break
time.sleep(0.2)
else:
raise UpdateError(f"the update script didn't start (see {log})")
if proc.poll() not in (None, 0):
raise UpdateError(f"the update script failed to start (exit {proc.returncode})")
if wait:
proc.wait()
return script
+7
View File
@@ -213,3 +213,10 @@ def test_steam_tags():
assert steam_tags(rift) == ["PC VR on Frame", "Oculus Rift", "Action", "Shooter", "Favorite"]
assert steam_tags(rift, "pc")[:2] == ["Rift via Revive", "Oculus Rift"]
assert steam_tags({"package": "com.x"})[:2] == ["Quest on Frame", "Meta Quest"]
def test_plain_description_drops_store_markup():
from frameport.artwork.details import plain_description
text = "[media]\n\n# Become The Knight.\n\nIt's **bold** and [a link](https://x.invalid).\n\n\n\n[media]\n\n**Hard**"
assert plain_description(text) == "Become The Knight.\n\nIt's bold and a link.\n\nHard"
+15
View File
@@ -30,3 +30,18 @@ def test_report_counts():
works = sum(1 for e in catalog.load().values() if e.status == "works")
assert f"{works} work" in text and "Path of the Warrior" in text
def test_ovrport_125_config_and_compat_are_expected():
from frameport import parity
from frameport.analysis.stubgen import build_stub_library
def cfg(by):
return b"\0\0" + ('{"version":1,"patched":{"by":"%s","with":"3.4.3-23204ea","patches":["patch_copy_libraries"]}}'
% by).encode() + b"\0"
kind, why = parity.classify("lib/arm64-v8a/liboverport.config.so", cfg("1.2.5"), cfg("1.2.3"))
assert kind == "expected" and "1.2.5" in why
assert parity.classify("lib/arm64-v8a/liboverport.config.so", cfg("1.2.5").replace(b"3.4.3", b"3.4.4"),
cfg("1.2.3"))[0] == "UNEXPLAINED"
compat = build_stub_library(["ovrMessageType_ToString"], soname="libovrplatformcompat.so")
assert parity._compat_export(compat)
+32
View File
@@ -234,3 +234,35 @@ def test_source_hints_are_generic_and_match_loosely():
assert source_hint_matches("Marvels Deadpool VR", "Marvel's Deadpool VR v9000+1.1 -XYZ") # other release, other name
assert source_hint_matches("The Climb 2 v974+2.2", "the climb 2 (quest) v1000")
assert not source_hint_matches("The Climb 2", "The Climb v100")
def test_ovrport_125_patches():
"""OVRPort 1.2.5's new patches: off by default, shown only where they can matter, mutual exclusions warned."""
from frameport.recommend import engine
quest = _analysis()
nexus = _analysis(package="com.Ubisoft.ACNexusVR")
vrapi = _analysis(direct_vrapi=True, libs=["libvrapi.so"])
for pid in ("patch_vrapi_openxr", "patch_disable_meta_xr_audio_telemetry", "patch_ac_nexus_no_appsw_72",
"patch_ac_nexus_no_appsw_90"):
assert not base.get(pid).default_on
assert not base.get(pid).applies(quest)
assert base.get("patch_ac_nexus_no_appsw_90").applies(nexus) and base.get("patch_vrapi_openxr").applies(vrapi)
assert not base.get("patch_disable_meta_xr_audio_telemetry").applies(nexus) # emulators only
recipe = Recipe(package="com.Ubisoft.ACNexusVR", patches=["patch_copy_libraries", "frame.adapter",
"patch_ac_nexus_no_appsw_72", "patch_ac_nexus_no_appsw_90"])
assert any("conflicts" in w for w in engine.warnings(recipe))
def test_overport_release_sources(monkeypatch):
from frameport.core import cache
from frameport.tools import toolchain
fork = {"tag_name": "v1.2.5", "assets": [{"name": "OVRPort-1.2.5-stable-cli.jar",
"browser_download_url": "https://example.invalid/OVRPort.jar"}]}
old = {"tag_name": "1.2.3", "assets": [{"name": "cli-jar.zip", "browser_download_url": "https://example.invalid/z"}]}
replies = {"overport-release-ovrport.json": fork, "overport-release.json": old}
monkeypatch.setattr(cache, "cached_json", lambda name, *a, **k: replies[name])
assert toolchain.latest_overport() == ("1.2.5", "https://example.invalid/OVRPort.jar")
replies["overport-release-ovrport.json"] = None # fork unreachable: the original project
assert toolchain.latest_overport() == ("1.2.3", "https://example.invalid/z")
+30
View File
@@ -213,3 +213,33 @@ def test_cli_hint_once_a_day_without_waiting(monkeypatch):
monkeypatch.setenv("FRAMEPORT_NO_UPDATE_CHECK", "1")
library.set_setting("update.cli_hint", None)
assert "is available" not in runner.invoke(app, ["list"]).output
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX swap script")
def test_apply_detached_checks_the_script_started(monkeypatch, tmp_path):
import time
up = updates.update_from_release(release(), "FramePort-linux-x64.tar.gz")
_fake_downloads(monkeypatch, {"FramePort-linux-x64.tar.gz": _linux_archive("9.9.9")})
app = updates.prepare(up, platform="linux")
installed = tmp_path / "apps/FramePort"
installed.mkdir(parents=True)
(installed / "FramePort").write_text("old")
updates.apply(app, installed, relaunch=False, pid=2 ** 22 + 2, platform="linux") # returns once the script runs
log = updates.user_data_dir() / "logs/update.log"
for _ in range(100):
if "not relaunching" in log.read_text():
break
time.sleep(0.1)
assert (installed / "data/version.txt").read_text() == "9.9.9"
def test_apply_reports_a_script_that_never_starts(monkeypatch, tmp_path):
installed = tmp_path / "FramePort"
installed.mkdir()
staged = tmp_path / "staged"
staged.mkdir()
monkeypatch.setattr(updates, "START_TIMEOUT", 0.5)
monkeypatch.setattr(updates.subprocess, "Popen", lambda *a, **k: type("P", (), {"poll": lambda self: None})())
with pytest.raises(updates.UpdateError):
updates.apply(staged, installed, relaunch=False, pid=1, platform="linux")