mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 19:00:43 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc40325c2d | ||
|
|
82c2209b6f | ||
|
|
e645ca86d1 | ||
|
|
75c7b2bf20 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||

|
||||
|
||||
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**).
|
||||
|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||
|
||||
- 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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,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"
|
||||
@@ -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"):
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user