mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 07:00:40 +02:00
Lines within 120 columns everywhere (CI lints without exceptions); community and licence files; docs updated
- reflowed long lines (texts unchanged; generated launcher/PowerShell/README texts byte-identical) - CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, THIRD_PARTY_NOTICES; pyproject metadata (SPDX licence, authors, urls, classifiers, keywords) - maintainer-only notes moved to a git-ignored CLAUDE.local.md; CLAUDE.md facts updated - README: what kinds of apps run and how; INSTALL: PC VR and Files wording, new labels
This commit is contained in:
1 parent
da399ef77a
commit
23a47880e0
72 files changed
+836
-424
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
@@ -43,8 +43,9 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
- **PC VR repacks are pre-patched to run directly** (proven: Rick and Morty, Vader Immortal run when the exe is
|
||||
launched directly; Revive breaks them). So Rift games default to `as_is` = install the copy unchanged and launch
|
||||
the exe directly (`pcvr.xr_timefix` for the Frame OpenXR-1.1→1.0 fix, `pcvr.no_crash_reporter` for Unreal).
|
||||
**Revive is off by default, opt-in** (`pcvr.revive`, and `pcvr.oculus_unreal`): only for an un-cracked Oculus game
|
||||
that fails at "Initializing OVR session". Those (Lone Echo, Robo Recall, Lies Beneath: crack .7z not extracted /
|
||||
**Revive is only suggested for games with Oculus (LibOVR) code** (`pcvr.revive`, and `pcvr.oculus_unreal`; repacks
|
||||
with a bundled Revive and SteamVR/OpenXR games run directly): needed by an un-cracked Oculus game that fails at
|
||||
"Initializing OVR session". Those (Lone Echo, Robo Recall, Lies Beneath: crack .7z not extracted /
|
||||
Platform SDK) hit Revive's Oculus-runtime **signature check** under Proton-arm64 — Revive's LoadLibrary/WinVerifyTrust
|
||||
hooks don't install (ARM64EC), and the game's Oculus SDK shim rejects the unsigned Revive runtime (wintrust +
|
||||
crypt32 signer "Oculus VR") — so they don't run on the Frame without extracting the repack's crack (which FramePort
|
||||
@@ -145,7 +146,8 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
- `agent/frameport_agent.py` — runs **on the Frame** (python3 stdlib only), JSON over SSH. Owns the install layout,
|
||||
launch.sh template, Steam shortcuts (binary VDF), launch tests. Bump `AGENT_VERSION` when changing it.
|
||||
- `bootstrap/bootstrap.sh` — one-time Frame setup served by the pairing server (sshd, app key, avahi service, Lepton).
|
||||
- `catalog/games/<package>.yaml` — 34 recipes verified 2026-09-28 + Deadpool VR (2026-10-01, owner-confirmed); `catalog/triage.yaml` — log signatures → fixes.
|
||||
- `catalog/games/<package>.yaml` — 38 recipes (34 verified 2026-09-28; Deadpool VR, 4XVR, NEX Player and AC Nexus's
|
||||
90 Hz default confirmed later by the owner); `catalog/triage.yaml` — log signatures → fixes.
|
||||
- `native/` — sources of the prebuilt binaries in `artifacts/` (adapter, VrApi bridge patches, GL shim, stubs).
|
||||
`native/build.py` rebuilds them with NDK r27c (downloaded on demand into `native/.cache`, git-ignored; uses
|
||||
`-ffile-prefix-map` so no local paths get embedded; zip symlinks are restored as copies). Users never need the NDK.
|
||||
@@ -160,7 +162,7 @@ uv run pytest # unit tests (no device, no game files)
|
||||
uv run frameport --help # CLI; uv run frameport-gui for the GUI
|
||||
uv run frameport parity --known-good <PATCHED/_known-good-*> --sources "<VR CyberDeck downloads>"
|
||||
```
|
||||
Games/device tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>), `pytest -m device` (FRAMEPORT_FRAME=steamos@host);
|
||||
Games tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>); on-device checks are CLI commands (below);
|
||||
native layer test: `FRAMEPORT_NATIVE_TESTS=1 pytest -m native` (compiles with the NDK, ~2 min on NTFS).
|
||||
Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattributes`).
|
||||
- `FRAMEPORT_HOME=<dir>` isolates all app data (tests use it); `FRAMEPORT_JAVA/_OVERPORT_JAR/_APKSIGNER_JAR` override
|
||||
@@ -350,7 +352,7 @@ swapchain was halved (1536/eye) in case memory is the limit (unverified). Frame
|
||||
(SteamVR runtimes, Lepton scripts, logs; never commit Valve binaries): `~/frameport-research/frame-data-2026-09-30/`.
|
||||
Not yet verified in the headset.
|
||||
|
||||
**Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `<base>/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Send files: `install/files.py`, `frameport frame send|storage`, GUI Frame → Send files.
|
||||
**Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `<base>/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Files: `install/files.py`, `frameport frame send|storage`, GUI Files tab (formerly Frame → Send files).
|
||||
**SteamVR per-app settings (2026-09-30):** editing steamvr.vrsettings while SteamVR runs is lost; the web API (127.0.0.1:27062 /app/setsettings) needs `x-steamvr-secret`. `native/vrsettings` = `fp_vrsettings.exe` (freestanding, OpenVR `FnTable:IVRSettings_003` as a Utility app, loads SteamVR's bin/win64/openvr_api.dll) sets them live and SteamVR persists them: section `steam.app.<shortcut appid>`, keys `preferredRefreshRate` (float) and `motionSmoothingOverride` (0 global, 1 on, 2 off, 3 always). Steam Link (vrlink) lists the Frame's rates 72/80/90/96/108/120/144 in vrserver.txt and follows the per-app preference ("host preferred N Hz"; whether the key is honoured is unverified in-headset yet). Judder metric: vrcompositor.txt session summary dropped + "Timed out. N total" (Stormland: 0 dropped but 313 timeouts in 2 min); fpsVR (`%LOCALAPPDATA%\fpsVR\*.json`, 0.1 ms histograms) gives p99 CPU/GPU ms. `pcvr.steamvr_tuning` (default on, PC only) applies on Play: highest rate whose budget ≥ p99×1.05, at least one step down, smoothing on.
|
||||
|
||||
**Unresolved (as of 2026-09-28):** Arcsmith (right-eye distortion) and Time Stall (both eyes) — swap, tracking, Valve
|
||||
@@ -358,6 +360,7 @@ layers, depth, pacing ruled out. Sniper Elite VR (DEVICE LOST), Espire 1 (Mesa G
|
||||
crash): use PC versions.
|
||||
|
||||
## Releases, CI, GitHub
|
||||
Maintainer-only notes (accounts, credentials, key locations) live in the git-ignored `CLAUDE.local.md`.
|
||||
Public repo `github.com/spoopyghosty0/frameport` (branch `main`). Push a `v*` tag → CI (`.github/workflows/build.yml`)
|
||||
tests, builds Windows x64 / macOS arm64 / Linux x64 bundles, signs, attests and publishes a GitHub Release
|
||||
(`FramePort-*.zip/.tar.gz`, the CLI wheel `frameport-<ver>-py3-none-any.whl`, `SHA256SUMS.txt`,
|
||||
@@ -370,8 +373,8 @@ Installed apps find the release themselves (self-update), so the notes are what
|
||||
- Signing is **free/self-signed by the owner's choice** (no paid certs, no SignPath): Windows binaries are signed with
|
||||
a self-signed "FramePort (self-signed)" code-signing cert (RSA 3072, valid to 2031, SHA-256
|
||||
`4E:12:98:91:62:C0:E4:50:FB:65:1D:34:BB:73:00:09:7B:78:BE:88:5C:A7:6C:42:23:46:9B:92:A1:59:A7:6E`); secrets
|
||||
`WINDOWS_CODESIGN_PFX` (base64) + `WINDOWS_CODESIGN_PASSWORD`. Private key: `~/.config/frameport-signing/` (WSL) and
|
||||
the backup `PATCHED/_signing-keys/frameport-app-codesign/` — never commit it. macOS is ad-hoc signed only (Gatekeeper
|
||||
`WINDOWS_CODESIGN_PFX` (base64) + `WINDOWS_CODESIGN_PASSWORD`. The private key is never committed (its
|
||||
location is in `CLAUDE.local.md`). macOS is ad-hoc signed only (Gatekeeper
|
||||
needs right-click → Open; notarization would need the paid Apple program). Users still see SmartScreen unless they
|
||||
import the .cer into Trusted Root.
|
||||
- CI gotchas: `flet build` needs `--yes --no-rich-output` (it prompts to install Flutter; rich output crashes the
|
||||
@@ -391,14 +394,6 @@ Installed apps find the release themselves (self-update), so the notes are what
|
||||
open). Windows installs of 0.3.1/0.3.2 can't update themselves: they need one manual download.
|
||||
- The Linux bundle is built on ubuntu-22.04: a 24.04-built Flutter bundle needs GLib 2.80 (`undefined symbol:
|
||||
g_once_init_enter_pointer` on 22.04).
|
||||
- **This project's GitHub identity is `spoopyghosty0`** (a dedicated account; the machine's default gh/git login is a
|
||||
different, personal account that must never touch this repo). `gh` (`~/.local/bin/gh`) uses it through
|
||||
`GH_CONFIG_DIR=~/.config/gh-spoopyghosty0` (set for Claude Code in the git-ignored `.claude/settings.local.json`);
|
||||
git pushes from this folder authenticate as it through a repo-local credential helper, and commits use the repo-local
|
||||
identity `spoopyghosty0 <336754034+spoopyghosty0@users.noreply.github.com>`. Check with `gh api user --jq .login`
|
||||
before any GitHub action.
|
||||
- **No Claude trailers** (`Co-Authored-By: Claude …`, `Claude-Session: …`) in commits or PR descriptions — the owner's
|
||||
rule; history was rewritten to remove them.
|
||||
- **The repo is public: never commit personal data** — the Frame's IP address, the Steam user id, the owner's email,
|
||||
local home paths (native builds use `-ffile-prefix-map`). History was rewritten once to remove them.
|
||||
|
||||
@@ -421,11 +416,14 @@ GLAD/GLES); Unreal → alternate no-ForceQuit build. Score changes with
|
||||
- Reinstalls keep one rollback copy (`<base>/previous-game.apk`, `settings.conf.previous`); `frameport frame cleanup`
|
||||
removes them (and `--path ~/X` extra folders under home).
|
||||
|
||||
## Project status (2026-09-29)
|
||||
## Project status (2026-10-02)
|
||||
Self-update, the Files tab, OVRPort 1.2.5, non-Quest Android apps (vr_kind) and GUI localisation (tr(), 0.3.x) are
|
||||
in; see the sections above. Earlier state (2026-09-29):
|
||||
34 Quest games ported; the owner confirmed in the headset that all FramePort-rebuilt games work: 23 work, 5 work with
|
||||
issues (Arcsmith/Time Stall eye distortion, AC Nexus some flipped launch text, Phantom DLC button, Silhouette hands),
|
||||
6 can't run (Sniper Elite VR, Espire 1, HITMAN, and the 32-bit Journey of the Gods / Shadow Point / Sports Scramble).
|
||||
Parity: all 34 rebuilt from the dumps match the known-good builds (`docs/parity-report.md`) and were reinstalled +
|
||||
Parity: all 34 rebuilt from the dumps match the known-good builds (`docs/parity-report.md`, generated locally and
|
||||
git-ignored; 34/34 again with OVRPort 1.2.5 on 2026-10-02) and were reinstalled +
|
||||
launch-tested with 0 regressions (`docs/parity-device-report.md`). `PATCHED/` holds exactly the installed builds.
|
||||
Owner preferences: manual installs (no third-party installer apps), Python + Flet, dynamic data over hardcoding,
|
||||
free tooling only, public repo scrubbed of personal data, keep the known-good backups.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for helping. The quickest contributions:
|
||||
|
||||
- **A game works on the Frame?** In FramePort: the game's menu → **Share working config…** opens a prefilled issue
|
||||
with its recipe. Accepted configs become built-in recipes.
|
||||
- **Something doesn't work?** The game's menu → **Report a problem…** saves a diagnostics zip (personal data
|
||||
removed) and opens a prefilled issue to attach it to.
|
||||
|
||||
## Development
|
||||
```
|
||||
uv sync --extra dev # Python 3.11+, uv (https://docs.astral.sh/uv/)
|
||||
uv run frameport-gui # the app
|
||||
uv run frameport --help # the command line
|
||||
uv run pytest # tests (no headset or game files needed)
|
||||
uv run ruff check . # lint
|
||||
```
|
||||
- Code and architecture: `CLAUDE.md`, `docs/ARCHITECTURE.md`; per-game symptoms and fixes: `docs/PLAYBOOK.md`.
|
||||
- Interface wording: `docs/STYLE.md`. GUI texts go through `tr()`; after changing them run
|
||||
`python scripts/i18n_extract.py`.
|
||||
- Translations: copy `src/frameport/locales/template.json` to `<language>.json`, fill in the values (a list
|
||||
`[one, other]` for plural entries) and add `"_language": "<the language's own name>"`.
|
||||
- New fixes are patch modules (`src/frameport/patches/`), with a triage signature (`catalog/triage.yaml`), a PLAYBOOK
|
||||
row and a unit test.
|
||||
- Native binaries in `artifacts/` are rebuilt with `python native/build.py` (downloads the NDK).
|
||||
|
||||
## Ground rules
|
||||
- Use only games you own. Contributions that help bypass DRM, licence or entitlement checks, or that add game files,
|
||||
are not accepted.
|
||||
- Never commit personal data (IP addresses, user names, home paths, Steam IDs).
|
||||
- By contributing you agree to license your work under GPL-3.0-only.
|
||||
@@ -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**.
|
||||
|
||||

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

|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Third-party notices
|
||||
|
||||
FramePort is GPL-3.0-only (see `LICENSE`). It includes or downloads the following third-party work.
|
||||
|
||||
## Included in this repository / the release bundles
|
||||
| Component | Where | License |
|
||||
|---|---|---|
|
||||
| OVRPort VrApi → OpenXR adapter (`native/vrapi`, github.com/Android-XR-Bridge/OVRPort), with FramePort's changes | `native/vrapi-bridge/`, `artifacts/arm64-v8a/libvrapi.so` | GPL-3.0 (`native/vrapi-bridge/LICENSE.upstream`) |
|
||||
| OVRPort platform compatibility library (`native/platform`) | `native/platformcompat/`, `artifacts/arm64-v8a/libovrplatformcompat.so` | GPL-3.0; Meta Platform SDK headers under the Meta Platform Technologies SDK License (`native/platformcompat/licenses/OCULUS-PLATFORM-SDK.txt`) |
|
||||
| Khronos OpenXR SDK headers | used to build the native layers | Apache-2.0 / MIT (`native/vrapi-bridge/licenses/OPENXR-SDK.txt`) |
|
||||
| Android NDK runtime (statically linked libc++) | native Android libraries | Apache-2.0 with LLVM exception, plus legacy notices (`native/vrapi-bridge/licenses/ANDROID-NDK.txt`) |
|
||||
| Flet and Flutter (desktop app runtime) | release bundles | Apache-2.0 / BSD-3-Clause |
|
||||
| flet-dropzone / desktop_drop (drag-and-drop) | release bundles | Apache-2.0 / MIT |
|
||||
| Python packages (paramiko, zeroconf, psutil, pyelftools, capstone, UnityPy, PyYAML, requests, typer, pyaxmlparser, Pillow, cryptography, …) | release bundles | their own licenses (see each package's metadata) |
|
||||
|
||||
FramePort's own native code (the FrameBridge adapter, GL/Vulkan/OpenXR shims, the Windows helpers) is GPL-3.0-only.
|
||||
|
||||
## Downloaded at run time (not redistributed)
|
||||
| Tool | Source | License |
|
||||
|---|---|---|
|
||||
| OVRPort / overport CLI | github.com/Android-XR-Bridge/OVRPort releases (fallback github.com/ovrport/app) | GPL-3.0 |
|
||||
| Eclipse Temurin JRE | api.adoptium.net | GPL-2.0 with Classpath Exception |
|
||||
| apksigner (Android build-tools) | dl.google.com Android repository | Apache-2.0 |
|
||||
| Revive (portable copy, only if Revive isn't installed) | github.com/LibreVR/Revive releases | GPL-3.0 |
|
||||
|
||||
Valve's Lepton, Proton and SteamVR, and Meta's software, are used on your devices as installed by Steam / Meta;
|
||||
FramePort doesn't distribute them.
|
||||
+36
-20
@@ -94,7 +94,8 @@ def find_app(name_regex):
|
||||
|
||||
def lepton_path():
|
||||
app = find_app(r"Lepton")
|
||||
candidates = ([os.path.join(app["dir"], "lepton")] if app else []) + [os.path.join(STEAM, "steamapps/common/Lepton/lepton")]
|
||||
candidates = (([os.path.join(app["dir"], "lepton")] if app else [])
|
||||
+ [os.path.join(STEAM, "steamapps/common/Lepton/lepton")])
|
||||
for c in candidates:
|
||||
if os.access(c, os.X_OK):
|
||||
return c, app
|
||||
@@ -577,9 +578,11 @@ def ensure_host_fixes():
|
||||
if not re.search(r"^\s*keyring\s*=", text, re.M):
|
||||
note = "# FramePort: stop rootless podman leaking a kernel keyring per container start (Lepton launches)\n"
|
||||
if re.search(r"^\[containers\]\s*$", text, re.M):
|
||||
text = re.sub(r"^\[containers\]\s*$", "[containers]\n" + note + "keyring = false", text, count=1, flags=re.M)
|
||||
text = re.sub(r"^\[containers\]\s*$", "[containers]\n" + note + "keyring = false", text, count=1,
|
||||
flags=re.M)
|
||||
else:
|
||||
text = text + ("\n" if text and not text.endswith("\n") else "") + "[containers]\n" + note + "keyring = false\n"
|
||||
sep = "\n" if text and not text.endswith("\n") else ""
|
||||
text = text + sep + "[containers]\n" + note + "keyring = false\n"
|
||||
os.makedirs(os.path.dirname(CONTAINERS_CONF), exist_ok=True)
|
||||
with open(CONTAINERS_CONF, "w") as f:
|
||||
f.write(text)
|
||||
@@ -633,7 +636,7 @@ def cmd_install_lepton(args):
|
||||
"hint": "Enable Developer Mode, then confirm the install in Steam (or launch 'Lepton Development' once)."}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Steam shortcuts (binary VDF)
|
||||
# ------------------------------------------------------------------------------------- Steam shortcuts (binary VDF)
|
||||
TYPE_MAP, TYPE_STRING, TYPE_INT, TYPE_END = 0, 1, 2, 8
|
||||
|
||||
|
||||
@@ -792,7 +795,8 @@ def cmd_shortcuts(args):
|
||||
rewritten, so the work runs in a detached systemd unit (terminals/SSH sessions started from Steam live in
|
||||
steam.service's cgroup and would be killed with it). Poll shortcut_status for the result."""
|
||||
packages = [check_pkg(p) for p in args.get("packages", [])]
|
||||
remove = [r for r in args.get("remove", []) if isinstance(r, dict) and str(r.get("exe", "")).startswith('"' + ANCHORS)]
|
||||
remove = [r for r in args.get("remove", [])
|
||||
if isinstance(r, dict) and str(r.get("exe", "")).startswith('"' + ANCHORS)]
|
||||
if not packages and not remove:
|
||||
return {"started": False}
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
@@ -1014,7 +1018,8 @@ def cmd_link_media(args):
|
||||
except OSError:
|
||||
shutil.copy2(real, target) # different file system (not the case with Lepton's layout)
|
||||
linked.append(os.path.basename(real))
|
||||
return {"folder": folder, "android": f"/sdcard/{folder}", "linked": linked, "existing": existing, "missing": missing}
|
||||
return {"folder": folder, "android": f"/sdcard/{folder}", "linked": linked, "existing": existing,
|
||||
"missing": missing}
|
||||
|
||||
|
||||
def cmd_list_files(args):
|
||||
@@ -1084,24 +1089,28 @@ def cmd_prepare(args):
|
||||
want_sha = args.get("apk_sha256")
|
||||
same_apk = bool(want_sha and os.path.exists(apk) and os.path.getsize(apk) == args.get("apk_size")
|
||||
and sha256_file(apk) == want_sha)
|
||||
st = os.statvfs(base if os.path.exists(base) else os.path.dirname(base) if os.path.exists(os.path.dirname(base)) else HOME)
|
||||
st = os.statvfs(base if os.path.exists(base)
|
||||
else os.path.dirname(base) if os.path.exists(os.path.dirname(base)) else HOME)
|
||||
lepton, _ = lepton_path()
|
||||
return {"package": pkg, "base": base, "anchor": anchor, "appid": appid, "incoming": incoming,
|
||||
"installed": bool(dep), "same_apk": same_apk, "existing_obb": existing,
|
||||
"free_bytes": st.f_bavail * st.f_frsize, "lepton": lepton}
|
||||
|
||||
|
||||
LAUNCH_SH = r"""#!/usr/bin/env bash
|
||||
LAUNCH_SH = (r"""#!/usr/bin/env bash
|
||||
# Steam Frame launcher for {title} ({pkg}). Generated by FramePort.
|
||||
set -euo pipefail
|
||||
app_dir={base_q}
|
||||
[[ -d "$app_dir/lepton-app" ]] || {{ echo "Game files missing at $app_dir (storage not mounted?)" >&2; exit 1; }}
|
||||
# Some games (Unreal cloud saves) create folders without write/search permission inside Lepton,
|
||||
# which silently breaks saving. Repair them before and during every launch.
|
||||
fix_perms() {{ find "$app_dir/lepton-data/external" -type d ! -perm -u+rwx -exec chmod u+rwx {{}} + 2>/dev/null || true; }}
|
||||
fix_perms() {{ find "$app_dir/lepton-data/external" -type d ! -perm -u+rwx -exec chmod u+rwx {{}} + 2>/dev/null ||"""
|
||||
r""" true; }}
|
||||
# Android can't create an app's external files/cache folders inside Lepton ("Invalid mkdirs path ... not a known app
|
||||
# path"): getExternalCacheDir() then returns nothing, which breaks e.g. Whirligig's video player cache. Create them here.
|
||||
mkdir -p "$app_dir/lepton-data/external/Android/data/{pkg}/files" "$app_dir/lepton-data/external/Android/data/{pkg}/cache" 2>/dev/null || true
|
||||
# path"): getExternalCacheDir() then returns nothing, which breaks e.g. Whirligig's video player cache. Create them"""
|
||||
r""" here.
|
||||
mkdir -p "$app_dir/lepton-data/external/Android/data/{pkg}/files" """
|
||||
r""""$app_dir/lepton-data/external/Android/data/{pkg}/cache" 2>/dev/null || true
|
||||
fix_perms
|
||||
( while sleep 2 && kill -0 $$ 2>/dev/null; do fix_perms; done ) & permfix=$!
|
||||
export SteamAppId={appid}
|
||||
@@ -1127,11 +1136,12 @@ trap 'exit 143' TERM
|
||||
setsid {lepton_q} start >"$app_dir/launch.log" 2>&1 &
|
||||
child=$!
|
||||
wait "$child"
|
||||
"""
|
||||
""")
|
||||
|
||||
|
||||
def write_launcher(anchor, base, pkg, title, appid, lepton, env):
|
||||
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items() if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
|
||||
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
|
||||
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
|
||||
text = LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid,
|
||||
lepton_q=shlex.quote(lepton), extra_env=extra)
|
||||
path = os.path.join(anchor, "launch.sh")
|
||||
@@ -1227,8 +1237,9 @@ def cmd_finalize(args):
|
||||
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Steam Frame controller models (XR_FB_render_model)
|
||||
# The FrameBridge adapter (setting controller_models=1) serves these to games that ask the runtime for controller models.
|
||||
# --------------------------------------------------------------- Steam Frame controller models (XR_FB_render_model)
|
||||
# The FrameBridge adapter (setting controller_models=1) serves these to games that ask the runtime for controller
|
||||
# models.
|
||||
# They are converted here, on the Frame, from the SteamVR render models the Frame already has (OBJ + PNG) into glTF
|
||||
# binaries, so Valve's models never leave the device.
|
||||
MODELS_CACHE = os.path.join(HOME, ".local/share/frameport/controller-models")
|
||||
@@ -1614,7 +1625,8 @@ def windows_path(path):
|
||||
|
||||
|
||||
XR_LAYER = "XR_APILAYER_FRAMEPORT_timefix"
|
||||
XR_LAYER_ENV = (f'# FramePort OpenXR layer: OpenXR 1.1 -> 1.0 fallback for the Frame runtime (+ timespec time emulation)\n'
|
||||
XR_LAYER_ENV = (f'# FramePort OpenXR layer: OpenXR 1.1 -> 1.0 fallback for the Frame runtime'
|
||||
f' (+ timespec time emulation)\n'
|
||||
f'export XR_API_LAYER_PATH="$base/xrlayer${{XR_API_LAYER_PATH:+:$XR_API_LAYER_PATH}}"\n'
|
||||
f'export XR_ENABLE_API_LAYERS="{XR_LAYER}${{XR_ENABLE_API_LAYERS:+:$XR_ENABLE_API_LAYERS}}"\n')
|
||||
|
||||
@@ -1786,7 +1798,8 @@ def cmd_finalize_pcvr(args):
|
||||
dep = {"package": pkg, "kind": "pcvr", "appid": int(appid), "base": base, "title": title, "exe": exe_rel,
|
||||
"tags": args.get("tags") or [],
|
||||
"sha256": args.get("exe_sha256"), "revive": revive, "revive_version": args.get("revive_version"),
|
||||
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd, "libovr_redirect": bool(args.get("libovr_redirect")),
|
||||
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd,
|
||||
"libovr_redirect": bool(args.get("libovr_redirect")),
|
||||
"recipe": args.get("recipe"),
|
||||
"installed_by": "frameport",
|
||||
"files": {t: manifests.get(t) or {} for t in PCVR_TREES},
|
||||
@@ -1947,7 +1960,8 @@ def cmd_launch_test(args):
|
||||
elapsed = round(time.time() - start)
|
||||
if state == "EXITED": # Lepton dumps the container's logcat buffers (crash backtraces) after "Exited!"
|
||||
until = time.time() + 15
|
||||
while time.time() < until and "Dumping logcat" not in (open(log, errors="replace").read() if os.path.exists(log) else ""):
|
||||
while time.time() < until and "Dumping logcat" not in (open(log, errors="replace").read()
|
||||
if os.path.exists(log) else ""):
|
||||
time.sleep(1)
|
||||
time.sleep(2)
|
||||
run(["systemctl", "--user", "stop", unit])
|
||||
@@ -1955,7 +1969,8 @@ def cmd_launch_test(args):
|
||||
time.sleep(3)
|
||||
crash = os.path.join(STEAM, "logs", "lepton-logcats", f"steamlaunch-{appid}", "logcat-crash.log")
|
||||
fresh = os.path.exists(crash) and os.path.getmtime(crash) >= start - 1 and os.path.getsize(crash) > 0
|
||||
return {"state": state, "elapsed": elapsed, "log": log, "log_size": os.path.getsize(log) if os.path.exists(log) else 0,
|
||||
return {"state": state, "elapsed": elapsed, "log": log,
|
||||
"log_size": os.path.getsize(log) if os.path.exists(log) else 0,
|
||||
"crash_log": crash if fresh else None,
|
||||
"kernel_keys_before": keys, "kernel_keys_after": key_usage()}
|
||||
|
||||
@@ -2198,7 +2213,8 @@ def cmd_cleanup(args):
|
||||
# only ordinary folders in the home folder: never dot folders (.ssh, .steam, .local, .config…)
|
||||
raise AgentError(f"refusing to remove {extra}")
|
||||
if os.path.exists(p):
|
||||
freed += sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs) if os.path.isdir(p) else os.path.getsize(p)
|
||||
freed += (sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs)
|
||||
if os.path.isdir(p) else os.path.getsize(p))
|
||||
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else os.remove(p)
|
||||
removed.append(p)
|
||||
return {"removed": removed, "freed_bytes": freed}
|
||||
|
||||
+9
-8
@@ -47,11 +47,12 @@ update log is `logs/update.log` in the data folder.
|
||||
Command line: `frameport update` (`--check` only checks, exit code 10 = update available; `--yes` doesn't ask).
|
||||
`FRAMEPORT_NO_UPDATE_CHECK=1` turns all checks off.
|
||||
|
||||
## Oculus Rift (PC VR) games
|
||||
## PC VR games
|
||||
|
||||
Scan a folder of Rift games (one folder per game) or use **Add games → Add one game folder…**. FramePort finds the
|
||||
game's program and asks when there is more than one candidate. **Already patched** on a game page installs a copy
|
||||
unchanged. FramePort uses an installed Revive, or downloads a portable copy.
|
||||
PC VR games are Windows VR games (OpenXR, SteamVR or Oculus). Scan a folder of them (one folder per game) or use
|
||||
**Add games → Add one game folder…**. FramePort finds the game's program and asks when there is more than one
|
||||
candidate. **Already patched** on a game page installs a copy unchanged. Oculus-only games need Revive: FramePort uses
|
||||
an installed Revive, or downloads a portable copy.
|
||||
|
||||
- **Play from this PC:** Windows with Steam and SteamVR. **Install on this PC** adds the game to Steam; stream it to the
|
||||
Frame with Steam Link. If a game can't keep up with the refresh rate, FramePort lowers the rate and enables motion
|
||||
@@ -71,9 +72,9 @@ skip files that are already there.
|
||||
|
||||
- **Videos**, **Downloads** and **Documents** appear inside every Quest game as `/sdcard/Movies`, `/sdcard/Download`
|
||||
and `/sdcard/Documents`. Apps find files by browsing folders; Android's media index doesn't work on the Frame.
|
||||
- **Game storage** is one game's own `/sdcard` (mods, saves). A game's menu → **Add videos & files…** opens it.
|
||||
Video players that list only their own folder (e.g. 4XVR's "Internal Storage" = `4XPlayer`) find videos uploaded
|
||||
into that folder.
|
||||
- Under **Game storage**, each installed game has its own `/sdcard` (mods, saves). A game's menu → **Add videos and
|
||||
files…** opens it. Video players that list only their own folder (e.g. 4XVR's "Internal Storage" = `4XPlayer`) find
|
||||
videos uploaded into that folder.
|
||||
- **Home folder** shows everything in the Frame's home folder (hidden files with **Show hidden files**).
|
||||
|
||||
Command line: `frameport frame send <files> --to videos`.
|
||||
@@ -82,7 +83,7 @@ Command line: `frameport frame send <files> --to videos`.
|
||||
|
||||
- **Share working config…** (game menu): opens a prefilled GitHub issue with the game's patches and settings. Accepted
|
||||
configs become built-in recipes.
|
||||
- **Report a problem…** (game menu, or Settings → Problems & feedback): saves a diagnostics zip to Documents (logs,
|
||||
- **Report a problem…** (game menu, or Settings → Problems and feedback): saves a diagnostics zip to Documents (logs,
|
||||
recipe, device details; IP addresses, user names, home folders and Steam ids replaced) and opens a prefilled GitHub
|
||||
issue to attach it to. Command line: `frameport diag report <game>`, `frameport share-recipe <game>`.
|
||||
|
||||
|
||||
+20
-3
@@ -1,9 +1,22 @@
|
||||
[project]
|
||||
name = "frameport"
|
||||
dynamic = ["version"]
|
||||
description = "Port Meta Quest standalone games to the Valve Steam Frame (Lepton): suggested patches, automated validation, one-click install."
|
||||
description = "Run Meta Quest, Android and PC VR games on the Valve Steam Frame: per-game patches, validation and one-click install."
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0-only" }
|
||||
license = "GPL-3.0-only"
|
||||
license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||
authors = [{ name = "spoopyghosty0" }]
|
||||
keywords = ["steam-frame", "vr", "openxr", "meta-quest", "android", "lepton", "proton", "revive"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: X11 Applications",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Operating System :: MacOS",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Games/Entertainment",
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"flet>=1.0",
|
||||
@@ -21,6 +34,11 @@ dependencies = [
|
||||
"pyaxmlparser>=0.3.31",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/spoopyghosty0/frameport"
|
||||
Issues = "https://github.com/spoopyghosty0/frameport/issues"
|
||||
Releases = "https://github.com/spoopyghosty0/frameport/releases"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8", "pytest-timeout>=2.3", "ruff>=0.6", "flet-web>=1.0", "flet-cli>=1.0", "playwright>=1.40"]
|
||||
|
||||
@@ -48,7 +66,6 @@ packages = ["src/frameport"]
|
||||
testpaths = ["tests"]
|
||||
timeout = 120
|
||||
markers = [
|
||||
"device: needs a reachable Steam Frame (FRAMEPORT_FRAME=user@host)",
|
||||
"games: needs the local game dumps (FRAMEPORT_GAMES=<downloads dir>)",
|
||||
"native: compiles native code with the NDK from native/build.py (FRAMEPORT_NATIVE_TESTS=1; slow)",
|
||||
]
|
||||
|
||||
@@ -23,7 +23,8 @@ def signature(recipe) -> dict:
|
||||
pats = recipe.patches
|
||||
return {
|
||||
"overport": sorted(p for p in pats if base.get(p).category == "overport" and not base.get(p).default_on),
|
||||
"overport_removed": sorted(p for p in (x.id for x in base.all_patches() if x.category == "overport" and x.default_on)
|
||||
"overport_removed": sorted(p for p in (x.id for x in base.all_patches()
|
||||
if x.category == "overport" and x.default_on)
|
||||
if p not in pats),
|
||||
"frame": sorted(p for p in pats if base.get(p).category == "frame" and not base.get(p).default_on),
|
||||
"adapter": {p.split(".", 1)[1]: v.get("value") for p, v in sorted(pats.items()) if p.startswith("adapter.")},
|
||||
@@ -47,7 +48,8 @@ def main() -> int:
|
||||
if not catalog.lookup(a.package):
|
||||
continue
|
||||
want = signature(engine.suggest(a, use_catalog=True))
|
||||
want["status_unsupported"] = catalog.lookup(a.package).status == "unsupported" and "32-bit" in catalog.lookup(a.package).notes
|
||||
want["status_unsupported"] = (catalog.lookup(a.package).status == "unsupported"
|
||||
and "32-bit" in catalog.lookup(a.package).notes)
|
||||
got = signature(engine.suggest(a, use_catalog=False))
|
||||
diffs = {k: (want[k], got[k]) for k in want if want[k] != got[k]}
|
||||
total += len(want)
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -74,9 +74,11 @@ def main() -> int:
|
||||
if not keep or (g.get("recipe") or {}).get("status", "unknown") in keep}
|
||||
view = {**(lib.get("settings", {}).get("ui.library") or {}), "sort": args.sort, "platform": "all",
|
||||
"where": "all", "status": "all", "tags": []}
|
||||
out = {"games": games, "settings": {"ui.library": view, "migrations": lib.get("settings", {}).get("migrations", [])}}
|
||||
migrations = lib.get("settings", {}).get("migrations", [])
|
||||
out = {"games": games, "settings": {"ui.library": view, "migrations": migrations}}
|
||||
text = json.dumps(out, indent=1)
|
||||
leftovers = [o for o in re.findall(r'"((?:/|[A-Za-z]:\\\\)[^"]*)"', text) if not o.startswith(("D:/Games/",) + DEVICE)]
|
||||
leftovers = [o for o in re.findall(r'"((?:/|[A-Za-z]:\\\\)[^"]*)"', text)
|
||||
if not o.startswith(("D:/Games/",) + DEVICE)]
|
||||
if leftovers:
|
||||
print("not scrubbed:", leftovers[:10], file=sys.stderr)
|
||||
return 1
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
|
||||
@@ -252,7 +252,8 @@ def define_meta_permissions(manifest: bytes) -> tuple[bytes, list[str]] | None:
|
||||
start = struct.pack("<HHIII", RES_XML_START_ELEMENT, 16, 56, 0, 0xFFFFFFFF)
|
||||
start += struct.pack("<IIHHHHHH", 0xFFFFFFFF, perm_el, 20, 20, 1, 0, 0, 0)
|
||||
start += struct.pack("<IIIHBBI", android_ns, name_attr, value, 8, 0, TYPE_STRING, value)
|
||||
end = struct.pack("<HHIII", RES_XML_END_ELEMENT, 16, 24, 0, 0xFFFFFFFF) + struct.pack("<II", 0xFFFFFFFF, perm_el)
|
||||
end = (struct.pack("<HHIII", RES_XML_END_ELEMENT, 16, 24, 0, 0xFFFFFFFF)
|
||||
+ struct.pack("<II", 0xFFFFFFFF, perm_el))
|
||||
chunks += start + end
|
||||
manifest_el = next(el for el in x.elements() if el.name == "manifest")
|
||||
insert_at = manifest_el.offset + manifest_el.size
|
||||
|
||||
@@ -74,7 +74,8 @@ def cert_digest(apk: Path) -> str | None:
|
||||
|
||||
|
||||
def alignment_problems(apk: Path) -> list[str]:
|
||||
"""Stored entries must be 4-byte aligned; stored .so files page (16 KiB) aligned (what zipalign -c -P 16 4 checks)."""
|
||||
"""Stored entries must be 4-byte aligned; stored .so files page (16 KiB) aligned (what zipalign -c -P 16 4
|
||||
checks)."""
|
||||
bad = []
|
||||
with open(apk, "rb") as f, zipfile.ZipFile(apk) as z:
|
||||
for info in z.infolist():
|
||||
|
||||
@@ -18,7 +18,8 @@ class ApkWorkspace:
|
||||
self.add: dict[str, bytes] = {}
|
||||
self.rename: dict[str, str] = {} # old name -> new name (content unchanged unless replaced)
|
||||
self.remove: set[str] = set()
|
||||
abi = next((a for a in ("arm64-v8a", "armeabi-v7a") if any(n.startswith(f"lib/{a}/") for n in self.infos)), None)
|
||||
abi = next((a for a in ("arm64-v8a", "armeabi-v7a")
|
||||
if any(n.startswith(f"lib/{a}/") for n in self.infos)), None)
|
||||
self.abi = abi
|
||||
self.libdir = f"lib/{abi}/" if abi else ""
|
||||
|
||||
@@ -89,7 +90,8 @@ class ApkWorkspace:
|
||||
if name in self.replace:
|
||||
dst.writestr(zi, self.replace[name])
|
||||
else:
|
||||
with self._zip.open(info) as fin, dst.open(zi, "w", force_zip64=info.file_size > 0x7FFFFFFF) as fout:
|
||||
with (self._zip.open(info) as fin,
|
||||
dst.open(zi, "w", force_zip64=info.file_size > 0x7FFFFFFF) as fout):
|
||||
shutil.copyfileobj(fin, fout, 1 << 22)
|
||||
for name, data in self.add.items():
|
||||
zi = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
|
||||
|
||||
+16
-8
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -22,7 +22,7 @@ MAX_TOTAL = 24 << 20 # GitHub's attachment limit is 25 MB
|
||||
ENTRY_DROP = ("details", "art_source", "artwork", "art") # store metadata: public, big, no debug value
|
||||
ELF_SYMBOL_PREFIXES = ("ovr", "xr", "vrapi_", "OVR", "Java_", "JNI_", "eglGetProcAddress", "gl")
|
||||
|
||||
README = """# FramePort diagnostics bundle
|
||||
README = ("""# FramePort diagnostics bundle
|
||||
|
||||
Created by FramePort {version} on {created}. Personal data was replaced by placeholders such as `<ip>`, `<home>`,
|
||||
`<user>`, `<data>` (FramePort's data folder), `<frame>` and `<steam-id>` (counts in manifest.json → redactions).
|
||||
@@ -32,20 +32,22 @@ Created by FramePort {version} on {created}. Personal data was replaced by place
|
||||
| manifest.json | app / agent / tool versions, OS, which games are included, warnings |
|
||||
| app/app.log, app/jobs/*.log | the app's log and recent GUI job logs (build, install, launch test, ...) |
|
||||
| app/settings.json | library settings and catalog sources |
|
||||
| frame/info.json, frame/host.json | the Frame: SteamOS build, Lepton/Proton, OpenXR runtime + layers, podman, kernel keys |
|
||||
| frame/info.json, frame/host.json | the Frame: SteamOS build, Lepton/Proton, OpenXR runtime + layers, podman, kernel"""
|
||||
""" keys |
|
||||
| games/<pkg>/entry.json | the library entry: analysis, recipe, build checks, installs, last launch test |
|
||||
| games/<pkg>/recipe.yaml | the recipe in catalog form (catalog/games/<pkg>.yaml) |
|
||||
| games/<pkg>/triage.json | the newest launch log re-triaged with the triage signatures of this app version |
|
||||
| games/<pkg>/package/ | stand-in for the game files: file list, AndroidManifest.xml, ELF imports/exports / PE imports |
|
||||
| games/<pkg>/logs/ | launch-test logs saved on the PC |
|
||||
| games/<pkg>/target/ | from the Frame (or PC): launch.sh, settings.conf, deployment.json, launch/logcat/Proton/game logs, file list |
|
||||
| games/<pkg>/target/ | from the Frame (or PC): launch.sh, settings.conf, deployment.json, launch/logcat/Proton/game"""
|
||||
""" logs, file list |
|
||||
|
||||
## Debugging from this bundle
|
||||
1. `frameport diag inspect <this zip>` re-runs triage with the current signatures (catalog/triage.yaml).
|
||||
2. Look up symptoms in docs/PLAYBOOK.md; runtime facts are in docs/FRAME_RUNTIME.md.
|
||||
3. Compare `recipe.yaml` with `catalog/games/` recipes of games using the same engine / XR API (entry.json → analysis).
|
||||
4. Headless launch tests can't show the picture: "RUNNING" only proves startup (see FRAME_RUNTIME.md).
|
||||
"""
|
||||
""")
|
||||
|
||||
|
||||
def _tail(text: str, limit: int = MAX_LOG) -> str:
|
||||
@@ -100,7 +102,8 @@ class _Writer:
|
||||
if not logs:
|
||||
break
|
||||
n = logs[0]
|
||||
self.files[n] = b"[... cut to fit the 25 MB attachment limit ...]\n" + self.files[n][-(len(self.files[n]) // 4):]
|
||||
tail = self.files[n][-(len(self.files[n]) // 4):]
|
||||
self.files[n] = b"[... cut to fit the 25 MB attachment limit ...]\n" + tail
|
||||
self.warnings.append(f"{n} was cut to fit the size limit")
|
||||
size = zipped()
|
||||
|
||||
@@ -135,7 +138,8 @@ def env_info(target_info: dict | None = None) -> dict:
|
||||
proton = target_info.get("proton") or {}
|
||||
if isinstance(proton, dict):
|
||||
ready = proton.get("ready")
|
||||
out["frame"]["proton"] = ready.get("display_name") or ready.get("name") if isinstance(ready, dict) else ready
|
||||
out["frame"]["proton"] = (ready.get("display_name") or ready.get("name")
|
||||
if isinstance(ready, dict) else ready)
|
||||
return out
|
||||
|
||||
|
||||
@@ -370,7 +374,8 @@ def collect(packages: list[str] | None, target=None, reporter: Reporter | None =
|
||||
tag = packages[0] if packages and len(packages) == 1 else ("games" if packages else "app")
|
||||
dest = dest / f"FramePort-diag-{tag}-{time.strftime('%Y%m%d-%H%M%S')}.zip"
|
||||
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name in ["README.md", "manifest.json"] + sorted(n for n in w.files if n not in ("README.md", "manifest.json")):
|
||||
rest = sorted(n for n in w.files if n not in ("README.md", "manifest.json"))
|
||||
for name in ["README.md", "manifest.json"] + rest:
|
||||
z.writestr(name, w.files[name])
|
||||
reporter.check("Diagnostics bundle", True, f"{dest.name} ({dest.stat().st_size / 2**20:.1f} MB)")
|
||||
applog.log.info("diagnostics bundle %s", dest)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -91,7 +91,8 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
else:
|
||||
def apk_cb(done, size):
|
||||
reporter.check_cancel()
|
||||
reporter.progress(done / total, f"APK {done / 2**20:.0f}/{size / 2**20:.0f} MiB", speed=speed.text(done))
|
||||
reporter.progress(done / total, f"APK {done / 2**20:.0f}/{size / 2**20:.0f} MiB",
|
||||
speed=speed.text(done))
|
||||
xfer.put(plan.apk, posixpath.join(incoming, "game.apk"), apk_cb)
|
||||
sent += plan.apk.stat().st_size
|
||||
if to_send:
|
||||
@@ -107,7 +108,8 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
reporter.stage("Finalize install")
|
||||
ctx = install_context(plan.recipe)
|
||||
result = frame.agent(
|
||||
"finalize", package=plan.package, title=plan.title, dest=plan.dest, apk_sha256=apk_sha, tags=_tags(plan.package),
|
||||
"finalize", package=plan.package, title=plan.title, dest=plan.dest, apk_sha256=apk_sha,
|
||||
tags=_tags(plan.package),
|
||||
apk_name=plan.apk.name, settings=ctx.adapter_settings,
|
||||
files={k: v.decode() if isinstance(v, bytes) else v for k, v in ctx.files.items()}, env=ctx.env,
|
||||
obb_manifest=manifest or None,
|
||||
|
||||
+28
-15
@@ -136,7 +136,8 @@ def classify(name: str, new: bytes, old: bytes) -> tuple[str, str]:
|
||||
why = _overport_config(new, old)
|
||||
if why:
|
||||
return "expected", why
|
||||
if base == "libovrplatformloader.so" and elf.is_elf(new) and elf.is_elf(old) and _text_bytes(new) == _text_bytes(old):
|
||||
if (base == "libovrplatformloader.so" and elf.is_elf(new) and elf.is_elf(old)
|
||||
and _text_bytes(new) == _text_bytes(old)):
|
||||
ignore = {"libovrstubs.so", COMPAT}
|
||||
if set(elf.needed(new)) - ignore == set(elf.needed(old)) - ignore and COMPAT in elf.needed(new):
|
||||
return "expected", "same loader, linked to the platform compat library by the overport CLI (OVRPort 1.2.5+)"
|
||||
@@ -161,8 +162,10 @@ def classify(name: str, new: bytes, old: bytes) -> tuple[str, str]:
|
||||
return "UNEXPLAINED", "stub exports differ"
|
||||
if elf.is_elf(new) and elf.is_elf(old):
|
||||
if (elf.needed(new) == elf.needed(old) and elf.dyn_symbols(new, True) == elf.dyn_symbols(old, True)
|
||||
and elf.dyn_symbols(new, False) == elf.dyn_symbols(old, False) and _text_bytes(new) == _text_bytes(old)):
|
||||
return "equivalent", f"same code, symbols and NEEDED ({', '.join(elf.needed(new)[:2])}, …); different layout"
|
||||
and elf.dyn_symbols(new, False) == elf.dyn_symbols(old, False)
|
||||
and _text_bytes(new) == _text_bytes(old)):
|
||||
return "equivalent", (f"same code, symbols and NEEDED ({', '.join(elf.needed(new)[:2])}, …); "
|
||||
"different layout")
|
||||
return "UNEXPLAINED", f"ELF differs (NEEDED new {elf.needed(new)[:3]} vs old {elf.needed(old)[:3]})"
|
||||
return "UNEXPLAINED", "content differs"
|
||||
|
||||
@@ -182,13 +185,16 @@ def _redundant_stubs(new_apk: Path, old_apk: Path, name: str) -> bool:
|
||||
def compare(new_apk: Path, old_apk: Path) -> dict:
|
||||
a, b = _entries(new_apk), _entries(old_apk)
|
||||
rows = []
|
||||
stub_names = [n for n in b if n.endswith("/libovrstubs.so") and n not in a and _redundant_stubs(new_apk, old_apk, n)]
|
||||
stub_names = [n for n in b
|
||||
if n.endswith("/libovrstubs.so") and n not in a and _redundant_stubs(new_apk, old_apk, n)]
|
||||
for n in stub_names:
|
||||
rows.append((n, "expected", "known-good stub only shadowed libovrplatformcompat's real ovrMessageType_ToString; not needed"))
|
||||
rows.append((n, "expected", "known-good stub only shadowed libovrplatformcompat's real "
|
||||
"ovrMessageType_ToString; not needed"))
|
||||
loader = n.rsplit("/", 1)[0] + "/libovrplatformloader.so"
|
||||
if loader in a and a[loader] != b.get(loader):
|
||||
new_l, old_l = _read(new_apk, loader), _read(old_apk, loader)
|
||||
if [x for x in elf.needed(old_l) if x != "libovrstubs.so"] == elf.needed(new_l) and _text_bytes(new_l) == _text_bytes(old_l):
|
||||
old_needed = [x for x in elf.needed(old_l) if x != "libovrstubs.so"]
|
||||
if old_needed == elf.needed(new_l) and _text_bytes(new_l) == _text_bytes(old_l):
|
||||
rows.append((loader, "expected", "same, minus the redundant libovrstubs.so dependency"))
|
||||
a[loader] = b[loader]
|
||||
b.pop(n)
|
||||
@@ -257,10 +263,11 @@ def run_parity(known_good: Path, sources: Path, outdir: Path, report: Path, only
|
||||
row["primary"] = compare(res.apk, folder / f"{pkg}.apk")
|
||||
if res.alt_apk:
|
||||
known_alt = folder / f"{pkg}.alt-noforcequit.apk"
|
||||
row["alt"] = compare(res.alt_apk, known_alt) if known_alt.exists() else {"verdict": "UNEXPLAINED",
|
||||
"diffs": [("alt", "UNEXPLAINED", "no known-good alt APK")]}
|
||||
no_alt = {"verdict": "UNEXPLAINED", "diffs": [("alt", "UNEXPLAINED", "no known-good alt APK")]}
|
||||
row["alt"] = compare(res.alt_apk, known_alt) if known_alt.exists() else no_alt
|
||||
elif (folder / f"{pkg}.alt-noforcequit.apk").exists():
|
||||
row["alt"] = {"verdict": "UNEXPLAINED", "diffs": [("alt", "UNEXPLAINED", "recipe has no alternate build")]}
|
||||
row["alt"] = {"verdict": "UNEXPLAINED",
|
||||
"diffs": [("alt", "UNEXPLAINED", "recipe has no alternate build")]}
|
||||
row["new_apk"] = str(res.apk)
|
||||
row["new_alt_apk"] = str(res.alt_apk) if res.alt_apk else None
|
||||
# unsupported games (32-bit) fail the 64-bit check by design
|
||||
@@ -288,7 +295,8 @@ def run_parity(known_good: Path, sources: Path, outdir: Path, report: Path, only
|
||||
|
||||
|
||||
def write_report(results: list[dict], path: Path) -> None:
|
||||
lines = ["# Parity report", "", f"Generated {time.strftime('%Y-%m-%d %H:%M')}. Rebuilt each game from its original dump "
|
||||
lines = ["# Parity report", "",
|
||||
f"Generated {time.strftime('%Y-%m-%d %H:%M')}. Rebuilt each game from its original dump "
|
||||
"with its catalog recipe and compared every APK entry with the known-good build (META-INF excluded).", "",
|
||||
"| Game | Primary | Alternate | Failed checks | Notes |", "|---|---|---|---|---|"]
|
||||
for r in results:
|
||||
@@ -302,7 +310,8 @@ def write_report(results: list[dict], path: Path) -> None:
|
||||
failed = ", ".join(c["name"] for c in r.get("checks_failed", [])) or "none"
|
||||
lines.append(f"| {r['game']} | {r['primary']['verdict']} | {r.get('alt', {}).get('verdict', '—')} | {failed} | "
|
||||
+ "<br>".join(notes) + " |")
|
||||
ok = sum(1 for r in results if "error" not in r and all(r.get(k, {}).get("verdict") != "UNEXPLAINED" for k in ("primary", "alt")))
|
||||
ok = sum(1 for r in results
|
||||
if "error" not in r and all(r.get(k, {}).get("verdict") != "UNEXPLAINED" for k in ("primary", "alt")))
|
||||
lines += ["", f"**{ok}/{len(results)} games at parity** (identical, expected or equivalent)."]
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
@@ -345,7 +354,8 @@ def install_and_test(results_json: Path, target, baseline: Path | None, report:
|
||||
res = {"game": row["game"], "package": pkg, "apk": apk.name}
|
||||
try:
|
||||
reporter.stage(f"install {title}")
|
||||
installer.install(target.frame, installer.InstallPlan(pkg, title, apk, None, recipe, apk_only=True), reporter)
|
||||
plan = installer.InstallPlan(pkg, title, apk, None, recipe, apk_only=True)
|
||||
installer.install(target.frame, plan, reporter)
|
||||
installed_pkgs.append(pkg)
|
||||
res["install"] = "ok"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -375,14 +385,17 @@ def install_and_test(results_json: Path, target, baseline: Path | None, report:
|
||||
out = sorted(previous.values(), key=lambda x: x["game"].lower()) if previous else out
|
||||
lines = ["# Device parity (install + headless launch)", "",
|
||||
f"Generated {time.strftime('%Y-%m-%d %H:%M')}. APK-only reinstall through the FramePort agent, Steam "
|
||||
"shortcuts re-added in one batch, then a 45 s headless launch per game compared with the pre-change baseline.",
|
||||
"shortcuts re-added in one batch, then a 45 s headless launch per game compared with the pre-change "
|
||||
"baseline.",
|
||||
"", "| Game | Install | Shortcut | Baseline | Now | Furthest milestone | fps | Fatal findings |",
|
||||
"|---|---|---|---|---|---|---|---|"]
|
||||
for r in out:
|
||||
lines.append(f"| {r['game']} | {r.get('install')} | {r.get('shortcut', '')} | {r.get('baseline', '')} | "
|
||||
f"{r.get('state', '')}{' **REGRESSION**' if r.get('regression') else ''} | {r.get('milestone') or ''} | "
|
||||
f"{r.get('state', '')}{' **REGRESSION**' if r.get('regression') else ''} | "
|
||||
f"{r.get('milestone') or ''} | "
|
||||
f"{r.get('fps') or ''} | {', '.join(r.get('findings') or [])} |")
|
||||
regressions = [r for r in out if r.get("regression") or (r.get("install") not in ("ok", None) and "skipped" not in r["install"])]
|
||||
regressions = [r for r in out if r.get("regression")
|
||||
or (r.get("install") not in ("ok", None) and "skipped" not in r["install"])]
|
||||
lines += ["", f"**{len(out) - len(regressions)}/{len(out)} OK, {len(regressions)} regression(s).**"]
|
||||
report.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return not regressions
|
||||
@@ -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:
|
||||
|
||||
@@ -23,11 +23,11 @@ class FrameBridgeAdapter(Patch):
|
||||
id = "frame.adapter"
|
||||
title = "FrameBridge OpenXR adapter"
|
||||
description = (
|
||||
"Wraps overport's generic OpenXR loader (renamed libopenxr_loader_original.so). Fixes the Frame runtime's gaps: "
|
||||
"retries rejected GLES swapchain formats/MSAA (Frame takes sRGB only), drops unsupported instance extensions "
|
||||
"and layers, emulates XR_FB_passthrough (ALPHA_BLEND), Meta scene/spatial entities (guardian-sized room), "
|
||||
"XR_KHR_convert_timespec_time, and vertically flipped quad layers; maps Frame controllers to Touch; optionally "
|
||||
"serves Steam Frame controller models (XR_FB_render_model). "
|
||||
"Wraps overport's generic OpenXR loader (renamed libopenxr_loader_original.so). Fixes the Frame runtime's "
|
||||
"gaps: retries rejected GLES swapchain formats/MSAA (Frame takes sRGB only), drops unsupported instance "
|
||||
"extensions and layers, emulates XR_FB_passthrough (ALPHA_BLEND), Meta scene/spatial entities (guardian-sized "
|
||||
"room), XR_KHR_convert_timespec_time, and vertically flipped quad layers; maps Frame controllers to Touch; "
|
||||
"optionally serves Steam Frame controller models (XR_FB_render_model). "
|
||||
"Settings are in the Adapter settings group."
|
||||
)
|
||||
category = "frame"
|
||||
|
||||
@@ -49,8 +49,8 @@ class NoDebuggable(Patch):
|
||||
return Suggestion(True, f"Unreal Engine {v[0]}.{v[1]}: older UE4 makes JNI calls CheckJNI rejects "
|
||||
"(e.g. Time Stall aborted on GetStringUTFChars(NULL)).")
|
||||
if any(lib.startswith("libmetaxraudio") for lib in a.libs):
|
||||
return Suggestion(True, "Unreal build of Meta XR Audio: its telemetry lookup leaves a pending JNI exception "
|
||||
"that CheckJNI turns into an abort (e.g. NOPE Challenge).")
|
||||
return Suggestion(True, "Unreal build of Meta XR Audio: its telemetry lookup leaves a pending JNI "
|
||||
"exception that CheckJNI turns into an abort (e.g. NOPE Challenge).")
|
||||
return Suggestion(False, "Enable if the game aborts with 'JNI DETECTED ERROR' (CheckJNI).")
|
||||
|
||||
def applies(self, a):
|
||||
@@ -74,11 +74,12 @@ class MetaPermissions(Patch):
|
||||
from ..applicability import needs_scene
|
||||
|
||||
if needs_scene(a):
|
||||
return Suggestion(True, "Mixed-reality game that needs the room model: its 'use spatial data' permission must "
|
||||
"be granted (e.g. Demeter).")
|
||||
return Suggestion(True, "Mixed-reality game that needs the room model: its 'use spatial data' "
|
||||
"permission must be granted (e.g. Demeter).")
|
||||
scene = [p for p in a.meta_permissions if any(k in p for k in ("SCENE", "ANCHOR", "SPATIAL", "BOUNDARY"))]
|
||||
if scene:
|
||||
return Suggestion(False, "Uses Meta scene/anchor permissions (" + ", ".join(p.rsplit(".", 1)[-1] for p in scene[:3])
|
||||
names = ", ".join(p.rsplit(".", 1)[-1] for p in scene[:3])
|
||||
return Suggestion(False, "Uses Meta scene/anchor permissions (" + names
|
||||
+ "); enable if the game says it needs spatial data access.")
|
||||
return None
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -62,7 +62,8 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
recipe.alt_patches = ["patch_remove_unreal_force_quit"] # build a fallback in case it quits itself
|
||||
if analysis.only_32bit:
|
||||
recipe.status = "unsupported"
|
||||
recipe.notes = "32-bit only: the Steam Frame has no AArch32 support. Consider the PC (Rift) version via Revive."
|
||||
recipe.notes = ("32-bit only: the Steam Frame has no AArch32 support. Consider the PC (Rift) version "
|
||||
"via Revive.")
|
||||
elif analysis.no_arm64:
|
||||
recipe.status = "unsupported"
|
||||
recipe.notes = f"No 64-bit ARM code ({', '.join(analysis.abis)}): the Steam Frame can't run it."
|
||||
|
||||
@@ -6,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)
|
||||
|
||||
|
||||
|
||||
@@ -301,7 +301,8 @@ class PcReviveTarget(Target):
|
||||
for m in result.milestones:
|
||||
reporter.check(m, True)
|
||||
for f in result.findings:
|
||||
reporter.check(f.id, None if f.severity in ("warning", "info") else False, f"{f.diagnosis} [{f.evidence[:160]}]")
|
||||
reporter.check(f.id, None if f.severity in ("warning", "info") else False,
|
||||
f"{f.diagnosis} [{f.evidence[:160]}]")
|
||||
return result, log
|
||||
|
||||
def set_settings(self, package, settings):
|
||||
|
||||
+107
-54
@@ -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)]))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,8 +87,9 @@ HELP: dict[str, str] = _Translated({
|
||||
"kernel_keys": "Every game start on the Frame used to leak one kernel key, and after about 200 launches every game "
|
||||
"fails to start. FramePort switches that leak off, but keys already used only come back after a "
|
||||
"restart of the Frame.",
|
||||
"ui_scale": "Size of text and layout. Automatic follows Windows' display scaling halfway (150 % in Windows → 125 %) "
|
||||
"when FramePort runs under WSL, where the window doesn't get it from Windows itself.",
|
||||
"ui_scale": "Size of text and layout. Automatic follows Windows' display scaling halfway "
|
||||
"(150 % in Windows → 125 %) when FramePort runs under WSL, where the window doesn't get it from "
|
||||
"Windows itself.",
|
||||
"app_updates": "FramePort checks GitHub for a new release every few hours and shows it in the sidebar and the "
|
||||
"Library. \"Update now\" downloads it, checks its checksum (and on Windows its signature), closes "
|
||||
"FramePort and opens the new version; your games, settings and Frame connection are kept. With "
|
||||
|
||||
+21
-10
@@ -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))),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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]))),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
@@ -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)),
|
||||
|
||||
@@ -80,7 +80,8 @@ def platform_asset() -> str | None:
|
||||
|
||||
def update_from_release(release: dict, asset_name: str | None = None) -> Update | None:
|
||||
"""The Update a GitHub "latest release" JSON describes (None for drafts/pre-releases or unusable data)."""
|
||||
if not isinstance(release, dict) or release.get("draft") or release.get("prerelease") or not release.get("tag_name"):
|
||||
if (not isinstance(release, dict) or release.get("draft") or release.get("prerelease")
|
||||
or not release.get("tag_name")):
|
||||
return None
|
||||
assets = {a.get("name"): a.get("browser_download_url") for a in release.get("assets") or [] if isinstance(a, dict)}
|
||||
asset_name = asset_name if asset_name is not None else platform_asset()
|
||||
@@ -305,7 +306,8 @@ def _signer_thumbprint(exe: Path) -> str | None:
|
||||
ps, env = _powershell()
|
||||
try:
|
||||
out = subprocess.run([ps, "-NoProfile", "-NonInteractive", "-Command",
|
||||
f"(Get-AuthenticodeSignature -LiteralPath {_ps_quote(exe)}).SignerCertificate.Thumbprint"],
|
||||
f"(Get-AuthenticodeSignature -LiteralPath {_ps_quote(exe)})"
|
||||
".SignerCertificate.Thumbprint"],
|
||||
capture_output=True, text=True, timeout=60, env=env)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
_log.warning("reading the signature of %s failed: %s", exe, exc)
|
||||
@@ -403,11 +405,12 @@ def swap_script(app: Path, target: Path, pid: int, platform: str, relaunch: bool
|
||||
if platform == "win32":
|
||||
start = f"Start-Process -FilePath {_ps_quote(target / 'FramePort.exe')} -WorkingDirectory {_ps_quote(target)}" \
|
||||
if relaunch else "Write-Log 'not relaunching'"
|
||||
return f"""$ErrorActionPreference = 'Stop'
|
||||
return (f"""$ErrorActionPreference = 'Stop'
|
||||
$log = {_ps_quote(log)}
|
||||
function Write-Log($m) {{ Add-Content -LiteralPath $log -Value ("$(Get-Date -Format s) $m") }}
|
||||
Write-Log 'update: waiting for FramePort (pid {pid}) to exit'
|
||||
for ($i = 0; $i -lt 240 -and (Get-Process -Id {pid} -ErrorAction SilentlyContinue); $i++) {{ Start-Sleep -Milliseconds 500 }}
|
||||
for ($i = 0; $i -lt 240 -and (Get-Process -Id {pid} -ErrorAction SilentlyContinue); $i++) {{ Start-Sleep"""
|
||||
f""" -Milliseconds 500 }}
|
||||
Start-Sleep -Milliseconds 500
|
||||
$src = {_ps_quote(app)}; $dst = {_ps_quote(target)}
|
||||
$backup = Join-Path (Split-Path -Parent $src) 'previous'
|
||||
@@ -431,7 +434,7 @@ try {{
|
||||
Copy-Item -Path (Join-Path $backup '*') -Destination $dst -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}}
|
||||
{start}
|
||||
"""
|
||||
""")
|
||||
if platform == "darwin":
|
||||
start = f"open {_sh_quote(target)}" if relaunch else "echo 'not relaunching' >>\"$log\""
|
||||
extra = f"xattr -dr com.apple.quarantine {_sh_quote(target)} 2>/dev/null || true"
|
||||
@@ -474,8 +477,8 @@ def apply(app: Path, target: Path | None = None, relaunch: bool = True, pid: int
|
||||
started = log.stat().st_size if log.exists() else 0
|
||||
if platform == "win32":
|
||||
ps, env = _powershell()
|
||||
proc = spawn_hidden([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
|
||||
"-File", str(script)], env=env)
|
||||
proc = spawn_hidden([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle",
|
||||
"Hidden", "-File", str(script)], env=env)
|
||||
else:
|
||||
proc = subprocess.Popen(["/bin/sh", str(script)], start_new_session=not wait, close_fds=True,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
+8
-4
@@ -153,7 +153,8 @@ def test_pcvr_install_flow(monkeypatch, tmp_path):
|
||||
(inc / "revive").mkdir(exist_ok=True)
|
||||
(inc / "revive/ReviveInjector.exe").write_bytes(b"MZ")
|
||||
(inc / "xrlayer").mkdir(exist_ok=True)
|
||||
layer_json = '{"api_layer": {"name": "XR_APILAYER_FRAMEPORT_timefix", "library_path": "./libxr_frameport_timefix.so"}}'
|
||||
layer_json = ('{"api_layer": {"name": "XR_APILAYER_FRAMEPORT_timefix", '
|
||||
'"library_path": "./libxr_frameport_timefix.so"}}')
|
||||
(inc / "xrlayer/XR_APILAYER_FRAMEPORT_timefix.json").write_text(layer_json)
|
||||
(inc / "xrlayer/libxr_frameport_timefix.so").write_bytes(b"ELF")
|
||||
manifests = {"game": {"Space Game.exe": 5, "Space Game_Data/level0": 4}, "revive": {"ReviveInjector.exe": 2},
|
||||
@@ -250,7 +251,8 @@ def test_run_tree_kills_the_whole_group(monkeypatch, tmp_path):
|
||||
out, code = a.run_tree(["bash", "-c", "echo started; (sleep 60 &) ; sleep 60"], dict(a.os.environ), str(tmp_path),
|
||||
str(tmp_path / "log"), 2)
|
||||
assert code is None and "started" in out and time.time() - start < 20
|
||||
out, code = a.run_tree(["bash", "-c", "echo ok; exit 3"], dict(a.os.environ), str(tmp_path), str(tmp_path / "l2"), 10)
|
||||
out, code = a.run_tree(["bash", "-c", "echo ok; exit 3"], dict(a.os.environ), str(tmp_path), str(tmp_path / "l2"),
|
||||
10)
|
||||
assert (out.strip(), code) == ("ok", 3)
|
||||
|
||||
|
||||
@@ -329,7 +331,8 @@ def test_pcvr_launcher_oculus_hmd_helper(monkeypatch, tmp_path):
|
||||
assert helper in cmd and cmd.index(helper) < cmd.index("ReviveInjector.exe")
|
||||
# everything after the helper is a Windows command line: the injector by its Z: path, then the game + args
|
||||
assert f"'Z:{base}/revive/ReviveInjector.exe'".replace("/", "\\") in cmd
|
||||
assert cmd.index("ReviveInjector.exe") < cmd.index("/openxr") < cmd.index("UEGame.exe") < cmd.index("-nocrashreports")
|
||||
assert (cmd.index("ReviveInjector.exe") < cmd.index("/openxr") < cmd.index("UEGame.exe")
|
||||
< cmd.index("-nocrashreports"))
|
||||
assert a.deployment("rift.ue_game")["oculus_hmd"] is True
|
||||
cmd, base = install("rift.ue_direct", revive=False)
|
||||
assert f"{base}/helpers/fp_oculushmd.exe" in cmd and "ReviveInjector" not in cmd and "'Z:" in cmd
|
||||
@@ -361,7 +364,8 @@ def test_launch_uses_steam_shortcut(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
||||
anchor.mkdir(parents=True)
|
||||
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 2546384938, "base": str(anchor)}))
|
||||
deployment = {"package": "com.x.y", "appid": 2546384938, "base": str(anchor)}
|
||||
(anchor / "deployment.json").write_text(json.dumps(deployment))
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **k: (calls.append(cmd), SimpleNamespace(returncode=0, stdout=""))[1])
|
||||
r = a.cmd_launch({"package": "com.x.y"})
|
||||
|
||||
+6
-4
@@ -15,11 +15,12 @@ from frameport.recommend import catalog
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
LOG = """
|
||||
LOG = ("""
|
||||
09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1150:com.example.game/u0a55 for activity
|
||||
09-28 17:39:02.000 1150 1170 E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "ovr_User_GetLoggedInUser" referenced by "libgame.so"
|
||||
09-28 17:39:02.000 1150 1170 E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol"""
|
||||
""" "ovr_User_GetLoggedInUser" referenced by "libgame.so"
|
||||
connecting to 192.168.1.23 from /home/alice/.local/share/frameport, steam id 76561198012345678
|
||||
"""
|
||||
""")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -218,7 +219,8 @@ def test_catalog_from_issue(tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["package: ../../etc/passwd\ntitle: x", "package: com.a.b\ntitle: x\nevil: 1",
|
||||
"package: com.a.b\ntitle: x\nstatus: great", "package: com.a.b\ntitle: x\nframe: [\"$(rm)\"]",
|
||||
"package: com.a.b\ntitle: x\nstatus: great",
|
||||
"package: com.a.b\ntitle: x\nframe: [\"$(rm)\"]",
|
||||
"package: com.a.b\ntitle: x\ndevice_files: {'../x': 1}", "- a list"])
|
||||
def test_catalog_from_issue_rejects(tmp_path, bad):
|
||||
s = _script()
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ def test_stub_library(abi):
|
||||
def _with_note(lib: bytes) -> bytes:
|
||||
"""Turn the stub's PT_GNU_STACK into a PT_NOTE so add_needed can reuse it (the common case)."""
|
||||
buf = bytearray(lib)
|
||||
phoff, phentsize, phnum = struct.unpack_from("<Q", buf, 32)[0], struct.unpack_from("<H", buf, 54)[0], struct.unpack_from("<H", buf, 56)[0]
|
||||
phoff = struct.unpack_from("<Q", buf, 32)[0]
|
||||
phentsize, phnum = struct.unpack_from("<H", buf, 54)[0], struct.unpack_from("<H", buf, 56)[0]
|
||||
for i in range(phnum):
|
||||
if struct.unpack_from("<I", buf, phoff + i * phentsize)[0] == 0x6474E551:
|
||||
struct.pack_into("<I", buf, phoff + i * phentsize, 4)
|
||||
|
||||
+2
-1
@@ -41,7 +41,8 @@ def test_heuristics_reproduce_catalog(dumps):
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location("ev", Path(__file__).resolve().parents[1] / "scripts/eval_heuristics.py")
|
||||
script = Path(__file__).resolve().parents[1] / "scripts/eval_heuristics.py"
|
||||
spec = importlib.util.spec_from_file_location("ev", script)
|
||||
ev = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(ev)
|
||||
exact = total = 0
|
||||
|
||||
@@ -19,7 +19,8 @@ def test_mixed_reality_only_scene_game():
|
||||
|
||||
|
||||
def test_mixed_reality_without_scene():
|
||||
_, r = suggest(extra={"mr_only": True, "meta_permissions_used": ["com.oculus.permission.USE_ANCHOR_API"], "size": 1})
|
||||
_, r = suggest(extra={"mr_only": True, "meta_permissions_used": ["com.oculus.permission.USE_ANCHOR_API"],
|
||||
"size": 1})
|
||||
assert "patch_force_passthrough" in r.patches and "adapter.scene_emul" not in r.patches
|
||||
|
||||
|
||||
@@ -44,7 +45,8 @@ def test_old_unreal_nodebug_and_alt_build():
|
||||
|
||||
|
||||
def test_cryengine_disables_vrs():
|
||||
_, r = suggest(engine="CryEngine", xr="VrApi", direct_vrapi=True, libs=["libCrySystem.so", "libCryRenderVulkan.so", "libvrapi.so"],
|
||||
_, r = suggest(engine="CryEngine", xr="VrApi", direct_vrapi=True,
|
||||
libs=["libCrySystem.so", "libCryRenderVulkan.so", "libvrapi.so"],
|
||||
graphics="Vulkan (declared in manifest)")
|
||||
assert r.params("device.files")["files"]["user.cfg"].startswith("r_variable_rate_shading = 0")
|
||||
assert "frame.vrapi_bridge" in r.patches
|
||||
|
||||
@@ -57,8 +57,10 @@ int main(void) {
|
||||
}
|
||||
// equirect2: full sphere, image centre (u 0.5) straight ahead (-Z), +X to the right, v 1 at the top
|
||||
float u, v;
|
||||
CHECK(lm_equirect2_uv((XrVector3f){0, 0, -1}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && NEAR(u, 0.5f) && NEAR(v, 0.5f));
|
||||
CHECK(lm_equirect2_uv((XrVector3f){1, 0, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && NEAR(u, 0.75f) && NEAR(v, 0.5f));
|
||||
CHECK(lm_equirect2_uv((XrVector3f){0, 0, -1}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v)
|
||||
&& NEAR(u, 0.5f) && NEAR(v, 0.5f));
|
||||
CHECK(lm_equirect2_uv((XrVector3f){1, 0, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v)
|
||||
&& NEAR(u, 0.75f) && NEAR(v, 0.5f));
|
||||
CHECK(lm_equirect2_uv((XrVector3f){-1, 0, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && NEAR(u, 0.25f));
|
||||
CHECK(lm_equirect2_uv((XrVector3f){0, 1, 0}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && NEAR(v, 1.0f));
|
||||
CHECK(lm_equirect2_uv((XrVector3f){0, -1, -0.0001f}, 2 * LM_PI, LM_PI / 2, -LM_PI / 2, &u, &v) && v < 0.001f);
|
||||
@@ -72,10 +74,12 @@ int main(void) {
|
||||
CHECK(lm_face_visible(1, ahead, cos_limit) && lm_face_visible(4, ahead, cos_limit));
|
||||
CHECK(lm_face_score(0, ahead) > lm_face_score(1, ahead));
|
||||
// pose algebra (stable_local): (A∘B)∘B⁻¹ = A
|
||||
XrPosef a = {lm_axis_angle(0, 1, 0, 0.7f), {0.3f, -0.2f, 1.1f}}, b = {lm_axis_angle(1, 0, 0, -0.4f), {-0.5f, 0.1f, 0.2f}};
|
||||
XrPosef a = {lm_axis_angle(0, 1, 0, 0.7f), {0.3f, -0.2f, 1.1f}},
|
||||
b = {lm_axis_angle(1, 0, 0, -0.4f), {-0.5f, 0.1f, 0.2f}};
|
||||
XrPosef back = lm_pose_mul(lm_pose_mul(a, b), lm_pose_inv(b));
|
||||
CHECK(VNEAR(back.position, a.position.x, a.position.y, a.position.z));
|
||||
CHECK(NEAR(fabsf(back.orientation.w), fabsf(a.orientation.w)) && NEAR(lm_angle(lm_qmul(back.orientation, lm_qconj(a.orientation))), 0));
|
||||
CHECK(NEAR(fabsf(back.orientation.w), fabsf(a.orientation.w))
|
||||
&& NEAR(lm_angle(lm_qmul(back.orientation, lm_qconj(a.orientation))), 0));
|
||||
CHECK(NEAR(lm_angle(lm_axis_angle(0, 1, 0, 0.5f)), 0.5f));
|
||||
puts("ok");
|
||||
return 0;
|
||||
|
||||
@@ -22,7 +22,8 @@ def test_needed_layout_equivalent():
|
||||
|
||||
|
||||
def test_settings_classification():
|
||||
kind, why = classify("lib/arm64-v8a/libframe_settings.so", b"scale=1.0\nscene_emul=1\n", b"scene_emul=1\nscale=1.0\n")
|
||||
kind, why = classify("lib/arm64-v8a/libframe_settings.so", b"scale=1.0\nscene_emul=1\n",
|
||||
b"scene_emul=1\nscale=1.0\n")
|
||||
assert kind == "equivalent"
|
||||
|
||||
|
||||
|
||||
+13
-7
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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": {}}))
|
||||
|
||||
@@ -36,8 +36,10 @@ int main(int argc, char **argv) {
|
||||
if (argc > 2) { CHECK(!render_models_available); return 0; }
|
||||
CHECK(render_models_available);
|
||||
emulate_render_model = 1;
|
||||
PFN_xrEnumerateRenderModelPathsFB enumerate = (PFN_xrEnumerateRenderModelPathsFB)render_model_emulation("xrEnumerateRenderModelPathsFB");
|
||||
PFN_xrGetRenderModelPropertiesFB props_fn = (PFN_xrGetRenderModelPropertiesFB)render_model_emulation("xrGetRenderModelPropertiesFB");
|
||||
PFN_xrEnumerateRenderModelPathsFB enumerate =
|
||||
(PFN_xrEnumerateRenderModelPathsFB)render_model_emulation("xrEnumerateRenderModelPathsFB");
|
||||
PFN_xrGetRenderModelPropertiesFB props_fn =
|
||||
(PFN_xrGetRenderModelPropertiesFB)render_model_emulation("xrGetRenderModelPropertiesFB");
|
||||
PFN_xrLoadRenderModelFB load = (PFN_xrLoadRenderModelFB)render_model_emulation("xrLoadRenderModelFB");
|
||||
CHECK(enumerate && props_fn && load && !render_model_emulation("xrCreatePassthroughFB"));
|
||||
uint32_t n = 0;
|
||||
@@ -51,10 +53,12 @@ int main(int argc, char **argv) {
|
||||
XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_2_BIT_FB};
|
||||
XrRenderModelPropertiesFB props = {XR_TYPE_RENDER_MODEL_PROPERTIES_FB, &req};
|
||||
CHECK(props_fn(XR_NULL_HANDLE, right, &props) == XR_SUCCESS);
|
||||
CHECK(props.vendorId == 0x28DE && strstr(props.modelName, "Right") && props.modelKey != XR_NULL_RENDER_MODEL_KEY_FB);
|
||||
CHECK(props.vendorId == 0x28DE && strstr(props.modelName, "Right")
|
||||
&& props.modelKey != XR_NULL_RENDER_MODEL_KEY_FB);
|
||||
CHECK(props.flags == XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_2_BIT_FB);
|
||||
XrRenderModelPropertiesFB bad = {XR_TYPE_RENDER_MODEL_PROPERTIES_FB};
|
||||
CHECK(props_fn(XR_NULL_HANDLE, 12345, &bad) == XR_ERROR_PATH_UNSUPPORTED && bad.modelKey == XR_NULL_RENDER_MODEL_KEY_FB);
|
||||
CHECK(props_fn(XR_NULL_HANDLE, 12345, &bad) == XR_ERROR_PATH_UNSUPPORTED
|
||||
&& bad.modelKey == XR_NULL_RENDER_MODEL_KEY_FB);
|
||||
XrRenderModelLoadInfoFB info = {XR_TYPE_RENDER_MODEL_LOAD_INFO_FB, NULL, props.modelKey};
|
||||
XrRenderModelBufferFB buf = {XR_TYPE_RENDER_MODEL_BUFFER_FB};
|
||||
CHECK(load(XR_NULL_HANDLE, &info, &buf) == XR_SUCCESS && buf.bufferCountOutput == 11);
|
||||
@@ -75,8 +79,9 @@ int main(int argc, char **argv) {
|
||||
def harness(tmp_path_factory):
|
||||
d = tmp_path_factory.mktemp("render_model")
|
||||
(d / "harness.c").write_text(HARNESS)
|
||||
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function", "-Wno-unused-variable",
|
||||
"-Wno-missing-field-initializers", "-I", str(INC), "-I", str(ROOT / "native/adapter"),
|
||||
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
|
||||
"-Wno-unused-variable", "-Wno-missing-field-initializers", "-I", str(INC),
|
||||
"-I", str(ROOT / "native/adapter"),
|
||||
str(d / "harness.c"), "-o", str(d / "harness")], check=True)
|
||||
return d / "harness"
|
||||
|
||||
|
||||
+7
-4
@@ -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")))
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user