From 23a47880e0b42d293b0f74dbf7023ad2f92bf507 Mon Sep 17 00:00:00 2001 From: spoopyghosty0 <336754034+spoopyghosty0@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:18:41 -0400 Subject: [PATCH] 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 --- .github/workflows/build.yml | 2 +- .gitignore | 6 + CLAUDE.md | 32 ++-- CODE_OF_CONDUCT.md | 8 + CONTRIBUTING.md | 31 ++++ README.md | 17 +- SECURITY.md | 8 + THIRD_PARTY_NOTICES.md | 27 +++ agent/frameport_agent.py | 56 +++--- docs/INSTALL.md | 17 +- pyproject.toml | 23 ++- scripts/eval_heuristics.py | 6 +- scripts/package.py | 4 +- scripts/scrub_library.py | 6 +- scripts/ui_smoke.py | 6 +- scripts/update_smoke.py | 3 +- src/frameport/analysis/detect.py | 15 +- src/frameport/analysis/rift.py | 8 +- src/frameport/apk/axml.py | 3 +- src/frameport/apk/sign.py | 3 +- src/frameport/apk/workspace.py | 6 +- src/frameport/cli.py | 24 ++- src/frameport/core/cache.py | 3 +- src/frameport/core/library.py | 6 +- src/frameport/diag/bundle.py | 19 ++- src/frameport/frame/discovery.py | 9 +- src/frameport/install/files.py | 3 +- src/frameport/install/installer.py | 6 +- src/frameport/parity.py | 43 +++-- src/frameport/patches/applicability.py | 6 +- src/frameport/patches/frame/adapter.py | 10 +- src/frameport/patches/frame/manifest_fixes.py | 11 +- src/frameport/patches/frame/telemetry.py | 10 +- src/frameport/patches/frame/unity_no_msaa.py | 15 +- src/frameport/patches/frame/vk_sanitize.py | 3 +- src/frameport/patches/frame/vrapi_bridge.py | 4 +- src/frameport/patches/overport.py | 27 +-- src/frameport/patches/pcvr.py | 20 ++- src/frameport/patches/settings.py | 39 +++-- src/frameport/pipeline.py | 3 +- src/frameport/recommend/catalog.py | 3 +- src/frameport/recommend/engine.py | 3 +- src/frameport/report.py | 9 +- src/frameport/targets/pc_revive.py | 3 +- src/frameport/ui/app.py | 161 ++++++++++++------ src/frameport/ui/components.py | 11 +- src/frameport/ui/help.py | 5 +- src/frameport/ui/updater.py | 31 ++-- src/frameport/ui/views/activity.py | 6 +- src/frameport/ui/views/art_dialog.py | 16 +- src/frameport/ui/views/files.py | 38 +++-- src/frameport/ui/views/files_dialog.py | 27 ++- src/frameport/ui/views/frame.py | 50 ++++-- src/frameport/ui/views/game.py | 79 +++++---- src/frameport/ui/views/library.py | 62 ++++--- src/frameport/ui/views/settings.py | 41 +++-- src/frameport/ui/views/welcome.py | 16 +- src/frameport/updates.py | 17 +- tests/test_agent.py | 12 +- tests/test_diag.py | 10 +- tests/test_elf.py | 3 +- tests/test_games.py | 3 +- tests/test_heuristics.py | 6 +- tests/test_layer_emul.py | 12 +- tests/test_parity.py | 3 +- tests/test_patches.py | 20 ++- tests/test_pc_target.py | 7 +- tests/test_render_model.py | 17 +- tests/test_rift.py | 11 +- tests/test_triage.py | 10 +- tests/test_ui_help.py | 9 +- tests/test_updates.py | 11 +- 72 files changed, 836 insertions(+), 424 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aeefb4e..287f664 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: diff --git a/.gitignore b/.gitignore index a0a4442..e6b93bc 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index d392c92..4e40970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.yaml` — 34 recipes verified 2026-09-28 + Deadpool VR (2026-10-01, owner-confirmed); `catalog/triage.yaml` — log signatures → fixes. +- `catalog/games/.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 --sources "" ``` -Games/device tests: `pytest -m games` (FRAMEPORT_GAMES=), `pytest -m device` (FRAMEPORT_FRAME=steamos@host); +Games tests: `pytest -m games` (FRAMEPORT_GAMES=); 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=` 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 → `/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 → `/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.`, 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--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 (`/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. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b957446 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4ac8960 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 `.json`, fill in the values (a list + `[one, other]` for plural entries) and add `"_language": ""`. +- 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. diff --git a/README.md b/README.md index e0b6cff..91454f2 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ee450a9 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..3a22b81 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/agent/frameport_agent.py b/agent/frameport_agent.py index 50fa9ab..3ba9829 100644 --- a/agent/frameport_agent.py +++ b/agent/frameport_agent.py @@ -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} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index c5f2f8b..6b9e2e5 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 --to videos`. @@ -82,7 +83,7 @@ Command line: `frameport frame send --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 `, `frameport share-recipe `. diff --git a/pyproject.toml b/pyproject.toml index 7d772dc..6f740ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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=)", "native: compiles native code with the NDK from native/build.py (FRAMEPORT_NATIVE_TESTS=1; slow)", ] diff --git a/scripts/eval_heuristics.py b/scripts/eval_heuristics.py index 056b1bc..70e0824 100644 --- a/scripts/eval_heuristics.py +++ b/scripts/eval_heuristics.py @@ -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) diff --git a/scripts/package.py b/scripts/package.py index 1d68c0b..dab6680 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -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", diff --git a/scripts/scrub_library.py b/scripts/scrub_library.py index f63a613..5c19f97 100644 --- a/scripts/scrub_library.py +++ b/scripts/scrub_library.py @@ -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 diff --git a/scripts/ui_smoke.py b/scripts/ui_smoke.py index 3084724..2dbc985 100644 --- a/scripts/ui_smoke.py +++ b/scripts/ui_smoke.py @@ -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) diff --git a/scripts/update_smoke.py b/scripts/update_smoke.py index b9dca29..847f523 100644 --- a/scripts/update_smoke.py +++ b/scripts/update_smoke.py @@ -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 diff --git a/src/frameport/analysis/detect.py b/src/frameport/analysis/detect.py index b55740f..db9976a 100644 --- a/src/frameport/analysis/detect.py +++ b/src/frameport/analysis/detect.py @@ -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 diff --git a/src/frameport/analysis/rift.py b/src/frameport/analysis/rift.py index 30cb7fe..efb4cf1 100644 --- a/src/frameport/analysis/rift.py +++ b/src/frameport/analysis/rift.py @@ -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()} diff --git a/src/frameport/apk/axml.py b/src/frameport/apk/axml.py index 058b735..7a640bd 100644 --- a/src/frameport/apk/axml.py +++ b/src/frameport/apk/axml.py @@ -252,7 +252,8 @@ def define_meta_permissions(manifest: bytes) -> tuple[bytes, list[str]] | None: start = struct.pack(" 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(): diff --git a/src/frameport/apk/workspace.py b/src/frameport/apk/workspace.py index abec7ed..28fb807 100644 --- a/src/frameport/apk/workspace.py +++ b/src/frameport/apk/workspace.py @@ -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)) diff --git a/src/frameport/cli.py b/src/frameport/cli.py index 1d6a0ba..f4e3b33 100644 --- a/src/frameport/cli.py +++ b/src/frameport/cli.py @@ -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") diff --git a/src/frameport/core/cache.py b/src/frameport/core/cache.py index b3c73cb..f8a7749 100644 --- a/src/frameport/core/cache.py +++ b/src/frameport/core/cache.py @@ -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 diff --git a/src/frameport/core/library.py b/src/frameport/core/library.py index a640fd3..3845e09 100644 --- a/src/frameport/core/library.py +++ b/src/frameport/core/library.py @@ -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) diff --git a/src/frameport/diag/bundle.py b/src/frameport/diag/bundle.py index cf5467c..a3bfeec 100644 --- a/src/frameport/diag/bundle.py +++ b/src/frameport/diag/bundle.py @@ -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 ``, ``, ``, `` (FramePort's data folder), `` and `` (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//entry.json | the library entry: analysis, recipe, build checks, installs, last launch test | | games//recipe.yaml | the recipe in catalog form (catalog/games/.yaml) | | games//triage.json | the newest launch log re-triaged with the triage signatures of this app version | | games//package/ | stand-in for the game files: file list, AndroidManifest.xml, ELF imports/exports / PE imports | | games//logs/ | launch-test logs saved on the PC | -| games//target/ | from the Frame (or PC): launch.sh, settings.conf, deployment.json, launch/logcat/Proton/game logs, file list | +| games//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 ` 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) diff --git a/src/frameport/frame/discovery.py b/src/frameport/frame/discovery.py index c21f425..28e9213 100644 --- a/src/frameport/frame/discovery.py +++ b/src/frameport/frame/discovery.py @@ -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]: diff --git a/src/frameport/install/files.py b/src/frameport/install/files.py index ce551b2..293a462 100644 --- a/src/frameport/install/files.py +++ b/src/frameport/install/files.py @@ -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} diff --git a/src/frameport/install/installer.py b/src/frameport/install/installer.py index 9c0ea03..52905d2 100644 --- a/src/frameport/install/installer.py +++ b/src/frameport/install/installer.py @@ -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, diff --git a/src/frameport/parity.py b/src/frameport/parity.py index 330bd43..3d09b29 100644 --- a/src/frameport/parity.py +++ b/src/frameport/parity.py @@ -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} | " + "
".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 diff --git a/src/frameport/patches/applicability.py b/src/frameport/patches/applicability.py index e3c346c..817e441 100644 --- a/src/frameport/patches/applicability.py +++ b/src/frameport/patches/applicability.py @@ -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: diff --git a/src/frameport/patches/frame/adapter.py b/src/frameport/patches/frame/adapter.py index 6b4c080..40cf0ad 100644 --- a/src/frameport/patches/frame/adapter.py +++ b/src/frameport/patches/frame/adapter.py @@ -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" diff --git a/src/frameport/patches/frame/manifest_fixes.py b/src/frameport/patches/frame/manifest_fixes.py index b894e13..5964649 100644 --- a/src/frameport/patches/frame/manifest_fixes.py +++ b/src/frameport/patches/frame/manifest_fixes.py @@ -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 diff --git a/src/frameport/patches/frame/telemetry.py b/src/frameport/patches/frame/telemetry.py index 0614c91..cf787eb 100644 --- a/src/frameport/patches/frame/telemetry.py +++ b/src/frameport/patches/frame/telemetry.py @@ -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): diff --git a/src/frameport/patches/frame/unity_no_msaa.py b/src/frameport/patches/frame/unity_no_msaa.py index 5ac7806..c29bd2e 100644 --- a/src/frameport/patches/frame/unity_no_msaa.py +++ b/src/frameport/patches/frame/unity_no_msaa.py @@ -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 diff --git a/src/frameport/patches/frame/vk_sanitize.py b/src/frameport/patches/frame/vk_sanitize.py index 5e4bc3e..b24dab6 100644 --- a/src/frameport/patches/frame/vk_sanitize.py +++ b/src/frameport/patches/frame/vk_sanitize.py @@ -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): diff --git a/src/frameport/patches/frame/vrapi_bridge.py b/src/frameport/patches/frame/vrapi_bridge.py index ae1fb0f..48f4a5e 100644 --- a/src/frameport/patches/frame/vrapi_bridge.py +++ b/src/frameport/patches/frame/vrapi_bridge.py @@ -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 diff --git a/src/frameport/patches/overport.py b/src/frameport/patches/overport.py index 2a2476b..bab9dd6 100644 --- a/src/frameport/patches/overport.py +++ b/src/frameport/patches/overport.py @@ -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: diff --git a/src/frameport/patches/pcvr.py b/src/frameport/patches/pcvr.py index 41ad7be..f95fce3 100644 --- a/src/frameport/patches/pcvr.py +++ b/src/frameport/patches/pcvr.py @@ -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) diff --git a/src/frameport/patches/settings.py b/src/frameport/patches/settings.py index 7bae109..e029370 100644 --- a/src/frameport/patches/settings.py +++ b/src/frameport/patches/settings.py @@ -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")] diff --git a/src/frameport/pipeline.py b/src/frameport/pipeline.py index 9b06490..711dddb 100644 --- a/src/frameport/pipeline.py +++ b/src/frameport/pipeline.py @@ -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) diff --git a/src/frameport/recommend/catalog.py b/src/frameport/recommend/catalog.py index cba6071..f1115ff 100644 --- a/src/frameport/recommend/catalog.py +++ b/src/frameport/recommend/catalog.py @@ -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], diff --git a/src/frameport/recommend/engine.py b/src/frameport/recommend/engine.py index 49f4d94..38a882d 100644 --- a/src/frameport/recommend/engine.py +++ b/src/frameport/recommend/engine.py @@ -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." diff --git a/src/frameport/report.py b/src/frameport/report.py index 3a4a8a8..d7a3b55 100644 --- a/src/frameport/report.py +++ b/src/frameport/report.py @@ -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) diff --git a/src/frameport/targets/pc_revive.py b/src/frameport/targets/pc_revive.py index 8e2dd3c..8743977 100644 --- a/src/frameport/targets/pc_revive.py +++ b/src/frameport/targets/pc_revive.py @@ -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): diff --git a/src/frameport/ui/app.py b/src/frameport/ui/app.py index 28c67ab..809768b 100644 --- a/src/frameport/ui/app.py +++ b/src/frameport/ui/app.py @@ -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)])) diff --git a/src/frameport/ui/components.py b/src/frameport/ui/components.py index 74767c6..d8d67a2 100644 --- a/src/frameport/ui/components.py +++ b/src/frameport/ui/components.py @@ -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, diff --git a/src/frameport/ui/help.py b/src/frameport/ui/help.py index f46d1d1..ea3d0e5 100644 --- a/src/frameport/ui/help.py +++ b/src/frameport/ui/help.py @@ -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 " diff --git a/src/frameport/ui/updater.py b/src/frameport/ui/updater.py index 8c8548d..10c5c01 100644 --- a/src/frameport/ui/updater.py +++ b/src/frameport/ui/updater.py @@ -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))), diff --git a/src/frameport/ui/views/activity.py b/src/frameport/ui/views/activity.py index d876446..e8659b0 100644 --- a/src/frameport/ui/views/activity.py +++ b/src/frameport/ui/views/activity.py @@ -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: diff --git a/src/frameport/ui/views/art_dialog.py b/src/frameport/ui/views/art_dialog.py index 5e3c8f9..4044d12 100644 --- a/src/frameport/ui/views/art_dialog.py +++ b/src/frameport/ui/views/art_dialog.py @@ -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')) diff --git a/src/frameport/ui/views/files.py b/src/frameport/ui/views/files.py index 1b75f06..9534f6e 100644 --- a/src/frameport/ui/views/files.py +++ b/src/frameport/ui/views/files.py @@ -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]))), diff --git a/src/frameport/ui/views/files_dialog.py b/src/frameport/ui/views/files_dialog.py index 8eaa78f..ab7e9c5 100644 --- a/src/frameport/ui/views/files_dialog.py +++ b/src/frameport/ui/views/files_dialog.py @@ -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] diff --git a/src/frameport/ui/views/frame.py b/src/frameport/ui/views/frame.py index 127a0df..78aa6a9 100644 --- a/src/frameport/ui/views/frame.py +++ b/src/frameport/ui/views/frame.py @@ -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) diff --git a/src/frameport/ui/views/game.py b/src/frameport/ui/views/game.py index c9f53f5..b0706e2 100644 --- a/src/frameport/ui/views/game.py +++ b/src/frameport/ui/views/game.py @@ -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(), diff --git a/src/frameport/ui/views/library.py b/src/frameport/ui/views/library.py index bffc41d..0d6e19c 100644 --- a/src/frameport/ui/views/library.py +++ b/src/frameport/ui/views/library.py @@ -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), diff --git a/src/frameport/ui/views/settings.py b/src/frameport/ui/views/settings.py index 2e85821..2340d0f 100644 --- a/src/frameport/ui/views/settings.py +++ b/src/frameport/ui/views/settings.py @@ -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) diff --git a/src/frameport/ui/views/welcome.py b/src/frameport/ui/views/welcome.py index 406cd54..d7cd16b 100644 --- a/src/frameport/ui/views/welcome.py +++ b/src/frameport/ui/views/welcome.py @@ -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)), diff --git a/src/frameport/updates.py b/src/frameport/updates.py index 6cee820..18e4f7c 100644 --- a/src/frameport/updates.py +++ b/src/frameport/updates.py @@ -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) diff --git a/tests/test_agent.py b/tests/test_agent.py index ca6217a..244e4b4 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -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"}) diff --git a/tests/test_diag.py b/tests/test_diag.py index fc81474..a0c128a 100644 --- a/tests/test_diag.py +++ b/tests/test_diag.py @@ -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() diff --git a/tests/test_elf.py b/tests/test_elf.py index c8c7e4d..69055ba 100644 --- a/tests/test_elf.py +++ b/tests/test_elf.py @@ -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(" 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; diff --git a/tests/test_parity.py b/tests/test_parity.py index 715cfc1..30a7196 100644 --- a/tests/test_parity.py +++ b/tests/test_parity.py @@ -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" diff --git a/tests/test_patches.py b/tests/test_patches.py index 4be072c..77567c9 100644 --- a/tests/test_patches.py +++ b/tests/test_patches.py @@ -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)) diff --git a/tests/test_pc_target.py b/tests/test_pc_target.py index 339a545..7dcc029 100644 --- a/tests/test_pc_target.py +++ b/tests/test_pc_target.py @@ -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": {}})) diff --git a/tests/test_render_model.py b/tests/test_render_model.py index 994e80d..82da622 100644 --- a/tests/test_render_model.py +++ b/tests/test_render_model.py @@ -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" diff --git a/tests/test_rift.py b/tests/test_rift.py index c8ba29b..7140fce 100644 --- a/tests/test_rift.py +++ b/tests/test_rift.py @@ -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"))) diff --git a/tests/test_triage.py b/tests/test_triage.py index b14f028..929275d 100644 --- a/tests/test_triage.py +++ b/tests/test_triage.py @@ -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(): diff --git a/tests/test_ui_help.py b/tests/test_ui_help.py index 9aa3737..b2edb98 100644 --- a/tests/test_ui_help.py +++ b/tests/test_ui_help.py @@ -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): diff --git a/tests/test_updates.py b/tests/test_updates.py index 681e733..499084d 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -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}