mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 18:03:05 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94bc4c479e | ||
|
|
0cb195ec2c | ||
|
|
69889f0ad8 | ||
|
|
182b3ad5fb | ||
|
|
6af0c40ec8 |
No files matched your search
@@ -6,7 +6,7 @@ description: Port a Meta Quest standalone game (APK + OBB dump) to the Valve Ste
|
||||
# Port a Quest game to the Steam Frame
|
||||
|
||||
Work from the repo root (`frameport/`). Read `CLAUDE.md` (facts) and `docs/PLAYBOOK.md` (symptom → fix) first.
|
||||
Set `UV_PROJECT_ENVIRONMENT=~/.cache/frameport-venv` (the repo is on NTFS).
|
||||
The dev environment is `.venv` in the repo root (`UV_LINK_MODE=copy uv sync --extra dev` if it's missing).
|
||||
|
||||
## 1. Setup (once)
|
||||
```
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
* text=auto eol=lf
|
||||
*.so binary
|
||||
*.dex binary
|
||||
*.exe binary
|
||||
*.apk binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
|
||||
@@ -8,7 +8,78 @@ install over SSH (agent) → Steam shortcut → headless launch test + log triag
|
||||
Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRAME_RUNTIME.md` for runtime facts.
|
||||
|
||||
## Layout
|
||||
- `src/frameport/` — Python package. `pipeline.py` is the API the CLI (`cli.py`) and GUI (`ui/app.py`, Flet 1.0) share.
|
||||
- `src/frameport/` — Python package. `pipeline.py` is the API the CLI (`cli.py`) and GUI (`ui/`, Flet 1.0) share.
|
||||
- `ui/` — `app.py` shell (sidebar with Frame connection + activity cards, routing, actions, 30 s connection poll),
|
||||
`theme.py` (dark design tokens; change colours/spacing only there), `components.py` (pill, card, callout,
|
||||
status_row, art_fill, confirm, `update()` = safe update: in Flet 1.0 reading `.page` of an unmounted control
|
||||
raises), `jobs.py` (background FIFO job queue, one at a time, cancel via Reporter; no Flet), `views/`
|
||||
(library: search/filters/tags/sort as pure tested helpers; game: hero + one-click install, patches under
|
||||
"Customize"; frame: device + readiness + installed, or connect wizard; settings; welcome; activity panel).
|
||||
User tags live in library entries (`tags`), filters in library setting `ui.library`.
|
||||
**Performance rules** (the app froze before): never put image bytes in controls — artwork is served by URL from the
|
||||
GUI assets dir (= user data dir; `ft.run(assets_dir=…)`), as thumbnails (`artwork/thumbs.py`, Pillow); the
|
||||
library view is persistent, streams cards in batches from a background thread and filters by toggling visibility;
|
||||
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).
|
||||
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
|
||||
from `app.game_actions()`. Picking art (`sources.apply_choice`) downloads into a staging dir and keeps the old
|
||||
art if nothing came back; "Update Steam art on Frame" re-sends the art set to the game's anchor (agent ≥ 12).
|
||||
Patch descriptions/reasons describe the general case, naming games only as "e.g. …".
|
||||
- Installs: queued/cancelled/failed ones are remembered (library setting `ui.installs`) → Library "Resume" bar;
|
||||
uploads are interruptible (Cancel checked per MiB) and resumable (big files via SFTP `.part` append, small files
|
||||
streamed in tar batches; the agent counts files already in `incoming/`). Multi-select in the Library queues
|
||||
installs after asking every needed question up front. Failures end in one pop-up (Resume / Uninstall / log).
|
||||
- **PC VR repacks are pre-patched to run directly** (proven: Rick and Morty, Vader Immortal run when the exe is
|
||||
launched directly; Revive breaks them). So Rift games default to `as_is` = install the copy unchanged and launch
|
||||
the exe directly (`pcvr.xr_timefix` for the Frame OpenXR-1.1→1.0 fix, `pcvr.no_crash_reporter` for Unreal).
|
||||
**Revive is off by default, opt-in** (`pcvr.revive`, and `pcvr.oculus_unreal`): only for an un-cracked Oculus game
|
||||
that fails at "Initializing OVR session". Those (Lone Echo, Robo Recall, Lies Beneath: crack .7z not extracted /
|
||||
Platform SDK) hit Revive's Oculus-runtime **signature check** under Proton-arm64 — Revive's LoadLibrary/WinVerifyTrust
|
||||
hooks don't install (ARM64EC), and the game's Oculus SDK shim rejects the unsigned Revive runtime (wintrust +
|
||||
crypt32 signer "Oculus VR") — so they don't run on the Frame without extracting the repack's crack (which FramePort
|
||||
doesn't do). `VD.bat` is Virtual Desktop's launcher: ignore it except as an exe-location hint. Library migration
|
||||
`rift_run_direct` resets existing recipes.
|
||||
Auto launch-test is skipped on PC installs (it would start the game on the user's desktop). Launch tests collect the Unreal
|
||||
game log + crash summaries from the Proton prefix; triage `unreal-crash`. Lies Beneath via Proton without Revive
|
||||
crashed (UE 4.23 "Unhandled exception").
|
||||
- Rift scanning: `sources/rift_dump.scan` = the scanned folder's subfolders are games (one per folder; a folder is a
|
||||
game if all candidate exes sit under one child), recursing into collections; `analysis/rift.py` walks once,
|
||||
filters helpers + non-GUI PEs, `rank_exes` (Unreal *-Shipping beats its launcher, Steam builds −10, ambiguous →
|
||||
`exe_confirmed=False` → GUI exe dialog), `clean_title`, fingerprint (unchanged folders aren't re-analyzed),
|
||||
modular-Unreal Oculus plugin DLLs + UTF-16 markers count as LibOVR, `revive_bundled` → as-is.
|
||||
- Rift VR-API routing (`analysis/rift.py`): `openvr`/`openxr`/`libovr` detected from imports + bundled DLLs (NOT the
|
||||
universal-in-Unreal `IVRSystem`/`VR_InitInternal` strings). `frame_native = (openxr or openvr) and not libovr` =
|
||||
confidently runs on the Frame via wineopenxr (SteamVR), no Revive. Any LibOVR game → `needs_revive` → **PC only**
|
||||
on the Frame (Revive's ARM64EC hooks don't work under Proton-arm64, and FramePort does **not** defeat the Oculus
|
||||
runtime Authenticode signature check). Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
not via static detection. PC installs default to Revive's **OpenVR** backend (`pcvr.revive_openvr` on) and
|
||||
auto-start SteamVR on Play (`winhost.start_steamvr`); the Frame launcher always uses `/openxr`. UI (`game.py`
|
||||
where()) states per-game where it runs; installing an Oculus game on the Frame shows a warning. Migration
|
||||
`rift_frame_native` re-analyzes + re-derives existing entries.
|
||||
- Art for Rift games: `artwork/sources.py` — Quest version package (OculusDB packageName, exact name or +
|
||||
"Unplugged"-type suffix, never sequels) → Meta art; OculusDB square cover; Steam (exact names only); exe icon.
|
||||
- Store details (`artwork/details.py`, entry `details`): OculusDB (description, genres, publisher, website; by Quest
|
||||
package or Rift match) + Steam appdetails (exact title: developer, release date, up to 6 screenshots → artwork
|
||||
`shot_N.jpg`). Meta store pages reject scraping, so Oculus exclusives have no screenshots. Genres become automatic
|
||||
tags. Steam shortcuts get a complete composed art set (`artwork/steam.py`: 600×900 / 920×430 / 1920×620 / logo /
|
||||
256 icon, blurred-backdrop compositing for square-only covers) and tags: how it runs, the original platform
|
||||
(Meta Quest / Oculus Rift), genres, user tags — merged with tags set in Steam (non-Steam shortcuts can't hold a
|
||||
description).
|
||||
- Quest/Rift twins stay separate entries, shown and named in Steam "Title (Quest)"/"(Rift)" (`core/titles.py`).
|
||||
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
|
||||
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
|
||||
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
|
||||
**not** turn Revive off: a repack's patches (cracks, `VD.bat` = Virtual Desktop's Oculus
|
||||
runtime) give a LibOVR game no Oculus runtime on the Frame; Revive is that runtime (Lone Echo without it: EXITED
|
||||
in seconds). SteamVR builds (Rick and Morty) turn Revive off via their recipe (`pcvr_remove`, catalog `as_is`).
|
||||
- Play: Library hover button / right-click / game page / Frame rows → agent `launch` = `steam://rungameid/<appid<<32
|
||||
| 0x02000000>` through the Frame's Steam (in-headset session); PC: Windows Steam. Launch tests use a
|
||||
flask icon (SCIENCE_OUTLINED) so they aren't confused with Play.
|
||||
- `uninstall.py` + `frameport uninstall-app` + Settings: removes the data dir (after an optional key backup zip),
|
||||
PC Steam shortcuts, the WSL Revive copy, and via agent `purge` FramePort's games/files on the Frame.
|
||||
- `patches/` — **the unit of modularity**. `base.py` (Patch interface, registry), `overport.py` (overport CLI patch ids,
|
||||
discovered dynamically via `overport patches`), `frame/*.py` (one module per Frame fix), `settings.py` (FrameBridge
|
||||
adapter keys + device files as patches). Add a patch = add a module that calls `register(...)`.
|
||||
@@ -18,25 +89,46 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
- `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.
|
||||
- `frame/` — SSH (paramiko), mDNS discovery, pairing server; `install/installer.py`; `validate/` (static, device, triage).
|
||||
- `targets/` — `Target` interface; `frame_lepton.py` now, `revive.py` placeholder (PC VR later).
|
||||
- `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).
|
||||
- Oculus Rift (PC VR): `sources/rift_dump.py`, `analysis/rift.py` (own PE reader), `patches/pcvr.py` (category
|
||||
`pcvr`, shown as patches; `base.for_game` separates Quest/PC VR patches), `tools/revive.py` (Revive: FRAMEPORT_REVIVE_DIR >
|
||||
the user's installed Revive (C:\Program Files\Revive, else registry HKLM/HKCU\Software\Revive; version = GitHub
|
||||
release matching the DLL build date, since Revive's version resources are stale) > portable copy unpacked from
|
||||
ReviveInstaller.exe in pure Python; never replaces the user's install). Library ids `rift.<slug>`, entries have `kind: rift`.
|
||||
- `parity.py` — rebuild catalog games from dumps and classify every APK entry difference vs known-good builds.
|
||||
- `agent/frameport_agent.py` — runs **on the Frame** (python3 stdlib only), JSON over SSH. Owns the install layout,
|
||||
launch.sh template, Steam shortcuts (binary VDF), launch tests. Bump `AGENT_VERSION` when changing it.
|
||||
- `bootstrap/bootstrap.sh` — one-time Frame setup served by the pairing server (sshd, app key, avahi service, Lepton).
|
||||
- `catalog/games/<package>.yaml` — 34 recipes verified 2026-09-28; `catalog/triage.yaml` — log signatures → fixes.
|
||||
- `native/` — sources of the prebuilt binaries in `artifacts/` (adapter, VrApi bridge patches, GL shim, stubs).
|
||||
`native/build.sh` rebuilds them with NDK r27c (downloaded on demand; never committed). Users never need the NDK.
|
||||
`native/build.py` rebuilds them with NDK r27c (downloaded on demand into `native/.cache`, git-ignored; uses
|
||||
`-ffile-prefix-map` so no local paths get embedded; zip symlinks are restored as copies). Users never need the NDK.
|
||||
- `scripts/` — `package.py` (flet build/pack), `eval_heuristics.py` (score heuristics), `ui_smoke.py` (GUI screenshots).
|
||||
- `docs/` — PLAYBOOK, FRAME_RUNTIME, ARCHITECTURE, INSTALL (end users), parity reports (offline + device).
|
||||
|
||||
## Dev commands
|
||||
```
|
||||
export UV_PROJECT_ENVIRONMENT=~/.cache/frameport-venv # keep the venv off NTFS (repo lives on D:)
|
||||
uv sync --extra dev # or: uv pip install -e .[dev]
|
||||
# the dev venv is .venv in the repo root (git-ignored); on the NTFS drive uv needs UV_LINK_MODE=copy
|
||||
UV_LINK_MODE=copy uv sync --extra dev # creates/updates ./.venv
|
||||
uv run pytest # unit tests (no device, no game files)
|
||||
uv run frameport --help # CLI; uv run frameport-gui for the GUI
|
||||
uv run frameport parity --known-good <PATCHED/_known-good-*> --sources "<VR CyberDeck downloads>"
|
||||
```
|
||||
Games/device tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>), `pytest -m device` (FRAMEPORT_FRAME=steamos@host).
|
||||
Games/device tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>), `pytest -m device` (FRAMEPORT_FRAME=steamos@host);
|
||||
native layer test: `FRAMEPORT_NATIVE_TESTS=1 pytest -m native` (compiles with the NDK, ~2 min on NTFS).
|
||||
Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattributes`).
|
||||
- `FRAMEPORT_HOME=<dir>` isolates all app data (tests use it); `FRAMEPORT_JAVA/_OVERPORT_JAR/_APKSIGNER_JAR` override
|
||||
managed tools. Real app data (WSL): `~/.local/share/frameport` (tools, overport workspace + game keystores, library,
|
||||
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`.
|
||||
- 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>]` and look
|
||||
at the PNGs. Flet 1.0 notes: `ft.run` must own the main thread; background work via `page.run_thread`; FilePicker is
|
||||
awaited (`await ft.FilePicker().get_directory_path()`); dialogs via `page.show_dialog/pop_dialog`; running from
|
||||
source needs `flet-desktop` (declared) and web mode needs `flet-web`.
|
||||
- Stopping the GUI: `pkill -f` patterns match your own shell — use `pgrep -f "[b]in/frameport-gui|[f]let-desktop-light"`.
|
||||
|
||||
## Hard-won facts (don't re-learn these)
|
||||
**Frame runtime (SteamOS 0.3.0, build 20260922):**
|
||||
@@ -63,6 +155,56 @@ since boot every game fails with `crun: create keyring …: Disk quota exceeded`
|
||||
`keyring = false` in `~/.config/containers/containers.conf` (agent `ensure_host_fixes`, bootstrap); leaked keys only
|
||||
go away with a reboot. Check usage: `grep "^ *1000:" /proc/key-users` (agent `info` → kernel_keys).
|
||||
|
||||
**Proton on the Frame (2026-09-29):** Steam registers ARM64 tools from app "Steam Frame ARM64 Compat List"
|
||||
(`proton_11-arm64` 4628740 needs SLR4-arm64 4185400; `proton-experimental-arm64` 4427310; `fex` 3127680) — not installed
|
||||
by default; `steam://install/<id>` only opens a dialog in the headset (agent `install_proton` mode `unattended` =
|
||||
appmanifest stubs + Steam restart; untested on device). Proton only sets up VR/wineopenxr when `SteamGameId` is set.
|
||||
Host OpenXR = SteamVR (`~/.config/openxr/1/active_runtime.json`). Steam VR games on the Frame (Pistol Whip etc.) are
|
||||
APKs via Lepton. Verified on the device: the app installs Proton itself (`installer.ensure_proton`: appmanifest stubs +
|
||||
Steam restart, ~90 s); Proton runs x86 code; wineopenxr gets registered; headless launches need DISPLAY/
|
||||
GAMESCOPE_WAYLAND_DISPLAY from Steam's environment (launch.sh imports them). The **Linux** SteamVR runtime supports
|
||||
XR_KHR_convert_timespec_time (only the Android runtime lacks it). **But it only accepts OpenXR 1.0 apps**: apiVersion
|
||||
1.1 → XR_ERROR_API_VERSION_UNSUPPORTED, and Proton 11's vrhelper asks for 1.1 → no VR at all (flat window; Revive:
|
||||
"Unable to load LibOVRRT DLL"). FramePort's layer (`native/xrlayer`, `artifacts/linux-arm64`, patch `pcvr.xr_timefix`,
|
||||
default on + one-time library migration in `core/library._migrate`) retries as 1.0. Proton's container (pressure-vessel)
|
||||
**drops `XR_API_LAYER_PATH`**, so the agent registers the layer as an explicit layer in
|
||||
`~/.local/share/openxr/1/api_layers/explicit.d/` (library: shared copy in `~/.local/share/frameport/xrlayer/`); it
|
||||
only loads when `XR_ENABLE_API_LAYERS` names it. Unreal PC VR games get
|
||||
`pcvr.no_crash_reporter` by default (`-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy)
|
||||
and `pcvr.oculus_unreal` (PC VR counterpart of overport's `patch_oculus_unreal`: UE's OculusHMD needs the Windows event
|
||||
`OculusHMDConnected`; launch.sh wraps the injector in `helpers/fp_oculushmd.exe` = `native/oculushmd`,
|
||||
`artifacts/win-x64`, which provides it until the game's job is empty; untested on the device yet).
|
||||
Rift games tried on the Frame: Rick and Morty (SteamVR build, no Revive: "OpenVR initialized!" with the layer; flat
|
||||
before — confirm in the headset), Lies Beneath (Oculus Store UE 4.23 build: delay-loads `LibOVRPlatform64_1.dll` =
|
||||
Platform SDK → crash 0xc06d007e; needs the Oculus app, FramePort doesn't replace it; also OVRPlugin's LibOVR shim
|
||||
reports "Unable to load LibOVRRT DLL" before any OpenXR call — likely needs a real Oculus runtime install, unverified).
|
||||
Launch tests ignore log files older than the launch (stale Proton/game logs used to be triaged). Revive injector CLI: `ReviveInjector.exe [/openxr] <exe path>`
|
||||
(joins args; logs to `%LOCALAPPDATA%\Revive\ReviveInjector.txt`). On WSL, run Windows programs from Windows paths
|
||||
(`\\wsl$` is unreliable), so PC mode copies Revive to `%LOCALAPPDATA%\FramePort`.
|
||||
FramePort never bypasses Oculus entitlement checks (Platform SDK games: flagged, PC mode with the Oculus app only).
|
||||
|
||||
**Upload speed (measured 2026-09-29):** via the home router (PC Wi-Fi → router → Frame wlan0) only 15-18 MB/s, the
|
||||
same for paramiko, OpenSSH and 4 parallel streams, so the network path is the limit, not the SSH code. Direct to the
|
||||
Frame's hotspot (`wlanap` 10.35.78.1; the owner's PC has Valve's USB Wi-Fi dongle on it) 83 MB/s OpenSSH / 97 MB/s
|
||||
paramiko; reading dumps from D: via WSL drvfs is then the cap (~82 MB/s). `Frame.fast_link()` (installer
|
||||
`transfer_link`, uploads ≥64 MiB) uses usb0 > wlanap when reachable **and** the host key matches the paired Frame.
|
||||
**Platform SDK detection** must include delay imports (`PEInfo.delay_imports`), modular Unreal's
|
||||
`*-OnlineSubsystemOculus-*.dll` and Ready At Dawn's `pnsovr.dll`: Robo Recall, Lies Beneath and Lone Echo I/II all use
|
||||
it; without the Oculus app they crash with 0xc06d007e (triage `delayload-missing`) — not fixable legitimately.
|
||||
GUI: Frame → Installed → folder icon = file browser (agent `list_files`, flags files missing vs the install
|
||||
manifest); Settings → About shows the bundled agent version and the Frame's.
|
||||
|
||||
**Revive under Proton arm64 (Lone Echo, 2026-09-29):** Revive's Detours hooks (LoadLibraryW/ExW, OpenEventW, the
|
||||
signature check) don't take effect: Wine's kernelbase is ARM64EC. The LibOVR shim in the game then does a plain
|
||||
`LoadLibrary("LibOVRRT64_1.dll")` search (exe dir, cwd, system32, windows, PATH — no registry, `OculusBase` or
|
||||
`LIBOVR_DLL_DIR` used by this build) → `Failed to initialize Oculus API (-3001)` (= Lies Beneath's "Unable to load
|
||||
LibOVRRT DLL"). A symlink `pfx/drive_c/windows/system32/LibOVRRT64_1.dll -> <base>/revive/LibReviveXR64.dll` (Wine
|
||||
maps a symlink to the already-loaded module) gets past it to **-3021 = ovrError_LibSignCheck**: the shim only accepts
|
||||
an Oculus-signed runtime. Next step (not done): make the runtime check pass without Detours, e.g. an injected helper
|
||||
that patches the game's import table (IAT) for LoadLibrary*/WinVerifyTrust, or a prefix-level override. This is
|
||||
runtime interop (what Revive does), not the Platform SDK licence check (which FramePort never touches).
|
||||
Uninstall (Quest, saves kept) used to leave `deployment.json` → still "installed"; fixed (agent v16).
|
||||
|
||||
**Discovery/network:** Developer-Mode SteamOS devices announce `_steamos-devkit._tcp` (TXT `login=steamos`) — use it;
|
||||
they don't publish `_ssh._tcp`. The Frame has several links: `wlan0` (home Wi-Fi), `wlanap` = its own hotspot at
|
||||
10.35.78.1/24 (a PC can join it directly), `usb0` = USB gadget network 10.86.200.233/29 (up when cabled to a PC).
|
||||
@@ -92,11 +234,56 @@ Output is deterministic (same input + runtime → same bytes), which is what mak
|
||||
layers, depth, pacing ruled out. Sniper Elite VR (DEVICE LOST), Espire 1 (Mesa GL upload crash), HITMAN 3 (freedreno
|
||||
crash): use PC versions.
|
||||
|
||||
## Releases
|
||||
Push a `v*` tag → CI builds Windows/macOS(arm64)/Linux bundles, signs Windows binaries with the self-signed
|
||||
certificate (secrets `WINDOWS_CODESIGN_PFX` base64 + `WINDOWS_CODESIGN_PASSWORD`; public cert `packaging/`,
|
||||
private copy only on the maintainer's machine + backup), ad-hoc signs the macOS app, adds build attestations and
|
||||
publishes a GitHub Release with SHA256SUMS. macOS x86_64 isn't built (cryptography cross-build fails).
|
||||
## Releases, CI, GitHub
|
||||
Public repo `github.com/spoopyghosty0/frameport` (branch `main`). Push a `v*` tag → CI (`.github/workflows/build.yml`)
|
||||
tests, builds Windows x64 / macOS arm64 / Linux x64 bundles, signs, attests and publishes a GitHub Release
|
||||
(`FramePort-*.zip/.tar.gz`, `SHA256SUMS.txt`, `FramePort-selfsigned.cer`, notes from `docs/INSTALL.md`). v0.1.0 exists.
|
||||
- Signing is **free/self-signed by the owner's choice** (no paid certs, no SignPath): Windows binaries are signed with
|
||||
a self-signed "FramePort (self-signed)" code-signing cert (RSA 3072, valid to 2031, SHA-256
|
||||
`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`); secrets
|
||||
`WINDOWS_CODESIGN_PFX` (base64) + `WINDOWS_CODESIGN_PASSWORD`. Private key: `~/.config/frameport-signing/` (WSL) and
|
||||
the backup `PATCHED/_signing-keys/frameport-app-codesign/` — never commit it. macOS is ad-hoc signed only (Gatekeeper
|
||||
needs right-click → Open; notarization would need the paid Apple program). Users still see SmartScreen unless they
|
||||
import the .cer into Trusted Root.
|
||||
- CI gotchas: `flet build` needs `--yes --no-rich-output` (it prompts to install Flutter; rich output crashes the
|
||||
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).
|
||||
- `gh` is a local install at `~/.local/bin/gh` (logged in as spoopyghosty0 with `workflow` scope; token in
|
||||
`~/.config/gh/hosts.yml`). Commit as `spoopyghosty0 <336754034+spoopyghosty0@users.noreply.github.com>` (set in the repo config).
|
||||
- **The repo is public: never commit personal data** — the Frame's IP address, the Steam user id, the owner's email,
|
||||
local home paths (native builds use `-ffile-prefix-map`). History was rewritten once to remove them.
|
||||
|
||||
## Heuristics (games not in the catalog)
|
||||
Each patch's `detect()` suggests itself from the Analysis; `applies()` says whether it can matter at all (the UI/CLI
|
||||
hide non-applicable patches; enabled ones are always shown). Rules learned from the 34 games:
|
||||
MR-only (PASSTHROUGH required + BOUNDARYLESS_APP) → force_passthrough, + USE_SCENE → scene_emul + meta_permissions;
|
||||
hand tracking required → controller_fix=0; OVRPlugin + ≥20 GiB → disable_space_warp; Unreal ≤4.21 or Unreal Meta XR
|
||||
Audio → nodebug; Oculus-OS class referenced by the Unreal audio build or ≥2 Meta libs → oculusos; legacy-VrApi Unity
|
||||
GLES with MSAA → unity_no_msaa; CryEngine → user.cfg r_variable_rate_shading=0; direct VrApi → bridge (+GL shim for
|
||||
GLAD/GLES); Unreal → alternate no-ForceQuit build. Score changes with
|
||||
`python scripts/eval_heuristics.py "<dumps>"` (catalog off vs verified recipes; currently 33/34 exact — Phantom's
|
||||
"use the no-ForceQuit build" is only detectable at runtime via triage). Add a rule → re-run the eval + `pytest -m games`.
|
||||
|
||||
## Frame operations
|
||||
- Find/connect: `frameport frame discover` / `frame info` (the remembered Frame is in `<user data>/frames.json`).
|
||||
- Power off over SSH: plain `systemctl poweroff` is refused by polkit for remote sessions; this works:
|
||||
`ssh steamos@<frame> 'systemd-run --user --wait --pipe --quiet systemctl poweroff'`. `sudo` needs the user's password
|
||||
(not known to Claude). A reboot clears leaked kernel keyrings.
|
||||
- Reinstalls keep one rollback copy (`<base>/previous-game.apk`, `settings.conf.previous`); `frameport frame cleanup`
|
||||
removes them (and `--path ~/X` extra folders under home).
|
||||
|
||||
## Project status (2026-09-29)
|
||||
34 Quest games ported; the owner confirmed in the headset that all FramePort-rebuilt games work: 23 work, 5 work with
|
||||
issues (Arcsmith/Time Stall eye distortion, AC Nexus some flipped launch text, Phantom DLC button, Silhouette hands),
|
||||
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,
|
||||
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),
|
||||
the unresolved eye distortion, and testing the release bundles on real Windows/macOS machines (never launched yet).
|
||||
|
||||
## Conventions
|
||||
- Dynamic first: fetch live data (tool versions, overport patch list/titles, artwork, catalog) with cache + bundled
|
||||
|
||||
+1009
-30
File diff suppressed because it is too large.
Load diff
@@ -4,3 +4,6 @@ ca27ffa2122def910697ddcaff64f25cfdbc63e2017c738155866ff2fcdb5e12 ./arm64-v8a/li
|
||||
32525cd0a9ee3d9993d8871cd6905bdcf22cde4f4ba28f5db6434ce7d6554758 ./arm64-v8a/libvrapi.so
|
||||
f3aa9419155b8970617fc0d68527a73c39abc82303214c159194c439bb776fd6 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
|
||||
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
|
||||
c2c70f4af1a5c3e6089dff1130161dd417f8044f99ec639eeecf1083930aab16 ./win-x64/fp_oculushmd.exe
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"file_format_version": "1.0.0",
|
||||
"api_layer": {
|
||||
"name": "XR_APILAYER_FRAMEPORT_timefix",
|
||||
"library_path": "./libxr_frameport_timefix.so",
|
||||
"api_version": "1.0",
|
||||
"implementation_version": "1",
|
||||
"description": "FramePort: XR_KHR_convert_timespec_time emulation for Proton games on the Steam Frame",
|
||||
"instance_extensions": [{"name": "XR_KHR_convert_timespec_time", "extension_version": "1"}]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,7 @@ package: com.crytek.climb2
|
||||
title: The Climb 2
|
||||
tested_version: '2.2'
|
||||
source_hint: The Climb 2 v974+2.2
|
||||
engine: Other
|
||||
engine: CryEngine
|
||||
xr: VrApi
|
||||
status: works
|
||||
notes: 'CryEngine direct VrApi (Vulkan): VrApi bridge + platform compat; user.cfg disables VRS.'
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package: rift.rick_and_morty_virtual_rick_ality
|
||||
title: 'Rick and Morty: Virtual Rick-ality'
|
||||
kind: rift
|
||||
tested_version: v2288226
|
||||
source_hint: Rick and Morty - Virtual Rick-ality v2288226
|
||||
engine: Unity
|
||||
xr: OpenVR
|
||||
status: works
|
||||
notes: Runs on the Frame under Proton without Revive (this build talks to SteamVR directly). Slight stutter.
|
||||
details: The dump's executable already uses SteamVR (OpenVR), so it is installed as it is; Proton's VR bridge only
|
||||
starts with FramePort's OpenXR layer (the Frame runtime accepts OpenXR 1.0 apps only). Confirmed in the headset.
|
||||
as_is: true
|
||||
pcvr:
|
||||
- pcvr.xr_timefix
|
||||
pcvr_remove:
|
||||
- pcvr.revive
|
||||
verified:
|
||||
date: '2026-09-29'
|
||||
target: frame
|
||||
frame_os: SteamOS 0.4.2 (build 20260928.6175029)
|
||||
proton: proton_11-arm64
|
||||
exe: Rick and Morty - Virtual Rick-ality/RickAndMortyVR.exe
|
||||
exe_sha256: 01678ebd02edec86258c482fbb5d00f3f0474e1a6515a905531f81df1a80ec50
|
||||
@@ -106,7 +106,94 @@ signatures:
|
||||
severity: warning
|
||||
diagnosis: The game created folders it can't write (saves break). The FramePort launcher repairs permissions every 2 s.
|
||||
suggest: []
|
||||
# ---- PC VR (Oculus Rift games under Revive; on the Frame under Proton). kind: pcvr = only for those logs
|
||||
- id: proton-missing-runtime
|
||||
kind: pcvr
|
||||
pattern: 'needs Steam app \d+ \(runtime\)|_v2-entry-point: (No such file|not found)'
|
||||
severity: fatal
|
||||
diagnosis: The Steam Linux Runtime that Proton needs isn't installed on the Frame.
|
||||
suggest: []
|
||||
- id: revive-inject-failed
|
||||
kind: pcvr
|
||||
pattern: 'Failed to create process'
|
||||
severity: fatal
|
||||
diagnosis: Revive's injector could not start the game (wrong exe path, or a 32-bit/64-bit mismatch).
|
||||
suggest: []
|
||||
- id: oculus-hmd-event
|
||||
kind: pcvr
|
||||
pattern: 'FramePort oculushmd: could not (create the OculusHMDConnected event|start the command)'
|
||||
severity: error
|
||||
diagnosis: FramePort's Oculus detection helper couldn't provide the OculusHMDConnected event (or couldn't start the game). Unreal's Oculus plugin then skips VR silently and the game runs as a flat window.
|
||||
suggest: [pcvr.oculus_unreal, pcvr.proton_log]
|
||||
- id: openxr-no-runtime
|
||||
kind: pcvr
|
||||
# (not the loader's 'xrCreateInstance failed': Proton's own OpenXR probe fails once and retries on the Frame)
|
||||
pattern: 'XR_ERROR_RUNTIME_(UNAVAILABLE|FAILURE)|No OpenXR runtime|OpenXR runtime (is )?not (found|available)|failed to (create|initialize) (the )?OpenXR'
|
||||
severity: fatal
|
||||
diagnosis: No OpenXR runtime reached the game. On a PC start SteamVR (or set it as the OpenXR runtime); on the Frame Proton couldn't bridge to the Frame runtime.
|
||||
suggest: []
|
||||
- id: openxr-missing-ext
|
||||
kind: pcvr
|
||||
pattern: 'XR_ERROR_EXTENSION_NOT_PRESENT|XR_KHR_D3D11_enable.*(not|unsupported)'
|
||||
severity: fatal
|
||||
diagnosis: The OpenXR runtime lacks an extension Revive requires (XR_KHR_D3D11_enable / win32 time conversion).
|
||||
suggest: [pcvr.xr_timefix, pcvr.revive_openvr]
|
||||
- id: openxr-api-version
|
||||
kind: pcvr
|
||||
pattern: 'LoaderInstance::CreateInstance chained CreateInstance call failed|XR_ERROR_API_VERSION_UNSUPPORTED|Unable to load LibOVRRT DLL'
|
||||
severity: fatal
|
||||
diagnosis: Proton's VR setup couldn't create an OpenXR instance (the Frame's SteamVR runtime only accepts OpenXR 1.0 apps; Proton asks for 1.1), so the game got no VR — a flat window, or Revive fails with "Unable to load LibOVRRT DLL".
|
||||
suggest: [pcvr.xr_timefix]
|
||||
- id: openxr-time-conversion
|
||||
kind: pcvr
|
||||
pattern: 'xrConvert(TimespecTimeToTime|TimeToTimespecTime)KHR failed|XR_KHR_convert_timespec_time not available'
|
||||
severity: error
|
||||
diagnosis: The OpenXR runtime refused time conversion, which Proton needs for Windows games' performance-counter times.
|
||||
suggest: [pcvr.xr_timefix]
|
||||
- id: delayload-missing
|
||||
kind: pcvr
|
||||
pattern: '[Ee]xception:? 0xc06d007e|code=c06d007e'
|
||||
severity: fatal
|
||||
diagnosis: A DLL the game loads on demand is missing (Windows delay-load error). For Oculus Store games this is usually LibOVRPlatform64_1.dll, the Oculus Platform SDK that the Oculus app installs for the licence check; FramePort doesn't replace it, so such games need the Oculus app (PC mode).
|
||||
suggest: []
|
||||
- id: oculus-entitlement
|
||||
kind: pcvr
|
||||
pattern: '(?i)entitlement (check )?fail|ovr_PlatformInitialize.*(fail|error)|ovrPlatformInitialize_(NotEntitled|Uninitialized|PreLoaded|FileInvalid|SignatureInvalid|UnableToVerify)'
|
||||
severity: fatal
|
||||
diagnosis: The Oculus Platform SDK entitlement check failed. The game needs the Oculus app running with a license you own (PC mode only).
|
||||
suggest: []
|
||||
- id: vcruntime-missing
|
||||
kind: pcvr
|
||||
pattern: '(?i)(MSVCP|VCRUNTIME)\d+[_\d]*\.dll.*(not found|missing)'
|
||||
severity: error
|
||||
diagnosis: The game needs a Microsoft Visual C++ runtime. Install the vc_redist that ships with the game (or winetricks vcrun2019 in the prefix).
|
||||
suggest: []
|
||||
- id: dxvk-device-lost
|
||||
kind: pcvr
|
||||
pattern: 'DXVK: .*(DEVICE_LOST|Device lost)|VK_ERROR_DEVICE_LOST'
|
||||
severity: fatal
|
||||
diagnosis: The GPU driver lost the device while running the game (D3D through DXVK on freedreno).
|
||||
suggest: [pcvr.proton_log]
|
||||
- id: unreal-crash
|
||||
kind: pcvr
|
||||
pattern: 'CrashReportClient|UE4CC-Windows|UECC-Windows|Fatal error!|Unhandled Exception: EXCEPTION'
|
||||
severity: fatal
|
||||
diagnosis: The game crashed (Unreal's crash reporter started). See the game log and crash summary in the log. Common causes are that no VR runtime reached the game (Revive off) or a GPU or driver problem under Proton.
|
||||
suggest: [pcvr.revive, pcvr.proton_log, pcvr.no_crash_reporter]
|
||||
- id: wine-crash
|
||||
kind: pcvr
|
||||
pattern: 'Unhandled exception: page fault|wine: Unhandled|Backtrace:'
|
||||
severity: error
|
||||
diagnosis: The game crashed under Wine/Proton (set the Proton debug log and check steam-<appid>.log).
|
||||
suggest: [pcvr.proton_log]
|
||||
|
||||
milestones: # progress markers, in order; the furthest one reached is reported
|
||||
- {id: pcvr-launch, kind: pcvr, pattern: 'FramePort: launching|Launched injector with', label: Launcher started}
|
||||
- {id: pcvr-oculus-hmd, kind: pcvr, pattern: 'FramePort oculushmd: OculusHMDConnected event ready', label: Oculus headset event provided}
|
||||
- {id: pcvr-inject, kind: pcvr, pattern: 'Succesfully injected|Successfully injected', label: Revive injected into the game}
|
||||
- {id: pcvr-window, kind: pcvr, pattern: 'Executable name: (?!steam\.exe|CrashReportClient)\S+\.exe|Creating Gamescope surface', label: Game window created}
|
||||
- {id: pcvr-openxr, kind: pcvr, pattern: 'wineopenxr: .*[Ss]ession|LibReviveXR|Revive.*OpenXR|xrCreateSession', label: OpenXR reached}
|
||||
- {id: pcvr-session, kind: pcvr, pattern: 'xrCreateSession|XR_SESSION_STATE_(READY|SYNCHRONIZED)', label: OpenXR session created}
|
||||
- {id: container, pattern: 'Boot complete!', label: Lepton container started}
|
||||
- {id: process, pattern: 'Start proc \d+:', label: App process started}
|
||||
- {id: adapter, pattern: 'FrameBridge: scale=', label: FrameBridge adapter loaded}
|
||||
|
||||
+22
-4
@@ -1,15 +1,15 @@
|
||||
# Architecture
|
||||
|
||||
```
|
||||
┌────────────── UI (Flet, ui/app.py) ─────────────┐ ┌── CLI (cli.py) ──┐
|
||||
┌──────── UI (Flet, ui/: app shell + views/) ─────┐ ┌── CLI (cli.py) ──┐
|
||||
└───────────────────────┬─────────────────────────┘ └────────┬─────────┘
|
||||
▼ ▼
|
||||
pipeline.py (add → suggest → build → install → test)
|
||||
┌──────────────┬──────────────┼───────────────┬───────────────┬──────────────┐
|
||||
sources/ analysis/ recommend/ build.py targets/ validate/
|
||||
quest_dump detect, elf catalog, engine overport → base.Target static, device,
|
||||
stubgen (+ catalog/*.yaml) patches(apk) → frame_lepton triage (+triage.yaml)
|
||||
apk/sign revive (todo)
|
||||
rift_dump stubgen, rift (+ catalog/*.yaml) patches(apk) → frame_lepton triage (+triage.yaml)
|
||||
apk/sign pc_revive
|
||||
│ │ │
|
||||
patches/ registry tools/ toolchain frame/ ssh, discovery, pairing
|
||||
overport, frame/*, (JRE, overport, install/installer ──► agent (on Frame)
|
||||
@@ -34,9 +34,27 @@
|
||||
## Extending
|
||||
- **New fix**: `patches/frame/<name>.py` with a `Patch` subclass (`detect`, `apply`, optional `validate`), plus a
|
||||
signature in `catalog/triage.yaml` and a PLAYBOOK row. Stages: `overport` | `apk` | `install`.
|
||||
- **New heuristic**: put it in the patch's `detect()` (and `applies()` for visibility), with the evidence in the
|
||||
reason text; check `scripts/eval_heuristics.py` still reproduces the catalog and add a test in
|
||||
`tests/test_heuristics.py`.
|
||||
- **New game recipe**: `catalog/games/<package>.yaml` (or "Save as known-good" in the GUI, which writes to the user
|
||||
catalog). Publish recipes by serving a folder with `index.json` and pointing `FRAMEPORT_CATALOG_URL` at it.
|
||||
- **New target** (Revive/PC VR): implement `targets/base.Target`; the pipeline and UI only use that interface.
|
||||
- **New target**: implement `targets/base.Target`; the pipeline and UI only use that interface.
|
||||
|
||||
## Oculus Rift (PC VR) games
|
||||
`sources/rift_dump` finds Windows game folders; `analysis/rift` (own PE reader) detects exe, bitness, engine, LibOVR vs
|
||||
OpenXR, D3D version and Oculus Platform SDK use; ids are `rift.<slug>`. Their only patches are the `pcvr` category
|
||||
(`patches/pcvr.py`: Revive, OpenVR backend, crash reporter, Oculus detection, OpenXR layer, Proton
|
||||
log/version/env); `base.for_game` keeps Quest and PC VR patches
|
||||
apart. "Build" = `pipeline.prepare_rift` (checks + Revive). Revive is a portable tool (`tools/revive.py` unpacks
|
||||
`ReviveInstaller.exe` in pure Python: NSIS header + deflate blocks). Targets:
|
||||
- `targets/pc_revive.PcReviveTarget`: Windows/WSL (`core/winhost.py`), non-Steam shortcut running
|
||||
`ReviveInjector.exe /openxr <exe>` via the agent's VDF code, grid art, local records in `<user data>/pc/`.
|
||||
- `FrameLeptonTarget.install_pcvr` → `installer.install_pcvr` → agent `prepare_pcvr`/`finalize_pcvr`: upload
|
||||
`game/` + `revive/` (+ `xrlayer/` for `pcvr.xr_timefix`, `helpers/` for `pcvr.oculus_unreal`; size-manifest dedupe,
|
||||
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,
|
||||
run `frameport parity` to see which games change.
|
||||
|
||||
|
||||
@@ -36,6 +36,47 @@
|
||||
VK_INSTANCE_LAYERS (set `VK_INSTANCE_LAYERS=""` through `device.lepton_env` to test without them).
|
||||
- GL ES: Zink (Mesa GL on Vulkan). Strict GLSL (see PLAYBOOK) and occasional `DEVICE LOST` with MSAA render-to-texture.
|
||||
|
||||
## Proton / Windows games (surveyed 2026-09-29; running a Rift game under it not yet verified)
|
||||
- Steam on the Frame registers ARM64 compat tools from the app **"Steam Frame ARM64 Compat List"** (appinfo
|
||||
`extended.compat_tools`, found dynamically by the agent): `proton_11-arm64` (4628740, needs
|
||||
`steamlinuxruntime_steamrt4-arm64` 4185400), `proton-experimental-arm64` (4427310), `fex` (3127680, FEX-Emu for
|
||||
Linux x86 binaries; `/usr/bin/FEXBash` shows the chain steam-launch-wrapper → reaper → fex-compat-tool →
|
||||
SteamLinuxRuntime_4). None are installed by default.
|
||||
- `steam -ifrunning steam://install/<appid>` only opens a confirmation dialog in the headset. The agent's unattended
|
||||
mode writes an appmanifest stub (StateFlags 1026, installdir from appinfo) and restarts Steam, which then downloads it.
|
||||
- The command Steam runs is built from each tool's `toolmanifest.vdf` (`commandline`, `require_tool_appid`):
|
||||
`<SLR4-arm64>/_v2-entry-point --verb=waitforexitandrun -- <Proton>/proton waitforexitandrun <exe>`.
|
||||
- Proton sets up VR (vrclient/wineopenxr registry) only when `SteamGameId` is set (steam_helper `setup_vr_registry`).
|
||||
- Host OpenXR runtime for Linux processes: `~/.config/openxr/1/active_runtime.json` → `/opt/steamvr/steamxr_linuxarm64.json`
|
||||
("SteamVR", `bin/linuxarm64/vrclient.so`); implicit API layer `XrApiLayer_VALVE_fdm_injection`.
|
||||
- The "Steam" VR games in the Frame's library (Pistol Whip, Job Simulator, SUPERHOT VR) are APKs run by Lepton, not
|
||||
Proton.
|
||||
- Verified on the device (agent `proton_selftest`, `xr_layer_test`): FramePort installs Proton 11 (ARM64) + SLR4 by
|
||||
itself (~90 s); Proton runs x86 `cmd.exe` in ~3 s; with `SteamGameId` set it registers wineopenxr
|
||||
(`HKLM\Software\Khronos\OpenXR\1` → `C:\openxr\wineopenxr64.json`). Headless launches need the display session (`DISPLAY=:0`,
|
||||
`GAMESCOPE_WAYLAND_DISPLAY=gamescope-0`), else Wine can't create windows and the process hangs.
|
||||
- The Linux SteamVR runtime (Proton's loader `files/lib/aarch64-linux-gnu/libopenxr_loader.so.1`) **does** support
|
||||
`XR_KHR_convert_timespec_time` (unlike the Android runtime Quest games see), so wineopenxr's
|
||||
`XR_KHR_win32_convert_performance_counter_time` works.
|
||||
- **The Linux SteamVR runtime only accepts OpenXR 1.0 apps**: `xrCreateInstance` with `apiVersion` 1.1 returns
|
||||
`XR_ERROR_API_VERSION_UNSUPPORTED` (-4), with any extensions; 1.0 works (checked 2026-09-29, SteamOS 0.4.2). Proton
|
||||
11's VR helper (`proton_vrhelper`) requests 1.1, so without help VR never starts for Proton games: the log shows
|
||||
`LoaderInstance::CreateInstance chained CreateInstance call failed`, games run as a flat window (Rick and Morty) and
|
||||
Revive's OVRPlugin reports `ovr_Initialize failed: Unable to load LibOVRRT DLL` (Lies Beneath, then a crash).
|
||||
FramePort's OpenXR layer (`native/xrlayer`, patch `pcvr.xr_timefix`, default on for PC VR games) retries such a
|
||||
create as 1.0; enabled by `XR_ENABLE_API_LAYERS` from launch.sh. Proton's Steam Linux Runtime container drops
|
||||
`XR_API_LAYER_PATH` (it keeps `XR_ENABLE_API_LAYERS`), so the agent registers the layer as an explicit layer in
|
||||
`~/.local/share/openxr/1/api_layers/explicit.d/` (home is shared into the container) with an absolute library path.
|
||||
- Oculus Store builds that delay-load `LibOVRPlatform64_1.dll` (Platform SDK, e.g. Lies Beneath) crash with
|
||||
`0xc06d007e` (delay-load module not found): that DLL comes with the Oculus app, which the Frame doesn't have. (No Oculus runtime DLLs or
|
||||
registry keys are needed: Revive's LibOVRRT hook works once OpenXR does.)
|
||||
- Unreal's Oculus plugin (and LibOVR's `ovr_Detect`) first checks for the Windows event `OculusHMDConnected` (created
|
||||
by the Oculus service on a PC) and silently skips VR without it. Revive hooks `OpenEventW` for that — Lies Beneath
|
||||
got as far as OVRPlugin, so the hook evidently works there — but it depends on Detours patching Wine's kernelbase,
|
||||
which is ARM64EC code under Proton arm64. Patch `pcvr.oculus_unreal` (default for Unreal Rift games) makes it
|
||||
independent of that: launch.sh starts the injector through `helpers/fp_oculushmd.exe` (`native/oculushmd`), which
|
||||
creates the real event and keeps it until the game (tracked by a job object) has exited.
|
||||
|
||||
## Steam integration
|
||||
- Non-Steam shortcut: binary `userdata/<id>/config/shortcuts.vdf`; appid = crc32(exe+title) | 0x80000000;
|
||||
artwork in `config/grid/<appid>p.*` (portrait), `<appid>.*` (landscape), `<appid>_hero.*`, `<appid>_logo.*`.
|
||||
|
||||
@@ -25,6 +25,28 @@ not a paid one, so your OS warns the first time:
|
||||
`xattr -dr com.apple.quarantine FramePort.app`.
|
||||
- **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort`.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Verify a download
|
||||
|
||||
Every archive has a GitHub build attestation proving it was built by this repository's CI from a tagged commit:
|
||||
|
||||
+32
-2
@@ -15,6 +15,16 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
|---|---|---|
|
||||
| Every game suddenly fails to start: `crun: create keyring …: Disk quota exceeded`, `is not a running context` | rootless podman leaked a kernel keyring per launch; 200-key quota exhausted | `keyring = false` in `~/.config/containers/containers.conf` (FramePort agent does it), then reboot the Frame once |
|
||||
| `APP_ACTIVITY is empty`, nothing starts | Manifest has category INFO only; Lepton needs LAUNCHER | `frame.launcher` (automatic) |
|
||||
| PC VR game on the Frame shows as a flat window / Revive: `Unable to load LibOVRRT DLL` / `LoaderInstance::CreateInstance chained CreateInstance call failed` | Frame SteamVR runtime rejects OpenXR apiVersion 1.1 (`XR_ERROR_API_VERSION_UNSUPPORTED`), which Proton 11's VR helper requests | `pcvr.xr_timefix` (Frame OpenXR layer, default on): retries xrCreateInstance as 1.0 |
|
||||
| Unreal PC VR game on the Frame runs as a flat window although OpenXR works (no `LogHMD` OVRPlugin lines; Unreal logs nothing when it skips the Oculus plugin) / launch.log: `FramePort oculushmd: could not create the OculusHMDConnected event` | UE's OculusHMD (and LibOVR's `ovr_Detect`) only start when the Windows event `OculusHMDConnected` exists and is signalled; on a PC the Oculus service creates it. Revive hooks `OpenEventW` for it, but that relies on Detours patching Wine's (ARM64EC) kernelbase | `pcvr.oculus_unreal` (default for Unreal Rift games; PC VR counterpart of overport's `patch_oculus_unreal`): launch.sh runs the injector through `fp_oculushmd.exe`, which provides the real event until the game exits |
|
||||
| PC VR game runs as a flat 2D window (no VR) | SteamVR was not running, so Revive/LibOVR had no runtime to bind to | FramePort now auto-starts SteamVR on Play; make sure your headset/SteamVR come up before the game |
|
||||
| PC VR game crashed launching via Steam on the OpenXR backend | Revive's newer OpenXR backend was less reliable | PC now defaults to Revive's OpenVR (SteamVR) backend; reinstall to apply |
|
||||
| Oculus Rift game won't run on the Steam Frame | Oculus/LibOVR game: needs Revive, whose hooks don't work under Proton-arm64, and FramePort won't defeat the Oculus runtime signature check | Play it on this PC (SteamVR + Revive). Only OpenVR/OpenXR-native Rift games run on the Frame |
|
||||
| Un-cracked Oculus PC VR game: "Initializing OVR session" then exits / signature check on the runtime | Revive's hooks don't install on Proton-arm64; the Oculus shim rejects the unsigned Revive runtime | Not runnable on the Frame without the repack's crack extracted (not done by FramePort); use the PC version |
|
||||
| PC VR game: `Failed to initialize Oculus API (-3001)` / `Unable to load LibOVRRT DLL` | Revive's LoadLibrary hook doesn't work under Proton arm64 (ARM64EC kernelbase); the game's LibOVR shim finds no runtime DLL | Symlink `LibOVRRT64_1.dll` → Revive's DLL in the prefix's system32 (manual so far) → then -3021 (runtime signature check; open) |
|
||||
| Uploads to the Frame are slow (~15 MB/s) | PC and Frame both on Wi-Fi through the home router | Connect the PC to the Frame's own hotspot (or a USB cable): FramePort uses the direct link automatically (~80-100 MB/s); job log line "Transfer link" |
|
||||
| PC VR game exits right away: `Unhandled Exception: 0xc06d007e` (after `FOnlineSubsystemOculus::InitWithWindowsPlatform`) | Delay-loaded `LibOVRPlatform64_1.dll` (Oculus Platform SDK, installed with the Oculus app) is missing | None on the Frame (entitlement check; FramePort doesn't replace it). PC mode with the Oculus app |
|
||||
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
|
||||
| `INSTALL_FAILED_NO_MATCHING_ABIS` | 32-bit-only APK; Frame has no AArch32 | None. PC/Rift version via Revive |
|
||||
| `UnsatisfiedLinkError` / `cannot locate symbol "ovr_…"` | overport's platform loader lacks Meta platform functions | `frame.ovrstubs` (automatic, generated stubs) |
|
||||
| missing `ovrMessageType_ToString` | same, but the game needs a real string | `frame.ovrplatformcompat` (automatic) |
|
||||
@@ -51,5 +61,25 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
|
||||
## When it can't run on the Frame
|
||||
Stream PC VR instead: native Steam version if one exists (Espire 1: 669290; HITMAN WoA: 1659040; Sniper Elite VR),
|
||||
or the Rift version through Revive + SteamVR (Journey of the Gods, Shadow Point). The catalog stores these as
|
||||
`pcvr_alternative`; a Revive target is planned (`targets/revive.py`).
|
||||
or the Rift version through Revive (Journey of the Gods, Shadow Point). The catalog stores these as
|
||||
`pcvr_alternative`. FramePort handles Rift games itself (next section).
|
||||
|
||||
## Oculus Rift (PC VR) games
|
||||
Scan a folder with Rift game dumps (Windows game folders) like Quest dumps; they get ids `rift.<slug>` and the
|
||||
"PC VR (Revive / Proton)" patch group. Two ways to run them:
|
||||
- **This PC** (`--to pc`, GUI "Install on this PC"): a non-Steam shortcut in the local Windows Steam runs
|
||||
`ReviveInjector.exe /openxr "<game.exe>"` (FramePort's portable Revive; on WSL copied to
|
||||
`%LOCALAPPDATA%\FramePort`). Play on the Frame by streaming from SteamVR.
|
||||
- **The Frame** (GUI "Install on Frame (Proton)"): game + Revive are uploaded to `~/Applications/quest-frame/rift.*`,
|
||||
and launch.sh runs them with the Frame's ARM64 Proton (Frame → Install Proton first). Experimental.
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| "Proton isn't installed on the Frame yet" | ARM64 Proton / Steam Linux Runtime 4 (arm64) not downloaded | Frame → Install Proton (confirm in the headset) or "Install without confirming" (`frameport frame proton --install --unattended`) |
|
||||
| Game quits at once; `ovrPlatformInitialize_NotEntitled` / entitlement failed | Oculus Platform SDK entitlement check (FramePort flags these: "Uses the Oculus Platform SDK") | PC mode with the Oculus app installed and a license you own. FramePort doesn't bypass entitlement checks |
|
||||
| `Failed to create process` in ReviveInjector.txt | wrong exe, or 32/64-bit mismatch | check the detected exe on the game page; rescan |
|
||||
| `XR_ERROR_RUNTIME_UNAVAILABLE` / no OpenXR runtime (PC) | SteamVR not running / not the OpenXR runtime | start SteamVR, set it as OpenXR runtime; or `pcvr.revive_openvr` |
|
||||
| `XR_ERROR_EXTENSION_NOT_PRESENT` / `xrConvertTimespecTimeToTimeKHR failed` (Frame) | ReviveXR needs `XR_KHR_win32_convert_performance_counter_time`; wineopenxr builds it on the host's timespec conversion. The Frame's Linux runtime supports it (2026-09-29), unlike its Android runtime | `pcvr.xr_timefix` (off by default): FramePort's timefix OpenXR layer emulates it like the Quest adapter |
|
||||
| Proton game hangs at start in a headless/SSH launch; log `no driver could be loaded` / `explorer process failed to start` | no display session (DISPLAY/GAMESCOPE_WAYLAND_DISPLAY) | the Proton launch.sh takes them from the running Steam (built in) |
|
||||
| Unreal game starts (window created) then `CrashReportClient` runs | the game crashed under Proton; often no VR runtime reached it (Revive off) | keep `pcvr.revive` on (repacks' bundled LibRevive64.dll isn't loaded by itself); read the game log + crash summary in the launch log |
|
||||
| `VK_ERROR_DEVICE_LOST` under DXVK | freedreno GPU hang | `pcvr.proton_log` to capture; PC mode |
|
||||
@@ -11,6 +11,8 @@ embeds debug paths (`-g`) and the dex depends on the d8 version, so those differ
|
||||
| `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`. |
|
||||
| `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. |
|
||||
| `oculushmd/` | `win-x64/fp_oculushmd.exe` | PC VR counterpart of overport's `patch_oculus_unreal` (patch `pcvr.oculus_unreal`, default for Unreal Rift games on the Frame). Unreal's OculusHMD and LibOVR's `ovr_Detect` only start when the Windows named event `OculusHMDConnected` exists and is signalled (the Oculus service creates it). `fp_oculushmd.exe <command line>` creates it (manual reset, signalled), runs the command (Revive's injector) in a job object and keeps the event until every process in the job — the game and its children — has exited (fallback without job support: 3 minutes); returns the command's exit code and logs `FramePort oculushmd: …` to stderr (launch.log). Freestanding Windows x64 console exe (no CRT, no Windows SDK): the NDK's clang + `lld-link /Brepro` with a kernel32 import library generated by `llvm-dlltool`, so it's byte-reproducible. Tested on Windows from WSL with a build whose event name is `-DEVENT_NAME=L"…"` (the Oculus service owns the real event on a PC with the Oculus app). |
|
||||
| `java-stubs/` | `dex/oculusos-stubs.dex` | No-op `com.oculus.os.AnalyticsEvent` / `UnifiedTelemetryLogger` for native code that looks them up. |
|
||||
|
||||
The ovr_* stub library is *not* prebuilt: `src/frameport/analysis/stubgen.py` generates it per game from the exact
|
||||
|
||||
+65
-7
@@ -5,10 +5,12 @@ Everything is fetched on demand into native/.cache (git-ignored):
|
||||
- Android NDK r27c (27.2.12479018) from Google's repository (checksummed via repository2-3.xml)
|
||||
- OpenXR headers at the commits each component was written against (KhronosGroup/OpenXR-SDK)
|
||||
- Temurin JDK 21 (javac) and Android build-tools (d8) for the Java stub classes
|
||||
Then builds: FrameBridge adapter (arm64 + arm32), VrApi bridge, platform compat, GL shim, oculusos stub dex, and
|
||||
Then builds: FrameBridge adapter (arm64 + arm32), VrApi bridge, platform compat, GL shim, oculusos stub dex, the
|
||||
timefix OpenXR layer for Proton games (linux-arm64, glibc; the NDK's clang builds it freestanding), the
|
||||
OculusHMDConnected helper for Rift games under Proton (win-x64 PE; the NDK's clang + lld-link, no Windows SDK), and
|
||||
rewrites artifacts/SHA256SUMS. Run `frameport parity` afterwards to see which games change.
|
||||
|
||||
python native/build.py [--only adapter,bridge,compat,glshim,dex] [--ndk PATH]
|
||||
python native/build.py [--only adapter,bridge,compat,glshim,dex,xrlayer,oculushmd] [--ndk PATH]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -34,8 +36,9 @@ REPO = "https://dl.google.com/android/repository/"
|
||||
OPENXR = { # component -> OpenXR-SDK commit its headers came from
|
||||
"adapter": "f2448a8797c85814aa892efc1ab8707900fbcc78",
|
||||
"bridge": "7f9285bce1ce8b69bb75554bf788666579d0c35e",
|
||||
"xrlayer": "f2448a8797c85814aa892efc1ab8707900fbcc78",
|
||||
}
|
||||
OPENXR_HEADERS = ("openxr.h", "openxr_platform.h", "openxr_platform_defines.h")
|
||||
OPENXR_HEADERS = ("openxr.h", "openxr_platform.h", "openxr_platform_defines.h", "openxr_loader_negotiation.h")
|
||||
|
||||
|
||||
def log(msg):
|
||||
@@ -81,13 +84,29 @@ def ndk(explicit: str | None) -> Path:
|
||||
with zipfile.ZipFile(z) as zf:
|
||||
zf.extractall(CACHE / "ndk-extract")
|
||||
top = next((CACHE / "ndk-extract").iterdir())
|
||||
top.rename(dest)
|
||||
shutil.move(str(top), str(dest)) # (a plain rename can fail on NTFS while the folder is being scanned)
|
||||
shutil.rmtree(CACHE / "ndk-extract", ignore_errors=True)
|
||||
restore_symlinks(z, dest)
|
||||
for p in (dest / "toolchains/llvm/prebuilt").rglob("bin/*"):
|
||||
p.chmod(0o755)
|
||||
return dest
|
||||
|
||||
|
||||
def restore_symlinks(archive: Path, dest: Path) -> None:
|
||||
"""zipfile writes symlinks (clang -> clang-18, ...) as small text files; replace them with copies of their
|
||||
targets (copies also work on filesystems without symlinks)."""
|
||||
with zipfile.ZipFile(archive) as zf:
|
||||
links = [i for i in zf.infolist() if (i.external_attr >> 16) & 0o170000 == 0o120000]
|
||||
for _ in range(3): # links to links
|
||||
for info in links:
|
||||
rel = Path(*Path(info.filename).parts[1:])
|
||||
path = dest / rel
|
||||
target = (path.parent / zf.read(info).decode()).resolve()
|
||||
if target.is_file() and (not path.exists() or path.stat().st_size < 256):
|
||||
path.unlink(missing_ok=True)
|
||||
shutil.copy2(target, path)
|
||||
|
||||
|
||||
def clang_dir(ndk_root: Path) -> Path:
|
||||
host = {"Linux": "linux-x86_64", "Darwin": "darwin-x86_64", "Windows": "windows-x86_64"}[platform.system()]
|
||||
return ndk_root / "toolchains/llvm/prebuilt" / host
|
||||
@@ -124,6 +143,44 @@ def build_adapter(tc: Path):
|
||||
"-o", ART / "armeabi-v7a/libopenxr_loader_generic.so"], cwd=src)
|
||||
|
||||
|
||||
def build_xrlayer(tc: Path):
|
||||
"""OpenXR API layer for Linux aarch64 (glibc), loaded by Proton's wineopenxr host loader on the Frame. Freestanding:
|
||||
no libc headers or link-time libs; clock_gettime binds to the process's libc at load time."""
|
||||
src = HERE / "xrlayer"
|
||||
inc = openxr_include("xrlayer")
|
||||
out = ART / "linux-arm64"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
run([tc / "bin/clang", "--target=aarch64-linux-gnu", "-ffreestanding", "-nostdlibinc", "-fno-stack-protector",
|
||||
"-fvisibility=hidden", "-fPIC", "-O2", "-Wall", "-Wextra", "-Werror", "-I", src / "include", "-I", inc,
|
||||
f"-ffile-prefix-map={HERE}=native", "-shared", "-nostdlib", "-fuse-ld=lld", "-Wl,--build-id=none",
|
||||
"-Wl,-z,max-page-size=65536", "-Wl,--hash-style=both", "-Wl,-soname,libxr_frameport_timefix.so",
|
||||
"timefix_layer.c", "-o", out / "libxr_frameport_timefix.so"], cwd=src)
|
||||
shutil.copy(src / "XR_APILAYER_FRAMEPORT_timefix.json", out / "XR_APILAYER_FRAMEPORT_timefix.json")
|
||||
|
||||
|
||||
OCULUSHMD_IMPORTS = ("CreateEventW", "CreateJobObjectW", "AssignProcessToJobObject", "QueryInformationJobObject",
|
||||
"CreateProcessW", "ResumeThread", "WaitForSingleObject", "GetExitCodeProcess", "CloseHandle",
|
||||
"Sleep", "GetCommandLineW", "GetLastError", "GetStdHandle", "WriteFile", "ExitProcess")
|
||||
|
||||
|
||||
def build_oculushmd(tc: Path, out: Path | None = None, defines=()):
|
||||
"""fp_oculushmd.exe (Windows x64, run by Proton on the Frame): provides the OculusHMDConnected event for Unreal
|
||||
Rift games. Freestanding: no CRT; the kernel32 import library is generated from a .def by llvm-dlltool."""
|
||||
src = HERE / "oculushmd"
|
||||
out = out or ART / "win-x64"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
work = CACHE / "oculushmd"
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
(work / "kernel32.def").write_text("LIBRARY kernel32.dll\nEXPORTS\n" + "".join(f" {n}\n" for n in OCULUSHMD_IMPORTS))
|
||||
run([tc / "bin/llvm-dlltool", "-m", "i386:x86-64", "-d", work / "kernel32.def", "-l", work / "kernel32.lib"])
|
||||
run([tc / "bin/clang", "--target=x86_64-pc-windows-msvc", "-ffreestanding", "-nostdlibinc", "-fno-stack-protector",
|
||||
"-fno-builtin", "-O2", "-Wall", "-Wextra", "-Werror", f"-ffile-prefix-map={HERE}=native", *defines, "-c",
|
||||
"fp_oculushmd.c", "-o", work / "fp_oculushmd.obj"], cwd=src)
|
||||
run([tc / "bin/lld-link", "/nologo", "/Brepro", "/nodefaultlib", "/entry:start", "/subsystem:console",
|
||||
"/dynamicbase", "/highentropyva", "/nxcompat", work / "fp_oculushmd.obj", work / "kernel32.lib",
|
||||
f"/out:{out / 'fp_oculushmd.exe'}"])
|
||||
|
||||
|
||||
def build_bridge(tc: Path):
|
||||
src = HERE / "vrapi-bridge"
|
||||
inc = openxr_include("bridge")
|
||||
@@ -197,7 +254,7 @@ def write_sums():
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--only", default="adapter,bridge,compat,glshim,dex")
|
||||
ap.add_argument("--only", default="adapter,bridge,compat,glshim,dex,xrlayer,oculushmd")
|
||||
ap.add_argument("--ndk")
|
||||
args = ap.parse_args()
|
||||
parts = set(args.only.split(","))
|
||||
@@ -205,8 +262,9 @@ def main():
|
||||
(ART / d).mkdir(parents=True, exist_ok=True)
|
||||
tc = clang_dir(ndk(args.ndk)) if parts - {"dex"} else None
|
||||
steps = {"adapter": lambda: build_adapter(tc), "bridge": lambda: build_bridge(tc), "compat": lambda: build_compat(tc),
|
||||
"glshim": lambda: build_glshim(tc), "dex": build_dex}
|
||||
for name in ("adapter", "bridge", "compat", "glshim", "dex"):
|
||||
"glshim": lambda: build_glshim(tc), "dex": build_dex, "xrlayer": lambda: build_xrlayer(tc),
|
||||
"oculushmd": lambda: build_oculushmd(tc)}
|
||||
for name in ("adapter", "bridge", "compat", "glshim", "dex", "xrlayer", "oculushmd"):
|
||||
if name in parts:
|
||||
log(f"build {name}")
|
||||
steps[name]()
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/* fp_oculushmd.exe — the PC VR counterpart of overport's patch_oculus_unreal, for Rift games under Proton on the Frame.
|
||||
*
|
||||
* Unreal's OculusVR plugin (and LibOVR's ovr_Detect) only start when the Windows named event "OculusHMDConnected"
|
||||
* exists and is signalled; on a real PC the Oculus service creates it. Without it Unreal never loads OVRPlugin and the
|
||||
* game runs as a flat window. Revive hooks OpenEventW for the same purpose, but that relies on Detours patching Wine's
|
||||
* kernelbase, which is ARM64EC code under Proton arm64. This helper simply provides the real event:
|
||||
*
|
||||
* fp_oculushmd.exe <command line to run> (e.g. ReviveInjector.exe /openxr Z:\...\Game-Win64-Shipping.exe)
|
||||
*
|
||||
* It creates the event (manual reset, signalled), runs the command inside a job object and stays alive (keeping the
|
||||
* event) until every process in the job has exited — ReviveInjector exits right after starting the game, the game
|
||||
* and its children stay in the job. Returns the exit code of the command.
|
||||
*
|
||||
* Freestanding (no CRT, no Windows SDK headers): built by native/build.py with the NDK's clang + lld-link.
|
||||
*/
|
||||
typedef void *HANDLE;
|
||||
typedef unsigned long DWORD;
|
||||
typedef int BOOL;
|
||||
typedef unsigned short WORD;
|
||||
typedef unsigned short WCHAR;
|
||||
typedef long long LONGLONG;
|
||||
|
||||
typedef struct {
|
||||
DWORD cb;
|
||||
WCHAR *lpReserved, *lpDesktop, *lpTitle;
|
||||
DWORD dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
|
||||
WORD wShowWindow, cbReserved2;
|
||||
unsigned char *lpReserved2;
|
||||
HANDLE hStdInput, hStdOutput, hStdError;
|
||||
} STARTUPINFOW;
|
||||
|
||||
typedef struct {
|
||||
HANDLE hProcess, hThread;
|
||||
DWORD dwProcessId, dwThreadId;
|
||||
} PROCESS_INFORMATION;
|
||||
|
||||
typedef struct {
|
||||
LONGLONG TotalUserTime, TotalKernelTime, ThisPeriodTotalUserTime, ThisPeriodTotalKernelTime;
|
||||
DWORD TotalPageFaultCount, TotalProcesses, ActiveProcesses, TotalTerminatedProcesses;
|
||||
} JOBOBJECT_BASIC_ACCOUNTING_INFORMATION;
|
||||
|
||||
#define WINAPI __attribute__((ms_abi))
|
||||
#define IMPORT __declspec(dllimport)
|
||||
IMPORT HANDLE WINAPI CreateEventW(void *, BOOL, BOOL, const WCHAR *);
|
||||
IMPORT HANDLE WINAPI CreateJobObjectW(void *, const WCHAR *);
|
||||
IMPORT BOOL WINAPI AssignProcessToJobObject(HANDLE, HANDLE);
|
||||
IMPORT BOOL WINAPI QueryInformationJobObject(HANDLE, int, void *, DWORD, DWORD *);
|
||||
IMPORT BOOL WINAPI CreateProcessW(const WCHAR *, WCHAR *, void *, void *, BOOL, DWORD, void *, const WCHAR *,
|
||||
STARTUPINFOW *, PROCESS_INFORMATION *);
|
||||
IMPORT DWORD WINAPI ResumeThread(HANDLE);
|
||||
IMPORT DWORD WINAPI WaitForSingleObject(HANDLE, DWORD);
|
||||
IMPORT BOOL WINAPI GetExitCodeProcess(HANDLE, DWORD *);
|
||||
IMPORT BOOL WINAPI CloseHandle(HANDLE);
|
||||
IMPORT void WINAPI Sleep(DWORD);
|
||||
IMPORT WCHAR *WINAPI GetCommandLineW(void);
|
||||
IMPORT DWORD WINAPI GetLastError(void);
|
||||
IMPORT HANDLE WINAPI GetStdHandle(DWORD);
|
||||
IMPORT BOOL WINAPI WriteFile(HANDLE, const void *, DWORD, DWORD *, void *);
|
||||
IMPORT __attribute__((noreturn)) void WINAPI ExitProcess(unsigned);
|
||||
|
||||
#define CREATE_SUSPENDED 0x4
|
||||
#define STD_ERROR_HANDLE ((DWORD)-12)
|
||||
#define JobObjectBasicAccountingInformation 1
|
||||
#ifndef EVENT_NAME /* overridable for tests on a PC where the Oculus service owns the real one */
|
||||
#define EVENT_NAME L"OculusHMDConnected" /* OVR_HMD_CONNECTED_EVENT_NAME */
|
||||
#endif
|
||||
#define GRACE_MS 180000 /* no job tracking: keep the event this long after the command exits (Unreal checks at start) */
|
||||
|
||||
static void say(const char *msg) {
|
||||
DWORD n = 0, len = 0;
|
||||
while (msg[len]) len++;
|
||||
WriteFile(GetStdHandle(STD_ERROR_HANDLE), msg, len, &n, 0);
|
||||
}
|
||||
|
||||
static void say_error(const char *msg) { /* msg + " (error N)" */
|
||||
char buf[16], *p = buf + sizeof buf;
|
||||
DWORD e = GetLastError();
|
||||
*--p = 0, *--p = '\n', *--p = ')';
|
||||
do *--p = (char)('0' + e % 10); while (e /= 10);
|
||||
say(msg);
|
||||
say(" (error ");
|
||||
say(p);
|
||||
}
|
||||
|
||||
/* The command line after our own program name (quoted or not), without leading blanks. */
|
||||
static WCHAR *rest_of_command_line(WCHAR *p) {
|
||||
if (*p == '"') {
|
||||
for (p++; *p && *p != '"'; p++) {}
|
||||
if (*p) p++;
|
||||
} else {
|
||||
while (*p && *p != ' ' && *p != '\t') p++;
|
||||
}
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
return p;
|
||||
}
|
||||
|
||||
void start(void) {
|
||||
WCHAR *cmd = rest_of_command_line(GetCommandLineW());
|
||||
if (!*cmd) {
|
||||
say("usage: fp_oculushmd.exe <command line>\n");
|
||||
ExitProcess(2);
|
||||
}
|
||||
HANDLE event = CreateEventW(0, 1, 1, EVENT_NAME);
|
||||
if (event)
|
||||
say("FramePort oculushmd: OculusHMDConnected event ready\n");
|
||||
else
|
||||
say_error("FramePort oculushmd: could not create the OculusHMDConnected event");
|
||||
|
||||
static STARTUPINFOW si; /* static: zeroed without memset */
|
||||
static PROCESS_INFORMATION pi;
|
||||
si.cb = sizeof si;
|
||||
if (!CreateProcessW(0, cmd, 0, 0, 0, CREATE_SUSPENDED, 0, 0, &si, &pi)) {
|
||||
say_error("FramePort oculushmd: could not start the command");
|
||||
ExitProcess(1);
|
||||
}
|
||||
HANDLE job = CreateJobObjectW(0, 0);
|
||||
BOOL tracked = job && AssignProcessToJobObject(job, pi.hProcess);
|
||||
ResumeThread(pi.hThread);
|
||||
CloseHandle(pi.hThread);
|
||||
WaitForSingleObject(pi.hProcess, 0xFFFFFFFF);
|
||||
DWORD code = 1;
|
||||
GetExitCodeProcess(pi.hProcess, &code);
|
||||
CloseHandle(pi.hProcess);
|
||||
|
||||
if (tracked) { /* the game started by the command (and its children) are in the job too */
|
||||
say("FramePort oculushmd: waiting for the game to exit\n");
|
||||
for (;;) {
|
||||
static JOBOBJECT_BASIC_ACCOUNTING_INFORMATION info;
|
||||
if (!QueryInformationJobObject(job, JobObjectBasicAccountingInformation, &info, sizeof info, 0)
|
||||
|| info.ActiveProcesses == 0)
|
||||
break;
|
||||
Sleep(1000);
|
||||
}
|
||||
} else {
|
||||
say("FramePort oculushmd: no job tracking; keeping the event for 3 minutes\n");
|
||||
Sleep(GRACE_MS);
|
||||
}
|
||||
if (event) CloseHandle(event);
|
||||
ExitProcess(code);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"file_format_version": "1.0.0",
|
||||
"api_layer": {
|
||||
"name": "XR_APILAYER_FRAMEPORT_timefix",
|
||||
"library_path": "./libxr_frameport_timefix.so",
|
||||
"api_version": "1.0",
|
||||
"implementation_version": "1",
|
||||
"description": "FramePort: XR_KHR_convert_timespec_time emulation for Proton games on the Steam Frame",
|
||||
"instance_extensions": [{"name": "XR_KHR_convert_timespec_time", "extension_version": "1"}]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Minimal <time.h> for the freestanding timefix layer (aarch64 Linux: time_t and long are 64-bit). The layer links
|
||||
* against nothing at build time; clock_gettime resolves to the process's libc when the loader dlopens it. */
|
||||
#pragma once
|
||||
typedef long time_t;
|
||||
struct timespec { time_t tv_sec; long tv_nsec; };
|
||||
#define CLOCK_MONOTONIC 1
|
||||
int clock_gettime(int clock_id, struct timespec *ts);
|
||||
@@ -0,0 +1,157 @@
|
||||
/* FramePort timefix: an OpenXR API layer for Windows PC VR games running under Proton on the Steam Frame.
|
||||
*
|
||||
* Proton's wineopenxr implements XR_KHR_win32_convert_performance_counter_time (required by Revive's OpenXR backend)
|
||||
* on top of the host's XR_KHR_convert_timespec_time, but the Frame runtime returns XR_ERROR_FUNCTION_UNSUPPORTED for
|
||||
* xrConvertTimespecTimeToTimeKHR / xrConvertTimeToTimespecTimeKHR. This layer supplies them with the same emulation
|
||||
* the FrameBridge adapter uses for Quest games: XrTime = CLOCK_MONOTONIC + offset, where the offset is measured at
|
||||
* every xrWaitFrame (predictedDisplayTime - predictedDisplayPeriod - now). The runtime's own functions are preferred
|
||||
* whenever they work. The layer also advertises XR_KHR_convert_timespec_time (manifest) and removes it from
|
||||
* xrCreateInstance if the runtime rejects it.
|
||||
*
|
||||
* OpenXR version fallback: the Frame's SteamVR runtime rejects apiVersion 1.1 with XR_ERROR_API_VERSION_UNSUPPORTED
|
||||
* (checked 2026-09-29; 1.0 works), which is what Proton 11's VR helper requests, so VR never started for Proton games
|
||||
* (flat window / "Unable to load LibOVRRT DLL" under Revive). The layer retries such a create as a 1.0 app.
|
||||
*
|
||||
* Enabled per game by FramePort's Proton launcher: XR_API_LAYER_PATH=<game>/xrlayer,
|
||||
* XR_ENABLE_API_LAYERS=XR_APILAYER_FRAMEPORT_timefix. Built freestanding (see ../build.py).
|
||||
*/
|
||||
#define XR_USE_TIMESPEC 1
|
||||
#include <time.h>
|
||||
#include <openxr/openxr.h>
|
||||
#include <openxr/openxr_platform.h>
|
||||
#include <openxr/openxr_loader_negotiation.h>
|
||||
|
||||
#define EXPORT __attribute__((visibility("default")))
|
||||
#define LAYER_NAME "XR_APILAYER_FRAMEPORT_timefix"
|
||||
#define TIMESPEC_EXT "XR_KHR_convert_timespec_time"
|
||||
#define MAX_EXTS 256
|
||||
|
||||
static PFN_xrGetInstanceProcAddr next_gipa;
|
||||
static PFN_xrConvertTimespecTimeToTimeKHR runtime_timespec_to_time;
|
||||
static PFN_xrConvertTimeToTimespecTimeKHR runtime_time_to_timespec;
|
||||
static PFN_xrWaitFrame runtime_wait_frame;
|
||||
static volatile long long time_offset;
|
||||
static volatile int calibrated;
|
||||
|
||||
static int str_eq(const char *a, const char *b) {
|
||||
if (!a || !b) return 0;
|
||||
while (*a && *a == *b) { ++a; ++b; }
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
static long long mono_ns(void) {
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
return (long long)now.tv_sec * 1000000000ll + now.tv_nsec;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_timespec_to_time(XrInstance instance, const struct timespec *ts, XrTime *time) {
|
||||
if (runtime_timespec_to_time) {
|
||||
XrResult r = runtime_timespec_to_time(instance, ts, time);
|
||||
if (r != XR_ERROR_FUNCTION_UNSUPPORTED) return r;
|
||||
}
|
||||
if (!ts || !time) return XR_ERROR_VALIDATION_FAILURE;
|
||||
*time = (XrTime)((long long)ts->tv_sec * 1000000000ll + ts->tv_nsec + (calibrated ? time_offset : 0));
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_time_to_timespec(XrInstance instance, XrTime time, struct timespec *ts) {
|
||||
if (runtime_time_to_timespec) {
|
||||
XrResult r = runtime_time_to_timespec(instance, time, ts);
|
||||
if (r != XR_ERROR_FUNCTION_UNSUPPORTED) return r;
|
||||
}
|
||||
if (!ts) return XR_ERROR_VALIDATION_FAILURE;
|
||||
long long mono = (long long)time - (calibrated ? time_offset : 0);
|
||||
ts->tv_sec = (time_t)(mono / 1000000000ll);
|
||||
ts->tv_nsec = (long)(mono % 1000000000ll);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL layer_wait_frame(XrSession session, const XrFrameWaitInfo *info, XrFrameState *state) {
|
||||
if (!runtime_wait_frame) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrResult r = runtime_wait_frame(session, info, state);
|
||||
if (XR_SUCCEEDED(r) && state && state->predictedDisplayTime) {
|
||||
// predictedDisplayTime is about one display period ahead of "now"
|
||||
time_offset = (long long)(state->predictedDisplayTime - state->predictedDisplayPeriod) - mono_ns();
|
||||
calibrated = 1;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL layer_get_instance_proc_addr(XrInstance instance, const char *name,
|
||||
PFN_xrVoidFunction *function) {
|
||||
if (!function || !name) return XR_ERROR_VALIDATION_FAILURE;
|
||||
if (!next_gipa) return XR_ERROR_HANDLE_INVALID;
|
||||
if (str_eq(name, "xrConvertTimespecTimeToTimeKHR") || str_eq(name, "xrConvertTimeToTimespecTimeKHR")) {
|
||||
PFN_xrVoidFunction real = 0;
|
||||
if (instance == XR_NULL_HANDLE) return XR_ERROR_HANDLE_INVALID;
|
||||
if (XR_FAILED(next_gipa(instance, name, &real))) real = 0;
|
||||
if (str_eq(name, "xrConvertTimespecTimeToTimeKHR")) {
|
||||
runtime_timespec_to_time = (PFN_xrConvertTimespecTimeToTimeKHR)real;
|
||||
*function = (PFN_xrVoidFunction)emu_timespec_to_time;
|
||||
} else {
|
||||
runtime_time_to_timespec = (PFN_xrConvertTimeToTimespecTimeKHR)real;
|
||||
*function = (PFN_xrVoidFunction)emu_time_to_timespec;
|
||||
}
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
if (str_eq(name, "xrWaitFrame") && instance != XR_NULL_HANDLE) {
|
||||
XrResult r = next_gipa(instance, name, (PFN_xrVoidFunction *)&runtime_wait_frame);
|
||||
if (XR_FAILED(r)) return r;
|
||||
*function = (PFN_xrVoidFunction)layer_wait_frame;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
return next_gipa(instance, name, function);
|
||||
}
|
||||
|
||||
static XrResult create_stripped(XrApiLayerNextInfo *next, const XrInstanceCreateInfo *info,
|
||||
const XrApiLayerCreateInfo *chained, XrInstance *instance) {
|
||||
XrResult r = next->nextCreateApiLayerInstance(info, chained, instance);
|
||||
if (r != XR_ERROR_EXTENSION_NOT_PRESENT) return r;
|
||||
// The runtime doesn't know XR_KHR_convert_timespec_time: we provide it, so don't pass it down.
|
||||
const char *exts[MAX_EXTS];
|
||||
uint32_t n = 0, dropped = 0;
|
||||
for (uint32_t i = 0; i < info->enabledExtensionCount && n < MAX_EXTS; ++i) {
|
||||
if (str_eq(info->enabledExtensionNames[i], TIMESPEC_EXT)) { ++dropped; continue; }
|
||||
exts[n++] = info->enabledExtensionNames[i];
|
||||
}
|
||||
if (!dropped) return r;
|
||||
XrInstanceCreateInfo stripped = *info;
|
||||
stripped.enabledExtensionCount = n;
|
||||
stripped.enabledExtensionNames = exts;
|
||||
return next->nextCreateApiLayerInstance(&stripped, chained, instance);
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL layer_create_instance(const XrInstanceCreateInfo *info,
|
||||
const XrApiLayerCreateInfo *layer_info, XrInstance *instance) {
|
||||
if (!layer_info || !layer_info->nextInfo || !info) return XR_ERROR_INITIALIZATION_FAILED;
|
||||
XrApiLayerNextInfo *next = layer_info->nextInfo;
|
||||
next_gipa = next->nextGetInstanceProcAddr;
|
||||
XrApiLayerCreateInfo chained = *layer_info;
|
||||
chained.nextInfo = next->next;
|
||||
XrResult r = create_stripped(next, info, &chained, instance);
|
||||
if (r != XR_ERROR_API_VERSION_UNSUPPORTED || XR_VERSION_MAJOR(info->applicationInfo.apiVersion) != 1 ||
|
||||
XR_VERSION_MINOR(info->applicationInfo.apiVersion) == 0)
|
||||
return r;
|
||||
// The Frame's SteamVR runtime only accepts OpenXR 1.0, but Proton 11's VR helper (and newer apps) ask for 1.1:
|
||||
// retry as a 1.0 app (1.1 core functions the runtime lacks are simply not returned by xrGetInstanceProcAddr).
|
||||
XrInstanceCreateInfo v10 = *info;
|
||||
v10.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
|
||||
return create_stripped(next, &v10, &chained, instance);
|
||||
}
|
||||
|
||||
EXPORT XRAPI_ATTR XrResult XRAPI_CALL xrNegotiateLoaderApiLayerInterface(const XrNegotiateLoaderInfo *loader_info,
|
||||
const char *layer_name,
|
||||
XrNegotiateApiLayerRequest *request) {
|
||||
if (!loader_info || !request || loader_info->structType != XR_LOADER_INTERFACE_STRUCT_LOADER_INFO ||
|
||||
request->structType != XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST ||
|
||||
loader_info->minInterfaceVersion > XR_CURRENT_LOADER_API_LAYER_VERSION ||
|
||||
loader_info->maxInterfaceVersion < XR_CURRENT_LOADER_API_LAYER_VERSION)
|
||||
return XR_ERROR_INITIALIZATION_FAILED;
|
||||
if (layer_name && !str_eq(layer_name, LAYER_NAME)) return XR_ERROR_INITIALIZATION_FAILED;
|
||||
request->layerInterfaceVersion = XR_CURRENT_LOADER_API_LAYER_VERSION;
|
||||
request->layerApiVersion = XR_CURRENT_API_VERSION;
|
||||
request->getInstanceProcAddr = layer_get_instance_proc_addr;
|
||||
request->createApiLayerInstance = layer_create_instance;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "frameport"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Port Meta Quest standalone games to the Valve Steam Frame (Lepton): suggested patches, automated validation, one-click install."
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0-only" }
|
||||
@@ -45,6 +45,7 @@ testpaths = ["tests"]
|
||||
markers = [
|
||||
"device: needs a reachable Steam Frame (FRAMEPORT_FRAME=user@host)",
|
||||
"games: needs the local game dumps (FRAMEPORT_GAMES=<downloads dir>)",
|
||||
"native: compiles native code with the NDK from native/build.py (FRAMEPORT_NATIVE_TESTS=1; slow)",
|
||||
]
|
||||
|
||||
[tool.flet]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Score the patch heuristics against the verified catalog: for every game dump that has a catalog recipe, suggest a
|
||||
recipe with the catalog switched off and compare the recipe-relevant choices.
|
||||
|
||||
python scripts/eval_heuristics.py "<folder with game dumps>" [--verbose]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from frameport.analysis.detect import analyze # noqa: E402
|
||||
from frameport.patches import base # noqa: E402
|
||||
from frameport.recommend import catalog, engine # noqa: E402
|
||||
from frameport.sources import quest_dump # noqa: E402
|
||||
|
||||
|
||||
def signature(recipe) -> dict:
|
||||
"""The parts of a recipe that differ between games (defaults/automatic fixes are excluded)."""
|
||||
pats = recipe.patches
|
||||
return {
|
||||
"overport": sorted(p for p in pats if base.get(p).category == "overport" and not base.get(p).default_on),
|
||||
"overport_removed": sorted(p for p in (x.id for x in base.all_patches() if x.category == "overport" and x.default_on)
|
||||
if p not in pats),
|
||||
"frame": sorted(p for p in pats if base.get(p).category == "frame" and not base.get(p).default_on),
|
||||
"adapter": {p.split(".", 1)[1]: v.get("value") for p, v in sorted(pats.items()) if p.startswith("adapter.")},
|
||||
"device_files": sorted((pats.get("device.files") or {}).get("files", {})),
|
||||
"alt": sorted(recipe.alt_patches),
|
||||
"use_alt": recipe.use_alt,
|
||||
"status_unsupported": recipe.status == "unsupported",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("dumps", type=Path)
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
args = ap.parse_args()
|
||||
games = quest_dump.scan(args.dumps)
|
||||
total = matched_fields = games_ok = 0
|
||||
misses = []
|
||||
for g in games:
|
||||
a = analyze(g.apk, data_bytes=g.data_bytes())
|
||||
if not catalog.lookup(a.package):
|
||||
continue
|
||||
want = signature(engine.suggest(a, use_catalog=True))
|
||||
want["status_unsupported"] = catalog.lookup(a.package).status == "unsupported" and "32-bit" in catalog.lookup(a.package).notes
|
||||
got = signature(engine.suggest(a, use_catalog=False))
|
||||
diffs = {k: (want[k], got[k]) for k in want if want[k] != got[k]}
|
||||
total += len(want)
|
||||
matched_fields += len(want) - len(diffs)
|
||||
games_ok += not diffs
|
||||
if diffs:
|
||||
misses.append((a.package, diffs))
|
||||
if args.verbose or diffs:
|
||||
print(f"{'OK ' if not diffs else 'DIFF'} {a.package}")
|
||||
for k, (w, h) in diffs.items():
|
||||
print(f" {k}: catalog={w} heuristics={h}")
|
||||
n = games_ok + len(misses)
|
||||
print(f"\n{games_ok}/{n} games reproduced exactly; {matched_fields}/{total} recipe fields match")
|
||||
return 0 if not misses else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+12
-2
@@ -41,12 +41,16 @@ def main() -> int:
|
||||
ap.add_argument("--out", type=Path, required=True)
|
||||
ap.add_argument("--game", default=None)
|
||||
ap.add_argument("--frame", default=None, help="steamos@host: also connect and run a launch-test job for --game")
|
||||
ap.add_argument("--no-test", action="store_true", help="with --frame: skip the launch-test job")
|
||||
args = ap.parse_args()
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
game = args.game or (library.games()[0]["package"] if library.games() else None)
|
||||
steps = [("library", lambda a: a.navigate(0)), ("frame", lambda a: a.navigate(1)), ("tools", lambda a: a.navigate(2))]
|
||||
if game:
|
||||
steps.insert(1, ("game", lambda a: a.open_game(game)))
|
||||
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
|
||||
# last: the right-click menu stays open over whatever comes next
|
||||
steps.append(("library-menu", lambda a: (a.navigate(0), time.sleep(3), a.library_view.open_menu(game))))
|
||||
if args.frame:
|
||||
from frameport.frame.connection import parse_target
|
||||
|
||||
@@ -64,7 +68,11 @@ def main() -> int:
|
||||
while a.job_running:
|
||||
time.sleep(2)
|
||||
|
||||
steps += [("frame-connected", connect), ("launch-test-job", job)]
|
||||
steps += [("frame-connected", connect), ("library-connected", lambda a: a.navigate(0))]
|
||||
if game:
|
||||
steps.append(("game-connected", lambda a: a.open_game(game)))
|
||||
if not args.no_test:
|
||||
steps.append(("launch-test-job", job))
|
||||
ready, done = threading.Event(), []
|
||||
|
||||
def app_main(page: ft.Page):
|
||||
@@ -101,7 +109,9 @@ def main() -> int:
|
||||
os._exit(1 if ERRORS else 0)
|
||||
|
||||
threading.Thread(target=shooter, daemon=True).start()
|
||||
ft.run(app_main, view=ft.AppView.WEB_BROWSER, port=PORT)
|
||||
from frameport.ui.app import assets_dir
|
||||
|
||||
ft.run(app_main, view=ft.AppView.WEB_BROWSER, port=PORT, assets_dir=assets_dir())
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,7 +23,40 @@ def _read_manifest_info(path: Path) -> tuple[str, str, str, str | None]:
|
||||
return apk.package, apk.version_name or "", label, apk.get_main_activity()
|
||||
|
||||
|
||||
def analyze(path: Path, deep: bool = True) -> Analysis:
|
||||
def _unreal_version(z: zipfile.ZipFile, lib: str) -> str | None:
|
||||
"""'4.20' from the '++UE4+Release-4.20' build string (streamed; engine libs can be >1 GB)."""
|
||||
tail = b""
|
||||
with z.open(lib) as f:
|
||||
while True:
|
||||
chunk = f.read(1 << 24)
|
||||
if not chunk:
|
||||
return None
|
||||
m = re.search(rb"\+\+UE[45]\+Release-([0-9]+\.[0-9]+)", tail + chunk)
|
||||
if m:
|
||||
return m.group(1).decode()
|
||||
tail = chunk[-64:]
|
||||
|
||||
|
||||
def _features(manifest: bytes) -> dict[str, bool]:
|
||||
"""uses-feature name -> required (android:required defaults to true)."""
|
||||
x = axml.Axml(manifest)
|
||||
names = x.strings()
|
||||
out = {}
|
||||
for el in x.elements():
|
||||
if el.name != "uses-feature":
|
||||
continue
|
||||
name = x.attr_str(el, "name")
|
||||
if not name:
|
||||
continue
|
||||
required = True
|
||||
for a in el.attrs:
|
||||
if a.name < len(names) and names[a.name] == "required":
|
||||
required = a.value != 0
|
||||
out[name] = required
|
||||
return out
|
||||
|
||||
|
||||
def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Analysis:
|
||||
path = Path(path)
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = z.namelist()
|
||||
@@ -39,12 +73,15 @@ def analyze(path: Path, deep: bool = True) -> Analysis:
|
||||
info = z.getinfo(prefix + lib)
|
||||
if info.file_size < 400 * 2**20:
|
||||
lib_bytes[lib] = z.read(info)
|
||||
engine_lib = next((prefix + n for n in ("libUE4.so", "libUnreal.so") if prefix and prefix + n in names), None)
|
||||
unreal_version = _unreal_version(z, engine_lib) if deep and engine_lib else None
|
||||
boot = z.read("assets/bin/Data/boot.config").decode("utf-8", "replace") if "assets/bin/Data/boot.config" in names else ""
|
||||
ggm = z.read(UNITY_GGM) if deep and UNITY_GGM in names else None
|
||||
|
||||
package, version, label, activity = _read_manifest_info(path)
|
||||
libset = set(libs)
|
||||
engine = "Unreal" if libset & {"libUE4.so", "libUnreal.so"} else "Unity" if "libunity.so" in libset else "Other"
|
||||
engine = ("Unreal" if libset & {"libUE4.so", "libUnreal.so"} else "Unity" if "libunity.so" in libset
|
||||
else "CryEngine" if "libCrySystem.so" in libset else "Other")
|
||||
has_openxr, has_vrapi = "libopenxr_loader.so" in libset, "libvrapi.so" in libset
|
||||
xr = "OpenXR+VrApi" if has_openxr and has_vrapi else "OpenXR" if has_openxr else "VrApi" if has_vrapi else "?"
|
||||
is_overport = "libopenxr_loader_generic.so" in libset or "liboverport.config.so" in libset
|
||||
@@ -62,12 +99,12 @@ def analyze(path: Path, deep: bool = True) -> Analysis:
|
||||
graphics = "GLES or unknown (no Vulkan declaration)"
|
||||
|
||||
uses_glad = False
|
||||
oculus_os = False
|
||||
oculus_os_refs = []
|
||||
for name, data in lib_bytes.items():
|
||||
if not elf.is_elf(data):
|
||||
continue
|
||||
if b"com/oculus/os/AnalyticsEvent" in data:
|
||||
oculus_os = True
|
||||
oculus_os_refs.append(name)
|
||||
if name not in ("libvrapi.so", "libOVRPlugin.so") and b"GLAD_GL_" in data and "eglGetProcAddress" in elf.dyn_symbols(data, False):
|
||||
uses_glad = True
|
||||
|
||||
@@ -81,6 +118,8 @@ def analyze(path: Path, deep: bool = True) -> Analysis:
|
||||
msaa_levels = 0
|
||||
|
||||
cats = axml.categories(manifest)
|
||||
features = _features(manifest)
|
||||
used_perms, _ = axml.used_and_declared_permissions(manifest)
|
||||
return Analysis(
|
||||
package=package,
|
||||
version=version,
|
||||
@@ -96,10 +135,21 @@ def analyze(path: Path, deep: bool = True) -> Analysis:
|
||||
meta_permissions=axml.undeclared_meta_permissions(manifest),
|
||||
uses_glad_gl=uses_glad,
|
||||
unity_msaa_levels=msaa_levels,
|
||||
oculus_os_classes=oculus_os,
|
||||
oculus_os_classes=bool(oculus_os_refs),
|
||||
is_overport_output=is_overport,
|
||||
debuggable=bool(axml.Axml(manifest).get_bool("application", "debuggable")),
|
||||
extra={"missing_ovr_symbols": sorted(missing_ovr_symbols(lib_bytes)), "size": path.stat().st_size},
|
||||
extra={
|
||||
"frame_patched": "libframe_settings.so" in libset, # already has FramePort's FrameBridge adapter
|
||||
"missing_ovr_symbols": sorted(missing_ovr_symbols(lib_bytes)), "size": path.stat().st_size,
|
||||
"data_bytes": data_bytes or 0,
|
||||
"features": features,
|
||||
"meta_permissions_used": sorted(p for p in used_perms if p.startswith(("com.oculus.permission.", "horizonos."))),
|
||||
# mixed-reality-only: passthrough required and no guardian (Meta's BOUNDARYLESS_APP)
|
||||
"mr_only": features.get("com.oculus.feature.PASSTHROUGH", False) and "com.oculus.feature.BOUNDARYLESS_APP" in features,
|
||||
"hand_tracking_only": features.get("oculus.software.handtracking", False),
|
||||
"unreal_version": unreal_version,
|
||||
"oculus_os_refs": sorted(oculus_os_refs),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
"""Inspect an Oculus Rift (Windows PC VR) game folder: which program starts it, bitness, engine, XR API, graphics API,
|
||||
and whether it uses the Oculus Platform SDK (entitlement checks that need the Oculus app).
|
||||
|
||||
Game folders come in many shapes (repacks nest the game one or more levels down, next to installers and archives):
|
||||
Stormland/Stormland.exe
|
||||
Asgards Wrath/WindowsNoEditor/WrathGame.exe + …/WrathGame/Binaries/Win64/WrathGame-Win64-Shipping.exe
|
||||
The Climb/bin/win_x64/Climb.exe + The Climb/bin/win_x64-steam/Climb.exe
|
||||
One recursive walk (walk()) collects every .exe/.dll; candidates are GUI-subsystem x86/x64 PE files that aren't
|
||||
installers or helpers; rank_exes() scores them with human-readable reasons and flags ambiguous cases for the user.
|
||||
A tiny PE reader (header, imports, icon resources) is enough; no third-party dependency (Pillow only for icons).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.models import Analysis
|
||||
|
||||
MACHINES = {0x14C: "x86", 0x8664: "x86_64", 0xAA64: "arm64"}
|
||||
GUI_SUBSYSTEM = 2
|
||||
SKIP_EXE = re.compile(
|
||||
r"(?i)(crash|unins|setup|install|redist|vc_?redist|dxsetup|prereq|launcher_helper|cefprocess|ueprereq|dotnet|"
|
||||
r"oculus.?platform|easyanticheat|eac_|report|uploader|bssndrpt|agrepack|modinstaller|^cli(-\d+)?\.exe$|"
|
||||
r"^gui(-\d+)?\.exe$|^t(32|64)\.exe$|^w(32|64)\.exe$|updater|patcher|benchmark|helper|touchup|"
|
||||
r"vrmonitor|steamerrorreporter|unitycrashhandler|ue4editor|editor\.exe$)")
|
||||
PRUNE_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", "site-packages", "_uninstall", "uninstallme",
|
||||
"redist", "_commonredist", "directx", "support", "prereqs", "__installer", "installers", "vcredist",
|
||||
"dotnetfx", "$pluginsdir", "__macosx"}
|
||||
MAX_DEPTH = 8
|
||||
_OVR_ASCII = (b"LibOVRRT%s_%d.dll", b"LibOVRRT64_1.dll", b"LibOVRRT32_1.dll", b"LibOVRRT%hs_%d.dll",
|
||||
b"OVR_CAPI", b"ovr_Initialize", b"OculusHMD", b"OculusRift")
|
||||
# Unreal stores most strings as UTF-16 (TEXT("…")), so look for both encodings
|
||||
LIBOVR_MARKERS = _OVR_ASCII + tuple(m.decode().encode("utf-16-le") for m in _OVR_ASCII)
|
||||
PLATFORM_DLLS = ("libovrplatform64_1.dll", "libovrplatform32_1.dll")
|
||||
# engine DLLs that wrap the Platform SDK (Ready At Dawn's platform services: Lone Echo, Echo Arena)
|
||||
PLATFORM_WRAPPERS = ("pnsovr.dll",)
|
||||
GRAPHICS = (("d3d12.dll", "D3D12"), ("d3d11.dll", "D3D11"), ("vulkan-1.dll", "Vulkan"), ("opengl32.dll", "OpenGL"))
|
||||
AMBIGUITY = 15 # a runner-up within this many points means "ask the user"
|
||||
|
||||
|
||||
class PEError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PEInfo:
|
||||
machine: str
|
||||
imports: list[str] = field(default_factory=list) # lower-case DLL names
|
||||
subsystem: int = 0
|
||||
delay_imports: list[str] = field(default_factory=list) # DLLs loaded on first use (e.g. the Oculus Platform SDK)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ PE reading
|
||||
def _pe_offsets(data: bytes) -> tuple[int, int, int, int, int]:
|
||||
if data[:2] != b"MZ" or len(data) < 0x40:
|
||||
raise PEError("not a PE file")
|
||||
pe = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
if data[pe:pe + 4] != b"PE\0\0":
|
||||
raise PEError("no PE header")
|
||||
machine, nsec, _, _, _, opt_size, _ = struct.unpack_from("<HHIIIHH", data, pe + 4)
|
||||
return pe, machine, nsec, opt_size, pe + 24
|
||||
|
||||
|
||||
def pe_header(path: Path) -> PEInfo:
|
||||
"""Machine + subsystem from the first 4 KiB (cheap: used to filter candidates)."""
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(4096)
|
||||
try:
|
||||
pe, machine, _, _, opt = _pe_offsets(data)
|
||||
subsystem = struct.unpack_from("<H", data, opt + 68)[0]
|
||||
except (struct.error, PEError, IndexError):
|
||||
raise PEError("not a PE file") from None
|
||||
return PEInfo(MACHINES.get(machine, hex(machine)), [], subsystem)
|
||||
|
||||
|
||||
def _sections(data: bytes, opt: int, opt_size: int, nsec: int) -> list[tuple[int, int, int]]:
|
||||
out = []
|
||||
for k in range(nsec):
|
||||
s = opt + opt_size + 40 * k
|
||||
vsize, va, rsize, raw = struct.unpack_from("<IIII", data, s + 8)
|
||||
out.append((va, max(vsize, rsize), raw))
|
||||
return out
|
||||
|
||||
|
||||
def _rva_to_off(sections, rva):
|
||||
for va, size, raw in sections:
|
||||
if va <= rva < va + size:
|
||||
return rva - va + raw
|
||||
return None
|
||||
|
||||
|
||||
def read_pe(path: Path, limit: int = 256 << 20) -> tuple[PEInfo, bytes]:
|
||||
data = path.read_bytes() if path.stat().st_size <= limit else path.open("rb").read(limit)
|
||||
pe, machine, nsec, opt_size, opt = _pe_offsets(data)
|
||||
magic = struct.unpack_from("<H", data, opt)[0]
|
||||
subsystem = struct.unpack_from("<H", data, opt + 68)[0]
|
||||
dd = opt + (96 if magic == 0x10B else 112) # data directories (PE32 / PE32+)
|
||||
imp_rva = struct.unpack_from("<I", data, dd + 8)[0] if opt_size >= dd - opt + 16 else 0
|
||||
sections = _sections(data, opt, opt_size, nsec)
|
||||
delay_rva = struct.unpack_from("<I", data, dd + 13 * 8)[0] if opt_size >= dd - opt + 14 * 8 else 0
|
||||
|
||||
def names(table_rva, entry_size, name_at):
|
||||
out = []
|
||||
p = _rva_to_off(sections, table_rva) if table_rva else None
|
||||
while p is not None and p + entry_size <= len(data) and len(out) < 512:
|
||||
name_rva = struct.unpack_from("<I", data, p + name_at)[0]
|
||||
if not name_rva:
|
||||
break
|
||||
q = _rva_to_off(sections, name_rva)
|
||||
if q is not None:
|
||||
end = data.find(b"\0", q, q + 256)
|
||||
out.append(data[q:end].decode("ascii", "replace").lower())
|
||||
p += entry_size
|
||||
return out
|
||||
return PEInfo(MACHINES.get(machine, hex(machine)), names(imp_rva, 20, 12), subsystem,
|
||||
names(delay_rva, 32, 4)), data
|
||||
|
||||
|
||||
def exe_icon(path: Path, max_bytes: int = 64 << 20) -> bytes | None:
|
||||
"""The largest icon image in a PE's resources (RT_GROUP_ICON → RT_ICON), as PNG bytes; None if there's none."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(max_bytes)
|
||||
pe, machine, nsec, opt_size, opt = _pe_offsets(data)
|
||||
magic = struct.unpack_from("<H", data, opt)[0]
|
||||
dd = opt + (96 if magic == 0x10B else 112)
|
||||
res_rva = struct.unpack_from("<I", data, dd + 16)[0]
|
||||
sections = _sections(data, opt, opt_size, nsec)
|
||||
base = _rva_to_off(sections, res_rva) if res_rva else None
|
||||
if base is None:
|
||||
return None
|
||||
|
||||
def entries(off):
|
||||
named, ids = struct.unpack_from("<HH", data, off + 12)
|
||||
for i in range(named + ids):
|
||||
name, target = struct.unpack_from("<II", data, off + 16 + 8 * i)
|
||||
yield name, target
|
||||
|
||||
def leaves(off): # (id, data offset, size) under a type directory: name → language → data
|
||||
for name, t in entries(off):
|
||||
if not t & 0x80000000:
|
||||
continue
|
||||
for _, lang in entries(base + (t & 0x7FFFFFFF)):
|
||||
if lang & 0x80000000:
|
||||
continue
|
||||
rva, size = struct.unpack_from("<II", data, base + lang)
|
||||
o = _rva_to_off(sections, rva)
|
||||
if o is not None:
|
||||
yield name & 0x7FFFFFFF, o, size
|
||||
types = {name: t for name, t in entries(base) if t & 0x80000000}
|
||||
if 14 not in types or 3 not in types:
|
||||
return None
|
||||
icons = {i: (o, s) for i, o, s in leaves(base + (types[3] & 0x7FFFFFFF))}
|
||||
best = None
|
||||
for _, o, _ in leaves(base + (types[14] & 0x7FFFFFFF)):
|
||||
count = struct.unpack_from("<H", data, o + 4)[0]
|
||||
for k in range(count):
|
||||
w, h, colors, _, planes, bits, size, icon_id = struct.unpack_from("<BBBBHHIH", data, o + 6 + 14 * k)
|
||||
area = (w or 256) * (h or 256) * max(bits, 1)
|
||||
if icon_id in icons and (best is None or area > best[0]):
|
||||
best = (area, icon_id, w, h, bits)
|
||||
if not best:
|
||||
return None
|
||||
o, size = icons[best[1]]
|
||||
raw = data[o:o + size]
|
||||
if raw[:4] == b"\x89PNG":
|
||||
return raw
|
||||
# a DIB: wrap it as a one-image .ico and let Pillow decode it
|
||||
w, h, bits = best[2], best[3], best[4]
|
||||
ico = struct.pack("<HHH", 0, 1, 1) + struct.pack("<BBBBHHII", w, h, 0, 0, 1, bits, len(raw), 22) + raw
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(ico)) as im:
|
||||
out = io.BytesIO()
|
||||
im.convert("RGBA").save(out, "PNG")
|
||||
return out.getvalue()
|
||||
except Exception: # noqa: BLE001 - any malformed resource: no icon
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ folder walk
|
||||
@dataclass
|
||||
class Tree:
|
||||
root: Path
|
||||
exes: list[Path] = field(default_factory=list) # every .exe (unfiltered)
|
||||
dlls: dict[str, Path] = field(default_factory=dict) # lower-case name -> first path
|
||||
has_apk: bool = False
|
||||
size: int = 0
|
||||
files: int = 0
|
||||
manifest: dict | None = None
|
||||
|
||||
def child_of(self, p: Path) -> str:
|
||||
rel = p.relative_to(self.root).parts
|
||||
return rel[0] if len(rel) > 1 else "."
|
||||
|
||||
|
||||
def walk(root: Path, max_depth: int = MAX_DEPTH) -> Tree:
|
||||
"""One pass over a folder: exes, dll names, apk presence, total size (and an Oculus manifest json if present)."""
|
||||
root = Path(root)
|
||||
t = Tree(root)
|
||||
stack = [(root, 0)]
|
||||
while stack:
|
||||
d, depth = stack.pop()
|
||||
try:
|
||||
it = os.scandir(d)
|
||||
except OSError:
|
||||
continue
|
||||
with it:
|
||||
for e in it:
|
||||
try:
|
||||
if e.is_dir(follow_symlinks=False):
|
||||
if depth < max_depth and e.name.lower() not in PRUNE_DIRS:
|
||||
stack.append((Path(e.path), depth + 1))
|
||||
continue
|
||||
name = e.name.lower()
|
||||
t.files += 1
|
||||
try:
|
||||
t.size += e.stat(follow_symlinks=False).st_size
|
||||
except OSError:
|
||||
pass
|
||||
if name.endswith(".exe"):
|
||||
t.exes.append(Path(e.path))
|
||||
elif name.endswith(".dll"):
|
||||
t.dlls.setdefault(name, Path(e.path))
|
||||
elif name.endswith(".apk"):
|
||||
t.has_apk = True
|
||||
elif name.endswith(".json") and depth <= 2 and t.manifest is None and e.stat().st_size < 1 << 20:
|
||||
t.manifest = _manifest(Path(e.path))
|
||||
except OSError:
|
||||
continue
|
||||
return t
|
||||
|
||||
|
||||
def _manifest(path: Path) -> dict | None:
|
||||
"""Oculus CoreData manifest (<canonicalName>.json with launchFile)."""
|
||||
try:
|
||||
d = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if isinstance(d, dict) and d.get("launchFile") and (d.get("canonicalName") or d.get("appId")):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def candidates(tree: Tree) -> list[Path]:
|
||||
"""Programs that could start the game: not an installer/helper, a Windows GUI program for x86/x64."""
|
||||
out = []
|
||||
for p in tree.exes:
|
||||
if SKIP_EXE.search(p.name):
|
||||
continue
|
||||
try:
|
||||
h = pe_header(p)
|
||||
except (PEError, OSError):
|
||||
continue
|
||||
if h.machine in ("x86", "x86_64") and h.subsystem == GUI_SUBSYSTEM:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ naming
|
||||
_TAG_WORDS = r"(?:ARMGDDN|VRP|JF|FFA|Repacks?|Shipping|GOG|CODEX|PLAZA|FitGirl|DODI|ElAmigos|Oculus|Rift|PCVR)"
|
||||
|
||||
|
||||
def clean_title(name: str) -> str:
|
||||
"""'Asgards Wrath v1.6.0 -ARMGDDN' -> 'Asgards Wrath'; 'Arktika 1 (v1.0.0.7) -VRP' -> 'Arktika 1';
|
||||
'Vader Immortal - Episode II v2.0.2+236948 Shipping' -> 'Vader Immortal - Episode II'."""
|
||||
t = re.sub(r"\[[^\]]*\]", " ", name) # [FFA Repacks]
|
||||
t = re.sub(r"\((?:[^)]*\d[^)]*|[^)]*" + _TAG_WORDS + r"[^)]*)\)", " ", t, flags=re.I) # (v1.0.0.7)
|
||||
t = re.sub(r"\s-\s*" + _TAG_WORDS + r"\b.*$", " ", t, flags=re.I) # -ARMGDDN, -VRP v76
|
||||
t = re.sub(r"\bv\d[\w.+\-]*", " ", t, flags=re.I) # v1.6.0, v008, v2.0.2+236948
|
||||
t = re.sub(r"(?<![\w.])\d+(?:\.\d+){2,}[\w+\-]*", " ", t) # 4.15.20, 21.11.08.358012
|
||||
t = re.sub(r"\b" + _TAG_WORDS + r"\b", " ", t, flags=re.I)
|
||||
t = re.sub(r"[_]+", " ", t)
|
||||
t = re.sub(r"\s+", " ", t).strip(" -_.")
|
||||
return t or name.strip()
|
||||
|
||||
|
||||
def slug(name: str) -> str:
|
||||
s = re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_").lower() or "game"
|
||||
return ("g" + s) if s[0].isdigit() else s
|
||||
|
||||
|
||||
def game_id(folder: Path, manifest: dict | None = None) -> str:
|
||||
base = (manifest or {}).get("canonicalName") or clean_title(folder.name)
|
||||
return "rift." + slug(base)
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", s.lower())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ ranking
|
||||
def rank_exes(folder: Path, cands: list[Path], manifest: dict | None = None,
|
||||
dlls: dict[str, Path] | None = None) -> list[dict]:
|
||||
"""Candidates best-first: {path (relative, /), score, reasons, size, machine}."""
|
||||
folder = Path(folder)
|
||||
title = _norm(clean_title(folder.name))
|
||||
launch = (manifest or {}).get("launchFile", "").replace("\\", "/").lower()
|
||||
out = []
|
||||
for p in cands:
|
||||
rel = p.relative_to(folder).as_posix()
|
||||
score, reasons = 0, []
|
||||
stem = p.stem.lower()
|
||||
if launch and rel.lower().endswith(launch):
|
||||
score += 1000
|
||||
reasons.append("named in the Oculus manifest")
|
||||
if stem.endswith("-win64-shipping") or stem.endswith("-win32-shipping"):
|
||||
score += 50
|
||||
reasons.append("Unreal game build")
|
||||
elif (p.parent / "UnityPlayer.dll").exists() or (p.parent / f"{p.stem}_Data").is_dir():
|
||||
score += 50
|
||||
reasons.append("Unity game (next to its data)")
|
||||
if list(p.parent.glob(f"*/Binaries/Win64/{p.stem}-Win64-Shipping.exe")):
|
||||
score -= 30
|
||||
reasons.append("Unreal launcher (starts the real game build)")
|
||||
n = _norm(p.stem.replace("-Win64-Shipping", ""))
|
||||
if n and title and (n in title or title in n or n[:4] == title[:4]):
|
||||
score += 20
|
||||
reasons.append("name matches the game")
|
||||
try:
|
||||
machine = pe_header(p).machine
|
||||
except (PEError, OSError):
|
||||
machine = "?"
|
||||
if machine == "x86_64":
|
||||
score += 5
|
||||
reasons.append("64-bit")
|
||||
parts = [x.lower() for x in p.relative_to(folder).parts[:-1]]
|
||||
if any("steam" in x for x in parts):
|
||||
score -= 10
|
||||
reasons.append("Steam build")
|
||||
if any(x in ("win7", "dx11") for x in parts):
|
||||
reasons.append(parts[-1])
|
||||
size = p.stat().st_size
|
||||
score += min(10, size // (40 << 20)) - len(parts)
|
||||
out.append({"path": rel, "score": score, "reasons": reasons, "size": size, "machine": machine})
|
||||
return sorted(out, key=lambda c: (-c["score"], c["path"]))
|
||||
|
||||
|
||||
def is_ambiguous(ranked: list[dict]) -> bool:
|
||||
return len(ranked) > 1 and ranked[0]["score"] < 1000 and ranked[1]["score"] >= ranked[0]["score"] - AMBIGUITY
|
||||
|
||||
|
||||
def find_exe(folder: Path) -> Path | None:
|
||||
"""The best program to start the game (used by callers that only need one)."""
|
||||
tree = walk(folder)
|
||||
ranked = rank_exes(folder, candidates(tree), tree.manifest)
|
||||
return Path(folder) / ranked[0]["path"] if ranked else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ analysis
|
||||
def fingerprint(folder: Path, exe_rel: str) -> str:
|
||||
p = Path(folder) / exe_rel
|
||||
try:
|
||||
st = p.stat()
|
||||
return f"{exe_rel}:{st.st_size}:{int(st.st_mtime)}"
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def analyze(folder: Path, exe: str | None = None, tree: Tree | None = None) -> Analysis:
|
||||
"""exe: a relative path chosen by the user (else the best-ranked candidate)."""
|
||||
folder = Path(folder)
|
||||
tree = tree or walk(folder)
|
||||
manifest = tree.manifest
|
||||
ranked = rank_exes(folder, candidates(tree), manifest, tree.dlls)
|
||||
if exe:
|
||||
chosen = exe
|
||||
confirmed = True
|
||||
elif ranked:
|
||||
chosen = ranked[0]["path"]
|
||||
confirmed = not is_ambiguous(ranked)
|
||||
else:
|
||||
raise PEError(f"no game program found in {folder}")
|
||||
exe_path = folder / chosen
|
||||
info, data = read_pe(exe_path)
|
||||
names = set(tree.dlls)
|
||||
unity = (exe_path.parent / "UnityPlayer.dll").exists() or (exe_path.parent / f"{exe_path.stem}_Data").is_dir() \
|
||||
or "unityplayer.dll" in names
|
||||
unreal = exe_path.name.lower().endswith("-shipping.exe") or "engine" in [x.lower() for x in exe_path.parts]
|
||||
engine = "Unity" if unity else "Unreal" if unreal else "Other"
|
||||
blob = data
|
||||
for extra in ("unityplayer.dll", "ovrplugin.dll"):
|
||||
p = tree.dlls.get(extra)
|
||||
if p:
|
||||
try:
|
||||
with open(p, "rb") as f:
|
||||
blob += f.read(64 << 20)
|
||||
except OSError:
|
||||
pass
|
||||
# monolithic engines carry the Oculus code in the exe; modular Unreal builds ship it as a plugin DLL
|
||||
# (Engine/Plugins/Runtime/OculusRift/Binaries/Win64/<Game>-OculusRift-Win64-Shipping.dll)
|
||||
libovr = any(m in blob for m in LIBOVR_MARKERS) or \
|
||||
any(x in n for n in names for x in ("ovrplugin", "oculushmd", "libovr", "oculusrift"))
|
||||
openxr = "openxr_loader.dll" in info.imports or "openxr_loader.dll" in names or b"xrCreateInstance" in data
|
||||
# OpenVR/SteamVR: the game ships/links openvr_api. NOTE: an Unreal build carries SteamVR plugin binaries + strings
|
||||
# whether or not it uses them, so this alone does NOT prove the game runs on SteamVR — it only matters for routing
|
||||
# when the game has NO Oculus/LibOVR code (see frame_native below). We ignore the (universal in UE) IVRSystem/
|
||||
# VR_InitInternal string markers for exactly this reason.
|
||||
openvr = any(d in info.imports or d in names for d in ("openvr_api64.dll", "openvr_api32.dll", "openvr_api.dll"))
|
||||
xr = "+".join(n for n, on in (("LibOVR", libovr), ("OpenXR", openxr), ("OpenVR", openvr)) if on) or "?"
|
||||
gfx_imports = set(info.imports)
|
||||
up = exe_path.parent / "UnityPlayer.dll"
|
||||
if up.exists():
|
||||
try:
|
||||
gfx_imports |= set(read_pe(up, 64 << 20)[0].imports)
|
||||
except (PEError, OSError, struct.error):
|
||||
pass
|
||||
graphics = next((g for dll, g in GRAPHICS if dll in gfx_imports), "D3D11 or unknown")
|
||||
# the Oculus Platform SDK (licence check, needs the Oculus app): shipped DLL, (delay-)imported by the exe, or by
|
||||
# a modular Unreal build's OnlineSubsystemOculus plugin DLL
|
||||
platform_imports = set(info.imports + info.delay_imports)
|
||||
for n, p in tree.dlls.items():
|
||||
if "onlinesubsystemoculus" in n:
|
||||
try:
|
||||
sub = read_pe(p, 64 << 20)[0]
|
||||
platform_imports |= set(sub.imports + sub.delay_imports)
|
||||
except (PEError, OSError, struct.error):
|
||||
pass
|
||||
platform_sdk = any(n in names for n in PLATFORM_DLLS + PLATFORM_WRAPPERS) or bool(platform_imports & set(PLATFORM_DLLS)) or \
|
||||
b"ovr_PlatformInitializeWindows" in blob or b"ovr_Entitlement_GetIsViewerEntitled" in blob
|
||||
canonical_hint = next((part for part in exe_path.relative_to(folder).parts[:-1]
|
||||
if re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+){2,}", part)), None) # Oculus Software dir name
|
||||
title = (manifest or {}).get("displayName") or clean_title(folder.name)
|
||||
return Analysis(
|
||||
package=game_id(folder, manifest), version=str((manifest or {}).get("version") or ""), label=title,
|
||||
abis=[info.machine], engine=engine, xr=xr, graphics=graphics, direct_vrapi=False, libs=sorted(names)[:200],
|
||||
launcher_activity=None, has_info_category=False, meta_permissions=[], uses_glad_gl=False, unity_msaa_levels=0,
|
||||
oculus_os_classes=False, is_overport_output=False, debuggable=False,
|
||||
extra={"kind": "rift", "exe": chosen, "folder": str(folder), "exe_candidates": ranked[:12],
|
||||
"exe_confirmed": confirmed, "fingerprint": fingerprint(folder, chosen),
|
||||
"platform_sdk": platform_sdk, "openxr_native": openxr and not libovr,
|
||||
"openvr_native": openvr and not libovr,
|
||||
# frame_native = confidently runs on the Frame through wineopenxr (SteamVR), no Revive: only when there
|
||||
# is NO Oculus/LibOVR code (pure OpenXR or pure OpenVR). A LibOVR game is treated as Oculus (needs
|
||||
# Revive, PC only) unless a known-good catalog recipe overrides it (e.g. Rick and Morty, verified on
|
||||
# the Frame) — static analysis can't tell which runtime a dual-API Unreal build picks at runtime.
|
||||
"frame_native": (openxr or openvr) and not libovr,
|
||||
"needs_revive": libovr,
|
||||
"vr_found": xr != "?", "data_bytes": tree.size, "files": tree.files,
|
||||
# repacks ship a LibRevive64.dll for their own (Virtual Desktop) setup; nothing loads it on its own, so
|
||||
# this is informational only — the game still needs FramePort's Revive for LibOVR → OpenXR
|
||||
"revive_bundled": any(n in names for n in ("librevive64.dll", "librevive32.dll", "librevivexr64.dll",
|
||||
"librevivexr32.dll")),
|
||||
"oculus_app_id": (manifest or {}).get("appId"),
|
||||
"canonical_name": (manifest or {}).get("canonicalName") or canonical_hint},
|
||||
)
|
||||
|
||||
|
||||
def is_rift(analysis: Analysis | dict) -> bool:
|
||||
extra = analysis.get("extra", {}) if isinstance(analysis, dict) else analysis.extra
|
||||
return (extra or {}).get("kind") == "rift"
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Store details for the game page (and Steam tags): description, genres, developer/publisher, release date, website
|
||||
and screenshots.
|
||||
|
||||
Sources (cached 30 days; exact title matches only, like the artwork):
|
||||
- OculusDB (Meta/Oculus store data): long description, genres, publisher, website — found by Quest package or by
|
||||
the Rift app match; covers Quest and Rift games, including Oculus exclusives
|
||||
- the Steam store (games also sold there): short description, genres, developers, publishers, release date and
|
||||
screenshots
|
||||
Meta's own store pages refuse automated requests, so Oculus-exclusive games get no screenshots.
|
||||
Screenshots are saved as artwork/<pkg>/shot_<n>.jpg; the rest goes into the library entry's `details`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from ..core import cache
|
||||
from . import fetch, sources
|
||||
|
||||
STEAM_DETAILS = "https://store.steampowered.com/api/appdetails?appids={id}&l=en"
|
||||
MAX_SHOTS = 6
|
||||
|
||||
|
||||
def _text(s: str | None) -> str:
|
||||
"""Store HTML/markdown-ish text → plain paragraphs."""
|
||||
if not s:
|
||||
return ""
|
||||
s = re.sub(r"(?i)<br\s*/?>|</p>|</li>|</h\d>", "\n", s)
|
||||
s = re.sub(r"<[^>]+>", "", s)
|
||||
s = html.unescape(s)
|
||||
s = re.sub(r"[ \t]+", " ", s)
|
||||
return re.sub(r"\n\s*\n+", "\n\n", s).strip()
|
||||
|
||||
|
||||
def oculusdb_app(entry: dict) -> dict | None:
|
||||
"""The OculusDB record of this game: by Quest package, else by the Rift match."""
|
||||
pkg = entry.get("quest_package") if entry.get("kind") == "rift" else entry["package"]
|
||||
if pkg and "." in pkg and not pkg.startswith("rift."):
|
||||
for r in sources.oculusdb_search(pkg, rift_only=False):
|
||||
if r.get("packageName") == pkg:
|
||||
return r
|
||||
if entry.get("kind") == "rift":
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
return sources.match_rift(entry.get("title") or "", extra.get("canonical_name"))
|
||||
return None
|
||||
|
||||
|
||||
def steam_app(title: str) -> dict | None:
|
||||
hit = sources.match_steam(title)
|
||||
if not hit:
|
||||
return None
|
||||
data = cache.cached_json(f"steamapp-{hit['id']}.json", STEAM_DETAILS.format(id=hit["id"]), max_age=sources.MAX_AGE,
|
||||
fallback={})
|
||||
d = (data or {}).get(str(hit["id"])) or {}
|
||||
return d.get("data") if d.get("success") else None
|
||||
|
||||
|
||||
def fetch_details(entry: dict, screenshots: bool = True) -> dict:
|
||||
"""Collect details for a library entry (doesn't store them; see pipeline.fetch_details)."""
|
||||
title = entry.get("title") or entry["package"]
|
||||
out: dict = {"sources": [], "fetched": time.time()}
|
||||
try:
|
||||
app = oculusdb_app(entry)
|
||||
except Exception: # noqa: BLE001 - offline
|
||||
app = None
|
||||
if app:
|
||||
out["sources"].append("oculusdb")
|
||||
out["description"] = _text(app.get("display_long_description"))
|
||||
out["genres"] = [g for g in app.get("genre_names") or [] if g]
|
||||
if app.get("publisher_name"):
|
||||
out["publisher"] = app["publisher_name"]
|
||||
if app.get("website_url"):
|
||||
out["website"] = app["website_url"]
|
||||
if app.get("id"):
|
||||
out["store_url"] = f"https://www.meta.com/experiences/{app['id']}/"
|
||||
try:
|
||||
st = steam_app(title)
|
||||
except Exception: # noqa: BLE001
|
||||
st = None
|
||||
if st:
|
||||
out["sources"].append("steam")
|
||||
out["steam_appid"] = st.get("steam_appid")
|
||||
out["short"] = _text(st.get("short_description"))
|
||||
if not out.get("description"):
|
||||
out["description"] = _text(st.get("about_the_game") or st.get("detailed_description"))
|
||||
out["genres"] = list(dict.fromkeys((out.get("genres") or []) +
|
||||
[g["description"] for g in st.get("genres") or []]))
|
||||
out.setdefault("developer", ", ".join(st.get("developers") or []) or None)
|
||||
out.setdefault("publisher", ", ".join(st.get("publishers") or []) or None)
|
||||
rd = (st.get("release_date") or {}).get("date")
|
||||
if rd:
|
||||
out["release_date"] = rd
|
||||
if st.get("website"):
|
||||
out.setdefault("website", st["website"])
|
||||
out["store_url_steam"] = f"https://store.steampowered.com/app/{st.get('steam_appid')}/"
|
||||
if screenshots:
|
||||
out["screenshots"] = save_screenshots(entry["package"], [s.get("path_full") for s in
|
||||
st.get("screenshots") or []])
|
||||
return {k: v for k, v in out.items() if v not in (None, "", [])}
|
||||
|
||||
|
||||
def save_screenshots(package: str, urls: list[str]) -> list[str]:
|
||||
d = fetch.artwork_dir(package)
|
||||
saved = []
|
||||
for i, url in enumerate([u for u in urls if u][:MAX_SHOTS], 1):
|
||||
path = d / f"shot_{i}.jpg"
|
||||
if not path.exists():
|
||||
try:
|
||||
r = cache.http_get(url, timeout=30)
|
||||
if r.status_code != 200 or len(r.content) < 1000:
|
||||
continue
|
||||
path.write_bytes(r.content)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
saved.append(path.name)
|
||||
for old in d.glob("shot_*.jpg"):
|
||||
if old.name not in saved:
|
||||
old.unlink()
|
||||
return saved
|
||||
|
||||
|
||||
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 store_links(details: dict) -> list[tuple[str, str]]:
|
||||
out = []
|
||||
if details.get("store_url"):
|
||||
out.append(("Meta store", details["store_url"]))
|
||||
if details.get("store_url_steam"):
|
||||
out.append(("Steam store", details["store_url_steam"]))
|
||||
if details.get("website"):
|
||||
out.append((urllib.parse.urlparse(details["website"]).netloc or "Website", details["website"]))
|
||||
return out
|
||||
@@ -20,6 +20,7 @@ KINDS = {
|
||||
"APP_IMG_LOGO_TRANSPARENT": "logo",
|
||||
"APP_IMG_ICON": "icon",
|
||||
}
|
||||
EXTRA_KINDS = ("square",) # OculusDB's square cover (Rift games)
|
||||
|
||||
|
||||
def artwork_dir(package: str) -> Path:
|
||||
@@ -32,14 +33,17 @@ def _ext(data: bytes) -> str:
|
||||
return ".png" if data[:4] == b"\x89PNG" else ".jpg"
|
||||
|
||||
|
||||
def fetch(package: str, apk: Path | None = None, refresh: bool = False) -> tuple[Path, str | None]:
|
||||
"""Returns (folder with <kind>.<ext> files, store display name or None)."""
|
||||
def fetch(package: str, apk: Path | None = None, refresh: bool = False,
|
||||
lookup: str | None = None) -> tuple[Path, str | None]:
|
||||
"""Returns (folder with <kind>.<ext> files, store display name or None). `lookup` is the package whose store art
|
||||
to use (a Rift game borrows its Quest version's art); Rift ids without one have no store art."""
|
||||
out = artwork_dir(package)
|
||||
have = {p.stem for p in out.iterdir()}
|
||||
title = None
|
||||
if refresh or not {"portrait", "landscape", "hero", "icon"} <= have:
|
||||
lookup = lookup or (None if package.startswith("rift.") else package)
|
||||
if lookup and (refresh or not {"portrait", "landscape", "hero", "icon"} <= have):
|
||||
try:
|
||||
data = cache.http_get(API.format(package=package), timeout=30).json()
|
||||
data = cache.http_get(API.format(package=lookup), timeout=30).json()
|
||||
except Exception:
|
||||
data = {}
|
||||
if data.get("status") == "ok":
|
||||
@@ -81,4 +85,4 @@ def apk_icon(apk: Path) -> bytes | None:
|
||||
|
||||
|
||||
def files(package: str) -> list[Path]:
|
||||
return sorted(p for p in artwork_dir(package).iterdir() if p.stem in KINDS.values())
|
||||
return sorted(p for p in artwork_dir(package).iterdir() if p.stem in KINDS.values() or p.stem in EXTRA_KINDS)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Find artwork for games that the Meta art service (by Android package) doesn't cover — Oculus Rift games.
|
||||
|
||||
Order (all lookups cached for 30 days; only exact title matches are used, so no wrong art is picked):
|
||||
1. the Quest version's package (catalog, library, or OculusDB's packageName) → the full Meta art set (fetch.fetch)
|
||||
2. OculusDB (community database of the Oculus store): the Rift app's square cover
|
||||
3. the Steam store (games also sold on Steam): portrait, hero, landscape and logo
|
||||
4. the game's own program icon
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from ..core import cache
|
||||
from . import fetch
|
||||
|
||||
OCULUSDB = "https://oculusdb.rui2015.me"
|
||||
STEAM_SEARCH = "https://store.steampowered.com/api/storesearch/?term={term}&cc=us&l=en"
|
||||
STEAM_CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps/{id}/{file}"
|
||||
STEAM_FILES = {"portrait": "library_600x900.jpg", "hero": "library_hero.jpg", "landscape": "header.jpg",
|
||||
"logo": "logo.png"}
|
||||
MAX_AGE = 30 * 86400
|
||||
|
||||
|
||||
ROMAN = {"i": "1", "ii": "2", "iii": "3", "iv": "4", "v": "5", "vi": "6", "vii": "7", "viii": "8", "ix": "9", "x": "10"}
|
||||
QUEST_SUFFIXES = {"", "unplugged", "quest", "questedition", "forquest", "vr"}
|
||||
|
||||
|
||||
def norm(s: str) -> str:
|
||||
"""Comparable title: lower-case letters and digits only, roman numerals as digits ('Lone Echo II' = 'Lone Echo 2',
|
||||
"Asgard's Wrath" = 'Asgards Wrath')."""
|
||||
words = re.findall(r"[a-z0-9]+", (s or "").lower().replace("'", ""))
|
||||
return "".join(ROMAN.get(w, w) if i and w in ROMAN else w for i, w in enumerate(words))
|
||||
|
||||
|
||||
def search_terms(title: str) -> list[str]:
|
||||
"""The title, then shorter forms (OculusDB's search doesn't match "Asgards" to "Asgard's")."""
|
||||
words = re.sub(r"[^\w\s]", " ", title).split()
|
||||
terms = [title]
|
||||
for n in (3, 2, 1):
|
||||
if len(words) > n and len(" ".join(words[:n])) >= 4:
|
||||
terms.append(" ".join(words[:n]))
|
||||
if words and len(words[0]) >= 5 and words[0].lower().endswith("s"):
|
||||
terms.append(words[0][:-1]) # "Asgards" -> "Asgard", "Wilsons" -> "Wilson"
|
||||
return list(dict.fromkeys(terms))
|
||||
|
||||
|
||||
def _key(term: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "_", term.lower())[:80]
|
||||
|
||||
|
||||
def oculusdb_search(term: str, rift_only: bool = True) -> list[dict]:
|
||||
q = urllib.parse.quote(term)
|
||||
url = f"{OCULUSDB}/api/v1/search/{q}" + ("?headsets=RIFT" if rift_only else "")
|
||||
res = cache.cached_json(f"oculusdb-{'rift' if rift_only else 'all'}-{_key(term)}.json", url, max_age=MAX_AGE,
|
||||
fallback=[])
|
||||
return [r for r in res or [] if isinstance(r, dict) and r.get("__OculusDBType", "Application") == "Application"]
|
||||
|
||||
|
||||
def steam_search(term: str) -> list[dict]:
|
||||
res = cache.cached_json(f"steamsearch-{_key(term)}.json", STEAM_SEARCH.format(term=urllib.parse.quote(term)),
|
||||
max_age=MAX_AGE, fallback={})
|
||||
return (res or {}).get("items") or []
|
||||
|
||||
|
||||
def match_rift(title: str, canonical: str | None = None) -> dict | None:
|
||||
"""The OculusDB Rift app for this game (exact normalized name or canonicalName match)."""
|
||||
want = norm(title)
|
||||
terms = ([canonical.replace("-", " ")] if canonical else []) + search_terms(title)
|
||||
for term in terms:
|
||||
for r in oculusdb_search(term):
|
||||
names = {norm(r.get("appName") or ""), norm(r.get("displayName") or "")}
|
||||
if (canonical and r.get("canonicalName") == canonical) or want in names:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def match_quest(title: str) -> str | None:
|
||||
"""Package of the game's Quest version on the Meta store (same name, or the name plus a short suffix such as
|
||||
'Unplugged'), from OculusDB."""
|
||||
want = norm(title)
|
||||
if len(want) < 4:
|
||||
return None
|
||||
for term in search_terms(title)[:2]:
|
||||
for r in oculusdb_search(term, rift_only=False):
|
||||
pkg = r.get("packageName")
|
||||
n = norm(r.get("appName") or r.get("displayName") or "")
|
||||
# same name, or the name plus a platform word ("Robo Recall: Unplugged") — never a sequel/other game
|
||||
if pkg and n.startswith(want) and n[len(want):] in QUEST_SUFFIXES:
|
||||
return pkg
|
||||
return None
|
||||
|
||||
|
||||
def match_steam(title: str) -> dict | None:
|
||||
want = norm(title)
|
||||
return next((i for i in steam_search(title) if norm(i.get("name") or "") == want), None)
|
||||
|
||||
|
||||
def _save(package: str, kind: str, data: bytes) -> None:
|
||||
if not data or len(data) < 200:
|
||||
return
|
||||
ext = ".png" if data[:4] == b"\x89PNG" else ".webp" if data[8:12] == b"WEBP" else ".jpg"
|
||||
d = fetch.artwork_dir(package)
|
||||
for old in d.glob(f"{kind}.*"):
|
||||
old.unlink()
|
||||
(d / f"{kind}{ext}").write_bytes(data)
|
||||
|
||||
|
||||
def _get(url: str) -> bytes | None:
|
||||
try:
|
||||
r = cache.http_get(url, timeout=30)
|
||||
return r.content if r.status_code == 200 else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def apply_oculusdb(package: str, app: dict) -> bool:
|
||||
link = app.get("imageLink") or (f"/cdn/images/{app['id']}" if app.get("id") else None)
|
||||
data = _get(OCULUSDB + link) if link else None
|
||||
if data:
|
||||
_save(package, "square", data)
|
||||
return bool(data)
|
||||
|
||||
|
||||
def apply_steam(package: str, appid: int | str) -> bool:
|
||||
got = False
|
||||
for kind, file in STEAM_FILES.items():
|
||||
data = _get(STEAM_CDN.format(id=appid, file=file))
|
||||
if data:
|
||||
_save(package, kind, data)
|
||||
got = True
|
||||
return got
|
||||
|
||||
|
||||
def has_art(package: str) -> bool:
|
||||
stems = {p.stem for p in fetch.files(package)}
|
||||
return bool(stems & {"portrait", "square", "landscape", "hero"})
|
||||
|
||||
|
||||
def fetch_rift(package: str, title: str, canonical: str | None = None, quest_package: str | None = None,
|
||||
exe: Path | None = None) -> dict:
|
||||
"""Find and store artwork for a Rift game. Returns what was found: {source, quest_package, oculus_app_id,
|
||||
canonical_name, steam_appid} (only the keys that apply)."""
|
||||
found: dict = {}
|
||||
app = None
|
||||
try:
|
||||
app = match_rift(title, canonical)
|
||||
except Exception: # noqa: BLE001 - offline: fall through to local sources
|
||||
app = None
|
||||
if app:
|
||||
found.update(oculus_app_id=app.get("id"), canonical_name=app.get("canonicalName"),
|
||||
title=app.get("displayName") or app.get("appName"))
|
||||
if not quest_package:
|
||||
try:
|
||||
quest_package = match_quest(found.get("title") or title)
|
||||
except Exception: # noqa: BLE001
|
||||
quest_package = None
|
||||
if quest_package:
|
||||
found["quest_package"] = quest_package
|
||||
fetch.fetch(package, lookup=quest_package)
|
||||
if has_art(package):
|
||||
found["source"] = "meta"
|
||||
if app and not found.get("source") and apply_oculusdb(package, app):
|
||||
found["source"] = "oculusdb"
|
||||
if found.get("source") != "meta":
|
||||
try:
|
||||
st = match_steam(found.get("title") or title)
|
||||
except Exception: # noqa: BLE001
|
||||
st = None
|
||||
if st and apply_steam(package, st["id"]):
|
||||
found["steam_appid"] = st["id"]
|
||||
found["source"] = found.get("source") or "steam"
|
||||
if not has_art(package) and exe is not None:
|
||||
from ..analysis.rift import exe_icon
|
||||
|
||||
icon = exe_icon(exe)
|
||||
if icon:
|
||||
_save(package, "icon", icon)
|
||||
found["source"] = "icon"
|
||||
return found
|
||||
|
||||
|
||||
def search(term: str) -> list[dict]:
|
||||
"""Candidates for the artwork picker: [{source, name, preview (URL), id|package}]."""
|
||||
out = []
|
||||
try:
|
||||
for r in oculusdb_search(term, rift_only=False)[:12]:
|
||||
link = r.get("imageLink") or (f"/cdn/images/{r['id']}" if r.get("id") else None)
|
||||
if link:
|
||||
out.append({"source": "Meta (Quest)" if r.get("packageName") else "Oculus Rift",
|
||||
"name": r.get("displayName") or r.get("appName"), "preview": OCULUSDB + link,
|
||||
"id": r.get("id"), "package": r.get("packageName"), "app": r})
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
for i in steam_search(term)[:8]:
|
||||
out.append({"source": "Steam", "name": i.get("name"), "preview": STEAM_CDN.format(id=i["id"],
|
||||
file="header.jpg"), "id": i["id"]})
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
ART_STEMS = frozenset(fetch.KINDS.values()) | frozenset(fetch.EXTRA_KINDS)
|
||||
|
||||
|
||||
def apply_choice(package: str, choice: dict) -> bool:
|
||||
"""Store the artwork of a picker result, replacing the current art. It's downloaded into a staging folder first:
|
||||
when the result yields no cover (dead store image, no art for that package) the current art stays and this
|
||||
returns False. Screenshots and the stored title are kept either way."""
|
||||
import shutil
|
||||
|
||||
tmp = f".pick.{package}"
|
||||
stage = fetch.artwork_dir(tmp)
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
stage = fetch.artwork_dir(tmp)
|
||||
try:
|
||||
if choice["source"] == "Steam":
|
||||
apply_steam(tmp, choice["id"])
|
||||
elif choice.get("package"):
|
||||
fetch.fetch(tmp, lookup=choice["package"], refresh=True)
|
||||
if not has_art(tmp):
|
||||
apply_oculusdb(tmp, choice["app"])
|
||||
else:
|
||||
apply_oculusdb(tmp, choice["app"])
|
||||
if not has_art(tmp):
|
||||
return False
|
||||
d = fetch.artwork_dir(package)
|
||||
for f in d.iterdir(): # the old art and its thumbnails (screenshot thumbnails are t_shot_*)
|
||||
if f.is_file() and (f.stem in ART_STEMS or f.name.startswith("t_") and not f.name.startswith("t_shot_")):
|
||||
f.unlink()
|
||||
for f in stage.iterdir():
|
||||
if f.is_file() and f.stem in ART_STEMS:
|
||||
f.replace(d / f.name)
|
||||
return True
|
||||
finally:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""A complete Steam library art set for a shortcut, composed from whatever artwork a game has.
|
||||
|
||||
Steam wants: portrait 600×900 (library grid), landscape 920×430 (recent/horizontal), hero 1920×620 (game page banner),
|
||||
logo (transparent, drawn on the hero) and an icon. Store art rarely covers all of them — Rift games from OculusDB only
|
||||
have a square cover — so missing shapes are composed: the best source image, fitted and centred over a blurred,
|
||||
darkened copy of itself (or a screenshot, for the hero). Results are cached in artwork/<pkg>/steam/.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from . import fetch
|
||||
|
||||
SIZES = {"portrait": (600, 900), "landscape": (920, 430), "hero": (1920, 620)}
|
||||
# preferred sources per Steam shape, best first ("shot" = first screenshot)
|
||||
PREFER = {"portrait": ("portrait", "square", "icon", "landscape"),
|
||||
"landscape": ("landscape", "hero", "shot", "square", "portrait"),
|
||||
"hero": ("hero", "shot", "landscape", "square", "portrait"),
|
||||
"icon": ("icon", "square", "portrait")}
|
||||
|
||||
|
||||
def _sources(package: str) -> dict[str, Path]:
|
||||
files = {p.stem: p for p in fetch.files(package)}
|
||||
shots = sorted(fetch.artwork_dir(package).glob("shot_*.jpg"))
|
||||
if shots:
|
||||
files["shot"] = shots[0]
|
||||
return files
|
||||
|
||||
|
||||
def _fill(im, size):
|
||||
"""Scale to cover `size` and centre-crop."""
|
||||
from PIL import Image
|
||||
|
||||
w, h = size
|
||||
s = max(w / im.width, h / im.height)
|
||||
im = im.resize((max(1, round(im.width * s)), max(1, round(im.height * s))), Image.LANCZOS)
|
||||
left, top = (im.width - w) // 2, (im.height - h) // 2
|
||||
return im.crop((left, top, left + w, top + h))
|
||||
|
||||
|
||||
def _compose(src: Path, size, exact: bool):
|
||||
"""exact: the source already has (about) the right shape → cover-fit; otherwise fit over a blurred backdrop."""
|
||||
from PIL import Image, ImageEnhance, ImageFilter
|
||||
|
||||
with Image.open(src) as im:
|
||||
im = im.convert("RGB")
|
||||
w, h = size
|
||||
if exact:
|
||||
return _fill(im, size)
|
||||
bg = _fill(im, size).filter(ImageFilter.GaussianBlur(radius=max(w, h) // 30))
|
||||
bg = ImageEnhance.Brightness(bg).enhance(0.45)
|
||||
s = min(w / im.width, h / im.height) * 0.94
|
||||
fg = im.resize((max(1, round(im.width * s)), max(1, round(im.height * s))), Image.LANCZOS)
|
||||
bg.paste(fg, ((w - fg.width) // 2, (h - fg.height) // 2))
|
||||
return bg
|
||||
|
||||
|
||||
def _ratio_ok(src: Path, size) -> bool:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(src) as im:
|
||||
r = im.width / im.height
|
||||
target = size[0] / size[1]
|
||||
return abs(r - target) / target < 0.18
|
||||
|
||||
|
||||
def steam_set(package: str) -> dict[str, Path]:
|
||||
"""{portrait, landscape, hero, logo?, icon?} files ready for Steam (created once per source change)."""
|
||||
src = _sources(package)
|
||||
if not src:
|
||||
return {}
|
||||
out_dir = fetch.artwork_dir(package) / "steam"
|
||||
out_dir.mkdir(exist_ok=True)
|
||||
key = hashlib.sha1("|".join(f"{k}:{p.stat().st_size}:{p.stat().st_mtime_ns}" for k, p in sorted(src.items()))
|
||||
.encode()).hexdigest()[:8]
|
||||
stamp = out_dir / f".{key}"
|
||||
result: dict[str, Path] = {}
|
||||
if stamp.exists():
|
||||
for p in out_dir.iterdir():
|
||||
if not p.name.startswith("."):
|
||||
result[p.stem] = p
|
||||
return result
|
||||
for p in out_dir.iterdir():
|
||||
p.unlink()
|
||||
for kind, size in SIZES.items():
|
||||
choice = next((k for k in PREFER[kind] if k in src), None)
|
||||
if not choice:
|
||||
continue
|
||||
try:
|
||||
# a screenshot always fills the wide banner; other sources only when their shape is close enough
|
||||
exact = _ratio_ok(src[choice], size) or (kind == "hero" and choice == "shot")
|
||||
img = _compose(src[choice], size, exact)
|
||||
path = out_dir / f"{kind}.jpg"
|
||||
img.save(path, "JPEG", quality=90, optimize=True)
|
||||
result[kind] = path
|
||||
except Exception: # noqa: BLE001 - unreadable source: skip that shape
|
||||
continue
|
||||
if "logo" in src:
|
||||
result["logo"] = out_dir / f"logo{src['logo'].suffix}"
|
||||
result["logo"].write_bytes(src["logo"].read_bytes())
|
||||
icon = next((src[k] for k in PREFER["icon"] if k in src), None)
|
||||
if icon:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(icon) as im:
|
||||
im = _fill(im.convert("RGBA"), (256, 256))
|
||||
path = out_dir / "icon.png"
|
||||
im.save(path, "PNG")
|
||||
result["icon"] = path
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
stamp.write_text("")
|
||||
return result
|
||||
|
||||
|
||||
def original_platform(entry: dict) -> str:
|
||||
return "Oculus Rift" if entry.get("kind") == "rift" else "Meta Quest"
|
||||
|
||||
|
||||
def steam_tags(entry: dict, where: str = "frame") -> list[str]:
|
||||
"""Tags for the Steam shortcut (Steam can group them into collections): how it runs, the game's original platform,
|
||||
genres and the user's own tags."""
|
||||
runs = ("PC VR on Frame" if entry.get("kind") == "rift" else "Quest on Frame") if where == "frame" else \
|
||||
"Rift via Revive"
|
||||
base = [runs, original_platform(entry)]
|
||||
genres = ((entry.get("details") or {}).get("genres") or [])[:4]
|
||||
return list(dict.fromkeys(base + genres + list(entry.get("tags") or [])))
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Small cached thumbnails of the artwork, served to the GUI by URL (the GUI's assets folder is the user data dir).
|
||||
|
||||
Full-size store art is 0.3–2 MB per image; sending it as bytes on every render froze the app. Thumbnails are JPEGs named
|
||||
after their source's content hash (t_<kind>_<width>_<sha8>.jpg), so the client cache stays correct when art changes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.paths import user_data_dir
|
||||
from . import fetch
|
||||
|
||||
WIDTHS = {"portrait": 400, "square": 400, "landscape": 1280, "hero": 1280, "icon": 96, "logo": 600}
|
||||
_locks: dict[str, threading.Lock] = {}
|
||||
_guard = threading.Lock()
|
||||
|
||||
|
||||
def _lock(key: str) -> threading.Lock:
|
||||
with _guard:
|
||||
return _locks.setdefault(key, threading.Lock())
|
||||
|
||||
|
||||
def _digest(path: Path) -> str:
|
||||
st = path.stat()
|
||||
return hashlib.sha1(f"{path.name}:{st.st_size}:{st.st_mtime_ns}".encode()).hexdigest()[:8]
|
||||
|
||||
|
||||
def thumb(path: Path, width: int, kind: str | None = None) -> Path | None:
|
||||
"""A JPEG (PNG for logos, which need transparency) at most `width` px wide; created once."""
|
||||
kind = kind or path.stem
|
||||
ext = ".png" if kind == "logo" else ".jpg"
|
||||
out = path.parent / f"t_{kind}_{width}_{_digest(path)}{ext}"
|
||||
if out.exists():
|
||||
return out
|
||||
with _lock(str(out)):
|
||||
if out.exists():
|
||||
return out
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as im:
|
||||
im.load()
|
||||
if im.width > width:
|
||||
im = im.resize((width, max(1, round(im.height * width / im.width))), Image.LANCZOS)
|
||||
for old in path.parent.glob(f"t_{kind}_{width}_*{ext}"):
|
||||
old.unlink(missing_ok=True)
|
||||
tmp = out.with_suffix(out.suffix + ".tmp")
|
||||
if ext == ".png":
|
||||
im.save(tmp, "PNG", optimize=True)
|
||||
else:
|
||||
im.convert("RGB").save(tmp, "JPEG", quality=86, optimize=True, progressive=True)
|
||||
tmp.replace(out)
|
||||
except Exception: # unreadable/odd image: use the original
|
||||
return path
|
||||
return out
|
||||
|
||||
|
||||
def pick(package: str, kinds: tuple[str, ...]) -> Path | None:
|
||||
files = {p.stem: p for p in fetch.files(package)}
|
||||
return next((files[k] for k in kinds if k in files), None)
|
||||
|
||||
|
||||
def url(package: str, kinds: tuple[str, ...], width: int | None = None) -> str | None:
|
||||
"""Asset URL (for ft.Image / DecorationImage src) of the first available kind, as a thumbnail."""
|
||||
src = pick(package, kinds)
|
||||
if not src:
|
||||
return None
|
||||
t = thumb(src, width or WIDTHS.get(src.stem, 600), src.stem)
|
||||
return asset_url(t)
|
||||
|
||||
|
||||
def asset_url(path: Path) -> str:
|
||||
return "/" + path.relative_to(user_data_dir()).as_posix()
|
||||
|
||||
|
||||
def prewarm(package: str) -> None:
|
||||
"""Create the thumbnails the GUI uses (cards, hero, icons) ahead of time (called after fetching art)."""
|
||||
for kinds, width in ((("portrait", "square", "icon"), 400), (("hero", "landscape", "portrait", "square"), 1280),
|
||||
(("icon", "square", "portrait"), 96)):
|
||||
src = pick(package, kinds)
|
||||
if src:
|
||||
thumb(src, width, src.stem)
|
||||
+121
-16
@@ -8,6 +8,9 @@
|
||||
frameport frame discover | pair | info --frame steamos@frame.local
|
||||
frameport install <pkg> --frame steamos@frame.local [--apk-only]
|
||||
frameport test <pkg> --frame ... # headless launch + triage
|
||||
frameport frame proton [--install] # Proton on the Frame, for Oculus Rift (PC VR) games
|
||||
frameport install rift.<game> --to pc|frame # Rift games: this PC (Revive + Steam) or the Frame (Proton)
|
||||
frameport pc info # Windows Steam / SteamVR / Revive on this PC
|
||||
frameport parity --known-good <PATCHED dir> # rebuild everything and diff against known-good APKs
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -27,11 +30,19 @@ from .patches import base
|
||||
app = typer.Typer(add_completion=False, no_args_is_help=True, help="Port Meta Quest games to the Steam Frame.")
|
||||
tools_app = typer.Typer(help="Manage the portable toolchain.")
|
||||
frame_app = typer.Typer(help="Find and connect to a Steam Frame.")
|
||||
pc_app = typer.Typer(help="This PC as a target for Oculus Rift (PC VR) games via Revive.")
|
||||
app.add_typer(tools_app, name="tools")
|
||||
app.add_typer(frame_app, name="frame")
|
||||
app.add_typer(pc_app, name="pc")
|
||||
|
||||
|
||||
def _target(frame: Optional[str], password: Optional[str] = None):
|
||||
def _target(frame: Optional[str], password: Optional[str] = None, to: str = "frame"):
|
||||
if to == "pc":
|
||||
from .targets.pc_revive import PcReviveTarget
|
||||
|
||||
return PcReviveTarget()
|
||||
if to != "frame":
|
||||
raise typer.BadParameter("--to must be 'frame' or 'pc'")
|
||||
from .frame.connection import parse_target, saved_targets
|
||||
from .targets.frame_lepton import FrameLeptonTarget
|
||||
|
||||
@@ -69,11 +80,14 @@ def tools_status(check_latest: bool = typer.Option(False, "--latest", help="also
|
||||
|
||||
|
||||
@tools_app.command("install")
|
||||
def tools_install(update: bool = typer.Option(False, help="update to the newest versions")):
|
||||
def tools_install(update: bool = typer.Option(False, help="update to the newest versions"),
|
||||
revive: bool = typer.Option(False, help="also install Revive (for Oculus Rift games)")):
|
||||
from .tools import overport as ov
|
||||
from .tools import toolchain
|
||||
|
||||
for s in toolchain.ensure_all(update=update):
|
||||
for s in toolchain.ensure_all(update=update, optional=revive):
|
||||
if s.optional and not s.installed:
|
||||
continue
|
||||
typer.echo(f"{s.name:10} {s.version} {s.path}")
|
||||
from .patches import overport as op
|
||||
|
||||
@@ -110,7 +124,8 @@ def list_games():
|
||||
|
||||
|
||||
@app.command()
|
||||
def show(package: str, as_json: bool = typer.Option(False, "--json")):
|
||||
def show(package: str, as_json: bool = typer.Option(False, "--json"),
|
||||
all_: bool = typer.Option(False, "--all", help="also list patches that don't apply to this game")):
|
||||
[pkg] = _pkgs(package, False)
|
||||
g = library.game(pkg)
|
||||
if as_json:
|
||||
@@ -120,10 +135,16 @@ def show(package: str, as_json: bool = typer.Option(False, "--json")):
|
||||
typer.echo(f"{g.get('title')} ({pkg} {a['version']})\n engine {a['engine']}, XR {a['xr']}, {a['graphics']}, "
|
||||
f"ABIs {', '.join(a['abis'])}, direct VrApi: {a['direct_vrapi']}")
|
||||
typer.echo(f" status: {r['status']} recipe source: {r['source']} {r['notes']}")
|
||||
for pid, params in r["patches"].items():
|
||||
p = base.get(pid)
|
||||
typer.echo(f" [x] {pid:36} {p.title}{' ' + json.dumps(params) if params else ''}"
|
||||
f"{' — ' + r['reasons'][pid] if pid in r.get('reasons', {}) else ''}")
|
||||
from .recommend.engine import visible_patches
|
||||
|
||||
shown, hidden = visible_patches(library.analysis_from_dict(a), library.recipe_from_dict(r))
|
||||
for p in (shown + hidden if all_ else shown):
|
||||
on = p.id in r["patches"]
|
||||
params = r["patches"].get(p.id) or {}
|
||||
typer.echo(f" [{'x' if on else ' '}] {p.id:36} {p.title}{' ' + json.dumps(params) if params else ''}"
|
||||
f"{' — ' + r['reasons'][p.id] if on and p.id in r.get('reasons', {}) else ''}")
|
||||
if hidden and not all_:
|
||||
typer.echo(f" ({len(hidden)} patches hidden as not relevant for this game; --all to list them)")
|
||||
if r.get("alt_patches"):
|
||||
typer.echo(f" alternate build adds: {', '.join(r['alt_patches'])} (installed: {'alt' if r['use_alt'] else 'primary'})")
|
||||
|
||||
@@ -139,7 +160,9 @@ def patches():
|
||||
@app.command()
|
||||
def recipe(package: str, enable: list[str] = typer.Option([], "--enable"), disable: list[str] = typer.Option([], "--disable"),
|
||||
set_: list[str] = typer.Option([], "--set", help="adapter setting key=value"),
|
||||
use_alt: Optional[bool] = typer.Option(None, "--use-alt/--no-alt"), reset: bool = False):
|
||||
use_alt: Optional[bool] = typer.Option(None, "--use-alt/--no-alt"), reset: bool = False,
|
||||
as_is: Optional[bool] = typer.Option(None, "--as-is/--patch", help="install unchanged (already patched)"),
|
||||
exe: Optional[str] = typer.Option(None, help="Rift games: the program that starts the game (relative)")):
|
||||
"""Change a game's patch selection."""
|
||||
[pkg] = _pkgs(package, False)
|
||||
r = pipeline.reset_recipe(pkg) if reset else library.recipe_from_dict(library.game(pkg)["recipe"])
|
||||
@@ -154,7 +177,14 @@ def recipe(package: str, enable: list[str] = typer.Option([], "--enable"), disab
|
||||
r.patches[f"adapter.{k}"] = {"value": float(v) if "." in v else int(v)}
|
||||
if use_alt is not None:
|
||||
r.use_alt = use_alt
|
||||
r.source = "user" if (enable or disable or set_ or use_alt is not None) else r.source
|
||||
if as_is is not None:
|
||||
r.as_is = as_is
|
||||
if library.game(pkg).get("kind") == "rift":
|
||||
r.patches.pop("pcvr.revive", None) if as_is else r.patches.setdefault("pcvr.revive", {})
|
||||
if exe:
|
||||
pipeline.set_exe(pkg, exe)
|
||||
r = library.recipe_from_dict(library.game(pkg)["recipe"])
|
||||
r.source = "user" if (enable or disable or set_ or use_alt is not None or as_is is not None) else r.source
|
||||
pipeline.set_recipe(pkg, r)
|
||||
show(pkg)
|
||||
|
||||
@@ -169,7 +199,7 @@ def build(package: Optional[str] = typer.Argument(None), all_: bool = typer.Opti
|
||||
rep = printing_reporter(verbose)
|
||||
try:
|
||||
info = pipeline.build_game(pkg, rep, outdir / pkg if outdir and all_ else outdir)
|
||||
typer.echo(f"{pkg}: {'OK' if info['ok'] else 'CHECKS FAILED'} -> {info['apk']}")
|
||||
typer.echo(f"{pkg}: {'OK' if info['ok'] else 'CHECKS FAILED'} -> {info.get('apk') or 'ready (Rift game)'}")
|
||||
if not info["ok"]:
|
||||
failed.append(pkg)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -182,8 +212,9 @@ def build(package: Optional[str] = typer.Argument(None), all_: bool = typer.Opti
|
||||
def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all"),
|
||||
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")):
|
||||
target = _target(frame, password)
|
||||
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)")):
|
||||
target = _target(frame, password, to)
|
||||
pkgs = _pkgs(package, all_)
|
||||
for pkg in pkgs:
|
||||
pipeline.install_game(pkg, target, printing_reporter(False), apk_only, add_to_library=False)
|
||||
@@ -193,9 +224,10 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
|
||||
|
||||
@app.command()
|
||||
def test(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all"),
|
||||
frame: Optional[str] = None, seconds: int = 45):
|
||||
"""Headless launch on the Frame + log triage."""
|
||||
target = _target(frame)
|
||||
frame: Optional[str] = None, seconds: int = 45,
|
||||
to: str = typer.Option("frame", help="frame, or pc (Rift games installed on this PC)")):
|
||||
"""Headless launch on the Frame (or this PC) + log triage."""
|
||||
target = _target(frame, to=to)
|
||||
installed = {g["package"] for g in target.installed()}
|
||||
for pkg in _pkgs(package, all_):
|
||||
if pkg not in installed:
|
||||
@@ -283,6 +315,53 @@ def frame_info(frame: Optional[str] = None):
|
||||
typer.echo(json.dumps(_target(frame).describe(), indent=1, default=str))
|
||||
|
||||
|
||||
@frame_app.command("proton")
|
||||
def frame_proton(frame: Optional[str] = None,
|
||||
install_: bool = typer.Option(False, "--install", help="install it (Steam on the Frame restarts once "
|
||||
"and downloads it; waits until done)"),
|
||||
in_headset: bool = typer.Option(False, help="with --install: only ask Steam (confirm in the headset)"),
|
||||
test: bool = typer.Option(False, "--test", help="run a Windows program under Proton on the Frame"),
|
||||
tool: Optional[str] = typer.Option(None, help="compat tool name, e.g. proton-experimental-arm64")):
|
||||
"""Proton (ARM64) on the Frame, needed for Oculus Rift (PC VR) games."""
|
||||
t = _target(frame)
|
||||
if test:
|
||||
r = t.proton_selftest(tool)
|
||||
r.pop("log_tail", None)
|
||||
typer.echo(json.dumps(r, indent=1))
|
||||
raise typer.Exit(0 if r.get("ran") else 1)
|
||||
if install_ and in_headset:
|
||||
typer.echo(json.dumps(t.install_proton(False, tool), indent=1))
|
||||
return
|
||||
if install_:
|
||||
from .install.installer import ensure_proton
|
||||
|
||||
ready = ensure_proton(t.frame, printing_reporter(False), tool)
|
||||
typer.echo(f"ready: {ready['display_name']}")
|
||||
return
|
||||
st = t.proton_status(tool)
|
||||
for p in st["tools"]:
|
||||
state = "installed" if p["installed"] and p["require_installed"] else \
|
||||
"needs runtime" if p["installed"] else "not installed"
|
||||
typer.echo(f"{p['name']:28} {p['display_name']:32} {state}")
|
||||
typer.echo(f"ready: {st['ready']['name'] if st.get('ready') else 'no (frameport frame proton --install)'}")
|
||||
typer.echo(f"OpenXR runtime: {(st.get('openxr') or {}).get('name') or 'none'}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ pc (Revive)
|
||||
@pc_app.command("info")
|
||||
def pc_info():
|
||||
typer.echo(json.dumps(_target(None, to="pc").describe(), indent=1, default=str))
|
||||
|
||||
|
||||
@pc_app.command("install-revive")
|
||||
def pc_install_revive():
|
||||
"""Download Revive's latest release and unpack it into FramePort's tools (no installer, no admin rights)."""
|
||||
from .tools import revive
|
||||
|
||||
path = revive.install(lambda f: None)
|
||||
typer.echo(f"Revive {revive.installed_version()} at {path}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ parity
|
||||
@app.command()
|
||||
def parity(known_good: Path = typer.Option(..., help="folder of known-good game folders (PATCHED layout)"),
|
||||
@@ -316,6 +395,32 @@ def parity_device(results: Path = typer.Option(Path("parity-out/parity.json")),
|
||||
raise typer.Exit(0 if ok else 1)
|
||||
|
||||
|
||||
@app.command("uninstall-app")
|
||||
def uninstall_app(frame: bool = typer.Option(False, help="also remove FramePort's games and files from the Frame"),
|
||||
keep_frame_saves: bool = typer.Option(True, help="with --frame: keep game saves on the Frame"),
|
||||
backup_keys: Optional[Path] = typer.Option(None, help="folder for a zip of the signing keys "
|
||||
"(default: your Documents folder)"),
|
||||
backup: bool = typer.Option(True, "--backup/--no-backup", help="back up the signing keys first"),
|
||||
yes: bool = typer.Option(False, "--yes", "-y", help="don't ask for confirmation")):
|
||||
"""Remove everything FramePort created on this PC (and optionally on the Frame). Then delete the app itself."""
|
||||
from . import uninstall as un
|
||||
|
||||
target = _target(None) if frame else None
|
||||
info = target.describe() if target else None
|
||||
pl = un.plan(info)
|
||||
typer.echo("This removes:")
|
||||
for it in pl.items:
|
||||
typer.echo(f" - {it.what}" + (f" ({it.path})" if it.path else "") +
|
||||
(f" {it.size / 2**30:.1f} GiB" if it.size > 2**28 else ""))
|
||||
dest = (backup_keys or un.default_backup_dir()) if backup else None
|
||||
typer.echo(f"Signing keys: {len(pl.keys)} " + ("(not backed up!)" if dest is None else f"→ backup zip in {dest}"))
|
||||
if not yes and not typer.confirm("Uninstall FramePort?", default=False):
|
||||
raise typer.Exit(1)
|
||||
out = un.run(printing_reporter(False), target.frame if target else None, keep_frame_saves, dest, frame)
|
||||
typer.echo("Done." + (f" Keys backup: {out['backup']}" if out.get("backup") else "") +
|
||||
" Delete the FramePort program folder to finish (or `uv tool uninstall frameport`).")
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
app()
|
||||
|
||||
|
||||
@@ -19,9 +19,91 @@ def _path() -> Path:
|
||||
|
||||
def load() -> dict:
|
||||
try:
|
||||
return json.loads(_path().read_text(encoding="utf-8"))
|
||||
data = json.loads(_path().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {"games": {}, "settings": {}}
|
||||
if _migrate(data):
|
||||
save(data)
|
||||
return data
|
||||
|
||||
|
||||
def _migrate(data: dict) -> bool:
|
||||
"""One-time updates of stored recipes when a new default is introduced (each runs once per library)."""
|
||||
done = data.setdefault("settings", {}).setdefault("migrations", [])
|
||||
changed = False
|
||||
if "no_crash_reporter" not in done:
|
||||
# the Unreal crash reporter is disabled by default for PC VR Unreal games, also those added before
|
||||
for g in (data.get("games") or {}).values():
|
||||
a, r = g.get("analysis") or {}, g.get("recipe")
|
||||
if isinstance(r, dict) and (a.get("extra") or {}).get("kind") == "rift" and a.get("engine") == "Unreal":
|
||||
r.setdefault("patches", {}).setdefault("pcvr.no_crash_reporter", {})
|
||||
r.setdefault("reasons", {}).setdefault(
|
||||
"pcvr.no_crash_reporter", "Unreal game: close on a crash instead of showing the crash reporter.")
|
||||
done.append("no_crash_reporter")
|
||||
changed = True
|
||||
if "xr_compat_layer" not in done:
|
||||
# the Frame OpenXR layer is needed by every PC VR game on the Frame (OpenXR 1.1 → 1.0 fallback)
|
||||
for g in (data.get("games") or {}).values():
|
||||
a, r = g.get("analysis") or {}, g.get("recipe")
|
||||
if isinstance(r, dict) and (a.get("extra") or {}).get("kind") == "rift":
|
||||
r.setdefault("patches", {}).setdefault("pcvr.xr_timefix", {})
|
||||
r.setdefault("reasons", {}).setdefault(
|
||||
"pcvr.xr_timefix", "The Frame's OpenXR runtime rejects Proton's OpenXR 1.1 request without it.")
|
||||
done.append("xr_compat_layer")
|
||||
changed = True
|
||||
if "oculus_unreal" not in done:
|
||||
# the Oculus detection patch is on by default for Unreal PC VR games that use LibOVR, also those added before
|
||||
for g in (data.get("games") or {}).values():
|
||||
a, r = g.get("analysis") or {}, g.get("recipe")
|
||||
extra = a.get("extra") or {}
|
||||
if (isinstance(r, dict) and extra.get("kind") == "rift" and a.get("engine") == "Unreal"
|
||||
and not extra.get("openxr_native")):
|
||||
r.setdefault("patches", {}).setdefault("pcvr.oculus_unreal", {})
|
||||
r.setdefault("reasons", {}).setdefault(
|
||||
"pcvr.oculus_unreal", "Unreal game: its Oculus plugin checks for the Oculus service before it starts VR.")
|
||||
done.append("oculus_unreal")
|
||||
changed = True
|
||||
if "rift_revive_correct" not in done:
|
||||
# Correct the earlier churn: re-derive every Rift recipe from the engine defaults (Oculus games get Revive for
|
||||
# VR; OpenXR/SteamVR-native and catalog games keep their own settings). The dump is never modified (as_is).
|
||||
from ..recommend import engine
|
||||
|
||||
for pkg, g in (data.get("games") or {}).items():
|
||||
a = g.get("analysis") or {}
|
||||
if not (isinstance(g.get("recipe"), dict) and (a.get("extra") or {}).get("kind") == "rift"):
|
||||
continue
|
||||
try:
|
||||
rec = engine.suggest(analysis_from_dict(a))
|
||||
except Exception: # noqa: BLE001 - a malformed entry keeps its recipe
|
||||
continue
|
||||
g["recipe"] = recipe_to_dict(rec)
|
||||
done.append("rift_revive_correct")
|
||||
changed = True
|
||||
if "rift_frame_native" not in done:
|
||||
# Re-analyze Rift games so existing entries pick up openvr_native/frame_native (honest Frame vs PC routing),
|
||||
# then re-derive their recipes. Needs the game files; entries without a readable game_dir keep what they have.
|
||||
from ..analysis import rift
|
||||
from ..recommend import engine
|
||||
|
||||
for pkg, g in (data.get("games") or {}).items():
|
||||
a = g.get("analysis") or {}
|
||||
if not (isinstance(g.get("recipe"), dict) and (a.get("extra") or {}).get("kind") == "rift"):
|
||||
continue
|
||||
gd = g.get("game_dir") or (a.get("extra") or {}).get("folder")
|
||||
try:
|
||||
if gd and Path(gd).is_dir():
|
||||
an = rift.analyze(Path(gd), exe=(a.get("extra") or {}).get("exe"))
|
||||
g["analysis"] = asdict(an)
|
||||
else: # no game files: derive frame_native from the old analysis flags (unknown -> Oculus/PC)
|
||||
x = a.setdefault("extra", {})
|
||||
x.setdefault("frame_native", bool(x.get("openxr_native") or x.get("openvr_native")))
|
||||
an = analysis_from_dict(a)
|
||||
g["recipe"] = recipe_to_dict(engine.suggest(an))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
done.append("rift_frame_native")
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def save(data: dict) -> None:
|
||||
@@ -72,4 +154,16 @@ def recipe_from_dict(d: dict) -> Recipe:
|
||||
|
||||
|
||||
def analysis_from_dict(d: dict) -> Analysis:
|
||||
return Analysis(**{k: v for k, v in d.items() if k in Analysis.__dataclass_fields__})
|
||||
"""Build an Analysis, filling in defaults for any field a partial/legacy entry is missing (so old library data
|
||||
still works with newer code)."""
|
||||
import dataclasses
|
||||
|
||||
kwargs = {}
|
||||
for f in dataclasses.fields(Analysis):
|
||||
if f.name in d:
|
||||
kwargs[f.name] = d[f.name]
|
||||
elif f.default is not dataclasses.MISSING or f.default_factory is not dataclasses.MISSING: # type: ignore[misc]
|
||||
continue # let the dataclass default apply
|
||||
else:
|
||||
kwargs[f.name] = "" if f.type == "str" else [] if "list" in str(f.type) else 0 if f.type == "int" else False if f.type == "bool" else None
|
||||
return Analysis(**kwargs)
|
||||
@@ -69,6 +69,7 @@ class Recipe:
|
||||
status: str = "unknown" # works | issues | unsupported | unknown
|
||||
notes: str = ""
|
||||
source: str = "heuristics" # catalog | heuristics | user
|
||||
as_is: bool = False # install the game unchanged (already patched): no overport / Frame fixes / Revive
|
||||
reasons: dict[str, str] = field(default_factory=dict) # patch id -> why it was suggested
|
||||
|
||||
def enabled(self, patch_id: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Display titles for games that exist in both a Quest and a Rift version (kept as separate library entries).
|
||||
|
||||
While both versions are in the library they're shown — and named in Steam — as "<Title> (Quest)" / "<Title> (Rift)".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _norm(t: str) -> str:
|
||||
from ..pipeline import _norm_title
|
||||
|
||||
return _norm_title(t)
|
||||
|
||||
|
||||
def twins(games: list[dict]) -> set[str]:
|
||||
"""Packages that have a counterpart of the other platform in the library (Quest ⟷ Rift version of one game)."""
|
||||
by_pkg = {g["package"]: g for g in games}
|
||||
quest_by_title = {}
|
||||
for g in games:
|
||||
if g.get("kind") != "rift":
|
||||
quest_by_title.setdefault(_norm(g.get("title") or ""), []).append(g["package"])
|
||||
out = set()
|
||||
for g in games:
|
||||
if g.get("kind") != "rift":
|
||||
continue
|
||||
mates = set(quest_by_title.get(_norm(g.get("title") or ""), []))
|
||||
if g.get("quest_package") in by_pkg:
|
||||
mates.add(g["quest_package"])
|
||||
if mates:
|
||||
out |= mates | {g["package"]}
|
||||
return out
|
||||
|
||||
|
||||
def display_title(game: dict, twin_set: set[str] | None = None) -> str:
|
||||
"""The title shown everywhere (cards, pages, jobs, Steam shortcut): "<Title> (Quest)" / "(Rift)" while both
|
||||
versions of a game are in the library."""
|
||||
title = game.get("title") or game["package"]
|
||||
if twin_set is not None and game["package"] in twin_set:
|
||||
return f"{title} ({'Rift' if game.get('kind') == 'rift' else 'Quest'})"
|
||||
return title
|
||||
|
||||
|
||||
def counterparts(game: dict, games: list[dict]) -> list[dict]:
|
||||
"""The other-platform versions of this game in the library."""
|
||||
rift = game.get("kind") == "rift"
|
||||
t = _norm(game.get("title") or "")
|
||||
out = []
|
||||
for g in games:
|
||||
if g["package"] == game["package"] or (g.get("kind") == "rift") == rift:
|
||||
continue
|
||||
linked = (rift and game.get("quest_package") == g["package"]) or \
|
||||
(not rift and g.get("quest_package") == game["package"])
|
||||
if linked or (t and _norm(g.get("title") or "") == t):
|
||||
out.append(g)
|
||||
return out
|
||||
@@ -0,0 +1,225 @@
|
||||
"""The Windows PC FramePort runs on: native Windows, or WSL (Windows programs via interop, C: at /mnt/c).
|
||||
|
||||
Used by the PC VR (Revive) target: finding Windows Steam, its active user, SteamVR, and starting/stopping Windows
|
||||
processes. Everything is discovered (registry, loginusers.vdf, libraryfolders.vdf); nothing about the user is stored.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
STEAMID64_BASE = 76561197960265728
|
||||
STEAMVR_APPID = "250820"
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def is_wsl() -> bool:
|
||||
if sys.platform != "linux":
|
||||
return False
|
||||
try:
|
||||
return "microsoft" in Path("/proc/version").read_text().lower()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return is_windows() or is_wsl()
|
||||
|
||||
|
||||
def _no_window() -> dict:
|
||||
return {"creationflags": 0x08000000} if is_windows() else {} # CREATE_NO_WINDOW
|
||||
|
||||
|
||||
def system32(exe: str) -> str:
|
||||
"""Path to a Windows system tool (tasklist.exe, reg.exe, ...) callable from here."""
|
||||
if is_windows():
|
||||
return exe
|
||||
for root in ("/mnt/c/Windows/System32", "/mnt/c/WINDOWS/system32"):
|
||||
if Path(root, exe).exists():
|
||||
return str(Path(root, exe))
|
||||
return shutil.which(exe) or exe
|
||||
|
||||
|
||||
def run_win(args: list[str], timeout: float = 30) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(args, capture_output=True, text=True, errors="replace", timeout=timeout, **_no_window())
|
||||
|
||||
|
||||
def to_windows(path: Path | str) -> str:
|
||||
"""Local path -> Windows path (D:\\Games\\X). Identity on Windows."""
|
||||
if is_windows():
|
||||
return str(Path(path).resolve())
|
||||
p = str(Path(path).resolve())
|
||||
m = re.match(r"^/mnt/([a-zA-Z])(/.*)?$", p)
|
||||
if m:
|
||||
return f"{m[1].upper()}:" + (m[2] or "/").replace("/", "\\")
|
||||
r = subprocess.run(["wslpath", "-w", p], capture_output=True, text=True)
|
||||
if r.returncode:
|
||||
raise ValueError(f"{p} is not reachable from Windows")
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def to_local(winpath: str) -> Path:
|
||||
"""Windows path -> local path (/mnt/d/... on WSL)."""
|
||||
if is_windows():
|
||||
return Path(winpath)
|
||||
m = re.match(r"^([a-zA-Z]):[\\/](.*)$", winpath.strip())
|
||||
if m:
|
||||
return Path(f"/mnt/{m[1].lower()}") / m[2].replace("\\", "/")
|
||||
r = subprocess.run(["wslpath", "-u", winpath], capture_output=True, text=True)
|
||||
return Path(r.stdout.strip())
|
||||
|
||||
|
||||
def reg_query(key: str, value: str) -> str | None:
|
||||
"""A registry value ("" = the key's default value). 64-bit view first, then the 32-bit one."""
|
||||
if is_windows():
|
||||
import winreg
|
||||
|
||||
hive, _, sub = key.partition("\\")
|
||||
root = {"HKCU": winreg.HKEY_CURRENT_USER, "HKLM": winreg.HKEY_LOCAL_MACHINE}[hive]
|
||||
for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
|
||||
try:
|
||||
with winreg.OpenKey(root, sub, 0, winreg.KEY_READ | view) as k:
|
||||
return str(winreg.QueryValueEx(k, value)[0])
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
for view in ("/reg:64", "/reg:32"):
|
||||
try:
|
||||
r = run_win([system32("reg.exe"), "query", key, *(["/ve"] if value == "" else ["/v", value]), view])
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
name = r"\(Default\)|\(Standard\)|\S*" if value == "" else re.escape(value) # "(Default)" is localized
|
||||
m = re.search(rf"^\s*(?:{name})\s+REG_\w+\s+(.+?)\s*$", r.stdout, re.M)
|
||||
if m:
|
||||
return m[1]
|
||||
return None
|
||||
|
||||
|
||||
def env_path(name: str) -> Path | None:
|
||||
"""%LOCALAPPDATA% etc. of the Windows user, as a local path."""
|
||||
if is_windows():
|
||||
v = os.environ.get(name)
|
||||
return Path(v) if v else None
|
||||
try:
|
||||
r = run_win([system32("cmd.exe"), "/c", f"echo %{name}%"], timeout=15)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
v = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else ""
|
||||
return to_local(v) if v and "%" not in v else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Steam
|
||||
def steam_root() -> Path | None:
|
||||
candidates = []
|
||||
v = reg_query(r"HKCU\Software\Valve\Steam", "SteamPath")
|
||||
if v:
|
||||
candidates.append(to_local(v.replace("/", "\\")))
|
||||
candidates += [to_local(r"C:\Program Files (x86)\Steam"), to_local(r"C:\Program Files\Steam")]
|
||||
return next((c for c in candidates if (c / "steam.exe").exists()), None)
|
||||
|
||||
|
||||
def steam_libraries(root: Path) -> list[Path]:
|
||||
libs = [root / "steamapps"]
|
||||
try:
|
||||
text = (root / "steamapps/libraryfolders.vdf").read_text(encoding="utf-8", errors="replace")
|
||||
for p in re.findall(r'"path"\s+"([^"]+)"', text):
|
||||
lib = to_local(p.replace("\\\\", "\\")) / "steamapps"
|
||||
if lib not in libs and lib.is_dir():
|
||||
libs.append(lib)
|
||||
except OSError:
|
||||
pass
|
||||
return libs
|
||||
|
||||
|
||||
def app_installed(root: Path, appid: str) -> bool:
|
||||
return any((lib / f"appmanifest_{appid}.acf").exists() for lib in steam_libraries(root))
|
||||
|
||||
|
||||
def steam_user(root: Path) -> str | None:
|
||||
"""userdata folder name (account id) of the most recent Steam login; the only one if there's just one."""
|
||||
users = sorted(p.name for p in (root / "userdata").glob("*") if p.name.isdigit() and p.name != "0")
|
||||
try:
|
||||
text = (root / "config/loginusers.vdf").read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
text = ""
|
||||
for sid, body in re.findall(r'"(\d{17})"\s*\{([^}]*)\}', text):
|
||||
if re.search(r'"MostRecent"\s+"1"', body):
|
||||
acct = str(int(sid) - STEAMID64_BASE)
|
||||
if acct in users:
|
||||
return acct
|
||||
return users[0] if len(users) == 1 else None
|
||||
|
||||
|
||||
def process_running(image: str) -> bool:
|
||||
try:
|
||||
r = run_win([system32("tasklist.exe"), "/FI", f"IMAGENAME eq {image}", "/NH"], timeout=20)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return image.lower() in r.stdout.lower()
|
||||
|
||||
|
||||
def kill(image: str) -> None:
|
||||
try:
|
||||
run_win([system32("taskkill.exe"), "/IM", image, "/F", "/T"], timeout=20)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
def start_detached(exe: Path, args: list[str] = (), cwd: Path | None = None) -> None:
|
||||
"""Start a Windows program without waiting (Steam, the Revive injector)."""
|
||||
if is_windows():
|
||||
subprocess.Popen([str(exe), *args], cwd=cwd, close_fds=True,
|
||||
creationflags=0x00000008 | 0x00000200) # DETACHED_PROCESS | NEW_PROCESS_GROUP
|
||||
return
|
||||
subprocess.Popen([str(exe), *args], cwd=str(cwd) if cwd else None, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, start_new_session=True)
|
||||
|
||||
|
||||
def stop_steam(root: Path, wait: float = 40) -> bool:
|
||||
"""Ask Steam to exit; True if it was running."""
|
||||
if not process_running("steam.exe"):
|
||||
return False
|
||||
start_detached(root / "steam.exe", ["-shutdown"])
|
||||
end = time.time() + wait
|
||||
while time.time() < end:
|
||||
time.sleep(1)
|
||||
if not process_running("steam.exe"):
|
||||
time.sleep(2) # let it finish writing its files
|
||||
return True
|
||||
raise RuntimeError("Steam did not close; the library was not changed")
|
||||
|
||||
|
||||
def start_steam(root: Path) -> None:
|
||||
start_detached(root / "steam.exe")
|
||||
|
||||
|
||||
def steamvr_running() -> bool:
|
||||
return process_running("vrmonitor.exe") or process_running("vrserver.exe")
|
||||
|
||||
|
||||
def start_steamvr(root: Path, wait: float = 25) -> bool:
|
||||
"""Start SteamVR (app 250820) and wait until it's up. Revive needs SteamVR running to provide VR; without it an
|
||||
Oculus game falls back to a flat window. No-op (returns True) if it's already running; returns False if SteamVR
|
||||
isn't installed or didn't come up in time."""
|
||||
if steamvr_running():
|
||||
return True
|
||||
if not app_installed(root, STEAMVR_APPID):
|
||||
return False
|
||||
start_detached(root / "steam.exe", ["-applaunch", STEAMVR_APPID])
|
||||
end = time.time() + wait
|
||||
while time.time() < end:
|
||||
time.sleep(1)
|
||||
if steamvr_running():
|
||||
time.sleep(2) # let vrserver finish coming up before the game connects
|
||||
return True
|
||||
return False
|
||||
@@ -72,6 +72,19 @@ def save_target(target: FrameTarget) -> None:
|
||||
(user_data_dir() / "frames.json").write_text(json.dumps([t.__dict__ for t in items], indent=2))
|
||||
|
||||
|
||||
# direct links to the Frame, fastest first (interface → what it is)
|
||||
FAST_LINKS = {"usb0": "USB cable", "wlanap": "the Frame's own Wi-Fi hotspot"}
|
||||
|
||||
|
||||
def bundled_agent_version() -> int | None:
|
||||
"""AGENT_VERSION of the agent this app ships (it's uploaded to the Frame whenever it differs)."""
|
||||
try:
|
||||
m = re.search(r"^AGENT_VERSION = (\d+)", (agent_dir() / "frameport_agent.py").read_text(), re.M)
|
||||
return int(m.group(1)) if m else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
class AgentFailed(RuntimeError):
|
||||
pass
|
||||
|
||||
@@ -118,6 +131,35 @@ class Frame:
|
||||
self.home = self.run("printf %s \"$HOME\"")[1].strip() or f"/home/{self.target.user}"
|
||||
return self
|
||||
|
||||
def addresses(self) -> list[tuple[str, str]]:
|
||||
"""(interface, IPv4) of every link the Frame has up."""
|
||||
out = self.run("ip -4 -o addr show up 2>/dev/null")[1]
|
||||
return re.findall(r"^\d+:\s+(\S+)\s+inet\s+([\d.]+)/", out, re.M)
|
||||
|
||||
def fast_link(self, timeout: float = 1.5) -> tuple["Frame", str]:
|
||||
"""A second connection over the fastest direct link, for bulk uploads: the USB cable (usb0) or the Frame's own
|
||||
hotspot (wlanap; the PC joins it with a Wi-Fi adapter, e.g. Valve's USB dongle) — several times faster than
|
||||
both going through the home router (measured 83-97 vs 15-18 MB/s). Used only if this PC can reach it and it
|
||||
presents the same SSH host key as this connection. Returns (frame, link name); (self, "") if none."""
|
||||
import socket
|
||||
|
||||
key = self.client.get_transport().get_remote_server_key()
|
||||
for iface in FAST_LINKS:
|
||||
for name, ip in self.addresses():
|
||||
if name != iface or ip == self.target.host:
|
||||
continue
|
||||
try:
|
||||
socket.create_connection((ip, self.target.port), timeout=timeout).close()
|
||||
other = Frame(FrameTarget(ip, self.target.user, self.target.port), self.password)
|
||||
other.connect(timeout=5)
|
||||
except (OSError, ConnectionError, paramiko.SSHException):
|
||||
continue
|
||||
if other.client.get_transport().get_remote_server_key() != key: # not our Frame: don't use it
|
||||
other.close()
|
||||
continue
|
||||
return other, f"{FAST_LINKS[iface]} ({ip})"
|
||||
return self, ""
|
||||
|
||||
def close(self) -> None:
|
||||
if self._sftp:
|
||||
self._sftp.close()
|
||||
@@ -175,12 +217,20 @@ class Frame:
|
||||
return reply["result"]
|
||||
|
||||
# ------------------------------------------------------------------ files
|
||||
def put(self, local: Path, remote: str, progress=None, resume: bool = True) -> None:
|
||||
"""Upload with resume: data goes to <remote>.part, appended from the existing size, then renamed."""
|
||||
def mkdirs(self, dirs) -> None:
|
||||
"""Create many remote folders with one command."""
|
||||
dirs = sorted(set(dirs))
|
||||
if dirs:
|
||||
self.run("xargs -0 mkdir -p", stdin="\0".join(dirs))
|
||||
|
||||
def put(self, local: Path, remote: str, progress=None, resume: bool = True, mkdir: bool = True) -> None:
|
||||
"""Upload with resume: data goes to <remote>.part, appended from the existing size, then renamed. progress may
|
||||
raise (e.g. Cancelled): the .part stays and the next put continues from there."""
|
||||
local = Path(local)
|
||||
size = local.stat().st_size
|
||||
part = remote + ".part"
|
||||
self.run(f"mkdir -p {sh_quote(posixpath.dirname(remote))}")
|
||||
if mkdir:
|
||||
self.run(f"mkdir -p {sh_quote(posixpath.dirname(remote))}")
|
||||
offset = 0
|
||||
if resume:
|
||||
try:
|
||||
@@ -203,6 +253,31 @@ class Frame:
|
||||
progress(done, size)
|
||||
self.run(f"mv -f {sh_quote(part)} {sh_quote(remote)}")
|
||||
|
||||
def put_tar(self, files: list[tuple[Path, str]], remote_root: str, progress=None) -> None:
|
||||
"""Upload many small files in one stream (tar → `tar -x` on the Frame): far faster than one SFTP transfer per
|
||||
file. files: (local path, path relative to remote_root). progress(done_bytes, rel) may raise to stop; files that
|
||||
arrived stay (a cut-off last file has the wrong size, so it's sent again next time)."""
|
||||
import tarfile
|
||||
|
||||
stdin, stdout, stderr = self.client.exec_command(
|
||||
f"mkdir -p {sh_quote(remote_root)} && tar -xf - -C {sh_quote(remote_root)}")
|
||||
done = 0
|
||||
try:
|
||||
with tarfile.open(fileobj=stdin, mode="w|", format=tarfile.PAX_FORMAT) as tar:
|
||||
for local, rel in files:
|
||||
tar.add(str(local), arcname=rel, recursive=False)
|
||||
done += Path(local).stat().st_size
|
||||
if progress:
|
||||
progress(done, rel)
|
||||
finally:
|
||||
try:
|
||||
stdin.channel.shutdown_write()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
code = stdout.channel.recv_exit_status()
|
||||
if code:
|
||||
raise OSError(f"remote tar failed ({code}): {stderr.read().decode(errors='replace')[-300:]}")
|
||||
|
||||
def get_text(self, remote: str, max_bytes: int = 64 << 20) -> str:
|
||||
with self.sftp.open(remote, "rb") as f:
|
||||
size = f.stat().st_size
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Install a built game on a Steam Frame over SSH (via the FramePort agent)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
|
||||
import posixpath
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -59,37 +61,30 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
total = max(need, 1)
|
||||
sent = 0
|
||||
|
||||
def progress_for(label):
|
||||
def cb(done, size):
|
||||
reporter.progress((sent + done) / total, f"{label} {done / 2**20:.0f}/{size / 2**20:.0f} MiB")
|
||||
return cb
|
||||
with transfer_link(frame, reporter, need) as xfer:
|
||||
reporter.stage("Upload APK")
|
||||
if prep["same_apk"]:
|
||||
reporter.log("identical APK already installed; not re-sending")
|
||||
else:
|
||||
def apk_cb(done, size):
|
||||
reporter.check_cancel()
|
||||
reporter.progress(done / total, f"APK {done / 2**20:.0f}/{size / 2**20:.0f} MiB")
|
||||
xfer.put(plan.apk, posixpath.join(incoming, "game.apk"), apk_cb)
|
||||
sent += plan.apk.stat().st_size
|
||||
if to_send:
|
||||
reporter.stage(f"Upload data ({len(to_send)} files)")
|
||||
upload_files(xfer, [(plan.data_dir / rel, f"obb/{rel}", manifest[rel]) for rel in to_send], incoming,
|
||||
reporter, total, sent)
|
||||
elif manifest:
|
||||
reporter.log("game data already on the Frame; not re-sending")
|
||||
|
||||
reporter.stage("Upload APK")
|
||||
if prep["same_apk"]:
|
||||
reporter.log("identical APK already installed; not re-sending")
|
||||
else:
|
||||
frame.put(plan.apk, posixpath.join(incoming, "game.apk"), progress_for("APK"))
|
||||
sent += plan.apk.stat().st_size
|
||||
if to_send:
|
||||
reporter.stage(f"Upload data ({len(to_send)} files)")
|
||||
for rel in to_send:
|
||||
reporter.check_cancel()
|
||||
frame.put(plan.data_dir / rel, posixpath.join(incoming, "obb", rel), progress_for(rel))
|
||||
sent += manifest[rel]
|
||||
elif manifest:
|
||||
reporter.log("game data already on the Frame; not re-sending")
|
||||
|
||||
reporter.stage("Artwork")
|
||||
art_dir, _ = artwork.fetch(plan.package, plan.apk)
|
||||
remote_art = posixpath.join(prep["base"], "incoming-artwork")
|
||||
frame.run(f"rm -rf '{remote_art}' && mkdir -p '{remote_art}'")
|
||||
for f in artwork.files(plan.package):
|
||||
frame.put(f, posixpath.join(remote_art, f.name), resume=False)
|
||||
artwork.fetch(plan.package, plan.apk)
|
||||
upload_steam_art(frame, plan.package, prep["base"], reporter)
|
||||
|
||||
reporter.stage("Finalize install")
|
||||
ctx = install_context(plan.recipe)
|
||||
result = frame.agent(
|
||||
"finalize", package=plan.package, title=plan.title, dest=plan.dest, apk_sha256=apk_sha,
|
||||
"finalize", package=plan.package, title=plan.title, dest=plan.dest, apk_sha256=apk_sha, tags=_tags(plan.package),
|
||||
apk_name=plan.apk.name, settings=ctx.adapter_settings,
|
||||
files={k: v.decode() if isinstance(v, bytes) else v for k, v in ctx.files.items()}, env=ctx.env,
|
||||
obb_manifest=manifest or None,
|
||||
@@ -99,6 +94,235 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class PcvrPlan:
|
||||
package: str # "rift.<slug>"
|
||||
title: str
|
||||
game_dir: Path
|
||||
exe: str # relative to game_dir
|
||||
recipe: Recipe
|
||||
revive_dir: Path | None # None: launch the exe directly (OpenXR-native game)
|
||||
exe_sha256: str | None = None
|
||||
revive_version: str | None = None
|
||||
art_lookup: str | None = None # package whose store art to use
|
||||
dest: str | None = None
|
||||
|
||||
|
||||
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
"""Pack a Windows PC VR game + Revive onto the Frame, run by Proton through a generated launch.sh."""
|
||||
from ..patches.pcvr import game_args, launch_env
|
||||
from ..tools import revive as revive_tool
|
||||
|
||||
from ..core.paths import artifacts_dir
|
||||
|
||||
tool = plan.recipe.params("pcvr.proton_tool").get("tool") or None
|
||||
reporter.stage("Prepare Frame")
|
||||
prep = frame.agent("prepare_pcvr", package=plan.package, title=plan.title, dest=plan.dest, tool=tool)
|
||||
if not prep.get("proton"):
|
||||
ensure_proton(frame, reporter, tool)
|
||||
prep = frame.agent("prepare_pcvr", package=plan.package, title=plan.title, dest=plan.dest, tool=tool)
|
||||
if not prep.get("proton"):
|
||||
raise RuntimeError("Proton is still not ready on the Frame")
|
||||
reporter.check("Proton on the Frame", True, prep["proton"]["display_name"])
|
||||
if prep.get("openxr"):
|
||||
reporter.check("OpenXR runtime", True, prep["openxr"].get("name") or prep["openxr"]["path"])
|
||||
else:
|
||||
reporter.check("OpenXR runtime", None, "no active_runtime.json found; the game may not see the headset")
|
||||
manifests = {"game": local_data_manifest(plan.game_dir)}
|
||||
sources = {"game": plan.game_dir}
|
||||
if plan.revive_dir:
|
||||
root = plan.revive_dir
|
||||
manifests["revive"] = {p.relative_to(root).as_posix(): p.stat().st_size
|
||||
for p in revive_tool.runtime_files(root)}
|
||||
sources["revive"] = root
|
||||
xr_layer = "pcvr.xr_timefix" in plan.recipe.patches
|
||||
if xr_layer:
|
||||
layer = artifacts_dir() / "linux-arm64"
|
||||
manifests["xrlayer"] = {p.name: p.stat().st_size for p in sorted(layer.iterdir()) if p.is_file()}
|
||||
sources["xrlayer"] = layer
|
||||
oculus_hmd = "pcvr.oculus_unreal" in plan.recipe.patches
|
||||
if oculus_hmd: # fp_oculushmd.exe: provides the OculusHMDConnected event Unreal's Oculus plugin checks for
|
||||
helpers = artifacts_dir() / "win-x64"
|
||||
manifests["helpers"] = {p.name: p.stat().st_size for p in sorted(helpers.iterdir()) if p.is_file()}
|
||||
sources["helpers"] = helpers
|
||||
to_send = [(t, rel) for t, m in manifests.items() for rel, size in m.items()
|
||||
if prep["existing"].get(t, {}).get(rel) != size]
|
||||
need = sum(manifests[t][rel] for t, rel in to_send)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
f"have {prep['free_bytes'] / 2**30:.1f} GiB")
|
||||
total = max(need, 1)
|
||||
if to_send:
|
||||
already = sum(sum(m.values()) for m in manifests.values()) - need
|
||||
reporter.stage(f"Upload game ({len(to_send)} files, {need / 2**30:.1f} GiB)"
|
||||
+ (f" — resuming, {already / 2**30:.1f} GiB already there" if already > 0 else ""))
|
||||
with transfer_link(frame, reporter, need) as xfer:
|
||||
upload_files(xfer, [(sources[t] / rel, f"{t}/{rel}", manifests[t][rel]) for t, rel in to_send],
|
||||
prep["incoming"], reporter, total, 0)
|
||||
else:
|
||||
reporter.log("all game files already on the Frame; not re-sending")
|
||||
|
||||
from ..artwork import sources
|
||||
|
||||
if not sources.has_art(plan.package):
|
||||
artwork.fetch(plan.package, lookup=plan.art_lookup)
|
||||
upload_steam_art(frame, plan.package, prep["base"], reporter)
|
||||
|
||||
reporter.stage("Finalize install")
|
||||
result = frame.agent(
|
||||
"finalize_pcvr", package=plan.package, title=plan.title, dest=plan.dest, tool=tool, exe=plan.exe,
|
||||
tags=_tags(plan.package),
|
||||
manifests=manifests, revive=plan.revive_dir is not None, xr_layer=xr_layer, oculus_hmd=oculus_hmd,
|
||||
env=launch_env(plan.recipe),
|
||||
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
|
||||
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version,
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source},
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
|
||||
return result
|
||||
|
||||
|
||||
def ensure_proton(frame: Frame, reporter: Reporter, tool: str | None = None, timeout: float = 45 * 60) -> dict:
|
||||
"""Install Proton (ARM64) + its runtime on the Frame without user interaction: the agent writes Steam appmanifest
|
||||
stubs and restarts Steam, which downloads them. Waits until they're installed (progress from the appmanifests)."""
|
||||
import time
|
||||
|
||||
st = frame.agent("proton_status", tool=tool)
|
||||
if st.get("ready"):
|
||||
return st["ready"]
|
||||
reporter.stage("Install Proton on the Frame")
|
||||
r = frame.agent("install_proton", mode="unattended", tool=tool)
|
||||
reporter.log(r.get("hint") or f"installing {r.get('tool')}")
|
||||
end = time.time() + timeout
|
||||
last = None
|
||||
while time.time() < end:
|
||||
reporter.check_cancel()
|
||||
time.sleep(10)
|
||||
try:
|
||||
st = frame.agent("proton_status", tool=tool, timeout=60)
|
||||
except Exception: # SSH can hiccup while Steam restarts
|
||||
continue
|
||||
if st.get("ready"):
|
||||
reporter.check("Proton installed", True, st["ready"]["display_name"])
|
||||
return st["ready"]
|
||||
dl = st.get("download") or {}
|
||||
if dl.get("total"):
|
||||
reporter.progress(dl["done"] / dl["total"], f"downloading Proton {dl['done'] / 2**20:.0f}/"
|
||||
f"{dl['total'] / 2**20:.0f} MiB")
|
||||
elif dl != last:
|
||||
reporter.log("waiting for Steam to start the download…")
|
||||
last = dl
|
||||
raise RuntimeError("Proton didn't finish installing on the Frame in time; check Steam's downloads on the Frame")
|
||||
|
||||
|
||||
BIG_FILE = 8 << 20 # bigger files go one by one (resumable); smaller ones are streamed in batches
|
||||
BATCH_BYTES, BATCH_FILES = 256 << 20, 2000
|
||||
|
||||
|
||||
FAST_LINK_MIN = 64 << 20 # below this a second connection isn't worth it
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def transfer_link(frame: Frame, reporter: Reporter, need: int):
|
||||
"""The connection bulk uploads should use: a direct link (USB cable / the Frame's hotspot) when this PC has one,
|
||||
else the normal connection. Agent commands keep using `frame`; both see the same files."""
|
||||
xfer, label = (frame, "")
|
||||
if need >= FAST_LINK_MIN:
|
||||
try:
|
||||
xfer, label = frame.fast_link()
|
||||
except Exception: # noqa: BLE001 - discovery is best effort
|
||||
xfer, label = frame, ""
|
||||
reporter.check("Transfer link", True, label or f"{frame.target.host} (connect this PC to the Frame's hotspot "
|
||||
"or a USB cable for faster uploads)" if need >= FAST_LINK_MIN
|
||||
else frame.target.host)
|
||||
try:
|
||||
yield xfer
|
||||
finally:
|
||||
if xfer is not frame:
|
||||
xfer.close()
|
||||
|
||||
|
||||
def upload_files(frame: Frame, items: list[tuple[Path, str, int]], remote_root: str, reporter: Reporter,
|
||||
total: int, sent: int = 0) -> int:
|
||||
"""Upload (local, relative path, size) items under remote_root. Interruptible (Cancel, dropped connection) and
|
||||
resumable: finished files are skipped next time (the agent counts files already in incoming/), a cut-off big file
|
||||
continues from its .part. Returns bytes sent."""
|
||||
big = [i for i in items if i[2] >= BIG_FILE]
|
||||
small = [i for i in items if i[2] < BIG_FILE]
|
||||
frame.mkdirs(posixpath.dirname(posixpath.join(remote_root, rel)) for _, rel, _ in big)
|
||||
state = {"sent": sent}
|
||||
|
||||
def show(extra: int, label: str):
|
||||
reporter.check_cancel()
|
||||
reporter.progress((state["sent"] + extra) / total, label)
|
||||
for local, rel, size in big:
|
||||
reporter.check_cancel()
|
||||
frame.put(local, posixpath.join(remote_root, rel),
|
||||
lambda done, size, rel=rel: show(done, f"{rel} {done / 2**20:.0f}/{size / 2**20:.0f} MiB"),
|
||||
mkdir=False)
|
||||
state["sent"] += size
|
||||
batch: list[tuple[Path, str]] = []
|
||||
batch_bytes = 0
|
||||
|
||||
def flush():
|
||||
nonlocal batch, batch_bytes
|
||||
if batch:
|
||||
base = state["sent"]
|
||||
frame.put_tar(batch, remote_root, lambda done, rel: show(done, f"{len(batch)} files · {rel}"))
|
||||
state["sent"] = base + batch_bytes
|
||||
batch, batch_bytes = [], 0
|
||||
for local, rel, size in small:
|
||||
batch.append((local, rel))
|
||||
batch_bytes += size
|
||||
if batch_bytes >= BATCH_BYTES or len(batch) >= BATCH_FILES:
|
||||
flush()
|
||||
flush()
|
||||
return state["sent"]
|
||||
|
||||
|
||||
def _tags(package: str) -> list[str]:
|
||||
from ..artwork.steam import steam_tags
|
||||
from ..core import library
|
||||
|
||||
entry = library.game(package)
|
||||
return steam_tags(entry) if entry else []
|
||||
|
||||
|
||||
def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter) -> None:
|
||||
"""The complete Steam art set (portrait, wide, hero, logo, icon — composed where the store has no such shape)."""
|
||||
from ..artwork.steam import steam_set
|
||||
|
||||
reporter.stage("Artwork for the Steam library")
|
||||
art = steam_set(package)
|
||||
remote_art = posixpath.join(base, "incoming-artwork")
|
||||
frame.run(f"rm -rf '{remote_art}' && mkdir -p '{remote_art}'")
|
||||
for kind, f in art.items():
|
||||
frame.put(f, posixpath.join(remote_art, f"{kind}{f.suffix}"), resume=False)
|
||||
reporter.check("Steam artwork", bool(art) or None, ", ".join(sorted(art)) or "none found")
|
||||
|
||||
|
||||
def update_steam_art(frame: Frame, package: str, reporter: Reporter) -> dict:
|
||||
"""Replace an installed game's Steam library art with the current artwork (after the user picked new art):
|
||||
the art set goes to the game's anchor, then the shortcut is rewritten (Steam restarts once)."""
|
||||
from ..artwork.steam import steam_set
|
||||
|
||||
dep = next((d for d in frame.agent("list_installed")["games"] if d.get("package") == package), None)
|
||||
if not dep:
|
||||
raise RuntimeError(f"{package} isn't installed on the Frame")
|
||||
if not dep.get("anchor"):
|
||||
raise RuntimeError("the Frame's FramePort agent is too old; reinstall the game to update its art")
|
||||
art = steam_set(package)
|
||||
if not art:
|
||||
raise RuntimeError("no artwork to send")
|
||||
reporter.stage("Artwork for the Steam library")
|
||||
remote = posixpath.join(dep["anchor"], "artwork")
|
||||
frame.run(f"rm -rf '{remote}' && mkdir -p '{remote}'")
|
||||
for kind, f in art.items():
|
||||
frame.put(f, posixpath.join(remote, f"{kind}{f.suffix}"), resume=False)
|
||||
reporter.check("Steam artwork", True, ", ".join(sorted(art)))
|
||||
return add_to_steam(frame, [package], reporter)
|
||||
|
||||
|
||||
def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: float = 120) -> dict:
|
||||
"""Add library entries + artwork for installed games; Steam restarts once (a few seconds)."""
|
||||
import time
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Predicates shared by patch `applies()` / `detect()` rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..core.models import Analysis
|
||||
|
||||
|
||||
def is_unreal(a: Analysis) -> bool:
|
||||
return a.engine == "Unreal"
|
||||
|
||||
|
||||
def is_unity(a: Analysis) -> bool:
|
||||
return a.engine == "Unity"
|
||||
|
||||
|
||||
def has_vrapi(a: Analysis) -> bool:
|
||||
return "libvrapi.so" in a.libs
|
||||
|
||||
|
||||
def is_gles(a: Analysis) -> bool:
|
||||
return "GLES" in a.graphics
|
||||
|
||||
|
||||
def is_vulkan(a: Analysis) -> bool:
|
||||
return a.graphics.startswith("Vulkan")
|
||||
|
||||
|
||||
def arm64(a: Analysis) -> bool:
|
||||
return "arm64-v8a" in a.abis
|
||||
|
||||
|
||||
def meta_audio_libs(a: Analysis) -> list[str]:
|
||||
return [l for l in a.libs if l.lower().startswith(("libmetaxraudio", "libovraudio", "libaksoundengine", "libovravatar"))]
|
||||
|
||||
|
||||
def uses_scene(a: Analysis) -> bool:
|
||||
perms = a.extra.get("meta_permissions_used") or a.meta_permissions
|
||||
return any(p.endswith(("USE_SCENE", "USE_ANCHOR_API")) for p in perms)
|
||||
|
||||
|
||||
def needs_scene(a: Analysis) -> bool:
|
||||
"""Mixed-reality-only game that builds its world from the room model."""
|
||||
perms = a.extra.get("meta_permissions_used") or a.meta_permissions
|
||||
return bool(a.extra.get("mr_only")) and any(p.endswith("USE_SCENE") for p in perms)
|
||||
|
||||
|
||||
def unreal_version(a: Analysis) -> tuple[int, int] | None:
|
||||
v = a.extra.get("unreal_version")
|
||||
try:
|
||||
major, minor = v.split(".")[:2]
|
||||
return int(major), int(minor)
|
||||
except (AttributeError, ValueError):
|
||||
return None
|
||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
||||
from ..core.models import Analysis
|
||||
|
||||
STAGES = ("overport", "apk", "install")
|
||||
CATEGORIES = ("overport", "frame", "adapter", "device")
|
||||
CATEGORIES = ("overport", "frame", "adapter", "device", "pcvr")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -74,6 +74,13 @@ class Patch:
|
||||
def detect(self, analysis: "Analysis") -> Suggestion | None:
|
||||
return Suggestion(True, "Recommended for every game.") if self.default_on else None
|
||||
|
||||
def applies(self, analysis: "Analysis") -> bool:
|
||||
"""False when the patch can't matter for this game (wrong engine, no VrApi, ...). The UI hides such patches
|
||||
(they stay in the recipe if they are defaults, where they are no-ops)."""
|
||||
return True
|
||||
|
||||
not_applicable_reason: str = ""
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool: # apk stage; returns True when something changed
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -110,6 +117,12 @@ def get(patch_id: str) -> Patch:
|
||||
raise KeyError(f"unknown patch {patch_id!r}") from None
|
||||
|
||||
|
||||
def for_game(patch: Patch, analysis: "Analysis") -> bool:
|
||||
"""Quest patches only for Quest games, PC VR (Revive) patches only for Rift games."""
|
||||
rift = (analysis.extra or {}).get("kind") == "rift"
|
||||
return (patch.category == "pcvr") == rift
|
||||
|
||||
|
||||
def all_patches() -> list[Patch]:
|
||||
load_all()
|
||||
return sorted(REGISTRY.values(), key=lambda p: (CATEGORIES.index(p.category), p.order, p.id))
|
||||
@@ -126,7 +139,7 @@ def load_all() -> None:
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
from . import frame, overport, settings # noqa: F401 (registration side effects)
|
||||
from . import frame, overport, pcvr, settings # noqa: F401 (registration side effects)
|
||||
|
||||
for mod in pkgutil.iter_modules(frame.__path__):
|
||||
importlib.import_module(f"{frame.__name__}.{mod.name}")
|
||||
|
||||
@@ -35,9 +35,27 @@ class NoDebuggable(Patch):
|
||||
id = "frame.nodebug"
|
||||
title = "Clear android:debuggable"
|
||||
description = ("overport marks apps debuggable; that turns on CheckJNI, which aborts some Unreal games on sloppy "
|
||||
"JNI calls (NOPE Challenge, Time Stall: 'GetStringUTFChars ... NULL'). Clear it for those.")
|
||||
"JNI calls ('JNI DETECTED ERROR', e.g. 'GetStringUTFChars ... NULL' in Time Stall). Clear it for "
|
||||
"those.")
|
||||
order = 21
|
||||
|
||||
def detect(self, a):
|
||||
from ..applicability import is_unreal, unreal_version
|
||||
|
||||
if not is_unreal(a):
|
||||
return None
|
||||
v = unreal_version(a)
|
||||
if v and v < (4, 22):
|
||||
return Suggestion(True, f"Unreal Engine {v[0]}.{v[1]}: older UE4 makes JNI calls CheckJNI rejects "
|
||||
"(e.g. Time Stall aborted on GetStringUTFChars(NULL)).")
|
||||
if any(l.startswith("libmetaxraudio") for l in a.libs):
|
||||
return Suggestion(True, "Unreal build of Meta XR Audio: its telemetry lookup leaves a pending JNI exception "
|
||||
"that CheckJNI turns into an abort (e.g. NOPE Challenge).")
|
||||
return Suggestion(False, "Enable if the game aborts with 'JNI DETECTED ERROR' (CheckJNI).")
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine in ("Unreal", "Other", "CryEngine")
|
||||
|
||||
def apply(self, ctx):
|
||||
fixed = axml.set_bool_attr(ctx.ws.read(MANIFEST), "application", "debuggable", False)
|
||||
if fixed:
|
||||
@@ -49,16 +67,24 @@ class MetaPermissions(Patch):
|
||||
id = "frame.meta_permissions"
|
||||
title = "Declare Meta-only permissions"
|
||||
description = ("Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants "
|
||||
"them at install (e.g. Demeter's 'use spatial data' check).")
|
||||
"them at install (e.g. the 'use spatial data' permission of mixed-reality games like Demeter).")
|
||||
order = 22
|
||||
|
||||
def detect(self, a):
|
||||
from ..applicability import needs_scene
|
||||
|
||||
if needs_scene(a):
|
||||
return Suggestion(True, "Mixed-reality game that needs the room model: its 'use spatial data' permission must "
|
||||
"be granted (e.g. Demeter).")
|
||||
scene = [p for p in a.meta_permissions if any(k in p for k in ("SCENE", "ANCHOR", "SPATIAL", "BOUNDARY"))]
|
||||
if scene:
|
||||
return Suggestion(False, "Uses Meta scene/anchor permissions (" + ", ".join(p.rsplit(".", 1)[-1] for p in scene[:3])
|
||||
+ "); enable if the game says it needs spatial data access.")
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
return bool(a.meta_permissions or a.extra.get("meta_permissions_used"))
|
||||
|
||||
def apply(self, ctx):
|
||||
fixed = axml.define_meta_permissions(ctx.ws.read(MANIFEST))
|
||||
if fixed:
|
||||
|
||||
@@ -25,7 +25,7 @@ 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 (The Climb 2).")
|
||||
"loader lacks it (e.g. The Climb 2).")
|
||||
order = 30
|
||||
default_on = True
|
||||
|
||||
@@ -47,7 +47,7 @@ class OvrStubs(Patch):
|
||||
id = "frame.ovrstubs"
|
||||
title = "Stub missing Meta platform functions"
|
||||
description = ("Generates no-op stubs for ovr_* functions the game imports but overport's platform loader "
|
||||
"lacks (Espire 1/2, Wallace & Gromit, The Climb 2). Symptom: UnsatisfiedLinkError / "
|
||||
"lacks (e.g. Espire 1/2, Wallace & Gromit, The Climb 2). Symptom: UnsatisfiedLinkError / "
|
||||
"'cannot locate symbol ovr_...'. Online/store features stay unavailable.")
|
||||
order = 31
|
||||
default_on = True
|
||||
|
||||
@@ -53,7 +53,8 @@ class MetaXrTelemetry(Patch):
|
||||
id = "frame.metaxr_telemetry"
|
||||
title = "Meta XR Audio: skip Quest telemetry class"
|
||||
description = ("Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on "
|
||||
"non-Quest devices. Patches that one JNI FindClass call so the library continues (NOPE Challenge).")
|
||||
"non-Quest devices. Patches that one JNI FindClass call so the library continues (e.g. NOPE "
|
||||
"Challenge).")
|
||||
order = 70
|
||||
default_on = True
|
||||
|
||||
@@ -62,6 +63,9 @@ class MetaXrTelemetry(Patch):
|
||||
return Suggestion(True, "Meta XR Audio present; patched automatically if it has the telemetry lookup.")
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unreal" and any(l.lower().startswith("libmetaxraudio") for l in a.libs)
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
changed = False
|
||||
for lib in ctx.ws.libs():
|
||||
@@ -77,15 +81,26 @@ class OculusOsStubs(Patch):
|
||||
id = "frame.oculusos"
|
||||
title = "Oculus OS telemetry class stubs"
|
||||
description = ("Adds no-op com.oculus.os.AnalyticsEvent / UnifiedTelemetryLogger classes for native code that "
|
||||
"looks them up through the app class loader (Nano, NOPE Challenge).")
|
||||
"looks them up through the app class loader (e.g. Nano, NOPE Challenge).")
|
||||
order = 71
|
||||
|
||||
def detect(self, a):
|
||||
refs = a.extra.get("oculus_os_refs") or []
|
||||
if any(r.startswith("libmetaxraudio") for r in refs):
|
||||
return Suggestion(True, "The Unreal build of Meta XR Audio looks up com.oculus.os.AnalyticsEvent and "
|
||||
"aborts without it (e.g. NOPE Challenge).")
|
||||
if len(refs) >= 2:
|
||||
return Suggestion(True, f"Several Meta SDK libraries look up com.oculus.os.AnalyticsEvent ({', '.join(refs)}); "
|
||||
"games like this abort without the stub (e.g. Nano).")
|
||||
if a.oculus_os_classes:
|
||||
return Suggestion(True, "Native code references com/oculus/os/AnalyticsEvent (a missing class aborts some "
|
||||
"games, e.g. Nano).")
|
||||
return Suggestion(False, f"{', '.join(refs) or 'Native code'} references com.oculus.os.AnalyticsEvent; usually "
|
||||
"harmless (e.g. Batman: Arkham Shadow and LEGO Bricktales run without it). Enable "
|
||||
"if the game aborts with ClassNotFoundException.")
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
return a.oculus_os_classes
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
uses = any(b"com/oculus/os/AnalyticsEvent" in ws.read_lib(lib) for lib in ws.libs())
|
||||
|
||||
@@ -50,15 +50,21 @@ class UnityNoMsaa(Patch):
|
||||
id = "frame.unity_no_msaa"
|
||||
title = "Unity: disable MSAA"
|
||||
description = ("Sets Unity QualitySettings antiAliasing to 0. Unity's multisampled render-to-texture path can hang "
|
||||
"the Frame's GL driver ('zink: DEVICE LOST', Sniper Elite VR). Try it for GLES Unity games that "
|
||||
"the Frame's GL driver ('zink: DEVICE LOST', e.g. Sniper Elite VR). Try it for GLES Unity games that "
|
||||
"freeze or crash the GPU.")
|
||||
order = 40
|
||||
|
||||
def detect(self, a):
|
||||
if a.engine == "Unity" and a.unity_msaa_levels and "GLES" in a.graphics:
|
||||
return Suggestion(False, f"GLES Unity game with MSAA on ({a.unity_msaa_levels} quality levels); "
|
||||
"enable if it hangs the GPU.")
|
||||
return None
|
||||
if a.engine != "Unity" or not a.unity_msaa_levels or "GLES" not in a.graphics or a.only_32bit:
|
||||
return None
|
||||
if a.xr == "VrApi":
|
||||
return Suggestion(True, f"Legacy VrApi Unity game on GLES with MSAA ({a.unity_msaa_levels} quality levels): "
|
||||
"multisampled render-to-texture hangs the Frame's GL driver (e.g. Sniper Elite VR).")
|
||||
return Suggestion(False, f"GLES Unity game with MSAA on ({a.unity_msaa_levels} quality levels); enable if it "
|
||||
"hangs the GPU ('zink: DEVICE LOST').")
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unity" and "GLES" in a.graphics
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
if not ctx.ws.has(GGM):
|
||||
|
||||
@@ -14,17 +14,20 @@ class VrApiBridge(Patch):
|
||||
description = (
|
||||
"Replaces libvrapi.so with a VrApi→OpenXR bridge (Android-XR-Bridge/OVRPort fork, GPL-3.0, patched for the "
|
||||
"Frame: GLES sessions, cylinder→quad layers, sRGB format fallback, 30 s VR-mode deadline, emulated time "
|
||||
"conversion). Needed when the engine calls VrApi itself instead of through OVRPlugin (The Climb 2, Path of "
|
||||
"conversion). Needed when the engine calls VrApi itself instead of through OVRPlugin (e.g. The Climb 2, Path of "
|
||||
"the Warrior); overport cannot translate those."
|
||||
)
|
||||
order = 60
|
||||
experimental = True
|
||||
|
||||
def detect(self, a):
|
||||
if a.direct_vrapi:
|
||||
if a.direct_vrapi and "arm64-v8a" in a.abis:
|
||||
return Suggestion(True, "The engine calls libvrapi.so directly; overport can't translate it.")
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
return "libvrapi.so" in a.libs and "arm64-v8a" in a.abis
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a" or not ws.has(ws.lib("libvrapi.so")):
|
||||
@@ -51,10 +54,13 @@ class GlShim(Patch):
|
||||
experimental = True
|
||||
|
||||
def detect(self, a):
|
||||
if a.direct_vrapi and a.uses_glad_gl and "GLES" in a.graphics:
|
||||
if a.direct_vrapi and a.uses_glad_gl and "GLES" in a.graphics and "arm64-v8a" in a.abis:
|
||||
return Suggestion(True, "GLES engine resolving GL through eglGetProcAddress (GLAD) on the VrApi bridge.")
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
return a.direct_vrapi and "GLES" in a.graphics and "arm64-v8a" in a.abis
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a":
|
||||
|
||||
@@ -37,7 +37,8 @@ OVERPORT_PATCHES = [
|
||||
("patch_force_passthrough", "Force enable passthrough", False,
|
||||
"For mixed-reality-only games. On the Frame, passthrough is emulated by the FrameBridge adapter (greyscale cameras)."),
|
||||
("patch_disable_space_warp", "Disable application space warp if used", False,
|
||||
"For heavy games; space warp causes artifacts/hangs on non-Quest runtimes (Asgard's Wrath 2, Batman)."),
|
||||
"For heavy games that use application space warp: it causes artifacts or hangs on non-Quest runtimes "
|
||||
"(e.g. Asgard's Wrath 2, Batman: Arkham Shadow)."),
|
||||
("patch_disable_controller_offset", "Disable controller tracking offset", False,
|
||||
"Removes overport's controller pose offset if controllers look misplaced."),
|
||||
("patch_remove_vrapi", "Remove VrApi library", False,
|
||||
@@ -54,12 +55,36 @@ class OverportPatch(Patch):
|
||||
self.id, self.title, self.default_on, self.description = pid, title, default, detail
|
||||
|
||||
def detect(self, analysis: Analysis) -> Suggestion | None:
|
||||
from . import applicability as ap
|
||||
|
||||
a = analysis
|
||||
if self.default_on:
|
||||
return Suggestion(True, "overport default.")
|
||||
if self.id == "patch_disable_space_warp" and analysis.extra.get("size", 0) > 3 * 2**30:
|
||||
return Suggestion(False, "Consider for very heavy games.")
|
||||
if self.id == "patch_force_passthrough" and a.extra.get("mr_only"):
|
||||
return Suggestion(True, "Mixed-reality-only game (passthrough required, no guardian): force passthrough on.")
|
||||
if self.id == "patch_disable_space_warp" and "libOVRPlugin.so" in a.libs:
|
||||
total = a.extra.get("data_bytes", 0) + a.extra.get("size", 0)
|
||||
if total >= 20 * 2**30:
|
||||
return Suggestion(True, f"Very heavy game ({total / 2**30:.0f} GiB) using OVRPlugin: application space "
|
||||
"warp causes artifacts/hangs off-Quest.")
|
||||
return Suggestion(False, "Enable if the game shows warping artifacts or hangs (uses OVRPlugin space warp).")
|
||||
if self.id == "patch_remove_unreal_force_quit" and ap.is_unreal(a):
|
||||
return Suggestion(False, "Built as the alternate APK for Unreal games (use it if the game quits itself).")
|
||||
return None
|
||||
|
||||
def applies(self, analysis: Analysis) -> bool:
|
||||
from . import applicability as ap
|
||||
|
||||
rules = {
|
||||
"patch_oculus_unity": ap.is_unity, "patch_oculus_unreal": ap.is_unreal,
|
||||
"patch_fix_unreal_crash": ap.is_unreal, "patch_remove_unreal_force_quit": ap.is_unreal,
|
||||
"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,
|
||||
}
|
||||
rule = rules.get(self.id)
|
||||
return rule(analysis) if rule else True
|
||||
|
||||
|
||||
STRINGS_URL = "https://raw.githubusercontent.com/ovrport/app/HEAD/composeApp/src/commonMain/composeResources/values/strings.xml"
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""PC VR (Oculus Rift) options, shown in the UI as patches like the overport ones.
|
||||
|
||||
They don't edit the game: they decide how FramePort launches it. On this PC the Steam shortcut runs
|
||||
ReviveInjector.exe (Revive's OpenXR backend by default); on the Frame the launcher runs the same injector under
|
||||
Proton (ARM64, x86 emulated by FEX inside Proton), which bridges OpenXR to the Frame runtime via wineopenxr.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import Param, Patch, Suggestion, register
|
||||
|
||||
|
||||
def _rift(analysis) -> bool:
|
||||
return (analysis.extra or {}).get("kind") == "rift"
|
||||
|
||||
|
||||
class _PcvrPatch(Patch):
|
||||
category = "pcvr"
|
||||
stage = "install"
|
||||
|
||||
def applies(self, analysis) -> bool:
|
||||
return _rift(analysis)
|
||||
|
||||
|
||||
class Revive(_PcvrPatch):
|
||||
id = "pcvr.revive"
|
||||
title = "Revive (Oculus LibOVR → OpenXR)"
|
||||
description = ("Launch the game through Revive's injector, which translates the Oculus PC SDK (LibOVR) to OpenXR. "
|
||||
"Needed by every Oculus Rift game that isn't OpenXR-native. FramePort downloads Revive itself.")
|
||||
order = 10
|
||||
|
||||
def detect(self, analysis):
|
||||
# Oculus/LibOVR games need Revive to get VR (the bare exe runs flat). Off for games with no Oculus code, which
|
||||
# SteamVR (PC) and the Frame's wineopenxr run directly (e.g. Rick and Morty, handled by its catalog recipe).
|
||||
if not _rift(analysis):
|
||||
return None
|
||||
if analysis.extra.get("frame_native"):
|
||||
return Suggestion(False, "No Oculus code: SteamVR / the Frame run it directly, without Revive.")
|
||||
return Suggestion(True, "Oculus/LibOVR game: Revive translates it to SteamVR/OpenXR (without it it runs flat).")
|
||||
|
||||
|
||||
class ReviveOpenVR(_PcvrPatch):
|
||||
id = "pcvr.revive_openvr"
|
||||
title = "Revive: SteamVR (OpenVR) backend on PC"
|
||||
description = ("On this PC, launch the game through Revive's OpenVR backend, which talks straight to SteamVR — the "
|
||||
"original, most reliable Revive path. On by default for PC installs. Turn it off to use Revive's "
|
||||
"newer OpenXR backend instead. Ignored on the Frame (which always uses OpenXR).")
|
||||
order = 20
|
||||
requires = ("pcvr.revive",)
|
||||
|
||||
def detect(self, analysis):
|
||||
if not _rift(analysis):
|
||||
return None
|
||||
if analysis.extra.get("frame_native"):
|
||||
return Suggestion(False, "Runs on SteamVR directly; no Revive needed.")
|
||||
return Suggestion(True, "PC: Revive's SteamVR/OpenVR backend is the most reliable path.")
|
||||
|
||||
|
||||
class XrTimefix(_PcvrPatch):
|
||||
id = "pcvr.xr_timefix"
|
||||
title = "Frame OpenXR compatibility layer"
|
||||
description = ("Loads FramePort's OpenXR layer under Proton on the Frame. The Frame's SteamVR runtime only accepts "
|
||||
"OpenXR 1.0 apps, but Proton's VR helper asks for 1.1, so without the layer VR never starts (the "
|
||||
"game shows as a flat window, or Revive fails with 'Unable to load LibOVRRT DLL'); the layer "
|
||||
"retries as 1.0. It also emulates xrConvertTimespecTimeToTimeKHR if a runtime refuses it. "
|
||||
"Ignored on this PC.")
|
||||
order = 25
|
||||
|
||||
def detect(self, analysis):
|
||||
if _rift(analysis):
|
||||
return Suggestion(True, "The Frame's OpenXR runtime rejects Proton's OpenXR 1.1 request without it.")
|
||||
return None
|
||||
|
||||
|
||||
class NoCrashReporter(_PcvrPatch):
|
||||
id = "pcvr.no_crash_reporter"
|
||||
title = "No Unreal crash reporter"
|
||||
description = ("Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply "
|
||||
"closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of "
|
||||
"CrashReportClient.exe so it can't start (your game files on this PC aren't changed).")
|
||||
order = 15
|
||||
|
||||
def detect(self, analysis):
|
||||
if _rift(analysis) and analysis.engine == "Unreal":
|
||||
return Suggestion(True, "Unreal game: close on a crash instead of showing the crash reporter.")
|
||||
return None
|
||||
|
||||
def applies(self, analysis) -> bool:
|
||||
return _rift(analysis) and analysis.engine == "Unreal"
|
||||
|
||||
|
||||
class OculusUnreal(_PcvrPatch):
|
||||
id = "pcvr.oculus_unreal"
|
||||
title = "Patch Oculus detection for Unreal (Frame)"
|
||||
description = ("The PC VR counterpart of overport's 'Patch Oculus detection for Unreal'. Unreal's Oculus plugin only "
|
||||
"starts when the Oculus service announces a headset (the Windows event 'OculusHMDConnected'); "
|
||||
"without it the game runs as a flat window. On the Frame the launcher runs the game through "
|
||||
"FramePort's small helper (fp_oculushmd.exe) that provides that event while the game runs, instead "
|
||||
"of relying only on Revive's hook of the check. Ignored on this PC (the Oculus app or Revive "
|
||||
"handle it there).")
|
||||
order = 18
|
||||
|
||||
def detect(self, analysis):
|
||||
if not (_rift(analysis) and analysis.engine == "Unreal"):
|
||||
return None
|
||||
if analysis.extra.get("frame_native"):
|
||||
return Suggestion(False, "No Oculus plugin in use: runs directly.")
|
||||
return Suggestion(True, "Unreal game: its Oculus plugin checks for the Oculus service before it starts VR.")
|
||||
|
||||
def applies(self, analysis) -> bool:
|
||||
return _rift(analysis) and analysis.engine == "Unreal"
|
||||
|
||||
|
||||
class ProtonLog(_PcvrPatch):
|
||||
id = "pcvr.proton_log"
|
||||
title = "Proton debug log (Frame)"
|
||||
description = ("Write Proton's log (PROTON_LOG=1) to steam-<appid>.log in the game folder on the Frame. Slower; "
|
||||
"use while debugging a game that doesn't start.")
|
||||
order = 30
|
||||
|
||||
|
||||
class ProtonTool(_PcvrPatch):
|
||||
id = "pcvr.proton_tool"
|
||||
title = "Proton version (Frame)"
|
||||
description = ("Which Proton build runs the game on the Frame (a Steam compat tool name from the Frame's ARM64 "
|
||||
"compat list, e.g. proton_11-arm64 or proton-experimental-arm64). Empty = newest installed.")
|
||||
order = 40
|
||||
params = [Param("tool", "str", "", "compat tool name")]
|
||||
|
||||
|
||||
class ProtonEnv(_PcvrPatch):
|
||||
id = "pcvr.proton_env"
|
||||
title = "Extra launch environment (Frame)"
|
||||
description = "Environment variables for the Proton launcher on the Frame (e.g. DXVK_HUD=fps), one KEY=value per line."
|
||||
order = 50
|
||||
params = [Param("env", "text", "", "KEY=value lines")]
|
||||
|
||||
|
||||
for _cls in (Revive, ReviveOpenVR, NoCrashReporter, OculusUnreal, XrTimefix, ProtonLog, ProtonTool, ProtonEnv):
|
||||
register(_cls)
|
||||
|
||||
|
||||
def game_args(recipe) -> list[str]:
|
||||
"""Extra command-line arguments for the game itself."""
|
||||
return ["-nocrashreports"] if "pcvr.no_crash_reporter" in recipe.patches else []
|
||||
|
||||
|
||||
def launch_env(recipe) -> dict[str, str]:
|
||||
"""Environment for the Frame launcher from the recipe's pcvr patches."""
|
||||
env = {}
|
||||
if "pcvr.proton_log" in recipe.patches:
|
||||
env["PROTON_LOG"] = "1"
|
||||
raw = recipe.params("pcvr.proton_env").get("env") or ""
|
||||
items = raw.items() if isinstance(raw, dict) else (line.partition("=")[::2] for line in str(raw).splitlines())
|
||||
for k, v in items:
|
||||
k = k.strip()
|
||||
if k:
|
||||
env[k] = str(v).strip()
|
||||
return env
|
||||
@@ -17,14 +17,17 @@ SETTINGS = [
|
||||
"Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels."),
|
||||
("foveation_fix", "int", 1, "Hide Quest foveation", "Hides Quest foveation extensions the Frame runtime lacks."),
|
||||
("controller_fix", "int", 1, "Report Touch controllers",
|
||||
"Reports Frame controllers as Oculus Touch and hides synthetic hand tracking. 0 for hand-tracking games (Silhouette)."),
|
||||
"Reports Frame controllers as Oculus Touch and hides synthetic hand tracking. Set 0 for games that require hand "
|
||||
"tracking (e.g. Silhouette)."),
|
||||
("swapchain_fix", "int", 1, "Swapchain format fallback", "Retry rejected GLES formats/MSAA with sRGB, samples=1."),
|
||||
("layer_fix", "int", 1, "Drop invalid layers", "Drop layers whose swapchain failed or whose extension isn't enabled."),
|
||||
("passthrough_emul", "int", 1, "Emulate passthrough", "XR_FB_passthrough via ALPHA_BLEND (Frame greyscale cameras)."),
|
||||
("flip_emul", "int", 1, "Emulate flipped quads",
|
||||
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan; AC Nexus UI)."),
|
||||
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and "
|
||||
"text that show upside down (e.g. Assassin's Creed Nexus)."),
|
||||
("scene_emul", "int", 0, "Emulate Meta scene (room)",
|
||||
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls (Demeter)."),
|
||||
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality "
|
||||
"games that build their level from the room (e.g. Demeter)."),
|
||||
("scene_height", "float", 2.5, "Emulated room height (m)", "Ceiling height for scene_emul."),
|
||||
("scene_width", "float", 0.0, "Emulated room width (m)", "Override the guardian width (0 = use guardian, min 1.5 m)."),
|
||||
("scene_depth", "float", 0.0, "Emulated room depth (m)", "Override the guardian depth (0 = use guardian, min 1.5 m)."),
|
||||
@@ -32,9 +35,11 @@ SETTINGS = [
|
||||
("strip_depth", "int", 0, "Strip depth layers", "Remove XR_KHR_composition_layer_depth chains (diagnostic)."),
|
||||
("mutable_fix", "int", 0, "Mutable swapchain fix", "Experimental Vulkan mutable-format workaround."),
|
||||
("respace_kick", "int", 0, "Re-create reference space", "Recreate spaces after the first frames (diagnostic)."),
|
||||
("flip_quads", "int", 0, "Rotate quads 180°", "Old AC Nexus workaround (quads are single-sided; prefer flip_emul)."),
|
||||
("flip_quads", "int", 0, "Rotate quads 180°", "Older workaround for upside-down quads: rotates them 180° (quads are single-sided; prefer "
|
||||
"flip_emul)."),
|
||||
("gl_hide_multiview", "int", 1, "GL shim: hide multiview",
|
||||
"GL shim only: hide GL_OVR_multiview so all passes use single-view shaders (Path of the Warrior)."),
|
||||
"GL shim only: hide GL_OVR_multiview so all passes use single-view shaders. For GLES games whose multiview "
|
||||
"shaders fail on single-view render targets (e.g. Path of the Warrior)."),
|
||||
]
|
||||
|
||||
|
||||
@@ -51,10 +56,34 @@ class AdapterSetting(Patch):
|
||||
self.default = default
|
||||
|
||||
def detect(self, a):
|
||||
from .applicability import needs_scene
|
||||
|
||||
if self.key == "controller_fix" and a.extra.get("hand_tracking_only"):
|
||||
return Suggestion(True, "Hand-tracking game: pass hands through.", {"value": 0})
|
||||
return Suggestion(True, "Hand tracking is required by the game: pass hands through instead of reporting "
|
||||
"Touch controllers (e.g. Silhouette).", {"value": 0})
|
||||
if self.key == "scene_emul" and needs_scene(a):
|
||||
return Suggestion(True, "Mixed-reality game that builds its level from the room model: emulate a "
|
||||
"guardian-sized room (e.g. Demeter).", {"value": 1})
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
from . import applicability as ap
|
||||
|
||||
rules = {
|
||||
"scene_emul": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
|
||||
"scene_height": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
|
||||
"scene_width": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
|
||||
"scene_depth": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
|
||||
"passthrough_emul": lambda a: a.extra.get("mr_only") or "com.oculus.feature.PASSTHROUGH" in (a.extra.get("features") or {}),
|
||||
"flip_emul": ap.is_vulkan,
|
||||
"flip_quads": ap.is_vulkan,
|
||||
"mutable_fix": ap.is_vulkan,
|
||||
"swapchain_fix": ap.is_gles,
|
||||
"gl_hide_multiview": lambda a: a.direct_vrapi and ap.is_gles(a),
|
||||
}
|
||||
rule = rules.get(self.key)
|
||||
return bool(rule(a)) if rule else True
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
pass # collected by adapter_settings()
|
||||
|
||||
@@ -79,12 +108,20 @@ def adapter_settings(recipe_patches: dict) -> dict:
|
||||
class DeviceFiles(Patch):
|
||||
id = "device.files"
|
||||
title = "Game config files"
|
||||
description = ("Writes files into the game's Android/data/<package>/files/ on the Frame, e.g. The Climb 2's "
|
||||
"user.cfg with r_variable_rate_shading = 0 (CryEngine VRS is unsupported on the Frame).")
|
||||
description = ("Writes config files into the game's Android/data/<package>/files/ on the Frame, to turn off engine "
|
||||
"features the Frame doesn't support (e.g. a CryEngine user.cfg with r_variable_rate_shading = 0, "
|
||||
"as The Climb 2 needs).")
|
||||
category = "device"
|
||||
stage = "install"
|
||||
params = [Param("files", "text", {}, "path relative to files/ -> content")]
|
||||
|
||||
def detect(self, a):
|
||||
if a.engine == "CryEngine" and a.graphics.startswith("Vulkan") or (a.engine == "CryEngine" and "libCryRenderVulkan.so" in a.libs):
|
||||
return Suggestion(True, "CryEngine: variable-rate shading isn't supported on the Frame, so user.cfg turns "
|
||||
"it off (r_variable_rate_shading = 0; e.g. The Climb 2).",
|
||||
{"files": {"user.cfg": "r_variable_rate_shading = 0\n"}})
|
||||
return None
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
for rel, content in (ctx.params.get("files") or {}).items():
|
||||
ctx.files[rel] = content.encode() if isinstance(content, str) else content
|
||||
|
||||
+339
-13
@@ -2,9 +2,14 @@
|
||||
|
||||
add (scan/import) → analyze → suggest recipe → [user confirms] → build → static checks
|
||||
→ install on target → add to library → launch test (+ triage suggestions)
|
||||
|
||||
Quest games (APK + OBB) are rebuilt with overport and installed on the Frame (Lepton). Oculus Rift games (Windows PC VR
|
||||
folders, ids "rift.<slug>") aren't modified: "build" checks them and fetches Revive, and they install either on this
|
||||
PC (Revive + local Steam) or on the Frame (Proton + Revive).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import build as builder
|
||||
@@ -15,26 +20,240 @@ from .core.events import Reporter
|
||||
from .core.models import Recipe, SourceGame
|
||||
from .core.paths import output_dir
|
||||
from .recommend import engine
|
||||
from .sources import quest_dump
|
||||
from .sources import quest_dump, rift_dump
|
||||
from .targets.base import Target
|
||||
|
||||
|
||||
def add_path(path: Path, reporter: Reporter | None = None) -> list[dict]:
|
||||
"""Scan a file/folder, analyze every game found and store it with a suggested recipe."""
|
||||
def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_rift: bool = False,
|
||||
art: bool = False) -> list[dict]:
|
||||
"""Scan a file/folder (recursively), analyze every game found (Quest APKs and Rift game folders) and store it with
|
||||
a suggested recipe. on_added(entry) is called as each game lands in the library (the GUI streams cards in).
|
||||
force_rift: `path` is one Rift game folder (added even if no VR runtime is detected). art: fetch artwork too."""
|
||||
path = Path(path)
|
||||
added = []
|
||||
for src in quest_dump.scan(Path(path)):
|
||||
try:
|
||||
added.append(add_game(src, reporter))
|
||||
except Exception as exc: # keep scanning; report the broken one
|
||||
|
||||
def done(entry):
|
||||
added.append(entry)
|
||||
if on_added:
|
||||
on_added(entry)
|
||||
if not force_rift:
|
||||
for src in quest_dump.scan(path):
|
||||
if reporter:
|
||||
reporter.log(f"skipped {src.apk.name}: {exc}")
|
||||
reporter.check_cancel()
|
||||
try:
|
||||
entry = add_game(src, reporter)
|
||||
if art:
|
||||
try:
|
||||
artwork.fetch(entry["package"], Path(entry["apk"]))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if not entry.get("details"):
|
||||
fetch_details(entry["package"], reporter)
|
||||
entry = library.game(entry["package"])
|
||||
done(entry)
|
||||
except Exception as exc: # keep scanning; report the broken one
|
||||
if reporter:
|
||||
reporter.log(f"skipped {src.apk.name}: {exc}")
|
||||
if path.is_dir():
|
||||
trees: dict = {}
|
||||
if reporter:
|
||||
reporter.stage("Looking for PC VR games")
|
||||
folders = [path] if force_rift else rift_dump.scan(path, trees=trees)
|
||||
for i, folder in enumerate(folders):
|
||||
if reporter:
|
||||
reporter.check_cancel()
|
||||
reporter.progress(i / max(len(folders), 1), f"{i + 1} / {len(folders)} · {folder.name}")
|
||||
try:
|
||||
entry = add_rift_game(folder, reporter, tree=trees.get(folder), force=force_rift, art=art)
|
||||
if entry and art and not entry.get("details"):
|
||||
fetch_details(entry["package"], reporter)
|
||||
entry = library.game(entry["package"])
|
||||
if entry:
|
||||
done(entry)
|
||||
except Exception as exc:
|
||||
if reporter:
|
||||
reporter.log(f"skipped {folder.name}: {exc}")
|
||||
return added
|
||||
|
||||
|
||||
def _norm_title(t: str) -> str:
|
||||
import re
|
||||
|
||||
t = re.sub(r"\b(vr|quest( edition)?|rift|oculus)\b|[^a-z0-9]+", " ", (t or "").lower())
|
||||
return " ".join(t.split())
|
||||
|
||||
|
||||
def quest_counterpart(title: str) -> str | None:
|
||||
"""Package of the Quest version of a Rift game (catalog or library, by title) for linking and store artwork."""
|
||||
from .recommend import catalog
|
||||
|
||||
want = _norm_title(title)
|
||||
if not want:
|
||||
return None
|
||||
for e in catalog.load().values():
|
||||
if e.kind != "rift" and _norm_title(e.title) == want:
|
||||
return e.package
|
||||
for g in library.games():
|
||||
if g.get("kind") != "rift" and _norm_title(g.get("title") or "") == want:
|
||||
return g["package"]
|
||||
return None
|
||||
|
||||
|
||||
def _existing_rift(folder: Path) -> dict | None:
|
||||
"""The library entry for this game folder: same folder, or the same game scanned one level up/down (a repack's
|
||||
wrapper folder vs. the game folder inside it)."""
|
||||
rifts = [g for g in library.games() if g.get("kind") == "rift" and g.get("game_dir")]
|
||||
exact = next((g for g in rifts if g["game_dir"] == str(folder)), None)
|
||||
if exact:
|
||||
return exact
|
||||
for g in rifts:
|
||||
other = Path(g["game_dir"])
|
||||
if other in folder.parents or folder in other.parents:
|
||||
return g
|
||||
return None
|
||||
|
||||
|
||||
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = None,
|
||||
force: bool = False, art: bool = False) -> dict | None:
|
||||
"""Analyze one Rift game folder and store it. Unchanged folders (same program, size, date) are not analyzed again;
|
||||
the user's exe choice, recipe and tags survive rescans. Returns None for folders without a VR runtime (unless
|
||||
force)."""
|
||||
from .analysis import rift
|
||||
from .recommend import catalog
|
||||
|
||||
folder = Path(folder)
|
||||
old = _existing_rift(folder)
|
||||
old_extra = ((old or {}).get("analysis") or {}).get("extra") or {}
|
||||
forced = exe is not None # set_exe: always analyze with the user's choice
|
||||
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
|
||||
if old and not forced and old_extra.get("fingerprint") and \
|
||||
old_extra["fingerprint"] == rift.fingerprint(folder, old.get("exe") or ""):
|
||||
if reporter:
|
||||
reporter.log(f"{old.get('title')}: unchanged")
|
||||
if art and not _has_art(old["package"]):
|
||||
_rift_art(old, reporter)
|
||||
return library.game(old["package"])
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {folder.name}")
|
||||
a = rift.analyze(folder, exe=exe, tree=tree)
|
||||
if not a.extra.get("vr_found") and not force and not old:
|
||||
if reporter:
|
||||
reporter.log(f"skipped {folder.name}: no Oculus/OpenXR runtime found in {a.extra['exe']}")
|
||||
return None
|
||||
package = old["package"] if old else a.package
|
||||
a.package = package
|
||||
recipe = engine.suggest(a)
|
||||
entry = catalog.lookup(package)
|
||||
quest = (old or {}).get("quest_package") or (entry.quest_package if entry else None) or \
|
||||
quest_counterpart(recipe.title or a.label)
|
||||
fields = dict(kind="rift", name=folder.name, apk=None, game_dir=str(folder), exe=a.extra["exe"],
|
||||
exe_confirmed=a.extra["exe_confirmed"], data_dir=None, data_bytes=a.extra["data_bytes"],
|
||||
origin=str(folder.parent), quest_package=quest, analysis=a.to_dict(),
|
||||
suggested=library.recipe_to_dict(recipe))
|
||||
if not old or (old.get("recipe") or {}).get("source") != "user":
|
||||
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
|
||||
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
|
||||
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
|
||||
stored = library.upsert_game(package, **fields)
|
||||
if art and not _has_art(package):
|
||||
_rift_art(stored, reporter)
|
||||
return library.game(package)
|
||||
|
||||
|
||||
def _has_art(package: str) -> bool:
|
||||
from .artwork import sources
|
||||
|
||||
return sources.has_art(package)
|
||||
|
||||
|
||||
def _rift_art(entry: dict, reporter: Reporter | None = None) -> dict:
|
||||
"""Find artwork for a Rift game (Quest version / OculusDB / Steam / exe icon) and record what matched."""
|
||||
from .artwork import sources, thumbs
|
||||
|
||||
pkg = entry["package"]
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
try:
|
||||
found = sources.fetch_rift(pkg, entry.get("title") or pkg, extra.get("canonical_name"),
|
||||
entry.get("quest_package"), Path(entry["game_dir"]) / entry["exe"])
|
||||
except Exception as exc: # noqa: BLE001 - artwork is optional
|
||||
if reporter:
|
||||
reporter.log(f"{entry.get('title')}: no artwork ({exc})")
|
||||
return {}
|
||||
update = {"art_source": found.get("source") or "none"} # "none": don't retry on every start
|
||||
if found.get("quest_package") and not entry.get("quest_package"):
|
||||
update["quest_package"] = found["quest_package"]
|
||||
if found.get("oculus_app_id"):
|
||||
update["oculus_app_id"] = found["oculus_app_id"]
|
||||
if found.get("title") and not entry.get("title_locked"):
|
||||
update["title"] = found["title"]
|
||||
library.upsert_game(pkg, **update)
|
||||
try:
|
||||
thumbs.prewarm(pkg)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if reporter:
|
||||
reporter.log(f"{entry.get('title')}: artwork from {found.get('source') or 'nowhere'}")
|
||||
return found
|
||||
|
||||
|
||||
def fetch_art(package: str, reporter: Reporter | None = None) -> dict:
|
||||
"""(Re)fetch artwork for any game in the library."""
|
||||
entry = library.game(package)
|
||||
if entry.get("kind") == "rift":
|
||||
return _rift_art(entry, reporter)
|
||||
artwork.fetch(package, Path(entry["apk"]) if entry.get("apk") else None, refresh=True)
|
||||
return {"source": "meta"}
|
||||
|
||||
|
||||
def fetch_details(package: str, reporter: Reporter | None = None) -> dict:
|
||||
"""Description, genres, developer/publisher, release date, links and screenshots (see artwork/details.py)."""
|
||||
from .artwork import details, thumbs
|
||||
|
||||
entry = library.game(package)
|
||||
try:
|
||||
d = details.fetch_details(entry)
|
||||
except Exception as exc: # noqa: BLE001 - details are optional
|
||||
if reporter:
|
||||
reporter.log(f"{entry.get('title')}: no details ({exc})")
|
||||
d = {"sources": [], "fetched": time.time()}
|
||||
library.upsert_game(package, details=d)
|
||||
for shot in details.screenshot_files(package):
|
||||
try:
|
||||
thumbs.thumb(shot, 480, shot.stem)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if reporter:
|
||||
reporter.log(f"{entry.get('title')}: details from {', '.join(d.get('sources') or []) or 'nowhere'}"
|
||||
+ (f", {len(d.get('screenshots') or [])} screenshots" if d.get("screenshots") else ""))
|
||||
return d
|
||||
|
||||
|
||||
def set_exe(package: str, exe: str) -> dict:
|
||||
"""The user picked the program that starts a Rift game: re-analyze with it (keeps recipe choices and tags)."""
|
||||
entry = library.game(package)
|
||||
if not entry or entry.get("kind") != "rift":
|
||||
raise ValueError(f"{package} is not a Rift game")
|
||||
folder = Path(entry["game_dir"])
|
||||
if not (folder / exe).is_file():
|
||||
raise FileNotFoundError(folder / exe)
|
||||
return add_rift_game(folder, exe=exe, force=True)
|
||||
|
||||
|
||||
def steam_title(entry: dict) -> str:
|
||||
"""Name for the Steam library: "<Title> (Quest)" / "(Rift)" while both versions of a game are in the library."""
|
||||
from .core.titles import display_title, twins
|
||||
|
||||
return display_title(entry, twins(library.games()))
|
||||
|
||||
|
||||
def is_rift(entry: dict) -> bool:
|
||||
return entry.get("kind") == "rift"
|
||||
|
||||
|
||||
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {src.apk.name}")
|
||||
a = analyze(src.apk)
|
||||
a = analyze(src.apk, data_bytes=src.data_bytes())
|
||||
recipe = engine.suggest(a)
|
||||
return library.upsert_game(
|
||||
a.package, title=recipe.title or a.label, name=src.name, apk=str(src.apk),
|
||||
@@ -55,14 +274,88 @@ def set_recipe(package: str, recipe: Recipe) -> None:
|
||||
|
||||
|
||||
def reset_recipe(package: str) -> Recipe:
|
||||
if is_rift(library.game(package) or {}):
|
||||
entry = library.game(package)
|
||||
a = library.analysis_from_dict(entry["analysis"])
|
||||
recipe = engine.suggest(a)
|
||||
set_recipe(package, recipe)
|
||||
return recipe
|
||||
entry = library.game(package)
|
||||
recipe = engine.suggest(library.analysis_from_dict(entry["analysis"]))
|
||||
set_recipe(package, recipe)
|
||||
return recipe
|
||||
|
||||
|
||||
def prepare_rift(package: str, reporter: Reporter) -> dict:
|
||||
"""Rift games aren't rebuilt: check the folder still matches the analysis and make sure Revive is available."""
|
||||
from .build import sha256
|
||||
from .tools import revive
|
||||
|
||||
entry = library.game(package)
|
||||
recipe = library.recipe_from_dict(entry["recipe"])
|
||||
extra = entry["analysis"].get("extra", {})
|
||||
reporter.stage("Check game files")
|
||||
exe = Path(entry["game_dir"]) / entry["exe"]
|
||||
checks = []
|
||||
|
||||
def check(name, ok, msg=""):
|
||||
checks.append({"name": name, "ok": ok, "message": msg})
|
||||
reporter.check(name, ok, msg)
|
||||
check("Game executable", exe.is_file(), str(exe))
|
||||
check("64-bit" if entry["analysis"]["abis"] == ["x86_64"] else "Executable type",
|
||||
True if entry["analysis"]["abis"][0] in ("x86", "x86_64") else False, entry["analysis"]["abis"][0])
|
||||
check("Oculus Platform SDK", None if extra.get("platform_sdk") else True,
|
||||
"entitlement check: needs the Oculus app with a license you own (PC mode only)"
|
||||
if extra.get("platform_sdk") else "not used")
|
||||
if "pcvr.revive" in recipe.patches:
|
||||
reporter.stage("Revive")
|
||||
rdir = revive.revive_dir() or revive.install(lambda f: reporter.progress(f, "downloading Revive"))
|
||||
check("Revive", True, f"{revive.installed_version()} ({rdir})")
|
||||
art, store_title = artwork.fetch(package, lookup=entry.get("quest_package"))
|
||||
info = {"sha256": sha256(exe) if exe.is_file() else None, "checks": checks,
|
||||
"ok": all(c["ok"] is not False for c in checks), "revive": revive.installed_version(), "kind": "rift"}
|
||||
library.upsert_game(package, build=info)
|
||||
return info
|
||||
|
||||
|
||||
def prepare_as_is(package: str, reporter: Reporter) -> dict:
|
||||
"""'Install as is': the APK is used unchanged (already patched). Only checks that it can start in Lepton."""
|
||||
from .apk import axml
|
||||
from .build import sha256
|
||||
|
||||
entry = library.game(package)
|
||||
apk = Path(entry["apk"])
|
||||
a = entry["analysis"]
|
||||
reporter.stage("Checking the game (installing it as it is)")
|
||||
checks = []
|
||||
|
||||
def check(name, ok, msg=""):
|
||||
checks.append({"name": name, "ok": ok, "message": msg})
|
||||
reporter.check(name, ok, msg)
|
||||
check("APK", apk.is_file(), str(apk))
|
||||
check("64-bit (arm64-v8a)", "arm64-v8a" in (a.get("abis") or []) or None, ", ".join(a.get("abis") or []))
|
||||
try:
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
cats = axml.categories(z.read("AndroidManifest.xml"))
|
||||
check("Launcher entry for Lepton", axml.LAUNCHER in cats or None,
|
||||
"present" if axml.LAUNCHER in cats else "missing: Lepton may not find the game (turn off 'Install as is')")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
check("Manifest", None, str(exc))
|
||||
art, store_title = artwork.fetch(package, apk)
|
||||
info = {"apk": str(apk), "alt_apk": None, "sha256": sha256(apk), "alt_sha256": None, "applied": [],
|
||||
"checks": checks, "ok": all(c["ok"] is not False for c in checks), "as_is": True}
|
||||
library.upsert_game(package, build=info, title=entry.get("title") or store_title)
|
||||
return info
|
||||
|
||||
|
||||
def build_game(package: str, reporter: Reporter, outdir: Path | None = None) -> dict:
|
||||
entry = library.game(package)
|
||||
if is_rift(entry):
|
||||
return prepare_rift(package, reporter)
|
||||
if library.recipe_from_dict(entry["recipe"]).as_is:
|
||||
return prepare_as_is(package, reporter)
|
||||
src = source_of(entry)
|
||||
a = library.analysis_from_dict(entry["analysis"])
|
||||
recipe = library.recipe_from_dict(entry["recipe"])
|
||||
@@ -79,24 +372,57 @@ 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:
|
||||
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"])
|
||||
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
|
||||
title = entry.get("title") or package
|
||||
title = steam_title(entry)
|
||||
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only)
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
installs = entry.get("installs", {})
|
||||
installs[target.label] = {"apk": str(apk), "result": result}
|
||||
installs[target.label] = {"apk": str(apk), "result": result, "time": time.time()}
|
||||
library.upsert_game(package, installs=installs)
|
||||
return result
|
||||
|
||||
|
||||
def install_rift(package: str, target: Target, reporter: Reporter, add_to_library: bool = True) -> dict:
|
||||
from .tools import revive
|
||||
|
||||
entry = library.game(package)
|
||||
if not (entry.get("build") or {}).get("ok"):
|
||||
prepare_rift(package, reporter)
|
||||
entry = library.game(package)
|
||||
recipe = library.recipe_from_dict(entry["recipe"])
|
||||
title = steam_title(entry)
|
||||
rdir = revive.revive_dir() if "pcvr.revive" in recipe.patches else None
|
||||
result = target.install_pcvr(package, title, Path(entry["game_dir"]), entry["exe"], recipe, reporter,
|
||||
revive_dir=rdir, revive_version=revive.installed_version() if rdir else None,
|
||||
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"))
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
installs = entry.get("installs", {})
|
||||
installs[target.label] = {"exe": entry["exe"], "result": result, "time": time.time()}
|
||||
library.upsert_game(package, installs=installs)
|
||||
return result
|
||||
|
||||
|
||||
def test_game(package: str, target: Target, reporter: Reporter, seconds: int = 45) -> dict:
|
||||
result, _log = target.launch_test(package, reporter, seconds)
|
||||
result, log = target.launch_test(package, reporter, seconds)
|
||||
from .core.paths import user_data_dir
|
||||
|
||||
logs = user_data_dir() / "logs"
|
||||
logs.mkdir(exist_ok=True)
|
||||
log_path = logs / f"{package}-{time.strftime('%Y%m%d-%H%M%S')}.log"
|
||||
log_path.write_text(log or "", encoding="utf-8", errors="replace")
|
||||
for old in sorted(logs.glob(f"{package}-*.log"))[:-5]: # keep the last 5 per game
|
||||
old.unlink(missing_ok=True)
|
||||
reporter.log(f"full launch log ({len((log or '').splitlines())} lines): {log_path}")
|
||||
summary = {"state": result.state, "verdict": result.verdict, "milestone": result.milestone, "fps": result.fps,
|
||||
"findings": [f.__dict__ for f in result.findings], "suggestions": result.suggestions()}
|
||||
"findings": [f.__dict__ for f in result.findings], "suggestions": result.suggestions(),
|
||||
"time": time.time(), "target": target.label, "log_path": str(log_path)}
|
||||
library.upsert_game(package, last_test=summary)
|
||||
return summary
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ class CatalogEntry:
|
||||
device_files: dict = field(default_factory=dict)
|
||||
lepton_env: dict = field(default_factory=dict)
|
||||
pcvr_alternative: str | None = None
|
||||
# Rift (PC VR) recipes: package is "rift.<slug>"; kind "rift"
|
||||
kind: str = "quest"
|
||||
quest_package: str | None = None # the Quest version of the same game (links the two in the UI)
|
||||
pcvr: list[str] = field(default_factory=list) # pcvr.* patches to enable
|
||||
pcvr_remove: list[str] = field(default_factory=list)
|
||||
proton_env: dict = field(default_factory=dict)
|
||||
proton_tool: str = ""
|
||||
as_is: bool = False # Rift: the dump runs unchanged (e.g. a SteamVR build: no Revive)
|
||||
verified: dict = field(default_factory=dict)
|
||||
source_hint: str = ""
|
||||
origin: str = "bundled"
|
||||
@@ -51,7 +59,7 @@ class CatalogEntry:
|
||||
out = {}
|
||||
for k, f in self.__dataclass_fields__.items():
|
||||
v = getattr(self, k)
|
||||
if k == "origin" or v in (None, "", [], {}, False):
|
||||
if k == "origin" or v in (None, "", [], {}, False) or (k == "kind" and v == "quest"):
|
||||
continue
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
@@ -9,17 +9,24 @@ from ..patches import base
|
||||
from . import catalog
|
||||
|
||||
|
||||
def suggest(analysis: Analysis) -> Recipe:
|
||||
entry = catalog.lookup(analysis.package)
|
||||
def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
entry = catalog.lookup(analysis.package) if use_catalog else None
|
||||
recipe = Recipe(analysis.package, title=analysis.label)
|
||||
# 1. heuristics / defaults from every patch module
|
||||
rift = (analysis.extra or {}).get("kind") == "rift"
|
||||
for patch in base.all_patches():
|
||||
if not base.for_game(patch, analysis):
|
||||
continue # Quest patches for Quest games, PC VR (Revive) options for Rift games
|
||||
if not patch.default_on and not patch.applies(analysis):
|
||||
continue # irrelevant for this game (engine, XR API, graphics API, ...)
|
||||
s = patch.detect(analysis)
|
||||
if s and s.recommended:
|
||||
recipe.patches[patch.id] = dict(s.params)
|
||||
recipe.reasons[patch.id] = s.reason
|
||||
# 2. known-good catalog recipe overrides heuristics
|
||||
if entry:
|
||||
if rift:
|
||||
_rift_recipe(analysis, recipe, entry)
|
||||
elif entry:
|
||||
recipe.source = f"catalog ({entry.origin})"
|
||||
recipe.status, recipe.notes, recipe.title = entry.status, entry.notes, entry.title or recipe.title
|
||||
why = f"Known-good recipe for {entry.title} (tested {entry.verified.get('date', '?')})."
|
||||
@@ -54,6 +61,11 @@ def suggest(analysis: Analysis) -> Recipe:
|
||||
if analysis.only_32bit:
|
||||
recipe.status = "unsupported"
|
||||
recipe.notes = "32-bit only: the Steam Frame has no AArch32 support. Consider the PC (Rift) version via Revive."
|
||||
if not rift and not entry and (analysis.extra or {}).get("frame_patched"):
|
||||
# already has FramePort's adapter (e.g. a PATCHED/ build): installing it unchanged is the safe default
|
||||
recipe.as_is = True
|
||||
recipe.notes = (recipe.notes + " " if recipe.notes else "") + \
|
||||
"This APK is already patched for the Frame, so FramePort installs it as it is."
|
||||
# requirements
|
||||
for pid in list(recipe.patches):
|
||||
for req in base.get(pid).requires:
|
||||
@@ -63,8 +75,56 @@ def suggest(analysis: Analysis) -> Recipe:
|
||||
return recipe
|
||||
|
||||
|
||||
def _rift_recipe(analysis: Analysis, recipe: Recipe, entry) -> None:
|
||||
extra = analysis.extra
|
||||
if entry:
|
||||
recipe.source = f"catalog ({entry.origin})"
|
||||
recipe.status, recipe.notes, recipe.title = entry.status, entry.notes, entry.title or recipe.title
|
||||
why = f"Known-good recipe for {entry.title} (tested {entry.verified.get('date', '?')})."
|
||||
for pid in entry.pcvr:
|
||||
recipe.patches.setdefault(pid, {})
|
||||
recipe.reasons[pid] = why
|
||||
for pid in entry.pcvr_remove:
|
||||
recipe.patches.pop(pid, None)
|
||||
if entry.proton_env:
|
||||
recipe.patches["pcvr.proton_env"] = {"env": "\n".join(f"{k}={v}" for k, v in entry.proton_env.items())}
|
||||
recipe.reasons["pcvr.proton_env"] = why
|
||||
if entry.proton_tool:
|
||||
recipe.patches["pcvr.proton_tool"] = {"tool": entry.proton_tool}
|
||||
recipe.reasons["pcvr.proton_tool"] = why
|
||||
recipe.as_is = entry.as_is
|
||||
# drop patches whose requirement the catalog removed (e.g. revive_openvr once revive is gone), so the
|
||||
# requirement pass in suggest() doesn't re-add it
|
||||
for pid in list(recipe.patches):
|
||||
if any(req not in recipe.patches for req in base.get(pid).requires):
|
||||
recipe.patches.pop(pid, None)
|
||||
recipe.reasons.pop(pid, None)
|
||||
return
|
||||
recipe.source = "heuristics"
|
||||
recipe.as_is = True # the dump is installed unchanged; Revive (when on) is a launch-time wrapper, not a file edit
|
||||
notes = []
|
||||
if extra.get("frame_native"):
|
||||
notes.append("No Oculus code (SteamVR/OpenXR): runs on the Frame and PC directly, without Revive.")
|
||||
elif extra.get("needs_revive"):
|
||||
notes.append("Oculus/LibOVR game: needs Revive, which works in PC mode with SteamVR running. Not supported on "
|
||||
"the Steam Frame (Revive can't run there).")
|
||||
if extra.get("platform_sdk"):
|
||||
notes.append("Uses the Oculus Platform SDK (entitlement check): normally it needs the Oculus app running with a "
|
||||
"license you own, so it may not start.")
|
||||
if analysis.abis and analysis.abis[0] not in ("x86", "x86_64"):
|
||||
recipe.status = "unsupported"
|
||||
notes.append(f"Unexpected executable type {analysis.abis[0]}.")
|
||||
recipe.notes = " ".join(notes)
|
||||
|
||||
|
||||
def warnings(recipe: Recipe) -> list[str]:
|
||||
out = []
|
||||
if recipe.package.startswith("rift."):
|
||||
for pid in recipe.patches:
|
||||
for r in base.get(pid).requires:
|
||||
if r not in recipe.patches:
|
||||
out.append(f"{base.get(pid).title} needs {base.get(r).title}.")
|
||||
return out
|
||||
for pid in recipe.patches:
|
||||
p = base.get(pid)
|
||||
for c in p.conflicts:
|
||||
@@ -78,3 +138,14 @@ def warnings(recipe: Recipe) -> list[str]:
|
||||
if "frame.adapter" not in recipe.patches:
|
||||
out.append("Without the FrameBridge adapter most games fail on the Frame runtime.")
|
||||
return out
|
||||
|
||||
|
||||
def visible_patches(analysis: Analysis, recipe: Recipe | None = None) -> tuple[list, list]:
|
||||
"""(relevant, hidden) patches for this game. Enabled patches are always shown, so a recipe never hides a choice."""
|
||||
shown, hidden = [], []
|
||||
for p in base.all_patches():
|
||||
if not base.for_game(p, analysis):
|
||||
continue # other kind of game entirely (Quest vs Rift): not even offered under "show all"
|
||||
on = recipe is not None and p.id in recipe.patches
|
||||
(shown if on and not p.default_on or p.applies(analysis) else hidden).append(p)
|
||||
return shown, hidden
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Find Oculus Rift (Windows PC VR) games on disk.
|
||||
|
||||
When scanning, the chosen folder's subfolders are the games (the folder itself only if a program sits directly in
|
||||
it). A subfolder is **one game** when it has candidate programs (see analysis.rift.candidates), no APKs (those are Quest
|
||||
dumps) and all candidates sit at its top level or under a single subfolder — repacks put the game one or more levels
|
||||
down, next to installers and archives. Otherwise it's a **collection**: every subfolder is one game (or, again, a
|
||||
collection), e.g. ARMGDDN_PCVR_OCULUS/<Game vX -TAG>/<Game>/…/Game.exe. Loose programs in a collection are ignored.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..analysis import rift
|
||||
|
||||
|
||||
def classify(path: Path, tree: rift.Tree | None = None) -> tuple[str, rift.Tree]:
|
||||
"""("game" | "collection" | "none", walk result)."""
|
||||
tree = tree or rift.walk(path)
|
||||
cands = rift.candidates(tree)
|
||||
if not cands:
|
||||
return ("collection" if tree.has_apk else "none"), tree
|
||||
places = {tree.child_of(p) for p in cands}
|
||||
if not tree.has_apk and len(places - {"."}) <= 1:
|
||||
return "game", tree
|
||||
return "collection", tree
|
||||
|
||||
|
||||
def is_game_folder(path: Path) -> bool:
|
||||
return Path(path).is_dir() and classify(Path(path))[0] == "game"
|
||||
|
||||
|
||||
def scan(root: Path, depth: int = 4, trees: dict | None = None) -> list[Path]:
|
||||
"""Game folders under root (root itself if it is one game). `trees` collects the walk results by folder, so the
|
||||
analysis doesn't walk again."""
|
||||
root = Path(root)
|
||||
if not root.is_dir():
|
||||
return []
|
||||
kind, tree = classify(root)
|
||||
if trees is not None:
|
||||
trees[root] = tree
|
||||
cands = rift.candidates(tree)
|
||||
# the scanned folder is itself a game only if a candidate sits directly in it; otherwise each subfolder is a game
|
||||
# (or a collection) — "one folder per game"
|
||||
if kind == "game" and any(tree.child_of(p) == "." for p in cands):
|
||||
return [root]
|
||||
if kind == "none" or depth <= 0:
|
||||
return []
|
||||
found = []
|
||||
children_with_games = {tree.child_of(p) for p in cands} - {"."}
|
||||
if tree.has_apk: # mixed downloads folder: look at every subfolder
|
||||
children_with_games = {p.name for p in root.iterdir() if p.is_dir()}
|
||||
for name in sorted(children_with_games):
|
||||
child = root / name
|
||||
if name.startswith((".", "_")) or not child.is_dir():
|
||||
continue
|
||||
ckind, ctree = classify(child)
|
||||
if ckind == "game":
|
||||
if trees is not None:
|
||||
trees[child] = ctree
|
||||
found.append(child)
|
||||
elif ckind == "collection":
|
||||
found += scan(child, depth - 1, trees)
|
||||
return found
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Install targets. The pipeline only talks to this interface, so new targets (e.g. Revive for PC/Rift games) plug in
|
||||
without touching the build, recommend or UI layers."""
|
||||
"""Install targets. The pipeline only talks to this interface, so new targets plug in without touching the build,
|
||||
recommend or UI layers. Quest games go through install(); Oculus Rift (PC VR) games through install_pcvr()."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
@@ -29,8 +29,17 @@ class Target(ABC):
|
||||
@abstractmethod
|
||||
def launch_test(self, package: str, reporter: Reporter, seconds: int = 45): ...
|
||||
|
||||
def launch(self, package: str) -> dict:
|
||||
"""Start an installed game for playing, through the target's Steam (Steam library shortcut)."""
|
||||
raise NotImplementedError(f"{self.label} can't launch games")
|
||||
|
||||
@abstractmethod
|
||||
def set_settings(self, package: str, settings: dict) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
def uninstall(self, package: str, keep_data: bool = True) -> dict: ...
|
||||
|
||||
def install_pcvr(self, package: str, title: str, game_dir: Path, exe: str, recipe: Recipe, reporter: Reporter,
|
||||
**extra) -> dict:
|
||||
"""Install an Oculus Rift (Windows PC VR) game: game_dir + its exe (relative), launched through Revive."""
|
||||
raise NotImplementedError(f"{self.label} can't install PC VR games")
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Steam Frame target: one Lepton container per game + a Steam library shortcut."""
|
||||
"""Steam Frame target: one Lepton container per Quest game, Proton for Oculus Rift (PC VR) games, plus a Steam
|
||||
library shortcut for each."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
@@ -37,15 +38,51 @@ class FrameLeptonTarget(Target):
|
||||
def add_to_library(self, packages, reporter):
|
||||
return installer.add_to_steam(self.connect().frame, packages, reporter)
|
||||
|
||||
def update_steam_art(self, package, reporter):
|
||||
return installer.update_steam_art(self.connect().frame, package, reporter)
|
||||
|
||||
def launch_test(self, package, reporter, seconds=45):
|
||||
return device.launch_test(self.connect().frame, package, reporter, seconds)
|
||||
|
||||
def launch(self, package):
|
||||
return self.connect().frame.agent("launch", package=package, timeout=60)
|
||||
|
||||
def set_settings(self, package, settings):
|
||||
return self.connect().frame.agent("set_settings", package=package, settings=settings)
|
||||
|
||||
def uninstall(self, package, keep_data=True):
|
||||
return self.connect().frame.agent("uninstall", package=package, keep_data=keep_data, remove_shortcut=True)
|
||||
|
||||
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
|
||||
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
|
||||
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"))
|
||||
return installer.install_pcvr(self.connect().frame, plan, reporter)
|
||||
|
||||
def proton_status(self, tool: str | None = None) -> dict:
|
||||
return self.connect().frame.agent("proton_status", tool=tool)
|
||||
|
||||
def install_proton(self, unattended: bool = False, tool: str | None = None) -> dict:
|
||||
"""Ask Steam on the Frame to install Proton (ARM64) + its runtime. unattended: no confirmation in the headset
|
||||
(Steam restarts once and downloads in the background)."""
|
||||
return self.connect().frame.agent("install_proton", mode="unattended" if unattended else "request", tool=tool)
|
||||
|
||||
def proton_selftest(self, tool: str | None = None, **opts) -> dict:
|
||||
"""Runs cmd.exe under the Frame's Proton (first run creates the prefix: a minute or two). opts: vr (default
|
||||
True: SteamGameId set, Proton sets up wineopenxr), log (PROTON_LOG), env, timeout."""
|
||||
seconds = int(opts.pop("timeout", 600))
|
||||
if opts.pop("layer", False): # also load FramePort's timefix OpenXR layer (as installed games do)
|
||||
from ..core.paths import artifacts_dir
|
||||
|
||||
remote = "/home/" + self.target.user + "/.local/share/frameport/xrlayer"
|
||||
self.frame.run(f"mkdir -p '{remote}'")
|
||||
for f in sorted((artifacts_dir() / "linux-arm64").iterdir()):
|
||||
self.frame.put(f, f"{remote}/{f.name}", resume=False)
|
||||
env = dict(opts.get("env") or {})
|
||||
env.update(XR_API_LAYER_PATH=remote, XR_ENABLE_API_LAYERS="XR_APILAYER_FRAMEPORT_timefix")
|
||||
opts["env"] = env
|
||||
return self.connect().frame.agent("proton_selftest", timeout=seconds + 120, tool=tool,
|
||||
**{"timeout_s": seconds, **opts})
|
||||
|
||||
def install_lepton(self) -> dict:
|
||||
return self.connect().frame.agent("install_lepton")
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""PC VR target: Oculus Rift games run on this Windows PC through Revive (LibOVR -> OpenXR/SteamVR), with a non-Steam
|
||||
shortcut in the local Steam library (play on the Frame by streaming from Steam/SteamVR).
|
||||
|
||||
Works on native Windows and from WSL (Windows programs via interop). Revive is FramePort's portable copy
|
||||
(tools/revive.py), so nothing is installed system-wide. Nothing is copied: the shortcut points at the game folder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from ..core import winhost
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
from ..core.paths import agent_dir, user_data_dir
|
||||
from ..patches.pcvr import game_args
|
||||
from ..validate.triage import triage
|
||||
from .base import Target
|
||||
|
||||
TAG = "Rift via Revive"
|
||||
REVIVE_LOG = "Revive/ReviveInjector.txt" # under %LOCALAPPDATA%
|
||||
|
||||
|
||||
def _vdf():
|
||||
"""The agent's binary-VDF/shortcut code (stdlib only), reused so there's a single implementation."""
|
||||
spec = importlib.util.spec_from_file_location("frameport_agent_vdf", agent_dir() / "frameport_agent.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def windows_revive(rdir: Path) -> Path:
|
||||
"""Revive where Windows can run it: on WSL FramePort's data lives on the Linux filesystem (\\wsl$ paths are
|
||||
unreliable for process creation / DLL injection), so a copy goes to %LOCALAPPDATA%\\FramePort\\<revive-version>."""
|
||||
if not winhost.is_wsl() or str(rdir).startswith("/mnt/"):
|
||||
return rdir
|
||||
from ..tools import revive
|
||||
|
||||
local = winhost.env_path("LOCALAPPDATA")
|
||||
if not local:
|
||||
return rdir
|
||||
dest = local / "FramePort" / rdir.name
|
||||
for f in revive.runtime_files(rdir):
|
||||
target = dest / f.relative_to(rdir)
|
||||
if not target.exists() or target.stat().st_size != f.stat().st_size:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(f, target)
|
||||
return dest
|
||||
|
||||
|
||||
def pc_dir() -> Path:
|
||||
path = user_data_dir() / "pc"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def shortcut_fields(dep: dict) -> tuple[str, str, str]:
|
||||
"""(Exe, StartDir, LaunchOptions) for the Steam shortcut of an installed game."""
|
||||
exe_win = dep["exe_win"]
|
||||
start = '"' + exe_win.rsplit("\\", 1)[0] + '\\"'
|
||||
extra = " ".join(dep.get("game_args") or [])
|
||||
if dep.get("revive_win"):
|
||||
args = ("" if dep.get("backend") == "openvr" else "/openxr ") + f'"{exe_win}"' + (f" {extra}" if extra else "")
|
||||
return f'"{dep["revive_win"]}\\ReviveInjector.exe"', start, args
|
||||
return f'"{exe_win}"', start, extra
|
||||
|
||||
|
||||
def local_installs() -> dict[str, dict]:
|
||||
"""PC installs by package (just reads FramePort's records; works without Windows)."""
|
||||
out = {}
|
||||
for f in sorted((user_data_dir() / "pc").glob("*/deployment.json")):
|
||||
try:
|
||||
dep = json.loads(f.read_text(encoding="utf-8"))
|
||||
out[dep["package"]] = dep
|
||||
except (OSError, ValueError, KeyError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
class PcReviveTarget(Target):
|
||||
kind = "pc"
|
||||
label = "This PC"
|
||||
|
||||
def __init__(self):
|
||||
if not winhost.available():
|
||||
raise RuntimeError("PC VR games need Windows (or FramePort running in WSL on Windows)")
|
||||
self.steam = winhost.steam_root()
|
||||
|
||||
# ------------------------------------------------------------------ info
|
||||
def describe(self) -> dict:
|
||||
from ..tools import revive
|
||||
|
||||
root = self.steam
|
||||
return {
|
||||
"platform": "wsl" if winhost.is_wsl() else "windows",
|
||||
"steam": str(root) if root else None,
|
||||
"steam_user": bool(root and winhost.steam_user(root)),
|
||||
"steam_running": winhost.process_running("steam.exe"),
|
||||
"steamvr": bool(root and winhost.app_installed(root, winhost.STEAMVR_APPID)),
|
||||
"revive": str(revive.revive_dir()) if revive.revive_dir() else None,
|
||||
"revive_version": revive.installed_version(),
|
||||
"installed": self.installed(),
|
||||
}
|
||||
|
||||
def installed(self) -> list[dict]:
|
||||
out = []
|
||||
for f in sorted(pc_dir().glob("*/deployment.json")):
|
||||
try:
|
||||
dep = json.loads(f.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
dep["apk_present"] = Path(dep.get("exe_local", "")).exists()
|
||||
out.append(dep)
|
||||
return out
|
||||
|
||||
def _dep(self, package: str) -> dict:
|
||||
try:
|
||||
return json.loads((pc_dir() / package / "deployment.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
raise RuntimeError(f"{package} is not installed on this PC") from None
|
||||
|
||||
# ------------------------------------------------------------------ install
|
||||
def install(self, package, title, apk, data_dir, recipe, reporter, apk_only=False):
|
||||
raise NotImplementedError("Quest (APK) games install on the Steam Frame, not on the PC")
|
||||
|
||||
def install_pcvr(self, package, title, game_dir, exe, recipe: Recipe, reporter: Reporter, **extra):
|
||||
from ..tools import revive
|
||||
|
||||
reporter.stage("Prepare this PC")
|
||||
if not self.steam:
|
||||
raise RuntimeError("Steam for Windows was not found on this PC")
|
||||
reporter.check("Steam", True, str(self.steam))
|
||||
vr = winhost.app_installed(self.steam, winhost.STEAMVR_APPID)
|
||||
reporter.check("SteamVR", vr or None, "installed" if vr else "not installed: install SteamVR from Steam")
|
||||
use_revive = "pcvr.revive" in recipe.patches
|
||||
rdir = None
|
||||
if use_revive:
|
||||
rdir = revive.revive_dir() or revive.install(lambda f: reporter.progress(f, "downloading Revive"))
|
||||
rdir = windows_revive(rdir)
|
||||
reporter.check("Revive", True, f"{revive.installed_version()} at {winhost.to_windows(rdir)}")
|
||||
game_dir = Path(game_dir)
|
||||
exe_local = game_dir / exe
|
||||
if not exe_local.is_file():
|
||||
raise RuntimeError(f"game executable missing: {exe_local}")
|
||||
dep = {"package": package, "kind": "pcvr", "title": title, "game_dir": str(game_dir),
|
||||
"exe_local": str(exe_local), "exe_win": winhost.to_windows(exe_local),
|
||||
"revive_win": winhost.to_windows(rdir) if rdir else None,
|
||||
"revive_version": revive.installed_version() if rdir else None,
|
||||
"backend": "openvr" if "pcvr.revive_openvr" in recipe.patches else "openxr",
|
||||
"game_args": game_args(recipe),
|
||||
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
|
||||
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
|
||||
exe_field, _, _ = shortcut_fields(dep)
|
||||
dep["appid"] = _vdf().shortcut_appid(exe_field, title)
|
||||
d = pc_dir() / package
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "deployment.json").write_text(json.dumps(dep, indent=2), encoding="utf-8")
|
||||
reporter.log(f"ready to run from {dep['exe_win']}" + (" through Revive" if rdir else ""))
|
||||
return {"ok": True, "appid": dep["appid"], "exe": dep["exe_win"]}
|
||||
|
||||
def add_to_library(self, packages, reporter):
|
||||
from ..artwork import fetch as artwork
|
||||
from ..artwork import sources
|
||||
from ..artwork.steam import steam_set, steam_tags
|
||||
from ..core import library
|
||||
|
||||
reporter.stage("Add to Steam library (this PC)")
|
||||
root = self.steam
|
||||
user = winhost.steam_user(root) if root else None
|
||||
if not user:
|
||||
raise RuntimeError("could not tell which Steam user to add the games for (log in to Steam once)")
|
||||
vdf_mod = _vdf()
|
||||
vdf = root / "userdata" / user / "config" / "shortcuts.vdf"
|
||||
was_running = winhost.stop_steam(root)
|
||||
added, errors = [], []
|
||||
try:
|
||||
grid = vdf.parent / "grid"
|
||||
grid.mkdir(parents=True, exist_ok=True)
|
||||
for pkg in packages:
|
||||
try:
|
||||
dep = self._dep(pkg)
|
||||
exe, start, opts = shortcut_fields(dep)
|
||||
entry = library.game(pkg) or {"kind": "rift"}
|
||||
if not sources.has_art(pkg):
|
||||
artwork.fetch(pkg, lookup=dep.get("art_lookup"))
|
||||
art = steam_set(pkg)
|
||||
for stale in vdf_mod.prune_shortcuts(str(vdf), dep["title"], exe, TAG):
|
||||
for old in grid.glob(f"{stale}*"):
|
||||
old.unlink(missing_ok=True)
|
||||
reporter.log(f"removed the old Steam entry for {dep['title']} (its launch command changed)")
|
||||
ident = vdf_mod.shortcut_appid(exe, dep["title"])
|
||||
icon = dep["exe_win"]
|
||||
if "icon" in art: # Steam wants a local path for the shortcut icon
|
||||
icon_path = grid / f"{ident}_icon.png"
|
||||
shutil.copy(art["icon"], icon_path)
|
||||
icon = winhost.to_windows(icon_path)
|
||||
got = vdf_mod.upsert_shortcut(str(vdf), exe, dep["title"], start, icon, TAG, opts,
|
||||
tags=steam_tags(entry, "pc"))
|
||||
for kind, f in art.items():
|
||||
suffix = {"portrait": "p", "landscape": "", "hero": "_hero", "logo": "_logo"}.get(kind)
|
||||
if suffix is None:
|
||||
continue
|
||||
for old in grid.glob(f"{got}{suffix}.*"):
|
||||
old.unlink()
|
||||
shutil.copy(f, grid / f"{got}{suffix}{f.suffix}")
|
||||
added.append({"package": pkg, "appid": got})
|
||||
reporter.check(f"Steam library (PC): {dep['title']}", True, f"shortcut {got}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"{pkg}: {exc}")
|
||||
reporter.check("Steam library (PC)", False, f"{pkg}: {exc}")
|
||||
finally:
|
||||
if was_running:
|
||||
winhost.start_steam(root)
|
||||
return {"state": "done" if not errors else "failed", "added": added, "errors": errors}
|
||||
|
||||
# ------------------------------------------------------------------ test / manage
|
||||
def launch(self, package):
|
||||
"""Through Windows Steam's shortcut, like clicking Play in Steam. Starts SteamVR first (Revive needs it, or the
|
||||
game runs as a flat window)."""
|
||||
dep = self._dep(package)
|
||||
root = winhost.steam_root()
|
||||
if not root or not dep.get("appid"):
|
||||
raise RuntimeError("Steam or the game's Steam shortcut wasn't found on this PC")
|
||||
vr = winhost.start_steamvr(root) # Revive binds to SteamVR; without it the game falls back to flatscreen
|
||||
winhost.start_detached(root / "steam.exe", [f"steam://rungameid/{(int(dep['appid']) << 32) | 0x02000000}"])
|
||||
return {"package": package, "title": dep.get("title"), "steamvr": vr}
|
||||
|
||||
def launch_test(self, package, reporter, seconds=45):
|
||||
reporter.stage("Launch test (this PC)")
|
||||
dep = self._dep(package)
|
||||
image = Path(dep["exe_local"]).name
|
||||
if winhost.process_running(image):
|
||||
raise RuntimeError(f"{dep['title']} is already running")
|
||||
root = winhost.steam_root()
|
||||
if root and dep.get("revive_win"): # a direct injector start bypasses Steam, so bring SteamVR up ourselves
|
||||
reporter.stage("Starting SteamVR")
|
||||
if not winhost.start_steamvr(root):
|
||||
reporter.log("SteamVR didn't start; the game may run as a flat window (no VR).")
|
||||
exe, _, opts = shortcut_fields(dep)
|
||||
exe_path = winhost.to_local(exe.strip('"'))
|
||||
import shlex
|
||||
|
||||
args = shlex.split(opts, posix=True) if opts else []
|
||||
log_path = (winhost.env_path("LOCALAPPDATA") or Path("/nonexistent")) / REVIVE_LOG
|
||||
before = log_path.stat().st_mtime if log_path.exists() else 0
|
||||
winhost.start_detached(exe_path, args, cwd=Path(dep["exe_local"]).parent)
|
||||
start, seen, state = time.time(), False, "RUNNING"
|
||||
while time.time() - start < seconds:
|
||||
time.sleep(3)
|
||||
running = winhost.process_running(image)
|
||||
seen = seen or running
|
||||
if seen and not running:
|
||||
state = "EXITED"
|
||||
break
|
||||
if not seen:
|
||||
state = "NEVER_STARTED"
|
||||
winhost.kill(image)
|
||||
log = ""
|
||||
if log_path.exists() and log_path.stat().st_mtime >= before:
|
||||
log = "===== ReviveInjector.txt\n" + log_path.read_text(encoding="utf-8", errors="replace")
|
||||
result = triage(log, state, package)
|
||||
reporter.check("Process", state == "RUNNING", f"{state} after {round(time.time() - start)}s")
|
||||
for m in result.milestones:
|
||||
reporter.check(m, True)
|
||||
for f in result.findings:
|
||||
reporter.check(f.id, None if f.severity in ("warning", "info") else False, f"{f.diagnosis} [{f.evidence[:160]}]")
|
||||
return result, log
|
||||
|
||||
def set_settings(self, package, settings):
|
||||
return {"settings": {}}
|
||||
|
||||
def uninstall(self, package, keep_data=True):
|
||||
dep = self._dep(package)
|
||||
root = self.steam
|
||||
user = winhost.steam_user(root) if root else None
|
||||
removed = False
|
||||
if user:
|
||||
vdf = root / "userdata" / user / "config" / "shortcuts.vdf"
|
||||
was_running = winhost.stop_steam(root)
|
||||
try:
|
||||
removed = _vdf().remove_shortcut(str(vdf), shortcut_fields(dep)[0])
|
||||
finally:
|
||||
if was_running:
|
||||
winhost.start_steam(root)
|
||||
shutil.rmtree(pc_dir() / package, ignore_errors=True)
|
||||
return {"removed": True, "shortcut_removed": removed, "kept_saves": True}
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Planned: PC VR target via Revive (Rift/Meta PC store games → SteamVR, streamed to the Frame).
|
||||
|
||||
Not implemented yet. It will implement targets.base.Target: detect a Revive install on the PC, register the game
|
||||
with SteamVR (vrmanifest), and use Steam's streaming to the Frame. Catalog entries already carry
|
||||
`pcvr_alternative` notes for games that can't run standalone (32-bit titles, GPU driver crashes).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import Target
|
||||
|
||||
|
||||
class ReviveTarget(Target): # pragma: no cover - placeholder
|
||||
kind = "revive"
|
||||
label = "PC VR (Revive)"
|
||||
|
||||
def _todo(self, *a, **k):
|
||||
raise NotImplementedError("The Revive target is planned but not implemented yet.")
|
||||
|
||||
describe = installed = install = add_to_library = launch_test = set_settings = uninstall = _todo
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Revive (LibreVR) as a portable tool: the latest ReviveInstaller.exe is downloaded from GitHub and the files FramePort
|
||||
needs are unpacked from it in pure Python (no installer run, no admin rights, works on every host OS).
|
||||
|
||||
Revive only ships as an NSIS installer. Its data is non-solid deflate, so each file is a separate deflate stream; we
|
||||
read the NSIS header (entries + string table) to map file names to data offsets. Only the runtime files are kept:
|
||||
ReviveInjector.exe, LibRevive{32,64}.dll, LibReviveXR{32,64}.dll, openvr_api{32,64}.dll and the Input/ bindings.
|
||||
Which Revive is used, in order: FRAMEPORT_REVIVE_DIR (a folder containing ReviveInjector.exe), a Revive the user
|
||||
installed on this Windows PC (the official installer: registry HKLM/HKCU\\Software\\Revive, default
|
||||
C:\\Program Files\\Revive), then FramePort's own portable copy (downloaded when first needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..core import cache
|
||||
from ..core.paths import tools_dir
|
||||
|
||||
REVIVE_RELEASE = "https://api.github.com/repos/LibreVR/Revive/releases/latest"
|
||||
INSTALLER = "ReviveInstaller.exe"
|
||||
RUNTIME_FILES = ("ReviveInjector.exe", "LibRevive32.dll", "LibRevive64.dll", "LibReviveXR32.dll", "LibReviveXR64.dll",
|
||||
"openvr_api32.dll", "openvr_api64.dll", "LICENSE")
|
||||
KEEP_DIRS = ("Input",)
|
||||
|
||||
NSIS_SIG = b"\xef\xbe\xad\xdeNullsoftInst"
|
||||
EW_CREATEDIR, EW_EXTRACTFILE = 11, 20 # NSIS 3 opcodes (SetOutPath, File)
|
||||
NS_LANG, NS_SHELL, NS_VAR, NS_SKIP = 1, 2, 3, 4 # Unicode NSIS 3 special codes, each followed by one 16-bit param
|
||||
VAR_INSTDIR, VAR_OUTDIR_BASE = 21, 31 # $INSTDIR, $_OUTDIR (File /r base folder; equals $INSTDIR here)
|
||||
|
||||
|
||||
class NsisError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class NsisFile:
|
||||
path: str # relative to $INSTDIR (backslashes turned into /)
|
||||
offset: int # absolute offset of the block (4-byte length prefix) in the installer
|
||||
|
||||
|
||||
def _read_block(data: bytes, offset: int) -> bytes:
|
||||
n = struct.unpack_from("<I", data, offset)[0]
|
||||
raw = data[offset + 4:offset + 4 + (n & 0x7FFFFFFF)]
|
||||
return zlib.decompressobj(-15).decompress(raw) if n & 0x80000000 else raw
|
||||
|
||||
|
||||
def nsis_files(data: bytes) -> list[NsisFile]:
|
||||
"""Files an NSIS 3 (Unicode, non-solid deflate) installer extracts, in script order."""
|
||||
sig = data.find(NSIS_SIG)
|
||||
if sig < 4:
|
||||
raise NsisError("not an NSIS installer")
|
||||
start = sig + 24 # after firstheader: flags, siginfo, magic, header length, total length
|
||||
first = struct.unpack_from("<I", data, start)[0]
|
||||
if not first & 0x80000000:
|
||||
raise NsisError("unsupported NSIS layout (solid or uncompressed header)")
|
||||
try:
|
||||
header = _read_block(data, start)
|
||||
except zlib.error as exc:
|
||||
raise NsisError(f"unsupported NSIS compression: {exc}") from None
|
||||
data_base = start + 4 + (first & 0x7FFFFFFF)
|
||||
blocks = [struct.unpack_from("<II", header, 4 + 8 * k) for k in range(8)]
|
||||
(ent_off, n_ent), (str_off, _) = blocks[2], blocks[3]
|
||||
str_end = blocks[4][0]
|
||||
strings = header[str_off:str_end]
|
||||
if strings[:2] != b"\0\0":
|
||||
raise NsisError("ANSI NSIS installers are not supported")
|
||||
|
||||
def text(index: int) -> str:
|
||||
out, k = [], index * 2
|
||||
while k + 2 <= len(strings):
|
||||
c = struct.unpack_from("<H", strings, k)[0]
|
||||
k += 2
|
||||
if c == 0:
|
||||
break
|
||||
if c in (NS_LANG, NS_SHELL, NS_VAR, NS_SKIP):
|
||||
param = struct.unpack_from("<H", strings, k)[0]
|
||||
k += 2
|
||||
if c == NS_SKIP:
|
||||
out.append(chr(param))
|
||||
elif c == NS_VAR and ((param & 0x7F) | ((param & 0x7F00) >> 1)) in (VAR_INSTDIR, VAR_OUTDIR_BASE):
|
||||
out.append("\1") # the install folder
|
||||
else:
|
||||
out.append("\0") # another variable / shell folder / language string
|
||||
continue
|
||||
out.append(chr(c))
|
||||
return "".join(out)
|
||||
|
||||
files, outdir = [], ""
|
||||
for k in range(n_ent):
|
||||
which, *parm = struct.unpack_from("<7I", header, ent_off + 28 * k)
|
||||
if which == EW_CREATEDIR:
|
||||
path = text(parm[0])
|
||||
# only folders under the install folder; others ($PLUGINSDIR, $TEMP, ...) are ignored
|
||||
ok = path.startswith("\1") and "\0" not in path and "\1" not in path[1:]
|
||||
outdir = path[1:].strip("\\").replace("\\", "/") if ok else None
|
||||
elif which == EW_EXTRACTFILE and outdir is not None:
|
||||
name = text(parm[1])
|
||||
if "\0" in name or "\1" in name:
|
||||
continue
|
||||
files.append(NsisFile(f"{outdir}/{name}" if outdir else name, data_base + parm[2]))
|
||||
return files
|
||||
|
||||
|
||||
def extract(installer: Path, dest: Path, wanted=None) -> list[str]:
|
||||
"""Unpack the selected files (default: Revive's runtime files) into dest. Returns the relative paths written."""
|
||||
data = installer.read_bytes()
|
||||
keep = wanted or (lambda p: p in RUNTIME_FILES or p.split("/")[0] in KEEP_DIRS)
|
||||
written = []
|
||||
for f in nsis_files(data):
|
||||
if not keep(f.path) or f.path in written:
|
||||
continue
|
||||
target = dest / f.path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(_read_block(data, f.offset))
|
||||
written.append(f.path)
|
||||
missing = [n for n in RUNTIME_FILES if n not in written] if wanted is None else []
|
||||
if missing:
|
||||
raise NsisError(f"installer is missing {', '.join(missing)}")
|
||||
return written
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ tool management
|
||||
def _state_file() -> Path:
|
||||
return tools_dir() / "revive.json"
|
||||
|
||||
|
||||
def _usable(path: Path | None) -> bool:
|
||||
return bool(path) and (path / "ReviveInjector.exe").is_file() and (path / "LibReviveXR64.dll").is_file()
|
||||
|
||||
|
||||
_system_cache: tuple[float, Path | None] | None = None
|
||||
|
||||
|
||||
def system_revive(max_age: float = 60) -> Path | None:
|
||||
"""A Revive installed with the official installer on this Windows PC (also found from WSL). The default folder is
|
||||
checked first; the registry (slow from WSL) only when it isn't there. Cached for max_age seconds."""
|
||||
import time
|
||||
|
||||
from ..core import winhost
|
||||
|
||||
global _system_cache
|
||||
if _system_cache and time.time() - _system_cache[0] < max_age:
|
||||
return _system_cache[1]
|
||||
found = None
|
||||
if winhost.available():
|
||||
defaults = [Path(os.environ[v]) / "Revive" for v in ("ProgramW6432", "ProgramFiles") if os.environ.get(v)] \
|
||||
if winhost.is_windows() else []
|
||||
defaults.append(winhost.to_local(r"C:\Program Files\Revive"))
|
||||
found = next((c for c in defaults if _usable(c)), None)
|
||||
if not found:
|
||||
for hive in ("HKLM", "HKCU"):
|
||||
v = winhost.reg_query(hive + r"\Software\Revive", "")
|
||||
if v and _usable(winhost.to_local(v.strip('"'))):
|
||||
found = winhost.to_local(v.strip('"'))
|
||||
break
|
||||
_system_cache = (time.time(), found)
|
||||
return found
|
||||
|
||||
|
||||
def release_for(path: Path) -> str | None:
|
||||
"""Revive release a folder's files came from: the first GitHub release published on/after LibReviveXR64.dll was
|
||||
built (Revive's version resources are stale: the DLLs carry the LibOVR version, the dashboard says 3.0.0.0)."""
|
||||
import datetime as dt
|
||||
|
||||
try:
|
||||
built = dt.datetime.fromtimestamp((path / "LibReviveXR64.dll").stat().st_mtime, dt.timezone.utc)
|
||||
except OSError:
|
||||
return None
|
||||
rels = cache.cached_json("revive-releases.json", REVIVE_RELEASE.rsplit("/", 1)[0] + "?per_page=50",
|
||||
max_age=7 * 86400) or []
|
||||
best = None
|
||||
for r in rels:
|
||||
try:
|
||||
when = dt.datetime.fromisoformat(r["published_at"].replace("Z", "+00:00"))
|
||||
except (KeyError, ValueError, AttributeError):
|
||||
continue
|
||||
if when >= built - dt.timedelta(days=1) and (best is None or when < best[0]):
|
||||
best = (when, r.get("tag_name"))
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _managed() -> tuple[Path, str] | None:
|
||||
try:
|
||||
st = json.loads(_state_file().read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
path = Path(st.get("path", ""))
|
||||
return (path, st.get("version")) if _usable(path) else None
|
||||
|
||||
|
||||
def source() -> str | None:
|
||||
"""Where the Revive in use comes from: "env" | "system" | "managed" | None."""
|
||||
if os.environ.get("FRAMEPORT_REVIVE_DIR"):
|
||||
return "env" if _usable(Path(os.environ["FRAMEPORT_REVIVE_DIR"])) else None
|
||||
if system_revive():
|
||||
return "system"
|
||||
return "managed" if _managed() else None
|
||||
|
||||
|
||||
def revive_dir() -> Path | None:
|
||||
"""Folder with ReviveInjector.exe + the Revive DLLs, or None."""
|
||||
src = source()
|
||||
if src == "env":
|
||||
return Path(os.environ["FRAMEPORT_REVIVE_DIR"])
|
||||
if src == "system":
|
||||
return system_revive()
|
||||
return _managed()[0] if src == "managed" else None
|
||||
|
||||
|
||||
def installed_version() -> str | None:
|
||||
src = source()
|
||||
if src == "managed":
|
||||
return _managed()[1]
|
||||
if src in ("env", "system"):
|
||||
return release_for(revive_dir()) or src
|
||||
return None
|
||||
|
||||
|
||||
def latest() -> tuple[str, str] | None:
|
||||
rel = cache.cached_json("revive-release.json", REVIVE_RELEASE, max_age=24 * 3600)
|
||||
if not rel:
|
||||
return None
|
||||
asset = next((a for a in rel.get("assets", []) if a["name"] == INSTALLER), None)
|
||||
return (rel["tag_name"], asset["browser_download_url"]) if asset else None
|
||||
|
||||
|
||||
def install(progress=None, force: bool = False) -> Path:
|
||||
"""FramePort's portable copy (skipped when a Revive is already installed on the system, unless force)."""
|
||||
if not force and source() in ("env", "system"):
|
||||
return revive_dir()
|
||||
found = latest()
|
||||
if not found:
|
||||
raise RuntimeError("could not reach GitHub to find the latest Revive release")
|
||||
version, url = found
|
||||
dest = tools_dir() / f"revive-{version}"
|
||||
if not (dest / "ReviveInjector.exe").exists():
|
||||
archive = cache.download(url, tools_dir() / f"ReviveInstaller-{version}.exe", progress)
|
||||
tmp = dest.with_name(dest.name + ".tmp")
|
||||
extract(archive, tmp)
|
||||
tmp.replace(dest)
|
||||
archive.unlink(missing_ok=True)
|
||||
_state_file().write_text(json.dumps({"version": version, "path": str(dest)}, indent=2))
|
||||
return dest
|
||||
|
||||
|
||||
def runtime_files(root: Path | None = None) -> list[Path]:
|
||||
"""Every file under the Revive folder that a game install needs (for packing onto the Frame)."""
|
||||
root = root or revive_dir()
|
||||
if not root:
|
||||
return []
|
||||
return sorted(p for p in root.rglob("*") if p.is_file() and
|
||||
(p.relative_to(root).as_posix() in RUNTIME_FILES or p.relative_to(root).parts[0] in KEEP_DIRS))
|
||||
@@ -34,6 +34,7 @@ class ToolStatus:
|
||||
path: Path | None
|
||||
latest: str | None = None
|
||||
detail: str = ""
|
||||
optional: bool = False # only needed for some games (Revive: Rift/PC VR games)
|
||||
|
||||
|
||||
def _os_arch() -> tuple[str, str]:
|
||||
@@ -200,7 +201,7 @@ def install_apksigner(progress=None) -> ToolStatus:
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ status / all
|
||||
def status(check_latest: bool = False) -> list[ToolStatus]:
|
||||
def status(check_latest: bool = False, include_optional: bool = True) -> list[ToolStatus]:
|
||||
st = _state()
|
||||
out = []
|
||||
for name, getter in (("java", java_path), ("overport", overport_jar), ("apksigner", apksigner_jar)):
|
||||
@@ -217,17 +218,55 @@ def status(check_latest: bool = False) -> list[ToolStatus]:
|
||||
except Exception:
|
||||
latest = None
|
||||
out.append(ToolStatus(name, bool(path and path.exists()), version, path, latest))
|
||||
if not include_optional:
|
||||
return out
|
||||
from . import revive
|
||||
|
||||
rdir = revive.revive_dir()
|
||||
latest = None
|
||||
if check_latest:
|
||||
try:
|
||||
latest = (revive.latest() or (None,))[0]
|
||||
except Exception:
|
||||
latest = None
|
||||
where = {"system": "your installed Revive", "env": "FRAMEPORT_REVIVE_DIR", "managed": "FramePort's copy"}.get(
|
||||
revive.source() or "", "")
|
||||
out.append(ToolStatus("revive", rdir is not None, revive.installed_version(), rdir, latest,
|
||||
"Revive (LibreVR): runs Oculus Rift games on OpenXR/SteamVR"
|
||||
+ (f" · using {where}" if where else ""), optional=True))
|
||||
return out
|
||||
|
||||
|
||||
def ensure_all(progress=None, update: bool = False) -> list[ToolStatus]:
|
||||
"""Install whatever is missing (or outdated when update=True)."""
|
||||
def revive_is_users() -> bool:
|
||||
from . import revive
|
||||
|
||||
return revive.source() in ("env", "system")
|
||||
|
||||
|
||||
def install_revive(progress=None) -> ToolStatus:
|
||||
from . import revive
|
||||
|
||||
path = revive.install(progress)
|
||||
return ToolStatus("revive", True, revive.installed_version(), path, optional=True)
|
||||
|
||||
|
||||
def ensure_all(progress=None, update: bool = False, optional: bool = False) -> list[ToolStatus]:
|
||||
"""Install whatever is missing (or outdated when update=True). Optional tools only with optional=True or when
|
||||
already installed (then they are updated like the others)."""
|
||||
results = []
|
||||
for s in status(check_latest=update):
|
||||
if s.installed and not (update and s.latest and s.version and s.latest != s.version):
|
||||
if s.optional and not optional and not s.installed:
|
||||
results.append(s)
|
||||
continue
|
||||
installer = {"java": install_java, "overport": install_overport, "apksigner": install_apksigner}[s.name]
|
||||
if s.name == "revive" and s.installed and revive_is_users():
|
||||
results.append(s) # never replace a Revive the user installed themselves
|
||||
continue
|
||||
if s.installed and not (update and s.latest and s.version and s.latest != s.version
|
||||
and s.version != "external"):
|
||||
results.append(s)
|
||||
continue
|
||||
installer = {"java": install_java, "overport": install_overport, "apksigner": install_apksigner,
|
||||
"revive": install_revive}[s.name]
|
||||
results.append(installer(progress))
|
||||
return results
|
||||
|
||||
|
||||
+1059
-489
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,278 @@
|
||||
"""Reusable GUI building blocks (all styling comes from ui.theme)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import flet as ft
|
||||
|
||||
from . import theme as T
|
||||
from .help import HELP
|
||||
|
||||
STATUS_STYLE = {
|
||||
"works": ("Works", T.OK),
|
||||
"issues": ("Works with issues", T.WARN),
|
||||
"unsupported": ("Can't run", T.ERROR),
|
||||
"unknown": ("Untested", T.TEXT_3),
|
||||
}
|
||||
# per-game state on a target: label, icon, color
|
||||
INSTALL_STYLE = {
|
||||
"installed": ("On Frame", ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK),
|
||||
"outdated": ("Update ready", ft.Icons.UPDATE_ROUNDED, T.WARN),
|
||||
"missing": ("Not installed", ft.Icons.CLOUD_OFF_ROUNDED, T.TEXT_3),
|
||||
"on_pc": ("On this PC", ft.Icons.COMPUTER_ROUNDED, T.PC),
|
||||
}
|
||||
|
||||
|
||||
def _deployment(game: dict, frame_info: dict | None) -> dict | None:
|
||||
return next((d for d in (frame_info or {}).get("installed", []) if d.get("package") == game.get("package")), None)
|
||||
|
||||
|
||||
def settings_diff(game: dict, frame_info: dict | None) -> tuple[list[str], list[str]] | None:
|
||||
"""(patches turned on, patches turned off) in the app since the Frame's copy was installed; None if the same (or
|
||||
unknown: installs from before FramePort recorded their recipe)."""
|
||||
dep = _deployment(game, frame_info)
|
||||
rec = (dep or {}).get("recipe")
|
||||
if not isinstance(rec, dict) or "patches" not in rec:
|
||||
return None
|
||||
installed, now = set(rec["patches"]), set((game.get("recipe") or {}).get("patches") or {})
|
||||
added, removed = sorted(now - installed), sorted(installed - now)
|
||||
return (added, removed) if added or removed else None
|
||||
|
||||
|
||||
def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
"""None when no Frame is connected; else 'installed', 'outdated' (the local build, or the patch settings, differ
|
||||
from what the Frame runs) or 'missing'."""
|
||||
if frame_info is None:
|
||||
return None
|
||||
dep = _deployment(game, frame_info)
|
||||
if not dep or dep.get("apk_present") is False: # no record, or the game files are gone (half uninstalled)
|
||||
return "missing"
|
||||
b = game.get("build") or {}
|
||||
built = {b.get("sha256"), b.get("alt_sha256")} - {None}
|
||||
if built and dep.get("sha256") and dep["sha256"] not in built:
|
||||
return "outdated"
|
||||
return "outdated" if settings_diff(game, frame_info) else "installed"
|
||||
|
||||
|
||||
def update(*controls: ft.Control) -> None:
|
||||
"""Update controls that may not be on the page yet (Flet raises for those)."""
|
||||
for c in controls:
|
||||
try:
|
||||
c.update()
|
||||
except (RuntimeError, AssertionError):
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ text
|
||||
def title(text: str, size: int = T.T_TITLE) -> ft.Text:
|
||||
return ft.Text(text, size=size, weight=ft.FontWeight.W_700, color=T.TEXT)
|
||||
|
||||
|
||||
def h2(text: str) -> ft.Text:
|
||||
return ft.Text(text, size=T.T_H2, weight=ft.FontWeight.W_600, color=T.TEXT)
|
||||
|
||||
|
||||
def body(text: str, color: str = T.TEXT_2, size: int = T.T_BODY, **kw) -> ft.Text:
|
||||
return ft.Text(text, size=size, color=color, **kw)
|
||||
|
||||
|
||||
def meta(text: str, color: str = T.TEXT_3, **kw) -> ft.Text:
|
||||
return ft.Text(text, size=T.T_META, color=color, **kw)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ chips / badges
|
||||
def pill(text: str, color: str = T.TEXT_2, icon: str | None = None, solid: bool = False,
|
||||
tooltip: str | ft.Tooltip | None = None, overlay: bool = False) -> ft.Container:
|
||||
"""overlay: for use on top of artwork (dark translucent backing so it reads on any image)."""
|
||||
fg = T.ON_ACCENT if solid else color
|
||||
items = ([ft.Icon(icon, size=13, color=fg)] if icon else []) + \
|
||||
[ft.Text(text, size=T.T_SMALL, color=fg, weight=ft.FontWeight.W_600)]
|
||||
return ft.Container(ft.Row(items, spacing=4, tight=True), tooltip=tooltip,
|
||||
bgcolor=color if solid else T.soft("#000000", 0.62) if overlay else T.soft(color, 0.12),
|
||||
border_radius=20,
|
||||
padding=ft.Padding(8, 3, 9, 3))
|
||||
|
||||
|
||||
def status_chip(status: str) -> ft.Container:
|
||||
label, color = STATUS_STYLE.get(status, STATUS_STYLE["unknown"])
|
||||
return pill(label, color, tooltip=tip(HELP["status"]))
|
||||
|
||||
|
||||
def install_badge(state: str, solid: bool = True) -> ft.Container:
|
||||
label, icon, color = INSTALL_STYLE[state]
|
||||
return pill(label, color, icon, solid=solid, tooltip=tip(HELP["update_ready"]) if state == "outdated" else None)
|
||||
|
||||
|
||||
def dot(color: str, size: int = 8) -> ft.Container:
|
||||
return ft.Container(width=size, height=size, border_radius=size, bgcolor=color)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ help hints
|
||||
def tip(text: str) -> ft.Tooltip:
|
||||
"""A tooltip that wraps long help text (plain string tooltips run across the whole window)."""
|
||||
return ft.Tooltip(message=text, size_constraints=ft.BoxConstraints(max_width=340),
|
||||
padding=ft.Padding(12, 8, 12, 8), prefer_below=True)
|
||||
|
||||
|
||||
def help_icon(key_or_text: str, size: int = 15) -> ft.Container:
|
||||
"""A small "?" that explains a non-obvious term on hover: a key of ui.help.HELP, or the text itself."""
|
||||
return ft.Container(ft.Icon(ft.Icons.HELP_OUTLINE_ROUNDED, size=size, color=T.TEXT_3),
|
||||
tooltip=tip(HELP.get(key_or_text, key_or_text)), padding=2, border_radius=size)
|
||||
|
||||
|
||||
def with_help(control: ft.Control, key: str | None) -> ft.Control:
|
||||
"""`control` followed by a help icon (or just `control` when there's no key)."""
|
||||
if not key:
|
||||
return control
|
||||
return ft.Row([control, help_icon(key)], spacing=4, tight=True,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ buttons
|
||||
def _shape(radius=T.RADIUS_SM):
|
||||
return ft.RoundedRectangleBorder(radius=radius)
|
||||
|
||||
|
||||
def primary(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
|
||||
tooltip: str | None = None, big: bool = False) -> ft.FilledButton:
|
||||
return ft.FilledButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
|
||||
style=ft.ButtonStyle(shape=_shape(), bgcolor={ft.ControlState.DEFAULT: T.ACCENT,
|
||||
ft.ControlState.DISABLED: T.SURFACE_3},
|
||||
color={ft.ControlState.DEFAULT: T.ON_ACCENT,
|
||||
ft.ControlState.DISABLED: T.TEXT_3},
|
||||
padding=ft.Padding(22, 18, 22, 18) if big else ft.Padding(16, 12, 16, 12),
|
||||
text_style=ft.TextStyle(size=15 if big else 13,
|
||||
weight=ft.FontWeight.W_600)))
|
||||
|
||||
|
||||
def secondary(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
|
||||
tooltip: str | None = None) -> ft.OutlinedButton:
|
||||
return ft.OutlinedButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
|
||||
style=ft.ButtonStyle(shape=_shape(), side=ft.BorderSide(1, T.BORDER_STRONG),
|
||||
color={ft.ControlState.DEFAULT: T.TEXT,
|
||||
ft.ControlState.DISABLED: T.TEXT_3},
|
||||
padding=ft.Padding(14, 12, 14, 12)))
|
||||
|
||||
|
||||
def ghost(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
|
||||
tooltip: str | None = None, color: str = T.TEXT_2) -> ft.TextButton:
|
||||
return ft.TextButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
|
||||
style=ft.ButtonStyle(shape=_shape(), color={ft.ControlState.DEFAULT: color,
|
||||
ft.ControlState.DISABLED: T.TEXT_3}))
|
||||
|
||||
|
||||
def icon_btn(icon: str, tooltip: str, on_click: Callable | None = None, disabled: bool = False,
|
||||
color: str = T.TEXT_2) -> ft.IconButton:
|
||||
return ft.IconButton(icon, tooltip=tooltip, on_click=on_click, disabled=disabled, icon_color=color,
|
||||
icon_size=20, style=ft.ButtonStyle(shape=_shape()))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ containers
|
||||
def card(content: ft.Control, padding: int = T.S4, bgcolor: str = T.SURFACE, **kw) -> ft.Container:
|
||||
return ft.Container(content, padding=padding, bgcolor=bgcolor, border_radius=T.RADIUS,
|
||||
border=ft.Border.all(1, T.BORDER), **kw)
|
||||
|
||||
|
||||
def section(heading: str, *controls: ft.Control, subtitle: str | None = None,
|
||||
action: ft.Control | None = None, help: str | None = None) -> ft.Column:
|
||||
head = ft.Row([ft.Column([with_help(h2(heading), help)] + ([meta(subtitle)] if subtitle else []), spacing=2,
|
||||
expand=True)]
|
||||
+ ([action] if action else []), vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
return ft.Column([head, *controls], spacing=T.S3)
|
||||
|
||||
|
||||
def callout(text: str | ft.Control, kind: str = "info", icon: str | None = None) -> ft.Container:
|
||||
color = {"info": T.INFO, "warn": T.WARN, "error": T.ERROR, "ok": T.OK, "pc": T.PC}[kind]
|
||||
icon = icon or {"info": ft.Icons.INFO_OUTLINE_ROUNDED, "warn": ft.Icons.WARNING_AMBER_ROUNDED,
|
||||
"error": ft.Icons.ERROR_OUTLINE_ROUNDED, "ok": ft.Icons.CHECK_CIRCLE_OUTLINE_ROUNDED,
|
||||
"pc": ft.Icons.COMPUTER_ROUNDED}[kind]
|
||||
content = body(text, T.TEXT) if isinstance(text, str) else text
|
||||
return ft.Container(ft.Row([ft.Icon(icon, color=color, size=18), ft.Container(content, expand=True)],
|
||||
spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.START),
|
||||
bgcolor=T.soft(color, 0.08), border=ft.Border.all(1, T.soft(color, 0.35)),
|
||||
border_radius=T.RADIUS_SM, padding=ft.Padding(14, 12, 14, 12))
|
||||
|
||||
|
||||
def empty_state(icon: str, heading: str, text: str, *actions: ft.Control) -> ft.Container:
|
||||
return ft.Container(ft.Column([
|
||||
ft.Container(ft.Icon(icon, size=40, color=T.ACCENT), width=84, height=84, border_radius=42,
|
||||
bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
|
||||
ft.Container(height=T.S2),
|
||||
title(heading, 22),
|
||||
ft.Container(body(text, text_align=ft.TextAlign.CENTER), width=460),
|
||||
ft.Container(height=T.S2),
|
||||
ft.Row(list(actions), alignment=ft.MainAxisAlignment.CENTER, spacing=T.S3),
|
||||
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S2, tight=True),
|
||||
alignment=ft.Alignment.CENTER, expand=True)
|
||||
|
||||
|
||||
def kv(label: str, value: str | ft.Control, help: str | None = None) -> ft.Row:
|
||||
return ft.Row([ft.Container(with_help(meta(label), help), width=130),
|
||||
ft.Container(value if isinstance(value, ft.Control) else body(value, T.TEXT, selectable=True),
|
||||
expand=True)], vertical_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
|
||||
def status_row(ok: bool | None, heading: str, detail: str = "", action: ft.Control | None = None,
|
||||
help: str | None = None) -> ft.Container:
|
||||
icon, color = {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.ERROR_ROUNDED, T.ERROR),
|
||||
None: (ft.Icons.RADIO_BUTTON_UNCHECKED_ROUNDED, T.WARN)}[ok]
|
||||
return ft.Container(ft.Row([
|
||||
ft.Icon(icon, color=color, size=20),
|
||||
ft.Column([with_help(body(heading, T.TEXT, weight=ft.FontWeight.W_500), help)]
|
||||
+ ([meta(detail)] if detail else []),
|
||||
spacing=1, expand=True),
|
||||
*([action] if action else []),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3), padding=ft.Padding(0, 6, 0, 6))
|
||||
|
||||
|
||||
def art_fill(src: str | None, radius: int = T.RADIUS, placeholder_icon: str = ft.Icons.VIDEOGAME_ASSET_ROUNDED,
|
||||
hero: bool = False, **kw) -> ft.Container:
|
||||
"""Artwork that covers its box (cards, heroes), by asset URL (artwork.thumbs.url — never image bytes: those are
|
||||
re-sent on every update); an accent gradient with a large faint icon when there's none."""
|
||||
if src:
|
||||
return ft.Container(image=ft.DecorationImage(src=src, fit=ft.BoxFit.COVER), border_radius=radius,
|
||||
bgcolor=T.SURFACE_2, **kw)
|
||||
icon = ft.Icon(placeholder_icon, size=220 if hero else 56, color=T.soft(T.ACCENT, 0.22 if hero else 0.45))
|
||||
return ft.Container(ft.Container(icon, padding=ft.Padding(0, 0, 60, 0) if hero else 0), border_radius=radius,
|
||||
alignment=ft.Alignment.CENTER_RIGHT if hero else ft.Alignment.CENTER,
|
||||
gradient=ft.LinearGradient(begin=ft.Alignment.TOP_LEFT, end=ft.Alignment.BOTTOM_RIGHT,
|
||||
colors=[T.ACCENT_SOFT, T.SURFACE, T.BG]), **kw)
|
||||
|
||||
|
||||
def bottom_fade(height: int | None = None, strength: float = 0.85) -> ft.Container:
|
||||
return ft.Container(height=height, border_radius=T.RADIUS,
|
||||
gradient=ft.LinearGradient(begin=ft.Alignment.TOP_CENTER, end=ft.Alignment.BOTTOM_CENTER,
|
||||
colors=[ft.Colors.TRANSPARENT, T.soft("#000000", strength)]))
|
||||
|
||||
|
||||
def confirm(page: ft.Page, heading: str, text: str, ok_label: str, on_ok: Callable[[], None],
|
||||
danger: bool = False) -> None:
|
||||
def go(e):
|
||||
page.pop_dialog()
|
||||
on_ok()
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(heading, weight=ft.FontWeight.W_600), content=ft.Container(body(text), width=420),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[ghost("Cancel", on_click=lambda e: page.pop_dialog()),
|
||||
ft.FilledButton(ok_label, on_click=go, style=ft.ButtonStyle(
|
||||
shape=_shape(), bgcolor=T.ERROR if danger else T.ACCENT, color=T.ON_ACCENT))]))
|
||||
|
||||
|
||||
def progress_bar(value: float | None = None, color: str = T.ACCENT) -> ft.ProgressBar:
|
||||
return ft.ProgressBar(value=value, color=color, bgcolor=T.SURFACE_3, border_radius=4, bar_height=6)
|
||||
|
||||
|
||||
def menu_items(actions: list[tuple | None]) -> list[ft.PopupMenuItem]:
|
||||
"""[(label, icon, handler) | None] → popup menu items; None becomes a divider (never first, last or doubled)."""
|
||||
out: list[ft.PopupMenuItem] = []
|
||||
for a in actions:
|
||||
if a is None:
|
||||
if out and out[-1].content is not None:
|
||||
out.append(ft.PopupMenuItem())
|
||||
continue
|
||||
label, icon, handler = a
|
||||
out.append(ft.PopupMenuItem(content=ft.Text(label), icon=icon, on_click=handler))
|
||||
while out and out[-1].content is None:
|
||||
out.pop()
|
||||
return out
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Help texts for the GUI's non-obvious terms and actions, shown as "?" hints and tooltips (components.help_icon).
|
||||
One place for the wording, so the same term is explained the same way everywhere. Plain text, no Flet."""
|
||||
from __future__ import annotations
|
||||
|
||||
HELP: dict[str, str] = {
|
||||
# ---- game status / library
|
||||
"status": "How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" come from recipes tested "
|
||||
"on a real Frame. \"Untested\" means FramePort suggested a recipe from the game's engine and VR API that "
|
||||
"nobody has confirmed yet. \"Can't run\" means there's a known blocker (for example a 32-bit-only game).",
|
||||
"update_ready": "The copy on your Frame differs from what FramePort would install now (the recipe or your game "
|
||||
"files changed). Update to install the new build; saves are kept.",
|
||||
"check_exe": "This game's folder has several programs and FramePort isn't sure which one starts the game. Open the "
|
||||
"game (or right-click → Change executable) to pick it.",
|
||||
"platform_quest": "A Meta Quest game (APK). It's rebuilt for the Frame and runs in Lepton, Valve's Android "
|
||||
"container.",
|
||||
"platform_pcvr": "An Oculus Rift PC VR game. It runs through Revive (Oculus → OpenXR), either on this PC or on the "
|
||||
"Frame with Proton.",
|
||||
"select": "Pick several games and install them in one go. FramePort asks every question first, then works through "
|
||||
"the queue in the background.",
|
||||
"tags": "Your own tags (filled) can be anything you like. Outlined tags are added automatically from the engine, "
|
||||
"VR API and store genres. Filter the library by either.",
|
||||
# ---- game page
|
||||
"recipe": "A recipe is the list of patches FramePort applies to a game before installing it. Known-good recipes "
|
||||
"were tested on a Frame; the others are suggested from the game's engine and VR API. Hover a patch "
|
||||
"for why it's there.",
|
||||
"standard_fixes": "Fixes every game of this kind gets (on by default). Customize lists them all.",
|
||||
"as_is": "Turn this on if your copy is already patched. Quest: the APK is installed unchanged instead of being "
|
||||
"patched again. PC VR: your folder is never modified either way; only the Frame's copy gets launch fixes, "
|
||||
"and Revive still runs when the game uses the Oculus PC SDK (a repack's own Oculus runtime, such as "
|
||||
"Virtual Desktop's launcher, doesn't exist on the Frame). Turn Revive off under Customize for SteamVR "
|
||||
"builds.",
|
||||
"alt_build": "Some games need a second build with different overport patches (for some Unreal games: one with "
|
||||
"Unreal's ForceQuit removed). Turn this on to install that build instead.",
|
||||
"show_all": "Patches that can't matter for this game (wrong engine or API) are hidden. Show them to force one on "
|
||||
"anyway.",
|
||||
"where": "Steam Frame: installed on the headset with an entry in its Steam library. This PC: a Steam shortcut on "
|
||||
"this Windows PC that starts the game through Revive, for a PC-tethered headset.",
|
||||
"launch_test": "Starts the game on the Frame while nobody is wearing it and reads the log: did it start, create a "
|
||||
"VR session and render frames. It can't check what you'd see: tracking only runs with the "
|
||||
"headset on.",
|
||||
"uninstall": "Removes the game from the Frame. Saves are kept, so a reinstall picks up where you left off.",
|
||||
"known_good": "Saves the current patches as a known-good recipe in your own catalog, so they're used again when "
|
||||
"the game is re-added. Do this after it worked in the headset.",
|
||||
"rebuild": "Applies the patches and runs the checks, without sending anything to the Frame.",
|
||||
"reset_recipe": "Discards your changes to the patches and goes back to the recipe FramePort suggests.",
|
||||
"experimental": "Not verified on many games yet: try it if the game doesn't work without it.",
|
||||
"abis": "The CPU types the game ships code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games "
|
||||
"can't run on it.",
|
||||
"graphics": "OpenGL ES games run on the Frame through Zink (OpenGL on top of Vulkan), which is stricter than "
|
||||
"Quest drivers. Vulkan games run natively.",
|
||||
"recipe_source": "Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by "
|
||||
"FramePort) or user (changed by you).",
|
||||
# ---- patch categories
|
||||
"cat_pcvr": "How a Rift game starts: Revive translates the Oculus API to OpenXR, Proton runs the Windows program on "
|
||||
"the Frame.",
|
||||
"cat_frame": "Fixes for what the Frame's runtime does differently from a Quest (graphics formats, missing OpenXR "
|
||||
"extensions, Lepton's launcher requirements).",
|
||||
"cat_overport": "overport converts Quest games from Meta's own VR APIs to standard OpenXR. These are its "
|
||||
"optional patches.",
|
||||
"cat_adapter": "Settings of FrameBridge, the adapter FramePort adds to Quest games. They can also be changed "
|
||||
"after installing, from the Frame page.",
|
||||
"cat_device": "Files and environment variables placed next to the game on the Frame.",
|
||||
# ---- Frame
|
||||
"developer_mode": "Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on "
|
||||
"your network and connect to it over SSH.",
|
||||
"first_time_setup": "The command fetches a small setup script from this app over your local network. It turns on "
|
||||
"SSH, lets this app's key in, makes the Frame findable and installs Lepton if needed.",
|
||||
"password": "The Frame's desktop password, only needed the first time so FramePort can add its own key. After "
|
||||
"that it connects with the key.",
|
||||
"lepton": "Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. "
|
||||
"It needs Developer Mode.",
|
||||
"proton": "Proton is Valve's Windows compatibility layer. On the Frame it runs Oculus Rift games (with Revive). "
|
||||
"Its ARM64 build isn't installed by default; FramePort can install it (Steam restarts once).",
|
||||
"openxr": "The VR runtime games talk to. On the Frame that's SteamVR.",
|
||||
"kernel_keys": "Every game start on the Frame used to leak one kernel key, and after about 200 launches every game "
|
||||
"fails to start. FramePort switches that leak off, but keys already used only come back after a "
|
||||
"restart of the Frame.",
|
||||
"free_space": "Deletes the rollback copies kept from each game's previous install and leftover uploads. The "
|
||||
"games and saves stay.",
|
||||
"adapter_settings": "Change FrameBridge settings of an installed game (render scale, controller mapping and so "
|
||||
"on). They're read when the game starts.",
|
||||
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so Rift games "
|
||||
"can run on the Frame.",
|
||||
# ---- settings
|
||||
"data_folder": "FramePort's tools, library, artwork and the signing keys of your rebuilt games. Back it up: an "
|
||||
"update must be signed with the same key, or the game (and its saves) has to be reinstalled.",
|
||||
"catalog": "Recipes tested on a real Frame: bundled with FramePort, fetched from the online catalog, and the ones "
|
||||
"you saved as known-good.",
|
||||
"revive": "Revive (by LibreVR) lets Oculus Rift games run on OpenXR headsets. FramePort uses your installed Revive "
|
||||
"if you have one, and never replaces it.",
|
||||
"steamvr_pc": "PC VR games on this PC run through SteamVR, so a headset connected to this PC works with them.",
|
||||
"frame_agent": "The small helper program FramePort runs on the Frame (installs, launch tests, Steam entries). It "
|
||||
"comes with this app and is replaced on the Frame automatically whenever it differs.",
|
||||
"transfer_link": "Uploads use the fastest link to the Frame: a USB cable, or the Frame's own Wi-Fi hotspot when "
|
||||
"this PC is connected to it, else your home network (several times slower: both go through "
|
||||
"the router).",
|
||||
}
|
||||
|
||||
|
||||
def text(key: str) -> str:
|
||||
return HELP[key]
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Background jobs for the GUI: one worker runs queued jobs in order (the Frame is one SSH connection and builds share
|
||||
the toolchain), each with its own Reporter. The UI subscribes to changes and renders the activity panel.
|
||||
No Flet imports here, so it's unit-testable."""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from ..core.events import Cancelled, Event, Reporter
|
||||
|
||||
_ids = itertools.count(1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
title: str
|
||||
run: Callable[["Job"], object] # does the work with job.reporter; may return a result
|
||||
package: str | None = None
|
||||
kind: str = "task" # install | test | build | scan | tool | task
|
||||
id: int = field(default_factory=lambda: next(_ids))
|
||||
state: str = "queued" # queued | running | done | failed | cancelled
|
||||
stage: str = ""
|
||||
message: str = ""
|
||||
fraction: float | None = None
|
||||
checks: list[dict] = field(default_factory=list)
|
||||
log: list[str] = field(default_factory=list)
|
||||
stages: list[str] = field(default_factory=list) # every stage seen, in order
|
||||
result: object = None
|
||||
error: str | None = None
|
||||
created: float = field(default_factory=time.time)
|
||||
started: float | None = None
|
||||
finished: float | None = None
|
||||
reporter: Reporter = field(default_factory=Reporter)
|
||||
version: int = 0 # bumped on every change (the activity panel only rebuilds tiles that changed)
|
||||
log_path: str | None = None # full launch log saved by a launch test
|
||||
summary: dict | None = None # launch-test summary (install/test jobs)
|
||||
to: str = "frame"
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.state in ("queued", "running")
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.reporter.cancelled.set()
|
||||
|
||||
def _on_event(self, ev: Event) -> None:
|
||||
self.version += 1
|
||||
if ev.kind == "stage":
|
||||
self.stage, self.message, self.fraction = ev.message, "", None
|
||||
if not self.stages or self.stages[-1] != ev.message:
|
||||
self.stages.append(ev.message)
|
||||
elif ev.kind == "progress":
|
||||
self.fraction = ev.fraction
|
||||
if ev.message:
|
||||
self.message = ev.message
|
||||
elif ev.kind == "check":
|
||||
self.checks.append({"name": ev.data.get("name"), "ok": ev.data.get("ok"), "detail": ev.message})
|
||||
if ev.message and ev.kind in ("log", "stage"):
|
||||
self.log.append(time.strftime("%H:%M:%S ", time.localtime(ev.time)) + ev.message)
|
||||
if len(self.log) > 4000:
|
||||
del self.log[:1000]
|
||||
|
||||
|
||||
class JobManager:
|
||||
def __init__(self, on_change: Callable[[Job | None], None] | None = None, throttle: float = 0.2):
|
||||
self.jobs: list[Job] = []
|
||||
self._queue: list[Job] = []
|
||||
self._cv = threading.Condition()
|
||||
self._listeners: list[Callable[[Job | None], None]] = [on_change] if on_change else []
|
||||
self._throttle = throttle
|
||||
self._last = 0.0
|
||||
self._worker = threading.Thread(target=self._loop, daemon=True, name="frameport-jobs")
|
||||
self._worker.start()
|
||||
|
||||
# ------------------------------------------------------------------ public
|
||||
def subscribe(self, fn: Callable[[Job | None], None]) -> None:
|
||||
self._listeners.append(fn)
|
||||
|
||||
def submit(self, job: Job) -> Job:
|
||||
job.reporter.subscribe(lambda ev, j=job: self._event(j, ev))
|
||||
with self._cv:
|
||||
self.jobs.append(job)
|
||||
self._queue.append(job)
|
||||
self._cv.notify()
|
||||
self._notify(job, force=True)
|
||||
return job
|
||||
|
||||
def current(self) -> Job | None:
|
||||
return next((j for j in self.jobs if j.state == "running"), None)
|
||||
|
||||
def pending(self) -> list[Job]:
|
||||
return [j for j in self.jobs if j.state == "queued"]
|
||||
|
||||
def busy_with(self, package: str) -> Job | None:
|
||||
return next((j for j in self.jobs if j.active and j.package == package), None)
|
||||
|
||||
def recent(self, limit: int = 20) -> list[Job]:
|
||||
return sorted(self.jobs, key=lambda j: (not j.active, -j.created))[:limit]
|
||||
|
||||
def clear_finished(self) -> None:
|
||||
with self._cv:
|
||||
self.jobs = [j for j in self.jobs if j.active]
|
||||
self._notify(None, force=True)
|
||||
|
||||
def cancel(self, job: Job) -> None:
|
||||
with self._cv:
|
||||
if job.state == "queued":
|
||||
self._queue.remove(job)
|
||||
job.state, job.finished = "cancelled", time.time()
|
||||
job.version += 1
|
||||
job.cancel()
|
||||
self._notify(job, force=True)
|
||||
|
||||
def wait_idle(self, timeout: float = 30) -> bool:
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if not any(j.active for j in self.jobs):
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------ internals
|
||||
def _event(self, job: Job, ev: Event) -> None:
|
||||
job._on_event(ev)
|
||||
self._notify(job, force=ev.kind in ("stage", "check"))
|
||||
|
||||
def _notify(self, job: Job | None, force: bool = False) -> None:
|
||||
now = time.time()
|
||||
if not force and now - self._last < self._throttle:
|
||||
return
|
||||
self._last = now
|
||||
for fn in list(self._listeners):
|
||||
try:
|
||||
fn(job)
|
||||
except Exception: # a broken listener must not stop jobs
|
||||
traceback.print_exc()
|
||||
|
||||
def _loop(self) -> None:
|
||||
while True:
|
||||
with self._cv:
|
||||
while not self._queue:
|
||||
self._cv.wait()
|
||||
job = self._queue.pop(0)
|
||||
job.state, job.started = "running", time.time()
|
||||
job.version += 1
|
||||
self._notify(job, force=True)
|
||||
try:
|
||||
job.result = job.run(job)
|
||||
job.state = "cancelled" if job.reporter.cancelled.is_set() else "done"
|
||||
except Cancelled:
|
||||
job.state = "cancelled"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
traceback.print_exc()
|
||||
job.state, job.error = "failed", f"{exc}" or type(exc).__name__
|
||||
job.finished = time.time()
|
||||
job.version += 1
|
||||
self._notify(job, force=True)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Design tokens for the FramePort GUI (dark only): one accent, a neutral surface ramp, semantic state colours, an
|
||||
8-pt spacing scale and a small type scale. Every view takes its colours and sizes from here."""
|
||||
from __future__ import annotations
|
||||
|
||||
import flet as ft
|
||||
|
||||
# surfaces (darkest → lightest)
|
||||
BG = "#0E0F13" # window background
|
||||
SIDEBAR = "#121319"
|
||||
SURFACE = "#171920" # cards
|
||||
SURFACE_2 = "#1E2029" # raised / hover
|
||||
SURFACE_3 = "#262935" # inputs, chips
|
||||
BORDER = "#2C2F3B"
|
||||
BORDER_STRONG = "#3A3E4D"
|
||||
|
||||
# text
|
||||
TEXT = "#ECEDF3"
|
||||
TEXT_2 = "#A9ADBD" # secondary
|
||||
TEXT_3 = "#737889" # meta / disabled
|
||||
|
||||
# accent + states
|
||||
ACCENT = "#8B7CFF"
|
||||
ACCENT_SOFT = "#2A2550"
|
||||
ON_ACCENT = "#0E0F13"
|
||||
OK = "#4ADE80"
|
||||
WARN = "#FBBF24"
|
||||
ERROR = "#F87171"
|
||||
INFO = "#60A5FA"
|
||||
PC = "#38BDF8" # PC VR / "on this PC"
|
||||
|
||||
# spacing / shape / type
|
||||
S1, S2, S3, S4, S5, S6 = 4, 8, 12, 16, 24, 32
|
||||
RADIUS = 12
|
||||
RADIUS_SM = 8
|
||||
T_TITLE, T_H2, T_BODY, T_META, T_SMALL = 28, 16, 13, 12, 11
|
||||
|
||||
|
||||
def soft(color: str, opacity: float = 0.14) -> str:
|
||||
return ft.Colors.with_opacity(opacity, color)
|
||||
|
||||
|
||||
def apply(page: ft.Page) -> None:
|
||||
scheme = ft.ColorScheme(
|
||||
primary=ACCENT, on_primary=ON_ACCENT, primary_container=ACCENT_SOFT, on_primary_container=TEXT,
|
||||
secondary=PC, on_secondary=ON_ACCENT, surface=SURFACE, on_surface=TEXT, on_surface_variant=TEXT_2,
|
||||
surface_container_lowest=BG, surface_container_low=SIDEBAR, surface_container=SURFACE,
|
||||
surface_container_high=SURFACE_2, surface_container_highest=SURFACE_3, outline=BORDER_STRONG,
|
||||
outline_variant=BORDER, error=ERROR, on_error=ON_ACCENT,
|
||||
)
|
||||
theme = ft.Theme(color_scheme=scheme, visual_density=ft.VisualDensity.COMFORTABLE, use_material3=True,
|
||||
divider_theme=ft.DividerTheme(color=BORDER, thickness=1, space=1),
|
||||
tooltip_theme=ft.TooltipTheme(
|
||||
decoration=ft.BoxDecoration(bgcolor=SURFACE_3, border_radius=6,
|
||||
border=ft.Border.all(1, BORDER)),
|
||||
text_style=ft.TextStyle(color=TEXT, size=12), wait_duration=400),
|
||||
expansion_tile_theme=ft.ExpansionTileTheme(
|
||||
shape=ft.RoundedRectangleBorder(radius=RADIUS), collapsed_shape=ft.RoundedRectangleBorder(
|
||||
radius=RADIUS), icon_color=TEXT_2, collapsed_icon_color=TEXT_3, text_color=TEXT,
|
||||
collapsed_text_color=TEXT),
|
||||
scaffold_bgcolor=BG, card_bgcolor=SURFACE, canvas_color=SURFACE_2)
|
||||
page.theme = page.dark_theme = theme
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
page.bgcolor = BG
|
||||
Whitespace-only changes.
@@ -0,0 +1,156 @@
|
||||
"""Activity panel (right side): running, queued and finished background jobs with steps, checks and the log."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..jobs import Job
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
CHECK_ICON = {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.CANCEL_ROUNDED, T.ERROR),
|
||||
None: (ft.Icons.WARNING_AMBER_ROUNDED, T.WARN)}
|
||||
STATE_STYLE = {"queued": (ft.Icons.SCHEDULE_ROUNDED, T.TEXT_3, "Waiting"),
|
||||
"running": (ft.Icons.SYNC_ROUNDED, T.ACCENT, "Working"),
|
||||
"done": (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK, "Done"),
|
||||
"failed": (ft.Icons.ERROR_ROUNDED, T.ERROR, "Failed"),
|
||||
"cancelled": (ft.Icons.DO_NOT_DISTURB_ON_OUTLINED, T.TEXT_3, "Cancelled")}
|
||||
|
||||
|
||||
def _dur(job: Job) -> str:
|
||||
if not job.started:
|
||||
return ""
|
||||
s = int((job.finished or time.time()) - job.started)
|
||||
return f"{s // 60}:{s % 60:02d}"
|
||||
|
||||
|
||||
class ActivityPanel:
|
||||
def __init__(self, app: "FramePortApp"):
|
||||
self.app = app
|
||||
self.expanded: set[int] = set()
|
||||
self.logs: set[int] = set()
|
||||
self._tiles: dict[int, tuple[tuple, ft.Control]] = {} # job id -> (state key, tile)
|
||||
self._live: dict[int, tuple] = {} # running job id -> (progress bar, message, stage text)
|
||||
self.list = ft.Column(spacing=T.S3, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
self.root = ft.Container(
|
||||
ft.Column([
|
||||
ft.Row([C.h2("Activity"), ft.Container(expand=True),
|
||||
C.ghost("Clear finished", on_click=lambda e: (app.jobs.clear_finished(), self.refresh())),
|
||||
C.icon_btn(ft.Icons.CLOSE_ROUNDED, "Close", lambda e: app.show_activity(False))]),
|
||||
self.list,
|
||||
], spacing=T.S3, expand=True),
|
||||
width=0, bgcolor=T.SIDEBAR, padding=ft.Padding(T.S4, T.S4, T.S4, T.S4),
|
||||
border=ft.Border(left=ft.BorderSide(1, T.BORDER)), animate_size=ft.Animation(180, ft.AnimationCurve.EASE_OUT),
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE)
|
||||
|
||||
@property
|
||||
def open(self) -> bool:
|
||||
return bool(self.root.width)
|
||||
|
||||
def set_open(self, on: bool):
|
||||
self.root.width = 400 if on else 0
|
||||
if on:
|
||||
self.refresh(update=False)
|
||||
|
||||
def refresh(self, update: bool = True):
|
||||
if not self.open:
|
||||
return
|
||||
jobs = self.app.jobs.recent(30)
|
||||
tiles = []
|
||||
for j in jobs:
|
||||
running = j.state == "running"
|
||||
# a running job's tile is rebuilt only when its structure changes (new stage/check, log shown); progress
|
||||
# updates go to its live controls — rebuilding on every tick swallowed clicks on Cancel
|
||||
key = (j.state, len(j.stages), len(j.checks), j.id in self.expanded, j.id in self.logs,
|
||||
len(j.log) if j.id in self.logs else 0) if running else \
|
||||
(j.version, j.id in self.expanded, j.id in self.logs)
|
||||
cached = self._tiles.get(j.id)
|
||||
if not cached or cached[0] != key: # unchanged tiles are reused, so text selections survive refreshes
|
||||
cached = (key, self.tile(j))
|
||||
self._tiles[j.id] = cached
|
||||
elif running and j.id in self._live:
|
||||
bar, msg, meta = self._live[j.id]
|
||||
bar.value = j.fraction
|
||||
msg.value = j.message or ""
|
||||
meta.value = j.stage or ""
|
||||
tiles.append(cached[1])
|
||||
self._tiles = {j.id: self._tiles[j.id] for j in jobs}
|
||||
self.list.controls = tiles or [
|
||||
ft.Container(C.body("Nothing running. Installs, launch tests and downloads show up here.",
|
||||
text_align=ft.TextAlign.CENTER), padding=T.S6, alignment=ft.Alignment.CENTER)]
|
||||
if update:
|
||||
C.update(self.root)
|
||||
|
||||
def tile(self, job: Job) -> ft.Control:
|
||||
icon, color, word = STATE_STYLE[job.state]
|
||||
if job.state == "done" and (job.summary or {}).get("verdict") == "fail":
|
||||
icon, color, word = ft.Icons.WARNING_AMBER_ROUNDED, T.WARN, "Installed · launch test failed"
|
||||
running = job.state == "running"
|
||||
expanded = running or job.id in self.expanded
|
||||
meta = C.meta(job.stage if running and job.stage else
|
||||
(job.error or word) + (f" · {_dur(job)}" if job.finished else ""),
|
||||
T.ERROR if job.state == "failed" else T.TEXT_2, max_lines=2)
|
||||
head = ft.Row([
|
||||
ft.ProgressRing(width=18, height=18, stroke_width=2, color=T.ACCENT) if running
|
||||
else ft.Icon(icon, color=color, size=20),
|
||||
ft.Column([C.body(job.title, T.TEXT, weight=ft.FontWeight.W_600, max_lines=2,
|
||||
overflow=ft.TextOverflow.ELLIPSIS), meta],
|
||||
spacing=2, expand=True),
|
||||
*([C.icon_btn(ft.Icons.CLOSE_ROUNDED, "Cancel", lambda e: self.app.jobs.cancel(job))] if job.active else []),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S3)
|
||||
parts: list[ft.Control] = [head]
|
||||
if running:
|
||||
bar = C.progress_bar(job.fraction)
|
||||
msg = C.meta(job.message or "", max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||
parts += [bar, msg]
|
||||
self._live[job.id] = (bar, msg, meta)
|
||||
if expanded:
|
||||
if job.stages:
|
||||
parts.append(ft.Column([
|
||||
ft.Row([ft.Icon(ft.Icons.CHECK_ROUNDED if (i < len(job.stages) - 1 or not running)
|
||||
else ft.Icons.ARROW_RIGHT_ROUNDED, size=14,
|
||||
color=T.OK if (i < len(job.stages) - 1 or job.state == "done") else T.ACCENT),
|
||||
C.meta(s, T.TEXT_2 if i < len(job.stages) - 1 else T.TEXT)], spacing=6)
|
||||
for i, s in enumerate(job.stages[-8:])], spacing=2))
|
||||
if job.checks:
|
||||
parts.append(ft.Column([
|
||||
ft.Row([ft.Icon(CHECK_ICON[c["ok"]][0], color=CHECK_ICON[c["ok"]][1], size=14),
|
||||
ft.Text(f"{c['name']}" + (f" — {c['detail']}" if c.get("detail") else ""), size=T.T_META,
|
||||
color=T.TEXT_2, expand=True, selectable=True)],
|
||||
spacing=6, vertical_alignment=ft.CrossAxisAlignment.START)
|
||||
for c in job.checks[-40:]], spacing=3))
|
||||
extra = self.app.job_followups(job)
|
||||
if extra:
|
||||
parts.append(ft.Row(extra, spacing=T.S2, wrap=True))
|
||||
show_log = job.id in self.logs
|
||||
parts.append(ft.Row([
|
||||
C.ghost("Hide log" if show_log else "Show log", ft.Icons.TERMINAL_ROUNDED,
|
||||
lambda e: self._toggle(self.logs, job.id)),
|
||||
C.ghost("Copy log", ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(self.job_text(job))),
|
||||
*([C.ghost("Full launch log", ft.Icons.DESCRIPTION_ROUNDED,
|
||||
lambda e: self.app.show_log_file(job.log_path, job.title))] if job.log_path else []),
|
||||
], spacing=0, wrap=True))
|
||||
if show_log:
|
||||
parts.append(ft.Container(ft.Column([ft.Text(
|
||||
"\n".join(job.log[-400:]), size=11, font_family="monospace", color=T.TEXT_2, selectable=True)],
|
||||
scroll=ft.ScrollMode.AUTO, auto_scroll=job.state == "running"),
|
||||
bgcolor=T.BG, border_radius=T.RADIUS_SM, padding=T.S2, height=240))
|
||||
return ft.Container(ft.Column(parts, spacing=T.S2), bgcolor=T.SURFACE, border_radius=T.RADIUS,
|
||||
border=ft.Border.all(1, T.ACCENT if running else T.BORDER), padding=T.S3,
|
||||
on_click=None if running else lambda e: self._toggle(self.expanded, job.id))
|
||||
|
||||
@staticmethod
|
||||
def job_text(job: Job) -> str:
|
||||
checks = [("PASS" if c["ok"] else "FAIL" if c["ok"] is False else "WARN") + f" {c['name']}"
|
||||
+ (f": {c['detail']}" if c.get("detail") else "") for c in job.checks]
|
||||
return "\n".join([f"{job.title} — {job.state}" + (f": {job.error}" if job.error else ""), "", "Checks:", *checks,
|
||||
"", "Log:", *job.log] + ([f"", f"Full launch log: {job.log_path}"] if job.log_path else []))
|
||||
|
||||
def _toggle(self, s: set[int], jid: int):
|
||||
s.symmetric_difference_update({jid})
|
||||
self.refresh()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""'Find artwork' — search the Meta/Oculus store (via OculusDB) and Steam, pick a result, or fetch automatically."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ... import pipeline
|
||||
from ...artwork import sources, thumbs
|
||||
from ...core import library
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
|
||||
def show_art_dialog(app: "FramePortApp", package: str) -> None:
|
||||
g = library.game(package)
|
||||
term = ft.TextField(value=g.get("title") or package, dense=True, expand=True, border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT, focused_border_color=T.ACCENT,
|
||||
content_padding=ft.Padding(12, 8, 12, 8), text_size=T.T_BODY)
|
||||
results = ft.GridView(max_extent=190, child_aspect_ratio=0.8, spacing=T.S3, run_spacing=T.S3, height=400)
|
||||
status = C.meta("")
|
||||
|
||||
def pick(choice):
|
||||
app.page.pop_dialog()
|
||||
|
||||
def work():
|
||||
if not sources.apply_choice(package, choice):
|
||||
app.toast(f"No artwork could be downloaded from that {choice['source']} result; the current artwork "
|
||||
"stays. Try another one.", error=True)
|
||||
return
|
||||
thumbs.prewarm(package)
|
||||
library.upsert_game(package, art_source=choice["source"].lower())
|
||||
app.refresh_view()
|
||||
if C.install_state(library.game(package), app.frame_info) in ("installed", "outdated"):
|
||||
app.toast(f"Artwork updated for {g.get('title')}. The Frame's Steam library still shows the old "
|
||||
"art.", action="Update on Frame", on_action=lambda e: app.update_steam_art(package))
|
||||
else:
|
||||
app.toast(f"Artwork updated for {g.get('title')}")
|
||||
app.run_bg(work)
|
||||
|
||||
def search(e=None):
|
||||
status.value = "Searching…"
|
||||
results.controls = []
|
||||
C.update(status, results)
|
||||
|
||||
def work():
|
||||
found = sources.search(term.value.strip())
|
||||
results.controls = [ft.Container(ft.Column([
|
||||
ft.Container(C.art_fill(r["preview"], radius=8, height=120), height=120),
|
||||
C.body(r["name"] or "", T.TEXT, size=T.T_META, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
C.meta(r["source"]),
|
||||
], spacing=4), padding=T.S2, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE, ink=True,
|
||||
on_click=lambda e, r=r: pick(r)) for r in found]
|
||||
status.value = f"{len(found)} results" if found else "Nothing found. Try a shorter or different name."
|
||||
C.update(status, results)
|
||||
app.run_bg(work)
|
||||
|
||||
def auto(e):
|
||||
app.page.pop_dialog()
|
||||
|
||||
def work():
|
||||
found = pipeline.fetch_art(package)
|
||||
thumbs.prewarm(package) # regenerate the thumbnails the cards/hero use, or the view shows the old art
|
||||
app.refresh_view()
|
||||
src = found.get("source")
|
||||
if src and src != "none" and C.install_state(library.game(package), app.frame_info) in ("installed",
|
||||
"outdated"):
|
||||
app.toast(f"Artwork updated ({src}). The Frame's Steam library still shows the old art.",
|
||||
action="Update on Frame", on_action=lambda e: app.update_steam_art(package))
|
||||
else:
|
||||
app.toast(f"Artwork: {src or 'none found'}")
|
||||
app.run_bg(work)
|
||||
|
||||
term.on_submit = search
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(f"Artwork for {g.get('title')}", weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([
|
||||
ft.Row([term, C.secondary("Search", ft.Icons.SEARCH_ROUNDED, search)], spacing=T.S2),
|
||||
status, results,
|
||||
], spacing=T.S3, tight=True), width=660),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost("Find automatically", ft.Icons.AUTO_AWESOME_ROUNDED, auto),
|
||||
C.ghost("Close", on_click=lambda e: app.page.pop_dialog())]))
|
||||
search()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""'Which program starts <game>?' — pick the executable of a Rift game when FramePort isn't sure (or to change it)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ... import pipeline
|
||||
from ...core import library
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
|
||||
def _size(n: int) -> str:
|
||||
return f"{n / 2**20:.0f} MB" if n < 2**30 else f"{n / 2**30:.1f} GB"
|
||||
|
||||
|
||||
def show_exe_dialog(app: "FramePortApp", package: str, remaining: int = 0,
|
||||
on_done: Callable[[], None] | None = None) -> None:
|
||||
g = library.game(package)
|
||||
extra = (g.get("analysis") or {}).get("extra") or {}
|
||||
cands = extra.get("exe_candidates") or [{"path": g.get("exe"), "score": 0, "reasons": [], "size": 0}]
|
||||
current = g.get("exe") or cands[0]["path"]
|
||||
group = ft.RadioGroup(value=current, content=ft.Column(spacing=T.S2))
|
||||
for i, c in enumerate(cands):
|
||||
tags = [C.pill(r, T.OK if r in ("Unreal game build", "Unity game (next to its data)", "name matches the game",
|
||||
"named in the Oculus manifest") else
|
||||
T.WARN if r in ("Steam build", "Unreal launcher (starts the real game build)") else T.TEXT_2)
|
||||
for r in c.get("reasons", [])]
|
||||
if i == 0:
|
||||
tags.insert(0, C.pill("Best guess", T.ACCENT, ft.Icons.AUTO_AWESOME_ROUNDED))
|
||||
folder, _, name = c["path"].rpartition("/")
|
||||
group.content.controls.append(ft.Container(ft.Row([
|
||||
ft.Radio(value=c["path"], active_color=T.ACCENT),
|
||||
ft.Column([
|
||||
ft.Row([C.body(name, T.TEXT, weight=ft.FontWeight.W_600), C.meta(_size(c.get("size") or 0))],
|
||||
spacing=T.S2),
|
||||
C.meta(folder or "(top folder)", selectable=True),
|
||||
ft.Row(tags, spacing=6, wrap=True) if tags else ft.Container(),
|
||||
], spacing=4, expand=True),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.START), padding=ft.Padding(T.S2, T.S2, T.S3, T.S2),
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE if c["path"] != current else T.ACCENT_SOFT,
|
||||
on_click=lambda e, p=c["path"]: (setattr(group, "value", p), group.update())))
|
||||
|
||||
def use(e):
|
||||
choice = group.value
|
||||
app.page.pop_dialog()
|
||||
|
||||
def work():
|
||||
try:
|
||||
pipeline.set_exe(package, choice)
|
||||
app.toast(f"{g.get('title')} starts with {choice.rsplit('/', 1)[-1]}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
app.toast(f"Couldn't use {choice}: {exc}", error=True)
|
||||
app.refresh_view()
|
||||
if on_done:
|
||||
on_done()
|
||||
app.run_bg(work)
|
||||
|
||||
def later(e):
|
||||
app.page.pop_dialog()
|
||||
if on_done:
|
||||
on_done()
|
||||
|
||||
title = f"Which program starts {g.get('title')}?"
|
||||
lead = ("FramePort found more than one program that could start this game. Pick the one you'd double-click to "
|
||||
"play it. Oculus builds usually work better with Revive than Steam builds.")
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Row([ft.Text(title, weight=ft.FontWeight.W_600, expand=True)]
|
||||
+ ([C.meta(f"{remaining} more after this")] if remaining else [])),
|
||||
content=ft.Container(ft.Column([C.body(lead), group], spacing=T.S4, scroll=ft.ScrollMode.AUTO, tight=True),
|
||||
width=620, height=min(120 + 96 * len(cands), 520)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost("Decide later", on_click=later), C.primary("Use this program", on_click=use)]))
|
||||
@@ -0,0 +1,177 @@
|
||||
"""'Files on the Frame' — a browsable tree of what an installed game actually has on the Frame (agent `list_files`):
|
||||
folders with their total size, expandable; a filter box; and files that are missing or incomplete compared to what
|
||||
was uploaded. The tree is built once from the flat file list; only visible rows are turned into controls."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
MAX_CHILDREN = 300 # rows shown per folder before "… N more"
|
||||
MAX_MATCHES = 500
|
||||
|
||||
|
||||
@dataclass
|
||||
class Node:
|
||||
name: str
|
||||
path: str = ""
|
||||
size: int = 0
|
||||
files: int = 0 # files below this folder (1 for a file)
|
||||
children: dict[str, "Node"] = field(default_factory=dict)
|
||||
is_dir: bool = True
|
||||
|
||||
|
||||
def build_tree(name: str, files: list[list]) -> Node:
|
||||
"""[[rel, size], ...] → a folder tree with aggregated sizes, children sorted folders-first by name."""
|
||||
root = Node(name)
|
||||
for rel, size in files:
|
||||
parts = rel.split("/")
|
||||
node = root
|
||||
node.size += size
|
||||
node.files += 1
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
child = node.children.get(part)
|
||||
if child is None:
|
||||
child = node.children[part] = Node(part, "/".join(parts[:i + 1]))
|
||||
child.size += size
|
||||
child.files += 1
|
||||
node = child
|
||||
node.children[parts[-1]] = Node(parts[-1], rel, size, 1, is_dir=False)
|
||||
return root
|
||||
|
||||
|
||||
def sorted_children(node: Node) -> list[Node]:
|
||||
return sorted(node.children.values(), key=lambda n: (not n.is_dir, n.name.lower()))
|
||||
|
||||
|
||||
def visible_rows(root: Node, expanded: set[str]) -> list[tuple[int, Node | int]]:
|
||||
"""(depth, node) for everything shown with `expanded` folder paths open; (depth, n) = "… n more"."""
|
||||
out: list[tuple[int, Node | int]] = []
|
||||
|
||||
def walk(node: Node, depth: int):
|
||||
kids = sorted_children(node)
|
||||
for child in kids[:MAX_CHILDREN]:
|
||||
out.append((depth, child))
|
||||
if child.is_dir and child.path in expanded:
|
||||
walk(child, depth + 1)
|
||||
if len(kids) > MAX_CHILDREN:
|
||||
out.append((depth, len(kids) - MAX_CHILDREN))
|
||||
walk(root, 0)
|
||||
return out
|
||||
|
||||
|
||||
def matches(files: list[list], text: str) -> list[list]:
|
||||
t = text.lower()
|
||||
return [f for f in files if t in f[0].lower()][:MAX_MATCHES]
|
||||
|
||||
|
||||
def human(n: int) -> str:
|
||||
for unit, div in (("GiB", 2**30), ("MiB", 2**20), ("KiB", 2**10)):
|
||||
if n >= div:
|
||||
return f"{n / div:.1f} {unit}"
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
def show_files_dialog(app: "FramePortApp", package: str, title: str) -> None:
|
||||
status = C.meta("Reading the file list from the Frame…")
|
||||
body = ft.Column([ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT), status],
|
||||
spacing=T.S2)], spacing=T.S3, expand=True)
|
||||
dialog = ft.AlertDialog(
|
||||
title=ft.Text(f"Files on the Frame — {title}", weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(body, width=760, height=560),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost("Close", on_click=lambda e: app.page.pop_dialog())])
|
||||
app.page.show_dialog(dialog)
|
||||
|
||||
def load():
|
||||
if not app.target:
|
||||
raise RuntimeError("The Frame isn't connected")
|
||||
result = app.target.frame.agent("list_files", package=package, timeout=180)
|
||||
trees = [(r, build_tree(r["name"], r["files"])) for r in result["roots"]]
|
||||
expanded: set[str] = set()
|
||||
rows = ft.ListView(spacing=0, expand=True)
|
||||
search = ft.TextField(hint_text="Filter files (e.g. .pak, Binaries)", dense=True, expand=True,
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT, content_padding=ft.Padding(12, 8, 12, 8),
|
||||
text_size=T.T_BODY, prefix_icon=ft.Icons.SEARCH_ROUNDED)
|
||||
|
||||
def row(depth: int, node: Node | int, root_key: str) -> ft.Control:
|
||||
pad = ft.Padding(8 + depth * 18, 3, 8, 3)
|
||||
if isinstance(node, int):
|
||||
return ft.Container(C.meta(f"… {node} more (use the filter to find them)"), padding=pad)
|
||||
key = f"{root_key}\0{node.path}"
|
||||
icon = (ft.Icons.FOLDER_OPEN_ROUNDED if key in expanded else ft.Icons.FOLDER_ROUNDED) if node.is_dir \
|
||||
else ft.Icons.INSERT_DRIVE_FILE_OUTLINED
|
||||
chevron = ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED if key in expanded else ft.Icons.CHEVRON_RIGHT_ROUNDED,
|
||||
size=16, color=T.TEXT_3) if node.is_dir else ft.Container(width=16)
|
||||
info = f"{human(node.size)} · {node.files} files" if node.is_dir else human(node.size)
|
||||
return ft.Container(ft.Row([
|
||||
chevron, ft.Icon(icon, size=16, color=T.ACCENT if node.is_dir else T.TEXT_3),
|
||||
C.body(node.name, T.TEXT, expand=True, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
C.meta(info),
|
||||
], spacing=6), padding=pad, border_radius=6, ink=node.is_dir,
|
||||
on_click=(lambda e, k=key: toggle(k)) if node.is_dir else None)
|
||||
|
||||
def render():
|
||||
text = (search.value or "").strip()
|
||||
controls: list[ft.Control] = []
|
||||
for r, tree in trees:
|
||||
controls.append(ft.Container(ft.Row([
|
||||
C.body(r["name"], T.TEXT, weight=ft.FontWeight.W_600),
|
||||
C.meta(f"{human(tree.size)} · {tree.files} files"),
|
||||
ft.Container(expand=True),
|
||||
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, "Copy path", lambda e, p=r["path"]: app.copy(p)),
|
||||
], spacing=T.S2), padding=ft.Padding(8, T.S2, 8, 2)))
|
||||
controls.append(ft.Container(C.meta(r["path"], selectable=True), padding=ft.Padding(8, 0, 8, 4)))
|
||||
if text:
|
||||
found = matches(r["files"], text)
|
||||
controls += [ft.Container(ft.Row([
|
||||
ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=16, color=T.TEXT_3),
|
||||
C.body(rel, T.TEXT, expand=True, selectable=True), C.meta(human(size))], spacing=6),
|
||||
padding=ft.Padding(8, 3, 8, 3)) for rel, size in found]
|
||||
if not found:
|
||||
controls.append(ft.Container(C.meta("No matching files"), padding=ft.Padding(8, 3, 8, 3)))
|
||||
else:
|
||||
visible = visible_rows(tree, {k.split("\0", 1)[1] for k in expanded
|
||||
if k.split("\0", 1)[0] == r["path"]})
|
||||
controls += [row(d, n, r["path"]) for d, n in visible]
|
||||
rows.controls = controls
|
||||
C.update(rows)
|
||||
|
||||
def toggle(key: str):
|
||||
expanded.symmetric_difference_update({key})
|
||||
render()
|
||||
|
||||
search.on_change = lambda e: render()
|
||||
head: list[ft.Control] = []
|
||||
total = sum(t.size for _, t in trees)
|
||||
count = sum(t.files for _, t in trees)
|
||||
summary = f"{count} files · {human(total)}" + (" (list cut off)" if result.get("truncated") else "")
|
||||
missing = result.get("missing") or []
|
||||
if missing:
|
||||
lines = "\n".join(f"{rel}: " + ("missing" if actual is None else f"{human(actual)} of {human(size)}")
|
||||
for rel, size, actual in missing[:8])
|
||||
more = f"\n… and {len(missing) - 8} more" if len(missing) > 8 else ""
|
||||
head.append(C.callout(f"{len(missing)} file(s) are missing or incomplete compared to what was "
|
||||
f"uploaded. Install the game again to re-send them.\n{lines}{more}", "warn"))
|
||||
elif result.get("kind") == "pcvr":
|
||||
head.append(C.callout("Every uploaded file is present with the right size.", "ok"))
|
||||
body.controls = [ft.Row([search, C.meta(summary)], spacing=T.S3), *head, rows]
|
||||
render()
|
||||
C.update(body)
|
||||
|
||||
def run():
|
||||
try:
|
||||
load()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status.value = f"Couldn't read the file list: {exc}"
|
||||
body.controls = [status]
|
||||
C.update(body)
|
||||
app.run_bg(run)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Frame page: the connected device with a readiness checklist and installed games, or a connect wizard."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ...core import library
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..help import HELP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
|
||||
class FrameView:
|
||||
def __init__(self, app: "FramePortApp"):
|
||||
self.app = app
|
||||
|
||||
# ---------------------------------------------------------------- connected
|
||||
def device_card(self, info: dict) -> ft.Control:
|
||||
app = self.app
|
||||
t = app.target
|
||||
free = (info.get("free_bytes") or 0) / 2**30
|
||||
return C.card(ft.Row([
|
||||
ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=34, color=T.ACCENT), width=72, height=72,
|
||||
border_radius=18, bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
|
||||
ft.Column([
|
||||
ft.Row([C.title(info.get("hostname") or t.label, 22), C.pill("Connected", T.OK, ft.Icons.CIRCLE)],
|
||||
spacing=T.S3),
|
||||
C.body(f"{t.target.user}@{t.target.host} · {info.get('os')} {info.get('os_version')} "
|
||||
f"(build {info.get('build_id')})"),
|
||||
C.meta(f"{free:.0f} GiB free · {len(info.get('installed') or [])} games installed"),
|
||||
], spacing=4, expand=True),
|
||||
ft.Column([
|
||||
C.secondary("Refresh", ft.Icons.REFRESH_ROUNDED, lambda e: app.refresh_frame()),
|
||||
C.ghost("Switch Frame…", ft.Icons.SWAP_HORIZ_ROUNDED, lambda e: app.disconnect()),
|
||||
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.END),
|
||||
], spacing=T.S4), padding=T.S5)
|
||||
|
||||
def readiness(self, info: dict) -> ft.Control:
|
||||
app = self.app
|
||||
rows = []
|
||||
lepton = info.get("lepton")
|
||||
rows.append(C.status_row(bool(lepton), "Quest games (Lepton)",
|
||||
"Ready" if lepton else "Valve's Android runtime isn't installed (needs Developer Mode)",
|
||||
None if lepton else C.secondary("Install", ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda e: app.install_lepton()),
|
||||
help="lepton"))
|
||||
pr = info.get("proton") or {}
|
||||
ready, sug = pr.get("ready"), pr.get("suggested")
|
||||
if pr:
|
||||
rows.append(C.status_row(
|
||||
bool(ready), "PC VR games (Proton)",
|
||||
f"{ready['display_name']} installed" if ready else
|
||||
(f"{sug['display_name']} can be installed (about 1 GB; Steam restarts once)" if sug else
|
||||
"Not offered by Steam on this Frame yet"),
|
||||
C.secondary("Test", ft.Icons.SCIENCE_OUTLINED, lambda e: app.test_proton()) if ready else
|
||||
C.primary("Install", ft.Icons.DOWNLOAD_ROUNDED, lambda e: app.install_proton(), disabled=not sug),
|
||||
help="proton"))
|
||||
xr = (pr.get("openxr") or {}).get("name")
|
||||
rows.append(C.status_row(bool(xr), "OpenXR runtime", xr or "None found", help="openxr"))
|
||||
keys = info.get("kernel_keys") or {}
|
||||
if keys.get("max_keys"):
|
||||
high = keys["keys"] > keys["max_keys"] * 0.75
|
||||
rows.append(C.status_row(not high if keys["keys"] < keys["max_keys"] else False, "Game launch capacity",
|
||||
f"{keys['keys']}/{keys['max_keys']} kernel keys used"
|
||||
+ (" · restart the Frame soon to reset it" if high else ""),
|
||||
help="kernel_keys"))
|
||||
return C.section("Ready to play", C.card(ft.Column(rows, spacing=0), padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)))
|
||||
|
||||
def installed(self, info: dict) -> ft.Control:
|
||||
"""The list fills in the background (icon thumbnails may need creating the first time)."""
|
||||
items = info.get("installed") or []
|
||||
col = ft.Column([ft.Container(bgcolor=T.SURFACE_2, height=56, border_radius=T.RADIUS_SM, opacity=0.5)
|
||||
for _ in items[:6]], spacing=4)
|
||||
self.app.run_bg(self._fill_installed, items, col)
|
||||
body = C.card(col, padding=T.S2) if items else \
|
||||
C.card(C.body("Nothing installed yet. Pick a game in the Library and click Install."), padding=T.S5)
|
||||
return C.section(f"Installed games ({len(items)})", body,
|
||||
action=C.ghost("Free up space", ft.Icons.CLEANING_SERVICES_ROUNDED,
|
||||
lambda e: self.app.cleanup_frame(), tooltip=C.tip(HELP["free_space"]))
|
||||
if items else None)
|
||||
|
||||
def _fill_installed(self, items: list[dict], col: ft.Column) -> None:
|
||||
def settings_slot() -> ft.Control:
|
||||
spacer = C.icon_btn(ft.Icons.TUNE_ROUNDED, "", None, disabled=True)
|
||||
spacer.opacity = 0
|
||||
return spacer
|
||||
|
||||
from ...artwork import thumbs
|
||||
from .files_dialog import show_files_dialog
|
||||
from .library import display_title, twins
|
||||
|
||||
app = self.app
|
||||
games = {g["package"]: g for g in library.games()}
|
||||
tw = twins(list(games.values()))
|
||||
rows = []
|
||||
for d in items:
|
||||
pkg = d["package"]
|
||||
pcvr = d.get("kind") == "pcvr"
|
||||
art = thumbs.url(pkg, ("icon", "square", "portrait"), 96)
|
||||
size = d.get("apk_size", 0)
|
||||
sub = ("PC VR · Proton" + (" + Revive" if d.get("revive") else "")) if pcvr else "Quest"
|
||||
sub += f" · {size / 2**30:.1f} GiB" if size >= 2**30 else f" · {size / 2**20:.0f} MiB"
|
||||
in_lib = pkg in games
|
||||
title = display_title(games[pkg], tw) if in_lib else (d.get("title") or pkg)
|
||||
rows.append(ft.Container(ft.Row([
|
||||
C.art_fill(art, radius=8, width=44, height=44),
|
||||
ft.Column([C.body(title, T.TEXT, weight=ft.FontWeight.W_500), C.meta(sub)],
|
||||
spacing=2, expand=True),
|
||||
# same slots on every row: PC VR games have no adapter settings, so theirs is an invisible spacer
|
||||
(settings_slot() if pcvr else
|
||||
C.icon_btn(ft.Icons.TUNE_ROUNDED, C.tip("Adapter settings. " + HELP["adapter_settings"]),
|
||||
lambda e, p=pkg: app.settings_dialog(p))),
|
||||
C.icon_btn(ft.Icons.FOLDER_OPEN_ROUNDED, "Files on the Frame: browse what this install contains",
|
||||
lambda e, p=pkg, t=title: show_files_dialog(app, p, t)),
|
||||
C.icon_btn(ft.Icons.PLAY_ARROW_ROUNDED, "Play: starts the game through the Frame's Steam (put the "
|
||||
"headset on)", lambda e, p=pkg: app.play(p, "frame"), color=T.ACCENT),
|
||||
C.icon_btn(ft.Icons.SCIENCE_OUTLINED, C.tip("Launch test. " + HELP["launch_test"]),
|
||||
lambda e, p=pkg: app.test_game(p, "frame")),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, C.tip("Uninstall. " + HELP["uninstall"]),
|
||||
lambda e, p=pkg: app.uninstall(p, "frame")),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S3, 8, T.S2, 8), border_radius=T.RADIUS_SM,
|
||||
on_click=(lambda e, p=pkg: app.open_game(p)) if in_lib else None, ink=in_lib))
|
||||
col.controls = rows
|
||||
col.spacing = 0
|
||||
C.update(col)
|
||||
|
||||
# ---------------------------------------------------------------- not connected
|
||||
def wizard(self) -> ft.Control:
|
||||
from ...frame.connection import parse_target, saved_targets
|
||||
|
||||
app = self.app
|
||||
found = ft.Column(spacing=T.S2)
|
||||
searching = ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT),
|
||||
C.meta("Looking for Frames on your network…")], spacing=T.S2)
|
||||
|
||||
def discover():
|
||||
from ...frame.discovery import browse
|
||||
|
||||
found.controls = [searching]
|
||||
C.update(found)
|
||||
res = browse(4)
|
||||
items = []
|
||||
for f in res:
|
||||
label = {"devkit": "SteamOS · Developer Mode", "frameport": "Set up for FramePort",
|
||||
"saved": "Remembered", "scan": "SSH found by network scan"}.get(f.source, f.source)
|
||||
items.append(ft.Container(ft.Row([
|
||||
ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED if f.source != "scan" else ft.Icons.DEVICES_OTHER_ROUNDED,
|
||||
color=T.ACCENT),
|
||||
ft.Column([C.body(f.name if f.source != "scan" else f.host, T.TEXT, weight=ft.FontWeight.W_500),
|
||||
C.meta(f"{f.host} · {label}")], spacing=2, expand=True),
|
||||
C.primary("Connect", on_click=lambda e, f=f: app.connect(parse_target(f"{f.user}@{f.host}"))),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S3, 8, T.S2, 8), bgcolor=T.SURFACE_2,
|
||||
border_radius=T.RADIUS_SM))
|
||||
found.controls = items or [C.body("No Frames found. Turn on Developer Mode on the Frame (Settings → "
|
||||
"System → Developer) and make sure it's on the same network, or use "
|
||||
"first-time setup below.")]
|
||||
found.controls.append(C.ghost("Search again", ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
|
||||
C.update(found)
|
||||
|
||||
pair_box = ft.Column(spacing=T.S3)
|
||||
|
||||
def pair(e):
|
||||
from ...frame.connection import FrameTarget
|
||||
from ...frame.pairing import PairingServer
|
||||
|
||||
if app.pairing:
|
||||
app.pairing.stop()
|
||||
|
||||
def on_paired(info):
|
||||
app.connect(FrameTarget(info["host"], info["user"], 22, info["name"]))
|
||||
app.pairing = PairingServer(on_paired=on_paired).start()
|
||||
line = app.pairing.one_liner
|
||||
pair_box.controls = [
|
||||
C.body("On the Frame: Steam button → Power → Switch to Desktop, open Konsole and run:", T.TEXT),
|
||||
ft.Container(ft.Row([ft.Text(line, font_family="monospace", selectable=True, size=12, color=T.TEXT,
|
||||
expand=True),
|
||||
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, "Copy", lambda e: app.copy(line))]),
|
||||
padding=ft.Padding(T.S3, T.S2, T.S2, T.S2), bgcolor=T.BG, border_radius=T.RADIUS_SM,
|
||||
border=ft.Border.all(1, T.BORDER)),
|
||||
ft.Row([ft.ProgressRing(width=14, height=14, stroke_width=2, color=T.ACCENT),
|
||||
C.meta(f"Waiting for your Frame… (code {app.pairing.code})")], spacing=T.S2),
|
||||
C.meta("It turns on SSH, trusts this app, makes the Frame findable on your network and installs "
|
||||
"Lepton if needed. You only do this once."),
|
||||
]
|
||||
pair_box.update()
|
||||
|
||||
style = dict(dense=True, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT, content_padding=ft.Padding(12, 10, 12, 10), text_size=T.T_BODY)
|
||||
addr = ft.TextField(hint_text="steamos@frame.local or an IP address", width=320, **style)
|
||||
pw = ft.TextField(hint_text="Password (first time only)", password=True, can_reveal_password=True, width=240,
|
||||
tooltip=C.tip(HELP["password"]), **style)
|
||||
saved = saved_targets()
|
||||
offline = None
|
||||
if saved and app.frame_state == "offline":
|
||||
offline = C.callout(ft.Row([C.body(f"{saved[0].label} ({saved[0].host}) isn't reachable. Make sure it's "
|
||||
"switched on and on the same network.", T.TEXT, expand=True),
|
||||
C.secondary("Try again", ft.Icons.REFRESH_ROUNDED,
|
||||
lambda e: app.connect(saved[0]))]), "warn")
|
||||
elif app.frame_state == "connecting":
|
||||
offline = C.callout(ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT),
|
||||
C.body(f"Connecting to {saved[0].label if saved else 'your Frame'}…", T.TEXT)],
|
||||
spacing=T.S3), "info")
|
||||
import time as _time
|
||||
|
||||
if _time.time() - getattr(app, "_discovered_at", 0) > 20: # not on every redraw
|
||||
app._discovered_at = _time.time()
|
||||
app.run_bg(discover)
|
||||
else:
|
||||
found.controls = [C.meta("Searched a moment ago."),
|
||||
C.ghost("Search again", ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover))]
|
||||
step = lambda n, head, text, *content, help=None: C.card(ft.Row([ # noqa: E731
|
||||
ft.Container(ft.Text(str(n), weight=ft.FontWeight.W_700, color=T.ACCENT), width=30, height=30,
|
||||
border_radius=15, bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
|
||||
ft.Column([C.with_help(C.h2(head), help), C.body(text), *content], spacing=T.S3, expand=True),
|
||||
], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.START), padding=T.S5)
|
||||
return ft.Column([
|
||||
app.top_bar("Connect your Steam Frame", "FramePort installs games on the Frame over your network"),
|
||||
*([offline] if offline else []),
|
||||
step(1, "On your network", "Frames in Developer Mode show up here.", found, help="developer_mode"),
|
||||
step(2, "First-time setup", "New Frame? Run one command on it and FramePort does the rest.",
|
||||
C.secondary("Show setup command", ft.Icons.TERMINAL_ROUNDED, pair), pair_box,
|
||||
help="first_time_setup"),
|
||||
step(3, "Enter the address", "If you know the Frame's address.",
|
||||
ft.Row([addr, pw, C.primary("Connect", on_click=lambda e: app.connect_manual(addr.value, pw.value))],
|
||||
wrap=True, spacing=T.S3)),
|
||||
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
|
||||
def build(self) -> ft.Control:
|
||||
app = self.app
|
||||
info = app.frame_info
|
||||
if app.frame_state != "connected" or not info:
|
||||
return self.wizard()
|
||||
return ft.Column([
|
||||
app.top_bar("Steam Frame", "Your headset, what it's ready for and what's installed"),
|
||||
self.device_card(info), self.readiness(info), self.installed(info),
|
||||
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
@@ -0,0 +1,508 @@
|
||||
"""Game page: a hero with the one-click action, where the game is installed, what FramePort will do, and the full
|
||||
patch list tucked under "Advanced"."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ... import pipeline
|
||||
from ...artwork import thumbs
|
||||
from ...core import library
|
||||
from ...patches import base
|
||||
from ...recommend import catalog, engine
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..help import HELP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
CATEGORY_TITLES = {"frame": "Steam Frame fixes", "overport": "overport patches", "adapter": "Adapter settings",
|
||||
"device": "Frame-side files & environment", "pcvr": "PC VR (Revive / Proton)"}
|
||||
|
||||
|
||||
def _ago(t: float | None) -> str:
|
||||
if not t:
|
||||
return ""
|
||||
d = time.time() - t
|
||||
return "just now" if d < 90 else f"{int(d / 60)} min ago" if d < 3600 else \
|
||||
f"{int(d / 3600)} h ago" if d < 86400 else time.strftime("%b %d", time.localtime(t))
|
||||
|
||||
|
||||
class GameView:
|
||||
def __init__(self, app: "FramePortApp", package: str, advanced: bool = False, show_all: bool = False):
|
||||
self.app, self.package, self.advanced, self.show_all = app, package, advanced, show_all
|
||||
from .library import twins
|
||||
|
||||
self.g = library.game(package)
|
||||
self.games = library.games()
|
||||
self.twins = twins(self.games)
|
||||
self.rift = bool(self.g) and self.g.get("kind") == "rift"
|
||||
|
||||
# ---------------------------------------------------------------- hero
|
||||
def hero(self) -> ft.Control:
|
||||
app, g, pkg = self.app, self.g, self.package
|
||||
from .library import display_title
|
||||
|
||||
a = g["analysis"]
|
||||
art = thumbs.url(pkg, ("hero", "landscape", "portrait", "square"), 1280)
|
||||
platform = "Oculus Rift · PC VR" if self.rift else "Meta Quest"
|
||||
facts = " · ".join(x for x in (platform, a.get("engine"), a.get("xr")) if x and x != "?")
|
||||
recipe = g.get("recipe") or {}
|
||||
chips = [C.status_chip(recipe.get("status", "unknown"))]
|
||||
st = C.install_state(g, app.frame_info)
|
||||
if st in ("installed", "outdated"):
|
||||
chips.append(C.install_badge(st))
|
||||
if self.rift and pkg in app.pc_installs():
|
||||
chips.append(C.install_badge("on_pc"))
|
||||
actions = self.actions()
|
||||
return ft.Container(
|
||||
ft.Stack([
|
||||
C.art_fill(art, radius=16, hero=True, left=0, right=0, top=0, bottom=0,
|
||||
placeholder_icon=ft.Icons.COMPUTER_ROUNDED if self.rift else ft.Icons.VIEW_IN_AR_ROUNDED),
|
||||
ft.Container(left=0, right=0, top=0, bottom=0, border_radius=16, gradient=ft.LinearGradient(
|
||||
begin=ft.Alignment.CENTER_LEFT, end=ft.Alignment.CENTER_RIGHT,
|
||||
colors=[T.soft(T.BG, 0.97), T.soft(T.BG, 0.80), T.soft(T.BG, 0.25)], stops=[0.0, 0.45, 1.0])),
|
||||
ft.Container(ft.Row([
|
||||
ft.Column([
|
||||
C.ghost("Library", ft.Icons.ARROW_BACK_ROUNDED, lambda e: app.go("library")),
|
||||
ft.Container(expand=True),
|
||||
C.meta(facts.upper(), T.TEXT_2, weight=ft.FontWeight.W_600),
|
||||
ft.Text(display_title(g, self.twins), size=34, weight=ft.FontWeight.W_800, color=T.TEXT,
|
||||
max_lines=2, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
ft.Row(chips, spacing=T.S2, wrap=True),
|
||||
ft.Container(height=T.S2),
|
||||
actions,
|
||||
], spacing=T.S2, expand=True),
|
||||
]), padding=ft.Padding(T.S4, T.S3, T.S5, T.S5), left=0, right=0, top=0, bottom=0),
|
||||
]),
|
||||
height=330, border_radius=16, border=ft.Border.all(1, T.BORDER))
|
||||
|
||||
def actions(self) -> ft.Control:
|
||||
app, g, pkg = self.app, self.g, self.package
|
||||
job = app.jobs.busy_with(pkg)
|
||||
if job:
|
||||
pct = f" {job.fraction:.0%}" if job.fraction is not None else ""
|
||||
return ft.Row([
|
||||
ft.FilledButton(content=ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ON_ACCENT),
|
||||
ft.Text(f"{job.stage or 'Queued'}{pct}", color=T.ON_ACCENT,
|
||||
weight=ft.FontWeight.W_600)], spacing=10, tight=True),
|
||||
on_click=lambda e: app.show_activity(True),
|
||||
style=ft.ButtonStyle(bgcolor=T.ACCENT, shape=ft.RoundedRectangleBorder(radius=8),
|
||||
padding=ft.Padding(22, 18, 22, 18))),
|
||||
C.ghost("Cancel", ft.Icons.CLOSE_ROUNDED, lambda e: app.jobs.cancel(job)),
|
||||
], spacing=T.S2)
|
||||
buttons: list[ft.Control] = []
|
||||
for i, (label, icon, handler, disabled, tip) in enumerate(app.play_options(g) + app.install_options(g)):
|
||||
buttons.append(C.primary(label, icon, handler, disabled, tip, big=True) if i == 0 else
|
||||
C.secondary(label, icon, handler, disabled, tip))
|
||||
more = ft.PopupMenuButton(icon=ft.Icons.MORE_HORIZ_ROUNDED, icon_color=T.TEXT_2, bgcolor=T.SURFACE_2,
|
||||
tooltip="More actions", items=C.menu_items(app.game_actions(pkg, quick=False)))
|
||||
return ft.Row(buttons + [more], spacing=T.S2, wrap=True)
|
||||
|
||||
# ---------------------------------------------------------------- sections
|
||||
def where(self) -> ft.Control:
|
||||
app, g, pkg = self.app, self.g, self.package
|
||||
cards = []
|
||||
st = C.install_state(g, app.frame_info)
|
||||
last = g.get("last_test") or {}
|
||||
diff = C.settings_diff(g, app.frame_info)
|
||||
if diff:
|
||||
def names(ids):
|
||||
return ", ".join(base.get(i).title if i in base.REGISTRY else i for i in ids)
|
||||
changed = "; ".join(filter(None, [f"on: {names(diff[0])}" if diff[0] else "",
|
||||
f"off: {names(diff[1])}" if diff[1] else ""]))
|
||||
frame_line = {"installed": "Installed",
|
||||
"outdated": (f"Installed · patch settings changed since ({changed}) — update to apply" if diff
|
||||
else "Installed · a newer build is ready"),
|
||||
"missing": "Not installed", None: "Frame not connected"}[st]
|
||||
frame_ok = st in ("installed", "outdated")
|
||||
# Oculus/LibOVR Rift games need Revive, which can't run on the Frame — be honest about it
|
||||
rift_oculus = self.rift and "pcvr.revive" in (g.get("recipe") or {}).get("patches", {})
|
||||
frame_color = (T.TEXT_3 if rift_oculus and not frame_ok else
|
||||
T.OK if st == "installed" else T.WARN if st == "outdated" else T.TEXT_3)
|
||||
frame_sub = ("Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR)."
|
||||
if rift_oculus else
|
||||
f"Last launch test: {last.get('verdict')} · furthest: {last.get('milestone') or '—'}" if last
|
||||
else "Runs directly — no Revive needed" if self.rift else "")
|
||||
cards.append(self.target_card(
|
||||
ft.Icons.VIEW_IN_AR_ROUNDED, "Steam Frame", frame_line, frame_color, frame_sub,
|
||||
[C.icon_btn(ft.Icons.SCIENCE_OUTLINED, C.tip("Launch test on the Frame. " + HELP["launch_test"]),
|
||||
lambda e: app.test_game(pkg, "frame"), not frame_ok),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, C.tip("Uninstall from the Frame. " + HELP["uninstall"]),
|
||||
lambda e: app.uninstall(pkg, "frame"), not frame_ok)] if frame_ok else []))
|
||||
if self.rift:
|
||||
dep = app.pc_installs().get(pkg)
|
||||
cards.append(self.target_card(
|
||||
ft.Icons.COMPUTER_ROUNDED, "This PC (Steam + Revive)",
|
||||
"In your Steam library" if dep else "Not installed", T.PC if dep else T.TEXT_3,
|
||||
f"Revive {dep.get('revive_version') or ''} · {dep.get('backend', 'openxr')} backend" if dep else "",
|
||||
[C.icon_btn(ft.Icons.SCIENCE_OUTLINED, "Launch test on this PC",
|
||||
lambda e: app.test_game(pkg, "pc")),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, "Remove from this PC's Steam library",
|
||||
lambda e: app.uninstall(pkg, "pc"))] if dep else []))
|
||||
return C.section("Where it's installed", ft.Row(cards, spacing=T.S3), help="where")
|
||||
|
||||
def target_card(self, icon, name, line, color, sub, buttons) -> ft.Control:
|
||||
return C.card(ft.Row([
|
||||
ft.Container(ft.Icon(icon, color=color, size=22), width=44, height=44, border_radius=10,
|
||||
bgcolor=T.soft(color, 0.14), alignment=ft.Alignment.CENTER),
|
||||
ft.Column([C.body(name, T.TEXT, weight=ft.FontWeight.W_600), C.body(line, color, size=T.T_META)]
|
||||
+ ([C.meta(sub)] if sub else []), spacing=2, expand=True),
|
||||
*buttons,
|
||||
], spacing=T.S3), expand=True)
|
||||
|
||||
def notes(self) -> list[ft.Control]:
|
||||
g, pkg = self.g, self.package
|
||||
recipe = library.recipe_from_dict(g["recipe"])
|
||||
entry = catalog.lookup(pkg)
|
||||
out = []
|
||||
extra = g["analysis"].get("extra", {})
|
||||
if self.rift and g.get("exe_confirmed") is False:
|
||||
out.append(C.callout(ft.Row([
|
||||
C.body(f"FramePort picked {g.get('exe', '').rsplit('/', 1)[-1]} to start this game, but there are "
|
||||
"other candidates. Check it before installing.", T.TEXT, expand=True),
|
||||
C.secondary("Check", ft.Icons.TERMINAL_ROUNDED, lambda e: self.app.choose_exe(pkg))]), "warn",
|
||||
ft.Icons.HELP_OUTLINE_ROUNDED))
|
||||
if self.rift and extra.get("platform_sdk"):
|
||||
out.append(C.callout("Uses the Oculus Platform SDK: it checks your Oculus license. Normally that needs the "
|
||||
"Oculus app on this PC with a license you own, so it may quit right after starting "
|
||||
"on the headset. You can still try it.", "warn"))
|
||||
if recipe.status == "unsupported":
|
||||
out.append(C.callout(recipe.notes or "This game can't run on the Steam Frame.", "error"))
|
||||
elif recipe.notes and not (self.rift and extra.get("platform_sdk")):
|
||||
out.append(C.callout(recipe.notes, "info"))
|
||||
if entry and entry.pcvr_alternative:
|
||||
out.append(C.callout(f"PC VR alternative: {entry.pcvr_alternative}", "pc"))
|
||||
from .library import counterparts
|
||||
|
||||
links = []
|
||||
for r in counterparts(g, self.games):
|
||||
other_rift = r.get("kind") == "rift"
|
||||
links.append(C.ghost(f"Also in your library: {'Rift' if other_rift else 'Quest'} version",
|
||||
ft.Icons.COMPUTER_ROUNDED if other_rift else ft.Icons.VIEW_IN_AR_ROUNDED,
|
||||
lambda e, p=r["package"]: self.app.open_game(p), color=T.ACCENT))
|
||||
if links:
|
||||
out.append(ft.Row(links, spacing=T.S2, wrap=True))
|
||||
warns = engine.warnings(recipe)
|
||||
if warns:
|
||||
out.append(C.callout("\n".join(warns), "warn"))
|
||||
return out
|
||||
|
||||
def about(self) -> ft.Control | None:
|
||||
from ...artwork import details as det
|
||||
|
||||
d = self.g.get("details") or {}
|
||||
shots = det.screenshot_files(self.package)
|
||||
if not d.get("description") and not shots and not d.get("genres"):
|
||||
if "details" in self.g:
|
||||
return None
|
||||
return C.section("About this game", C.card(ft.Row([
|
||||
ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT),
|
||||
C.meta("Looking up the store description and screenshots…")], spacing=T.S2)))
|
||||
parts: list[ft.Control] = []
|
||||
if shots:
|
||||
strip = ft.Row(spacing=T.S3, scroll=ft.ScrollMode.AUTO)
|
||||
for i, shot in enumerate(shots):
|
||||
url = thumbs.asset_url(thumbs.thumb(shot, 480, shot.stem))
|
||||
strip.controls.append(ft.Container(
|
||||
C.art_fill(url, radius=T.RADIUS_SM, width=256, height=144), border_radius=T.RADIUS_SM,
|
||||
on_click=lambda e, i=i: self.lightbox(shots, i), ink=True, tooltip="View screenshot"))
|
||||
parts.append(strip)
|
||||
facts = [(k, d.get(k)) for k in ("developer", "publisher", "release_date") if d.get(k)]
|
||||
if facts:
|
||||
parts.append(ft.Row([ft.Column([C.meta({"developer": "Developer", "publisher": "Publisher",
|
||||
"release_date": "Released"}[k]), C.body(v, T.TEXT)], spacing=2)
|
||||
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=6, wrap=True))
|
||||
text = 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)
|
||||
parts.append(body)
|
||||
if long:
|
||||
def more(e):
|
||||
body.value = text if body.value.endswith("…") else text[:480].rsplit(" ", 1)[0] + "…"
|
||||
e.control.text = "Show less" if not body.value.endswith("…") else "Show more"
|
||||
body.update()
|
||||
e.control.update()
|
||||
parts.append(ft.TextButton("Show more", on_click=more, style=ft.ButtonStyle(color=T.ACCENT)))
|
||||
links = det.store_links(d)
|
||||
if links:
|
||||
parts.append(ft.Row([ft.TextButton(label, icon=ft.Icons.OPEN_IN_NEW_ROUNDED, url=url,
|
||||
style=ft.ButtonStyle(color=T.ACCENT)) for label, url in links],
|
||||
spacing=T.S2, wrap=True))
|
||||
src = ", ".join({"oculusdb": "Meta store (OculusDB)", "steam": "Steam store"}.get(s, s)
|
||||
for s in d.get("sources") or [])
|
||||
return C.section("About this game", C.card(ft.Column(parts, spacing=T.S4)),
|
||||
subtitle=f"From the {src}" if src else None)
|
||||
|
||||
def lightbox(self, shots: list, index: int) -> None:
|
||||
page = self.app.page
|
||||
state = {"i": index}
|
||||
img = ft.Image(src=thumbs.asset_url(shots[index]), fit=ft.BoxFit.CONTAIN, width=1100, height=620,
|
||||
border_radius=T.RADIUS_SM)
|
||||
counter = C.meta(f"{index + 1} / {len(shots)}")
|
||||
|
||||
def show(delta):
|
||||
state["i"] = (state["i"] + delta) % len(shots)
|
||||
img.src = thumbs.asset_url(shots[state["i"]])
|
||||
counter.value = f"{state['i'] + 1} / {len(shots)}"
|
||||
img.update()
|
||||
counter.update()
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
content=ft.Container(ft.Column([img, ft.Row([
|
||||
C.icon_btn(ft.Icons.CHEVRON_LEFT_ROUNDED, "Previous", lambda e: show(-1)), counter,
|
||||
C.icon_btn(ft.Icons.CHEVRON_RIGHT_ROUNDED, "Next", lambda e: show(1)),
|
||||
ft.Container(expand=True), C.ghost("Close", on_click=lambda e: page.pop_dialog())])],
|
||||
spacing=T.S2, tight=True), width=1100),
|
||||
bgcolor=T.BG, shape=ft.RoundedRectangleBorder(radius=T.RADIUS), content_padding=T.S3))
|
||||
|
||||
def tags(self) -> ft.Control:
|
||||
from .library import all_tags, auto_tags, normalize_tag, user_tags
|
||||
|
||||
pkg = self.package
|
||||
row = ft.Row(spacing=T.S2, run_spacing=T.S2, wrap=True)
|
||||
|
||||
def save(tags):
|
||||
library.upsert_game(pkg, tags=tags)
|
||||
self.g = library.game(pkg)
|
||||
fill()
|
||||
row.update()
|
||||
|
||||
def remove(tag):
|
||||
save([t for t in user_tags(self.g) if t != tag])
|
||||
|
||||
def add(e):
|
||||
tag = normalize_tag(e.control.value)
|
||||
e.control.value = ""
|
||||
if tag and tag.lower() not in {t.lower() for t in user_tags(self.g)}:
|
||||
save(user_tags(self.g) + [tag])
|
||||
else:
|
||||
e.control.update()
|
||||
|
||||
field = ft.TextField(hint_text="Add a tag", dense=True, width=150, text_size=T.T_META,
|
||||
border_radius=20, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT, content_padding=ft.Padding(12, 6, 12, 6), on_submit=add)
|
||||
|
||||
def fill():
|
||||
mine = user_tags(self.g)
|
||||
chips = [ft.Container(ft.Row([C.body(t, T.TEXT, size=T.T_META),
|
||||
ft.Icon(ft.Icons.CLOSE_ROUNDED, size=13, color=T.TEXT_2)], spacing=4, tight=True),
|
||||
bgcolor=T.ACCENT_SOFT, border_radius=20, padding=ft.Padding(10, 5, 8, 5),
|
||||
on_click=lambda e, t=t: remove(t), tooltip="Remove tag")
|
||||
for t in mine]
|
||||
chips += [ft.Container(C.meta(t), border=ft.Border.all(1, T.BORDER), border_radius=20,
|
||||
padding=ft.Padding(10, 5, 10, 5),
|
||||
tooltip="Added automatically (engine, VR API or store genre)")
|
||||
for t in auto_tags(self.g) if t.lower() not in {m.lower() for m in mine}]
|
||||
used = {x for g in self.games for x in user_tags(g)}
|
||||
suggestions = [t for t in all_tags(self.games) if t in used and t not in mine][:6]
|
||||
chips.append(field)
|
||||
chips += [ft.Container(C.meta("+ " + t, T.ACCENT), padding=ft.Padding(6, 5, 6, 5),
|
||||
on_click=lambda e, t=t: save(user_tags(self.g) + [t]), tooltip="Add this tag")
|
||||
for t in suggestions]
|
||||
row.controls = chips
|
||||
fill()
|
||||
return C.section("Tags", row, subtitle="Use tags to group and filter your library", help="tags")
|
||||
|
||||
def recipe_summary(self) -> ft.Control:
|
||||
recipe = library.recipe_from_dict(self.g["recipe"])
|
||||
entry = catalog.lookup(self.package)
|
||||
on = [base.get(pid) for pid in recipe.patches if pid in base.REGISTRY or _known(pid)]
|
||||
visible = [p for p in on if not p.default_on or p.category in ("pcvr",)]
|
||||
if entry and recipe.source.startswith("catalog"):
|
||||
lead = f"Known-good recipe for this game, tested {entry.verified.get('date', '')}".strip(", ")
|
||||
icon, color = ft.Icons.VERIFIED_ROUNDED, T.OK
|
||||
elif recipe.source == "user":
|
||||
lead, icon, color = "Your custom recipe", ft.Icons.TUNE_ROUNDED, T.ACCENT
|
||||
else:
|
||||
lead, icon, color = "Suggested by FramePort from the game's engine and APIs", ft.Icons.AUTO_AWESOME_ROUNDED, \
|
||||
T.ACCENT
|
||||
chips = [ft.Container(C.body(p.title, T.TEXT, size=T.T_META),
|
||||
tooltip=C.tip(recipe.reasons.get(p.id) or p.description),
|
||||
bgcolor=T.SURFACE_3, border_radius=6, padding=ft.Padding(10, 5, 10, 5))
|
||||
for p in visible]
|
||||
base_count = len(on) - len(visible)
|
||||
if base_count > 0:
|
||||
chips.append(C.with_help(C.meta(f"+ {base_count} standard fixes"), "standard_fixes"))
|
||||
as_is = recipe.as_is
|
||||
if as_is and self.rift:
|
||||
# a pre-patched Rift copy still needs a VR runtime on the Frame: Revive is added at launch, not to its files
|
||||
lead, icon, color = ("Your copy is used as it is (only the Frame's copy gets launch fixes)" +
|
||||
(" · Revive provides the Oculus runtime" if "pcvr.revive" in recipe.patches else "")), \
|
||||
ft.Icons.INVENTORY_2_ROUNDED, T.PC
|
||||
elif as_is:
|
||||
lead, icon, color = "Installs the game exactly as it is: no patches (your copy is already patched)", \
|
||||
ft.Icons.INVENTORY_2_ROUNDED, T.PC
|
||||
chips = []
|
||||
|
||||
def toggle_as_is(e):
|
||||
r = library.recipe_from_dict(library.game(self.package)["recipe"])
|
||||
r.as_is = bool(e.control.value) # Revive stays as it is: it isn't a change to the game's files
|
||||
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,
|
||||
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(
|
||||
"What FramePort will do",
|
||||
C.card(ft.Column([
|
||||
ft.Row([ft.Icon(icon, color=color, size=18), C.body(lead, T.TEXT, weight=ft.FontWeight.W_500,
|
||||
expand=True)], spacing=T.S2),
|
||||
ft.Row(chips, spacing=T.S2, run_spacing=T.S2, wrap=True) if chips else
|
||||
(ft.Container() if as_is else C.meta("Nothing to patch: it runs as is.")),
|
||||
ft.Divider(),
|
||||
switch,
|
||||
], spacing=T.S3)),
|
||||
action=C.ghost("Hide patches" if self.advanced else "Customize", ft.Icons.TUNE_ROUNDED,
|
||||
lambda e: self.app.open_game(self.package, advanced=not self.advanced),
|
||||
tooltip=None if self.advanced else "See every patch and turn them on or off"),
|
||||
help="recipe")
|
||||
|
||||
def advanced_panel(self) -> ft.Control:
|
||||
app, g, package = self.app, self.g, self.package
|
||||
recipe = library.recipe_from_dict(g["recipe"])
|
||||
analysis = library.analysis_from_dict(g["analysis"])
|
||||
shown, hidden = engine.visible_patches(analysis, recipe)
|
||||
listed = {p.id for p in (shown + hidden if self.show_all else shown)}
|
||||
state = {"recipe": recipe}
|
||||
warn = C.body("", T.WARN)
|
||||
|
||||
def save(r):
|
||||
r.source = "user"
|
||||
pipeline.set_recipe(package, r)
|
||||
warn.value = "\n".join(engine.warnings(r))
|
||||
warn.update()
|
||||
|
||||
def toggle(pid):
|
||||
def handler(e):
|
||||
r = state["recipe"]
|
||||
if e.control.value:
|
||||
patch = base.get(pid)
|
||||
r.patches[pid] = {"value": patch.params[0].default} if patch.category == "adapter" else \
|
||||
{q.key: (r.patches.get(pid) or {}).get(q.key, q.default) for q in patch.params}
|
||||
r.reasons[pid] = r.reasons.get(pid) or "Enabled by you."
|
||||
else:
|
||||
r.patches.pop(pid, None)
|
||||
save(r)
|
||||
return handler
|
||||
|
||||
def set_value(pid, kind):
|
||||
def handler(e):
|
||||
try:
|
||||
v = float(e.control.value) if kind == "float" else int(float(e.control.value))
|
||||
except ValueError:
|
||||
return
|
||||
state["recipe"].patches[pid] = {"value": v}
|
||||
save(state["recipe"])
|
||||
return handler
|
||||
|
||||
def set_param(pid, key):
|
||||
def handler(e):
|
||||
r = state["recipe"]
|
||||
r.patches.setdefault(pid, {})[key] = e.control.value or ""
|
||||
r.reasons[pid] = r.reasons.get(pid) or "Set by you."
|
||||
save(r)
|
||||
app.open_game(package, advanced=True, show_all=self.show_all) # the switch shows it's on now
|
||||
return handler
|
||||
|
||||
sections = []
|
||||
for cat in ("pcvr", "frame", "overport", "adapter", "device"):
|
||||
rows = []
|
||||
for p in [p for p in base.all_patches() if p.category == cat and p.id in listed]:
|
||||
on = p.id in recipe.patches
|
||||
reason = recipe.reasons.get(p.id, "")
|
||||
sub = [C.meta(p.description, T.TEXT_2)]
|
||||
if reason:
|
||||
sub.insert(0, C.meta(reason, T.ACCENT))
|
||||
extra = None
|
||||
if cat == "adapter":
|
||||
val = recipe.params(p.id).get("value", p.params[0].default)
|
||||
extra = ft.TextField(value=str(val), width=90, dense=True, text_size=T.T_BODY,
|
||||
border_color=T.BORDER, on_blur=set_value(p.id, p.params[0].kind))
|
||||
elif p.params:
|
||||
q = p.params[0]
|
||||
multi = q.kind == "text"
|
||||
extra = ft.TextField(value=str(recipe.params(p.id).get(q.key, q.default) or ""), hint_text=q.help,
|
||||
width=260, dense=True, multiline=multi, min_lines=1,
|
||||
max_lines=4 if multi else 1, text_size=T.T_BODY, border_color=T.BORDER,
|
||||
on_blur=set_param(p.id, q.key))
|
||||
rows.append(ft.Container(ft.Row([
|
||||
ft.Column([ft.Row([C.body(p.title, T.TEXT, weight=ft.FontWeight.W_500)]
|
||||
+ ([C.pill("experimental", T.WARN, tooltip=C.tip(HELP["experimental"]))]
|
||||
if p.experimental else [])
|
||||
+ [C.meta(p.id)], spacing=T.S2, wrap=True), *sub], spacing=3, expand=True),
|
||||
*([extra] if extra else []),
|
||||
ft.Switch(value=on, on_change=toggle(p.id), active_color=T.ACCENT),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S4, 10, T.S4, 10), border=ft.Border(
|
||||
top=ft.BorderSide(1, T.BORDER))))
|
||||
if not rows:
|
||||
continue
|
||||
count = sum(1 for p in base.all_patches() if p.category == cat and p.id in recipe.patches)
|
||||
sections.append(C.card(ft.Column([
|
||||
ft.Container(ft.Row([C.body(CATEGORY_TITLES[cat], T.TEXT, weight=ft.FontWeight.W_600),
|
||||
C.help_icon(f"cat_{cat}"), C.meta(f"{count} on")], spacing=T.S2), padding=ft.Padding(T.S4, T.S3, T.S4, T.S3)),
|
||||
*rows], spacing=0), padding=0))
|
||||
|
||||
def set_alt(e):
|
||||
state["recipe"].use_alt = e.control.value
|
||||
save(state["recipe"])
|
||||
top = []
|
||||
if recipe.alt_patches:
|
||||
top.append(C.with_help(ft.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(
|
||||
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"))
|
||||
warn.value = "\n".join(engine.warnings(recipe))
|
||||
return C.section("Patches", *top, warn, *sections,
|
||||
subtitle="Changes apply to the next install. Hover a chip above for why it was suggested.")
|
||||
|
||||
def details(self) -> ft.Control:
|
||||
g, a = self.g, self.g["analysis"]
|
||||
rows = [C.kv("Package", g["package"])]
|
||||
if self.rift:
|
||||
rows += [C.kv("Folder", g.get("game_dir") or ""), C.kv("Executable", g.get("exe") or ""),
|
||||
C.kv("Type", f"{a['abis'][0]} · {a['graphics']}"),
|
||||
C.kv("Size", f"{(a.get('extra', {}).get('data_bytes') or 0) / 2**30:.1f} GiB")]
|
||||
else:
|
||||
rows += [C.kv("Version", a.get("version") or ""), C.kv("ABIs", ", ".join(a.get("abis") or []), "abis"),
|
||||
C.kv("Graphics", a.get("graphics") or "", "graphics"), C.kv("APK", g.get("apk") or ""),
|
||||
C.kv("Data", f"{(g.get('data_bytes') or 0) / 2**30:.1f} GiB"
|
||||
+ (f" · {g.get('data_dir')}" if g.get("data_dir") else ""))]
|
||||
b = g.get("build") or {}
|
||||
if b.get("apk"):
|
||||
rows.append(C.kv("Last build", b["apk"]))
|
||||
rows.append(C.kv("Recipe", (g.get("recipe") or {}).get("source", ""), "recipe_source"))
|
||||
return ft.ExpansionTile(title=C.body("Details", T.TEXT, weight=ft.FontWeight.W_600), controls=[
|
||||
ft.Container(ft.Column(rows, spacing=T.S2), padding=ft.Padding(T.S4, 0, T.S4, T.S4))],
|
||||
bgcolor=T.SURFACE, collapsed_bgcolor=T.SURFACE)
|
||||
|
||||
def build(self) -> ft.Control:
|
||||
if not self.g:
|
||||
return C.empty_state(ft.Icons.SEARCH_OFF_ROUNDED, "Game not found", "It was removed from the library.",
|
||||
C.primary("Back to library", on_click=lambda e: self.app.go("library")))
|
||||
about = self.about()
|
||||
body = [self.hero(), *self.notes(), self.where(), *([about] if about else []), self.tags(),
|
||||
self.recipe_summary()]
|
||||
if self.advanced:
|
||||
body.append(self.advanced_panel())
|
||||
body.append(self.details())
|
||||
return ft.Column([ft.Container(ft.Column(body, spacing=T.S5), padding=ft.Padding(0, 0, T.S3, T.S6))],
|
||||
scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
|
||||
|
||||
def _known(pid: str) -> bool:
|
||||
try:
|
||||
base.get(pid)
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Library: search, filters, tags and a grid of game cards with hover quick actions.
|
||||
|
||||
Performance: the view is created once and re-mounted; cards are built in a background thread and streamed in batches;
|
||||
search and filters only toggle visibility / order of existing cards; images are small thumbnails by URL."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ...artwork import thumbs
|
||||
from ...core import library
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..help import HELP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
DEFAULT_FILTERS = {"q": "", "where": "all", "platform": "all", "status": "all", "sort": "name", "tags": []}
|
||||
STATUS_ORDER = {"works": 0, "issues": 1, "unknown": 2, "unsupported": 3}
|
||||
|
||||
|
||||
def auto_tags(game: dict) -> list[str]:
|
||||
"""Tags FramePort derives from the analysis (not stored): platform, engine, XR API, mixed reality."""
|
||||
a = game.get("analysis") or {}
|
||||
out = ["PC VR" if game.get("kind") == "rift" else "Quest"]
|
||||
if a.get("engine") and a["engine"] not in ("Other", "?"):
|
||||
out.append(a["engine"])
|
||||
xr = a.get("xr") or ""
|
||||
out += [t for t in ("OpenXR", "VrApi", "LibOVR") if t in xr]
|
||||
patches = (game.get("recipe") or {}).get("patches") or {}
|
||||
if "patch_force_passthrough" in patches or "adapter.scene_emul" in patches:
|
||||
out.append("Mixed reality")
|
||||
out += ((game.get("details") or {}).get("genres") or [])[:4] # from the store
|
||||
return out
|
||||
|
||||
|
||||
def user_tags(game: dict) -> list[str]:
|
||||
return list(game.get("tags") or [])
|
||||
|
||||
|
||||
def game_tags(game: dict) -> list[str]:
|
||||
seen, out = set(), []
|
||||
for t in user_tags(game) + auto_tags(game):
|
||||
if t.lower() not in seen:
|
||||
seen.add(t.lower())
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def all_tags(games: list[dict]) -> list[str]:
|
||||
"""Every tag in the library: the user's own first (alphabetical), then the automatic ones."""
|
||||
mine = sorted({t for g in games for t in user_tags(g)}, key=str.lower)
|
||||
auto = sorted({t for g in games for t in auto_tags(g)} - set(mine), key=str.lower)
|
||||
return mine + auto
|
||||
|
||||
|
||||
def last_used(game: dict) -> float:
|
||||
"""Latest play, install or launch test (seconds since epoch; 0 if never)."""
|
||||
times = [(game.get("last_test") or {}).get("time") or 0, game.get("last_played") or 0]
|
||||
times += [i.get("time") or 0 for i in (game.get("installs") or {}).values() if isinstance(i, dict)]
|
||||
return max(times)
|
||||
|
||||
|
||||
def game_size(game: dict) -> int:
|
||||
if game.get("kind") == "rift":
|
||||
return ((game.get("analysis") or {}).get("extra") or {}).get("data_bytes") or 0
|
||||
return game.get("data_bytes") or 0
|
||||
|
||||
|
||||
from ...core.titles import counterparts, display_title, twins # noqa: E402,F401 (used by the views)
|
||||
|
||||
|
||||
def normalize_tag(text: str) -> str:
|
||||
return " ".join((text or "").replace(",", " ").split())[:32]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ pure helpers (tested)
|
||||
def location(game: dict, frame_info: dict | None, pc_installs: set[str]) -> set[str]:
|
||||
st = C.install_state(game, frame_info)
|
||||
out = set()
|
||||
if st in ("installed", "outdated"):
|
||||
out.add("frame")
|
||||
if game["package"] in pc_installs:
|
||||
out.add("pc")
|
||||
return out
|
||||
|
||||
|
||||
def filter_games(games: list[dict], f: dict, frame_info: dict | None = None,
|
||||
pc_installs: set[str] | frozenset = frozenset()) -> list[dict]:
|
||||
q = (f.get("q") or "").strip().lower()
|
||||
out = []
|
||||
for g in games:
|
||||
tags = [t.lower() for t in game_tags(g)]
|
||||
if q and q not in f"{g.get('title') or ''} {g['package']} {' '.join(tags)}".lower():
|
||||
continue
|
||||
if any(t.lower() not in tags for t in f.get("tags") or []):
|
||||
continue
|
||||
rift = g.get("kind") == "rift"
|
||||
if f.get("platform") == "quest" and rift or f.get("platform") == "pcvr" and not rift:
|
||||
continue
|
||||
if f.get("status", "all") != "all" and (g.get("recipe") or {}).get("status", "unknown") != f["status"]:
|
||||
continue
|
||||
where = f.get("where", "all")
|
||||
loc = location(g, frame_info, set(pc_installs))
|
||||
if where == "frame" and "frame" not in loc or where == "pc" and "pc" not in loc or \
|
||||
where == "none" and loc:
|
||||
continue
|
||||
out.append(g)
|
||||
key = {
|
||||
"name": lambda g: (g.get("title") or g["package"]).lower(),
|
||||
"recent": lambda g: -(g.get("added") or 0),
|
||||
"played": lambda g: -last_used(g),
|
||||
"size": lambda g: -game_size(g),
|
||||
"status": lambda g: (STATUS_ORDER.get((g.get("recipe") or {}).get("status", "unknown"), 9),
|
||||
(g.get("title") or g["package"]).lower()),
|
||||
}.get(f.get("sort", "name"))
|
||||
return sorted(out, key=key)
|
||||
|
||||
|
||||
def load_filters() -> dict:
|
||||
saved = library.setting("ui.library") or {}
|
||||
return {**DEFAULT_FILTERS, "tags": [], **{k: v for k, v in saved.items() if k in DEFAULT_FILTERS and k != "q"}}
|
||||
|
||||
|
||||
def save_filters(f: dict) -> None:
|
||||
library.set_setting("ui.library", {k: v for k, v in f.items() if k != "q"})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ view
|
||||
BATCH = 8
|
||||
|
||||
|
||||
class LibraryView:
|
||||
"""Created once per app; `mount()` returns the same control tree, `reload()` refreshes it in the background."""
|
||||
|
||||
def __init__(self, app: "FramePortApp"):
|
||||
self.app = app
|
||||
self.f = app.lib_filters
|
||||
self.cards: dict[str, tuple[tuple, ft.Control]] = {} # package -> (state key, card)
|
||||
self.checks: dict[str, ft.Control] = {} # package -> selection checkbox overlay
|
||||
self.selected: set[str] = set()
|
||||
self.select_mode = False
|
||||
self.sel_bar = ft.Container(visible=False)
|
||||
self.resume_bar = ft.Container(visible=False)
|
||||
self.games: list[dict] = []
|
||||
self._lock = threading.Lock()
|
||||
self._search_timer: threading.Timer | None = None
|
||||
self._gen = 0
|
||||
self.grid = ft.GridView(expand=True, max_extent=196, child_aspect_ratio=0.62, spacing=T.S4,
|
||||
run_spacing=T.S4, padding=ft.Padding(0, T.S2, T.S2, T.S5))
|
||||
self.count = C.meta("")
|
||||
self.subtitle = C.body("", max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||
self.search = ft.TextField(
|
||||
value=self.f["q"], hint_text="Search games and tags", prefix_icon=ft.Icons.SEARCH_ROUNDED, dense=True,
|
||||
width=260, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT, content_padding=ft.Padding(12, 8, 12, 8), text_size=T.T_BODY,
|
||||
on_change=self._on_search)
|
||||
app.search_field = self.search
|
||||
self.filters = ft.Container()
|
||||
self.hint = ft.Container(visible=False)
|
||||
# one right-click menu for every card: filled with that game's actions when it opens (open_menu)
|
||||
self.menu = ft.ContextMenu(content=self.grid, secondary_trigger=None, tertiary_trigger=None, expand=True)
|
||||
self.body = ft.Container(self.menu, expand=True)
|
||||
add = ft.PopupMenuButton(
|
||||
content=ft.Container(ft.Row([ft.Icon(ft.Icons.ADD_ROUNDED, color=T.ON_ACCENT, size=18),
|
||||
ft.Text("Add games", color=T.ON_ACCENT, weight=ft.FontWeight.W_600, size=13)],
|
||||
spacing=6, tight=True),
|
||||
bgcolor=T.ACCENT, border_radius=T.RADIUS_SM, padding=ft.Padding(14, 9, 16, 9)),
|
||||
items=[ft.PopupMenuItem(content=ft.Text("Scan a folder…"), icon=ft.Icons.FOLDER_OPEN_ROUNDED,
|
||||
on_click=app.pick_folder),
|
||||
ft.PopupMenuItem(content=ft.Text("Add one game folder…"), icon=ft.Icons.CREATE_NEW_FOLDER_ROUNDED,
|
||||
on_click=app.pick_game_folder),
|
||||
ft.PopupMenuItem(content=ft.Text("Add an APK file…"), icon=ft.Icons.ANDROID_ROUNDED,
|
||||
on_click=app.pick_apk)],
|
||||
bgcolor=T.SURFACE_2, tooltip="")
|
||||
self.select_btn = C.secondary("Select", ft.Icons.CHECKLIST_ROUNDED, lambda e: self.set_select_mode(True),
|
||||
tooltip=C.tip(HELP["select"]))
|
||||
self.root = ft.Column([
|
||||
ft.Row([ft.Column([ft.Text("Library", size=T.T_TITLE, weight=ft.FontWeight.W_700, color=T.TEXT,
|
||||
no_wrap=True), self.subtitle], spacing=2, expand=True), self.search,
|
||||
self.select_btn, add], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3),
|
||||
self.resume_bar, self.hint, self.filters, self.body, self.sel_bar,
|
||||
], expand=True, spacing=T.S4)
|
||||
# skeleton cards until the first batch arrives
|
||||
n = min(len(library.games()), 15)
|
||||
self.grid.controls = [ft.Container(bgcolor=T.SURFACE, border_radius=T.RADIUS, border=ft.Border.all(1, T.BORDER),
|
||||
opacity=0.6) for _ in range(n)]
|
||||
|
||||
# ---------------------------------------------------------------- public
|
||||
def mount(self) -> ft.Control:
|
||||
self._update_hint()
|
||||
self.update_resume_bar()
|
||||
self._update_sel_bar()
|
||||
return self.root
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Rebuild changed cards (streamed in batches), then apply filters. Call from a background thread."""
|
||||
from ...targets.pc_revive import local_installs
|
||||
|
||||
with self._lock:
|
||||
self._gen += 1
|
||||
gen = self._gen
|
||||
games = library.games()
|
||||
self.games = games
|
||||
pc = set(local_installs())
|
||||
tw = twins(games)
|
||||
self._update_header(games, pc)
|
||||
if not games:
|
||||
self.body.content = C.empty_state(
|
||||
ft.Icons.LIBRARY_ADD_ROUNDED, "Add your games",
|
||||
"Point FramePort at a folder with Quest game dumps (APK + OBB) or Oculus Rift PC games (one folder "
|
||||
"per game, or a folder of them). It finds them, works out what each needs and fetches artwork.",
|
||||
C.primary("Scan a folder", ft.Icons.FOLDER_OPEN_ROUNDED, self.app.pick_folder, big=True),
|
||||
C.secondary("Add an APK file", ft.Icons.ANDROID_ROUNDED, self.app.pick_apk))
|
||||
self.cards.clear()
|
||||
C.update(self.root)
|
||||
return
|
||||
if self.body.content is not self.menu:
|
||||
self.body.content = self.menu
|
||||
first = not self.cards
|
||||
if first:
|
||||
C.update(self.root)
|
||||
pending = []
|
||||
for g in games:
|
||||
key = self._key(g, pc, tw)
|
||||
old = self.cards.get(g["package"])
|
||||
if old and old[0] == key:
|
||||
continue
|
||||
pending.append((g, key))
|
||||
for i in range(0, len(pending), BATCH):
|
||||
if gen != self._gen:
|
||||
return # a newer reload took over
|
||||
for g, key in pending[i:i + BATCH]:
|
||||
self.cards[g["package"]] = (key, self.card(g, pc, tw))
|
||||
if first or i + BATCH >= len(pending):
|
||||
self._apply(update=True)
|
||||
gone = set(self.cards) - {g["package"] for g in games}
|
||||
for pkg in gone:
|
||||
self.cards.pop(pkg, None)
|
||||
if gone or not pending:
|
||||
self._apply(update=True)
|
||||
|
||||
# ---------------------------------------------------------------- selection (queue several installs)
|
||||
def set_select_mode(self, on: bool) -> None:
|
||||
self.select_mode = on
|
||||
if not on:
|
||||
self.selected.clear()
|
||||
for pkg, chk in self.checks.items():
|
||||
chk.visible = on
|
||||
chk.content.value = pkg in self.selected
|
||||
self._update_sel_bar()
|
||||
C.update(self.grid, self.sel_bar, self.select_btn)
|
||||
|
||||
def toggle_selected(self, pkg: str) -> None:
|
||||
self.selected.symmetric_difference_update({pkg})
|
||||
chk = self.checks.get(pkg)
|
||||
if chk:
|
||||
chk.content.value = pkg in self.selected
|
||||
C.update(chk)
|
||||
self._update_sel_bar()
|
||||
|
||||
def select_visible(self) -> None:
|
||||
self.selected |= {pkg for pkg, (_, card) in self.cards.items() if card.visible}
|
||||
self.set_select_mode(True)
|
||||
|
||||
def _update_sel_bar(self) -> None:
|
||||
app = self.app
|
||||
n = len(self.selected)
|
||||
self.select_btn.visible = not self.select_mode
|
||||
rift = [p for p in self.selected if p.startswith("rift.")]
|
||||
from ...core import winhost
|
||||
|
||||
self.sel_bar.visible = self.select_mode
|
||||
self.sel_bar.content = ft.Container(ft.Row([
|
||||
ft.Icon(ft.Icons.CHECKLIST_ROUNDED, color=T.ACCENT),
|
||||
C.body(f"{n} selected" if n else "Select games to install", T.TEXT, weight=ft.FontWeight.W_600),
|
||||
ft.Container(expand=True),
|
||||
C.ghost("Select all shown", on_click=lambda e: self.select_visible()),
|
||||
C.ghost("Clear", on_click=lambda e: (self.selected.clear(), self.set_select_mode(True))),
|
||||
*([C.secondary(f"Install {len(rift)} on this PC", ft.Icons.COMPUTER_ROUNDED,
|
||||
lambda e: app.install_many(sorted(rift), "pc"), disabled=not winhost.available())]
|
||||
if rift else []),
|
||||
C.primary(f"Install {n} on Frame" if n else "Install on Frame", ft.Icons.VIEW_IN_AR_ROUNDED,
|
||||
lambda e: app.install_many(sorted(self.selected), "frame"),
|
||||
disabled=not n or app.frame_state != "connected",
|
||||
tooltip=None if app.frame_state == "connected" else "Connect your Frame first"),
|
||||
C.ghost("Done", on_click=lambda e: self.set_select_mode(False)),
|
||||
], spacing=T.S2), bgcolor=T.SURFACE_2, border_radius=T.RADIUS, padding=ft.Padding(T.S4, T.S2, T.S2, T.S2),
|
||||
border=ft.Border.all(1, T.ACCENT))
|
||||
C.update(self.sel_bar, self.select_btn)
|
||||
|
||||
def update_resume_bar(self) -> None:
|
||||
"""Installs that didn't finish (cancelled, failed, or the app closed): resume them from here."""
|
||||
app = self.app
|
||||
pending = app.unfinished_installs()
|
||||
self.resume_bar.visible = bool(pending)
|
||||
if pending:
|
||||
names = ", ".join(app._title(p) for p in list(pending)[:3]) + ("…" if len(pending) > 3 else "")
|
||||
self.resume_bar.content = C.callout(ft.Row([
|
||||
C.body(f"{len(pending)} install{'s' if len(pending) != 1 else ''} didn't finish: {names}. What was "
|
||||
"already copied is kept, so resuming continues where it stopped.", T.TEXT, expand=True),
|
||||
C.primary("Resume", ft.Icons.PLAY_ARROW_ROUNDED, lambda e: app.resume_installs()),
|
||||
C.ghost("Dismiss", on_click=lambda e: app.forget_installs()),
|
||||
], spacing=T.S3), "warn", ft.Icons.PAUSE_CIRCLE_OUTLINE_ROUNDED)
|
||||
C.update(self.resume_bar)
|
||||
|
||||
def refresh_async(self) -> None:
|
||||
self.app.page.run_thread(self.reload)
|
||||
|
||||
# ---------------------------------------------------------------- filtering (no rebuilds)
|
||||
def _on_search(self, e):
|
||||
self.f["q"] = e.control.value
|
||||
if self._search_timer:
|
||||
self._search_timer.cancel()
|
||||
self._search_timer = threading.Timer(0.18, lambda: self._apply(update=True))
|
||||
self._search_timer.daemon = True
|
||||
self._search_timer.start()
|
||||
|
||||
def _set(self, key, value):
|
||||
self.f[key] = value
|
||||
save_filters(self.f)
|
||||
self._update_header(self.games, set(self.app.pc_installs()))
|
||||
self._apply(update=True)
|
||||
C.update(self.filters)
|
||||
|
||||
def _apply(self, update: bool = False) -> None:
|
||||
pc = set(self.app.pc_installs())
|
||||
shown = filter_games(self.games, self.f, self.app.frame_info, pc)
|
||||
order = [g["package"] for g in shown]
|
||||
visible = set(order)
|
||||
rest = [p for p in self.cards if p not in visible]
|
||||
controls = []
|
||||
for pkg in order + rest:
|
||||
entry = self.cards.get(pkg)
|
||||
if not entry:
|
||||
continue
|
||||
card = entry[1]
|
||||
card.visible = pkg in visible
|
||||
controls.append(card)
|
||||
self.grid.controls = controls
|
||||
n = len(self.games)
|
||||
self.count.value = f"Showing {len(shown)} of {n}" if len(shown) != n else ""
|
||||
if update:
|
||||
C.update(self.grid, self.count)
|
||||
|
||||
# ---------------------------------------------------------------- header / filter bar
|
||||
def _update_hint(self):
|
||||
app = self.app
|
||||
if app.frame_state != "connected":
|
||||
self.hint.content = C.callout(ft.Row([
|
||||
C.body("Connect your Steam Frame to install games and see what's on it.", T.TEXT, expand=True),
|
||||
C.ghost("Connect", ft.Icons.ARROW_FORWARD_ROUNDED, lambda e: app.go("frame"), color=T.ACCENT)]),
|
||||
"info", ft.Icons.VIEW_IN_AR_ROUNDED)
|
||||
self.hint.visible = True
|
||||
else:
|
||||
self.hint.visible = False
|
||||
|
||||
def _update_header(self, games: list[dict], pc: set[str]) -> None:
|
||||
n = len(games)
|
||||
on = sum(1 for g in games if C.install_state(g, self.app.frame_info) in ("installed", "outdated"))
|
||||
n_pc = len(pc & {g["package"] for g in games})
|
||||
parts = [f"{n} game{'s' if n != 1 else ''}"]
|
||||
if self.app.frame_info is not None:
|
||||
parts.append(f"{on} on your Frame")
|
||||
if n_pc:
|
||||
parts.append(f"{n_pc} on this PC")
|
||||
self.subtitle.value = " · ".join(parts)
|
||||
self._update_hint()
|
||||
has_rift = any(g.get("kind") == "rift" for g in games)
|
||||
where = [("all", "All"), ("frame", "On Frame"), ("none", "Not installed")]
|
||||
if has_rift:
|
||||
where.insert(2, ("pc", "On this PC"))
|
||||
self.filters.content = ft.Row([
|
||||
self._seg("where", where),
|
||||
self._seg("platform", [("all", "All"), ("quest", "Quest"), ("pcvr", "PC VR")]) if has_rift else
|
||||
ft.Container(),
|
||||
self._menu_chip("status", "Status", [("all", "Any"), ("works", "Works"), ("issues", "Works with issues"),
|
||||
("unknown", "Untested"), ("unsupported", "Can't run")]),
|
||||
C.help_icon("status"),
|
||||
self._tag_menu(games),
|
||||
ft.Container(expand=True),
|
||||
self.count,
|
||||
self._menu_chip("sort", "Sort", [("name", "Name"), ("recent", "Recently added"), ("played", "Recently used"),
|
||||
("status", "Status"), ("size", "Size")]),
|
||||
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
self.filters.visible = bool(games)
|
||||
C.update(self.subtitle, self.hint, self.filters)
|
||||
|
||||
def _seg(self, key: str, options: list[tuple[str, str]]) -> ft.Control:
|
||||
items = []
|
||||
for value, label in options:
|
||||
on = self.f.get(key) == value
|
||||
items.append(ft.Container(
|
||||
ft.Text(label, size=T.T_META, weight=ft.FontWeight.W_600, color=T.TEXT if on else T.TEXT_2),
|
||||
padding=ft.Padding(12, 6, 12, 6), border_radius=20, bgcolor=T.SURFACE_3 if on else None,
|
||||
on_click=lambda e, v=value: self._set(key, v), ink=True))
|
||||
return ft.Container(ft.Row(items, spacing=2, tight=True), padding=3, border_radius=22,
|
||||
border=ft.Border.all(1, T.BORDER))
|
||||
|
||||
def _menu_chip(self, key: str, label: str, options: list[tuple[str, str]]) -> ft.Control:
|
||||
current = dict(options).get(self.f.get(key), options[0][1])
|
||||
return ft.PopupMenuButton(
|
||||
content=ft.Container(ft.Row([C.meta(label + ":"), C.body(current, T.TEXT, size=T.T_META),
|
||||
ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED, size=16, color=T.TEXT_2)],
|
||||
spacing=4, tight=True),
|
||||
padding=ft.Padding(12, 7, 8, 7), border_radius=20, border=ft.Border.all(1, T.BORDER)),
|
||||
items=[ft.PopupMenuItem(content=ft.Text(text), checked=self.f.get(key) == value,
|
||||
on_click=lambda e, v=value: self._set(key, v)) for value, text in options],
|
||||
bgcolor=T.SURFACE_2, tooltip="")
|
||||
|
||||
def _toggle_tag(self, tag: str):
|
||||
tags = list(self.f.get("tags") or [])
|
||||
tags.remove(tag) if tag in tags else tags.append(tag)
|
||||
self._set("tags", tags)
|
||||
|
||||
def _tag_menu(self, games: list[dict]) -> ft.Control:
|
||||
chosen = self.f.get("tags") or []
|
||||
label = ", ".join(chosen) if chosen else "Any"
|
||||
items = [ft.PopupMenuItem(content=ft.Text(t), checked=t in chosen, on_click=lambda e, t=t: self._toggle_tag(t))
|
||||
for t in all_tags(games)]
|
||||
if chosen:
|
||||
items.append(ft.PopupMenuItem(content=ft.Text("Clear tags"), icon=ft.Icons.CLEAR_ROUNDED,
|
||||
on_click=lambda e: self._set("tags", [])))
|
||||
return ft.PopupMenuButton(
|
||||
content=ft.Container(ft.Row([ft.Icon(ft.Icons.SELL_OUTLINED, size=14, color=T.TEXT_2), C.meta("Tags:"),
|
||||
C.body(label if len(label) < 28 else f"{len(chosen)} selected", T.TEXT,
|
||||
size=T.T_META),
|
||||
ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED, size=16, color=T.TEXT_2)],
|
||||
spacing=4, tight=True),
|
||||
padding=ft.Padding(12, 7, 8, 7), border_radius=20,
|
||||
border=ft.Border.all(1, T.ACCENT if chosen else T.BORDER)),
|
||||
items=items, bgcolor=T.SURFACE_2, tooltip=C.tip(HELP["tags"]))
|
||||
|
||||
# ---------------------------------------------------------------- cards
|
||||
def _key(self, g: dict, pc: set[str], tw: set[str]) -> tuple:
|
||||
pkg = g["package"]
|
||||
job = self.app.jobs.busy_with(pkg)
|
||||
return (display_title(g, tw), (g.get("recipe") or {}).get("status"), C.install_state(g, self.app.frame_info),
|
||||
pkg in pc, bool(job), self.app.quick_action(g)[0], thumbs.url(pkg, ("portrait", "square", "icon")),
|
||||
self.app.frame_state)
|
||||
|
||||
def card(self, g: dict, pc: set[str], tw: set[str]) -> ft.Control:
|
||||
app = self.app
|
||||
pkg = g["package"]
|
||||
rift = g.get("kind") == "rift"
|
||||
art = thumbs.url(pkg, ("portrait", "square", "icon"))
|
||||
state = C.install_state(g, app.frame_info)
|
||||
on_pc = pkg in pc
|
||||
status = (g.get("recipe") or {}).get("status", "unknown")
|
||||
s_label, s_color = C.STATUS_STYLE.get(status, C.STATUS_STYLE["unknown"])
|
||||
job = app.jobs.busy_with(pkg)
|
||||
badges = []
|
||||
if job:
|
||||
badges.append(C.pill("Working…", T.ACCENT, ft.Icons.SYNC_ROUNDED, solid=True))
|
||||
elif state in ("installed", "outdated"):
|
||||
badges.append(C.install_badge(state))
|
||||
if on_pc:
|
||||
badges.append(C.install_badge("on_pc"))
|
||||
if rift and g.get("exe_confirmed") is False:
|
||||
badges.append(C.pill("Check exe", T.WARN, ft.Icons.HELP_OUTLINE_ROUNDED, overlay=True,
|
||||
tooltip=C.tip(HELP["check_exe"])))
|
||||
platform = C.pill("PC VR" if rift else "Quest", T.PC if rift else T.TEXT,
|
||||
ft.Icons.COMPUTER_ROUNDED if rift else ft.Icons.VIEW_IN_AR_ROUNDED, overlay=True,
|
||||
tooltip=C.tip(HELP["platform_pcvr" if rift else "platform_quest"]))
|
||||
check = ft.Container(ft.Checkbox(value=pkg in self.selected, active_color=T.ACCENT, check_color=T.ON_ACCENT,
|
||||
on_change=lambda e: self.toggle_selected(pkg)),
|
||||
bgcolor=T.soft("#000000", 0.6), border_radius=8, left=6, top=40,
|
||||
visible=self.select_mode)
|
||||
self.checks[pkg] = check
|
||||
quick_label, quick_icon = app.quick_action(g)
|
||||
quick = ft.Container(
|
||||
ft.FilledButton(quick_label, icon=quick_icon, on_click=lambda e: app.primary_action(pkg),
|
||||
style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=20), bgcolor=T.ACCENT,
|
||||
color=T.ON_ACCENT, padding=ft.Padding(14, 8, 14, 8),
|
||||
text_style=ft.TextStyle(size=12, weight=ft.FontWeight.W_600))),
|
||||
left=0, right=0, bottom=64, alignment=ft.Alignment.CENTER, opacity=0,
|
||||
animate_opacity=ft.Animation(160, ft.AnimationCurve.EASE_OUT)) if quick_label else None
|
||||
dim = state == "missing" and not on_pc
|
||||
tile = ft.Container(
|
||||
ft.Stack([
|
||||
C.art_fill(art, left=0, right=0, top=0, bottom=0, opacity=0.5 if dim else 1.0,
|
||||
placeholder_icon=ft.Icons.COMPUTER_ROUNDED if rift else ft.Icons.VIEW_IN_AR_ROUNDED),
|
||||
ft.Container(C.bottom_fade(None, 0.92), left=0, right=0, bottom=0, top=90),
|
||||
ft.Container(ft.Row([platform], spacing=4), left=10, top=10),
|
||||
ft.Container(ft.Column(badges, spacing=4, horizontal_alignment=ft.CrossAxisAlignment.END),
|
||||
right=10, top=10),
|
||||
*([quick] if quick else []),
|
||||
check,
|
||||
ft.Container(ft.Column([
|
||||
ft.Text(display_title(g, tw), size=14, weight=ft.FontWeight.W_700, color=T.TEXT, max_lines=2,
|
||||
overflow=ft.TextOverflow.ELLIPSIS),
|
||||
ft.Container(ft.Row([C.dot(s_color, 7), C.meta(s_label, T.TEXT_2)], spacing=6, tight=True),
|
||||
tooltip=C.tip(HELP["status"])),
|
||||
], spacing=4), left=12, right=12, bottom=12),
|
||||
], expand=True),
|
||||
border_radius=T.RADIUS, bgcolor=T.SURFACE, border=ft.Border.all(1, T.BORDER), expand=True,
|
||||
scale=1.0, animate_scale=ft.Animation(140, ft.AnimationCurve.EASE_OUT),
|
||||
shadow=ft.BoxShadow(blur_radius=18, color=T.soft("#000000", 0.35), offset=ft.Offset(0, 6)),
|
||||
tooltip=ft.Tooltip(message="Click to open · right-click for quick actions", wait_duration=1500),
|
||||
on_click=lambda e: self.toggle_selected(pkg) if self.select_mode else app.open_game(pkg))
|
||||
|
||||
def hover(e):
|
||||
on = e.data in (True, "true")
|
||||
tile.scale = 1.03 if on else 1.0
|
||||
tile.border = ft.Border.all(1, T.ACCENT if on else T.BORDER)
|
||||
if quick:
|
||||
quick.opacity = 1 if on and not self.select_mode else 0
|
||||
tile.update()
|
||||
tile.on_hover = hover
|
||||
return ft.GestureDetector(content=tile, expand=True,
|
||||
on_secondary_tap_down=lambda e: self.open_menu(pkg, e.global_position))
|
||||
|
||||
def open_menu(self, pkg: str, position=None) -> None:
|
||||
"""Right-click on a card: that game's quick actions (built now, so they match the game's current state)."""
|
||||
self.menu.items = C.menu_items(self.app.game_actions(pkg))
|
||||
C.update(self.menu)
|
||||
self.app.page.run_task(self.menu.open, global_position=position)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Settings: the portable toolchain (incl. Revive), this PC for PC VR games, data folder, about."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ...core.paths import user_data_dir
|
||||
from ...recommend import catalog
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
TOOL_TITLES = {"java": "Java runtime", "overport": "overport", "apksigner": "apksigner", "revive": "Revive"}
|
||||
TOOL_WHY = {"java": "Runs overport and apksigner", "overport": "Converts Quest games to OpenXR",
|
||||
"apksigner": "Signs rebuilt games", "revive": "Runs Oculus Rift games on OpenXR"}
|
||||
|
||||
|
||||
class SettingsView:
|
||||
def __init__(self, app: "FramePortApp"):
|
||||
self.app = app
|
||||
self.tools = ft.Column(spacing=0)
|
||||
self.pc = ft.Column(spacing=0)
|
||||
|
||||
def fill_tools(self, check_latest=False):
|
||||
from ...tools import toolchain
|
||||
|
||||
rows = []
|
||||
for s in toolchain.status(check_latest):
|
||||
newer = s.latest and s.version and s.latest != s.version and s.version not in ("external", "system")
|
||||
detail = TOOL_WHY.get(s.name, "")
|
||||
if s.installed:
|
||||
detail += f" · {s.version or 'installed'}" + (f" (update: {s.latest})" if newer else "")
|
||||
if s.name == "revive" and "using" in (s.detail or ""):
|
||||
detail += " · " + s.detail.split("·")[-1].strip()
|
||||
else:
|
||||
detail += " · downloaded when first needed" if s.optional else " · not installed yet"
|
||||
rows.append(C.status_row(True if s.installed else (None if s.optional else False),
|
||||
TOOL_TITLES.get(s.name, s.name), detail,
|
||||
help="revive" if s.name == "revive" else None))
|
||||
self.tools.controls = rows
|
||||
C.update(self.tools)
|
||||
|
||||
def fill_pc(self):
|
||||
from ...core import winhost
|
||||
|
||||
if not winhost.available():
|
||||
self.pc.controls = [C.status_row(None, "Windows not detected",
|
||||
"PC VR games can run on this PC only with Windows (or WSL on Windows)")]
|
||||
else:
|
||||
try:
|
||||
from ...targets.pc_revive import PcReviveTarget
|
||||
|
||||
d = PcReviveTarget().describe()
|
||||
self.pc.controls = [
|
||||
C.status_row(bool(d["steam"]), "Steam", "Found" if d["steam"] else "Steam for Windows not found"),
|
||||
C.status_row(d["steamvr"] or None, "SteamVR",
|
||||
"Installed" if d["steamvr"] else "Install SteamVR from Steam to play PC VR games",
|
||||
help="steamvr_pc"),
|
||||
C.status_row(bool(d["revive"]), "Revive", (f"{d['revive_version']} · {d['revive']}"
|
||||
if d["revive"] else "Downloaded when first needed"),
|
||||
help="revive"),
|
||||
]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.pc.controls = [C.status_row(False, "Couldn't check this PC", str(exc))]
|
||||
C.update(self.pc)
|
||||
|
||||
def agent_text(self) -> str:
|
||||
"""The agent version this app ships, and the one on the connected Frame (it's replaced on the next command
|
||||
whenever the files differ)."""
|
||||
from ...frame.connection import bundled_agent_version
|
||||
|
||||
mine = bundled_agent_version()
|
||||
text = f"v{mine}" if mine else "unknown"
|
||||
info = self.app.frame_info or {}
|
||||
remote = info.get("agent_version")
|
||||
if self.app.frame_state == "connected" and remote:
|
||||
text += " · on the Frame: " + (f"v{remote}" if remote == mine else f"v{remote} (updates on the next action)")
|
||||
else:
|
||||
text += " · Frame not connected"
|
||||
return text
|
||||
|
||||
def build(self) -> ft.Control:
|
||||
app = self.app
|
||||
self.tools.controls = [ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT),
|
||||
C.meta("Checking tools…")], spacing=T.S2)]
|
||||
app.run_bg(self.fill_tools)
|
||||
self.pc.controls = [ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT),
|
||||
C.meta("Checking this PC…")], spacing=T.S2)]
|
||||
app.run_bg(self.fill_pc)
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
ver = version("frameport")
|
||||
except Exception: # noqa: BLE001
|
||||
ver = "dev"
|
||||
data = str(user_data_dir())
|
||||
return ft.Column([
|
||||
app.top_bar("Settings", "Tools, this PC and where FramePort keeps its data"),
|
||||
C.section("Tools", C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
|
||||
subtitle="FramePort manages its own copies; nothing is installed system-wide",
|
||||
action=ft.Row([C.ghost("Check for updates", ft.Icons.UPDATE_ROUNDED,
|
||||
lambda e: app.update_tools(update=True)),
|
||||
C.secondary("Install missing", ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda e: app.update_tools())], spacing=T.S2)),
|
||||
C.section("This PC (for PC VR games)", C.card(self.pc, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2))),
|
||||
C.section("Data", C.card(ft.Column([
|
||||
C.kv("Data folder", ft.Row([C.body(data, T.TEXT, selectable=True, expand=True),
|
||||
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, "Copy path",
|
||||
lambda e: app.copy(data))]), "data_folder"),
|
||||
C.kv("Catalog", f"{len(catalog.load())} known-good recipes (bundled, remote and yours)", "catalog"),
|
||||
], spacing=T.S2))),
|
||||
C.section("Remove FramePort", C.card(ft.Row([
|
||||
C.body("Removes everything FramePort created: its data and tools on this PC, the Steam entries it "
|
||||
"added, and (optionally) its games and files on the Frame. Your game dumps aren't touched.",
|
||||
expand=True),
|
||||
ft.OutlinedButton("Uninstall FramePort…", icon=ft.Icons.DELETE_FOREVER_ROUNDED,
|
||||
on_click=lambda e: app.uninstall_app(),
|
||||
style=ft.ButtonStyle(color=T.ERROR, side=ft.BorderSide(1, T.soft(T.ERROR, 0.6)),
|
||||
shape=ft.RoundedRectangleBorder(radius=T.RADIUS_SM))),
|
||||
], spacing=T.S4))),
|
||||
C.section("About", C.card(ft.Column([
|
||||
C.kv("Version", ver),
|
||||
C.kv("Frame agent", self.agent_text(), "frame_agent"),
|
||||
C.kv("Source", "github.com/spoopyghosty0/frameport"),
|
||||
C.meta("Uses overport, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta."),
|
||||
], spacing=T.S2))),
|
||||
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""First run: get the toolchain, find the Frame, add games — each step starts by itself and can be skipped."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ...core import library
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
|
||||
def needed() -> bool:
|
||||
"""Show the welcome screen: never finished it and something basic is missing."""
|
||||
from ...frame.connection import saved_targets
|
||||
from ...tools import toolchain
|
||||
|
||||
if library.setting("ui.welcome_done"):
|
||||
return False
|
||||
tools_ok = all(s.installed for s in toolchain.status(include_optional=False))
|
||||
return not tools_ok or (not saved_targets() and not library.games())
|
||||
|
||||
|
||||
class WelcomeView:
|
||||
def __init__(self, app: "FramePortApp"):
|
||||
self.app = app
|
||||
|
||||
def step(self, n: int, head: str, text: str, state: str, *content: ft.Control) -> ft.Control:
|
||||
icon = {"done": (ft.Icons.CHECK_ROUNDED, T.OK), "busy": (None, T.ACCENT),
|
||||
"todo": (None, T.TEXT_3), "error": (ft.Icons.PRIORITY_HIGH_ROUNDED, T.ERROR)}[state]
|
||||
marker = ft.ProgressRing(width=20, height=20, stroke_width=2, color=T.ACCENT) if state == "busy" else \
|
||||
ft.Icon(icon[0], size=18, color=icon[1]) if icon[0] else ft.Text(str(n), weight=ft.FontWeight.W_700,
|
||||
color=T.TEXT_2)
|
||||
return C.card(ft.Row([
|
||||
ft.Container(marker, width=36, height=36, border_radius=18, alignment=ft.Alignment.CENTER,
|
||||
bgcolor=T.soft(icon[1], 0.15)),
|
||||
ft.Column([C.h2(head), C.body(text), *content], spacing=T.S2, expand=True),
|
||||
], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.START), padding=T.S5)
|
||||
|
||||
def build(self) -> ft.Control:
|
||||
from ...tools import toolchain
|
||||
|
||||
app = self.app
|
||||
tools = toolchain.status(include_optional=False)
|
||||
tools_done = all(s.installed for s in tools)
|
||||
tool_job = next((j for j in app.jobs.jobs if j.kind == "tools" and j.active), None)
|
||||
if not tools_done and not tool_job and not app.welcome_started:
|
||||
app.welcome_started = True
|
||||
tool_job = app.update_tools(quiet=True)
|
||||
t_state = "done" if tools_done else "busy" if tool_job else "error"
|
||||
t_text = ("Java, overport and apksigner are ready." if tools_done else
|
||||
(tool_job.message or tool_job.stage or "Downloading…") if tool_job else
|
||||
"The download didn't finish. Check your internet connection.")
|
||||
f_state = {"connected": "done", "connecting": "busy"}.get(app.frame_state, "todo")
|
||||
f_text = (f"Connected to {app.target.label}." if app.frame_state == "connected" else
|
||||
"Looking for your Frame…" if app.frame_state == "connecting" else
|
||||
"FramePort installs games on the Frame over your network.")
|
||||
g_state = "done" if library.games() else "todo"
|
||||
return ft.Column([
|
||||
ft.Container(height=T.S5),
|
||||
ft.Row([ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=30, color=T.ON_ACCENT), width=56,
|
||||
height=56, border_radius=16, bgcolor=T.ACCENT, alignment=ft.Alignment.CENTER),
|
||||
ft.Column([C.title("Welcome to FramePort"),
|
||||
C.body("Play your Quest and Oculus Rift games on the Steam Frame. Three steps and "
|
||||
"you're set.")], spacing=2)], spacing=T.S4),
|
||||
ft.Container(height=T.S3),
|
||||
self.step(1, "Getting FramePort ready", t_text, t_state,
|
||||
*([C.progress_bar(tool_job.fraction)] if tool_job else []),
|
||||
*([C.secondary("Try again", ft.Icons.REFRESH_ROUNDED, lambda e: app.update_tools())]
|
||||
if t_state == "error" else [])),
|
||||
self.step(2, "Connect your Steam Frame", f_text, f_state,
|
||||
*([] if f_state == "done" else [C.secondary("Set up the Frame", ft.Icons.ARROW_FORWARD_ROUNDED,
|
||||
lambda e: app.go("frame"))])),
|
||||
self.step(3, "Add your games", "A folder with Quest game dumps (APK + OBB) or Oculus Rift PC games.",
|
||||
g_state, ft.Row([C.primary("Scan a folder", ft.Icons.FOLDER_OPEN_ROUNDED, app.pick_folder),
|
||||
C.ghost("Add an APK file", ft.Icons.ANDROID_ROUNDED, app.pick_apk)],
|
||||
spacing=T.S2)),
|
||||
ft.Row([ft.Container(expand=True),
|
||||
C.primary("Go to my library", ft.Icons.ARROW_FORWARD_ROUNDED, lambda e: app.finish_welcome())
|
||||
if g_state == "done" else C.ghost("Skip for now", on_click=lambda e: app.finish_welcome())]),
|
||||
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True, width=760)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Uninstall FramePort: remove every file it generated.
|
||||
|
||||
This PC:
|
||||
- the data folder (user_data_dir: tools, library, artwork, cache, builds, logs, remembered Frames, the app's SSH key
|
||||
and the per-game signing keys — back those up first: updates must be signed with the same key)
|
||||
- Steam shortcuts FramePort added for PC VR games (Windows Steam) and their grid artwork
|
||||
- the Revive copy under %LOCALAPPDATA%\\FramePort (WSL only; on Windows that *is* the data folder)
|
||||
The Frame (optional; needs a connection): FramePort's games and their Steam entries (saves optionally kept),
|
||||
~/Applications/quest-frame and ~/.local/share/frameport. Proton/Lepton stay (Steam apps you can remove in Steam).
|
||||
The FramePort program itself: delete its folder (portable app) or `uv tool uninstall` / `pip uninstall`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .core.events import Reporter
|
||||
from .core.paths import user_data_dir
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
what: str
|
||||
path: str | None = None
|
||||
size: int = 0
|
||||
kind: str = "pc" # pc | steam | frame
|
||||
|
||||
|
||||
@dataclass
|
||||
class Plan:
|
||||
items: list[Item] = field(default_factory=list)
|
||||
keys: list[Path] = field(default_factory=list) # signing keystores (backup candidates)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return sum(i.size for i in self.items)
|
||||
|
||||
|
||||
def _tree_size(p: Path) -> int:
|
||||
try:
|
||||
return sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def keystores() -> list[Path]:
|
||||
root = user_data_dir()
|
||||
return sorted({*root.glob("overport-workspace/signatures/*.keystore"), *root.glob("keystores/**/*.keystore"),
|
||||
*root.glob("keystores/**/*.jks")})
|
||||
|
||||
|
||||
def wsl_revive_copy() -> Path | None:
|
||||
from .core import winhost
|
||||
|
||||
if not winhost.is_wsl():
|
||||
return None
|
||||
local = winhost.env_path("LOCALAPPDATA")
|
||||
p = local / "FramePort" if local else None
|
||||
return p if p and p.exists() else None
|
||||
|
||||
|
||||
def plan(frame_info: dict | None = None) -> Plan:
|
||||
from .targets.pc_revive import local_installs
|
||||
|
||||
pl = Plan(keys=keystores())
|
||||
data = user_data_dir()
|
||||
pl.items.append(Item("FramePort data (tools, library, artwork, cache, builds, keys)", str(data), _tree_size(data)))
|
||||
for pkg, dep in local_installs().items():
|
||||
pl.items.append(Item(f"Steam shortcut on this PC: {dep.get('title') or pkg}", kind="steam"))
|
||||
rv = wsl_revive_copy()
|
||||
if rv:
|
||||
pl.items.append(Item("Revive copy for Windows", str(rv), _tree_size(rv)))
|
||||
for d in (frame_info or {}).get("installed") or []:
|
||||
pl.items.append(Item(f"On the Frame: {d.get('title') or d['package']}", d.get("base"), d.get("apk_size") or 0,
|
||||
kind="frame"))
|
||||
return pl
|
||||
|
||||
|
||||
def backup_keys(dest: Path) -> Path | None:
|
||||
"""Zip the signing keystores (and the app's SSH key) to dest (a folder or a .zip path)."""
|
||||
keys = keystores()
|
||||
ssh = list((user_data_dir() / "ssh").glob("*"))
|
||||
if not keys and not ssh:
|
||||
return None
|
||||
dest = Path(dest)
|
||||
if dest.suffix.lower() != ".zip":
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest / f"FramePort-keys-{time.strftime('%Y%m%d-%H%M%S')}.zip"
|
||||
root = user_data_dir()
|
||||
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for f in keys + ssh:
|
||||
if f.is_file():
|
||||
z.write(f, f.relative_to(root).as_posix())
|
||||
z.writestr("README.txt", "FramePort signing keys (password 'password', alias 'key') and the app's SSH key.\n"
|
||||
"Restore: `frameport tools import-keys <folder with the .keystore files>`.\n")
|
||||
return dest
|
||||
|
||||
|
||||
def default_backup_dir() -> Path:
|
||||
from .core import winhost
|
||||
|
||||
if winhost.is_wsl():
|
||||
home = winhost.env_path("USERPROFILE")
|
||||
if home:
|
||||
return home / "Documents"
|
||||
docs = Path.home() / "Documents"
|
||||
return docs if docs.is_dir() else Path.home()
|
||||
|
||||
|
||||
def remove_pc_shortcuts(reporter: Reporter) -> None:
|
||||
"""All PC VR shortcuts FramePort added to Windows Steam, with one Steam restart."""
|
||||
from .core import winhost
|
||||
from .targets.pc_revive import _vdf, local_installs, shortcut_fields
|
||||
|
||||
deps = local_installs()
|
||||
if not deps or not winhost.available():
|
||||
return
|
||||
root = winhost.steam_root()
|
||||
user = winhost.steam_user(root) if root else None
|
||||
if not user:
|
||||
reporter.check("Steam shortcuts on this PC", None, "Steam user not found; remove them in Steam")
|
||||
return
|
||||
vdf_mod = _vdf()
|
||||
cfg = root / "userdata" / user / "config"
|
||||
was_running = winhost.stop_steam(root)
|
||||
try:
|
||||
for pkg, dep in deps.items():
|
||||
exe = shortcut_fields(dep)[0]
|
||||
removed = vdf_mod.remove_shortcut(str(cfg / "shortcuts.vdf"), exe)
|
||||
for art in (cfg / "grid").glob(f"{dep.get('appid')}*"):
|
||||
art.unlink(missing_ok=True)
|
||||
reporter.check(f"Steam shortcut: {dep.get('title') or pkg}", True if removed else None,
|
||||
"removed" if removed else "wasn't there")
|
||||
finally:
|
||||
if was_running:
|
||||
winhost.start_steam(root)
|
||||
|
||||
|
||||
def purge_frame(frame, reporter: Reporter, keep_saves: bool = True, wait: float = 180) -> dict:
|
||||
reporter.stage("Removing FramePort from the Frame")
|
||||
frame.agent("purge", keep_saves=keep_saves)
|
||||
end = time.time() + wait
|
||||
st = {}
|
||||
while time.time() < end:
|
||||
time.sleep(3)
|
||||
try:
|
||||
st = frame.agent("purge_status", timeout=30)
|
||||
except Exception: # noqa: BLE001 - the agent deletes itself at the end; SSH may hiccup while Steam restarts
|
||||
st = {"state": "done?"}
|
||||
break
|
||||
if st.get("state") in ("done", "failed"):
|
||||
break
|
||||
for r in st.get("removed", []):
|
||||
reporter.check(r, True)
|
||||
for e in st.get("errors", []):
|
||||
reporter.check("Frame", False, e)
|
||||
if st.get("kept"):
|
||||
reporter.log(f"kept saves on the Frame in: {', '.join(st['kept'])}")
|
||||
return st
|
||||
|
||||
|
||||
def run(reporter: Reporter, frame=None, keep_frame_saves: bool = True, backup_dir: Path | None = None,
|
||||
remove_frame: bool = False) -> dict:
|
||||
"""Uninstall. Returns {backup, removed}. The data folder goes last."""
|
||||
out: dict = {"backup": None}
|
||||
if backup_dir is not None:
|
||||
reporter.stage("Backing up signing keys")
|
||||
z = backup_keys(backup_dir)
|
||||
out["backup"] = str(z) if z else None
|
||||
reporter.check("Signing keys backup", True, str(z) if z else "no keys to back up")
|
||||
if remove_frame and frame is not None:
|
||||
purge_frame(frame, reporter, keep_frame_saves)
|
||||
try:
|
||||
frame.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
reporter.stage("Removing Steam shortcuts on this PC")
|
||||
try:
|
||||
remove_pc_shortcuts(reporter)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
reporter.check("Steam shortcuts on this PC", False, str(exc))
|
||||
rv = wsl_revive_copy()
|
||||
if rv:
|
||||
shutil.rmtree(rv, ignore_errors=True)
|
||||
reporter.check("Revive copy for Windows", True, str(rv))
|
||||
reporter.stage("Removing FramePort's data")
|
||||
data = user_data_dir()
|
||||
shutil.rmtree(data, ignore_errors=True)
|
||||
reporter.check("FramePort data", not data.exists(), str(data))
|
||||
return out
|
||||
@@ -80,14 +80,19 @@ def game_lines(log: str, package: str | None = None) -> list[str]:
|
||||
|
||||
def triage(log: str, state: str = "UNKNOWN", package: str | None = None) -> TriageResult:
|
||||
db = database()
|
||||
lines = game_lines(log, package)
|
||||
kind = "pcvr" if package and package.startswith("rift.") else "quest" # Proton/Revive logs vs Lepton logcat
|
||||
lines = game_lines(log, package) if kind == "quest" else [ANSI.sub("", l) for l in log.splitlines()]
|
||||
text = "\n".join(lines)
|
||||
res = TriageResult(state, None)
|
||||
for m in db["milestones"]:
|
||||
if m.get("kind", "quest") != kind:
|
||||
continue
|
||||
if re.search(m["pattern"], text):
|
||||
res.milestones.append(m["label"])
|
||||
res.milestone = m["label"]
|
||||
for sig in db["signatures"]:
|
||||
if sig.get("kind", "quest") != kind:
|
||||
continue
|
||||
hit = re.search(sig["pattern"], text)
|
||||
if hit:
|
||||
line = next((l for l in lines if re.search(sig["pattern"], l)), hit.group(0))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""The Frame-side agent (stdlib only) — pieces that don't need a Frame."""
|
||||
import importlib.util
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -78,3 +79,319 @@ def test_cleanup_refuses_outside_paths(monkeypatch, tmp_path):
|
||||
for bad in ("/etc", "~/Applications/quest-frame", "~/.."):
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_cleanup({"paths": [bad]})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ PC VR under Proton
|
||||
def fake_steam_tools(a, tmp_path):
|
||||
"""A Steam library with an ARM64 Proton (needing a runtime) installed, like the Frame's."""
|
||||
apps = tmp_path / ".local/share/Steam/steamapps"
|
||||
(apps / "common/Proton 11.0 (ARM64)").mkdir(parents=True)
|
||||
(apps / "common/SteamLinuxRuntime_4-arm64").mkdir(parents=True)
|
||||
(apps / "common/Proton 11.0 (ARM64)/toolmanifest.vdf").write_text(
|
||||
'"manifest"\n{\n "version" "2"\n "commandline" "/proton %verb%"\n "require_tool_appid" "4185400"\n}\n')
|
||||
(apps / "common/SteamLinuxRuntime_4-arm64/toolmanifest.vdf").write_text(
|
||||
'"manifest"\n{\n "commandline" "/_v2-entry-point --verb=%verb% --"\n}\n')
|
||||
for appid, name, d in ((4628740, "Proton 11.0 (ARM64)", "Proton 11.0 (ARM64)"),
|
||||
(4185400, "Steam Linux Runtime 4.0 - Arm64", "SteamLinuxRuntime_4-arm64")):
|
||||
(apps / f"appmanifest_{appid}.acf").write_text(
|
||||
f'"AppState"\n{{\n\t"appid"\t\t"{appid}"\n\t"name"\t\t"{name}"\n\t"StateFlags"\t\t"4"\n'
|
||||
f'\t"installdir"\t\t"{d}"\n}}\n')
|
||||
a.arm64_compat_tools = lambda: {
|
||||
"proton_11-arm64": {"appid": 4628740, "display_name": "Proton 11.0-2 (ARM64)", "from_oslist": "windows",
|
||||
"require_tool_appid": 4185400, "aliases": "proton-stable-arm64"},
|
||||
"proton-experimental-arm64": {"appid": 4427310, "display_name": "Proton Experimental (ARM64)",
|
||||
"from_oslist": "windows", "aliases": "proton-experimental"},
|
||||
"steamlinuxruntime_steamrt4-arm64": {"appid": 4185400, "from_oslist": "linux"},
|
||||
}
|
||||
return apps
|
||||
|
||||
|
||||
def test_proton_status_and_command(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
apps = fake_steam_tools(a, tmp_path)
|
||||
st = a.cmd_proton_status({})
|
||||
assert st["ready"]["name"] == "proton_11-arm64"
|
||||
assert [t["name"] for t in st["tools"]] == ["proton_11-arm64", "proton-experimental-arm64"] # stable first
|
||||
cmd = a.compat_command(st["ready"]["dir"])
|
||||
assert cmd == [str(apps / "common/SteamLinuxRuntime_4-arm64/_v2-entry-point"), "--verb=waitforexitandrun", "--",
|
||||
str(apps / "common/Proton 11.0 (ARM64)/proton"), "waitforexitandrun"]
|
||||
assert a.pick_proton(st["tools"], "proton-experimental")["installed"] is False
|
||||
|
||||
|
||||
def test_install_proton_request_mode(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, tmp_path)
|
||||
calls = []
|
||||
monkeypatch.setattr(a.subprocess, "Popen", lambda args, **k: calls.append(args))
|
||||
r = a.cmd_install_proton({"tool": "proton-experimental-arm64"})
|
||||
assert r["requested"] == [4427310] and calls == [["steam", "-ifrunning", "steam://install/4427310"]]
|
||||
assert a.cmd_install_proton({})["installed"] is True
|
||||
|
||||
|
||||
def test_stub_manifest(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
lib = tmp_path / "lib"
|
||||
lib.mkdir()
|
||||
assert a.write_stub_manifest(4427310, "Proton Experimental (ARM64)", "Proton Experimental (ARM64)", str(lib))
|
||||
text = (lib / "appmanifest_4427310.acf").read_text()
|
||||
assert '"StateFlags"\t\t"1026"' in text and '"installdir"\t\t"Proton Experimental (ARM64)"' in text
|
||||
assert not a.write_stub_manifest(4427310, "x", "x", str(lib)) # never overwrites a real manifest
|
||||
|
||||
|
||||
def test_pcvr_install_flow(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, tmp_path)
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
prep = a.cmd_prepare_pcvr({"package": "rift.space_game", "title": "Space Game"})
|
||||
inc = Path(prep["incoming"])
|
||||
(inc / "game/Space Game_Data").mkdir(parents=True)
|
||||
(inc / "game/Space Game.exe").write_bytes(b"MZexe")
|
||||
(inc / "game/Space Game_Data/level0").write_bytes(b"1234")
|
||||
(inc / "revive").mkdir(exist_ok=True)
|
||||
(inc / "revive/ReviveInjector.exe").write_bytes(b"MZ")
|
||||
(inc / "xrlayer").mkdir(exist_ok=True)
|
||||
layer_json = '{"api_layer": {"name": "XR_APILAYER_FRAMEPORT_timefix", "library_path": "./libxr_frameport_timefix.so"}}'
|
||||
(inc / "xrlayer/XR_APILAYER_FRAMEPORT_timefix.json").write_text(layer_json)
|
||||
(inc / "xrlayer/libxr_frameport_timefix.so").write_bytes(b"ELF")
|
||||
manifests = {"game": {"Space Game.exe": 5, "Space Game_Data/level0": 4}, "revive": {"ReviveInjector.exe": 2},
|
||||
"xrlayer": {"XR_APILAYER_FRAMEPORT_timefix.json": len(layer_json), "libxr_frameport_timefix.so": 3}}
|
||||
r = a.cmd_finalize_pcvr({"package": "rift.space_game", "title": "Space Game", "exe": "Space Game.exe",
|
||||
"manifests": manifests, "env": {"PROTON_LOG": "1", "bad key": "x"}, "xr_layer": True})
|
||||
assert r["ok"] and r["proton"] == "proton_11-arm64"
|
||||
launch = Path(prep["anchor"]) / "launch.sh"
|
||||
text = launch.read_text()
|
||||
assert subprocess.run(["bash", "-n", str(launch)]).returncode == 0
|
||||
assert "SteamGameId=" in text and "export PROTON_LOG=1" in text and "bad key" not in text
|
||||
assert "ReviveInjector.exe /openxr 'Z:" in text and "Space Game.exe'" in text
|
||||
assert 'XR_ENABLE_API_LAYERS="XR_APILAYER_FRAMEPORT_timefix' in text and 'XR_API_LAYER_PATH="$base/xrlayer' in text
|
||||
env = subprocess.run(["bash", "-c", text.split("export XDG_RUNTIME_DIR")[0].replace("set -euo pipefail", "set -eu")
|
||||
.split("[[ -d")[0] + 'base=/b\n' + text.split("export PROTON_LOG_DIR=\"$base\"\n")[1]
|
||||
.split("export XDG_RUNTIME_DIR")[0] + 'echo "$XR_API_LAYER_PATH|$XR_ENABLE_API_LAYERS"'],
|
||||
capture_output=True, text=True)
|
||||
assert env.stdout.strip() == "/b/xrlayer|XR_APILAYER_FRAMEPORT_timefix", env.stderr
|
||||
# the layer is registered as an explicit layer in the user's XDG data dir (Proton's container drops
|
||||
# XR_API_LAYER_PATH), pointing at a shared absolute copy
|
||||
reg = json.loads((tmp_path / ".local/share/openxr/1/api_layers/explicit.d/XR_APILAYER_FRAMEPORT_timefix.json")
|
||||
.read_text())
|
||||
lib = Path(reg["api_layer"]["library_path"])
|
||||
assert lib.is_absolute() and lib.read_bytes() == b"ELF"
|
||||
dep = a.deployment("rift.space_game")
|
||||
assert dep["kind"] == "pcvr" and dep["files"]["game"] == manifests["game"]
|
||||
listed = a.cmd_list_installed({})["games"]
|
||||
assert listed[0]["kind"] == "pcvr" and listed[0]["apk_present"] and "files" not in listed[0]
|
||||
# an update that drops a file removes it, and a new prepare sees what's there
|
||||
prep2 = a.cmd_prepare_pcvr({"package": "rift.space_game", "title": "Space Game"})
|
||||
assert prep2["existing"]["game"] == manifests["game"] and prep2["appid"] == prep["appid"]
|
||||
a.cmd_finalize_pcvr({"package": "rift.space_game", "title": "Space Game", "exe": "Space Game.exe",
|
||||
"manifests": {"game": {"Space Game.exe": 5}, "revive": manifests["revive"]}})
|
||||
assert not (Path(prep["base"]) / "game/Space Game_Data/level0").exists()
|
||||
# uninstall keeps the Proton prefix (saves)
|
||||
(Path(prep["base"]) / "compatdata/pfx").mkdir(parents=True)
|
||||
assert a.cmd_uninstall({"package": "rift.space_game", "keep_data": True})["removed"]
|
||||
assert (Path(prep["base"]) / "compatdata/pfx").is_dir() and not (Path(prep["base"]) / "game").exists()
|
||||
|
||||
|
||||
def test_pcvr_finalize_rejects_bad_exe(monkeypatch, tmp_path):
|
||||
import pytest
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, tmp_path)
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_finalize_pcvr({"package": "rift.x", "title": "X", "exe": "../../etc/passwd", "manifests": {}})
|
||||
|
||||
|
||||
def test_shortcut_tag_and_launch_options(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
vdf = tmp_path / "shortcuts.vdf"
|
||||
a.upsert_shortcut(str(vdf), '"C:\\Revive\\ReviveInjector.exe"', "Game", '"D:\\G\\"', "", "Rift via Revive",
|
||||
'/openxr "D:\\G\\Game.exe"')
|
||||
e = a.vdf_decode(vdf.read_bytes())["shortcuts"]["0"]
|
||||
assert e["tags"] == {"0": "Rift via Revive"} and e["LaunchOptions"] == '/openxr "D:\\G\\Game.exe"'
|
||||
|
||||
|
||||
def test_appinfo_parser(monkeypatch, tmp_path):
|
||||
"""appinfo.vdf v29: header, one app (binary KV with string-table keys), string table."""
|
||||
import struct
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
keys = ["appinfo", "appid", "common", "name", "extended", "compat_tools", "proton-x-arm64", "from_oslist"]
|
||||
k = {n: i for i, n in enumerate(keys)}
|
||||
|
||||
def s(key, val):
|
||||
return b"\x01" + struct.pack("<I", k[key]) + val.encode() + b"\0"
|
||||
|
||||
def m(key, body):
|
||||
return b"\x00" + struct.pack("<I", k[key]) + body + b"\x08"
|
||||
kv = m("appinfo", b"\x02" + struct.pack("<Ii", k["appid"], 7) + m("common", s("name", "Compat List")) +
|
||||
m("extended", m("compat_tools", m("proton-x-arm64", s("from_oslist", "windows") +
|
||||
b"\x02" + struct.pack("<Ii", k["appid"], 99))))) + b"\x08"
|
||||
app = struct.pack("<I", 7) + struct.pack("<I", 60 + len(kv)) + b"\0" * 60 + kv
|
||||
body = app + struct.pack("<I", 0)
|
||||
str_off = 16 + len(body)
|
||||
table = struct.pack("<I", len(keys)) + b"".join(n.encode() + b"\0" for n in keys)
|
||||
data = struct.pack("<II", 0x07564429, 1) + struct.pack("<q", str_off) + body + table
|
||||
f = tmp_path / "appinfo.vdf"
|
||||
f.write_bytes(data)
|
||||
out = a.appinfo_entries(path=str(f))
|
||||
assert out[7]["common"]["name"] == "Compat List"
|
||||
assert out[7]["extended"]["compat_tools"]["proton-x-arm64"] == {"from_oslist": "windows", "appid": 99}
|
||||
|
||||
|
||||
def test_run_tree_kills_the_whole_group(monkeypatch, tmp_path):
|
||||
"""Wine leaves children holding the output open; a timeout must still return and kill them."""
|
||||
import time
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
start = time.time()
|
||||
out, code = a.run_tree(["bash", "-c", "echo started; (sleep 60 &) ; sleep 60"], dict(a.os.environ), str(tmp_path),
|
||||
str(tmp_path / "log"), 2)
|
||||
assert code is None and "started" in out and time.time() - start < 20
|
||||
out, code = a.run_tree(["bash", "-c", "echo ok; exit 3"], dict(a.os.environ), str(tmp_path), str(tmp_path / "l2"), 10)
|
||||
assert (out.strip(), code) == ("ok", 3)
|
||||
|
||||
|
||||
def test_purge_removes_frameport_and_keeps_saves(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, tmp_path)
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: True)
|
||||
monkeypatch.setattr(a, "start_steam", lambda s: None)
|
||||
users = tmp_path / ".local/share/Steam/userdata/42/config"
|
||||
(users / "grid").mkdir(parents=True)
|
||||
# a Quest-style install (base = anchor, saves in lepton-data) and a PC VR one (saves in compatdata)
|
||||
q = Path(a.ANCHORS) / "com.x.y"
|
||||
(q / "lepton-app").mkdir(parents=True)
|
||||
(q / "lepton-data" / "external").mkdir(parents=True)
|
||||
(q / "launch.sh").write_text("#!/bin/sh")
|
||||
(q / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 7, "base": str(q), "title": "Q"}))
|
||||
a.upsert_shortcut(str(users / "shortcuts.vdf"), f'"{q}/launch.sh"', "Q", str(q))
|
||||
(users / "grid" / "7p.png").write_bytes(b"x")
|
||||
agent_home = Path(a.AGENT_HOME)
|
||||
(agent_home / "agent").mkdir(parents=True)
|
||||
a.purge_worker(json.dumps({"keep_saves": True, "status": str(tmp_path / "st.json")}))
|
||||
st = json.loads((tmp_path / "st.json").read_text())
|
||||
assert st["state"] == "done", st
|
||||
assert (q / "lepton-data").is_dir() and not (q / "lepton-app").exists() and not (q / "launch.sh").exists()
|
||||
assert a.vdf_decode((users / "shortcuts.vdf").read_bytes())["shortcuts"] == {}
|
||||
assert not (users / "grid" / "7p.png").exists() and not agent_home.exists()
|
||||
# without keeping saves everything goes
|
||||
a.purge_worker(json.dumps({"keep_saves": False, "status": str(tmp_path / "st.json")}))
|
||||
assert not Path(a.ANCHORS).exists()
|
||||
|
||||
|
||||
def test_shortcut_tags_merge(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
vdf = tmp_path / "shortcuts.vdf"
|
||||
exe = '"/x/launch.sh"'
|
||||
a.upsert_shortcut(str(vdf), exe, "G", "/x", tags=["Meta Quest", "Action"])
|
||||
root = a.vdf_decode(vdf.read_bytes())
|
||||
root["shortcuts"]["0"]["tags"]["9"] = "My collection" # a tag the user set in Steam
|
||||
vdf.write_bytes(a.vdf_encode(root))
|
||||
a.upsert_shortcut(str(vdf), exe, "G", "/x", tags=["Meta Quest", "Action", "Indie"])
|
||||
tags = list(a.vdf_decode(vdf.read_bytes())["shortcuts"]["0"]["tags"].values())
|
||||
assert tags == ["Quest on Frame", "Meta Quest", "Action", "My collection", "Indie"]
|
||||
|
||||
|
||||
def test_pcvr_launcher_oculus_hmd_helper(monkeypatch, tmp_path):
|
||||
"""pcvr.oculus_unreal: launch.sh runs Revive's injector (or the game) through fp_oculushmd.exe."""
|
||||
import pytest
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, tmp_path)
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
|
||||
def install(pkg, helper=True, revive=True, oculus_hmd=True):
|
||||
prep = a.cmd_prepare_pcvr({"package": pkg, "title": "UE Game"})
|
||||
inc = Path(prep["incoming"])
|
||||
(inc / "game").mkdir(parents=True, exist_ok=True)
|
||||
(inc / "game/UEGame.exe").write_bytes(b"MZexe")
|
||||
manifests = {"game": {"UEGame.exe": 5}}
|
||||
if revive:
|
||||
(inc / "revive").mkdir(exist_ok=True)
|
||||
(inc / "revive/ReviveInjector.exe").write_bytes(b"MZ")
|
||||
manifests["revive"] = {"ReviveInjector.exe": 2}
|
||||
if helper:
|
||||
(inc / "helpers").mkdir(exist_ok=True)
|
||||
(inc / "helpers/fp_oculushmd.exe").write_bytes(b"MZhelp")
|
||||
manifests["helpers"] = {"fp_oculushmd.exe": 6}
|
||||
a.cmd_finalize_pcvr({"package": pkg, "title": "UE Game", "exe": "UEGame.exe", "manifests": manifests,
|
||||
"revive": revive, "oculus_hmd": oculus_hmd, "game_args": ["-nocrashreports"]})
|
||||
launch = Path(prep["anchor"]) / "launch.sh"
|
||||
assert subprocess.run(["bash", "-n", str(launch)]).returncode == 0
|
||||
return launch.read_text().splitlines()[-1], prep["base"]
|
||||
|
||||
cmd, base = install("rift.ue_game")
|
||||
helper = f"{base}/helpers/fp_oculushmd.exe"
|
||||
assert helper in cmd and cmd.index(helper) < cmd.index("ReviveInjector.exe")
|
||||
# everything after the helper is a Windows command line: the injector by its Z: path, then the game + args
|
||||
assert f"'Z:{base}/revive/ReviveInjector.exe'".replace("/", "\\") in cmd
|
||||
assert cmd.index("ReviveInjector.exe") < cmd.index("/openxr") < cmd.index("UEGame.exe") < cmd.index("-nocrashreports")
|
||||
assert a.deployment("rift.ue_game")["oculus_hmd"] is True
|
||||
cmd, base = install("rift.ue_direct", revive=False)
|
||||
assert f"{base}/helpers/fp_oculushmd.exe" in cmd and "ReviveInjector" not in cmd and "'Z:" in cmd
|
||||
cmd, base = install("rift.ue_plain", helper=False, oculus_hmd=False)
|
||||
assert "fp_oculushmd" not in cmd and f"{base}/revive/ReviveInjector.exe /openxr" in cmd
|
||||
with pytest.raises(a.AgentError, match="fp_oculushmd.exe missing"):
|
||||
install("rift.ue_nohelper", helper=False)
|
||||
|
||||
|
||||
def test_list_files(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
base = tmp_path / "Applications/quest-frame/rift.g"
|
||||
(base / "game/Bin").mkdir(parents=True)
|
||||
(base / "game/Bin/Game.exe").write_bytes(b"MZ12")
|
||||
(base / "game/Bin/CrashReportClient.exe.disabled").write_bytes(b"MZ")
|
||||
(base / "launch.sh").write_text("#!/bin/sh\n")
|
||||
(base / "deployment.json").write_text(json.dumps({
|
||||
"package": "rift.g", "kind": "pcvr", "base": str(base),
|
||||
"files": {"game": {"Bin/Game.exe": 4, "Bin/Data.pak": 10, "Bin/CrashReportClient.exe": 2}}}))
|
||||
r = a.cmd_list_files({"package": "rift.g"})
|
||||
assert len(r["roots"]) == 1 and r["roots"][0]["name"] == "Install folder" # the anchor is the same folder
|
||||
files = dict(map(tuple, r["roots"][0]["files"]))
|
||||
assert files["game/Bin/Game.exe"] == 4 and "launch.sh" in files
|
||||
assert r["missing"] == [["game/Bin/Data.pak", 10, None]] # the renamed crash reporter isn't "missing"
|
||||
assert not r["truncated"]
|
||||
|
||||
|
||||
def test_launch_uses_steam_shortcut(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
||||
anchor.mkdir(parents=True)
|
||||
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 2546384938, "base": str(anchor)}))
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **k: (calls.append(cmd), SimpleNamespace(returncode=0, stdout=""))[1])
|
||||
r = a.cmd_launch({"package": "com.x.y"})
|
||||
assert r["gameid"] == (2546384938 << 32) | 0x02000000
|
||||
assert calls[-1][-1] == f"steam://rungameid/{r['gameid']}" and calls[-1][0] == "systemd-run"
|
||||
|
||||
|
||||
def test_uninstall_quest_keeping_saves_drops_the_install_record(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
||||
(anchor / "lepton-app").mkdir(parents=True)
|
||||
(anchor / "lepton-app/game.apk").write_bytes(b"PK")
|
||||
(anchor / "lepton-data/saves").mkdir(parents=True)
|
||||
(anchor / "lepton-data/saves/slot1").write_text("progress")
|
||||
(anchor / "launch.sh").write_text("#!/bin/sh\n")
|
||||
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "base": str(anchor), "appid": 1}))
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
||||
assert a.cmd_uninstall({"package": "com.x.y"})["removed"]
|
||||
assert not a.cmd_list_installed({})["games"] # no longer reported as installed
|
||||
assert (anchor / "lepton-data/saves/slot1").read_text() == "progress" # saves kept
|
||||
|
||||
|
||||
def test_prune_shortcuts_on_relaunch_change(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
vdf = str(tmp_path / "shortcuts.vdf")
|
||||
old = a.upsert_shortcut(vdf, '"C:\\Revive\\ReviveInjector.exe"', "Vader", "/d", tag="Rift via Revive",
|
||||
tags=["Rift via Revive"])
|
||||
a.upsert_shortcut(vdf, '"C:\\Other.exe"', "Other game", "/d", tag="Rift via Revive", tags=["Rift via Revive"])
|
||||
removed = a.prune_shortcuts(vdf, "Vader", '"C:\\game\\WKND.exe"', "Rift via Revive") # new direct launch
|
||||
assert removed == [old]
|
||||
root = a.vdf_decode(open(vdf, "rb").read())
|
||||
names = {v.get("appname") for v in root["shortcuts"].values() if isinstance(v, dict)}
|
||||
assert names == {"Other game"} # the stale Vader entry is gone, the unrelated one stays
|
||||
# re-adding Vader directly, then pruning again, is a no-op for the kept entry
|
||||
a.upsert_shortcut(vdf, '"C:\\game\\WKND.exe"', "Vader", "/d", tag="Rift via Revive", tags=["Rift via Revive"])
|
||||
assert a.prune_shortcuts(vdf, "Vader", '"C:\\game\\WKND.exe"', "Rift via Revive") == []
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Artwork for Rift games (mocked network), thumbnails, Quest/Rift twin titles, uninstall."""
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.artwork import fetch, sources, thumbs
|
||||
from frameport.core import library
|
||||
|
||||
|
||||
def png(w=800, h=1200, color=(40, 80, 160)) -> bytes:
|
||||
from PIL import Image
|
||||
|
||||
b = io.BytesIO()
|
||||
Image.new("RGB", (w, h), color).save(b, "PNG")
|
||||
return b.getvalue()
|
||||
|
||||
|
||||
class Resp:
|
||||
def __init__(self, content=b"", status=200, data=None):
|
||||
self.content, self.status_code, self._data = content, status, data
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def net(monkeypatch):
|
||||
"""Fake OculusDB / Steam / Meta art: search results by URL substring, images by URL."""
|
||||
db = {"rift": [], "all": [], "steam": [], "images": {}, "meta": {}}
|
||||
|
||||
def cached_json(name, url, max_age=0, fallback=None):
|
||||
if "oculusdb" in url:
|
||||
term = url.split("/search/")[1].split("?")[0]
|
||||
rows = db["rift"] if "headsets=RIFT" in url else db["all"]
|
||||
return [r for r in rows if r.get("_term", "").lower() in term.lower().replace("%20", " ")]
|
||||
if "storesearch" in url:
|
||||
return {"items": db["steam"]}
|
||||
return fallback
|
||||
|
||||
def http_get(url, timeout=30):
|
||||
if "by_package" in url:
|
||||
pkg = url.split("package=")[1]
|
||||
if pkg in db["meta"]:
|
||||
return Resp(data={"status": "ok", "displayName": "Meta name", "images": [
|
||||
{"image_type": "APP_IMG_COVER_PORTRAIT", "uri": __import__("base64").b64encode(png()).decode()}]})
|
||||
return Resp(data={"status": "not found"})
|
||||
if url in db["images"]:
|
||||
return Resp(db["images"][url])
|
||||
return Resp(b"", 404)
|
||||
monkeypatch.setattr(sources.cache, "cached_json", cached_json)
|
||||
monkeypatch.setattr(sources.cache, "http_get", http_get)
|
||||
monkeypatch.setattr(fetch.cache, "http_get", http_get)
|
||||
return db
|
||||
|
||||
|
||||
def test_norm_and_terms():
|
||||
assert sources.norm("Asgard's Wrath") == sources.norm("Asgards Wrath")
|
||||
assert sources.norm("Lone Echo II") == sources.norm("Lone Echo 2")
|
||||
assert sources.norm("Vader Immortal: Episode III") == sources.norm("Vader Immortal - Episode III")
|
||||
assert "Asgard" in sources.search_terms("Asgards Wrath")
|
||||
|
||||
|
||||
def test_quest_version_rules(net):
|
||||
net["all"] = [{"_term": "robo recall", "appName": "Robo Recall: Unplugged", "packageName": "com.YC.RoboRecall"},
|
||||
{"_term": "the climb", "appName": "The Climb 2", "packageName": "com.crytek.climb2"},
|
||||
{"_term": "chronos", "appName": "Chrono Strike", "packageName": "com.greensky.chronostrike"}]
|
||||
assert sources.match_quest("Robo Recall") == "com.YC.RoboRecall"
|
||||
assert sources.match_quest("The Climb") is None # a sequel isn't the Quest version
|
||||
assert sources.match_quest("Chronos") is None
|
||||
|
||||
|
||||
def test_rift_art_chain(net):
|
||||
# 1. OculusDB square cover, exact (apostrophe-insensitive) match found through a shorter search term
|
||||
net["rift"] = [{"_term": "asgard", "appName": "Asgard's Wrath", "id": "111", "imageLink": "/cdn/images/111",
|
||||
"canonicalName": "sanzaru-asgards-wrath"}]
|
||||
net["images"][sources.OCULUSDB + "/cdn/images/111"] = png(720, 720)
|
||||
found = sources.fetch_rift("rift.asgards_wrath", "Asgards Wrath")
|
||||
assert found["source"] == "oculusdb" and found["title"] == "Asgard's Wrath"
|
||||
assert {p.stem for p in fetch.files("rift.asgards_wrath")} == {"square"}
|
||||
# 2. the Quest version's full Meta art wins when there is one
|
||||
net["all"] = [{"_term": "robo recall", "appName": "Robo Recall: Unplugged", "packageName": "com.YC.RoboRecall"}]
|
||||
net["meta"]["com.YC.RoboRecall"] = True
|
||||
found = sources.fetch_rift("rift.robo_recall", "Robo Recall")
|
||||
assert found["source"] == "meta" and found["quest_package"] == "com.YC.RoboRecall"
|
||||
# 3. Steam, exact names only ("Stormlander" isn't "Stormland")
|
||||
net["steam"] = [{"id": 1, "name": "Stormlander"}, {"id": 469610, "name": "Rick and Morty: Virtual Rick-ality"}]
|
||||
for f in sources.STEAM_FILES.values():
|
||||
net["images"][sources.STEAM_CDN.format(id=469610, file=f)] = png()
|
||||
assert sources.fetch_rift("rift.stormland", "Stormland").get("source") is None
|
||||
found = sources.fetch_rift("rift.rm", "Rick and Morty - Virtual Rick-ality")
|
||||
assert found["source"] == "steam" and {"portrait", "hero", "landscape", "logo"} <= \
|
||||
{p.stem for p in fetch.files("rift.rm")}
|
||||
|
||||
|
||||
def test_exe_icon_fallback(net, tmp_path, monkeypatch):
|
||||
exe = tmp_path / "g.exe"
|
||||
exe.write_bytes(b"MZ")
|
||||
monkeypatch.setattr("frameport.analysis.rift.exe_icon", lambda p: png(64, 64))
|
||||
assert sources.fetch_rift("rift.none", "Totally Unknown Game", exe=exe)["source"] == "icon"
|
||||
|
||||
|
||||
def test_thumbnails_and_urls():
|
||||
d = fetch.artwork_dir("com.x")
|
||||
(d / "portrait.png").write_bytes(png(1000, 1500))
|
||||
url = thumbs.url("com.x", ("portrait",), 400)
|
||||
assert url.startswith("/artwork/com.x/t_portrait_400_") and url.endswith(".jpg")
|
||||
from PIL import Image
|
||||
|
||||
t = next(d.glob("t_portrait_400_*.jpg"))
|
||||
assert Image.open(t).size == (400, 600)
|
||||
assert thumbs.url("com.x", ("portrait",), 400) == url # cached
|
||||
assert "t_portrait" not in "".join(p.name for p in fetch.files("com.x")) # thumbnails aren't artwork kinds
|
||||
|
||||
|
||||
def test_twin_titles():
|
||||
from frameport.core.titles import counterparts, display_title, twins
|
||||
|
||||
games = [{"package": "com.YC.RoboRecall", "title": "Robo Recall"},
|
||||
{"package": "rift.robo_recall", "kind": "rift", "title": "Robo Recall", "quest_package": None},
|
||||
{"package": "rift.lone_echo", "kind": "rift", "title": "Lone Echo"},
|
||||
{"package": "com.x.vader", "title": "Vader Immortal: Episode I"},
|
||||
{"package": "rift.vader1", "kind": "rift", "title": "Vader Immortal - Episode I",
|
||||
"quest_package": "com.x.vader"}]
|
||||
tw = twins(games)
|
||||
assert tw == {"com.YC.RoboRecall", "rift.robo_recall", "com.x.vader", "rift.vader1"}
|
||||
assert display_title(games[0], tw) == "Robo Recall (Quest)" and display_title(games[1], tw) == "Robo Recall (Rift)"
|
||||
assert display_title(games[2], tw) == "Lone Echo"
|
||||
assert [g["package"] for g in counterparts(games[3], games)] == ["rift.vader1"]
|
||||
|
||||
|
||||
def test_steam_title_uses_twin_suffix():
|
||||
from frameport import pipeline
|
||||
|
||||
library.upsert_game("com.YC.RoboRecall", title="Robo Recall")
|
||||
library.upsert_game("rift.robo_recall", kind="rift", title="Robo Recall")
|
||||
assert pipeline.steam_title(library.game("rift.robo_recall")) == "Robo Recall (Rift)"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ uninstall
|
||||
def test_uninstall_backs_up_keys_and_removes_data(tmp_path, monkeypatch):
|
||||
from frameport import uninstall
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.core.paths import user_data_dir
|
||||
|
||||
data = user_data_dir()
|
||||
ks = data / "overport-workspace" / "signatures"
|
||||
ks.mkdir(parents=True)
|
||||
(ks / "com.x.keystore").write_bytes(b"key")
|
||||
(data / "ssh").mkdir()
|
||||
(data / "ssh" / "id_ed25519").write_text("secret")
|
||||
library.upsert_game("com.x", title="X")
|
||||
monkeypatch.setattr(uninstall, "wsl_revive_copy", lambda: None)
|
||||
pl = uninstall.plan()
|
||||
assert pl.keys and pl.items[0].path == str(data)
|
||||
out = uninstall.run(Reporter(), backup_dir=tmp_path / "backup")
|
||||
z = zipfile.ZipFile(out["backup"])
|
||||
assert {"overport-workspace/signatures/com.x.keystore", "ssh/id_ed25519", "README.txt"} <= set(z.namelist())
|
||||
assert not data.exists()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ details / Steam art
|
||||
def test_details_from_oculusdb_and_steam(net, monkeypatch):
|
||||
from frameport import pipeline
|
||||
from frameport.artwork import details
|
||||
|
||||
net["all"] = [{"_term": "com.x.game", "appName": "Game", "packageName": "com.x.game", "id": "9",
|
||||
"display_long_description": "Fight <b>robots</b>.<br/>Save the day.", "genre_names": ["Action"],
|
||||
"publisher_name": "Pub", "website_url": "https://game.example"}]
|
||||
net["steam"] = [{"id": 5, "name": "Game"}]
|
||||
steam = {"5": {"success": True, "data": {"steam_appid": 5, "short_description": "Robots!",
|
||||
"genres": [{"description": "Action"}, {"description": "Indie"}],
|
||||
"developers": ["Dev"], "publishers": ["Pub"],
|
||||
"release_date": {"date": "Apr 20, 2017"},
|
||||
"screenshots": [{"path_full": "https://img/ss1.jpg"},
|
||||
{"path_full": "https://img/ss2.jpg"}]}}}
|
||||
real = sources.cache.cached_json
|
||||
monkeypatch.setattr(details.cache, "cached_json",
|
||||
lambda name, url, **k: steam if "appdetails" in url else real(name, url, **k))
|
||||
net["images"]["https://img/ss1.jpg"] = png(1920, 1080)
|
||||
net["images"]["https://img/ss2.jpg"] = png(1920, 1080)
|
||||
monkeypatch.setattr(details.cache, "http_get", sources.cache.http_get)
|
||||
library.upsert_game("com.x.game", title="Game")
|
||||
d = pipeline.fetch_details("com.x.game")
|
||||
assert d["description"] == "Fight robots.\nSave the day." and d["genres"] == ["Action", "Indie"]
|
||||
assert (d["developer"], d["publisher"], d["release_date"]) == ("Dev", "Pub", "Apr 20, 2017")
|
||||
assert d["screenshots"] == ["shot_1.jpg", "shot_2.jpg"] and sorted(d["sources"]) == ["oculusdb", "steam"]
|
||||
assert [x[0] for x in details.store_links(d)] == ["Meta store", "Steam store", "game.example"]
|
||||
from frameport.ui.views.library import auto_tags
|
||||
|
||||
assert "Indie" in auto_tags(library.game("com.x.game"))
|
||||
|
||||
|
||||
def test_steam_set_from_square_cover_and_screenshot():
|
||||
from PIL import Image
|
||||
|
||||
from frameport.artwork import steam
|
||||
|
||||
d = fetch.artwork_dir("rift.sq")
|
||||
(d / "square.webp").write_bytes(png(720, 720))
|
||||
(d / "shot_1.jpg").write_bytes(png(1920, 1080))
|
||||
art = steam.steam_set("rift.sq")
|
||||
assert set(art) == {"portrait", "landscape", "hero", "icon"}
|
||||
assert Image.open(art["portrait"]).size == (600, 900) and Image.open(art["hero"]).size == (1920, 620)
|
||||
assert Image.open(art["landscape"]).size == (920, 430) and Image.open(art["icon"]).size == (256, 256)
|
||||
assert steam.steam_set("rift.sq") == art # cached
|
||||
|
||||
|
||||
def test_steam_tags():
|
||||
from frameport.artwork.steam import steam_tags
|
||||
|
||||
rift = {"kind": "rift", "details": {"genres": ["Action", "Shooter"]}, "tags": ["Favorite"]}
|
||||
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"]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Picking artwork in the GUI (artwork.sources.apply_choice): never leaves a game without art, keeps screenshots."""
|
||||
import pytest
|
||||
|
||||
from frameport.artwork import fetch, sources
|
||||
|
||||
JPEG = b"\xff\xd8\xff" + b"x" * 400
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def art(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path))
|
||||
d = fetch.artwork_dir("com.x")
|
||||
for name in ("portrait.jpg", "t_portrait_400_aaaa.jpg", "shot_0.jpg", "t_shot_0_480_bbbb.jpg", "title.txt"):
|
||||
(d / name).write_bytes(b"old")
|
||||
return d
|
||||
|
||||
|
||||
def test_failed_pick_keeps_the_current_art(art, monkeypatch):
|
||||
monkeypatch.setattr(sources, "apply_oculusdb", lambda pkg, app: False) # dead store image
|
||||
monkeypatch.setattr(fetch, "fetch", lambda pkg, **kw: (fetch.artwork_dir(pkg), None)) # no art for the package
|
||||
assert sources.apply_choice("com.x", {"source": "Meta (Quest)", "package": "com.other", "app": {}}) is False
|
||||
assert (art / "portrait.jpg").read_bytes() == b"old"
|
||||
assert not any(p.name.startswith(".pick") for p in art.parent.iterdir())
|
||||
|
||||
|
||||
def test_pick_replaces_art_but_keeps_screenshots_and_title(art, monkeypatch):
|
||||
monkeypatch.setattr(sources, "apply_oculusdb", lambda pkg, app: (sources._save(pkg, "square", JPEG), True)[1])
|
||||
assert sources.apply_choice("com.x", {"source": "Oculus Rift", "app": {"id": "1"}}) is True
|
||||
names = {p.name for p in art.iterdir() if p.is_file()}
|
||||
assert names == {"square.jpg", "shot_0.jpg", "t_shot_0_480_bbbb.jpg", "title.txt"}
|
||||
assert not any(p.name.startswith(".pick") for p in art.parent.iterdir())
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Frame.fast_link: bulk uploads go over a direct link (USB / the Frame's hotspot) only when it is reachable and
|
||||
presents the same SSH host key as the paired connection."""
|
||||
import socket
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.frame import connection as conn
|
||||
|
||||
|
||||
def fake_frame(host, key):
|
||||
f = conn.Frame(conn.FrameTarget(host, "steamos", 22))
|
||||
f.client = SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key))
|
||||
return f
|
||||
|
||||
|
||||
def test_fast_link_prefers_usb_then_hotspot_and_checks_host_key(monkeypatch):
|
||||
main = fake_frame("192.168.1.5", "KEY")
|
||||
monkeypatch.setattr(main, "addresses", lambda: [("lo", "127.0.0.1"), ("wlan0", "192.168.1.5"),
|
||||
("wlanap", "10.35.78.1"), ("usb0", "10.86.200.233")])
|
||||
keys = {"10.86.200.233": "OTHER", "10.35.78.1": "KEY"} # something else answers on the USB address
|
||||
reachable = set(keys)
|
||||
monkeypatch.setattr(socket, "create_connection",
|
||||
lambda addr, timeout: SimpleNamespace(close=lambda: None) if addr[0] in reachable
|
||||
else (_ for _ in ()).throw(OSError("unreachable")))
|
||||
|
||||
def connect(self, timeout=10):
|
||||
self.client = SimpleNamespace(get_transport=lambda: SimpleNamespace(
|
||||
get_remote_server_key=lambda: keys[self.target.host]), close=lambda: None)
|
||||
return self
|
||||
monkeypatch.setattr(conn.Frame, "connect", connect)
|
||||
link, label = main.fast_link()
|
||||
assert link.target.host == "10.35.78.1" and "hotspot" in label # the USB impostor was rejected
|
||||
|
||||
reachable.clear() # PC not on any direct link: the normal connection
|
||||
link, label = main.fast_link()
|
||||
assert link is main and label == ""
|
||||
@@ -33,3 +33,27 @@ def test_every_catalog_game_found_and_detected(dumps):
|
||||
if entry.status == "unsupported" and "32-bit" in entry.notes:
|
||||
assert a.only_32bit, pkg
|
||||
print(f"checked {len(matched)} catalog games")
|
||||
|
||||
|
||||
def test_heuristics_reproduce_catalog(dumps):
|
||||
"""Without the catalog, heuristics must reproduce the verified recipes (Phantom's use-the-no-ForceQuit-build is a
|
||||
runtime finding the triage makes, not a static one)."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location("ev", Path(__file__).resolve().parents[1] / "scripts/eval_heuristics.py")
|
||||
ev = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(ev)
|
||||
exact = total = 0
|
||||
for g in dumps:
|
||||
a = analyze(g.apk, data_bytes=g.data_bytes())
|
||||
entry = catalog.lookup(a.package)
|
||||
if not entry:
|
||||
continue
|
||||
from frameport.recommend import engine
|
||||
|
||||
want, got = ev.signature(engine.suggest(a)), ev.signature(engine.suggest(a, use_catalog=False))
|
||||
want["status_unsupported"] = got["status_unsupported"] = False
|
||||
total += 1
|
||||
exact += want == got
|
||||
assert exact >= total - 1, f"only {exact}/{total} catalog recipes reproduced by heuristics"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Heuristics for games without a catalog recipe (catalog switched off)."""
|
||||
from frameport.recommend import engine
|
||||
|
||||
from test_patches import _analysis
|
||||
|
||||
|
||||
def suggest(**kw):
|
||||
kw.setdefault("package", "x.y.unknown")
|
||||
a = _analysis(**kw)
|
||||
return a, engine.suggest(a, use_catalog=False)
|
||||
|
||||
|
||||
def test_mixed_reality_only_scene_game():
|
||||
_, r = suggest(extra={"mr_only": True, "meta_permissions_used": ["com.oculus.permission.USE_SCENE"], "size": 1},
|
||||
meta_permissions=["com.oculus.permission.USE_SCENE"])
|
||||
assert "patch_force_passthrough" in r.patches
|
||||
assert r.params("adapter.scene_emul") == {"value": 1}
|
||||
assert "frame.meta_permissions" in r.patches
|
||||
|
||||
|
||||
def test_mixed_reality_without_scene():
|
||||
_, r = suggest(extra={"mr_only": True, "meta_permissions_used": ["com.oculus.permission.USE_ANCHOR_API"], "size": 1})
|
||||
assert "patch_force_passthrough" in r.patches and "adapter.scene_emul" not in r.patches
|
||||
|
||||
|
||||
def test_hand_tracking_only():
|
||||
_, r = suggest(extra={"hand_tracking_only": True, "size": 1})
|
||||
assert r.params("adapter.controller_fix") == {"value": 0}
|
||||
|
||||
|
||||
def test_heavy_ovrplugin_game_disables_space_warp():
|
||||
_, r = suggest(libs=["libOVRPlugin.so", "libunity.so"], extra={"size": 2**30, "data_bytes": 25 * 2**30})
|
||||
assert "patch_disable_space_warp" in r.patches
|
||||
_, r = suggest(libs=["libOVRPlugin.so", "libunity.so"], extra={"size": 2**30, "data_bytes": 2**30})
|
||||
assert "patch_disable_space_warp" not in r.patches
|
||||
|
||||
|
||||
def test_old_unreal_nodebug_and_alt_build():
|
||||
_, r = suggest(engine="Unreal", xr="VrApi", libs=["libUE4.so", "libOVRPlugin.so", "libvrapi.so"],
|
||||
extra={"unreal_version": "4.20", "size": 1})
|
||||
assert "frame.nodebug" in r.patches and r.alt_patches == ["patch_remove_unreal_force_quit"]
|
||||
_, r = suggest(engine="Unreal", libs=["libUE4.so"], extra={"unreal_version": "4.27", "size": 1})
|
||||
assert "frame.nodebug" not in r.patches
|
||||
|
||||
|
||||
def test_cryengine_disables_vrs():
|
||||
_, r = suggest(engine="CryEngine", xr="VrApi", direct_vrapi=True, libs=["libCrySystem.so", "libCryRenderVulkan.so", "libvrapi.so"],
|
||||
graphics="Vulkan (declared in manifest)")
|
||||
assert r.params("device.files")["files"]["user.cfg"].startswith("r_variable_rate_shading = 0")
|
||||
assert "frame.vrapi_bridge" in r.patches
|
||||
|
||||
|
||||
def test_legacy_vrapi_unity_gles_msaa():
|
||||
_, r = suggest(xr="VrApi", graphics="GLES or unknown", unity_msaa_levels=3)
|
||||
assert "frame.unity_no_msaa" in r.patches
|
||||
|
||||
|
||||
def test_irrelevant_patches_hidden_for_unity():
|
||||
a, r = suggest(engine="Unity", libs=["libunity.so", "libOVRPlugin.so"])
|
||||
shown, hidden = engine.visible_patches(a, r)
|
||||
hidden_ids = {p.id for p in hidden}
|
||||
assert {"patch_oculus_unreal", "patch_remove_unreal_force_quit", "frame.vrapi_bridge", "frame.gl_shim",
|
||||
"frame.metaxr_telemetry"} <= hidden_ids
|
||||
assert "patch_oculus_unity" not in hidden_ids and "frame.adapter" not in hidden_ids
|
||||
# hidden overport defaults stay in the recipe (no-ops), so builds are unchanged
|
||||
assert "patch_oculus_unreal" in r.patches
|
||||
|
||||
|
||||
def test_enabled_patch_is_never_hidden():
|
||||
a, r = suggest(engine="Unity", libs=["libunity.so"])
|
||||
r.patches["frame.vrapi_bridge"] = {}
|
||||
shown, _ = engine.visible_patches(a, r)
|
||||
assert "frame.vrapi_bridge" in {p.id for p in shown}
|
||||
@@ -26,4 +26,7 @@ def test_settings_classification():
|
||||
|
||||
def test_report_counts():
|
||||
text = render()
|
||||
assert "23 work" in text and "Path of the Warrior" in text
|
||||
from frameport.recommend import catalog
|
||||
|
||||
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
|
||||
@@ -0,0 +1,259 @@
|
||||
"""PC VR target (Revive + local Windows Steam) against a fake Steam folder; no Windows needed."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.core import winhost
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.core.models import Recipe
|
||||
|
||||
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
|
||||
|
||||
|
||||
def vdf_mod():
|
||||
spec = importlib.util.spec_from_file_location("agent_for_test", AGENT)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def fake_steam(tmp_path):
|
||||
root = tmp_path / "Steam"
|
||||
(root / "userdata/12345/config").mkdir(parents=True)
|
||||
(root / "userdata/999/config").mkdir(parents=True)
|
||||
(root / "config").mkdir()
|
||||
(root / "steam.exe").write_bytes(b"MZ")
|
||||
(root / "config/loginusers.vdf").write_text(
|
||||
'"users"\n{\n\t"76561197960278073"\n\t{\n\t\t"AccountName"\t\t"a"\n\t\t"MostRecent"\t\t"1"\n\t}\n'
|
||||
'\t"76561197960266727"\n\t{\n\t\t"MostRecent"\t\t"0"\n\t}\n}\n')
|
||||
return root
|
||||
|
||||
|
||||
def test_steam_user_most_recent(tmp_path):
|
||||
assert winhost.steam_user(fake_steam(tmp_path)) == "12345"
|
||||
|
||||
|
||||
def test_path_translation():
|
||||
if not winhost.is_wsl():
|
||||
return
|
||||
assert winhost.to_windows("/mnt/d/Games/X") == "D:\\Games\\X"
|
||||
assert winhost.to_local("C:\\Program Files\\Revive") == Path("/mnt/c/Program Files/Revive")
|
||||
|
||||
|
||||
def test_install_and_shortcut(tmp_path, monkeypatch):
|
||||
from frameport.targets import pc_revive
|
||||
from frameport.tools import revive
|
||||
|
||||
root = fake_steam(tmp_path)
|
||||
rv = tmp_path / "revive"
|
||||
rv.mkdir()
|
||||
for n in revive.RUNTIME_FILES:
|
||||
(rv / n).write_bytes(b"x")
|
||||
monkeypatch.setenv("FRAMEPORT_REVIVE_DIR", str(rv))
|
||||
events = []
|
||||
monkeypatch.setattr(winhost, "available", lambda: True)
|
||||
monkeypatch.setattr(winhost, "is_wsl", lambda: False) # never copy into the real %LOCALAPPDATA%
|
||||
monkeypatch.setattr(winhost, "env_path", lambda name: None)
|
||||
monkeypatch.setattr(winhost, "steam_root", lambda: root)
|
||||
monkeypatch.setattr(winhost, "stop_steam", lambda r: events.append("stop") or True)
|
||||
monkeypatch.setattr(winhost, "start_steam", lambda r: events.append("start"))
|
||||
monkeypatch.setattr(winhost, "to_windows", lambda p: "W:" + str(p).replace("/", "\\"))
|
||||
monkeypatch.setattr("frameport.artwork.fetch.cache.http_get", lambda *a, **k: (_ for _ in ()).throw(OSError()))
|
||||
game = tmp_path / "Games" / "Space Game"
|
||||
game.mkdir(parents=True)
|
||||
(game / "Space Game.exe").write_bytes(b"MZ")
|
||||
t = pc_revive.PcReviveTarget()
|
||||
r = Recipe("rift.space_game", {"pcvr.revive": {}})
|
||||
res = t.install_pcvr("rift.space_game", "Space Game", game, "Space Game.exe", r, Reporter())
|
||||
assert res["ok"] and [d["package"] for d in t.installed()] == ["rift.space_game"]
|
||||
t.add_to_library(["rift.space_game"], Reporter())
|
||||
assert events == ["stop", "start"]
|
||||
sc = vdf_mod().vdf_decode((root / "userdata/12345/config/shortcuts.vdf").read_bytes())["shortcuts"]["0"]
|
||||
exe_win = "W:" + str(game / "Space Game.exe").replace("/", "\\")
|
||||
assert sc["Exe"] == '"' + "W:" + str(rv).replace("/", "\\") + '\\ReviveInjector.exe"'
|
||||
assert sc["LaunchOptions"] == f'/openxr "{exe_win}"'
|
||||
assert sc["tags"] == {"0": "Rift via Revive", "1": "Oculus Rift"} and sc["appid"] == res["appid"]
|
||||
assert sc["StartDir"].endswith('Space Game\\"')
|
||||
# OpenVR backend drops /openxr
|
||||
r2 = Recipe("rift.space_game", {"pcvr.revive": {}, "pcvr.revive_openvr": {}})
|
||||
t.install_pcvr("rift.space_game", "Space Game", game, "Space Game.exe", r2, Reporter())
|
||||
assert pc_revive.shortcut_fields(t._dep("rift.space_game"))[2] == f'"{exe_win}"'
|
||||
assert t.uninstall("rift.space_game")["removed"] and t.installed() == []
|
||||
|
||||
|
||||
def test_openxr_native_runs_exe_directly():
|
||||
from frameport.targets.pc_revive import shortcut_fields
|
||||
|
||||
exe, start, opts = shortcut_fields({"exe_win": "D:\\G\\Game.exe", "revive_win": None})
|
||||
assert (exe, start, opts) == ('"D:\\G\\Game.exe"', '"D:\\G\\"', "")
|
||||
|
||||
|
||||
def test_no_crash_reporter_patch(tmp_path):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.core.models import Recipe
|
||||
from frameport.patches import pcvr
|
||||
from frameport.patches.base import get
|
||||
|
||||
p = get("pcvr.no_crash_reporter")
|
||||
ue = SimpleNamespace(engine="Unreal", extra={"kind": "rift"})
|
||||
unity = SimpleNamespace(engine="Unity", extra={"kind": "rift"})
|
||||
assert p.detect(ue).recommended and p.applies(ue)
|
||||
assert p.detect(unity) is None and not p.applies(unity)
|
||||
assert pcvr.game_args(Recipe(package="rift.x", patches=["pcvr.no_crash_reporter"])) == ["-nocrashreports"]
|
||||
assert pcvr.game_args(Recipe(package="rift.x", patches=[])) == []
|
||||
|
||||
|
||||
def test_agent_crash_reporter_toggle(tmp_path):
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location("fpa", Path(__file__).parents[1] / "agent" / "frameport_agent.py")
|
||||
agent = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(agent)
|
||||
exe = tmp_path / "game" / "Engine" / "Binaries" / "Win64" / "CrashReportClient.exe"
|
||||
exe.parent.mkdir(parents=True)
|
||||
exe.write_bytes(b"MZ")
|
||||
agent.set_crash_reporter(str(tmp_path), enabled=False)
|
||||
assert not exe.exists() and exe.with_name("CrashReportClient.exe.disabled").exists()
|
||||
# still counts as uploaded (not re-sent on the next install)
|
||||
assert "Engine/Binaries/Win64/CrashReportClient.exe" in agent.tree_manifest(str(tmp_path / "game"))
|
||||
agent.set_crash_reporter(str(tmp_path), enabled=True)
|
||||
assert exe.exists()
|
||||
|
||||
|
||||
def test_library_migration_adds_no_crash_reporter(tmp_path, monkeypatch):
|
||||
import json
|
||||
|
||||
from frameport.core import library
|
||||
|
||||
monkeypatch.setattr(library, "_path", lambda: tmp_path / "library.json")
|
||||
(tmp_path / "library.json").write_text(json.dumps({"games": {
|
||||
"rift.ue": {"analysis": {"engine": "Unreal", "extra": {"kind": "rift"}}, "recipe": {"patches": {}}},
|
||||
"rift.unity": {"analysis": {"engine": "Unity", "extra": {"kind": "rift"}}, "recipe": {"patches": {}}},
|
||||
}, "settings": {}}))
|
||||
games = library.load()["games"]
|
||||
# the run-correct migration re-derives from engine defaults: Oculus games get Revive + as-is
|
||||
assert set(games["rift.ue"]["recipe"]["patches"]) == {"pcvr.revive", "pcvr.revive_openvr",
|
||||
"pcvr.no_crash_reporter", "pcvr.oculus_unreal", "pcvr.xr_timefix"}
|
||||
assert set(games["rift.unity"]["recipe"]["patches"]) == {"pcvr.revive", "pcvr.revive_openvr", "pcvr.xr_timefix"}
|
||||
assert games["rift.ue"]["recipe"]["as_is"] and games["rift.unity"]["recipe"]["as_is"]
|
||||
# runs once: a user who turns it off keeps it off
|
||||
data = library.load()
|
||||
data["games"]["rift.ue"]["recipe"]["patches"].pop("pcvr.no_crash_reporter")
|
||||
library.save(data)
|
||||
assert "pcvr.no_crash_reporter" not in library.load()["games"]["rift.ue"]["recipe"]["patches"]
|
||||
|
||||
|
||||
def test_oculus_unreal_patch():
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.patches.base import get
|
||||
|
||||
p = get("pcvr.oculus_unreal")
|
||||
ue = SimpleNamespace(engine="Unreal", extra={"kind": "rift"})
|
||||
ue_xr = SimpleNamespace(engine="Unreal", extra={"kind": "rift", "frame_native": True})
|
||||
unity = SimpleNamespace(engine="Unity", extra={"kind": "rift"})
|
||||
quest_ue = SimpleNamespace(engine="Unreal", extra={})
|
||||
assert p.category == "pcvr" and p.detect(ue).recommended and p.applies(ue)
|
||||
assert not p.detect(ue_xr).recommended and p.applies(ue_xr)
|
||||
assert p.detect(unity) is None and not p.applies(unity)
|
||||
assert p.detect(quest_ue) is None and not p.applies(quest_ue)
|
||||
|
||||
|
||||
def test_migration_rederives_rift_recipes(tmp_path, monkeypatch):
|
||||
import json
|
||||
|
||||
"""The run-correct migration re-derives every Rift recipe from engine defaults: Oculus games get Revive for VR."""
|
||||
from frameport.core import library
|
||||
|
||||
monkeypatch.setattr(library, "_path", lambda: tmp_path / "library.json")
|
||||
(tmp_path / "library.json").write_text(json.dumps({"games": {
|
||||
"rift.ue": {"analysis": {"engine": "Unreal", "abis": ["x86_64"], "extra": {"kind": "rift", "needs_revive": True}},
|
||||
"recipe": {"as_is": True, "patches": {}}}, # Revive wrongly dropped -> restored
|
||||
"com.q.ue": {"analysis": {"engine": "Unreal", "extra": {}}, "recipe": {"patches": {}}},
|
||||
}, "settings": {}}))
|
||||
games = library.load()["games"]
|
||||
r = games["rift.ue"]["recipe"]
|
||||
assert r["as_is"] and "pcvr.revive" in r["patches"] and "pcvr.oculus_unreal" in r["patches"]
|
||||
assert games["com.q.ue"]["recipe"]["patches"] == {} # Quest games untouched
|
||||
|
||||
def test_oculus_hmd_helper_artifact():
|
||||
"""The prebuilt helper is a freestanding Windows x64 console exe that only imports kernel32."""
|
||||
import struct
|
||||
|
||||
from frameport.core.paths import artifacts_dir
|
||||
|
||||
data = (artifacts_dir() / "win-x64" / "fp_oculushmd.exe").read_bytes()
|
||||
assert data[:2] == b"MZ"
|
||||
pe = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
assert data[pe:pe + 4] == b"PE\0\0"
|
||||
assert struct.unpack_from("<H", data, pe + 4)[0] == 0x8664 # AMD64
|
||||
assert struct.unpack_from("<H", data, pe + 24)[0] == 0x20B # PE32+
|
||||
assert struct.unpack_from("<H", data, pe + 24 + 68)[0] == 3 # console subsystem (stderr -> launch.log)
|
||||
assert b"kernel32.dll" in data and b"CreateEventW" in data
|
||||
assert "OculusHMDConnected".encode("utf-16-le") in data
|
||||
assert not any(dll in data.lower() for dll in (b"msvcrt", b"vcruntime", b"ucrtbase"))
|
||||
|
||||
|
||||
def test_oculus_hmd_triage():
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
ok = triage("FramePort: launching rift.x with proton_11-arm64\n"
|
||||
"FramePort oculushmd: OculusHMDConnected event ready\n", package="rift.x")
|
||||
assert "Oculus headset event provided" in ok.milestones and not ok.findings
|
||||
bad = triage("FramePort oculushmd: could not create the OculusHMDConnected event (error 5)\n", package="rift.x")
|
||||
assert [f.id for f in bad.findings] == ["oculus-hmd-event"]
|
||||
assert "pcvr.oculus_unreal" in bad.findings[0].suggest
|
||||
|
||||
|
||||
def test_migration_respects_catalog_openxr_native(tmp_path, monkeypatch):
|
||||
import json
|
||||
|
||||
"""A catalog SteamVR/OpenXR-native recipe (Revive removed) keeps Revive off after the re-derive."""
|
||||
from frameport.core import library
|
||||
|
||||
monkeypatch.setattr(library, "_path", lambda: tmp_path / "library.json")
|
||||
(tmp_path / "library.json").write_text(json.dumps({"games": {
|
||||
# openxr_native -> engine.suggest doesn't add Revive
|
||||
"rift.xr": {"analysis": {"engine": "Unity", "abis": ["x86_64"],
|
||||
"extra": {"kind": "rift", "openxr_native": True}},
|
||||
"recipe": {"as_is": True, "patches": {"pcvr.revive": {}}}},
|
||||
}, "settings": {}}))
|
||||
r = library.load()["games"]["rift.xr"]["recipe"]
|
||||
assert "pcvr.revive" not in r["patches"]
|
||||
|
||||
|
||||
def test_pc_defaults_to_openvr_backend():
|
||||
from frameport.core.models import Recipe
|
||||
from frameport.patches import base
|
||||
from frameport.targets.pc_revive import shortcut_fields
|
||||
|
||||
p = base.get("pcvr.revive_openvr")
|
||||
assert p.detect(SimpleNamespace(extra={"kind": "rift"})).recommended # on by default (PC)
|
||||
assert not p.detect(SimpleNamespace(extra={"kind": "rift", "frame_native": True})).recommended
|
||||
# OpenVR backend -> no /openxr flag in the launch command
|
||||
dep_ovr = {"exe_win": "C:\\g\\Game.exe", "revive_win": "C:\\Revive", "backend": "openvr", "game_args": []}
|
||||
dep_xr = {**dep_ovr, "backend": "openxr"}
|
||||
assert "/openxr" not in shortcut_fields(dep_ovr)[2] and "Game.exe" in shortcut_fields(dep_ovr)[2]
|
||||
assert "/openxr" in shortcut_fields(dep_xr)[2]
|
||||
|
||||
|
||||
def test_start_steamvr(monkeypatch, tmp_path):
|
||||
from frameport.core import winhost
|
||||
|
||||
running = {"vr": False}
|
||||
monkeypatch.setattr(winhost, "process_running", lambda img: running["vr"] if "vr" in img.lower() else False)
|
||||
monkeypatch.setattr(winhost, "app_installed", lambda root, appid: True)
|
||||
started = []
|
||||
monkeypatch.setattr(winhost, "start_detached", lambda exe, args=(), cwd=None: (started.append(list(args)),
|
||||
running.__setitem__("vr", True))[0])
|
||||
assert winhost.start_steamvr(tmp_path, wait=5) is True and started == [["-applaunch", "250820"]]
|
||||
# already running -> no-op
|
||||
started.clear()
|
||||
assert winhost.start_steamvr(tmp_path) is True and started == []
|
||||
# not installed -> False, nothing started
|
||||
running["vr"] = False
|
||||
monkeypatch.setattr(winhost, "app_installed", lambda root, appid: False)
|
||||
assert winhost.start_steamvr(tmp_path, wait=2) is False and started == []
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Oculus Rift (PC VR) support: PE detection, scanning, recipes, Revive unpacking, triage."""
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.analysis import rift
|
||||
from frameport.core import library
|
||||
from frameport.recommend import engine
|
||||
from frameport.sources import rift_dump
|
||||
|
||||
|
||||
def make_pe(machine=0x8664, imports=(), extra=b"", subsystem=2, delay=()) -> bytes:
|
||||
"""A minimal PE (one section holding the import directory + name strings) — enough for rift.read_pe."""
|
||||
pe32plus = machine == 0x8664
|
||||
opt_size = 240 if pe32plus else 224
|
||||
sect_rva, sect_raw = 0x1000, 0x400
|
||||
body = bytearray()
|
||||
desc_size = 20 * (len(imports) + 1)
|
||||
names = bytearray()
|
||||
descs = bytearray()
|
||||
for name in imports:
|
||||
descs += struct.pack("<IIIII", 0, 0, 0, sect_rva + desc_size + len(names), 0)
|
||||
names += name.encode() + b"\0"
|
||||
descs += b"\0" * 20
|
||||
delay_off = desc_size + len(names) + sum(len(n) + 1 for n in delay)
|
||||
delay_descs = bytearray()
|
||||
for name in delay: # ImgDelayDescr: attributes, then the DLL name RVA
|
||||
delay_descs += struct.pack("<II", 1, sect_rva + desc_size + len(names)) + b"\0" * 24
|
||||
names += name.encode() + b"\0"
|
||||
if delay:
|
||||
delay_descs += b"\0" * 32
|
||||
body += descs + names + delay_descs + extra
|
||||
hdr = bytearray(b"MZ" + b"\0" * 0x3A + struct.pack("<I", 0x80))
|
||||
hdr += b"\0" * (0x80 - len(hdr))
|
||||
hdr += b"PE\0\0" + struct.pack("<HHIIIHH", machine, 1, 0, 0, 0, opt_size, 0x22)
|
||||
opt = bytearray(opt_size)
|
||||
struct.pack_into("<H", opt, 0, 0x20B if pe32plus else 0x10B)
|
||||
struct.pack_into("<H", opt, 68, subsystem) # 2 = Windows GUI (games), 3 = console
|
||||
dd = 112 if pe32plus else 96
|
||||
struct.pack_into("<II", opt, dd + 8, sect_rva if imports else 0, desc_size)
|
||||
if delay:
|
||||
struct.pack_into("<II", opt, dd + 13 * 8, sect_rva + delay_off, len(delay_descs))
|
||||
hdr += opt
|
||||
hdr += b".idata\0\0" + struct.pack("<IIII", len(body), sect_rva, len(body), sect_raw) + b"\0" * 16
|
||||
hdr += b"\0" * (sect_raw - len(hdr))
|
||||
return bytes(hdr + body)
|
||||
|
||||
|
||||
def unity_game(root: Path, name="Space Game", imports=("d3d11.dll", "kernel32.dll"), platform=False, extra=b"") -> Path:
|
||||
g = root / name
|
||||
(g / f"{name}_Data" / "Plugins").mkdir(parents=True)
|
||||
(g / f"{name}.exe").write_bytes(make_pe(imports=("kernel32.dll",)))
|
||||
(g / "UnityPlayer.dll").write_bytes(make_pe(imports=imports, extra=extra))
|
||||
(g / "UnityCrashHandler64.exe").write_bytes(make_pe())
|
||||
(g / f"{name}_Data" / "Plugins" / "OVRPlugin.dll").write_bytes(make_pe(extra=b"ovr_Initialize"))
|
||||
if platform:
|
||||
(g / f"{name}_Data" / "Plugins" / "LibOVRPlatform64_1.dll").write_bytes(make_pe())
|
||||
return g
|
||||
|
||||
|
||||
def test_read_pe_machine_and_imports(tmp_path):
|
||||
p = tmp_path / "a.exe"
|
||||
p.write_bytes(make_pe(0x14C, ("d3d11.dll", "LibOVRPlatform32_1.dll")))
|
||||
info, _ = rift.read_pe(p)
|
||||
assert info.machine == "x86"
|
||||
assert info.imports == ["d3d11.dll", "libovrplatform32_1.dll"]
|
||||
|
||||
|
||||
def test_unity_rift_game(tmp_path):
|
||||
g = unity_game(tmp_path)
|
||||
a = rift.analyze(g)
|
||||
assert a.package == "rift.space_game"
|
||||
assert (a.engine, a.xr, a.graphics, a.abis) == ("Unity", "LibOVR", "D3D11", ["x86_64"])
|
||||
assert a.extra["exe"] == "Space Game.exe" and a.extra["kind"] == "rift"
|
||||
assert not a.extra["platform_sdk"] and a.extra["needs_revive"]
|
||||
|
||||
|
||||
def test_platform_sdk_and_openxr_native(tmp_path):
|
||||
a = rift.analyze(unity_game(tmp_path, "Owned", platform=True))
|
||||
assert a.extra["platform_sdk"]
|
||||
g = tmp_path / "XR"
|
||||
g.mkdir()
|
||||
(g / "XR.exe").write_bytes(make_pe(imports=("openxr_loader.dll", "d3d12.dll")))
|
||||
b = rift.analyze(g)
|
||||
assert b.xr == "OpenXR" and b.graphics == "D3D12" and b.extra["openxr_native"]
|
||||
|
||||
|
||||
def test_unreal_layout_picks_shipping_exe(tmp_path):
|
||||
g = tmp_path / "Climb Game"
|
||||
(g / "Climb" / "Binaries" / "Win64").mkdir(parents=True)
|
||||
(g / "Engine").mkdir()
|
||||
(g / "Climb.exe").write_bytes(make_pe())
|
||||
(g / "Climb" / "Binaries" / "Win64" / "Climb-Win64-Shipping.exe").write_bytes(
|
||||
make_pe(imports=("d3d11.dll",), extra=b"LibOVRRT%hs_%d.dll" + b"\0" * 4096))
|
||||
a = rift.analyze(g)
|
||||
assert a.engine == "Unreal" and a.xr == "LibOVR"
|
||||
assert a.extra["exe"] == "Climb/Binaries/Win64/Climb-Win64-Shipping.exe"
|
||||
r = engine.suggest(a) # Oculus/LibOVR Unreal: Revive on (PC OpenVR backend), files unchanged, crash reporter off
|
||||
assert r.as_is and set(r.patches) == {"pcvr.revive", "pcvr.revive_openvr", "pcvr.no_crash_reporter",
|
||||
"pcvr.oculus_unreal", "pcvr.xr_timefix"}
|
||||
|
||||
|
||||
def test_scan_finds_rift_games_not_parents_or_quest(tmp_path):
|
||||
unity_game(tmp_path / "dumps")
|
||||
q = tmp_path / "dumps" / "Quest Game"
|
||||
q.mkdir()
|
||||
(q / "game.apk").write_bytes(b"PK")
|
||||
(q / "setup.exe").write_bytes(make_pe())
|
||||
found = rift_dump.scan(tmp_path / "dumps")
|
||||
assert [p.name for p in found] == ["Space Game"]
|
||||
assert [p.name for p in rift_dump.scan(tmp_path)] == ["Space Game"] # dumps/Space Game: 2 levels down
|
||||
assert rift_dump.scan(tmp_path, depth=1) == []
|
||||
|
||||
|
||||
def test_recipe_only_pcvr_patches(tmp_path):
|
||||
a = rift.analyze(unity_game(tmp_path))
|
||||
r = engine.suggest(a)
|
||||
assert r.as_is and "pcvr.revive" in r.patches # Oculus game: Revive gives it VR
|
||||
assert all(pid.startswith("pcvr.") for pid in r.patches), r.patches
|
||||
shown, hidden = engine.visible_patches(a, r)
|
||||
assert shown and all(p.category == "pcvr" for p in shown + hidden)
|
||||
assert engine.warnings(r) == []
|
||||
|
||||
|
||||
def test_quest_games_never_see_pcvr_patches(quest_manifest, tmp_path):
|
||||
import zipfile
|
||||
|
||||
from frameport.analysis.detect import analyze
|
||||
|
||||
apk = tmp_path / "q.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", quest_manifest)
|
||||
z.writestr("lib/arm64-v8a/libunity.so", b"\x7fELF")
|
||||
a = analyze(apk, deep=False)
|
||||
r = engine.suggest(a)
|
||||
shown, hidden = engine.visible_patches(a, r)
|
||||
assert not any(p.category == "pcvr" for p in shown + hidden)
|
||||
assert not any(pid.startswith("pcvr.") for pid in r.patches)
|
||||
|
||||
|
||||
def test_platform_sdk_note(tmp_path):
|
||||
r = engine.suggest(rift.analyze(unity_game(tmp_path, "Owned", platform=True)))
|
||||
assert "Oculus app" in r.notes
|
||||
|
||||
|
||||
def test_pipeline_add_path_and_prepare(tmp_path, monkeypatch):
|
||||
from frameport import pipeline
|
||||
from frameport.core.events import Reporter
|
||||
|
||||
unity_game(tmp_path / "dumps")
|
||||
added = pipeline.add_path(tmp_path / "dumps")
|
||||
assert [g["package"] for g in added] == ["rift.space_game"]
|
||||
g = library.game("rift.space_game")
|
||||
assert g["kind"] == "rift" and g["exe"] == "Space Game.exe"
|
||||
monkeypatch.setenv("FRAMEPORT_REVIVE_DIR", str(fake_revive(tmp_path / "revive")))
|
||||
monkeypatch.setattr("frameport.artwork.fetch.cache.http_get", lambda *a, **k: (_ for _ in ()).throw(OSError()))
|
||||
info = pipeline.build_game("rift.space_game", Reporter())
|
||||
assert info["ok"] and info["sha256"] and info["kind"] == "rift"
|
||||
|
||||
|
||||
def test_launch_env():
|
||||
from frameport.core.models import Recipe
|
||||
from frameport.patches.pcvr import launch_env
|
||||
|
||||
r = Recipe("rift.x", {"pcvr.proton_log": {}, "pcvr.proton_env": {"env": "DXVK_HUD=fps\n\nA = b"}})
|
||||
assert launch_env(r) == {"PROTON_LOG": "1", "DXVK_HUD": "fps", "A": "b"}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Revive / NSIS
|
||||
def fake_revive(d: Path) -> Path:
|
||||
from frameport.tools import revive
|
||||
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
for n in revive.RUNTIME_FILES:
|
||||
(d / n).write_bytes(b"x")
|
||||
return d
|
||||
|
||||
|
||||
def make_nsis(files: dict[str, bytes], outdir_var=21) -> bytes:
|
||||
"""A tiny Unicode NSIS 3 installer: SetOutPath $INSTDIR[\\sub] + File entries, non-solid deflate."""
|
||||
strings = bytearray(b"\0\0")
|
||||
|
||||
def add(s, var=None):
|
||||
off = len(strings) // 2
|
||||
if var is not None:
|
||||
strings.extend(struct.pack("<HH", 3, (var & 0x7F) | ((var & 0x3F80) << 1) | 0x8080))
|
||||
strings.extend(s.encode("utf-16-le") + b"\0\0")
|
||||
return off
|
||||
|
||||
data = bytearray()
|
||||
entries = []
|
||||
|
||||
def block(raw):
|
||||
comp = zlib.compressobj(9, zlib.DEFLATED, -15)
|
||||
c = comp.compress(raw) + comp.flush()
|
||||
off = len(data)
|
||||
data.extend(struct.pack("<I", len(c) | 0x80000000) + c)
|
||||
return off
|
||||
|
||||
dirs = {}
|
||||
for path, content in files.items():
|
||||
folder, _, name = path.rpartition("/")
|
||||
if folder not in dirs:
|
||||
dirs[folder] = add("\\" + folder.replace("/", "\\") if folder else "", outdir_var)
|
||||
entries.append((11, dirs[folder], 0, 0, 0, 0, 0))
|
||||
entries.append((20, 0, add(name), block(content), 0, 0, 0))
|
||||
entries.append((11, add("", 26), 0, 0, 0, 0, 0)) # $PLUGINSDIR: ignored
|
||||
entries.append((20, 0, add("plugin.dll"), block(b"p"), 0, 0, 0))
|
||||
ent = b"".join(struct.pack("<7I", *e) for e in entries)
|
||||
head_len = 4 + 8 * 8
|
||||
ent_off, str_off = head_len, head_len + len(ent)
|
||||
lang_off = str_off + len(strings)
|
||||
header = struct.pack("<I", 0) + struct.pack("<16I", 0, 0, 0, 0, ent_off, len(entries), str_off, 0, lang_off, 0,
|
||||
lang_off, 0, 0, 0, 0, 0) + ent + bytes(strings)
|
||||
comp = zlib.compressobj(9, zlib.DEFLATED, -15)
|
||||
ch = comp.compress(header) + comp.flush()
|
||||
body = struct.pack("<I", len(ch) | 0x80000000) + ch + data
|
||||
first = struct.pack("<I", 0) + b"\xef\xbe\xad\xdeNullsoftInst" + struct.pack("<II", len(header), len(body) + 28)
|
||||
return b"MZ" + b"\0" * 510 + first + body
|
||||
|
||||
|
||||
def test_nsis_extract(tmp_path):
|
||||
from frameport.tools import revive
|
||||
|
||||
files = {n: n.encode() for n in revive.RUNTIME_FILES}
|
||||
files["Input/oculus_touch_default.json"] = b"{}"
|
||||
files["Qt5Core.dll"] = b"qt"
|
||||
inst = tmp_path / "ReviveInstaller.exe"
|
||||
inst.write_bytes(make_nsis(files))
|
||||
written = revive.extract(inst, tmp_path / "out")
|
||||
assert set(written) == set(revive.RUNTIME_FILES) | {"Input/oculus_touch_default.json"}
|
||||
assert (tmp_path / "out" / "ReviveInjector.exe").read_bytes() == b"ReviveInjector.exe"
|
||||
assert not (tmp_path / "out" / "plugin.dll").exists()
|
||||
assert [p.name for p in revive.runtime_files(tmp_path / "out")][:1] == ["oculus_touch_default.json"]
|
||||
|
||||
|
||||
def test_nsis_missing_runtime_files(tmp_path):
|
||||
from frameport.tools import revive
|
||||
|
||||
inst = tmp_path / "i.exe"
|
||||
inst.write_bytes(make_nsis({"LICENSE": b"l"}))
|
||||
with pytest.raises(revive.NsisError):
|
||||
revive.extract(inst, tmp_path / "out")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not Path(__file__).with_name("fixtures").joinpath("ReviveInstaller.exe").exists(),
|
||||
reason="real installer not present")
|
||||
def test_real_revive_installer(tmp_path): # pragma: no cover - optional
|
||||
from frameport.tools import revive
|
||||
|
||||
written = revive.extract(Path(__file__).with_name("fixtures") / "ReviveInstaller.exe", tmp_path)
|
||||
assert "LibReviveXR64.dll" in written
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ triage
|
||||
def test_pcvr_triage_uses_pcvr_signatures_only():
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
log = ("FramePort: launching rift.x with proton_11-arm64\nLaunched injector with: x\nSuccesfully injected!\n"
|
||||
"ovrPlatformInitialize_NotEntitled\n")
|
||||
r = triage(log, "EXITED", "rift.x")
|
||||
assert r.milestone == "Revive injected into the game"
|
||||
assert [f.id for f in r.findings] == ["oculus-entitlement"]
|
||||
q = triage("Failed to create process\nFrameBridge: scale=1", "RUNNING", "com.x.y")
|
||||
assert "revive-inject-failed" not in [f.id for f in q.findings]
|
||||
|
||||
|
||||
def test_system_revive_preferred(tmp_path, monkeypatch):
|
||||
"""A Revive the user installed (official installer) wins over FramePort's copy and is never replaced."""
|
||||
from frameport.core import winhost
|
||||
from frameport.tools import revive, toolchain
|
||||
|
||||
managed = fake_revive(tmp_path / "managed")
|
||||
revive._state_file().write_text('{"version": "3.2.0", "path": "%s"}' % managed.as_posix())
|
||||
system = fake_revive(tmp_path / "Program Files" / "Revive")
|
||||
monkeypatch.delenv("FRAMEPORT_REVIVE_DIR", raising=False)
|
||||
monkeypatch.setattr(winhost, "available", lambda: True)
|
||||
monkeypatch.setattr(winhost, "is_windows", lambda: False)
|
||||
monkeypatch.setattr(winhost, "to_local", lambda p: system if "Program Files" in p else tmp_path / "nope")
|
||||
monkeypatch.setattr(winhost, "reg_query", lambda key, value: None)
|
||||
monkeypatch.setattr(revive, "_system_cache", None)
|
||||
monkeypatch.setattr(revive, "release_for", lambda p: "3.2.0")
|
||||
assert revive.source() == "system" and revive.revive_dir() == system
|
||||
assert revive.install() == system # no download when the user has Revive
|
||||
assert revive.installed_version() == "3.2.0"
|
||||
assert toolchain.revive_is_users()
|
||||
# not in the default folder: found through the registry (HKLM\Software\Revive default value)
|
||||
other = fake_revive(tmp_path / "D" / "Tools" / "Revive")
|
||||
monkeypatch.setattr(winhost, "to_local", lambda p: other if p.startswith("D:") else tmp_path / "nope")
|
||||
monkeypatch.setattr(winhost, "reg_query", lambda key, value: "D:\\Tools\\Revive" if key.startswith("HKLM") else None)
|
||||
monkeypatch.setattr(revive, "_system_cache", None)
|
||||
assert revive.revive_dir() == other
|
||||
# nothing installed on the system: FramePort's copy
|
||||
monkeypatch.setattr(winhost, "reg_query", lambda key, value: None)
|
||||
monkeypatch.setattr(revive, "_system_cache", None)
|
||||
assert revive.source() == "managed" and revive.revive_dir() == managed
|
||||
|
||||
|
||||
def test_release_for_matches_build_date(tmp_path, monkeypatch):
|
||||
import os
|
||||
|
||||
from frameport.tools import revive
|
||||
|
||||
d = fake_revive(tmp_path / "rv")
|
||||
os.utime(d / "LibReviveXR64.dll", (1679184000, 1679184000)) # 2023-03-19
|
||||
rels = [{"tag_name": "3.2.0", "published_at": "2023-03-20T01:56:30Z"},
|
||||
{"tag_name": "3.1.2", "published_at": "2022-10-01T00:00:00Z"},
|
||||
{"tag_name": "3.3.0", "published_at": "2024-01-01T00:00:00Z"}]
|
||||
monkeypatch.setattr(revive.cache, "cached_json", lambda *a, **k: rels)
|
||||
assert revive.release_for(d) == "3.2.0"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ nested collections
|
||||
def armgddn_tree(root: Path) -> Path:
|
||||
"""A collection like ARMGDDN_PCVR_OCULUS: one folder per game, the game nested inside, installers around it."""
|
||||
col = root / "PCVR"
|
||||
# Unity game one level down, with an uninstaller and a top-level Setup.exe next to archive parts
|
||||
a = col / "Space Game v1.6.0 -ARMGDDN"
|
||||
(a / "Space Game" / "Space Game_Data").mkdir(parents=True)
|
||||
(a / "Setup.exe").write_bytes(make_pe())
|
||||
(a / "Space Game v1.6.0 -ARMGDDN.7z.001").write_bytes(b"7z")
|
||||
(a / "Space Game" / "Space Game.exe").write_bytes(make_pe(extra=b"ovr_Initialize"))
|
||||
(a / "Space Game" / "UnityPlayer.dll").write_bytes(make_pe(imports=("d3d11.dll",)))
|
||||
(a / "Space Game" / "_UnInstall").mkdir()
|
||||
(a / "Space Game" / "_UnInstall" / "unins000.exe").write_bytes(make_pe())
|
||||
# Unreal: bootstrap exe + the shipping build that really runs (UTF-16 marker, as Unreal stores strings)
|
||||
b = col / "Wrath Game v008 [FFA Repacks]" / "Wrath" / "WindowsNoEditor"
|
||||
(b / "WrathGame" / "Binaries" / "Win64").mkdir(parents=True)
|
||||
(b / "WrathGame.exe").write_bytes(make_pe())
|
||||
(b / "WrathGame" / "Binaries" / "Win64" / "WrathGame-Win64-Shipping.exe").write_bytes(
|
||||
make_pe(imports=("d3d11.dll",), extra="LibOVRRT%hs_%d.dll".encode("utf-16-le")))
|
||||
(b.parent / "Engine" / "Binaries" / "Win64").mkdir(parents=True)
|
||||
(b.parent / "Engine" / "Binaries" / "Win64" / "CrashReportClient.exe").write_bytes(make_pe())
|
||||
# two builds of the same game: ambiguous
|
||||
c = col / "The Climber v1.5.0.16 -ARMGDDN" / "The Climber" / "bin"
|
||||
for build in ("win_x64", "win_x64-steam"):
|
||||
(c / build).mkdir(parents=True)
|
||||
(c / build / "Climber.exe").write_bytes(make_pe(extra=b"LibOVRRT64_1.dll"))
|
||||
# not games: a console tool and a Quest dump
|
||||
tool = col / "Some Tool"
|
||||
tool.mkdir()
|
||||
(tool / "tool.exe").write_bytes(make_pe(subsystem=3))
|
||||
q = col / "Quest Game -VRP"
|
||||
q.mkdir()
|
||||
(q / "game.apk").write_bytes(b"PK")
|
||||
return col
|
||||
|
||||
|
||||
def test_scan_collection_finds_nested_games(tmp_path):
|
||||
col = armgddn_tree(tmp_path)
|
||||
games = rift_dump.scan(col)
|
||||
assert sorted(p.name for p in games) == ["Space Game v1.6.0 -ARMGDDN", "The Climber v1.5.0.16 -ARMGDDN",
|
||||
"Wrath Game v008 [FFA Repacks]"]
|
||||
assert [p.name for p in rift_dump.scan(tmp_path)] == [p.name for p in games] # a level above works too
|
||||
|
||||
|
||||
def test_ranking_and_ambiguity(tmp_path):
|
||||
col = armgddn_tree(tmp_path)
|
||||
a = rift.analyze(col / "Space Game v1.6.0 -ARMGDDN")
|
||||
assert a.extra["exe"] == "Space Game/Space Game.exe" and a.extra["exe_confirmed"] and a.label == "Space Game"
|
||||
w = rift.analyze(col / "Wrath Game v008 [FFA Repacks]")
|
||||
assert w.extra["exe"].endswith("WrathGame-Win64-Shipping.exe") and w.xr == "LibOVR" and w.engine == "Unreal"
|
||||
assert "Unreal launcher (starts the real game build)" in w.extra["exe_candidates"][1]["reasons"]
|
||||
c = rift.analyze(col / "The Climber v1.5.0.16 -ARMGDDN")
|
||||
assert c.extra["exe_confirmed"] is False and c.extra["exe"] == "The Climber/bin/win_x64/Climber.exe"
|
||||
assert "Steam build" in c.extra["exe_candidates"][1]["reasons"]
|
||||
assert c.label == "The Climber" and c.package == "rift.the_climber"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,title", [
|
||||
("Asgards Wrath v1.6.0 -ARMGDDN", "Asgards Wrath"),
|
||||
("Arktika 1 (v1.0.0.7) -VRP", "Arktika 1"),
|
||||
("Vader Immortal - Episode II v2.0.2+236948 Shipping", "Vader Immortal - Episode II"),
|
||||
("Vader Immortal - Episode I v1.1.0+236956 Shipping -[VRP Repacks]", "Vader Immortal - Episode I"),
|
||||
("Vader Immortal - Episode III v3.0.2+236944 [FFA Repacks]", "Vader Immortal - Episode III"),
|
||||
("Lies Beneath 4.15.20 -ARMGDDN", "Lies Beneath"),
|
||||
("Defector v21.11.08.358012 -ARMGDDN", "Defector"),
|
||||
("Lone Echo 2", "Lone Echo 2"),
|
||||
("Rick and Morty - Virtual Rick-ality v2288226 -ARMGDDN", "Rick and Morty - Virtual Rick-ality"),
|
||||
])
|
||||
def test_clean_title(name, title):
|
||||
assert rift.clean_title(name) == title
|
||||
|
||||
|
||||
def test_set_exe_and_rescan_keep_choice(tmp_path):
|
||||
from frameport import pipeline
|
||||
|
||||
col = armgddn_tree(tmp_path)
|
||||
added = {g["title"]: g for g in pipeline.add_path(col)}
|
||||
assert set(added) == {"Space Game", "Wrath Game", "The Climber"} # the fake Quest dump isn't a real APK
|
||||
climber = added["The Climber"]
|
||||
assert climber["exe_confirmed"] is False
|
||||
pipeline.set_exe(climber["package"], "The Climber/bin/win_x64-steam/Climber.exe")
|
||||
g = library.game(climber["package"])
|
||||
assert g["exe_confirmed"] is True and g["exe"].endswith("win_x64-steam/Climber.exe")
|
||||
library.upsert_game(g["package"], tags=["Mine"])
|
||||
pipeline.add_path(col) # rescan: the choice and the tags stay
|
||||
g = library.game(climber["package"])
|
||||
assert g["exe"].endswith("win_x64-steam/Climber.exe") and g["tags"] == ["Mine"]
|
||||
|
||||
|
||||
def test_unchanged_folder_not_reanalyzed(tmp_path, monkeypatch):
|
||||
from frameport import pipeline
|
||||
|
||||
col = armgddn_tree(tmp_path)
|
||||
pipeline.add_path(col)
|
||||
calls = []
|
||||
real = rift.analyze
|
||||
monkeypatch.setattr(rift, "analyze", lambda *a, **k: calls.append(a) or real(*a, **k))
|
||||
pipeline.add_path(col)
|
||||
assert [c for c in calls if "Space Game" in str(c[0])] == [] # skipped via the fingerprint
|
||||
|
||||
|
||||
def test_bundled_revive_dll_still_uses_framework_revive(tmp_path):
|
||||
"""Repacks carry a LibRevive64.dll for their own launcher; it isn't loaded by itself, so Revive stays on."""
|
||||
g = unity_game(tmp_path, "Repacked Game")
|
||||
(g / "LibRevive64.dll").write_bytes(make_pe())
|
||||
a = rift.analyze(g)
|
||||
assert a.extra["revive_bundled"]
|
||||
r = engine.suggest(a)
|
||||
assert r.as_is and "pcvr.revive" in r.patches # don't reuse the repack's DLL; Revive provides the runtime
|
||||
|
||||
|
||||
def test_quest_as_is_skips_patching(quest_manifest, tmp_path, monkeypatch):
|
||||
import zipfile
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.core.events import Reporter
|
||||
|
||||
apk = tmp_path / "patched.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", quest_manifest)
|
||||
z.writestr("lib/arm64-v8a/libframe_settings.so", b"\x7fELF")
|
||||
monkeypatch.setattr("frameport.artwork.fetch.cache.http_get", lambda *a, **k: (_ for _ in ()).throw(OSError()))
|
||||
from frameport.analysis.detect import analyze
|
||||
|
||||
a = analyze(apk, deep=False)
|
||||
assert a.extra["frame_patched"] and engine.suggest(a).as_is
|
||||
library.upsert_game(a.package, apk=str(apk), analysis=a.to_dict(),
|
||||
recipe=library.recipe_to_dict(engine.suggest(a)))
|
||||
called = []
|
||||
monkeypatch.setattr(pipeline.builder, "build", lambda *x, **k: called.append(1))
|
||||
info = pipeline.build_game(a.package, Reporter())
|
||||
assert info["as_is"] and info["apk"] == str(apk) and not called # the original APK, overport never runs
|
||||
|
||||
|
||||
def test_same_game_scanned_from_wrapper_or_inner_folder(tmp_path):
|
||||
from frameport import pipeline
|
||||
|
||||
col = armgddn_tree(tmp_path)
|
||||
pipeline.add_path(col)
|
||||
pkg = "rift.space_game"
|
||||
library.upsert_game(pkg, title="Space Game: Remastered", art_source="oculusdb")
|
||||
pipeline.add_path(col / "Space Game v1.6.0 -ARMGDDN") # scanning the game's own folder finds the inner one
|
||||
games = [g for g in library.games() if g["package"].startswith("rift.space")]
|
||||
assert len(games) == 1 and games[0]["title"] == "Space Game: Remastered"
|
||||
|
||||
|
||||
def test_upload_files_big_small_and_cancel(tmp_path):
|
||||
from frameport.core.events import Cancelled, Reporter
|
||||
from frameport.install import installer
|
||||
|
||||
class FakeFrame:
|
||||
def __init__(self):
|
||||
self.puts, self.tars, self.dirs = [], [], []
|
||||
|
||||
def mkdirs(self, dirs):
|
||||
self.dirs += list(dirs)
|
||||
|
||||
def put(self, local, remote, progress=None, resume=True, mkdir=True):
|
||||
progress(Path(local).stat().st_size // 2, Path(local).stat().st_size)
|
||||
self.puts.append(remote)
|
||||
|
||||
def put_tar(self, files, root, progress=None):
|
||||
for local, rel in files:
|
||||
progress(0, rel)
|
||||
self.tars.append([rel for _, rel in files])
|
||||
big = tmp_path / "big.bin"
|
||||
big.write_bytes(b"x" * (installer.BIG_FILE + 1))
|
||||
smalls = []
|
||||
for i in range(3):
|
||||
p = tmp_path / f"s{i}.txt"
|
||||
p.write_bytes(b"y" * 10)
|
||||
smalls.append(p)
|
||||
items = [(big, "game/big.bin", big.stat().st_size)] + [(p, f"game/sub/{p.name}", 10) for p in smalls]
|
||||
f = FakeFrame()
|
||||
installer.upload_files(f, items, "/r/incoming", Reporter(), 10 ** 9)
|
||||
assert f.puts == ["/r/incoming/game/big.bin"] and f.tars == [[f"game/sub/s{i}.txt" for i in range(3)]]
|
||||
assert f.dirs == ["/r/incoming/game"]
|
||||
rep = Reporter()
|
||||
rep.cancelled.set()
|
||||
with pytest.raises(Cancelled):
|
||||
installer.upload_files(FakeFrame(), items, "/r/incoming", rep, 10 ** 9)
|
||||
|
||||
|
||||
def test_platform_sdk_delay_loaded(tmp_path):
|
||||
"""Oculus Store Unreal builds delay-load the Platform SDK (the exe or a modular build's OnlineSubsystemOculus
|
||||
plugin): it has to count as the Platform SDK (the game crashes with 0xc06d007e without the Oculus app)."""
|
||||
exe = tmp_path / "a.exe"
|
||||
exe.write_bytes(make_pe(imports=("kernel32.dll",), delay=("LibOVRPlatform64_1.dll", "OVRPlugin.dll")))
|
||||
info, _ = rift.read_pe(exe)
|
||||
assert info.imports == ["kernel32.dll"] and info.delay_imports == ["libovrplatform64_1.dll", "ovrplugin.dll"]
|
||||
g = tmp_path / "Game"
|
||||
(g / "Game/Binaries/Win64").mkdir(parents=True)
|
||||
(g / "Game/Binaries/Win64/Game-Win64-Shipping.exe").write_bytes(make_pe(imports=("kernel32.dll",)))
|
||||
plug = g / "Engine/Plugins/Online/OnlineSubsystemOculus/Binaries/Win64"
|
||||
plug.mkdir(parents=True)
|
||||
(plug / "Game-OnlineSubsystemOculus-Win64-Shipping.dll").write_bytes(make_pe(delay=("LibOVRPlatform64_1.dll",)))
|
||||
(g / "Engine/Plugins/Runtime/OculusVR/Binaries/Win64").mkdir(parents=True)
|
||||
(g / "Engine/Plugins/Runtime/OculusVR/Binaries/Win64/Game-OculusHMD-Win64-Shipping.dll").write_bytes(make_pe())
|
||||
assert rift.analyze(g).extra["platform_sdk"]
|
||||
|
||||
|
||||
def test_openvr_native_routing(tmp_path):
|
||||
"""A game that links OpenVR (openvr_api) and has no Oculus/LibOVR code is Frame-native (no Revive). A game that
|
||||
also has LibOVR is treated as Oculus (needs Revive) — static analysis can't tell which runtime a dual build picks."""
|
||||
g = tmp_path / "SteamVR Game"
|
||||
(g / f"{g.name}_Data").mkdir(parents=True)
|
||||
(g / "SteamVR Game.exe").write_bytes(make_pe(imports=("openvr_api64.dll", "kernel32.dll")))
|
||||
(g / "openvr_api64.dll").write_bytes(make_pe())
|
||||
a = rift.analyze(g)
|
||||
assert a.extra["openvr_native"] and a.extra["frame_native"] and not a.extra["needs_revive"]
|
||||
r = engine.suggest(a)
|
||||
assert "pcvr.revive" not in r.patches and "OpenVR" in a.xr
|
||||
# add LibOVR markers -> Oculus, needs Revive, not Frame-native
|
||||
(g / "OVRPlugin.dll").write_bytes(make_pe(extra=b"LibOVRRT%hs_%d.dll"))
|
||||
a2 = rift.analyze(g)
|
||||
assert not a2.extra["frame_native"] and a2.extra["needs_revive"]
|
||||
assert "pcvr.revive" in engine.suggest(a2).patches
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Help hints and the game action menus (library right-click, game page "…")."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.ui import components as C
|
||||
from frameport.ui.help import HELP
|
||||
|
||||
UI = Path(__file__).resolve().parents[1] / "src" / "frameport" / "ui"
|
||||
|
||||
|
||||
def test_every_help_key_used_in_the_ui_exists():
|
||||
used = set()
|
||||
for f in UI.rglob("*.py"):
|
||||
src = f.read_text(encoding="utf-8")
|
||||
used |= set(re.findall(r'help="(\w+)"', src))
|
||||
used |= set(re.findall(r'HELP\["(\w+)"\]', src))
|
||||
used |= set(re.findall(r'help_icon\("(\w+)"\)', src))
|
||||
used |= set(re.findall(r'with_help\([^\n]*?, "(\w+)"\)', src))
|
||||
from frameport.ui.views.game import CATEGORY_TITLES
|
||||
|
||||
used |= {f"cat_{c}" for c in CATEGORY_TITLES}
|
||||
assert used, "the pattern scan found nothing"
|
||||
assert used <= set(HELP), sorted(used - set(HELP))
|
||||
|
||||
|
||||
def test_help_icon_accepts_a_key_or_text():
|
||||
assert C.help_icon("lepton").tooltip.message == HELP["lepton"]
|
||||
assert C.help_icon("Some text").tooltip.message == "Some text"
|
||||
plain = C.meta("x")
|
||||
assert C.with_help(plain, None) is plain
|
||||
|
||||
|
||||
def test_menu_items_dividers():
|
||||
act = ("A", None, None)
|
||||
items = C.menu_items([None, act, None, None, act, None])
|
||||
assert [i.content is not None for i in items] == [True, False, True]
|
||||
|
||||
|
||||
def _app(monkeypatch, game, frame_info=None, busy=None, pc=()):
|
||||
from frameport.core import library
|
||||
from frameport.ui.app import FramePortApp
|
||||
|
||||
monkeypatch.setattr(library, "game", lambda p: game if p == game["package"] else None)
|
||||
app = object.__new__(FramePortApp)
|
||||
app.frame_state = "connected" if frame_info is not None else "none"
|
||||
app.frame_info = frame_info
|
||||
app.jobs = SimpleNamespace(busy_with=lambda p: busy)
|
||||
app.library_view = None
|
||||
app._pc_cache = (float("inf"), {p: {} for p in pc})
|
||||
return app
|
||||
|
||||
|
||||
def _labels(actions):
|
||||
return [a[0] if a else "—" for a in actions]
|
||||
|
||||
|
||||
def test_right_click_menu_follows_install_state(monkeypatch):
|
||||
g = {"package": "com.q", "title": "Q", "recipe": {"status": "works"}, "build": {"sha256": "new"}}
|
||||
installed = _labels(_app(monkeypatch, g, {"installed": [{"package": "com.q", "sha256": "old"}]}).game_actions("com.q"))
|
||||
assert installed[:3] == ["Open", "Play on Frame", "Update on Frame"]
|
||||
assert {"Launch test on Frame", "Adapter settings…", "Uninstall from Frame", "Remove from library"} <= set(installed)
|
||||
|
||||
missing = _labels(_app(monkeypatch, g, {"installed": []}).game_actions("com.q"))
|
||||
assert "Install on Frame" in missing and "Uninstall from Frame" not in missing and "Play on Frame" not in missing
|
||||
|
||||
offline = _labels(_app(monkeypatch, g).game_actions("com.q"))
|
||||
assert "Connect your Frame" in offline and "Launch test on Frame" not in offline
|
||||
|
||||
|
||||
def test_right_click_menu_while_busy_and_on_game_page(monkeypatch):
|
||||
g = {"package": "rift.r", "kind": "rift", "title": "R", "recipe": {"status": "unknown"}}
|
||||
busy = _labels(_app(monkeypatch, g, {"installed": []}, busy=object()).game_actions("rift.r"))
|
||||
assert "Cancel" in busy and "Install on Frame" not in busy and "Check game files" not in busy
|
||||
page = _labels(_app(monkeypatch, g, {"installed": []}).game_actions("rift.r", quick=False))
|
||||
assert page[0] == "Change executable…" and "Open" not in page and "Install on Frame" not in page
|
||||
|
||||
|
||||
def test_play_is_the_quick_action_when_installed(monkeypatch):
|
||||
g = {"package": "com.q", "title": "Q", "recipe": {"status": "works"}, "build": {"sha256": "x"}}
|
||||
app = _app(monkeypatch, g, {"installed": [{"package": "com.q", "sha256": "x"}]})
|
||||
assert app.quick_action(g)[0] == "Play"
|
||||
assert app.quick_action(g) != _app(monkeypatch, g, {"installed": []}).quick_action(g)
|
||||
r = {"package": "rift.r", "kind": "rift", "title": "R", "recipe": {}}
|
||||
assert [o[0] for o in _app(monkeypatch, r, {"installed": []}, pc=("rift.r",)).play_options(r)] == ["Play on this PC"]
|
||||
@@ -0,0 +1,174 @@
|
||||
from frameport.ui.app import install_state
|
||||
|
||||
|
||||
def game(sha=None, alt=None):
|
||||
return {"package": "com.x", "build": {"sha256": sha, "alt_sha256": alt} if sha else {}}
|
||||
|
||||
|
||||
def frame(*deps):
|
||||
return {"installed": list(deps)}
|
||||
|
||||
|
||||
def test_not_connected():
|
||||
assert install_state(game("a"), None) is None
|
||||
|
||||
|
||||
def test_missing():
|
||||
assert install_state(game("a"), frame({"package": "com.other", "sha256": "a"})) == "missing"
|
||||
|
||||
|
||||
def test_installed_same_build_or_alt():
|
||||
assert install_state(game("a", "b"), frame({"package": "com.x", "sha256": "a"})) == "installed"
|
||||
assert install_state(game("a", "b"), frame({"package": "com.x", "sha256": "b"})) == "installed"
|
||||
|
||||
|
||||
def test_installed_without_local_build():
|
||||
assert install_state(game(), frame({"package": "com.x", "sha256": "z"})) == "installed"
|
||||
|
||||
|
||||
def test_outdated():
|
||||
assert install_state(game("a"), frame({"package": "com.x", "sha256": "old"})) == "outdated"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ library filters / tags
|
||||
def lib_games():
|
||||
return [
|
||||
{"package": "com.a", "title": "Alpha", "added": 1, "recipe": {"status": "works", "patches": {}},
|
||||
"analysis": {"engine": "Unity", "xr": "OpenXR"}, "tags": ["Favorite"], "data_bytes": 5},
|
||||
{"package": "rift.b", "kind": "rift", "title": "Bravo", "added": 3, "recipe": {"status": "unknown"},
|
||||
"analysis": {"engine": "Unreal", "xr": "LibOVR", "extra": {"data_bytes": 50}},
|
||||
"last_test": {"time": 100}},
|
||||
{"package": "com.c", "title": "charlie", "added": 2, "recipe": {"status": "issues",
|
||||
"patches": {"patch_force_passthrough": {}}},
|
||||
"analysis": {"engine": "Unreal", "xr": "VrApi"}, "installs": {"frame": {"time": 200}}, "data_bytes": 20},
|
||||
]
|
||||
|
||||
|
||||
def test_filters_and_sort():
|
||||
from frameport.ui.views.library import DEFAULT_FILTERS, filter_games
|
||||
|
||||
games = lib_games()
|
||||
f = dict(DEFAULT_FILTERS)
|
||||
names = lambda fl, **kw: [g["title"] for g in filter_games(games, {**f, **fl}, **kw)] # noqa: E731
|
||||
assert names({}) == ["Alpha", "Bravo", "charlie"]
|
||||
assert names({"platform": "pcvr"}) == ["Bravo"] and names({"platform": "quest"}) == ["Alpha", "charlie"]
|
||||
assert names({"status": "issues"}) == ["charlie"]
|
||||
assert names({"q": "unreal"}) == ["Bravo", "charlie"] # search covers tags
|
||||
assert names({"tags": ["favorite"]}) == ["Alpha"] and names({"tags": ["Unreal", "Mixed reality"]}) == ["charlie"]
|
||||
assert names({"sort": "recent"}) == ["Bravo", "charlie", "Alpha"]
|
||||
assert names({"sort": "played"}) == ["charlie", "Bravo", "Alpha"]
|
||||
assert names({"sort": "size"}) == ["Bravo", "charlie", "Alpha"]
|
||||
assert names({"sort": "status"}) == ["Alpha", "charlie", "Bravo"]
|
||||
frame = {"installed": [{"package": "com.c"}]}
|
||||
assert names({"where": "frame"}, frame_info=frame) == ["charlie"]
|
||||
assert names({"where": "pc"}, pc_installs={"rift.b"}) == ["Bravo"]
|
||||
assert names({"where": "none"}, frame_info=frame, pc_installs={"rift.b"}) == ["Alpha"]
|
||||
|
||||
|
||||
def test_tags():
|
||||
from frameport.ui.views.library import all_tags, auto_tags, game_tags, normalize_tag
|
||||
|
||||
a, b, c = lib_games()
|
||||
assert auto_tags(b) == ["PC VR", "Unreal", "LibOVR"]
|
||||
assert "Mixed reality" in auto_tags(c)
|
||||
assert game_tags(a)[0] == "Favorite"
|
||||
assert all_tags([a, b, c])[0] == "Favorite" # the user's own tags first
|
||||
assert normalize_tag(" my, tag ") == "my tag"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ job queue
|
||||
def test_jobs_run_in_order_one_at_a_time():
|
||||
import threading
|
||||
import time
|
||||
|
||||
from frameport.ui.jobs import Job, JobManager
|
||||
|
||||
events, running = [], []
|
||||
lock = threading.Lock()
|
||||
|
||||
def work(name):
|
||||
def run(job):
|
||||
with lock:
|
||||
running.append(name)
|
||||
assert len(running) == 1, "two jobs ran at once"
|
||||
job.reporter.stage(f"{name} stage")
|
||||
job.reporter.check("thing", True, "ok")
|
||||
time.sleep(0.05)
|
||||
with lock:
|
||||
running.remove(name)
|
||||
events.append(name)
|
||||
return name
|
||||
return run
|
||||
m = JobManager(throttle=0)
|
||||
jobs = [m.submit(Job(n, work(n), package="p" if n == "a" else None)) for n in "abc"]
|
||||
assert m.wait_idle(5)
|
||||
assert events == ["a", "b", "c"] and [j.state for j in jobs] == ["done"] * 3
|
||||
assert jobs[0].stages == ["a stage"] and jobs[0].checks[0]["ok"] is True and jobs[0].result == "a"
|
||||
assert m.busy_with("p") is None
|
||||
|
||||
|
||||
def test_job_cancel_and_failure():
|
||||
import time
|
||||
|
||||
from frameport.ui.jobs import Job, JobManager
|
||||
|
||||
def slow(job):
|
||||
for _ in range(100):
|
||||
job.reporter.check_cancel()
|
||||
time.sleep(0.02)
|
||||
|
||||
def boom(job):
|
||||
raise RuntimeError("no Frame")
|
||||
m = JobManager(throttle=0)
|
||||
first = m.submit(Job("slow", slow, package="x"))
|
||||
queued = m.submit(Job("later", slow))
|
||||
failing = m.submit(Job("boom", boom))
|
||||
time.sleep(0.1)
|
||||
assert m.busy_with("x") is first and first.state == "running"
|
||||
m.cancel(queued) # still queued: never runs
|
||||
m.cancel(first)
|
||||
assert m.wait_idle(5)
|
||||
assert (first.state, queued.state, failing.state) == ("cancelled", "cancelled", "failed")
|
||||
assert failing.error == "no Frame"
|
||||
m.clear_finished()
|
||||
assert m.jobs == []
|
||||
|
||||
|
||||
def test_files_tree():
|
||||
from frameport.ui.views import files_dialog as fd
|
||||
|
||||
files = [["game/Bin/Game.exe", 4], ["game/Bin/Data.pak", 10], ["launch.sh", 1], ["game/Readme.txt", 2]]
|
||||
root = fd.build_tree("Install folder", files)
|
||||
assert (root.size, root.files) == (17, 4)
|
||||
game = root.children["game"]
|
||||
assert (game.size, game.files) == (16, 3)
|
||||
# folders first, then files; only expanded folders show their children
|
||||
assert [(d, n.name) for d, n in fd.visible_rows(root, set())] == [(0, "game"), (0, "launch.sh")]
|
||||
rows = fd.visible_rows(root, {"game", "game/Bin"})
|
||||
assert [(d, n.name) for d, n in rows] == [(0, "game"), (1, "Bin"), (2, "Data.pak"), (2, "Game.exe"),
|
||||
(1, "Readme.txt"), (0, "launch.sh")]
|
||||
assert fd.matches(files, ".PAK") == [["game/Bin/Data.pak", 10]]
|
||||
assert fd.human(3 * 2**30) == "3.0 GiB" and fd.human(512) == "512 B"
|
||||
|
||||
|
||||
def test_files_tree_caps_children():
|
||||
from frameport.ui.views import files_dialog as fd
|
||||
|
||||
root = fd.build_tree("x", [[f"f{i:04}", 1] for i in range(fd.MAX_CHILDREN + 5)])
|
||||
rows = fd.visible_rows(root, set())
|
||||
assert len(rows) == fd.MAX_CHILDREN + 1 and rows[-1] == (0, 5)
|
||||
|
||||
|
||||
def test_install_state_tracks_patch_settings():
|
||||
from frameport.ui import components as C
|
||||
|
||||
g = {"package": "rift.g", "kind": "rift", "recipe": {"patches": {"pcvr.revive": {}, "pcvr.xr_timefix": {}}}}
|
||||
fi = {"installed": [{"package": "rift.g", "recipe": {"patches": ["pcvr.revive", "pcvr.xr_timefix"]}}]}
|
||||
assert C.install_state(g, fi) == "installed" and C.settings_diff(g, fi) is None
|
||||
g["recipe"]["patches"].pop("pcvr.revive")
|
||||
g["recipe"]["patches"]["pcvr.no_crash_reporter"] = {}
|
||||
assert C.install_state(g, fi) == "outdated"
|
||||
assert C.settings_diff(g, fi) == (["pcvr.no_crash_reporter"], ["pcvr.revive"])
|
||||
old = {"installed": [{"package": "rift.g"}]} # installed before recipes were recorded: no false alarm
|
||||
assert C.install_state(g, old) == "installed"
|
||||
assert C.install_state(g, {"installed": []}) == "missing"
|
||||
@@ -0,0 +1,189 @@
|
||||
"""The timefix OpenXR layer (native/xrlayer), compiled for this host and driven through ctypes as a fake loader +
|
||||
runtime: negotiation, extension stripping, time conversion emulation and calibration at xrWaitFrame.
|
||||
Opt-in (FRAMEPORT_NATIVE_TESTS=1, -m native): needs the NDK in native/.cache (from native/build.py)."""
|
||||
import ctypes as C
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CLANG = next(iter((ROOT / "native/.cache").glob("ndk-*/toolchains/llvm/prebuilt/*/bin/clang")), None)
|
||||
INC = next(iter((ROOT / "native/.cache").glob("openxr-*")), None)
|
||||
pytestmark = [pytest.mark.native, pytest.mark.skipif(
|
||||
not os.environ.get("FRAMEPORT_NATIVE_TESTS") or not CLANG or not INC or platform.machine() != "x86_64",
|
||||
reason="set FRAMEPORT_NATIVE_TESTS=1 (needs the NDK from native/build.py on an x86_64 host)")]
|
||||
|
||||
XR_SUCCESS, XR_ERROR_FUNCTION_UNSUPPORTED, XR_ERROR_EXTENSION_NOT_PRESENT = 0, -7, -9
|
||||
LOADER_INFO, API_LAYER_REQUEST, CREATE_INFO, NEXT_INFO = 1, 2, 4, 5 # XrLoaderInterfaceStructs
|
||||
|
||||
|
||||
class Timespec(C.Structure):
|
||||
_fields_ = [("tv_sec", C.c_long), ("tv_nsec", C.c_long)]
|
||||
|
||||
|
||||
class InstanceCreateInfo(C.Structure):
|
||||
_fields_ = [("type", C.c_int), ("next", C.c_void_p), ("createFlags", C.c_uint64),
|
||||
("applicationInfo", C.c_char * 272), ("enabledApiLayerCount", C.c_uint32),
|
||||
("enabledApiLayerNames", C.c_void_p), ("enabledExtensionCount", C.c_uint32),
|
||||
("enabledExtensionNames", C.POINTER(C.c_char_p))]
|
||||
|
||||
|
||||
PFN = C.c_void_p
|
||||
GIPA = C.CFUNCTYPE(C.c_int, C.c_uint64, C.c_char_p, C.POINTER(C.c_void_p))
|
||||
CREATE = C.CFUNCTYPE(C.c_int, C.POINTER(InstanceCreateInfo), C.c_void_p, C.POINTER(C.c_uint64))
|
||||
|
||||
|
||||
class NextInfo(C.Structure):
|
||||
pass
|
||||
|
||||
|
||||
NextInfo._fields_ = [("structType", C.c_int), ("structVersion", C.c_uint32), ("structSize", C.c_size_t),
|
||||
("layerName", C.c_char * 256), ("nextGetInstanceProcAddr", GIPA),
|
||||
("nextCreateApiLayerInstance", CREATE), ("next", C.POINTER(NextInfo))]
|
||||
|
||||
|
||||
class LayerCreateInfo(C.Structure):
|
||||
_fields_ = [("structType", C.c_int), ("structVersion", C.c_uint32), ("structSize", C.c_size_t),
|
||||
("loaderInstance", C.c_void_p), ("settings_file_location", C.c_char * 512),
|
||||
("nextInfo", C.POINTER(NextInfo))]
|
||||
|
||||
|
||||
class LoaderInfo(C.Structure):
|
||||
_fields_ = [("structType", C.c_int), ("structVersion", C.c_uint32), ("structSize", C.c_size_t),
|
||||
("minInterfaceVersion", C.c_uint32), ("maxInterfaceVersion", C.c_uint32),
|
||||
("minApiVersion", C.c_uint64), ("maxApiVersion", C.c_uint64)]
|
||||
|
||||
|
||||
class LayerRequest(C.Structure):
|
||||
_fields_ = [("structType", C.c_int), ("structVersion", C.c_uint32), ("structSize", C.c_size_t),
|
||||
("layerInterfaceVersion", C.c_uint32), ("layerApiVersion", C.c_uint64),
|
||||
("getInstanceProcAddr", C.c_void_p), ("createApiLayerInstance", C.c_void_p)]
|
||||
|
||||
|
||||
class FrameState(C.Structure):
|
||||
_fields_ = [("type", C.c_int), ("next", C.c_void_p), ("predictedDisplayTime", C.c_int64),
|
||||
("predictedDisplayPeriod", C.c_int64), ("shouldRender", C.c_uint32)]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def layer(tmp_path_factory):
|
||||
out = tmp_path_factory.mktemp("xrlayer") / "timefix.so"
|
||||
src = ROOT / "native/xrlayer"
|
||||
subprocess.run([str(CLANG), "--target=x86_64-linux-gnu", "-ffreestanding", "-nostdlibinc", "-fno-stack-protector",
|
||||
"-fvisibility=hidden", "-fPIC", "-O2", "-Wall", "-Werror", "-I", str(src / "include"), "-I",
|
||||
str(INC), "-shared", "-nostdlib", "-fuse-ld=lld", str(src / "timefix_layer.c"), "-o", str(out)],
|
||||
check=True)
|
||||
return C.CDLL(str(out), mode=C.RTLD_GLOBAL)
|
||||
|
||||
|
||||
def negotiate(lib):
|
||||
li = LoaderInfo(LOADER_INFO, 1, C.sizeof(LoaderInfo), 1, 1, 0, 0)
|
||||
req = LayerRequest(API_LAYER_REQUEST, 1, C.sizeof(LayerRequest))
|
||||
assert lib.xrNegotiateLoaderApiLayerInterface(C.byref(li), b"XR_APILAYER_FRAMEPORT_timefix", C.byref(req)) == 0
|
||||
assert lib.xrNegotiateLoaderApiLayerInterface(C.byref(li), b"other", C.byref(req)) != 0
|
||||
layer_create = C.CFUNCTYPE(C.c_int, C.POINTER(InstanceCreateInfo), C.POINTER(LayerCreateInfo),
|
||||
C.POINTER(C.c_uint64))
|
||||
return GIPA(req.getInstanceProcAddr), layer_create(req.createApiLayerInstance)
|
||||
|
||||
|
||||
def test_layer_end_to_end(layer):
|
||||
gipa, create = negotiate(layer)
|
||||
seen = []
|
||||
|
||||
@CREATE
|
||||
def runtime_create(info, layer_info, instance):
|
||||
names = [info.contents.enabledExtensionNames[i] for i in range(info.contents.enabledExtensionCount)]
|
||||
seen.append(names)
|
||||
if b"XR_KHR_convert_timespec_time" in names:
|
||||
return XR_ERROR_EXTENSION_NOT_PRESENT # like the Frame runtime
|
||||
instance[0] = 42
|
||||
return XR_SUCCESS
|
||||
|
||||
CONV = C.CFUNCTYPE(C.c_int, C.c_uint64, C.POINTER(Timespec), C.POINTER(C.c_int64))
|
||||
WAIT = C.CFUNCTYPE(C.c_int, C.c_uint64, C.c_void_p, C.POINTER(FrameState))
|
||||
|
||||
@CONV
|
||||
def runtime_convert(inst, ts, t):
|
||||
return XR_ERROR_FUNCTION_UNSUPPORTED # like the Frame runtime
|
||||
|
||||
@WAIT
|
||||
def runtime_wait(session, info, state):
|
||||
state[0].predictedDisplayTime = 10**15 # the runtime's clock: far from CLOCK_MONOTONIC
|
||||
state[0].predictedDisplayPeriod = 13_888_888
|
||||
return XR_SUCCESS
|
||||
|
||||
@GIPA
|
||||
def runtime_gipa(inst, name, fn):
|
||||
table = {b"xrConvertTimespecTimeToTimeKHR": runtime_convert, b"xrWaitFrame": runtime_wait}
|
||||
if name in table:
|
||||
fn[0] = C.cast(table[name], C.c_void_p).value
|
||||
return XR_SUCCESS
|
||||
return XR_ERROR_FUNCTION_UNSUPPORTED
|
||||
|
||||
nxt = NextInfo(NEXT_INFO, 1, C.sizeof(NextInfo), b"runtime", runtime_gipa, runtime_create, None)
|
||||
li = LayerCreateInfo(CREATE_INFO, 1, C.sizeof(LayerCreateInfo), None, b"", C.pointer(nxt))
|
||||
exts = (C.c_char_p * 2)(b"XR_KHR_vulkan_enable", b"XR_KHR_convert_timespec_time")
|
||||
info = InstanceCreateInfo(3, None, 0, b"", 0, None, 2, exts)
|
||||
inst = C.c_uint64()
|
||||
assert create(C.byref(info), C.byref(li), C.byref(inst)) == XR_SUCCESS and inst.value == 42
|
||||
assert seen == [[b"XR_KHR_vulkan_enable", b"XR_KHR_convert_timespec_time"], [b"XR_KHR_vulkan_enable"]]
|
||||
|
||||
fn = C.c_void_p()
|
||||
assert gipa(42, b"xrConvertTimespecTimeToTimeKHR", C.byref(fn)) == XR_SUCCESS
|
||||
to_time = CONV(fn.value)
|
||||
assert gipa(42, b"xrConvertTimeToTimespecTimeKHR", C.byref(fn)) == XR_SUCCESS
|
||||
to_ts = C.CFUNCTYPE(C.c_int, C.c_uint64, C.c_int64, C.POINTER(Timespec))(fn.value)
|
||||
assert gipa(42, b"xrWaitFrame", C.byref(fn)) == XR_SUCCESS
|
||||
wait = WAIT(fn.value)
|
||||
|
||||
t = C.c_int64()
|
||||
assert to_time(42, C.byref(Timespec(5, 7)), C.byref(t)) == XR_SUCCESS and t.value == 5_000_000_007 # uncalibrated
|
||||
st = FrameState()
|
||||
assert wait(42, None, C.byref(st)) == XR_SUCCESS
|
||||
now = Timespec()
|
||||
C.CDLL(None).clock_gettime(1, C.byref(now))
|
||||
assert to_time(42, C.byref(now), C.byref(t)) == XR_SUCCESS
|
||||
expected = 10**15 - 13_888_888 # "now" in runtime time right after xrWaitFrame
|
||||
assert abs(t.value - expected) < 50_000_000, t.value - expected
|
||||
back = Timespec()
|
||||
assert to_ts(42, t.value, C.byref(back)) == XR_SUCCESS
|
||||
assert (back.tv_sec, back.tv_nsec) == (now.tv_sec, now.tv_nsec)
|
||||
# everything else passes through to the next layer/runtime
|
||||
assert gipa(42, b"xrEndFrame", C.byref(fn)) == XR_ERROR_FUNCTION_UNSUPPORTED
|
||||
|
||||
|
||||
def test_layer_api_version_fallback(layer):
|
||||
"""The Frame's SteamVR runtime rejects apiVersion 1.1 (XR_ERROR_API_VERSION_UNSUPPORTED): retried as 1.0."""
|
||||
import struct
|
||||
|
||||
_, create = negotiate(layer)
|
||||
versions = []
|
||||
|
||||
@CREATE
|
||||
def runtime_create(info, layer_info, instance):
|
||||
app_addr = C.addressof(info.contents) + InstanceCreateInfo.applicationInfo.offset
|
||||
v = C.c_uint64.from_address(app_addr + 264).value # XrApplicationInfo.apiVersion
|
||||
versions.append((v >> 48, (v >> 32) & 0xFFFF))
|
||||
if versions[-1] != (1, 0):
|
||||
return -4 # XR_ERROR_API_VERSION_UNSUPPORTED
|
||||
instance[0] = 7
|
||||
return XR_SUCCESS
|
||||
|
||||
@GIPA
|
||||
def runtime_gipa(inst, name, fn):
|
||||
return XR_ERROR_FUNCTION_UNSUPPORTED
|
||||
|
||||
nxt = NextInfo(NEXT_INFO, 1, C.sizeof(NextInfo), b"runtime", runtime_gipa, runtime_create, None)
|
||||
li = LayerCreateInfo(CREATE_INFO, 1, C.sizeof(LayerCreateInfo), None, b"", C.pointer(nxt))
|
||||
for requested, expect in (((1, 1), [(1, 1), (1, 0)]), ((1, 0), [(1, 0)])):
|
||||
versions.clear()
|
||||
app = bytearray(272)
|
||||
struct.pack_into("<Q", app, 264, (requested[0] << 48) | (requested[1] << 32))
|
||||
info = InstanceCreateInfo(3, None, 0, b"", 0, None, 0, None)
|
||||
C.memmove(C.addressof(info) + InstanceCreateInfo.applicationInfo.offset, bytes(app), len(app))
|
||||
inst = C.c_uint64()
|
||||
assert create(C.byref(info), C.byref(li), C.byref(inst)) == XR_SUCCESS and inst.value == 7
|
||||
assert versions == expect
|
||||
Reference in new issue
Block a user