Lines within 120 columns everywhere (CI lints without exceptions); community and licence files; docs updated

- reflowed long lines (texts unchanged; generated launcher/PowerShell/README texts byte-identical)
- CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, THIRD_PARTY_NOTICES; pyproject metadata (SPDX licence, authors, urls,
  classifiers, keywords)
- maintainer-only notes moved to a git-ignored CLAUDE.local.md; CLAUDE.md facts updated
- README: what kinds of apps run and how; INSTALL: PC VR and Files wording, new labels
This commit is contained in:
spoopyghosty0 committed 2026-10-02 14:18:41 -04:00
1 parent da399ef77a
commit 23a47880e0
72 files changed
+836 -424

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- uses: astral-sh/setup-uv@v10.2.0
- run: uv sync --extra dev
- name: Lint
run: uv run ruff check . --extend-ignore E501 # TODO(polish): drop the ignore once long lines are fixed
run: uv run ruff check .
- run: uv run pytest -m "not games and not device"
bundle:
+6
View File
@@ -13,3 +13,9 @@ native/.cache/
native/**/build/
# per-machine Claude Code settings (contain home paths)
.claude/settings.local.json
# maintainer-local notes for Claude Code (accounts, credentials)
CLAUDE.local.md
# outputs of `frameport report` / `parity` / ruff
REPORT.md
.ruff_cache/
+15 -17
View File
@@ -43,8 +43,9 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
- **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 /
**Revive is only suggested for games with Oculus (LibOVR) code** (`pcvr.revive`, and `pcvr.oculus_unreal`; repacks
with a bundled Revive and SteamVR/OpenXR games run directly): needed by 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
@@ -145,7 +146,8 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
- `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 + Deadpool VR (2026-10-01, owner-confirmed); `catalog/triage.yaml` — log signatures → fixes.
- `catalog/games/<package>.yaml` — 38 recipes (34 verified 2026-09-28; Deadpool VR, 4XVR, NEX Player and AC Nexus's
90 Hz default confirmed later by the owner); `catalog/triage.yaml` — log signatures → fixes.
- `native/` — sources of the prebuilt binaries in `artifacts/` (adapter, VrApi bridge patches, GL shim, stubs).
`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.
@@ -160,7 +162,7 @@ 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 tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>); on-device checks are CLI commands (below);
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
@@ -350,7 +352,7 @@ swapchain was halved (1536/eye) in case memory is the limit (unverified). Frame
(SteamVR runtimes, Lepton scripts, logs; never commit Valve binaries): `~/frameport-research/frame-data-2026-09-30/`.
Not yet verified in the headset.
**Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `<base>/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Send files: `install/files.py`, `frameport frame send|storage`, GUI Frame → Send files.
**Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `<base>/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Files: `install/files.py`, `frameport frame send|storage`, GUI Files tab (formerly Frame → Send files).
**SteamVR per-app settings (2026-09-30):** editing steamvr.vrsettings while SteamVR runs is lost; the web API (127.0.0.1:27062 /app/setsettings) needs `x-steamvr-secret`. `native/vrsettings` = `fp_vrsettings.exe` (freestanding, OpenVR `FnTable:IVRSettings_003` as a Utility app, loads SteamVR's bin/win64/openvr_api.dll) sets them live and SteamVR persists them: section `steam.app.<shortcut appid>`, keys `preferredRefreshRate` (float) and `motionSmoothingOverride` (0 global, 1 on, 2 off, 3 always). Steam Link (vrlink) lists the Frame's rates 72/80/90/96/108/120/144 in vrserver.txt and follows the per-app preference ("host preferred N Hz"; whether the key is honoured is unverified in-headset yet). Judder metric: vrcompositor.txt session summary dropped + "Timed out. N total" (Stormland: 0 dropped but 313 timeouts in 2 min); fpsVR (`%LOCALAPPDATA%\fpsVR\*.json`, 0.1 ms histograms) gives p99 CPU/GPU ms. `pcvr.steamvr_tuning` (default on, PC only) applies on Play: highest rate whose budget ≥ p99×1.05, at least one step down, smoothing on.
**Unresolved (as of 2026-09-28):** Arcsmith (right-eye distortion) and Time Stall (both eyes) — swap, tracking, Valve
@@ -358,6 +360,7 @@ layers, depth, pacing ruled out. Sniper Elite VR (DEVICE LOST), Espire 1 (Mesa G
crash): use PC versions.
## Releases, CI, GitHub
Maintainer-only notes (accounts, credentials, key locations) live in the git-ignored `CLAUDE.local.md`.
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`, the CLI wheel `frameport-<ver>-py3-none-any.whl`, `SHA256SUMS.txt`,
@@ -370,8 +373,8 @@ Installed apps find the release themselves (self-update), so the notes are what
- 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
`WINDOWS_CODESIGN_PFX` (base64) + `WINDOWS_CODESIGN_PASSWORD`. The private key is never committed (its
location is in `CLAUDE.local.md`). 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
@@ -391,14 +394,6 @@ Installed apps find the release themselves (self-update), so the notes are what
open). Windows installs of 0.3.1/0.3.2 can't update themselves: they need one manual download.
- The Linux bundle is built on ubuntu-22.04: a 24.04-built Flutter bundle needs GLib 2.80 (`undefined symbol:
g_once_init_enter_pointer` on 22.04).
- **This project's GitHub identity is `spoopyghosty0`** (a dedicated account; the machine's default gh/git login is a
different, personal account that must never touch this repo). `gh` (`~/.local/bin/gh`) uses it through
`GH_CONFIG_DIR=~/.config/gh-spoopyghosty0` (set for Claude Code in the git-ignored `.claude/settings.local.json`);
git pushes from this folder authenticate as it through a repo-local credential helper, and commits use the repo-local
identity `spoopyghosty0 <336754034+spoopyghosty0@users.noreply.github.com>`. Check with `gh api user --jq .login`
before any GitHub action.
- **No Claude trailers** (`Co-Authored-By: Claude …`, `Claude-Session: …`) in commits or PR descriptions — the owner's
rule; history was rewritten to remove them.
- **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.
@@ -421,11 +416,14 @@ GLAD/GLES); Unreal → alternate no-ForceQuit build. Score changes with
- 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)
## Project status (2026-10-02)
Self-update, the Files tab, OVRPort 1.2.5, non-Quest Android apps (vr_kind) and GUI localisation (tr(), 0.3.x) are
in; see the sections above. Earlier state (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 +
Parity: all 34 rebuilt from the dumps match the known-good builds (`docs/parity-report.md`, generated locally and
git-ignored; 34/34 again with OVRPort 1.2.5 on 2026-10-02) and were reinstalled +
launch-tested with 0 regressions (`docs/parity-device-report.md`). `PATCHED/` holds exactly the installed builds.
Owner preferences: manual installs (no third-party installer apps), Python + Flet, dynamic data over hardcoding,
free tooling only, public repo scrubbed of personal data, keep the known-good backups.
+8
View File
@@ -0,0 +1,8 @@
# Code of conduct
This project follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
In short: be respectful and constructive, assume good faith, and keep discussions about the project. Harassment and
personal attacks aren't tolerated.
Report unacceptable behaviour privately through a GitHub security advisory on this repository (it reaches the
maintainer only). Reports are handled confidentially.
+31
View File
@@ -0,0 +1,31 @@
# Contributing
Thanks for helping. The quickest contributions:
- **A game works on the Frame?** In FramePort: the game's menu → **Share working config…** opens a prefilled issue
with its recipe. Accepted configs become built-in recipes.
- **Something doesn't work?** The game's menu → **Report a problem…** saves a diagnostics zip (personal data
removed) and opens a prefilled issue to attach it to.
## Development
```
uv sync --extra dev # Python 3.11+, uv (https://docs.astral.sh/uv/)
uv run frameport-gui # the app
uv run frameport --help # the command line
uv run pytest # tests (no headset or game files needed)
uv run ruff check . # lint
```
- Code and architecture: `CLAUDE.md`, `docs/ARCHITECTURE.md`; per-game symptoms and fixes: `docs/PLAYBOOK.md`.
- Interface wording: `docs/STYLE.md`. GUI texts go through `tr()`; after changing them run
`python scripts/i18n_extract.py`.
- Translations: copy `src/frameport/locales/template.json` to `<language>.json`, fill in the values (a list
`[one, other]` for plural entries) and add `"_language": "<the language's own name>"`.
- New fixes are patch modules (`src/frameport/patches/`), with a triage signature (`catalog/triage.yaml`), a PLAYBOOK
row and a unit test.
- Native binaries in `artifacts/` are rebuilt with `python native/build.py` (downloads the NDK).
## Ground rules
- Use only games you own. Contributions that help bypass DRM, licence or entitlement checks, or that add game files,
are not accepted.
- Never commit personal data (IP addresses, user names, home paths, Steam IDs).
- By contributing you agree to license your work under GPL-3.0-only.
+11 -6
View File
@@ -35,7 +35,8 @@ marked as not runnable on the Frame.
3. **Connect the Frame**: turn on Developer Mode on the Frame (Settings → System → Developer). In FramePort open
**Steam Frame**; if the Frame isn't listed, switch it to Desktop mode, open Konsole and paste the one command
FramePort shows. This is needed once.
4. **Add games**: **Add games → Scan a folder** with your Quest game backups (APK + OBB) or Rift game folders.
4. **Add games**: **Add games → Scan a folder** with your game backups: Android/Quest games (APK + OBB) or PC VR
game folders.
FramePort identifies each game and fetches its artwork and store details.
5. **Install**: open a game and click **Install on Frame**. FramePort patches, checks, uploads and adds the game to the
Frame's Steam library, then runs a short launch test.
@@ -53,11 +54,15 @@ switched on or off under **Customize**.
![Patches](docs/images/patches.png)
- Quest games run in Valve's Android runtime (Lepton). 32-bit-only Quest games can't run (the Frame has no 32-bit ARM
support).
- Rift games run through Proton on the Frame (experimental) or on a Windows PC with SteamVR, streamed to the Frame.
Games that use the Oculus Platform SDK need the Oculus app and a licence, so they run on the PC only.
- Automated launch tests confirm a game starts; visuals can only be checked in the headset.
| Kind of app | On the Steam Frame |
|---|---|
| Meta Quest games (APK) | Translated to OpenXR (OVRPort) and patched for the Frame; run in Valve's Android runtime (Lepton) |
| Other Android VR apps using OpenXR (e.g. Pico builds) | Translated the same way; the other headset's own extensions and store services aren't available |
| Ordinary Android apps and games (no VR) | Installed unchanged and run in Lepton |
| PC VR games (Windows; OpenXR, SteamVR or Oculus) | Run through Proton on the Frame (experimental), or on a Windows PC with SteamVR and streamed to the Frame; Oculus-only games use Revive |
| Can't run | 32-bit-only or x86-only APKs, Pico/HTC Wave SDK apps, Android XR apps, and games that check an Oculus licence (they need the Oculus app on a PC) |
Automated launch tests confirm that a game starts; visuals can only be checked in the headset.
![Steam Frame](docs/images/frame.png)
+8
View File
@@ -0,0 +1,8 @@
# Security
FramePort talks to your Steam Frame over SSH with its own key and runs a small agent there; it also downloads tools
and its own updates (checked against the release's SHA-256 sums and, on Windows, its signature).
To report a vulnerability, please open a GitHub security advisory for this repository (Security → Report a
vulnerability) instead of a public issue. Include the FramePort version (`frameport --version`) and how to reproduce
it. Fixes are released as normal updates; FramePort offers them automatically.
+27
View File
@@ -0,0 +1,27 @@
# Third-party notices
FramePort is GPL-3.0-only (see `LICENSE`). It includes or downloads the following third-party work.
## Included in this repository / the release bundles
| Component | Where | License |
|---|---|---|
| OVRPort VrApi → OpenXR adapter (`native/vrapi`, github.com/Android-XR-Bridge/OVRPort), with FramePort's changes | `native/vrapi-bridge/`, `artifacts/arm64-v8a/libvrapi.so` | GPL-3.0 (`native/vrapi-bridge/LICENSE.upstream`) |
| OVRPort platform compatibility library (`native/platform`) | `native/platformcompat/`, `artifacts/arm64-v8a/libovrplatformcompat.so` | GPL-3.0; Meta Platform SDK headers under the Meta Platform Technologies SDK License (`native/platformcompat/licenses/OCULUS-PLATFORM-SDK.txt`) |
| Khronos OpenXR SDK headers | used to build the native layers | Apache-2.0 / MIT (`native/vrapi-bridge/licenses/OPENXR-SDK.txt`) |
| Android NDK runtime (statically linked libc++) | native Android libraries | Apache-2.0 with LLVM exception, plus legacy notices (`native/vrapi-bridge/licenses/ANDROID-NDK.txt`) |
| Flet and Flutter (desktop app runtime) | release bundles | Apache-2.0 / BSD-3-Clause |
| flet-dropzone / desktop_drop (drag-and-drop) | release bundles | Apache-2.0 / MIT |
| Python packages (paramiko, zeroconf, psutil, pyelftools, capstone, UnityPy, PyYAML, requests, typer, pyaxmlparser, Pillow, cryptography, …) | release bundles | their own licenses (see each package's metadata) |
FramePort's own native code (the FrameBridge adapter, GL/Vulkan/OpenXR shims, the Windows helpers) is GPL-3.0-only.
## Downloaded at run time (not redistributed)
| Tool | Source | License |
|---|---|---|
| OVRPort / overport CLI | github.com/Android-XR-Bridge/OVRPort releases (fallback github.com/ovrport/app) | GPL-3.0 |
| Eclipse Temurin JRE | api.adoptium.net | GPL-2.0 with Classpath Exception |
| apksigner (Android build-tools) | dl.google.com Android repository | Apache-2.0 |
| Revive (portable copy, only if Revive isn't installed) | github.com/LibreVR/Revive releases | GPL-3.0 |
Valve's Lepton, Proton and SteamVR, and Meta's software, are used on your devices as installed by Steam / Meta;
FramePort doesn't distribute them.
+36 -20
View File
@@ -94,7 +94,8 @@ def find_app(name_regex):
def lepton_path():
app = find_app(r"Lepton")
candidates = ([os.path.join(app["dir"], "lepton")] if app else []) + [os.path.join(STEAM, "steamapps/common/Lepton/lepton")]
candidates = (([os.path.join(app["dir"], "lepton")] if app else [])
+ [os.path.join(STEAM, "steamapps/common/Lepton/lepton")])
for c in candidates:
if os.access(c, os.X_OK):
return c, app
@@ -577,9 +578,11 @@ def ensure_host_fixes():
if not re.search(r"^\s*keyring\s*=", text, re.M):
note = "# FramePort: stop rootless podman leaking a kernel keyring per container start (Lepton launches)\n"
if re.search(r"^\[containers\]\s*$", text, re.M):
text = re.sub(r"^\[containers\]\s*$", "[containers]\n" + note + "keyring = false", text, count=1, flags=re.M)
text = re.sub(r"^\[containers\]\s*$", "[containers]\n" + note + "keyring = false", text, count=1,
flags=re.M)
else:
text = text + ("\n" if text and not text.endswith("\n") else "") + "[containers]\n" + note + "keyring = false\n"
sep = "\n" if text and not text.endswith("\n") else ""
text = text + sep + "[containers]\n" + note + "keyring = false\n"
os.makedirs(os.path.dirname(CONTAINERS_CONF), exist_ok=True)
with open(CONTAINERS_CONF, "w") as f:
f.write(text)
@@ -633,7 +636,7 @@ def cmd_install_lepton(args):
"hint": "Enable Developer Mode, then confirm the install in Steam (or launch 'Lepton Development' once)."}
# ------------------------------------------------------------------------------------------ Steam shortcuts (binary VDF)
# ------------------------------------------------------------------------------------- Steam shortcuts (binary VDF)
TYPE_MAP, TYPE_STRING, TYPE_INT, TYPE_END = 0, 1, 2, 8
@@ -792,7 +795,8 @@ def cmd_shortcuts(args):
rewritten, so the work runs in a detached systemd unit (terminals/SSH sessions started from Steam live in
steam.service's cgroup and would be killed with it). Poll shortcut_status for the result."""
packages = [check_pkg(p) for p in args.get("packages", [])]
remove = [r for r in args.get("remove", []) if isinstance(r, dict) and str(r.get("exe", "")).startswith('"' + ANCHORS)]
remove = [r for r in args.get("remove", [])
if isinstance(r, dict) and str(r.get("exe", "")).startswith('"' + ANCHORS)]
if not packages and not remove:
return {"started": False}
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
@@ -1014,7 +1018,8 @@ def cmd_link_media(args):
except OSError:
shutil.copy2(real, target) # different file system (not the case with Lepton's layout)
linked.append(os.path.basename(real))
return {"folder": folder, "android": f"/sdcard/{folder}", "linked": linked, "existing": existing, "missing": missing}
return {"folder": folder, "android": f"/sdcard/{folder}", "linked": linked, "existing": existing,
"missing": missing}
def cmd_list_files(args):
@@ -1084,24 +1089,28 @@ def cmd_prepare(args):
want_sha = args.get("apk_sha256")
same_apk = bool(want_sha and os.path.exists(apk) and os.path.getsize(apk) == args.get("apk_size")
and sha256_file(apk) == want_sha)
st = os.statvfs(base if os.path.exists(base) else os.path.dirname(base) if os.path.exists(os.path.dirname(base)) else HOME)
st = os.statvfs(base if os.path.exists(base)
else os.path.dirname(base) if os.path.exists(os.path.dirname(base)) else HOME)
lepton, _ = lepton_path()
return {"package": pkg, "base": base, "anchor": anchor, "appid": appid, "incoming": incoming,
"installed": bool(dep), "same_apk": same_apk, "existing_obb": existing,
"free_bytes": st.f_bavail * st.f_frsize, "lepton": lepton}
LAUNCH_SH = r"""#!/usr/bin/env bash
LAUNCH_SH = (r"""#!/usr/bin/env bash
# Steam Frame launcher for {title} ({pkg}). Generated by FramePort.
set -euo pipefail
app_dir={base_q}
[[ -d "$app_dir/lepton-app" ]] || {{ echo "Game files missing at $app_dir (storage not mounted?)" >&2; exit 1; }}
# Some games (Unreal cloud saves) create folders without write/search permission inside Lepton,
# which silently breaks saving. Repair them before and during every launch.
fix_perms() {{ find "$app_dir/lepton-data/external" -type d ! -perm -u+rwx -exec chmod u+rwx {{}} + 2>/dev/null || true; }}
fix_perms() {{ find "$app_dir/lepton-data/external" -type d ! -perm -u+rwx -exec chmod u+rwx {{}} + 2>/dev/null ||"""
r""" true; }}
# Android can't create an app's external files/cache folders inside Lepton ("Invalid mkdirs path ... not a known app
# path"): getExternalCacheDir() then returns nothing, which breaks e.g. Whirligig's video player cache. Create them here.
mkdir -p "$app_dir/lepton-data/external/Android/data/{pkg}/files" "$app_dir/lepton-data/external/Android/data/{pkg}/cache" 2>/dev/null || true
# path"): getExternalCacheDir() then returns nothing, which breaks e.g. Whirligig's video player cache. Create them"""
r""" here.
mkdir -p "$app_dir/lepton-data/external/Android/data/{pkg}/files" """
r""""$app_dir/lepton-data/external/Android/data/{pkg}/cache" 2>/dev/null || true
fix_perms
( while sleep 2 && kill -0 $$ 2>/dev/null; do fix_perms; done ) & permfix=$!
export SteamAppId={appid}
@@ -1127,11 +1136,12 @@ trap 'exit 143' TERM
setsid {lepton_q} start >"$app_dir/launch.log" 2>&1 &
child=$!
wait "$child"
"""
""")
def write_launcher(anchor, base, pkg, title, appid, lepton, env):
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items() if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
text = LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid,
lepton_q=shlex.quote(lepton), extra_env=extra)
path = os.path.join(anchor, "launch.sh")
@@ -1227,8 +1237,9 @@ def cmd_finalize(args):
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models}
# ------------------------------------------------------------------ Steam Frame controller models (XR_FB_render_model)
# The FrameBridge adapter (setting controller_models=1) serves these to games that ask the runtime for controller models.
# --------------------------------------------------------------- Steam Frame controller models (XR_FB_render_model)
# The FrameBridge adapter (setting controller_models=1) serves these to games that ask the runtime for controller
# models.
# They are converted here, on the Frame, from the SteamVR render models the Frame already has (OBJ + PNG) into glTF
# binaries, so Valve's models never leave the device.
MODELS_CACHE = os.path.join(HOME, ".local/share/frameport/controller-models")
@@ -1614,7 +1625,8 @@ def windows_path(path):
XR_LAYER = "XR_APILAYER_FRAMEPORT_timefix"
XR_LAYER_ENV = (f'# FramePort OpenXR layer: OpenXR 1.1 -> 1.0 fallback for the Frame runtime (+ timespec time emulation)\n'
XR_LAYER_ENV = (f'# FramePort OpenXR layer: OpenXR 1.1 -> 1.0 fallback for the Frame runtime'
f' (+ timespec time emulation)\n'
f'export XR_API_LAYER_PATH="$base/xrlayer${{XR_API_LAYER_PATH:+:$XR_API_LAYER_PATH}}"\n'
f'export XR_ENABLE_API_LAYERS="{XR_LAYER}${{XR_ENABLE_API_LAYERS:+:$XR_ENABLE_API_LAYERS}}"\n')
@@ -1786,7 +1798,8 @@ def cmd_finalize_pcvr(args):
dep = {"package": pkg, "kind": "pcvr", "appid": int(appid), "base": base, "title": title, "exe": exe_rel,
"tags": args.get("tags") or [],
"sha256": args.get("exe_sha256"), "revive": revive, "revive_version": args.get("revive_version"),
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd, "libovr_redirect": bool(args.get("libovr_redirect")),
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd,
"libovr_redirect": bool(args.get("libovr_redirect")),
"recipe": args.get("recipe"),
"installed_by": "frameport",
"files": {t: manifests.get(t) or {} for t in PCVR_TREES},
@@ -1947,7 +1960,8 @@ def cmd_launch_test(args):
elapsed = round(time.time() - start)
if state == "EXITED": # Lepton dumps the container's logcat buffers (crash backtraces) after "Exited!"
until = time.time() + 15
while time.time() < until and "Dumping logcat" not in (open(log, errors="replace").read() if os.path.exists(log) else ""):
while time.time() < until and "Dumping logcat" not in (open(log, errors="replace").read()
if os.path.exists(log) else ""):
time.sleep(1)
time.sleep(2)
run(["systemctl", "--user", "stop", unit])
@@ -1955,7 +1969,8 @@ def cmd_launch_test(args):
time.sleep(3)
crash = os.path.join(STEAM, "logs", "lepton-logcats", f"steamlaunch-{appid}", "logcat-crash.log")
fresh = os.path.exists(crash) and os.path.getmtime(crash) >= start - 1 and os.path.getsize(crash) > 0
return {"state": state, "elapsed": elapsed, "log": log, "log_size": os.path.getsize(log) if os.path.exists(log) else 0,
return {"state": state, "elapsed": elapsed, "log": log,
"log_size": os.path.getsize(log) if os.path.exists(log) else 0,
"crash_log": crash if fresh else None,
"kernel_keys_before": keys, "kernel_keys_after": key_usage()}
@@ -2198,7 +2213,8 @@ def cmd_cleanup(args):
# only ordinary folders in the home folder: never dot folders (.ssh, .steam, .local, .config…)
raise AgentError(f"refusing to remove {extra}")
if os.path.exists(p):
freed += sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs) if os.path.isdir(p) else os.path.getsize(p)
freed += (sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs)
if os.path.isdir(p) else os.path.getsize(p))
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else os.remove(p)
removed.append(p)
return {"removed": removed, "freed_bytes": freed}
+9 -8
View File
@@ -47,11 +47,12 @@ update log is `logs/update.log` in the data folder.
Command line: `frameport update` (`--check` only checks, exit code 10 = update available; `--yes` doesn't ask).
`FRAMEPORT_NO_UPDATE_CHECK=1` turns all checks off.
## Oculus Rift (PC VR) games
## PC VR games
Scan a folder of Rift games (one folder per game) or use **Add games → Add one game folder…**. FramePort finds the
game's program and asks when there is more than one candidate. **Already patched** on a game page installs a copy
unchanged. FramePort uses an installed Revive, or downloads a portable copy.
PC VR games are Windows VR games (OpenXR, SteamVR or Oculus). Scan a folder of them (one folder per game) or use
**Add games → Add one game folder…**. FramePort finds the game's program and asks when there is more than one
candidate. **Already patched** on a game page installs a copy unchanged. Oculus-only games need Revive: FramePort uses
an installed Revive, or downloads a portable copy.
- **Play from this PC:** Windows with Steam and SteamVR. **Install on this PC** adds the game to Steam; stream it to the
Frame with Steam Link. If a game can't keep up with the refresh rate, FramePort lowers the rate and enables motion
@@ -71,9 +72,9 @@ skip files that are already there.
- **Videos**, **Downloads** and **Documents** appear inside every Quest game as `/sdcard/Movies`, `/sdcard/Download`
and `/sdcard/Documents`. Apps find files by browsing folders; Android's media index doesn't work on the Frame.
- **Game storage** is one game's own `/sdcard` (mods, saves). A game's menu → **Add videos & files…** opens it.
Video players that list only their own folder (e.g. 4XVR's "Internal Storage" = `4XPlayer`) find videos uploaded
into that folder.
- Under **Game storage**, each installed game has its own `/sdcard` (mods, saves). A game's menu → **Add videos and
files…** opens it. Video players that list only their own folder (e.g. 4XVR's "Internal Storage" = `4XPlayer`) find
videos uploaded into that folder.
- **Home folder** shows everything in the Frame's home folder (hidden files with **Show hidden files**).
Command line: `frameport frame send <files> --to videos`.
@@ -82,7 +83,7 @@ Command line: `frameport frame send <files> --to videos`.
- **Share working config…** (game menu): opens a prefilled GitHub issue with the game's patches and settings. Accepted
configs become built-in recipes.
- **Report a problem…** (game menu, or Settings → Problems & feedback): saves a diagnostics zip to Documents (logs,
- **Report a problem…** (game menu, or Settings → Problems and feedback): saves a diagnostics zip to Documents (logs,
recipe, device details; IP addresses, user names, home folders and Steam ids replaced) and opens a prefilled GitHub
issue to attach it to. Command line: `frameport diag report <game>`, `frameport share-recipe <game>`.
+20 -3
View File
@@ -1,9 +1,22 @@
[project]
name = "frameport"
dynamic = ["version"]
description = "Port Meta Quest standalone games to the Valve Steam Frame (Lepton): suggested patches, automated validation, one-click install."
description = "Run Meta Quest, Android and PC VR games on the Valve Steam Frame: per-game patches, validation and one-click install."
readme = "README.md"
license = { text = "GPL-3.0-only" }
license = "GPL-3.0-only"
license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
authors = [{ name = "spoopyghosty0" }]
keywords = ["steam-frame", "vr", "openxr", "meta-quest", "android", "lepton", "proton", "revive"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: X11 Applications",
"Intended Audience :: End Users/Desktop",
"Operating System :: Microsoft :: Windows",
"Operating System :: MacOS",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Topic :: Games/Entertainment",
]
requires-python = ">=3.11"
dependencies = [
"flet>=1.0",
@@ -21,6 +34,11 @@ dependencies = [
"pyaxmlparser>=0.3.31",
]
[project.urls]
Homepage = "https://github.com/spoopyghosty0/frameport"
Issues = "https://github.com/spoopyghosty0/frameport/issues"
Releases = "https://github.com/spoopyghosty0/frameport/releases"
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-timeout>=2.3", "ruff>=0.6", "flet-web>=1.0", "flet-cli>=1.0", "playwright>=1.40"]
@@ -48,7 +66,6 @@ packages = ["src/frameport"]
testpaths = ["tests"]
timeout = 120
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)",
]
+4 -2
View File
@@ -23,7 +23,8 @@ def signature(recipe) -> dict:
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)
"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.")},
@@ -47,7 +48,8 @@ def main() -> int:
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
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)
+3 -1
View File
@@ -53,8 +53,10 @@ def main() -> int:
stage_data()
try:
if args.pyinstaller:
sep = ";" if TARGET == "windows" else ":"
# --yes: a failed flet build's folder doesn't stop it
cmd = ["flet", "pack", str(ROOT / "src/main.py"), "--name", "FramePort", "--product-name", "FramePort",
"--add-data", f"{DATA}{';' if TARGET == 'windows' else ':'}frameport/_data", "--distpath", str(ROOT / "dist"), "--yes"] # --yes: a failed flet build's folder doesn't stop it
"--add-data", f"{DATA}{sep}frameport/_data", "--distpath", str(ROOT / "dist"), "--yes"]
else:
# --yes: install the Flutter SDK etc. without prompting; --no-rich-output: plain logs (CI, Windows consoles)
cmd = ["flet", "build", TARGET, str(ROOT), "--project", "FramePort", "--product", "FramePort",
+4 -2
View File
@@ -74,9 +74,11 @@ def main() -> int:
if not keep or (g.get("recipe") or {}).get("status", "unknown") in keep}
view = {**(lib.get("settings", {}).get("ui.library") or {}), "sort": args.sort, "platform": "all",
"where": "all", "status": "all", "tags": []}
out = {"games": games, "settings": {"ui.library": view, "migrations": lib.get("settings", {}).get("migrations", [])}}
migrations = lib.get("settings", {}).get("migrations", [])
out = {"games": games, "settings": {"ui.library": view, "migrations": migrations}}
text = json.dumps(out, indent=1)
leftovers = [o for o in re.findall(r'"((?:/|[A-Za-z]:\\\\)[^"]*)"', text) if not o.startswith(("D:/Games/",) + DEVICE)]
leftovers = [o for o in re.findall(r'"((?:/|[A-Za-z]:\\\\)[^"]*)"', text)
if not o.startswith(("D:/Games/",) + DEVICE)]
if leftovers:
print("not scrubbed:", leftovers[:10], file=sys.stderr)
return 1
+4 -2
View File
@@ -136,7 +136,8 @@ def main() -> int:
vw, vh = (int(x) for x in args.viewport.split("x"))
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)), ("files", lambda a: a.go("files")), ("tools", lambda a: a.go("settings"))]
steps = [("library", lambda a: a.navigate(0)), ("frame", lambda a: a.navigate(1)),
("files", lambda a: a.go("files")), ("tools", lambda a: a.go("settings"))]
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)))
@@ -148,7 +149,8 @@ def main() -> int:
if args.docs:
steps = [("library", lambda a: a.navigate(0))]
if game:
steps += [("game", lambda a: a.open_game(game)), ("game-customize", lambda a: a.open_game(game, advanced=True))]
steps += [("game", lambda a: a.open_game(game)),
("game-customize", lambda a: a.open_game(game, advanced=True))]
steps.append(("frame", lambda a: a.navigate(1)))
steps.append(("files", lambda a: a.go("files")))
steps.append(("files-select", lambda a: [a.files_view._toggle(e.path, True)
+2 -1
View File
@@ -44,7 +44,8 @@ def main() -> int:
nested = sub / "new-version.txt" # files in existing subfolders must be replaced too (Windows merges folders)
nested.write_text("new")
if platform == "win32" and os.environ.get("HAS_CERT") == "true":
a, b = updates._signer_thumbprint(installed / "FramePort.exe"), updates._signer_thumbprint(new / "FramePort.exe")
a = updates._signer_thumbprint(installed / "FramePort.exe")
b = updates._signer_thumbprint(new / "FramePort.exe")
print(f"signer thumbprints: installed {a} new {b}")
assert a and a == b, "signature check failed"
# the same launch as the app (detached; apply() checks the script started), then wait for it to finish
+10 -5
View File
@@ -103,7 +103,8 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
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 ""
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)
@@ -136,7 +137,8 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
oculus_os_refs.append(name)
if name != "libOVRPlugin.so": # OVRPlugin lists every layer extension; only the game's own requests count
layer_exts |= {ext for ext in LAYER_EXTENSIONS if ext.encode() + b"\0" in data}
if name not in ("libvrapi.so", "libOVRPlugin.so") and b"GLAD_GL_" in data and "eglGetProcAddress" in elf.dyn_symbols(data, False):
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
msaa_levels = 0
@@ -175,9 +177,11 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
"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."))),
"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,
"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),
@@ -205,7 +209,8 @@ def missing_ovr_symbols(lib_bytes: dict[str, bytes]) -> set[str]:
exported |= elf.dyn_symbols(lib_bytes[extra], True)
wanted = set()
for name, data in lib_bytes.items():
if name.startswith(("libovrplatformloader", "libopenxr_loader", "libframe_settings", "libfrda")) or not elf.is_elf(data):
if (name.startswith(("libovrplatformloader", "libopenxr_loader", "libframe_settings", "libfrda"))
or not elf.is_elf(data)):
continue
wanted |= {s for s in elf.dyn_symbols(data, False) if s.startswith(("ovr_", "ovrMessageType_"))}
return wanted - exported
+5 -3
View File
@@ -40,7 +40,8 @@ PLATFORM_DLLS = ("libovrplatform64_1.dll", "libovrplatform32_1.dll")
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"
# repacks start their bundled Revive through a proxy DLL next to the exe (Windows loads DLLs from the exe's folder first)
# repacks start their bundled Revive through a proxy DLL next to the exe (Windows loads DLLs from the exe's folder
# first)
LOADER_DLLS = ("xinput1_3.dll", "xinput1_4.dll", "xinput9_1_0.dll", "dinput8.dll", "version.dll", "winmm.dll")
REVIVE_DLLS = ("librevive64.dll", "librevive32.dll", "librevivexr64.dll", "librevivexr32.dll")
@@ -425,8 +426,9 @@ def analyze(folder: Path, exe: str | None = None, tree: Tree | None = None) -> A
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
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)
# how to start it with VR on SteamVR / the Frame (see launch_mode())
try:
exe_dir = {p.name.lower() for p in exe_path.parent.iterdir()}
+2 -1
View File
@@ -252,7 +252,8 @@ def define_meta_permissions(manifest: bytes) -> tuple[bytes, list[str]] | None:
start = struct.pack("<HHIII", RES_XML_START_ELEMENT, 16, 56, 0, 0xFFFFFFFF)
start += struct.pack("<IIHHHHHH", 0xFFFFFFFF, perm_el, 20, 20, 1, 0, 0, 0)
start += struct.pack("<IIIHBBI", android_ns, name_attr, value, 8, 0, TYPE_STRING, value)
end = struct.pack("<HHIII", RES_XML_END_ELEMENT, 16, 24, 0, 0xFFFFFFFF) + struct.pack("<II", 0xFFFFFFFF, perm_el)
end = (struct.pack("<HHIII", RES_XML_END_ELEMENT, 16, 24, 0, 0xFFFFFFFF)
+ struct.pack("<II", 0xFFFFFFFF, perm_el))
chunks += start + end
manifest_el = next(el for el in x.elements() if el.name == "manifest")
insert_at = manifest_el.offset + manifest_el.size
+2 -1
View File
@@ -74,7 +74,8 @@ def cert_digest(apk: Path) -> str | None:
def alignment_problems(apk: Path) -> list[str]:
"""Stored entries must be 4-byte aligned; stored .so files page (16 KiB) aligned (what zipalign -c -P 16 4 checks)."""
"""Stored entries must be 4-byte aligned; stored .so files page (16 KiB) aligned (what zipalign -c -P 16 4
checks)."""
bad = []
with open(apk, "rb") as f, zipfile.ZipFile(apk) as z:
for info in z.infolist():
+4 -2
View File
@@ -18,7 +18,8 @@ class ApkWorkspace:
self.add: dict[str, bytes] = {}
self.rename: dict[str, str] = {} # old name -> new name (content unchanged unless replaced)
self.remove: set[str] = set()
abi = next((a for a in ("arm64-v8a", "armeabi-v7a") if any(n.startswith(f"lib/{a}/") for n in self.infos)), None)
abi = next((a for a in ("arm64-v8a", "armeabi-v7a")
if any(n.startswith(f"lib/{a}/") for n in self.infos)), None)
self.abi = abi
self.libdir = f"lib/{abi}/" if abi else ""
@@ -89,7 +90,8 @@ class ApkWorkspace:
if name in self.replace:
dst.writestr(zi, self.replace[name])
else:
with self._zip.open(info) as fin, dst.open(zi, "w", force_zip64=info.file_size > 0x7FFFFFFF) as fout:
with (self._zip.open(info) as fin,
dst.open(zi, "w", force_zip64=info.file_size > 0x7FFFFFFF) as fout):
shutil.copyfileobj(fin, fout, 1 << 22)
for name, data in self.add.items():
zi = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
+16 -8
View File
@@ -157,7 +157,8 @@ def _pkgs(package: Optional[str], all_: bool) -> list[str]:
return [g["package"] for g in library.games()]
if not package:
raise typer.BadParameter("give a package or --all")
matches = [g["package"] for g in library.games() if package.lower() in (g["package"] + " " + (g.get("title") or "")).lower()]
matches = [g["package"] for g in library.games()
if package.lower() in (g["package"] + " " + (g.get("title") or "")).lower()]
if package in [g["package"] for g in library.games()]:
return [package]
if len(matches) != 1:
@@ -172,7 +173,8 @@ def tools_status(check_latest: bool = typer.Option(False, "--latest", help="also
for s in toolchain.status(check_latest):
latest = f" (latest {s.latest})" if s.latest else ""
typer.echo(f"{s.name:10} {'installed' if s.installed else 'missing':9} {s.version or '-'}{latest} {s.path or ''}")
state = "installed" if s.installed else "missing"
typer.echo(f"{s.name:10} {state:9} {s.version or '-'}{latest} {s.path or ''}")
@tools_app.command("install")
@@ -242,7 +244,8 @@ def show(package: str, as_json: bool = typer.Option(False, "--json"),
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'})")
which = "alt" if r["use_alt"] else "primary"
typer.echo(f" alternate build adds: {', '.join(r['alt_patches'])} (installed: {which})")
@app.command()
@@ -254,7 +257,8 @@ def patches():
@app.command()
def recipe(package: str, enable: list[str] = typer.Option([], "--enable"), disable: list[str] = typer.Option([], "--disable"),
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,
as_is: Optional[bool] = typer.Option(None, "--as-is/--patch", help="install unchanged (already patched)"),
@@ -346,7 +350,8 @@ def triage(logfile: Path, package: Optional[str] = None):
r = run_triage(logfile.read_text(errors="replace"), "UNKNOWN", package)
typer.echo(f"furthest milestone: {r.milestone}; fps {r.fps}")
for f in r.findings:
typer.echo(f" {f.severity:7} {f.id}: {f.diagnosis}\n {f.evidence[:200]}\n suggest: {f.suggest}")
typer.echo(f" {f.severity:7} {f.id}: {f.diagnosis}\n {f.evidence[:200]}\n"
f" suggest: {f.suggest}")
@app.command()
@@ -399,12 +404,15 @@ def frame_connect(address: str, password: Optional[str] = typer.Option(None, pro
info = f.agent("info")
t.name = info["hostname"]
save_target(t)
typer.echo(json.dumps({k: info[k] for k in ("hostname", "os", "os_version", "lepton", "steam_users", "free_bytes")}, indent=1))
keys = ("hostname", "os", "os_version", "lepton", "steam_users", "free_bytes")
typer.echo(json.dumps({k: info[k] for k in keys}, indent=1))
@frame_app.command("cleanup")
def frame_cleanup(frame: Optional[str] = None, keep_rollback: bool = typer.Option(False, help="keep previous-game.apk copies"),
path: list[str] = typer.Option([], help="extra folder under the Frame's home to delete, e.g. ~/PATCHED")):
def frame_cleanup(frame: Optional[str] = None,
keep_rollback: bool = typer.Option(False, help="keep previous-game.apk copies"),
path: list[str] = typer.Option([], help="extra folder under the Frame's home to delete, "
"e.g. ~/PATCHED")):
"""Free space on the Frame: rollback APKs from reinstalls, leftover uploads, optional extra folders."""
r = _target(frame).frame.agent("cleanup", rollback=not keep_rollback, paths=path)
typer.echo(f"removed {len(r['removed'])} item(s), freed {r['freed_bytes'] / 2**30:.1f} GiB")
+2 -1
View File
@@ -56,7 +56,8 @@ def cached_json(name: str, url: str, max_age: float = 86400, fallback=None):
return fallback
def download(url: str, dest: Path, progress=None, expected_sha256: str | None = None, expected_sha1: str | None = None) -> Path:
def download(url: str, dest: Path, progress=None, expected_sha256: str | None = None,
expected_sha1: str | None = None) -> Path:
"""Stream url to dest (atomic), verifying a checksum when given."""
import hashlib
+4 -2
View File
@@ -75,7 +75,8 @@ def _migrate(data: dict) -> bool:
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.")
"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:
@@ -274,5 +275,6 @@ def analysis_from_dict(d: dict) -> Analysis:
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
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)
+12 -7
View File
@@ -22,7 +22,7 @@ MAX_TOTAL = 24 << 20 # GitHub's attachment limit is 25 MB
ENTRY_DROP = ("details", "art_source", "artwork", "art") # store metadata: public, big, no debug value
ELF_SYMBOL_PREFIXES = ("ovr", "xr", "vrapi_", "OVR", "Java_", "JNI_", "eglGetProcAddress", "gl")
README = """# FramePort diagnostics bundle
README = ("""# FramePort diagnostics bundle
Created by FramePort {version} on {created}. Personal data was replaced by placeholders such as `<ip>`, `<home>`,
`<user>`, `<data>` (FramePort's data folder), `<frame>` and `<steam-id>` (counts in manifest.json → redactions).
@@ -32,20 +32,22 @@ Created by FramePort {version} on {created}. Personal data was replaced by place
| manifest.json | app / agent / tool versions, OS, which games are included, warnings |
| app/app.log, app/jobs/*.log | the app's log and recent GUI job logs (build, install, launch test, ...) |
| app/settings.json | library settings and catalog sources |
| frame/info.json, frame/host.json | the Frame: SteamOS build, Lepton/Proton, OpenXR runtime + layers, podman, kernel keys |
| frame/info.json, frame/host.json | the Frame: SteamOS build, Lepton/Proton, OpenXR runtime + layers, podman, kernel"""
""" keys |
| games/<pkg>/entry.json | the library entry: analysis, recipe, build checks, installs, last launch test |
| games/<pkg>/recipe.yaml | the recipe in catalog form (catalog/games/<pkg>.yaml) |
| games/<pkg>/triage.json | the newest launch log re-triaged with the triage signatures of this app version |
| games/<pkg>/package/ | stand-in for the game files: file list, AndroidManifest.xml, ELF imports/exports / PE imports |
| games/<pkg>/logs/ | launch-test logs saved on the PC |
| games/<pkg>/target/ | from the Frame (or PC): launch.sh, settings.conf, deployment.json, launch/logcat/Proton/game logs, file list |
| games/<pkg>/target/ | from the Frame (or PC): launch.sh, settings.conf, deployment.json, launch/logcat/Proton/game"""
""" logs, file list |
## Debugging from this bundle
1. `frameport diag inspect <this zip>` re-runs triage with the current signatures (catalog/triage.yaml).
2. Look up symptoms in docs/PLAYBOOK.md; runtime facts are in docs/FRAME_RUNTIME.md.
3. Compare `recipe.yaml` with `catalog/games/` recipes of games using the same engine / XR API (entry.json → analysis).
4. Headless launch tests can't show the picture: "RUNNING" only proves startup (see FRAME_RUNTIME.md).
"""
""")
def _tail(text: str, limit: int = MAX_LOG) -> str:
@@ -100,7 +102,8 @@ class _Writer:
if not logs:
break
n = logs[0]
self.files[n] = b"[... cut to fit the 25 MB attachment limit ...]\n" + self.files[n][-(len(self.files[n]) // 4):]
tail = self.files[n][-(len(self.files[n]) // 4):]
self.files[n] = b"[... cut to fit the 25 MB attachment limit ...]\n" + tail
self.warnings.append(f"{n} was cut to fit the size limit")
size = zipped()
@@ -135,7 +138,8 @@ def env_info(target_info: dict | None = None) -> dict:
proton = target_info.get("proton") or {}
if isinstance(proton, dict):
ready = proton.get("ready")
out["frame"]["proton"] = ready.get("display_name") or ready.get("name") if isinstance(ready, dict) else ready
out["frame"]["proton"] = (ready.get("display_name") or ready.get("name")
if isinstance(ready, dict) else ready)
return out
@@ -370,7 +374,8 @@ def collect(packages: list[str] | None, target=None, reporter: Reporter | None =
tag = packages[0] if packages and len(packages) == 1 else ("games" if packages else "app")
dest = dest / f"FramePort-diag-{tag}-{time.strftime('%Y%m%d-%H%M%S')}.zip"
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
for name in ["README.md", "manifest.json"] + sorted(n for n in w.files if n not in ("README.md", "manifest.json")):
rest = sorted(n for n in w.files if n not in ("README.md", "manifest.json"))
for name in ["README.md", "manifest.json"] + rest:
z.writestr(name, w.files[name])
reporter.check("Diagnostics bundle", True, f"{dest.name} ({dest.stat().st_size / 2**20:.1f} MB)")
applog.log.info("diagnostics bundle %s", dest)
+6 -3
View File
@@ -65,7 +65,8 @@ def _mdns(seconds: float) -> dict[str, Found]:
info = zc.get_service_info(type_, name, timeout=2000)
if not info:
return
props = {k.decode(): (v.decode() if isinstance(v, bytes) else v) for k, v in (info.properties or {}).items()}
props = {k.decode(): (v.decode() if isinstance(v, bytes) else v)
for k, v in (info.properties or {}).items()}
dev = (info.server or name).split(".")[0]
f = found.setdefault(dev, Found(dev, "", source="devkit" if "devkit" in type_ else "frameport"))
if "frameport" in type_:
@@ -107,7 +108,8 @@ def local_subnets() -> list[ipaddress.IPv4Network]:
nets = set()
stats = psutil.net_if_stats()
for nic, addrs in psutil.net_if_addrs().items():
if not stats.get(nic) or not stats[nic].isup or any(v in nic.lower() for v in ("vethernet", "docker", "virbr", "vmnet", "wsl")):
virtual = any(v in nic.lower() for v in ("vethernet", "docker", "virbr", "vmnet", "wsl"))
if not stats.get(nic) or not stats[nic].isup or virtual:
continue
for a in addrs:
if a.family == socket.AF_INET and not a.address.startswith(("127.", "169.254.", "172.")):
@@ -119,7 +121,8 @@ def _scan(nets: list[ipaddress.IPv4Network], skip: set[str]) -> list[str]:
own = local_addresses()
hosts = [str(h) for n in nets for h in n.hosts() if str(h) not in skip and str(h) not in own]
with ThreadPoolExecutor(128) as pool:
return [h for h, ok in zip(hosts, pool.map(lambda h: ssh_open(h, timeout=0.4), hosts), strict=True) if ok and h not in own]
results = pool.map(lambda h: ssh_open(h, timeout=0.4), hosts)
return [h for h, ok in zip(hosts, results, strict=True) if ok and h not in own]
def browse(seconds: float = 4.0, scan: bool = True) -> list[Found]:
+2 -1
View File
@@ -101,7 +101,8 @@ def send_files(frame: Frame, paths: list[Path], target: str = "videos", package:
else:
reporter.log("the game has no folder of its own yet (start it once); it finds the files in " + where)
reporter.log(f"done: in the app, open {where}")
return {"files": len(items), "skipped": len(have), "bytes": total, "path": posixpath.join(dest["path"], sub), "android": where,
return {"files": len(items), "skipped": len(have), "bytes": total, "path": posixpath.join(dest["path"], sub),
"android": where,
"shared": dest["shared"], "linked": linked}
+4 -2
View File
@@ -91,7 +91,8 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
else:
def apk_cb(done, size):
reporter.check_cancel()
reporter.progress(done / total, f"APK {done / 2**20:.0f}/{size / 2**20:.0f} MiB", speed=speed.text(done))
reporter.progress(done / total, f"APK {done / 2**20:.0f}/{size / 2**20:.0f} MiB",
speed=speed.text(done))
xfer.put(plan.apk, posixpath.join(incoming, "game.apk"), apk_cb)
sent += plan.apk.stat().st_size
if to_send:
@@ -107,7 +108,8 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
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, tags=_tags(plan.package),
"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,
+28 -15
View File
@@ -136,7 +136,8 @@ def classify(name: str, new: bytes, old: bytes) -> tuple[str, str]:
why = _overport_config(new, old)
if why:
return "expected", why
if base == "libovrplatformloader.so" and elf.is_elf(new) and elf.is_elf(old) and _text_bytes(new) == _text_bytes(old):
if (base == "libovrplatformloader.so" and elf.is_elf(new) and elf.is_elf(old)
and _text_bytes(new) == _text_bytes(old)):
ignore = {"libovrstubs.so", COMPAT}
if set(elf.needed(new)) - ignore == set(elf.needed(old)) - ignore and COMPAT in elf.needed(new):
return "expected", "same loader, linked to the platform compat library by the overport CLI (OVRPort 1.2.5+)"
@@ -161,8 +162,10 @@ def classify(name: str, new: bytes, old: bytes) -> tuple[str, str]:
return "UNEXPLAINED", "stub exports differ"
if elf.is_elf(new) and elf.is_elf(old):
if (elf.needed(new) == elf.needed(old) and elf.dyn_symbols(new, True) == elf.dyn_symbols(old, True)
and elf.dyn_symbols(new, False) == elf.dyn_symbols(old, False) and _text_bytes(new) == _text_bytes(old)):
return "equivalent", f"same code, symbols and NEEDED ({', '.join(elf.needed(new)[:2])}, …); different layout"
and elf.dyn_symbols(new, False) == elf.dyn_symbols(old, False)
and _text_bytes(new) == _text_bytes(old)):
return "equivalent", (f"same code, symbols and NEEDED ({', '.join(elf.needed(new)[:2])}, …); "
"different layout")
return "UNEXPLAINED", f"ELF differs (NEEDED new {elf.needed(new)[:3]} vs old {elf.needed(old)[:3]})"
return "UNEXPLAINED", "content differs"
@@ -182,13 +185,16 @@ def _redundant_stubs(new_apk: Path, old_apk: Path, name: str) -> bool:
def compare(new_apk: Path, old_apk: Path) -> dict:
a, b = _entries(new_apk), _entries(old_apk)
rows = []
stub_names = [n for n in b if n.endswith("/libovrstubs.so") and n not in a and _redundant_stubs(new_apk, old_apk, n)]
stub_names = [n for n in b
if n.endswith("/libovrstubs.so") and n not in a and _redundant_stubs(new_apk, old_apk, n)]
for n in stub_names:
rows.append((n, "expected", "known-good stub only shadowed libovrplatformcompat's real ovrMessageType_ToString; not needed"))
rows.append((n, "expected", "known-good stub only shadowed libovrplatformcompat's real "
"ovrMessageType_ToString; not needed"))
loader = n.rsplit("/", 1)[0] + "/libovrplatformloader.so"
if loader in a and a[loader] != b.get(loader):
new_l, old_l = _read(new_apk, loader), _read(old_apk, loader)
if [x for x in elf.needed(old_l) if x != "libovrstubs.so"] == elf.needed(new_l) and _text_bytes(new_l) == _text_bytes(old_l):
old_needed = [x for x in elf.needed(old_l) if x != "libovrstubs.so"]
if old_needed == elf.needed(new_l) and _text_bytes(new_l) == _text_bytes(old_l):
rows.append((loader, "expected", "same, minus the redundant libovrstubs.so dependency"))
a[loader] = b[loader]
b.pop(n)
@@ -257,10 +263,11 @@ def run_parity(known_good: Path, sources: Path, outdir: Path, report: Path, only
row["primary"] = compare(res.apk, folder / f"{pkg}.apk")
if res.alt_apk:
known_alt = folder / f"{pkg}.alt-noforcequit.apk"
row["alt"] = compare(res.alt_apk, known_alt) if known_alt.exists() else {"verdict": "UNEXPLAINED",
"diffs": [("alt", "UNEXPLAINED", "no known-good alt APK")]}
no_alt = {"verdict": "UNEXPLAINED", "diffs": [("alt", "UNEXPLAINED", "no known-good alt APK")]}
row["alt"] = compare(res.alt_apk, known_alt) if known_alt.exists() else no_alt
elif (folder / f"{pkg}.alt-noforcequit.apk").exists():
row["alt"] = {"verdict": "UNEXPLAINED", "diffs": [("alt", "UNEXPLAINED", "recipe has no alternate build")]}
row["alt"] = {"verdict": "UNEXPLAINED",
"diffs": [("alt", "UNEXPLAINED", "recipe has no alternate build")]}
row["new_apk"] = str(res.apk)
row["new_alt_apk"] = str(res.alt_apk) if res.alt_apk else None
# unsupported games (32-bit) fail the 64-bit check by design
@@ -288,7 +295,8 @@ def run_parity(known_good: Path, sources: Path, outdir: Path, report: Path, only
def write_report(results: list[dict], path: Path) -> None:
lines = ["# Parity report", "", f"Generated {time.strftime('%Y-%m-%d %H:%M')}. Rebuilt each game from its original dump "
lines = ["# Parity report", "",
f"Generated {time.strftime('%Y-%m-%d %H:%M')}. Rebuilt each game from its original dump "
"with its catalog recipe and compared every APK entry with the known-good build (META-INF excluded).", "",
"| Game | Primary | Alternate | Failed checks | Notes |", "|---|---|---|---|---|"]
for r in results:
@@ -302,7 +310,8 @@ def write_report(results: list[dict], path: Path) -> None:
failed = ", ".join(c["name"] for c in r.get("checks_failed", [])) or "none"
lines.append(f"| {r['game']} | {r['primary']['verdict']} | {r.get('alt', {}).get('verdict', '—')} | {failed} | "
+ "<br>".join(notes) + " |")
ok = sum(1 for r in results if "error" not in r and all(r.get(k, {}).get("verdict") != "UNEXPLAINED" for k in ("primary", "alt")))
ok = sum(1 for r in results
if "error" not in r and all(r.get(k, {}).get("verdict") != "UNEXPLAINED" for k in ("primary", "alt")))
lines += ["", f"**{ok}/{len(results)} games at parity** (identical, expected or equivalent)."]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -345,7 +354,8 @@ def install_and_test(results_json: Path, target, baseline: Path | None, report:
res = {"game": row["game"], "package": pkg, "apk": apk.name}
try:
reporter.stage(f"install {title}")
installer.install(target.frame, installer.InstallPlan(pkg, title, apk, None, recipe, apk_only=True), reporter)
plan = installer.InstallPlan(pkg, title, apk, None, recipe, apk_only=True)
installer.install(target.frame, plan, reporter)
installed_pkgs.append(pkg)
res["install"] = "ok"
except Exception as exc: # noqa: BLE001
@@ -375,14 +385,17 @@ def install_and_test(results_json: Path, target, baseline: Path | None, report:
out = sorted(previous.values(), key=lambda x: x["game"].lower()) if previous else out
lines = ["# Device parity (install + headless launch)", "",
f"Generated {time.strftime('%Y-%m-%d %H:%M')}. APK-only reinstall through the FramePort agent, Steam "
"shortcuts re-added in one batch, then a 45 s headless launch per game compared with the pre-change baseline.",
"shortcuts re-added in one batch, then a 45 s headless launch per game compared with the pre-change "
"baseline.",
"", "| Game | Install | Shortcut | Baseline | Now | Furthest milestone | fps | Fatal findings |",
"|---|---|---|---|---|---|---|---|"]
for r in out:
lines.append(f"| {r['game']} | {r.get('install')} | {r.get('shortcut', '')} | {r.get('baseline', '')} | "
f"{r.get('state', '')}{' **REGRESSION**' if r.get('regression') else ''} | {r.get('milestone') or ''} | "
f"{r.get('state', '')}{' **REGRESSION**' if r.get('regression') else ''} | "
f"{r.get('milestone') or ''} | "
f"{r.get('fps') or ''} | {', '.join(r.get('findings') or [])} |")
regressions = [r for r in out if r.get("regression") or (r.get("install") not in ("ok", None) and "skipped" not in r["install"])]
regressions = [r for r in out if r.get("regression")
or (r.get("install") not in ("ok", None) and "skipped" not in r["install"])]
lines += ["", f"**{len(out) - len(regressions)}/{len(out)} OK, {len(regressions)} regression(s).**"]
report.write_text("\n".join(lines) + "\n", encoding="utf-8")
return not regressions
+4 -2
View File
@@ -29,7 +29,8 @@ def arm64(a: Analysis) -> bool:
def meta_audio_libs(a: Analysis) -> list[str]:
return [lib for lib in a.libs if lib.lower().startswith(("libmetaxraudio", "libovraudio", "libaksoundengine", "libovravatar"))]
prefixes = ("libmetaxraudio", "libovraudio", "libaksoundengine", "libovravatar")
return [lib for lib in a.libs if lib.lower().startswith(prefixes)]
def uses_scene(a: Analysis) -> bool:
@@ -55,7 +56,8 @@ def unreal_version(a: Analysis) -> tuple[int, int] | None:
def uses_render_models(a: Analysis) -> bool:
"""The game declares Meta's runtime controller models (XR_FB_render_model): permission or feature RENDER_MODEL."""
perms = a.extra.get("meta_permissions_used") or a.meta_permissions
return any(p.endswith("RENDER_MODEL") for p in perms) or "com.oculus.feature.RENDER_MODEL" in (a.extra.get("features") or {})
return (any(p.endswith("RENDER_MODEL") for p in perms)
or "com.oculus.feature.RENDER_MODEL" in (a.extra.get("features") or {}))
def may_use_render_models(a: Analysis) -> bool:
+5 -5
View File
@@ -23,11 +23,11 @@ class FrameBridgeAdapter(Patch):
id = "frame.adapter"
title = "FrameBridge OpenXR adapter"
description = (
"Wraps overport's generic OpenXR loader (renamed libopenxr_loader_original.so). Fixes the Frame runtime's gaps: "
"retries rejected GLES swapchain formats/MSAA (Frame takes sRGB only), drops unsupported instance extensions "
"and layers, emulates XR_FB_passthrough (ALPHA_BLEND), Meta scene/spatial entities (guardian-sized room), "
"XR_KHR_convert_timespec_time, and vertically flipped quad layers; maps Frame controllers to Touch; optionally "
"serves Steam Frame controller models (XR_FB_render_model). "
"Wraps overport's generic OpenXR loader (renamed libopenxr_loader_original.so). Fixes the Frame runtime's "
"gaps: retries rejected GLES swapchain formats/MSAA (Frame takes sRGB only), drops unsupported instance "
"extensions and layers, emulates XR_FB_passthrough (ALPHA_BLEND), Meta scene/spatial entities (guardian-sized "
"room), XR_KHR_convert_timespec_time, and vertically flipped quad layers; maps Frame controllers to Touch; "
"optionally serves Steam Frame controller models (XR_FB_render_model). "
"Settings are in the Adapter settings group."
)
category = "frame"
@@ -49,8 +49,8 @@ class NoDebuggable(Patch):
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(lib.startswith("libmetaxraudio") for lib 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(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):
@@ -74,11 +74,12 @@ class MetaPermissions(Patch):
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).")
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])
names = ", ".join(p.rsplit(".", 1)[-1] for p in scene[:3])
return Suggestion(False, "Uses Meta scene/anchor permissions (" + names
+ "); enable if the game says it needs spatial data access.")
return None
+5 -5
View File
@@ -90,12 +90,12 @@ class OculusOsStubs(Patch):
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).")
return Suggestion(True, "Several Meta SDK libraries look up com.oculus.os.AnalyticsEvent "
f"({', '.join(refs)}); games like this abort without the stub (e.g. Nano).")
if a.oculus_os_classes:
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 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):
+8 -7
View File
@@ -49,19 +49,20 @@ def disable_msaa(ggm_bytes: bytes) -> bytes | None:
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', e.g. Sniper Elite VR). Try it for GLES Unity games that "
"freeze or crash the GPU.")
description = ("Sets Unity QualitySettings antiAliasing to 0. Unity's multisampled render-to-texture path can "
"hang 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" 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').")
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
+2 -1
View File
@@ -24,7 +24,8 @@ class VulkanSanitize(Patch):
def detect(self, a):
if a.engine == "Unreal":
return Suggestion(True, "Unreal game: applied automatically when the engine loads Vulkan by name; keeps "
"Lepton's Fossilize layer from crashing on uninitialized pointers (e.g. Deadpool VR).")
"Lepton's Fossilize layer from crashing on uninitialized pointers "
"(e.g. Deadpool VR).")
return None
def applies(self, a):
+2 -2
View File
@@ -14,8 +14,8 @@ 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 (e.g. The Climb 2, Path of "
"the Warrior); overport cannot translate those."
"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
+17 -10
View File
@@ -2,8 +2,9 @@
github.com/Android-XR-Bridge/OVRPort (1.2.5+), originally github.com/ovrport/app.
The patch list is discovered dynamically (`overport patches`), and titles are fetched from ovrport/app's
strings.xml on GitHub (both cached; the fork dropped that file, so its new patches are described here). The table below is the offline fallback and adds what we learned on the
Steam Frame; `default` mirrors overport's recommended set (Patch(..., true) in its sources).
strings.xml on GitHub (both cached; the fork dropped that file, so its new patches are described here). The table
below is the offline fallback and adds what we learned on the Steam Frame; `default` mirrors overport's recommended
set (Patch(..., true) in its sources).
"""
from __future__ import annotations
@@ -22,8 +23,10 @@ OVERPORT_PATCHES = [
("patch_generate_config", "Generate overport config", True, "Writes liboverport.config.so with runtime options."),
("patch_remove_localized_names", "Remove localized app names", True, "Keeps one label so the title is stable."),
("patch_clean_up_frida", "Clean up Frida leftovers in smali", True, "Removes leftovers from dumped/modded APKs."),
("patch_oculus_unity", "Patch Oculus detection for Unity", True, "Makes Unity's Oculus checks pass on other runtimes."),
("patch_oculus_unreal", "Patch Oculus detection for Unreal", True, "Makes Unreal's Oculus checks pass on other runtimes."),
("patch_oculus_unity", "Patch Oculus detection for Unity", True,
"Makes Unity's Oculus checks pass on other runtimes."),
("patch_oculus_unreal", "Patch Oculus detection for Unreal", True,
"Makes Unreal's Oculus checks pass on other runtimes."),
("patch_vr_metadata", "Pico/YVR/Quest metadata", True, "Adds the VR app metadata other launchers expect."),
("patch_launcher_entry", "Fix launcher icon entry", True, "Adds a launcher entry point (Lepton additionally needs "
"category LAUNCHER, see the Frame 'launcher' fix)."),
@@ -31,12 +34,15 @@ OVERPORT_PATCHES = [
("patch_fix_unreal_crash", "Fix UE4 crash with Unity stub", True, "Works around a UE4 startup crash."),
("patch_meta_xr_audio", "Patch Meta XR Audio", True, "Neutralises Meta XR Audio's Quest-only calls (Unity/Wwise)."),
("patch_mark_as_debuggable", "Mark application as debuggable", True,
"Lets you read logs/attach. Some Unreal games abort under CheckJNI when debuggable; the Frame 'nodebug' fix undoes it."),
"Lets you read logs/attach. Some Unreal games abort under CheckJNI when debuggable; the Frame 'nodebug' fix "
"undoes it."),
("patch_mark_allow_backup", "Mark application to allow backup", True, "Allows data backup."),
("patch_remove_unreal_force_quit", "Remove Unreal's ForceQuit", False,
"For Unreal games that close themselves right after starting (e.g. Phantom: Covert Ops). Also disables the in-game Quit."),
"For Unreal games that close themselves right after starting (e.g. Phantom: Covert Ops). Also disables the "
"in-game Quit."),
("patch_force_passthrough", "Force enable passthrough", False,
"For mixed-reality-only games. On the Frame, passthrough is emulated by the FrameBridge adapter (greyscale cameras)."),
"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 that use application space warp: it causes artifacts or hangs on non-Quest runtimes "
"(e.g. Asgard's Wrath 2, Batman: Arkham Shadow)."),
@@ -46,8 +52,8 @@ OVERPORT_PATCHES = [
"Not recommended: breaks games that load VrApi through OVRPlugin."),
("patch_vrapi_openxr", "VrApi → OpenXR adapter (OVRPort)", False,
"OVRPort's own VrApi→OpenXR adapter for engines that call libvrapi.so directly: the same upstream code as "
"FramePort's 'VrApi → OpenXR bridge' without its Frame-specific changes. Only in OVRPort's experimental CLI builds "
"(the stable CLI lists it but can't apply it)."),
"FramePort's 'VrApi → OpenXR bridge' without its Frame-specific changes. Only in OVRPort's experimental CLI "
"builds (the stable CLI lists it but can't apply it)."),
("patch_disable_meta_xr_audio_telemetry", "Disable Meta XR Audio telemetry", False,
"Skips Meta XR Audio's telemetry under x86_64 ARM translation (emulators). Not needed on the Frame, which runs "
"games natively."),
@@ -83,7 +89,8 @@ class OverportPatch(Patch):
if self.default_on:
return Suggestion(True, "overport default.")
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.")
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:
+11 -9
View File
@@ -87,7 +87,8 @@ class LibovrRedirect(_PcvrPatch):
return None
if _mode(analysis) == "native":
return Suggestion(False, "Supports SteamVR/OpenXR itself: no Oculus runtime needed.")
return Suggestion(True, "Lets the game find Revive's runtime on the Frame (doesn't bypass its signature check).")
return Suggestion(True, "Lets the game find Revive's runtime on the Frame (doesn't bypass its signature "
"check).")
class RepackLauncher(_PcvrPatch):
@@ -154,9 +155,9 @@ class XrTimefix(_PcvrPatch):
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).")
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):
@@ -171,8 +172,8 @@ class NoCrashReporter(_PcvrPatch):
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'); "
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 "
@@ -210,7 +211,8 @@ class ProtonTool(_PcvrPatch):
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."
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")]
@@ -233,8 +235,8 @@ class SteamvrTuning(_PcvrPatch):
return None
for _cls in (RepackLauncher, LaunchArgs, Revive, ReviveOpenVR, LibovrRedirect, NoCrashReporter, OculusUnreal, XrTimefix, ProtonLog,
ProtonTool, ProtonEnv, SteamvrTuning):
for _cls in (RepackLauncher, LaunchArgs, Revive, ReviveOpenVR, LibovrRedirect, NoCrashReporter, OculusUnreal, XrTimefix,
ProtonLog, ProtonTool, ProtonEnv, SteamvrTuning):
register(_cls)
+24 -15
View File
@@ -19,9 +19,12 @@ SETTINGS = [
("controller_fix", "int", 1, "Report Touch controllers",
"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)."),
("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). Fixes UI panels and "
"text that show upside down (e.g. Assassin's Creed Nexus)."),
@@ -31,8 +34,8 @@ SETTINGS = [
("equirect_emul", "int", 0, "Show 360° layers",
"360° (equirect) layers, e.g. a video player's virtual theatre or 360° videos (e.g. 4XVR), which the Frame's "
"runtime lacks, are drawn by a background thread into a layer behind the game's own picture: the 360° image is "
"converted only when it changes (a theatre once, a 360° video once per video frame) and the view is drawn for each "
"frame's head pose. GLES games only; without it these layers are missing (black)."),
"converted only when it changes (a theatre once, a 360° video once per video frame) and the view is drawn for "
"each frame's head pose. GLES games only; without it these layers are missing (black)."),
("equirect_face", "int", 1536, "360° detail",
"Maximum size in pixels of each face of the cube the 360° image is converted to (256–2730). Higher is sharper "
"but uses more GPU memory."),
@@ -55,7 +58,8 @@ SETTINGS = [
("aim_pitch", "float", 0.0, "Pointer tilt (degrees)",
"Tilts the controllers' pointing ray up (+) or down (−), for games whose pointer doesn't hit what you aim at."),
("aim_yaw", "float", 0.0, "Pointer turn (degrees)", "Turns the controllers' pointing ray left (+) or right (−)."),
("aim_forward", "float", 0.0, "Pointer origin forward (m)", "Moves where the pointing ray starts forward (+) or back (−)."),
("aim_forward", "float", 0.0, "Pointer origin forward (m)",
"Moves where the pointing ray starts forward (+) or back (−)."),
("refresh_rate", "float", 0.0, "Refresh rate (Hz)",
"Display refresh rate for this game (72, 80, 90, 96, 108, 120 or 144; 0 = the game's choice). A video's frame "
"rate that divides the refresh rate plays smoothest (e.g. 30 fps at 90 Hz, 24 fps at 72 Hz)."),
@@ -71,14 +75,16 @@ SETTINGS = [
"are converted on the Frame at install time. Games that ship their own controller meshes aren't affected. Turning "
"it on needs a rebuild (it adds a small library in front of overport's loader)."),
("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)."),
("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)."),
("swap_eyes", "int", 0, "Swap eyes", "Swap left/right views (diagnostic)."),
("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°", "Older workaround for upside-down quads: rotates them 180° (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. For GLES games whose multiview "
"shaders fail on single-view render targets (e.g. Path of the Warrior)."),
@@ -122,15 +128,16 @@ class AdapterSetting(Patch):
"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 {}),
"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),
"controller_models": ap.may_use_render_models,
**{k: ap.is_gles for k in ("equirect_emul", "equirect_face", "equirect_res", "equirect_flip", "equirect_fps",
"equirect_stereo")},
**{k: ap.is_gles for k in ("equirect_emul", "equirect_face", "equirect_res", "equirect_flip",
"equirect_fps", "equirect_stereo")},
}
rule = rules.get(self.key)
return bool(rule(a)) if rule else True
@@ -167,7 +174,8 @@ class DeviceFiles(Patch):
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):
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"}})
@@ -181,7 +189,8 @@ class DeviceFiles(Patch):
class LeptonEnv(Patch):
id = "device.lepton_env"
title = "Extra Lepton environment"
description = "Environment variables exported by the launcher (e.g. VK_INSTANCE_LAYERS=\"\" to test without Valve's layers)."
description = ("Environment variables exported by the launcher (e.g. VK_INSTANCE_LAYERS=\"\" to test without "
"Valve's layers).")
category = "device"
stage = "install"
params = [Param("env", "text", {}, "NAME -> value")]
+2 -1
View File
@@ -357,7 +357,8 @@ def prepare_as_is(package: str, reporter: Reporter) -> dict:
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')")
"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)
+2 -1
View File
@@ -164,7 +164,8 @@ def entry_from_library(g: dict, status: str = "works", notes: str | None = None,
return None
return CatalogEntry(
**common, as_is=r.as_is,
overport_extra=[p for p in r.patches if (c := cat(p)) and c.category == "overport" and p not in DEFAULT_OVERPORT],
overport_extra=[p for p in r.patches
if (c := cat(p)) and c.category == "overport" and p not in DEFAULT_OVERPORT],
overport_remove=[p for p in DEFAULT_OVERPORT if p not in r.patches],
alt_overport=r.alt_patches, use_alt=r.use_alt,
frame=[p for p in r.patches if (c := cat(p)) and c.category == "frame" and not c.default_on],
+2 -1
View File
@@ -62,7 +62,8 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
recipe.alt_patches = ["patch_remove_unreal_force_quit"] # build a fallback in case it quits itself
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."
recipe.notes = ("32-bit only: the Steam Frame has no AArch32 support. Consider the PC (Rift) version "
"via Revive.")
elif analysis.no_arm64:
recipe.status = "unsupported"
recipe.notes = f"No 64-bit ARM code ({', '.join(analysis.abis)}): the Steam Frame can't run it."
+6 -3
View File
@@ -6,7 +6,8 @@ from pathlib import Path
from .recommend import catalog
LABEL = {"works": "Works", "issues": "Works with issues", "unsupported": "Does not work on the Frame", "unknown": "Untested"}
LABEL = {"works": "Works", "issues": "Works with issues", "unsupported": "Does not work on the Frame",
"unknown": "Untested"}
def render() -> str:
@@ -14,7 +15,8 @@ def render() -> str:
by = {k: [e for e in entries if e.status == k] for k in LABEL}
out = ["# Steam Frame conversions: status", "",
f"Generated by FramePort from its catalog on {time.strftime('%Y-%m-%d')}. "
f"**{len(by['works'])} work · {len(by['issues'])} work with issues · {len(by['unsupported'])} do not work**", ""]
f"**{len(by['works'])} work · {len(by['issues'])} work with issues · "
f"{len(by['unsupported'])} do not work**", ""]
for k in ("works", "issues", "unsupported"):
if not by[k]:
continue
@@ -32,7 +34,8 @@ def render() -> str:
out.append(line)
out.append("")
out += ["All games also get the overport defaults and the automatic Frame fixes (FrameBridge adapter, launcher "
"category, platform stubs/compat, telemetry stubs) when they need them. See docs/PLAYBOOK.md in FramePort.", ""]
"category, platform stubs/compat, telemetry stubs) when they need them. See docs/PLAYBOOK.md in "
"FramePort.", ""]
return "\n".join(out)
+2 -1
View File
@@ -301,7 +301,8 @@ class PcReviveTarget(Target):
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]}]")
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):
+107 -54
View File
@@ -24,7 +24,8 @@ from . import theme as T
from .components import install_state # noqa: F401 (re-exported: tests and older callers import it from here)
from .jobs import Job, JobManager
NAV = [("library", tr("Library"), ft.Icons.GRID_VIEW_ROUNDED), ("frame", tr("Steam Frame"), ft.Icons.VIEW_IN_AR_ROUNDED),
NAV = [("library", tr("Library"), ft.Icons.GRID_VIEW_ROUNDED),
("frame", tr("Steam Frame"), ft.Icons.VIEW_IN_AR_ROUNDED),
("files", tr("Files"), ft.Icons.FOLDER_OPEN_ROUNDED), ("settings", tr("Settings"), ft.Icons.TUNE_ROUNDED)]
POLL_SECONDS = 30
@@ -70,8 +71,9 @@ class FramePortApp:
self.activity = ActivityPanel(self)
sidebar = ft.Container(ft.Column([
ft.Container(ft.Row([
ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(18), color=T.ON_ACCENT), width=T.px(32), height=T.px(32),
border_radius=T.px(9), bgcolor=T.ACCENT, alignment=ft.Alignment.CENTER),
ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(18), color=T.ON_ACCENT),
width=T.px(32), height=T.px(32), border_radius=T.px(9), bgcolor=T.ACCENT,
alignment=ft.Alignment.CENTER),
ft.Text(tr("FramePort"), size=T.px(17), weight=ft.FontWeight.W_800, color=T.TEXT)], spacing=T.S3),
padding=ft.Padding(T.S2, T.S2, 0, T.S5)),
self.nav_col,
@@ -92,9 +94,9 @@ class FramePortApp:
# ================================================================== shell
def top_bar(self, heading: str, subtitle: str = "", actions: list[ft.Control] | None = None) -> ft.Control:
return ft.Row([ft.Column([C.title(heading), C.body(subtitle)] if subtitle else [C.title(heading)], spacing=T.px(2),
expand=True),
*(actions or [])], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3)
heads = [C.title(heading), C.body(subtitle)] if subtitle else [C.title(heading)]
return ft.Row([ft.Column(heads, spacing=T.px(2), expand=True), *(actions or [])],
vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3)
def _build_sidebar_controls(self) -> None:
"""The sidebar's controls are created once and only their properties change: rebuilding them while a job
@@ -105,8 +107,9 @@ class FramePortApp:
tx = ft.Text(label, size=T.px(14), weight=ft.FontWeight.W_500, color=T.TEXT_2, expand=True)
badge = C.dot(T.OK, 7)
badge.visible = False
box = ft.Container(ft.Row([ic, tx, badge], spacing=T.S3), padding=ft.Padding(T.S3, T.px(10), T.S3, T.px(10)),
border_radius=T.RADIUS_SM, ink=True, on_click=lambda e, k=key: self.go(k))
box = ft.Container(ft.Row([ic, tx, badge], spacing=T.S3),
padding=ft.Padding(T.S3, T.px(10), T.S3, T.px(10)), border_radius=T.RADIUS_SM, ink=True,
on_click=lambda e, k=key: self.go(k))
nav[key] = (box, ic, tx, badge)
self.nav_col.controls = [v[0] for v in nav.values()]
# activity card: a "running" layout and an "idle" layout, switched by visibility
@@ -116,8 +119,9 @@ class FramePortApp:
self._act_bar = C.progress_bar(None)
self._act_stage = C.meta("", max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
self._act_running = ft.Column([
ft.Row([ft.ProgressRing(width=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT), self._act_title,
self._act_pct], spacing=T.S2), self._act_bar, self._act_stage], spacing=T.px(6), visible=False)
ft.Row([ft.ProgressRing(width=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT),
self._act_title, self._act_pct], spacing=T.S2), self._act_bar, self._act_stage],
spacing=T.px(6), visible=False)
self._act_idle_text = C.meta(tr("No activity"), expand=True, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
self._act_idle = ft.Row([ft.Icon(ft.Icons.HISTORY_ROUNDED, size=T.px(16), color=T.TEXT_3), self._act_idle_text],
spacing=T.S2)
@@ -160,7 +164,8 @@ class FramePortApp:
self._act_pct.value = (f"{cur.fraction:.0%}" if cur.fraction is not None else "") + \
(f" · {cur.speed.split(' · ')[0]}" if cur.speed else "")
self._act_bar.value = cur.fraction
self._act_stage.value = (cur.stage or tr("Starting…")) + (tr(" · {queued} more queued").format(queued=queued) if queued else "")
more = tr(" · {queued} more queued").format(queued=queued) if queued else ""
self._act_stage.value = (cur.stage or tr("Starting…")) + more
else:
recent = next((j for j in self.jobs.recent(1)), None)
self._act_idle_text.value = tr("No activity") if not recent else \
@@ -176,7 +181,8 @@ class FramePortApp:
self._conn_line.color = color
if st == "connected" and self.frame_info:
pr = (self.frame_info.get("proton") or {}).get("ready")
self._conn_extra.content.value = (tr("Quest ✓") if self.frame_info.get("lepton") else tr("Quest ✗")) + " " + \
quest = tr("Quest ✓") if self.frame_info.get("lepton") else tr("Quest ✗")
self._conn_extra.content.value = quest + " " + \
(tr("PC VR ✓") if pr else tr("PC VR —"))
self._conn_extra.visible = True
else:
@@ -301,7 +307,8 @@ class FramePortApp:
if job.kind in ("install", "test", "uninstall", "tool-frame") and self.target:
self.refresh_frame(quiet=True)
if job.kind == "tool-frame" and self.route[0] == "files" and self.files_view is not None:
self.files_view.load() # show what an upload added # re-renders when the Frame's list changed (e.g. after an uninstall)
# show what an upload added # re-renders when the Frame's list changed (e.g. after an uninstall)
self.files_view.load()
if job.kind == "install" and job.package and job.package in self._records():
self._record(job.package, to=job.to, state="paused" if job.state == "cancelled" else "failed",
error=job.error, stage=job.stage)
@@ -319,7 +326,8 @@ class FramePortApp:
on_action=(lambda e: self.open_game(pkg)) if pkg and self.route[:2] != ("game", pkg)
else (lambda e: self.show_activity(True)))
elif job.state == "failed":
self.toast(tr("{title} failed: {error}").format(title=job.title, error=job.error), error=True, action=tr("Details"),
msg = tr("{title} failed: {error}").format(title=job.title, error=job.error)
self.toast(msg, error=True, action=tr("Details"),
on_action=lambda e: self.show_activity(True))
self.refresh_view()
if job.kind == "scan" and self.exe_queue:
@@ -378,10 +386,12 @@ class FramePortApp:
pkg = g["package"]
connected = self.frame_state == "connected"
st = C.install_state(g, self.frame_info)
frame_label = {"installed": tr("Reinstall on Frame"), "outdated": tr("Update on Frame")}.get(st, tr("Install on Frame"))
frame_label = {"installed": tr("Reinstall on Frame"),
"outdated": tr("Update on Frame")}.get(st, tr("Install on Frame"))
blocked = (g.get("recipe") or {}).get("status") == "unsupported" and g.get("kind") != "rift"
if blocked and connected: # known blocker: still allowed (e.g. to try a fix), after a warning
frame_label = {"installed": tr("Reinstall anyway"), "outdated": tr("Update anyway")}.get(st, tr("Install anyway"))
frame_label = {"installed": tr("Reinstall anyway"),
"outdated": tr("Update anyway")}.get(st, tr("Install anyway"))
return [(frame_label, ft.Icons.WARNING_AMBER_ROUNDED, lambda e: self.install_blocked(pkg), False,
tr("Marked \"Can't run\": ") + ((g.get("recipe") or {}).get("notes") or tr("a known blocker")))]
frame_opt = (frame_label, ft.Icons.VIEW_IN_AR_ROUNDED, lambda e: self.install(pkg, "frame"), False, None) \
@@ -393,7 +403,9 @@ class FramePortApp:
on_pc = pkg in self.pc_installs()
stale = on_pc and C.pc_outdated(g, self.pc_installs()[pkg])
pc_opt = (tr("Update on this PC") if stale else tr("Reinstall on this PC") if on_pc else tr("Install on this PC"),
pc_label = (tr("Update on this PC") if stale else tr("Reinstall on this PC") if on_pc
else tr("Install on this PC"))
pc_opt = (pc_label,
ft.Icons.COMPUTER_ROUNDED, lambda e: self.install(pkg, "pc"), not winhost.available(),
tr("The launch settings changed since it was installed: update the Steam shortcut") if stale else
None if winhost.available() else tr("Needs Windows (or WSL on Windows)"))
@@ -420,7 +432,9 @@ class FramePortApp:
if to == "frame":
self.toast(tr("Starting {title} on the Frame — put the headset on").format(title=title))
elif res.get("steamvr") is False:
self.toast(tr("Starting {title}, but SteamVR isn't running — it may open as a flat window. Start SteamVR and relaunch.").format(title=title), error=True)
msg = tr("Starting {title}, but SteamVR isn't running — it may open as a flat window. "
"Start SteamVR and relaunch.").format(title=title)
self.toast(msg, error=True)
else:
self.toast(tr("Starting SteamVR and {title} — put your headset on").format(title=title))
self.run_bg(work)
@@ -496,7 +510,8 @@ class FramePortApp:
out.append((tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, lambda e: self.find_artwork(pkg)))
if not job and self.frame_state == "connected" and \
C.install_state(g, self.frame_info) in ("installed", "outdated"):
out.append((tr("Update Steam art on Frame"), ft.Icons.WALLPAPER_ROUNDED, lambda e: self.update_steam_art(pkg)))
out.append((tr("Update Steam art on Frame"), ft.Icons.WALLPAPER_ROUNDED,
lambda e: self.update_steam_art(pkg)))
out.append((tr("Refresh store details"), ft.Icons.SYNC_ROUNDED, lambda e: self.refresh_details(pkg)))
if not job:
out.append((tr("Rebuild only (no install)") if not rift else tr("Check game files"), ft.Icons.BUILD_ROUNDED,
@@ -527,10 +542,12 @@ class FramePortApp:
games = [library.game(p) for p in pkgs]
games = [g for g in games if g and not self.jobs.busy_with(g["package"])]
skipped = [g for g in games if to == "pc" and g.get("kind") != "rift" or
not allow_blocked and g.get("kind") != "rift" and (g.get("recipe") or {}).get("status") == "unsupported"]
not allow_blocked and g.get("kind") != "rift"
and (g.get("recipe") or {}).get("status") == "unsupported"]
games = [g for g in games if g not in skipped]
if not games:
self.toast(tr("Nothing to install") + (tr(" ({len} can't be installed there)").format(len=len(skipped)) if skipped else ""))
why = tr(" ({len} can't be installed there)").format(len=len(skipped)) if skipped else ""
self.toast(tr("Nothing to install") + why)
return
need_exe = [g["package"] for g in games if g.get("kind") == "rift" and g.get("exe_confirmed") is False]
@@ -557,7 +574,8 @@ class FramePortApp:
keep = {p for p, b in boxes.items() if b.value}
games = [g for g in games if g not in oculus or g["package"] in keep]
if not games:
self.toast(tr("Nothing to install on the Frame — those Oculus games need PC mode (SteamVR + Revive)."))
self.toast(tr("Nothing to install on the Frame — "
"those Oculus games need PC mode (SteamVR + Revive)."))
return
ask_license()
self.page.show_dialog(ft.AlertDialog(
@@ -574,7 +592,8 @@ class FramePortApp:
def ask_license():
from ..core import winhost
sdk = [g for g in games if g.get("kind") == "rift" and (g["analysis"].get("extra") or {}).get("platform_sdk")]
sdk = [g for g in games
if g.get("kind") == "rift" and (g["analysis"].get("extra") or {}).get("platform_sdk")]
if not sdk or to == "pc" and winhost.oculus_platform_dir():
return go([g["package"] for g in games]) # the Meta Horizon app provides the Platform SDK here
frame = to == "frame"
@@ -604,7 +623,8 @@ class FramePortApp:
for p in final:
self._submit_install(p, to)
if len(final) > 1:
self.toast(tr("Queued {len} installs — they run one after another in the background").format(len=len(final)),
msg = tr("Queued {len} installs — they run one after another in the background").format(len=len(final))
self.toast(msg,
action=tr("Activity"), on_action=lambda e: self.show_activity(True))
if self.library_view:
self.library_view.set_select_mode(False)
@@ -625,7 +645,8 @@ class FramePortApp:
why = tr("Failed: {error}").format(error=job.error)
else:
why = tr("Installed, but it didn't start properly: ") + (
fatal[0]["diagnosis"] if fatal else tr("it stopped at '{value}'").format(value=s.get('milestone') or 'the start'))
fatal[0]["diagnosis"] if fatal
else tr("it stopped at '{value}'").format(value=s.get('milestone') or 'the start'))
buttons = []
if pkg in recs:
buttons.append(C.primary(tr("Resume"), ft.Icons.PLAY_ARROW_ROUNDED,
@@ -649,7 +670,8 @@ class FramePortApp:
n = len(jobs)
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(tr_n("{n} game didn't work out", "{n} games didn't work out", n) if n > 1 else
tr("{value} didn't work out").format(value=self._title(jobs[0].package) if jobs[0].package else jobs[0].title),
tr("{value} didn't work out").format(
value=self._title(jobs[0].package) if jobs[0].package else jobs[0].title),
weight=ft.FontWeight.W_600),
content=ft.Container(ft.Column(rows, spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True), width=T.px(600),
height=min(160 * n + 20, 520)),
@@ -731,18 +753,24 @@ class FramePortApp:
self._record(pkg, remove=True) # installed; the launch test below is a separate question
if to == "pc": # a PC launch test would start the game on the user's desktop — skip it
rep.stage("Installed")
return (tr("{get} is installed on this PC — launch it from your Steam library or the Play button (SteamVR starts with it)").format(get=g.get('title')))
return (tr("{get} is installed on this PC — launch it from your Steam library or the Play button "
"(SteamVR starts with it)").format(get=g.get('title')))
rep.check_cancel()
if not library.setting("install.launch_test", True): # Settings → Installing
rep.stage("Installed")
return (tr("{get} is installed on {where}: put the headset on and launch it from your Steam library (automatic launch test is off in Settings)").format(get=g.get('title'), where=where))
return (tr("{get} is installed on {where}: put the headset on and launch it from your Steam library "
"(automatic launch test is off in Settings)").format(get=g.get('title'), where=where))
summary = pipeline.test_game(pkg, target, rep)
job.summary, job.to, job.log_path = summary, to, summary.get("log_path")
rep.stage(f"Launch test: {'passed' if summary['verdict'] == 'pass' else summary['verdict']}")
ok = summary["verdict"] == "pass"
return (tr("{get} is ready on {where}: put the headset on and launch it from your Steam library").format(get=g.get('title'), where=where)
if ok else tr("{get} is installed on {where}, but the launch test needs a look").format(get=g.get('title'), where=where))
job = self.submit(tr("Install {title} on {value}").format(title=self._title(pkg), value='Frame' if to == 'frame' else 'this PC'), run, pkg,
return (tr("{get} is ready on {where}: put the headset on and launch it from your Steam library")
.format(get=g.get('title'), where=where)
if ok else tr("{get} is installed on {where}, but the launch test needs a look")
.format(get=g.get('title'), where=where))
job_title = tr("Install {title} on {value}").format(title=self._title(pkg),
value='Frame' if to == 'frame' else 'this PC')
job = self.submit(job_title, run, pkg,
"install")
job.to = to
return job
@@ -783,7 +811,8 @@ class FramePortApp:
self.install_many([pkg], "frame", allow_blocked=True)
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("Install {title} anyway?").format(title=self._title(pkg))), bgcolor=T.SURFACE_2,
content=ft.Column([C.body(tr("This game is marked \"Can't run\" on the Steam Frame:"), T.TEXT_2), C.body(notes),
content=ft.Column([C.body(tr("This game is marked \"Can't run\" on the Steam Frame:"), T.TEXT_2),
C.body(notes),
C.body(tr("Install it anyway to try it, e.g. with different patches."), T.TEXT_2)],
tight=True, width=T.px(520)),
actions=[C.ghost(tr("Cancel"), on_click=lambda e: self.page.pop_dialog()),
@@ -800,7 +829,8 @@ class FramePortApp:
res, _ = target.launch_test(pkg, job.reporter)
summary = {"verdict": res.verdict, "milestone": res.milestone, "suggestions": []}
job.summary, job.to, job.log_path = summary, to, summary.get("log_path")
return tr("{title}: launch test {verdict} (furthest: {value})").format(title=title, verdict=summary['verdict'], value=summary.get('milestone') or '—')
return tr("{title}: launch test {verdict} (furthest: {value})").format(
title=title, verdict=summary['verdict'], value=summary.get('milestone') or '—')
return self.submit(tr("Launch test: {title}").format(title=title), run, pkg, "test")
def update_steam_art(self, pkg: str) -> Job:
@@ -818,8 +848,10 @@ class FramePortApp:
def uninstall(self, pkg: str, to: str = "frame") -> None:
title = self._title(pkg)
text = (tr("Removes {title}'s game files from the Frame. Saves are kept; the Steam entry disappears after the next Steam restart.").format(title=title)) if to == "frame" else \
tr("Removes {title} from this PC's Steam library (Steam restarts once). The game folder isn't touched.").format(title=title)
text = (tr("Removes {title}'s game files from the Frame. Saves are kept; the Steam entry disappears "
"after the next Steam restart.").format(title=title)) if to == "frame" else \
tr("Removes {title} from this PC's Steam library (Steam restarts once). "
"The game folder isn't touched.").format(title=title)
def run(job: Job):
self._target_for(to).uninstall(pkg, keep_data=True)
@@ -832,7 +864,8 @@ class FramePortApp:
title = self._title(pkg)
C.confirm(self.page, tr("Remove {title} from the library?").format(title=title),
tr("Only FramePort's entry is removed. Your game files and anything installed stay."), tr("Remove"),
lambda: (library.remove_game(pkg), self.go("library"), self.toast(tr("Removed {title}").format(title=title))), danger=True)
lambda: (library.remove_game(pkg), self.go("library"),
self.toast(tr("Removed {title}").format(title=title))), danger=True)
def _target_for(self, to: str):
if to == "pc":
@@ -880,7 +913,8 @@ class FramePortApp:
self.open_url(pipeline.problem_report(pkg, report, path, info if target is self.target else None))
return tr("Saved {name}: drag it into the GitHub issue that just opened").format(name=path.name)
return tr("Saved {name} (in {parent})").format(name=path.name, parent=path.parent)
self.submit(tr("Collect logs: {title}").format(title=self._title(pkg)) if pkg else tr("Collect app logs"), run, pkg, "diag")
job_title = tr("Collect logs: {title}").format(title=self._title(pkg)) if pkg else tr("Collect app logs")
self.submit(job_title, run, pkg, "diag")
def report_problem_dialog(self, pkg: str | None = None) -> None:
text = ft.TextField(label=tr("What happens? (optional: you can also write it on GitHub)"), multiline=True,
@@ -890,7 +924,8 @@ class FramePortApp:
self.page.pop_dialog()
self.collect_logs(pkg, text.value or "")
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("Report a problem · {title}").format(title=self._title(pkg)) if pkg else tr("Report a problem")),
title=ft.Text(tr("Report a problem · {title}").format(title=self._title(pkg)) if pkg
else tr("Report a problem")),
bgcolor=T.SURFACE_2,
content=ft.Column([
C.body(tr("FramePort saves a diagnostics zip (logs, recipe, device info; no game files, personal data "
@@ -933,10 +968,12 @@ class FramePortApp:
C.body(tr("Opens a prefilled GitHub issue with this game's recipe, so it can join the built-in catalog "
"(no account token needed; you review and submit it on GitHub). No game files or personal "
"data are sent."), T.TEXT_2),
*([C.body(tr("Last launch test: {get} · furthest: {value}").format(get=last.get('verdict'), value=last.get('milestone') or '—'),
*([C.body(tr("Last launch test: {get} · furthest: {value}").format(
get=last.get('verdict'), value=last.get('milestone') or '—'),
T.TEXT_3)] if last else []),
ft.Row([status, C.help_icon("share_config")]), notes, played,
C.body(tr("Launch tests run without the headset worn, so only you can confirm the picture and controls."),
C.body(tr("Launch tests run without the headset worn, "
"so only you can confirm the picture and controls."),
T.TEXT_3),
], tight=True, spacing=T.S3, width=T.px(600)),
actions=[C.ghost(tr("Cancel"), on_click=lambda e: self.page.pop_dialog()), send]))
@@ -954,17 +991,20 @@ class FramePortApp:
if target is None:
self.toast(tr("Connect your Frame first"), error=True)
return
self.run_bg(lambda: self.toast(tr("Saved: {settings}").format(settings=target.set_settings(package, vals)['settings'])))
self.run_bg(lambda: self.toast(
tr("Saved: {settings}").format(settings=target.set_settings(package, vals)['settings'])))
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("Adapter settings · {title}").format(title=self._title(package))), bgcolor=T.SURFACE_2,
content=ft.Column([C.body(tr("Only filled-in values change. Restart the game afterwards.")),
ft.Row(list(fields.values()), wrap=True, width=T.px(640))], tight=True,
scroll=ft.ScrollMode.AUTO),
actions=[C.ghost(tr("Cancel"), on_click=lambda e: self.page.pop_dialog()), C.primary(tr("Save"), on_click=save)]))
actions=[C.ghost(tr("Cancel"), on_click=lambda e: self.page.pop_dialog()),
C.primary(tr("Save"), on_click=save)]))
# ================================================================== library
async def pick_folder(self, e=None):
path = await ft.FilePicker().get_directory_path(dialog_title=tr("Folder with VR games (Android APKs or PC VR games)"))
path = await ft.FilePicker().get_directory_path(
dialog_title=tr("Folder with VR games (Android APKs or PC VR games)"))
if path:
self.scan(path)
@@ -1032,7 +1072,8 @@ class FramePortApp:
self.route = ("library",)
n = len(added)
if only_new:
return tr_n("{n} new game in {name}", "{n} new games in {name}", n, name=Path(path).name) if added else \
return tr_n("{n} new game in {name}", "{n} new games in {name}", n,
name=Path(path).name) if added else \
tr("No new games in {name}").format(name=Path(path).name)
return tr_n("Added {n} game", "Added {n} games", n) if added else tr("No games found in that folder")
return self.submit(tr("Scan {name}").format(name=Path(path).name), run, None, "scan")
@@ -1057,7 +1098,8 @@ class FramePortApp:
def run(job: Job):
d = pipeline.fetch_details(pkg, job.reporter)
n = len(d.get("screenshots") or [])
return tr("{title}: details from {value}").format(title=self._title(pkg), value=', '.join(d.get('sources') or []) or 'nowhere') + \
sources = ', '.join(d.get('sources') or []) or 'nowhere'
return tr("{title}: details from {value}").format(title=self._title(pkg), value=sources) + \
(tr(", {n} screenshots").format(n=n) if n else "")
return self.submit(tr("Store details: {title}").format(title=self._title(pkg)), run, pkg, "art")
@@ -1219,7 +1261,8 @@ class FramePortApp:
def install_lepton(self):
def run(job: Job):
r = self.target.install_lepton()
return tr("Lepton is installed") if r.get("installed") else r.get("hint") or tr("Asked Steam to install Lepton")
return tr("Lepton is installed") if r.get("installed") else \
r.get("hint") or tr("Asked Steam to install Lepton")
self.submit(tr("Install Lepton on the Frame"), run, kind="tool-frame")
def install_proton(self):
@@ -1277,7 +1320,8 @@ class FramePortApp:
op.refresh(ov.list_patches)
except Exception: # noqa: BLE001
pass
return tr("Tools are up to date") if not todo else tr("Installed {join}").format(join=', '.join(s.name for s in todo))
return tr("Tools are up to date") if not todo else \
tr("Installed {join}").format(join=', '.join(s.name for s in todo))
return self.submit(tr("Update tools") if update else tr("Get FramePort ready"), run, kind="tools",
open_panel=not quiet)
@@ -1290,7 +1334,9 @@ class FramePortApp:
backup_dir = un.default_backup_dir()
shown_dir = winhost.to_windows(backup_dir) if winhost.is_wsl() else str(backup_dir)
cb_keys = ft.Checkbox(label=tr("Back up the signing keys ({len}) to {shown_dir} first").format(len=len(pl.keys), shown_dir=shown_dir),
keys_label = tr("Back up the signing keys ({len}) to {shown_dir} first").format(len=len(pl.keys),
shown_dir=shown_dir)
cb_keys = ft.Checkbox(label=keys_label,
value=bool(pl.keys), active_color=T.ACCENT)
cb_frame = ft.Checkbox(label=tr("Also remove FramePort's games and files from the Frame") +
("" if connected else tr(" (connect the Frame first)")), value=connected,
@@ -1299,13 +1345,19 @@ class FramePortApp:
frame_items = [i for i in pl.items if i.kind == "frame"]
steam_items = [i for i in pl.items if i.kind == "steam"]
other = [i for i in pl.items if i.kind == "pc"]
lines = [C.body(f"• {i.what}" + (tr(" — {value:.1f} GiB").format(value=i.size / 2**30) if i.size > 2**28 else ""), T.TEXT_2)
lines = [C.body(f"• {i.what}" + (tr(" — {value:.1f} GiB").format(value=i.size / 2**30)
if i.size > 2**28 else ""), T.TEXT_2)
for i in other]
if steam_items:
lines.append(C.body(tr_n("• {n} Steam shortcut on this PC for PC VR games", "• {n} Steam shortcuts on this PC for PC VR games", len(steam_items)), T.TEXT_2))
lines.append(C.body(tr_n("• {n} Steam shortcut on this PC for PC VR games",
"• {n} Steam shortcuts on this PC for PC VR games", len(steam_items)), T.TEXT_2))
if frame_items:
size = sum(i.size for i in frame_items) / 2**30
lines.append(C.body(tr_n("• On the Frame (if selected below): {n} game ({size}), its Steam entry and FramePort's files", "• On the Frame (if selected below): {n} games ({size}), their Steam entries and FramePort's files", len(frame_items), size=fmt_size(size * 2**30, 0)), T.TEXT_2))
lines.append(C.body(tr_n("• On the Frame (if selected below): {n} game ({size}), "
"its Steam entry and FramePort's files",
"• On the Frame (if selected below): {n} games ({size}), "
"their Steam entries and FramePort's files",
len(frame_items), size=fmt_size(size * 2**30, 0)), T.TEXT_2))
def go(e):
self.page.pop_dialog()
@@ -1326,8 +1378,8 @@ class FramePortApp:
title=ft.Text(tr("Uninstall FramePort?"), weight=ft.FontWeight.W_600),
content=ft.Container(ft.Column([C.body(tr("This removes:"), T.TEXT), *lines, ft.Container(height=T.S2),
cb_keys, cb_frame, cb_saves,
C.meta(tr("Signing keys matter: game updates must be signed with the same key "
"or their saves are lost on reinstall."))],
C.meta(tr("Signing keys matter: game updates must be signed with "
"the same key or their saves are lost on reinstall."))],
spacing=T.S2, tight=True, scroll=ft.ScrollMode.AUTO), width=T.px(560)),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
actions=[C.ghost(tr("Cancel"), on_click=lambda e: self.page.pop_dialog()),
@@ -1345,7 +1397,8 @@ class FramePortApp:
modal=True, title=ft.Text(tr("FramePort was removed"), weight=ft.FontWeight.W_600),
content=ft.Container(ft.Column([
C.body(tr("All of FramePort's data on this PC is gone") +
(tr(", and your signing keys were saved to {backup}.").format(backup=out['backup']) if out.get("backup") else ".")),
(tr(", and your signing keys were saved to {backup}.").format(backup=out['backup'])
if out.get("backup") else ".")),
C.body(tr("To finish, close FramePort and delete its program folder.")),
], spacing=T.S2, tight=True), width=T.px(520)),
bgcolor=T.SURFACE_2, actions=[C.primary(tr("Close FramePort"), on_click=close)]))
+7 -4
View File
@@ -174,12 +174,14 @@ def _shape(radius=None):
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:
padding = (ft.Padding(T.px(22), T.px(18), T.px(22), T.px(18)) if big
else ft.Padding(T.px(16), T.px(12), T.px(16), T.px(12)))
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(T.px(22), T.px(18), T.px(22), T.px(18)) if big else ft.Padding(T.px(16), T.px(12), T.px(16), T.px(12)),
padding=padding,
text_style=ft.TextStyle(size=T.px(15) if big else T.px(13),
weight=ft.FontWeight.W_600)))
@@ -235,8 +237,8 @@ def callout(text: str | ft.Control, kind: str = "info", icon: str | None = None)
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=T.px(40), color=T.ACCENT), width=T.px(84), height=T.px(84), border_radius=T.px(42),
bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
ft.Container(ft.Icon(icon, size=T.px(40), color=T.ACCENT), width=T.px(84), height=T.px(84),
border_radius=T.px(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=T.px(460)),
@@ -273,7 +275,8 @@ def art_fill(src: str | None, radius: int | None = None, placeholder_icon: str =
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=T.px(220) if hero else T.px(56), color=T.soft(T.ACCENT, 0.22 if hero else 0.45))
icon = ft.Icon(placeholder_icon, size=T.px(220) if hero else T.px(56),
color=T.soft(T.ACCENT, 0.22 if hero else 0.45))
return ft.Container(ft.Container(icon, padding=ft.Padding(0, 0, T.px(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,
+3 -2
View File
@@ -87,8 +87,9 @@ HELP: dict[str, str] = _Translated({
"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.",
"ui_scale": "Size of text and layout. Automatic follows Windows' display scaling halfway (150 % in Windows → 125 %) "
"when FramePort runs under WSL, where the window doesn't get it from Windows itself.",
"ui_scale": "Size of text and layout. Automatic follows Windows' display scaling halfway "
"(150 % in Windows → 125 %) when FramePort runs under WSL, where the window doesn't get it from "
"Windows itself.",
"app_updates": "FramePort checks GitHub for a new release every few hours and shows it in the sidebar and the "
"Library. \"Update now\" downloads it, checks its checksum (and on Windows its signature), closes "
"FramePort and opens the new version; your games, settings and Frame connection are kept. With "
+21 -10
View File
@@ -77,7 +77,8 @@ class Updater:
self.preparing = True
try:
updates.prepare(up)
self.app.toast(tr("FramePort {version} is downloaded and installs the next time FramePort starts").format(version=up.version),
self.app.toast(tr("FramePort {version} is downloaded and installs the next time FramePort starts")
.format(version=up.version),
action=tr("Restart now"), on_action=lambda e: self.install())
except Exception as exc: # noqa: BLE001
applog.log.warning("automatic update download failed: %s", exc)
@@ -106,13 +107,16 @@ class Updater:
page.pop_dialog()
self.install()
page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("FramePort {version} is available").format(version=up.version), color=T.TEXT, weight=ft.FontWeight.W_600), bgcolor=T.SURFACE_2,
title=ft.Text(tr("FramePort {version} is available").format(version=up.version), color=T.TEXT,
weight=ft.FontWeight.W_600),
bgcolor=T.SURFACE_2,
content=ft.Container(ft.Column([
C.body(tr("You have {version}. {how}").format(version=__version__, how=how), T.TEXT_2),
ft.Container(ft.Markdown(notes, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
md_style_sheet=_notes_style(),
on_tap_link=lambda e: page.launch_url(e.data)),
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE, border=ft.Border.all(1, T.BORDER)),
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE,
border=ft.Border.all(1, T.BORDER)),
], spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True, horizontal_alignment=ft.CrossAxisAlignment.STRETCH),
width=T.px(560), height=T.px(min(420, 130 + 26 * len(notes.splitlines())))),
actions=[C.ghost(tr("Skip this version"), on_click=later),
@@ -129,11 +133,13 @@ class Updater:
if kind == "bundle":
target = updates.bundle_root()
if not target or not updates.can_replace(target):
self.app.toast(tr("FramePort can't replace itself in {value} (no permission): opening the release page to download it").format(value=target or 'this folder'), error=True)
self.app.toast(tr("FramePort can't replace itself in {value} (no permission): opening the release "
"page to download it").format(value=target or 'this folder'), error=True)
page.launch_url(up.page)
return
if kind == "source" and updates.source_is_dirty():
self.app.toast(tr("This source checkout has uncommitted changes: commit or stash them, then update"), error=True)
self.app.toast(tr("This source checkout has uncommitted changes: commit or stash them, then update"),
error=True)
return
if page.web:
self.app.toast(tr("Updating works in the desktop app"))
@@ -152,15 +158,18 @@ class Updater:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=updates.UPGRADE_TIMEOUT,
env=updates.upgrade_env(), stdin=subprocess.DEVNULL)
except subprocess.TimeoutExpired:
raise RuntimeError(tr("{value} took too long; update by hand: {join}").format(value=cmd[0], join=' '.join(cmd))) from None
raise RuntimeError(tr("{value} took too long; update by hand: {join}")
.format(value=cmd[0], join=' '.join(cmd))) from None
for line in (out.stdout + out.stderr).splitlines()[-20:]:
rep.log(line)
if out.returncode:
raise RuntimeError(tr("{value} failed ({returncode})").format(value=cmd[0], returncode=out.returncode))
raise RuntimeError(tr("{value} failed ({returncode})")
.format(value=cmd[0], returncode=out.returncode))
self.restart = self._restart_process
rep.stage("Restarting")
return tr("FramePort {version} is ready: restarting").format(version=up.version)
self.app.submit(tr("Update FramePort to {version}").format(version=up.version), run, None, kind="app-update", open_panel=True)
self.app.submit(tr("Update FramePort to {version}").format(version=up.version), run, None, kind="app-update",
open_panel=True)
if self.app.jobs.current() and self.app.jobs.current().kind != "app-update":
self.app.toast(tr("FramePort updates and restarts after the current job"))
@@ -202,7 +211,8 @@ def _notes_style() -> ft.MarkdownStyleSheet:
p_text_style=text(T.TEXT_2), list_bullet_text_style=text(T.TEXT_2),
strong_text_style=text(weight=ft.FontWeight.W_600), em_text_style=text(T.TEXT_2, italic=True),
a_text_style=text(T.ACCENT), code_text_style=text(T.TEXT, 13, font_family="monospace"),
h1_text_style=text(size=18, weight=ft.FontWeight.W_600), h2_text_style=text(size=16, weight=ft.FontWeight.W_600),
h1_text_style=text(size=18, weight=ft.FontWeight.W_600),
h2_text_style=text(size=16, weight=ft.FontWeight.W_600),
h3_text_style=text(size=15, weight=ft.FontWeight.W_600), blockquote_text_style=text(T.TEXT_2))
@@ -212,7 +222,8 @@ def library_bar(app: FramePortApp) -> ft.Control | None:
if not up:
return None
return C.callout(ft.Row([
C.body(tr("FramePort {version} is available (you have {version2}).").format(version=up.version, version2=__version__), T.TEXT, expand=True),
C.body(tr("FramePort {version} is available (you have {version2}).")
.format(version=up.version, version2=__version__), T.TEXT, expand=True),
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED, lambda e: app.updater.install()),
C.ghost(tr("What's new"), on_click=lambda e: app.updater.show_dialog()),
C.ghost(tr("Later"), on_click=lambda e: (updates.skip(up.version), app.updater._set(None))),
+4 -2
View File
@@ -53,7 +53,8 @@ class ActivityPanel:
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),
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
@@ -114,7 +115,8 @@ class ActivityPanel:
ft.Column([C.body(job.title, T.TEXT, weight=ft.FontWeight.W_600, max_lines=2,
overflow=ft.TextOverflow.ELLIPSIS), meta],
spacing=T.px(2), expand=True),
*([C.icon_btn(ft.Icons.CLOSE_ROUNDED, tr("Cancel"), lambda e: self.app.jobs.cancel(job))] if job.active else []),
*([C.icon_btn(ft.Icons.CLOSE_ROUNDED, tr("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:
+11 -5
View File
@@ -21,7 +21,8 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
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(T.px(12), T.px(8), T.px(12), T.px(8)), text_size=T.T_BODY)
results = ft.GridView(max_extent=T.px(190), child_aspect_ratio=0.8, spacing=T.S3, run_spacing=T.S3, height=T.px(400))
results = ft.GridView(max_extent=T.px(190), child_aspect_ratio=0.8, spacing=T.S3, run_spacing=T.S3,
height=T.px(400))
status = C.meta("")
def pick(choice):
@@ -29,13 +30,16 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
def work():
if not sources.apply_choice(package, choice):
app.toast(tr("No artwork could be downloaded from that {source} result; the current artwork stays. Try another one.").format(source=choice['source']), error=True)
app.toast(tr("No artwork could be downloaded from that {source} result; the current artwork stays. "
"Try another one.").format(source=choice['source']), 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(tr("Artwork updated for {get}. The Frame's Steam library still shows the old art.").format(get=g.get('title')), action=tr("Update on Frame"), on_action=lambda e: app.update_steam_art(package))
app.toast(tr("Artwork updated for {get}. The Frame's Steam library still shows the old art.")
.format(get=g.get('title')),
action=tr("Update on Frame"), on_action=lambda e: app.update_steam_art(package))
else:
app.toast(tr("Artwork updated for {get}").format(get=g.get('title')))
app.run_bg(work)
@@ -53,7 +57,8 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
C.meta(r["source"]),
], spacing=T.px(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 = tr("{len} results").format(len=len(found)) if found else tr("Nothing found. Try a shorter or different name.")
status.value = (tr("{len} results").format(len=len(found)) if found
else tr("Nothing found. Try a shorter or different name."))
C.update(status, results)
app.run_bg(work)
@@ -67,7 +72,8 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
src = found.get("source")
if src and src != "none" and C.install_state(library.game(package), app.frame_info) in ("installed",
"outdated"):
app.toast(tr("Artwork updated ({src}). The Frame's Steam library still shows the old art.").format(src=src),
app.toast(tr("Artwork updated ({src}). The Frame's Steam library still shows the old art.")
.format(src=src),
action=tr("Update on Frame"), on_action=lambda e: app.update_steam_art(package))
else:
app.toast(tr("Artwork: {value}").format(value=src or 'none found'))
+25 -13
View File
@@ -271,7 +271,8 @@ class FilesView:
else T.TEXT_2)))
android = loc.get("android")
rel = posixpath.relpath(self.path, loc["path"]) if self.path != loc["path"] else ""
self.where.value = (tr("Games see this folder as {value}").format(value=posixpath.join(android, rel) if rel else android)
self.where.value = (tr("Games see this folder as {value}")
.format(value=posixpath.join(android, rel) if rel else android)
if android else self.path)
rows = []
self.checks = {}
@@ -284,7 +285,8 @@ class FilesView:
self.listing.controls = rows
n_dirs = sum(e.is_dir for e in self.entries)
size = sum(e.size for e in self.entries if not e.is_dir)
self.status.value = ", ".join([tr_n("{n} folder", "{n} folders", n_dirs), tr_n("{n} file", "{n} files", len(self.entries) - n_dirs), human(size)])
self.status.value = ", ".join([tr_n("{n} folder", "{n} folders", n_dirs),
tr_n("{n} file", "{n} files", len(self.entries) - n_dirs), human(size)])
self._update_selection(render=False)
for c in (self.crumb_row, self.where, self.listing, self.status):
C.update(c)
@@ -296,10 +298,13 @@ class FilesView:
padding=ft.Padding(T.S3, T.px(8), T.S3, T.px(8)), border_radius=T.RADIUS_SM, ink=True,
on_click=lambda ev: self.cd(posixpath.dirname(self.path)))
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(e.mtime)) if e.mtime else ""
info = ("folder" if e.is_dir else human(e.size)) + (tr(" · link") if e.link else "") + (f" · {when}" if when else "")
actions = [C.icon_btn(ft.Icons.DOWNLOAD_ROUNDED, tr("Download to this PC"), lambda ev, x=e: self.download([x]))]
info = (("folder" if e.is_dir else human(e.size)) + (tr(" · link") if e.link else "")
+ (f" · {when}" if when else ""))
actions = [C.icon_btn(ft.Icons.DOWNLOAD_ROUNDED, tr("Download to this PC"),
lambda ev, x=e: self.download([x]))]
if not self._protected(e):
actions += [C.icon_btn(ft.Icons.DRIVE_FILE_RENAME_OUTLINE_ROUNDED, tr("Rename"), lambda ev, x=e: self.rename(x)),
actions += [C.icon_btn(ft.Icons.DRIVE_FILE_RENAME_OUTLINE_ROUNDED, tr("Rename"),
lambda ev, x=e: self.rename(x)),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Delete"), lambda ev, x=e: self.delete([x]))]
check = ft.Checkbox(value=e.path in self.selected, active_color=T.ACCENT, check_color=T.ON_ACCENT,
on_change=lambda ev, p=e.path: self._toggle(p, ev.control.value),
@@ -345,7 +350,8 @@ class FilesView:
def _update_selection(self, render: bool = True) -> None:
n = len(self.selected)
size = sum(x.size for x in self.entries if x.path in self.selected and not x.is_dir)
self.sel_label.value = tr("{n} selected").format(n=n) + (tr(" · {human} in files").format(human=human(size)) if size else "")
self.sel_label.value = (tr("{n} selected").format(n=n)
+ (tr(" · {human} in files").format(human=human(size)) if size else ""))
self.sel_bar.visible = bool(n)
self.select_all.value = bool(self.entries) and n == len([x for x in self.entries if self._selectable(x)])
if render:
@@ -389,8 +395,10 @@ class FilesView:
from ...install import files
sent, skipped, total = files.upload(app.target.frame, paths, dest, job.reporter)
return tr_n("Uploaded {n} file", "Uploaded {n} files", len(sent)) + (tr_n(", {n} already there", ", {n} already there", len(skipped)) if skipped else "")
app.submit(tr("Upload to {label}").format(label=loc['label']), run, loc.get("package"), kind="tool-frame", open_panel=True)
return (tr_n("Uploaded {n} file", "Uploaded {n} files", len(sent))
+ (tr_n(", {n} already there", ", {n} already there", len(skipped)) if skipped else ""))
app.submit(tr("Upload to {label}").format(label=loc['label']), run, loc.get("package"), kind="tool-frame",
open_panel=True)
async def download(self, items: list) -> None:
folder = await ft.FilePicker().get_directory_path(dialog_title=tr("Download to which folder on this PC?"))
@@ -402,8 +410,10 @@ class FilesView:
from ...install import files
r = files.download(app.target.frame, root, [x.path for x in items], Path(folder), job.reporter)
return tr_n("Downloaded {n} file to {folder}", "Downloaded {n} files to {folder}", r["files"], folder=r["folder"])
app.submit(tr("Download {name}").format(name=items[0].name) + (tr_n(" and {n} more", " and {n} more", len(items) - 1) if len(items) > 1 else ""), run,
return tr_n("Downloaded {n} file to {folder}", "Downloaded {n} files to {folder}", r["files"],
folder=r["folder"])
more = tr_n(" and {n} more", " and {n} more", len(items) - 1) if len(items) > 1 else ""
app.submit(tr("Download {name}").format(name=items[0].name) + more, run,
None, kind="tool-frame", open_panel=True)
def new_folder(self) -> None:
@@ -411,13 +421,15 @@ class FilesView:
lambda files, frame: files.make_dir(frame, self.loc["path"], self.path, name)))
def rename(self, e) -> None:
self._ask_name(tr("Rename {name}").format(name=e.name), tr("New name"), e.name, tr("Rename"), lambda name: self._fs(
lambda files, frame: files.rename(frame, self.loc["path"], e.path, name)))
self._ask_name(tr("Rename {name}").format(name=e.name), tr("New name"), e.name, tr("Rename"),
lambda name: self._fs(
lambda files, frame: files.rename(frame, self.loc["path"], e.path, name)))
def delete(self, items: list) -> None:
names = ", ".join(x.name for x in items[:3]) + (" …" if len(items) > 3 else "")
what = tr("folder and everything in it") if any(x.is_dir for x in items) else "file"
C.confirm(self.app.page, tr("Delete {names}?").format(names=names), tr("This deletes the {what} on the Frame. It can't be undone.").format(what=what),
C.confirm(self.app.page, tr("Delete {names}?").format(names=names),
tr("This deletes the {what} on the Frame. It can't be undone.").format(what=what),
tr("Delete"), lambda: (self.selected.difference_update(x.path for x in items),
self._fs(lambda files, frame: files.delete(frame, self.loc["path"],
[x.path for x in items]))),
+19 -8
View File
@@ -82,7 +82,8 @@ def human(n: int) -> str:
def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
status = C.meta(tr("Reading the file list from the Frame…"))
body = ft.Column([ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT), status],
ring = ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT)
body = ft.Column([ft.Row([ring, status],
spacing=T.S2)], spacing=T.S3, expand=True)
dialog = ft.AlertDialog(
title=ft.Text(tr("Files on the Frame — {title}").format(title=title), weight=ft.FontWeight.W_600),
@@ -100,19 +101,22 @@ def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
rows = ft.ListView(spacing=0, expand=True)
search = ft.TextField(hint_text=tr("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(T.px(12), T.px(8), T.px(12), T.px(8)),
focused_border_color=T.ACCENT,
content_padding=ft.Padding(T.px(12), T.px(8), T.px(12), T.px(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, T.px(3), T.px(8), T.px(3))
if isinstance(node, int):
return ft.Container(C.meta(tr("… {node} more (use the filter to find them)").format(node=node)), padding=pad)
return ft.Container(C.meta(tr("… {node} more (use the filter to find them)").format(node=node)),
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=T.px(16), color=T.TEXT_3) if node.is_dir else ft.Container(width=T.px(16))
info = tr("{human} · {files} files").format(human=human(node.size), files=node.files) if node.is_dir else human(node.size)
info = (tr("{human} · {files} files").format(human=human(node.size), files=node.files) if node.is_dir
else human(node.size))
return ft.Container(ft.Row([
chevron, ft.Icon(icon, size=T.px(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),
@@ -130,7 +134,8 @@ def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
ft.Container(expand=True),
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, tr("Copy path"), lambda e, p=r["path"]: app.copy(p)),
], spacing=T.S2), padding=ft.Padding(T.px(8), T.S2, T.px(8), T.px(2))))
controls.append(ft.Container(C.meta(r["path"], selectable=True), padding=ft.Padding(T.px(8), 0, T.px(8), T.px(4))))
controls.append(ft.Container(C.meta(r["path"], selectable=True),
padding=ft.Padding(T.px(8), 0, T.px(8), T.px(4))))
if text:
found = matches(r["files"], text)
controls += [ft.Container(ft.Row([
@@ -138,7 +143,8 @@ def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
C.body(rel, T.TEXT, expand=True, selectable=True), C.meta(human(size))], spacing=T.px(6)),
padding=ft.Padding(T.px(8), T.px(3), T.px(8), T.px(3))) for rel, size in found]
if not found:
controls.append(ft.Container(C.meta(tr("No matching files")), padding=ft.Padding(T.px(8), T.px(3), T.px(8), T.px(3))))
controls.append(ft.Container(C.meta(tr("No matching files")),
padding=ft.Padding(T.px(8), T.px(3), T.px(8), T.px(3))))
else:
visible = visible_rows(tree, {k.split("\0", 1)[1] for k in expanded
if k.split("\0", 1)[0] == r["path"]})
@@ -154,13 +160,18 @@ def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
head: list[ft.Control] = []
total = sum(t.size for _, t in trees)
count = sum(t.files for _, t in trees)
summary = tr("{count} files · {human}").format(count=count, human=human(total)) + (tr(" (list cut off)") if result.get("truncated") else "")
summary = (tr("{count} files · {human}").format(count=count, human=human(total))
+ (tr(" (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 = tr_n("\n… and {n} more", "\n… and {n} more", len(missing) - 8) if len(missing) > 8 else ""
head.append(C.callout(tr_n("{n} file is missing or incomplete compared to what was uploaded. Install the game again to re-send it.\n{lines}{more}", "{n} files are missing or incomplete compared to what was uploaded. Install the game again to re-send them.\n{lines}{more}", len(missing), lines=lines, more=more), "warn"))
head.append(C.callout(tr_n("{n} file is missing or incomplete compared to what was uploaded. "
"Install the game again to re-send it.\n{lines}{more}",
"{n} files are missing or incomplete compared to what was uploaded. "
"Install the game again to re-send them.\n{lines}{more}",
len(missing), lines=lines, more=more), "warn"))
elif result.get("kind") == "pcvr":
head.append(C.callout(tr("Every uploaded file is present with the right size."), "ok"))
body.controls = [ft.Row([search, C.meta(summary)], spacing=T.S3), *head, rows]
+32 -18
View File
@@ -25,13 +25,16 @@ class FrameView:
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=T.px(34), color=T.ACCENT), width=T.px(72), height=T.px(72),
border_radius=T.px(18), bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(34), color=T.ACCENT), width=T.px(72),
height=T.px(72), border_radius=T.px(18), bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
ft.Column([
ft.Row([C.title(info.get("hostname") or t.label, 22), C.pill(tr("Connected"), T.OK, ft.Icons.CIRCLE)],
spacing=T.S3),
C.body(tr("{user}@{host} · {get} {get2} (build {get3})").format(user=t.target.user, host=t.target.host, get=info.get('os'), get2=info.get('os_version'), get3=info.get('build_id'))),
C.meta(tr("{free:.0f} GiB free · {len} games installed").format(free=free, len=len(info.get('installed') or []))),
C.body(tr("{user}@{host} · {get} {get2} (build {get3})")
.format(user=t.target.user, host=t.target.host, get=info.get('os'),
get2=info.get('os_version'), get3=info.get('build_id'))),
C.meta(tr("{free:.0f} GiB free · {len} games installed")
.format(free=free, len=len(info.get('installed') or []))),
], spacing=T.px(4), expand=True),
ft.Column([
C.secondary(tr("Refresh"), ft.Icons.REFRESH_ROUNDED, lambda e: app.refresh_frame()),
@@ -44,7 +47,8 @@ class FrameView:
rows = []
lepton = info.get("lepton")
rows.append(C.status_row(bool(lepton), tr("Quest games (Lepton)"),
tr("Ready") if lepton else tr("Valve's Android runtime isn't installed (needs Developer Mode)"),
tr("Ready") if lepton
else tr("Valve's Android runtime isn't installed (needs Developer Mode)"),
None if lepton else C.secondary(tr("Install"), ft.Icons.DOWNLOAD_ROUNDED,
lambda e: app.install_lepton()),
help="lepton"))
@@ -54,7 +58,8 @@ class FrameView:
rows.append(C.status_row(
bool(ready), tr("PC VR games (Proton)"),
tr("{display_name} installed").format(display_name=ready['display_name']) if ready else
(tr("{display_name} can be installed (about 1 GiB; Steam restarts once)").format(display_name=sug['display_name']) if sug else
(tr("{display_name} can be installed (about 1 GiB; Steam restarts once)")
.format(display_name=sug['display_name']) if sug else
tr("Not offered by Steam on this Frame yet")),
C.secondary(tr("Test"), ft.Icons.SCIENCE_OUTLINED, lambda e: app.test_proton()) if ready else
C.primary(tr("Install"), ft.Icons.DOWNLOAD_ROUNDED, lambda e: app.install_proton(), disabled=not sug),
@@ -68,7 +73,8 @@ class FrameView:
f"{keys['keys']}/{keys['max_keys']} kernel keys used"
+ (tr(" · restart the Frame soon to reset it") if high else ""),
help="kernel_keys"))
return C.section(tr("Ready to play"), C.card(ft.Column(rows, spacing=0), padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)))
return C.section(tr("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)."""
@@ -106,7 +112,8 @@ class FrameView:
art = thumbs.url(pkg, ("icon", "square", "portrait"), 96)
size = d.get("apk_size", 0)
sub = (tr("PC VR · Proton") + (tr(" + Revive") if d.get("revive") else "")) if pcvr else tr("Quest")
sub += tr(" · {value:.1f} GiB").format(value=size / 2**30) if size >= 2**30 else tr(" · {value:.0f} MiB").format(value=size / 2**20)
sub += (tr(" · {value:.1f} GiB").format(value=size / 2**30) if size >= 2**30
else tr(" · {value:.0f} MiB").format(value=size / 2**20))
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([
@@ -179,32 +186,38 @@ class FrameView:
line = app.pairing.one_liner
pair_box.controls = [
C.body(tr("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=T.px(12), color=T.TEXT,
expand=True),
ft.Container(ft.Row([ft.Text(line, font_family="monospace", selectable=True, size=T.px(12),
color=T.TEXT, expand=True),
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, tr("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=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT),
C.meta(tr("Waiting for your Frame… (code {code})").format(code=app.pairing.code))], spacing=T.S2),
C.meta(tr("Waiting for your Frame… (code {code})").format(code=app.pairing.code))],
spacing=T.S2),
C.meta(tr("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(T.px(12), T.px(10), T.px(12), T.px(10)), text_size=T.T_BODY)
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(10), T.px(12), T.px(10)),
text_size=T.T_BODY)
addr = ft.TextField(hint_text=tr("steamos@frame.local or an IP address"), width=T.px(320), **style)
pw = ft.TextField(hint_text=tr("Password (first time only)"), password=True, can_reveal_password=True, width=T.px(240),
tooltip=C.tip(HELP["password"]), **style)
pw = ft.TextField(hint_text=tr("Password (first time only)"), password=True, can_reveal_password=True,
width=T.px(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(tr("{label} ({host}) isn't reachable. Make sure it's switched on and on the same network.").format(label=saved[0].label, host=saved[0].host), T.TEXT, expand=True),
unreachable = tr("{label} ({host}) isn't reachable. Make sure it's switched on and on the same "
"network.").format(label=saved[0].label, host=saved[0].host)
offline = C.callout(ft.Row([C.body(unreachable, T.TEXT, expand=True),
C.secondary(tr("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=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT),
C.body(tr("Connecting to {value}…").format(value=saved[0].label if saved else 'your Frame'), T.TEXT)],
connecting = tr("Connecting to {value}…").format(value=saved[0].label if saved else 'your Frame')
offline = C.callout(ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
color=T.ACCENT),
C.body(connecting, T.TEXT)],
spacing=T.S3), "info")
import time as _time
@@ -227,7 +240,8 @@ class FrameView:
C.secondary(tr("Show setup command"), ft.Icons.TERMINAL_ROUNDED, pair), pair_box,
help="first_time_setup"),
step(3, tr("Enter the address"), tr("If you know the Frame's address."),
ft.Row([addr, pw, C.primary(tr("Connect"), on_click=lambda e: app.connect_manual(addr.value, pw.value))],
ft.Row([addr, pw, C.primary(tr("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)
+50 -29
View File
@@ -20,8 +20,9 @@ from ..help import HELP
if TYPE_CHECKING:
from ..app import FramePortApp
CATEGORY_TITLES = {"frame": tr("Steam Frame patches"), "overport": tr("OVRPort patches"), "adapter": tr("Adapter settings"),
"device": tr("Files and environment on the Frame"), "pcvr": tr("PC VR (Revive / Proton)")}
CATEGORY_TITLES = {"frame": tr("Steam Frame patches"), "overport": tr("OVRPort patches"),
"adapter": tr("Adapter settings"), "device": tr("Files and environment on the Frame"),
"pcvr": tr("PC VR (Revive / Proton)")}
def _ago(t: float | None) -> str:
@@ -88,7 +89,8 @@ class GameView:
pct = (f" {job.fraction:.0%}" if job.fraction is not None else "") + \
(f" · {job.speed.split(' · ')[0]}" if job.speed else "")
return ft.Row([
ft.FilledButton(content=ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ON_ACCENT),
ft.FilledButton(content=ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
color=T.ON_ACCENT),
ft.Text(f"{job.stage or 'Queued'}{pct}", color=T.ON_ACCENT,
weight=ft.FontWeight.W_600)], spacing=T.px(10), tight=True),
on_click=lambda e: app.show_activity(True),
@@ -104,8 +106,8 @@ class GameView:
C.install_state(g, app.frame_info) in ("installed", "outdated"):
buttons.append(C.secondary(tr("Add videos"), ft.Icons.VIDEO_LIBRARY_OUTLINED,
lambda e: app.go("files", pkg), False,
tr("Opens this player's storage on the Frame (Files tab): upload videos into the "
"folder it lists")))
tr("Opens this player's storage on the Frame (Files tab): upload videos "
"into the folder it lists")))
more = ft.PopupMenuButton(icon=ft.Icons.MORE_HORIZ_ROUNDED, icon_color=T.TEXT_2, bgcolor=T.SURFACE_2,
tooltip=tr("More actions"), items=C.menu_items(app.game_actions(pkg, quick=False)))
return ft.Row(buttons + [more], spacing=T.S2, wrap=True)
@@ -139,7 +141,8 @@ class GameView:
tr("Needs the Oculus Platform (Meta Horizon app) for its license check, which the Frame doesn't "
"have — it crashes at startup there. Play it on this PC.") if rift_platform else
tr("Uses the repack's bundled Revive — experimental on the Frame") if rift_repack and not last else
tr("Last launch test: {get} · furthest: {value}").format(get=last.get('verdict'), value=last.get('milestone') or '—') if last
tr("Last launch test: {get} · furthest: {value}")
.format(get=last.get('verdict'), value=last.get('milestone') or '—') if last
else tr("Runs directly — no Revive needed") if self.rift else "")
cards.append(self.target_card(
ft.Icons.VIEW_IN_AR_ROUNDED, tr("Steam Frame"), frame_line, frame_color, frame_sub,
@@ -154,7 +157,8 @@ class GameView:
(tr("In your Steam library · launch settings changed — update it") if C.pc_outdated(g, dep) else
tr("In your Steam library")) if dep else tr("Not installed"),
(T.WARN if C.pc_outdated(g, dep) else T.PC) if dep else T.TEXT_3,
(tr("Revive {value} · {value2} backend").format(value=dep.get('revive_version') or '', value2=dep.get('backend') or 'openxr')
(tr("Revive {value} · {value2} backend")
.format(value=dep.get('revive_version') or '', value2=dep.get('backend') or 'openxr')
if dep.get("revive_win") else tr("The repack's own Revive · runs the game directly")
if dep.get("launch") == "repack" else tr("Runs the game directly")) if dep else "",
[C.icon_btn(ft.Icons.SCIENCE_OUTLINED, tr("Launch test on this PC"),
@@ -165,8 +169,8 @@ class GameView:
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=T.px(22)), width=T.px(44), height=T.px(44), border_radius=T.px(10),
bgcolor=T.soft(color, 0.14), alignment=ft.Alignment.CENTER),
ft.Container(ft.Icon(icon, color=color, size=T.px(22)), width=T.px(44), height=T.px(44),
border_radius=T.px(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=T.px(2), expand=True),
*buttons,
@@ -180,25 +184,29 @@ class GameView:
extra = g["analysis"].get("extra", {})
if self.rift and g.get("exe_confirmed") is False:
out.append(C.callout(ft.Row([
C.body(tr("FramePort picked {value} to start this game, but there are other candidates. Check it before installing.").format(value=g.get('exe', '').rsplit('/', 1)[-1]), T.TEXT, expand=True),
C.body(tr("FramePort picked {value} to start this game, but there are other candidates. "
"Check it before installing.").format(value=g.get('exe', '').rsplit('/', 1)[-1]),
T.TEXT, expand=True),
C.secondary(tr("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(tr("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"))
out.append(C.callout(tr("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 tr("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(tr("PC VR alternative: {pcvr_alternative}").format(pcvr_alternative=entry.pcvr_alternative), "pc"))
out.append(C.callout(tr("PC VR alternative: {pcvr_alternative}")
.format(pcvr_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(tr("Also in your library: {value} version").format(value='Rift' if other_rift else 'Quest'),
links.append(C.ghost(tr("Also in your library: {value} version")
.format(value='Rift' if other_rift else 'Quest'),
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:
@@ -231,7 +239,8 @@ class GameView:
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=T.px(2))
"release_date": "Released"}[k]), C.body(v, T.TEXT)],
spacing=T.px(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=T.px(6), wrap=True))
@@ -307,13 +316,16 @@ class GameView:
field = ft.TextField(hint_text=tr("Add a tag"), dense=True, width=T.px(150), text_size=T.T_META,
border_radius=T.px(20), bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), on_submit=add)
focused_border_color=T.ACCENT,
content_padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(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=T.px(13), color=T.TEXT_2)], spacing=T.px(4), tight=True),
bgcolor=T.ACCENT_SOFT, border_radius=T.px(20), padding=ft.Padding(T.px(10), T.px(5), T.px(8), T.px(5)),
ft.Icon(ft.Icons.CLOSE_ROUNDED, size=T.px(13), color=T.TEXT_2)],
spacing=T.px(4), tight=True),
bgcolor=T.ACCENT_SOFT, border_radius=T.px(20),
padding=ft.Padding(T.px(10), T.px(5), T.px(8), T.px(5)),
on_click=lambda e, t=t: remove(t), tooltip=tr("Remove tag"))
for t in mine]
chips += [ft.Container(C.meta(t), border=ft.Border.all(1, T.BORDER), border_radius=T.px(20),
@@ -341,20 +353,23 @@ class GameView:
elif recipe.source == "user":
lead, icon, color = tr("Your custom recipe"), ft.Icons.TUNE_ROUNDED, T.ACCENT
else:
lead, icon, color = tr("Suggested by FramePort from the game's engine and APIs"), ft.Icons.AUTO_AWESOME_ROUNDED, \
T.ACCENT
lead, icon, color = tr("Suggested by FramePort from the game's engine and APIs"), \
ft.Icons.AUTO_AWESOME_ROUNDED, T.ACCENT
chips = [ft.Container(C.body(tr(p.title), T.TEXT, size=T.T_META),
tooltip=C.tip(tr(recipe.reasons.get(p.id) or p.description)),
bgcolor=T.SURFACE_3, border_radius=T.px(6), padding=ft.Padding(T.px(10), T.px(5), T.px(10), T.px(5)))
bgcolor=T.SURFACE_3, border_radius=T.px(6),
padding=ft.Padding(T.px(10), T.px(5), T.px(10), T.px(5)))
for p in visible]
base_count = len(on) - len(visible)
if base_count > 0:
chips.append(C.with_help(C.meta(tr("+ {base_count} standard patches").format(base_count=base_count)), "standard_fixes"))
chips.append(C.with_help(C.meta(tr("+ {base_count} standard patches").format(base_count=base_count)),
"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 = (tr("Your copy is used as it is (only the Frame's copy gets launch patches)") +
(tr(" · Revive provides the Oculus runtime") if "pcvr.revive" in recipe.patches else "")), \
(tr(" · 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 = tr("Installs the game exactly as it is: no patches (your copy is already patched)"), \
@@ -467,7 +482,9 @@ class GameView:
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(tr("{count} on").format(count=count))], spacing=T.S2), padding=ft.Padding(T.S4, T.S3, T.S4, T.S3)),
C.help_icon(f"cat_{cat}"), C.meta(tr("{count} on").format(count=count))],
spacing=T.S2),
padding=ft.Padding(T.S4, T.S3, T.S4, T.S3)),
*rows], spacing=0), padding=0))
def set_alt(e):
@@ -480,7 +497,8 @@ class GameView:
"alt_build"))
if hidden:
top.append(C.with_help(C.switch(
label=tr("Show all patches ({len} don't apply to this game)").format(len=len(hidden)), value=self.show_all,
label=tr("Show all patches ({len} don't apply to this game)").format(len=len(hidden)),
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))
@@ -493,9 +511,11 @@ class GameView:
if self.rift:
rows += [C.kv(tr("Folder"), g.get("game_dir") or ""), C.kv(tr("Executable"), g.get("exe") or ""),
C.kv(tr("Type"), f"{a['abis'][0]} · {a['graphics']}"),
C.kv(tr("Size"), tr("{value:.1f} GiB").format(value=(a.get('extra', {}).get('data_bytes') or 0) / 2**30))]
C.kv(tr("Size"), tr("{value:.1f} GiB")
.format(value=(a.get('extra', {}).get('data_bytes') or 0) / 2**30))]
else:
rows += [C.kv(tr("Version"), a.get("version") or ""), C.kv(tr("ABIs"), ", ".join(a.get("abis") or []), "abis"),
rows += [C.kv(tr("Version"), a.get("version") or ""),
C.kv(tr("ABIs"), ", ".join(a.get("abis") or []), "abis"),
C.kv(tr("Graphics"), a.get("graphics") or "", "graphics"), C.kv(tr("APK"), g.get("apk") or ""),
C.kv(tr("Data"), tr("{value:.1f} GiB").format(value=(g.get('data_bytes') or 0) / 2**30)
+ (f" · {g.get('data_dir')}" if g.get("data_dir") else ""))]
@@ -509,7 +529,8 @@ class GameView:
def build(self) -> ft.Control:
if not self.g:
return C.empty_state(ft.Icons.SEARCH_OFF_ROUNDED, tr("Game not found"), tr("It was removed from the library."),
return C.empty_state(ft.Icons.SEARCH_OFF_ROUNDED, tr("Game not found"),
tr("It was removed from the library."),
C.primary(tr("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(),
+40 -22
View File
@@ -169,7 +169,8 @@ class LibraryView:
self.search = ft.TextField(
value=self.f["q"], hint_text=tr("Search games and tags"), prefix_icon=ft.Icons.SEARCH_ROUNDED, dense=True,
width=T.px(260), border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)), text_size=T.T_BODY,
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)),
text_size=T.T_BODY,
on_change=self._on_search)
app.search_field = self.search
self.filters = ft.Container()
@@ -179,27 +180,30 @@ class LibraryView:
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=T.px(18)),
ft.Text(tr("Add games"), color=T.ON_ACCENT, weight=ft.FontWeight.W_600, size=T.px(13))],
ft.Text(tr("Add games"), color=T.ON_ACCENT, weight=ft.FontWeight.W_600,
size=T.px(13))],
spacing=T.px(6), tight=True),
bgcolor=T.ACCENT, border_radius=T.RADIUS_SM, padding=ft.Padding(T.px(14), T.px(9), T.px(16), T.px(9))),
bgcolor=T.ACCENT, border_radius=T.RADIUS_SM,
padding=ft.Padding(T.px(14), T.px(9), T.px(16), T.px(9))),
items=[ft.PopupMenuItem(content=ft.Text(tr("Scan a folder…")), icon=ft.Icons.FOLDER_OPEN_ROUNDED,
on_click=app.pick_folder),
ft.PopupMenuItem(content=ft.Text(tr("Add one game folder…")), icon=ft.Icons.CREATE_NEW_FOLDER_ROUNDED,
on_click=app.pick_game_folder),
ft.PopupMenuItem(content=ft.Text(tr("Add one game folder…")),
icon=ft.Icons.CREATE_NEW_FOLDER_ROUNDED, on_click=app.pick_game_folder),
ft.PopupMenuItem(content=ft.Text(tr("Add an APK file…")), icon=ft.Icons.ANDROID_ROUNDED,
on_click=app.pick_apk)],
bgcolor=T.SURFACE_2, tooltip="")
self.rescan_btn = C.secondary(tr("Rescan folders"), ft.Icons.REFRESH_ROUNDED, app.rescan,
tooltip=C.tip(HELP["rescan"]))
self.update_all_btn = C.secondary(tr("Update all"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED, lambda e: app.update_all(),
tooltip=C.tip(HELP["update_all"]))
self.update_all_btn = C.secondary(tr("Update all"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED,
lambda e: app.update_all(), tooltip=C.tip(HELP["update_all"]))
self.update_all_btn.visible = False
self.select_btn = C.secondary(tr("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(tr("Library"), size=T.T_TITLE, weight=ft.FontWeight.W_700, color=T.TEXT,
no_wrap=True), self.subtitle], spacing=T.px(2), expand=True), self.search,
self.update_all_btn, self.rescan_btn, self.select_btn, add], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3),
self.update_all_btn, self.rescan_btn, self.select_btn, add],
vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3),
self.update_bar, self.resume_bar, self.hint, self.filters, self.body, self.sel_bar,
], expand=True, spacing=T.S4)
# skeleton cards until the first batch arrives
@@ -233,8 +237,9 @@ class LibraryView:
if not games:
self.body.content = C.empty_state(
ft.Icons.LIBRARY_ADD_ROUNDED, tr("Add your games"),
tr("Point FramePort at a folder with Android games (APK + OBB, e.g. Quest games) or PC VR games (one folder "
"per game, or a folder of them). It finds them, works out what each needs and fetches artwork."),
tr("Point FramePort at a folder with Android games (APK + OBB, e.g. Quest games) or PC VR games "
"(one folder per game, or a folder of them). It finds them, works out what each needs and "
"fetches artwork."),
C.primary(tr("Scan a folder"), ft.Icons.FOLDER_OPEN_ROUNDED, self.app.pick_folder, big=True),
C.secondary(tr("Add an APK file"), ft.Icons.ANDROID_ROUNDED, self.app.pick_apk))
self.cards.clear()
@@ -298,14 +303,16 @@ class LibraryView:
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(tr("{n} selected").format(n=n) if n else tr("Select games to install"), T.TEXT, weight=ft.FontWeight.W_600),
C.body(tr("{n} selected").format(n=n) if n else tr("Select games to install"), T.TEXT,
weight=ft.FontWeight.W_600),
ft.Container(expand=True),
C.ghost(tr("Select all shown"), on_click=lambda e: self.select_visible()),
C.ghost(tr("Clear"), on_click=lambda e: (self.selected.clear(), self.set_select_mode(True))),
*([C.secondary(tr("Install {len} on this PC").format(len=len(rift)), ft.Icons.COMPUTER_ROUNDED,
lambda e: app.install_many(sorted(rift), "pc"), disabled=not winhost.available())]
if rift else []),
C.primary(tr("Install {n} on Frame").format(n=n) if n else tr("Install on Frame"), ft.Icons.VIEW_IN_AR_ROUNDED,
C.primary(tr("Install {n} on Frame").format(n=n) if n else tr("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 tr("Connect your Frame first")),
@@ -330,7 +337,11 @@ class LibraryView:
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(tr_n("{n} install didn't finish: {names}. What was already copied is kept, so resuming continues where it stopped.", "{n} installs didn't finish: {names}. What was already copied is kept, so resuming continues where it stopped.", len(pending), names=names), T.TEXT, expand=True),
C.body(tr_n("{n} install didn't finish: {names}. What was already copied is kept, so resuming "
"continues where it stopped.",
"{n} installs didn't finish: {names}. What was already copied is kept, so resuming "
"continues where it stopped.",
len(pending), names=names), T.TEXT, expand=True),
C.primary(tr("Resume"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: app.resume_installs()),
C.ghost(tr("Dismiss"), on_click=lambda e: app.forget_installs()),
], spacing=T.S3), "warn", ft.Icons.PAUSE_CIRCLE_OUTLINE_ROUNDED)
@@ -399,7 +410,8 @@ class LibraryView:
self.subtitle.value = " · ".join(parts)
updates = len(self.app.updatable())
self.update_all_btn.visible = updates > 0
self.update_all_btn.content = tr("Update all ({updates})").format(updates=updates) if updates else tr("Update all")
self.update_all_btn.content = (tr("Update all ({updates})").format(updates=updates) if updates
else tr("Update all"))
C.update(self.update_all_btn)
self._update_hint()
has_rift = any(g.get("kind") == "rift" for g in games)
@@ -410,14 +422,16 @@ class LibraryView:
self._seg("where", where),
self._seg("platform", [("all", "All"), ("quest", "Android"), ("pcvr", "PC VR")]) if has_rift else
ft.Container(),
self._menu_chip("status", tr("Status"), [("all", tr("Any")), ("works", tr("Works")), ("issues", tr("Works with issues")),
("unknown", tr("Untested")), ("unsupported", tr("Can't run"))]),
self._menu_chip("status", tr("Status"), [("all", tr("Any")), ("works", tr("Works")),
("issues", tr("Works with issues")),
("unknown", tr("Untested")), ("unsupported", tr("Can't run"))]),
C.help_icon("status"),
self._tag_menu(games),
ft.Container(expand=True),
self.count,
self._menu_chip("sort", tr("Sort"), [("name", tr("Name")), ("recent", tr("Recently added")), ("played", tr("Recently used")),
("status", tr("Status")), ("size", tr("Size"))]),
self._menu_chip("sort", tr("Sort"), [("name", tr("Name")), ("recent", tr("Recently added")),
("played", tr("Recently used")),
("status", tr("Status")), ("size", tr("Size"))]),
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
self.filters.visible = bool(games)
C.update(self.subtitle, self.hint, self.filters)
@@ -428,7 +442,8 @@ class LibraryView:
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(T.px(12), T.px(6), T.px(12), T.px(6)), border_radius=T.px(20), bgcolor=T.SURFACE_3 if on else None,
padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), border_radius=T.px(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=T.px(2), tight=True), padding=T.px(3), border_radius=T.px(22),
border=ft.Border.all(1, T.BORDER))
@@ -439,7 +454,8 @@ class LibraryView:
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=T.px(16), color=T.TEXT_2)],
spacing=T.px(4), tight=True),
padding=ft.Padding(T.px(12), T.px(7), T.px(8), T.px(7)), border_radius=T.px(20), border=ft.Border.all(1, T.BORDER)),
padding=ft.Padding(T.px(12), T.px(7), T.px(8), T.px(7)), border_radius=T.px(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="")
@@ -458,8 +474,10 @@ class LibraryView:
items.append(ft.PopupMenuItem(content=ft.Text(tr("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=T.px(14), color=T.TEXT_2), C.meta(tr("Tags:")),
C.body(label if len(label) < 28 else tr("{len} selected").format(len=len(chosen)), T.TEXT,
content=ft.Container(ft.Row([ft.Icon(ft.Icons.SELL_OUTLINED, size=T.px(14), color=T.TEXT_2),
C.meta(tr("Tags:")),
C.body(label if len(label) < 28
else tr("{len} selected").format(len=len(chosen)), T.TEXT,
size=T.T_META),
ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED, size=T.px(16), color=T.TEXT_2)],
spacing=T.px(4), tight=True),
+27 -14
View File
@@ -34,7 +34,8 @@ class SettingsView:
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'}" + (tr(" (update: {latest})").format(latest=s.latest) if newer else "")
detail += (f" · {s.version or 'installed'}"
+ (tr(" (update: {latest})").format(latest=s.latest) if newer else ""))
if s.name == "revive" and "using" in (s.detail or ""):
detail += " · " + s.detail.split("·")[-1].strip()
else:
@@ -49,17 +50,20 @@ class SettingsView:
from ...core import winhost
if not winhost.available():
self.pc.controls = [C.status_row(None, tr("Windows not detected"),
tr("PC VR games can run on this PC only with Windows (or WSL on Windows)"))]
self.pc.controls = [C.status_row(
None, tr("Windows not detected"),
tr("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"]), tr("Steam"), tr("Found") if d["steam"] else tr("Steam for Windows not found")),
C.status_row(bool(d["steam"]), tr("Steam"),
tr("Found") if d["steam"] else tr("Steam for Windows not found")),
C.status_row(d["steamvr"] or None, tr("SteamVR"),
tr("Installed") if d["steamvr"] else tr("Install SteamVR from Steam to play PC VR games"),
tr("Installed") if d["steamvr"]
else tr("Install SteamVR from Steam to play PC VR games"),
help="steamvr_pc"),
C.status_row(bool(d["revive"]), tr("Revive"), (f"{d['revive_version']} · {d['revive']}"
if d["revive"] else tr("Downloaded when first needed")),
@@ -89,10 +93,12 @@ class SettingsView:
last = library.setting("update.last_check")
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(last)) if last else "never"
found = app.updater.found
status = (C.callout(ft.Row([C.body(tr("FramePort {version} is available.").format(version=found.version), T.TEXT, expand=True),
status = (C.callout(ft.Row([C.body(tr("FramePort {version} is available.").format(version=found.version),
T.TEXT, expand=True),
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED,
lambda e: app.updater.install())], spacing=T.S3), "info")
if found else C.meta(tr("You have the latest version as of the last check ({when}).").format(when=when)))
if found else C.meta(tr("You have the latest version as of the last check ({when}).")
.format(when=when)))
def auto_check(e):
library.set_setting("update.auto_check", bool(e.control.value))
@@ -126,7 +132,8 @@ class SettingsView:
def changed(e):
library.set_setting("ui.scale", e.control.value)
new = T.scale_from_setting(e.control.value)
note.value = (tr("Now {scale:.0%}; {new:.0%} after restarting FramePort.").format(scale=T.SCALE, new=new) if abs(new - T.SCALE) > 0.01
note.value = (tr("Now {scale:.0%}; {new:.0%} after restarting FramePort.").format(scale=T.SCALE, new=new)
if abs(new - T.SCALE) > 0.01
else tr("Now {scale:.0%}.").format(scale=T.SCALE))
C.update(note)
@@ -154,17 +161,20 @@ class SettingsView:
info = self.app.frame_info or {}
remote = info.get("agent_version")
if self.app.frame_state == "connected" and remote:
text += tr(" · on the Frame: ") + (f"v{remote}" if remote == mine else tr("v{remote} (updates on the next action)").format(remote=remote))
text += tr(" · on the Frame: ") + (f"v{remote}" if remote == mine
else tr("v{remote} (updates on the next action)").format(remote=remote))
else:
text += tr(" · Frame not connected")
return text
def build(self) -> ft.Control:
app = self.app
self.tools.controls = [ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT),
self.tools.controls = [ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
color=T.ACCENT),
C.meta(tr("Checking tools…"))], spacing=T.S2)]
app.run_bg(self.fill_tools)
self.pc.controls = [ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT),
self.pc.controls = [ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
color=T.ACCENT),
C.meta(tr("Checking this PC…"))], spacing=T.S2)]
app.run_bg(self.fill_pc)
from ... import __version__ as ver
@@ -183,14 +193,16 @@ class SettingsView:
C.kv(tr("Data folder"), ft.Row([C.body(data, T.TEXT, selectable=True, expand=True),
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, tr("Copy path"),
lambda e: app.copy(data))]), "data_folder"),
C.kv(tr("Catalog"), tr("{len} known-good recipes (bundled, remote and yours)").format(len=len(catalog.load())), "catalog"),
C.kv(tr("Catalog"), tr("{len} known-good recipes (bundled, remote and yours)")
.format(len=len(catalog.load())), "catalog"),
], spacing=T.S2))),
C.section(tr("Problems and feedback"), C.card(ft.Row([
C.body(tr("Something not working? Collect a diagnostics zip (logs, settings, device info; personal "
"data removed) and attach it to a GitHub issue. For one game, use its menu instead."),
expand=True),
C.ghost(tr("Collect app logs"), ft.Icons.FOLDER_ZIP_OUTLINED, lambda e: app.collect_logs()),
C.secondary(tr("Report a problem…"), ft.Icons.BUG_REPORT_OUTLINED, lambda e: app.report_problem_dialog()),
C.secondary(tr("Report a problem…"), ft.Icons.BUG_REPORT_OUTLINED,
lambda e: app.report_problem_dialog()),
], spacing=T.S3)), help="diag_bundle"),
C.section(tr("Remove FramePort"), C.card(ft.Row([
C.body(tr("Removes everything FramePort created: its data and tools on this PC, the Steam entries it "
@@ -208,6 +220,7 @@ class SettingsView:
C.kv(tr("Frame agent"), self.agent_text(), "frame_agent"),
C.kv(tr("Source"), ft.TextButton(REPO_URL.removeprefix("https://"), icon=ft.Icons.OPEN_IN_NEW_ROUNDED,
url=REPO_URL)),
C.meta(tr("Uses overport, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta.")),
C.meta(tr("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)
+10 -6
View File
@@ -32,7 +32,8 @@ class WelcomeView:
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=T.px(20), height=T.px(20), stroke_width=T.px(2), color=T.ACCENT) if state == "busy" else \
marker = ft.ProgressRing(width=T.px(20), height=T.px(20), stroke_width=T.px(2),
color=T.ACCENT) if state == "busy" else \
ft.Icon(icon[0], size=T.px(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([
@@ -62,8 +63,9 @@ class WelcomeView:
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=T.px(30), color=T.ON_ACCENT), width=T.px(56),
height=T.px(56), border_radius=T.px(16), bgcolor=T.ACCENT, alignment=ft.Alignment.CENTER),
ft.Row([ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(30), color=T.ON_ACCENT),
width=T.px(56), height=T.px(56), border_radius=T.px(16), bgcolor=T.ACCENT,
alignment=ft.Alignment.CENTER),
ft.Column([C.title(tr("Welcome to FramePort")),
C.body(tr("Play your Quest, Android and PC VR games on the Steam Frame. Three steps "
"and you're set."))], spacing=T.px(2))], spacing=T.S4),
@@ -73,9 +75,11 @@ class WelcomeView:
*([C.secondary(tr("Try again"), ft.Icons.REFRESH_ROUNDED, lambda e: app.update_tools())]
if t_state == "error" else [])),
self.step(2, tr("Connect your Steam Frame"), f_text, f_state,
*([] if f_state == "done" else [C.secondary(tr("Set up the Frame"), ft.Icons.ARROW_FORWARD_ROUNDED,
lambda e: app.go("frame"))])),
self.step(3, tr("Add your games"), tr("A folder with Android games (APK + OBB, e.g. Quest games) or PC VR games."),
*([] if f_state == "done"
else [C.secondary(tr("Set up the Frame"), ft.Icons.ARROW_FORWARD_ROUNDED,
lambda e: app.go("frame"))])),
self.step(3, tr("Add your games"),
tr("A folder with Android games (APK + OBB, e.g. Quest games) or PC VR games."),
g_state, ft.Row([C.primary(tr("Scan a folder"), ft.Icons.FOLDER_OPEN_ROUNDED, app.pick_folder),
C.ghost(tr("Add an APK file"), ft.Icons.ANDROID_ROUNDED, app.pick_apk)],
spacing=T.S2)),
+10 -7
View File
@@ -80,7 +80,8 @@ def platform_asset() -> str | None:
def update_from_release(release: dict, asset_name: str | None = None) -> Update | None:
"""The Update a GitHub "latest release" JSON describes (None for drafts/pre-releases or unusable data)."""
if not isinstance(release, dict) or release.get("draft") or release.get("prerelease") or not release.get("tag_name"):
if (not isinstance(release, dict) or release.get("draft") or release.get("prerelease")
or not release.get("tag_name")):
return None
assets = {a.get("name"): a.get("browser_download_url") for a in release.get("assets") or [] if isinstance(a, dict)}
asset_name = asset_name if asset_name is not None else platform_asset()
@@ -305,7 +306,8 @@ def _signer_thumbprint(exe: Path) -> str | None:
ps, env = _powershell()
try:
out = subprocess.run([ps, "-NoProfile", "-NonInteractive", "-Command",
f"(Get-AuthenticodeSignature -LiteralPath {_ps_quote(exe)}).SignerCertificate.Thumbprint"],
f"(Get-AuthenticodeSignature -LiteralPath {_ps_quote(exe)})"
".SignerCertificate.Thumbprint"],
capture_output=True, text=True, timeout=60, env=env)
except (OSError, subprocess.SubprocessError) as exc:
_log.warning("reading the signature of %s failed: %s", exe, exc)
@@ -403,11 +405,12 @@ def swap_script(app: Path, target: Path, pid: int, platform: str, relaunch: bool
if platform == "win32":
start = f"Start-Process -FilePath {_ps_quote(target / 'FramePort.exe')} -WorkingDirectory {_ps_quote(target)}" \
if relaunch else "Write-Log 'not relaunching'"
return f"""$ErrorActionPreference = 'Stop'
return (f"""$ErrorActionPreference = 'Stop'
$log = {_ps_quote(log)}
function Write-Log($m) {{ Add-Content -LiteralPath $log -Value ("$(Get-Date -Format s) $m") }}
Write-Log 'update: waiting for FramePort (pid {pid}) to exit'
for ($i = 0; $i -lt 240 -and (Get-Process -Id {pid} -ErrorAction SilentlyContinue); $i++) {{ Start-Sleep -Milliseconds 500 }}
for ($i = 0; $i -lt 240 -and (Get-Process -Id {pid} -ErrorAction SilentlyContinue); $i++) {{ Start-Sleep"""
f""" -Milliseconds 500 }}
Start-Sleep -Milliseconds 500
$src = {_ps_quote(app)}; $dst = {_ps_quote(target)}
$backup = Join-Path (Split-Path -Parent $src) 'previous'
@@ -431,7 +434,7 @@ try {{
Copy-Item -Path (Join-Path $backup '*') -Destination $dst -Recurse -Force -ErrorAction SilentlyContinue
}}
{start}
"""
""")
if platform == "darwin":
start = f"open {_sh_quote(target)}" if relaunch else "echo 'not relaunching' >>\"$log\""
extra = f"xattr -dr com.apple.quarantine {_sh_quote(target)} 2>/dev/null || true"
@@ -474,8 +477,8 @@ def apply(app: Path, target: Path | None = None, relaunch: bool = True, pid: int
started = log.stat().st_size if log.exists() else 0
if platform == "win32":
ps, env = _powershell()
proc = spawn_hidden([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
"-File", str(script)], env=env)
proc = spawn_hidden([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle",
"Hidden", "-File", str(script)], env=env)
else:
proc = subprocess.Popen(["/bin/sh", str(script)], start_new_session=not wait, close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+8 -4
View File
@@ -153,7 +153,8 @@ def test_pcvr_install_flow(monkeypatch, tmp_path):
(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"}}'
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},
@@ -250,7 +251,8 @@ def test_run_tree_kills_the_whole_group(monkeypatch, tmp_path):
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)
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)
@@ -329,7 +331,8 @@ def test_pcvr_launcher_oculus_hmd_helper(monkeypatch, tmp_path):
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 (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
@@ -361,7 +364,8 @@ 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)}))
deployment = {"package": "com.x.y", "appid": 2546384938, "base": str(anchor)}
(anchor / "deployment.json").write_text(json.dumps(deployment))
calls = []
monkeypatch.setattr(a, "run", lambda cmd, **k: (calls.append(cmd), SimpleNamespace(returncode=0, stdout=""))[1])
r = a.cmd_launch({"package": "com.x.y"})
+6 -4
View File
@@ -15,11 +15,12 @@ from frameport.recommend import catalog
ROOT = Path(__file__).resolve().parents[1]
LOG = """
LOG = ("""
09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1150:com.example.game/u0a55 for activity
09-28 17:39:02.000 1150 1170 E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "ovr_User_GetLoggedInUser" referenced by "libgame.so"
09-28 17:39:02.000 1150 1170 E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol"""
""" "ovr_User_GetLoggedInUser" referenced by "libgame.so"
connecting to 192.168.1.23 from /home/alice/.local/share/frameport, steam id 76561198012345678
"""
""")
@pytest.fixture(autouse=True)
@@ -218,7 +219,8 @@ def test_catalog_from_issue(tmp_path):
@pytest.mark.parametrize("bad", ["package: ../../etc/passwd\ntitle: x", "package: com.a.b\ntitle: x\nevil: 1",
"package: com.a.b\ntitle: x\nstatus: great", "package: com.a.b\ntitle: x\nframe: [\"$(rm)\"]",
"package: com.a.b\ntitle: x\nstatus: great",
"package: com.a.b\ntitle: x\nframe: [\"$(rm)\"]",
"package: com.a.b\ntitle: x\ndevice_files: {'../x': 1}", "- a list"])
def test_catalog_from_issue_rejects(tmp_path, bad):
s = _script()
+2 -1
View File
@@ -22,7 +22,8 @@ def test_stub_library(abi):
def _with_note(lib: bytes) -> bytes:
"""Turn the stub's PT_GNU_STACK into a PT_NOTE so add_needed can reuse it (the common case)."""
buf = bytearray(lib)
phoff, phentsize, phnum = struct.unpack_from("<Q", buf, 32)[0], struct.unpack_from("<H", buf, 54)[0], struct.unpack_from("<H", buf, 56)[0]
phoff = struct.unpack_from("<Q", buf, 32)[0]
phentsize, phnum = struct.unpack_from("<H", buf, 54)[0], struct.unpack_from("<H", buf, 56)[0]
for i in range(phnum):
if struct.unpack_from("<I", buf, phoff + i * phentsize)[0] == 0x6474E551:
struct.pack_into("<I", buf, phoff + i * phentsize, 4)
+2 -1
View File
@@ -41,7 +41,8 @@ def test_heuristics_reproduce_catalog(dumps):
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location("ev", Path(__file__).resolve().parents[1] / "scripts/eval_heuristics.py")
script = Path(__file__).resolve().parents[1] / "scripts/eval_heuristics.py"
spec = importlib.util.spec_from_file_location("ev", script)
ev = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ev)
exact = total = 0
+4 -2
View File
@@ -19,7 +19,8 @@ def test_mixed_reality_only_scene_game():
def test_mixed_reality_without_scene():
_, r = suggest(extra={"mr_only": True, "meta_permissions_used": ["com.oculus.permission.USE_ANCHOR_API"], "size": 1})
_, 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
@@ -44,7 +45,8 @@ def test_old_unreal_nodebug_and_alt_build():
def test_cryengine_disables_vrs():
_, r = suggest(engine="CryEngine", xr="VrApi", direct_vrapi=True, libs=["libCrySystem.so", "libCryRenderVulkan.so", "libvrapi.so"],
_, 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
+8 -4
View File
@@ -57,8 +57,10 @@ int main(void) {
}
// equirect2: full sphere, image centre (u 0.5) straight ahead (-Z), +X to the right, v 1 at the top
float u, v;
CHECK(lm_equirect2_uv((XrVector3f){0, 0, -1}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && NEAR(u, 0.5f) && NEAR(v, 0.5f));
CHECK(lm_equirect2_uv((XrVector3f){1, 0, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && NEAR(u, 0.75f) && NEAR(v, 0.5f));
CHECK(lm_equirect2_uv((XrVector3f){0, 0, -1}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v)
&& NEAR(u, 0.5f) && NEAR(v, 0.5f));
CHECK(lm_equirect2_uv((XrVector3f){1, 0, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v)
&& NEAR(u, 0.75f) && NEAR(v, 0.5f));
CHECK(lm_equirect2_uv((XrVector3f){-1, 0, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && NEAR(u, 0.25f));
CHECK(lm_equirect2_uv((XrVector3f){0, 1, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && NEAR(v, 1.0f));
CHECK(lm_equirect2_uv((XrVector3f){0, -1, -0.0001f}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && v < 0.001f);
@@ -72,10 +74,12 @@ int main(void) {
CHECK(lm_face_visible(1, ahead, cos_limit) && lm_face_visible(4, ahead, cos_limit));
CHECK(lm_face_score(0, ahead) > lm_face_score(1, ahead));
// pose algebra (stable_local): (A∘B)∘B⁻¹ = A
XrPosef a = {lm_axis_angle(0, 1, 0, 0.7f), {0.3f, -0.2f, 1.1f}}, b = {lm_axis_angle(1, 0, 0, -0.4f), {-0.5f, 0.1f, 0.2f}};
XrPosef a = {lm_axis_angle(0, 1, 0, 0.7f), {0.3f, -0.2f, 1.1f}},
b = {lm_axis_angle(1, 0, 0, -0.4f), {-0.5f, 0.1f, 0.2f}};
XrPosef back = lm_pose_mul(lm_pose_mul(a, b), lm_pose_inv(b));
CHECK(VNEAR(back.position, a.position.x, a.position.y, a.position.z));
CHECK(NEAR(fabsf(back.orientation.w), fabsf(a.orientation.w)) && NEAR(lm_angle(lm_qmul(back.orientation, lm_qconj(a.orientation))), 0));
CHECK(NEAR(fabsf(back.orientation.w), fabsf(a.orientation.w))
&& NEAR(lm_angle(lm_qmul(back.orientation, lm_qconj(a.orientation))), 0));
CHECK(NEAR(lm_angle(lm_axis_angle(0, 1, 0, 0.5f)), 0.5f));
puts("ok");
return 0;
+2 -1
View File
@@ -22,7 +22,8 @@ def test_needed_layout_equivalent():
def test_settings_classification():
kind, why = classify("lib/arm64-v8a/libframe_settings.so", b"scale=1.0\nscene_emul=1\n", b"scene_emul=1\nscale=1.0\n")
kind, why = classify("lib/arm64-v8a/libframe_settings.so", b"scale=1.0\nscene_emul=1\n",
b"scene_emul=1\nscale=1.0\n")
assert kind == "equivalent"
+13 -7
View File
@@ -49,7 +49,8 @@ def test_adapter_and_launcher(tmp_path, quest_manifest):
names = z.namelist()
assert z.read("lib/arm64-v8a/libopenxr_loader_original.so") == b"\x7fELF-original-loader"
assert z.read("lib/arm64-v8a/libopenxr_loader_generic.so").startswith(b"\x7fELF")
assert z.read("lib/arm64-v8a/libframe_settings.so") == b"scale=1.0\nfoveation_fix=1\ncontroller_fix=1\nscene_emul=1\n"
assert (z.read("lib/arm64-v8a/libframe_settings.so")
== b"scale=1.0\nfoveation_fix=1\ncontroller_fix=1\nscene_emul=1\n")
assert names.count("lib/arm64-v8a/libopenxr_loader_generic.so") == 1
@@ -58,7 +59,8 @@ def test_controller_models_adds_xrshim(tmp_path, quest_manifest):
from frameport.analysis import elf
# like Meta's OVRPlugin: DT_NEEDED libopenxr_loader.so + dlopen("libopenxr_loader.so") / dlsym(xrGetInstanceProcAddr)
# like Meta's OVRPlugin: DT_NEEDED libopenxr_loader.so + dlopen("libopenxr_loader.so") /
# dlsym(xrGetInstanceProcAddr)
plugin = (Path(__file__).with_name("fixtures") / "libfakeovrplugin_arm64.so").read_bytes()
for enabled in (True, False):
apk = _apk(tmp_path, quest_manifest)
@@ -118,7 +120,8 @@ def test_equirect_emul_setting():
assert not patch.applies(vulkan_360) and patch.detect(vulkan_360) is None # never offered for Vulkan games
plain = _analysis(**gles, extra={"xr_layer_exts": ["XR_KHR_composition_layer_cylinder"]})
assert patch.applies(plain) and patch.detect(plain) is None # GLES but no 360 layers requested
player = _analysis(**gles, extra={"xr_layer_exts": ["XR_KHR_composition_layer_cylinder", "XR_KHR_composition_layer_equirect2"]})
player = _analysis(**gles, extra={"xr_layer_exts": ["XR_KHR_composition_layer_cylinder",
"XR_KHR_composition_layer_equirect2"]})
s = patch.detect(player)
assert s.recommended and s.params == {"value": 1}
for key in ("equirect_face", "equirect_res", "equirect_flip", "equirect_fps", "equirect_stereo"):
@@ -222,7 +225,8 @@ def test_vk_sanitize_routes_engine_vulkan_through_shim(tmp_path, quest_manifest)
# a game without the dlopen string is left alone
apk2 = _apk(tmp_path, quest_manifest)
with ApkWorkspace(apk2) as ws:
assert not base.get("frame.vk_sanitize").apply(base.ApkContext(ws, _analysis(engine="Unreal"), {}, Reporter(), {}))
ctx = base.ApkContext(ws, _analysis(engine="Unreal"), {}, Reporter(), {})
assert not base.get("frame.vk_sanitize").apply(ctx)
def test_source_hints_are_generic_and_match_loosely():
@@ -231,7 +235,8 @@ def test_source_hints_are_generic_and_match_loosely():
assert generic_source_hint("4XVR Video Player (Pro + Trial Bypass) v20022+2.0.22 -JF") == "4XVR Video Player"
assert generic_source_hint("Marvels Deadpool VR (English Only) v8742+1.0.40.356975.Quest") == "Marvels Deadpool VR"
assert generic_source_hint("Batman- Arkham Shadow (Inc Lang Packs) v350961+1.4.1-350961") == "Batman- Arkham Shadow"
assert source_hint_matches("Marvels Deadpool VR", "Marvel's Deadpool VR v9000+1.1 -XYZ") # other release, other name
# other release, other name
assert source_hint_matches("Marvels Deadpool VR", "Marvel's Deadpool VR v9000+1.1 -XYZ")
assert source_hint_matches("The Climb 2 v974+2.2", "the climb 2 (quest) v1000")
assert not source_hint_matches("The Climb 2", "The Climb v100")
@@ -254,8 +259,9 @@ def test_ovrport_125_patches():
from frameport.recommend import engine as eng
assert "patch_ac_nexus_no_appsw_90" in eng.suggest(nexus).patches # catalog default (owner, 2026-10-02)
assert "patch_ac_nexus_no_appsw_90" not in eng.suggest(other_nexus).patches # other builds would fail to patch
recipe = Recipe(package="com.Ubisoft.ACNexusVR", patches=["patch_copy_libraries", "frame.adapter",
"patch_ac_nexus_no_appsw_72", "patch_ac_nexus_no_appsw_90"])
recipe = Recipe(package="com.Ubisoft.ACNexusVR",
patches=["patch_copy_libraries", "frame.adapter",
"patch_ac_nexus_no_appsw_72", "patch_ac_nexus_no_appsw_90"])
assert any("conflicts" in w for w in engine.warnings(recipe))
+4 -3
View File
@@ -134,8 +134,8 @@ def test_library_migration_adds_no_crash_reporter(tmp_path, monkeypatch):
}, "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.libovr_redirect", "pcvr.steamvr_tuning",
"pcvr.no_crash_reporter", "pcvr.oculus_unreal", "pcvr.xr_timefix"}
assert set(games["rift.ue"]["recipe"]["patches"]) == {"pcvr.revive", "pcvr.revive_openvr", "pcvr.libovr_redirect",
"pcvr.steamvr_tuning", "pcvr.no_crash_reporter", "pcvr.oculus_unreal", "pcvr.xr_timefix"}
assert set(games["rift.unity"]["recipe"]["patches"]) == {"pcvr.revive", "pcvr.revive_openvr", "pcvr.steamvr_tuning",
"pcvr.libovr_redirect", "pcvr.xr_timefix"}
assert games["rift.ue"]["recipe"]["as_is"] and games["rift.unity"]["recipe"]["as_is"]
@@ -170,7 +170,8 @@ def test_migration_rederives_rift_recipes(tmp_path, monkeypatch):
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}},
"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": {}}))
+11 -6
View File
@@ -36,8 +36,10 @@ int main(int argc, char **argv) {
if (argc > 2) { CHECK(!render_models_available); return 0; }
CHECK(render_models_available);
emulate_render_model = 1;
PFN_xrEnumerateRenderModelPathsFB enumerate = (PFN_xrEnumerateRenderModelPathsFB)render_model_emulation("xrEnumerateRenderModelPathsFB");
PFN_xrGetRenderModelPropertiesFB props_fn = (PFN_xrGetRenderModelPropertiesFB)render_model_emulation("xrGetRenderModelPropertiesFB");
PFN_xrEnumerateRenderModelPathsFB enumerate =
(PFN_xrEnumerateRenderModelPathsFB)render_model_emulation("xrEnumerateRenderModelPathsFB");
PFN_xrGetRenderModelPropertiesFB props_fn =
(PFN_xrGetRenderModelPropertiesFB)render_model_emulation("xrGetRenderModelPropertiesFB");
PFN_xrLoadRenderModelFB load = (PFN_xrLoadRenderModelFB)render_model_emulation("xrLoadRenderModelFB");
CHECK(enumerate && props_fn && load && !render_model_emulation("xrCreatePassthroughFB"));
uint32_t n = 0;
@@ -51,10 +53,12 @@ int main(int argc, char **argv) {
XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_2_BIT_FB};
XrRenderModelPropertiesFB props = {XR_TYPE_RENDER_MODEL_PROPERTIES_FB, &req};
CHECK(props_fn(XR_NULL_HANDLE, right, &props) == XR_SUCCESS);
CHECK(props.vendorId == 0x28DE && strstr(props.modelName, "Right") && props.modelKey != XR_NULL_RENDER_MODEL_KEY_FB);
CHECK(props.vendorId == 0x28DE && strstr(props.modelName, "Right")
&& props.modelKey != XR_NULL_RENDER_MODEL_KEY_FB);
CHECK(props.flags == XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_2_BIT_FB);
XrRenderModelPropertiesFB bad = {XR_TYPE_RENDER_MODEL_PROPERTIES_FB};
CHECK(props_fn(XR_NULL_HANDLE, 12345, &bad) == XR_ERROR_PATH_UNSUPPORTED && bad.modelKey == XR_NULL_RENDER_MODEL_KEY_FB);
CHECK(props_fn(XR_NULL_HANDLE, 12345, &bad) == XR_ERROR_PATH_UNSUPPORTED
&& bad.modelKey == XR_NULL_RENDER_MODEL_KEY_FB);
XrRenderModelLoadInfoFB info = {XR_TYPE_RENDER_MODEL_LOAD_INFO_FB, NULL, props.modelKey};
XrRenderModelBufferFB buf = {XR_TYPE_RENDER_MODEL_BUFFER_FB};
CHECK(load(XR_NULL_HANDLE, &info, &buf) == XR_SUCCESS && buf.bufferCountOutput == 11);
@@ -75,8 +79,9 @@ int main(int argc, char **argv) {
def harness(tmp_path_factory):
d = tmp_path_factory.mktemp("render_model")
(d / "harness.c").write_text(HARNESS)
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function", "-Wno-unused-variable",
"-Wno-missing-field-initializers", "-I", str(INC), "-I", str(ROOT / "native/adapter"),
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
"-Wno-unused-variable", "-Wno-missing-field-initializers", "-I", str(INC),
"-I", str(ROOT / "native/adapter"),
str(d / "harness.c"), "-o", str(d / "harness")], check=True)
return d / "harness"
+7 -4
View File
@@ -99,8 +99,9 @@ def test_unreal_layout_picks_shipping_exe(tmp_path):
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.libovr_redirect", "pcvr.steamvr_tuning",
"pcvr.no_crash_reporter", "pcvr.oculus_unreal", "pcvr.xr_timefix"}
assert r.as_is and set(r.patches) == {"pcvr.revive", "pcvr.revive_openvr", "pcvr.libovr_redirect",
"pcvr.steamvr_tuning", "pcvr.no_crash_reporter", "pcvr.oculus_unreal",
"pcvr.xr_timefix"}
def test_scan_finds_rift_games_not_parents_or_quest(tmp_path):
@@ -290,7 +291,8 @@ def test_system_revive_preferred(tmp_path, monkeypatch):
# 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(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
@@ -516,7 +518,8 @@ def test_platform_sdk_delay_loaded(tmp_path):
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."""
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")))
+6 -4
View File
@@ -8,11 +8,13 @@ LOG_OK = """
09-28 17:39:05.000 1147 1174 I FrameBridge: pacing: 72.2 fps, displayTime vs predicted: avg 0.00 ms
"""
LOG_BAD = """
LOG_BAD = ("""
09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1150:com.example.game/u0a55 for activity
09-28 17:39:02.000 1150 1170 E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "ovr_User_GetLoggedInUser" referenced by "libgame.so"
09-28 17:39:02.000 1150 1170 I GLShim : SHADER COMPILE FAILED 1: 0:9(1): error: #extension directive is not allowed in the middle of a shader
"""
09-28 17:39:02.000 1150 1170 E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol"""
""" "ovr_User_GetLoggedInUser" referenced by "libgame.so"
09-28 17:39:02.000 1150 1170 I GLShim : SHADER COMPILE FAILED 1: 0:9(1): error: #extension directive is not"""
""" allowed in the middle of a shader
""")
def test_healthy_log():
+6 -3
View File
@@ -57,9 +57,11 @@ def _labels(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"))
app = _app(monkeypatch, g, {"installed": [{"package": "com.q", "sha256": "old"}]})
installed = _labels(app.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)
expected = {"Launch test on Frame", "Adapter settings…", "Uninstall from Frame", "Remove from library"}
assert expected <= 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
@@ -86,7 +88,8 @@ def test_play_is_the_quick_action_when_installed(monkeypatch):
assert old.quick_action(g) == ("Play on Frame", "play") # installed (even outdated): playing comes first
assert _app(monkeypatch, g).quick_action(g) == (None, None) # no Frame: "Connect" isn't a quick action
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"]
app = _app(monkeypatch, r, {"installed": []}, pc=("rift.r",))
assert [o[0] for o in app.play_options(r)] == ["Play on this PC"]
def test_menus_offer_sharing_and_diagnostics(monkeypatch):
+7 -4
View File
@@ -14,8 +14,9 @@ from frameport import __version__, updates
from frameport.core import cache, library
def release(tag="v9.9.9", assets=("FramePort-windows-x64.zip", "FramePort-macos-arm64.zip", "FramePort-linux-x64.tar.gz",
"SHA256SUMS.txt", "frameport-9.9.9-py3-none-any.whl"), **kw):
def release(tag="v9.9.9", assets=("FramePort-windows-x64.zip", "FramePort-macos-arm64.zip",
"FramePort-linux-x64.tar.gz", "SHA256SUMS.txt", "frameport-9.9.9-py3-none-any.whl"),
**kw):
return {"tag_name": tag, "body": "What's new: things", "html_url": f"https://example.invalid/releases/{tag}",
"draft": False, "prerelease": False, "published_at": "2026-10-02T00:00:00Z",
"assets": [{"name": n, "browser_download_url": f"https://example.invalid/dl/{n}"} for n in assets], **kw}
@@ -30,7 +31,8 @@ def test_versions():
def test_release_parsing_picks_this_platform_and_skips_drafts():
up = updates.update_from_release(release(), "FramePort-linux-x64.tar.gz")
assert up.version == "9.9.9" and up.asset_url.endswith("linux-x64.tar.gz") and up.sums_url.endswith("SHA256SUMS.txt")
assert (up.version == "9.9.9" and up.asset_url.endswith("linux-x64.tar.gz")
and up.sums_url.endswith("SHA256SUMS.txt"))
assert up.wheel_url.endswith(".whl") and "What's new" in up.notes
assert updates.update_from_release(release(draft=True)) is None
assert updates.update_from_release(release(prerelease=True)) is None
@@ -83,7 +85,8 @@ def test_wheel_commands(monkeypatch):
def test_sums_parsing():
sums = updates.parse_sums("a" * 64 + " FramePort-linux-x64.tar.gz\n" + "B" * 64 + " *FramePort-windows-x64.zip\njunk\n")
sums = updates.parse_sums("a" * 64 + " FramePort-linux-x64.tar.gz\n"
+ "B" * 64 + " *FramePort-windows-x64.zip\njunk\n")
assert sums == {"FramePort-linux-x64.tar.gz": "a" * 64, "FramePort-windows-x64.zip": "b" * 64}