Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5603edeef7 | ||
|
|
5c47185938 | ||
|
|
fefdc19cac | ||
|
|
db6ccece32 | ||
|
|
59018cb396 | ||
|
|
70216de397 | ||
|
|
bef61563d5 | ||
|
|
1b728d7c75 | ||
|
|
60cde5f846 | ||
|
|
c3a63bbd85 | ||
|
|
bbe76ab888 | ||
|
|
b89e48cec0 | ||
|
|
dc020a2cb7 | ||
|
|
cc3febfae4 | ||
|
|
e729f3f4bc | ||
|
|
2fecfbf6ea | ||
|
|
6e0bb8ee59 | ||
|
|
26679114ed | ||
|
|
b8e49a3419 | ||
|
|
f196cf556d | ||
|
|
1b0345b15b | ||
|
|
4508608dcf | ||
|
|
3e16199e64 | ||
|
|
a7fa1c06d7 | ||
|
|
dabd9b2095 | ||
|
|
0774536473 | ||
|
|
4a7fe3645c | ||
|
|
204aa70ada | ||
|
|
8bb3df0ec8 | ||
|
|
d9f9695360 | ||
|
|
d45d391683 | ||
|
|
b0682ee570 | ||
|
|
d1cf305c0a | ||
|
|
d22f416489 | ||
|
|
2dc92a090f | ||
|
|
23a47880e0 | ||
|
|
da399ef77a | ||
|
|
f0192c0e9d | ||
|
|
cad54bb2dd | ||
|
|
507dd4ca6e | ||
|
|
a361cdc84d | ||
|
|
fa7567503f | ||
|
|
1c90669eb4 | ||
|
|
942a71dd54 | ||
|
|
e462907c49 | ||
|
|
60efbbe7a9 | ||
|
|
321588d94b | ||
|
|
b4ae5e0023 | ||
|
|
4ba148908a | ||
|
|
7af21e17ce | ||
|
|
fa3544565a | ||
|
|
f7b59a75b3 | ||
|
|
58eaab3c19 | ||
|
|
3d32be1a98 | ||
|
|
8da5a44efb | ||
|
|
51f0a5d75c | ||
|
|
b06cc9659b | ||
|
|
bc40325c2d |
No files matched your search
@@ -1,6 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
@@ -12,10 +13,13 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v10.2.0
|
||||
- run: uv sync --extra dev
|
||||
- name: Lint
|
||||
run: uv run ruff check .
|
||||
- run: uv run pytest -m "not games and not device"
|
||||
|
||||
bundle:
|
||||
needs: test
|
||||
if: github.event_name != 'push' || startsWith(github.ref, 'refs/tags/v') # pushes to main: tests only
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # build provenance attestations (Sigstore)
|
||||
@@ -31,9 +35,10 @@ jobs:
|
||||
include:
|
||||
- os: windows-latest
|
||||
archive: FramePort-windows-x64.zip
|
||||
- os: macos-latest
|
||||
- os: macos-26 # Apple Silicon + Xcode 26 (macos-15's Xcode 16.4 fails a Flutter plugin; macos-latest
|
||||
# went unassigned on 2026-10-02)
|
||||
archive: FramePort-macos-arm64.zip
|
||||
- os: ubuntu-latest
|
||||
- os: ubuntu-22.04 # oldest supported: the bundle needs the build host's GLib/glibc or newer
|
||||
archive: FramePort-linux-x64.tar.gz
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
|
||||
@@ -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/
|
||||
@@ -14,7 +14,14 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
status_row, art_fill, confirm, `update()` = safe update: in Flet 1.0 reading `.page` of an unmounted control
|
||||
raises), `jobs.py` (background FIFO job queue, one at a time, cancel via Reporter; no Flet), `views/`
|
||||
(library: search/filters/tags/sort as pure tested helpers; game: hero + one-click install, patches under
|
||||
"Customize"; frame: device + readiness + installed, or connect wizard; settings; welcome; activity panel).
|
||||
"Customize"; frame: device + readiness + installed, or connect wizard; files: the Frame's file manager (persistent
|
||||
like the library; locations = Videos/Downloads/Documents from agent `storage_targets`, each installed Quest game's
|
||||
storage, the home folder; SFTP via `install/files.py` list_dir/upload/download/make_dir/rename/delete, all
|
||||
confined to the location by `files.inside`; Lepton's links in a game's storage can't be renamed/deleted; replaced
|
||||
the "Send files" dialog, game menu → "Add videos & files…" = `go("files", pkg)`; multi-select bar (download/
|
||||
delete); drag-and-drop from the OS via the `flet-dropzone` extension (Apache-2.0, Flutter `desktop_drop`), which
|
||||
only a `flet build` bundle contains: `files.dropzone_available()` keeps it out of source runs and the PyInstaller
|
||||
fallback, which would show an unknown control); settings; welcome; activity panel).
|
||||
User tags live in library entries (`tags`), filters in library setting `ui.library`.
|
||||
**Performance rules** (the app froze before): never put image bytes in controls — artwork is served by URL from the
|
||||
GUI assets dir (= user data dir; `ft.run(assets_dir=…)`), as thumbnails (`artwork/thumbs.py`, Pillow); the
|
||||
@@ -36,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
|
||||
@@ -74,7 +82,8 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
package or Rift match) + Steam appdetails (exact title: developer, release date, up to 6 screenshots → artwork
|
||||
`shot_N.jpg`). Meta store pages reject scraping, so Oculus exclusives have no screenshots. Genres become automatic
|
||||
tags. Steam shortcuts get a complete composed art set (`artwork/steam.py`: 600×900 / 920×430 / 1920×620 / logo /
|
||||
256 icon, blurred-backdrop compositing for square-only covers) and tags: how it runs, the original platform
|
||||
256 icon, blurred-backdrop compositing for square-only covers; no store art (at most the APK icon) → a placeholder
|
||||
set: the name on a colour from the title hash + the APK icon, `steam_set_for`; 2D Android apps get no store lookups) and tags: how it runs, the original platform
|
||||
(Meta Quest / Oculus Rift), genres, user tags — merged with tags set in Steam (non-Steam shortcuts can't hold a
|
||||
description).
|
||||
- Quest/Rift twins stay separate entries, shown and named in Steam "Title (Quest)"/"(Rift)" (`core/titles.py`).
|
||||
@@ -132,11 +141,14 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
[--yes]`, once-a-day stderr hint read from the cache only (`refresh_cache()` in a daemon thread writes no
|
||||
settings, to avoid read-modify-write races). `scripts/update_smoke.py <archive>` runs the real extract + swap script
|
||||
(no relaunch) — CI runs it on all three OS with the archive it just built.
|
||||
Verified: Linux + Windows (native, signer check) smoke with the v0.2.0 bundles; macOS only via the CI smoke.
|
||||
Verified end to end (2026-10-02): real 0.3.3 bundles on Windows and Linux (Ubuntu 22.04/WSLg) found, staged and
|
||||
installed 0.3.4 ("Install updates automatically" path) and relaunched as 0.3.4 with settings kept. Not yet clicked
|
||||
by hand: the "Update now" button path (same apply(), called from the running app). macOS: CI smoke only.
|
||||
- `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.
|
||||
@@ -149,9 +161,9 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
UV_LINK_MODE=copy uv sync --extra dev # creates/updates ./.venv
|
||||
uv run pytest # unit tests (no device, no game files)
|
||||
uv run frameport --help # CLI; uv run frameport-gui for the GUI
|
||||
uv run frameport parity --known-good <PATCHED/_known-good-*> --sources "<VR CyberDeck downloads>"
|
||||
uv run frameport parity --known-good <PATCHED/_known-good-*> --sources "<folder with the game dumps>"
|
||||
```
|
||||
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
|
||||
@@ -159,7 +171,7 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
|
||||
artwork, frames.json, the app's SSH key which the dev Frame authorizes).
|
||||
- Device checks: `frameport test <pkg>` / `frameport parity-device --results <parity.json> --baseline <launch.txt>
|
||||
[--test-only]`; the pre-FramePort baseline is `PATCHED/_known-good-2026-09-28/_frame-state/baseline-launch.txt`.
|
||||
- Docs screenshots (`docs/images/`): `python scripts/scrub_library.py ~/.local/share/frameport <dir>` (copies
|
||||
- Docs screenshots (`docs/images/`): `python scripts/scrub_library.py ~/.local/share/frameport <dir> --status works,issues` (copies
|
||||
library + artwork only; titles replace folder names, local paths → `D:/Games/...`, sort by size) then
|
||||
`FRAMEPORT_HOME=<dir> python scripts/ui_smoke.py --out <shots> --docs --fake-frame --game <pkg>` (set
|
||||
FRAMEPORT_JAVA/_OVERPORT_JAR/_APKSIGNER_JAR so no tool download toast appears; `--viewport 1280x2600` + crop for the
|
||||
@@ -178,6 +190,25 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
|
||||
an `expand` child in a `wrap=True` Row).
|
||||
- Stopping the GUI: `pkill -f` patterns match your own shell — use `pgrep -f "[b]in/frameport-gui|[f]let-desktop-light"`.
|
||||
|
||||
## Round-2 polish (2026-10-02)
|
||||
- `errors.py`: `explain(exc)` (plain sentence for GUI + CLI), `is_connection_error`. Install/test jobs for the Frame
|
||||
(`Job.needs_frame`) that lose the connection go back to the queue front and the queue pauses (`jobs.paused`);
|
||||
`app._poll` retries every 10 s and resumes (owner-verified on the device 2026-10-02: the queue resumed after the
|
||||
connection was lost). Wake lock: agent v30 `keep_awake` (systemd-inhibit idle:sleep, idle-only
|
||||
fallback because polkit `inhibit-block-sleep` is auth_admin for non-local sessions), held while Frame jobs exist.
|
||||
- **Already converted inputs** (`analysis.is_overport_output`, e.g. the owner's library points at `PATCHED/` copies)
|
||||
are not converted again: a second OVRPort run replaced libovrplatformloader.so and dropped its DT_NEEDED on
|
||||
libovrstubs.so (Wallace & Gromit / Espire 2 crashed: cannot locate symbol ovr_…). Alt builds come from the saved
|
||||
`<pkg>.alt-noforcequit.apk`, else one OVRPort run (frame.ovrstubs relinks). `missing_ovr_symbols` only counts
|
||||
linked stub/compat libs. Converted copies in `output/` are removed after a Frame install (`build.keep_copies`).
|
||||
- Art: `.picked` marker = user's pick, automatic fetches never overwrite it and only fill missing kinds; a Meta
|
||||
result's picture is the OculusDB image shown in the picker (the ovrp image service served "dogfooding" placeholder
|
||||
covers for some packages, e.g. Asgard's Wrath 2). `steam_art_stale` → game page reminder. No store art → generated
|
||||
`cover.jpg`/`banner.jpg` (APK icon via `fetch.apk_icon`).
|
||||
- Game settings dialog (`ui/views/adapter_dialog.py`, metadata `patches/settings.UI`); plain patch summaries
|
||||
(`patches/summaries.py`, `Patch.summary`, "Show technical details" = setting `ui.patch_details`); share nudge
|
||||
(`views/game.should_ask_to_share`); saving a recipe keeps its status (was always "works").
|
||||
|
||||
## Hard-won facts (don't re-learn these)
|
||||
**Frame runtime (SteamOS 0.3.0, build 20260922):**
|
||||
- No AArch32: 32-bit-only APKs fail with `INSTALL_FAILED_NO_MATCHING_ABIS`. Unfixable; point to Rift + Revive.
|
||||
@@ -195,6 +226,10 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
|
||||
flags 0x3. So automated tests prove startup (process alive, instance/session created, frames paced), never visuals.
|
||||
|
||||
**Lepton:** needs an activity with category **LAUNCHER** (Quest apps often only have INFO → "APP_ACTIVITY is empty").
|
||||
**2D apps:** Lepton runs every app headless (`lepton.headless=true`, only OpenXR output reaches the headset) unless the app folder (`<base>/lepton-app/`) has a `lepton-show-flatscreen` file (liblepton/app_metadata.sh) → Waydroid window on gamescope; agent v28 `set_flatscreen` at finalize for `vr_kind == "none"`. Android 11's navbar covered the
|
||||
app's controls → patch `device.hide_navbar` (default on for vr_kind none, migration `flat_hide_navbar`) exports
|
||||
`qemu.hw.mainkeys=1` as a second line of `LEPTON_GFXRECON_FP_PROPS` (runtime alternatives failed, see
|
||||
docs/FRAME_RUNTIME.md). Verified in the headset 2026-10-02 (2048).
|
||||
Lepton = Steam app 3029110 (+ "Lepton Development" 3056000, needs Developer Mode). Per-game env: STEAM_COMPAT_INSTALL_PATH
|
||||
/DATA_PATH/SHADER_PATH, SteamAppId. Logs: `<base>/launch.log` and `~/.local/share/Steam/logs/lepton-logcats/steamlaunch-<appid>`.
|
||||
Containers are podman `lepton-steamlaunch-<appid>`. Some Unreal games create save dirs without u+rwx → launcher repairs every 2 s.
|
||||
@@ -276,6 +311,12 @@ the **unpatched** upstream of our `frame.vrapi_bridge` (`native/vrapi` unchanged
|
||||
experimental CLI built with `-PwithVrApi=true` (stable jars list it but fail). The owner prefers OVRPort's fixes over
|
||||
ours where they work as well (less to maintain): compare in the headset before switching a default.
|
||||
`frameport install <pkg> --apk <file>` installs a specific (test) build signed with the game's key.
|
||||
Headset results (2026-10-02, `PATCHED/_test-ovrport-1.2.5/TESTING.md`): OVRPort's own VrApi translator fails on the
|
||||
Frame (Climb 2: requests VkFormat 37 → crash; POTW: no GLES path, missing `vrapi_GetTextureSwapChainHandle`) → keep
|
||||
`frame.vrapi_bridge`; those two changes are upstream candidates. 1.2.5 builds (its platform compat, new permissions)
|
||||
work. AC Nexus: `patch_ac_nexus_no_appsw_90` is the catalog default (owner preferred 90 Hz); `STRICT` patches are only
|
||||
taken from a catalog recipe where `applies()` holds (the AC Nexus ones need build MAIN.450412.207706.final, else the
|
||||
whole overport run fails).
|
||||
|
||||
**Patching gotchas:**
|
||||
- UnityPy re-serialization breaks scene loading → patch QualitySettings ints in place.
|
||||
@@ -335,7 +376,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
|
||||
@@ -343,6 +384,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`,
|
||||
@@ -355,26 +397,27 @@ 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
|
||||
Windows console) plus PYTHONUTF8; macOS builds need `--python-version 3.12 --arch arm64` (cryptography has no wheels
|
||||
for flet's default Python / x86_64 cross-build), with a PyInstaller fallback step; `astral-sh/setup-uv` has no
|
||||
floating major tags after v7 → pin the exact version; force-moving a tag starts duplicate runs (cancel one).
|
||||
- macOS runner (2026-10-02): `macos-latest` jobs went unassigned (cancelled after 15 min, no steps); `macos-15`'s Xcode
|
||||
16.4 fails a Flutter plugin (`NWPath has no member`); `macos-26` (Xcode 26) builds with `flet build`. The PyInstaller
|
||||
fallback (`package.py --pyinstaller`) passes `--yes` so a failed `flet build`'s folder doesn't stop it at a prompt.
|
||||
- Windows PowerShell calls from Python (`updates._powershell()`): use `%SystemRoot%\System32\WindowsPowerShell\v1.0\
|
||||
powershell.exe` with `PSModulePath` removed from the environment — started under PowerShell 7 (CI's default shell,
|
||||
or a user's pwsh terminal) it couldn't run Get-AuthenticodeSignature (v0.3.0's Windows update smoke failed on it).
|
||||
`v0.3.0` is a tag without a release (that failed build); the updater shipped first in v0.3.1.
|
||||
- **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.
|
||||
- **Never start PowerShell with `DETACHED_PROCESS` from the packaged app**: it exits 0 without running anything (found
|
||||
in the 0.3.1→0.3.2 end-to-end test: the app quit, nothing updated). `updates.spawn_hidden` = CREATE_NEW_CONSOLE +
|
||||
hidden window; `apply()` waits until the script has logged that it runs and raises otherwise (the app then stays
|
||||
open). Windows installs of 0.3.1/0.3.2 can't update themselves: they need one manual download.
|
||||
- The Linux bundle is built on ubuntu-22.04: a 24.04-built Flutter bundle needs GLib 2.80 (`undefined symbol:
|
||||
g_once_init_enter_pointer` on 22.04).
|
||||
- **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.
|
||||
|
||||
@@ -397,11 +440,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,46 @@
|
||||
# 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: see [Translating FramePort](#translating-frameport) below.
|
||||
- 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).
|
||||
|
||||
## Translating FramePort
|
||||
The app's texts (screens, help, patch and setting descriptions) can be translated; the command line, logs and
|
||||
diagnostics stay English. No programming needed:
|
||||
|
||||
1. Copy `src/frameport/locales/template.json` to `src/frameport/locales/<code>.json`, where `<code>` is the language
|
||||
code (`de`, `fr`, `pt`, `ja`, …).
|
||||
2. Add `"_language": "<the language's own name>"` (e.g. `"Deutsch"`); it's shown in Settings → Language.
|
||||
3. Fill in the values. Each key is the English text; leave a value empty to keep the English text. Plural entries
|
||||
have a list value: `["<one>", "<other>"]`. Keep `{placeholders}` such as `{title}` or `{n}` exactly as they are,
|
||||
and keep `…`, `·` and line breaks where the English has them.
|
||||
4. Try it: `uv run frameport-gui`, choose the language in Settings → Language, restart FramePort.
|
||||
5. Open a pull request with the file. When the English texts change, `python scripts/i18n_extract.py` refreshes the
|
||||
template; new texts then show in English until they're translated.
|
||||
|
||||
Wording rules for English (and a guide for tone) are in `docs/STYLE.md`.
|
||||
|
||||
## 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.
|
||||
@@ -1,8 +1,8 @@
|
||||
# FramePort
|
||||
|
||||
FramePort installs Meta Quest standalone games and Oculus Rift PC VR games on the **Valve Steam Frame**. It patches a
|
||||
game so it runs on the Frame's runtimes, copies it to the headset over Wi-Fi and adds it to the Frame's Steam library
|
||||
with artwork.
|
||||
FramePort installs Meta Quest standalone games, other Android apps and PC VR games on the **Valve Steam Frame**. It
|
||||
patches a game so it runs on the Frame's runtimes, copies it to the headset over Wi-Fi and adds it to the Frame's Steam
|
||||
library with artwork.
|
||||
|
||||

|
||||
|
||||
@@ -35,32 +35,54 @@ 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.
|
||||
Frame's Steam library, then runs a short launch test. Several installs queue up; if the Frame goes to sleep or
|
||||
drops off the Wi-Fi, the queue waits and continues where it stopped (FramePort keeps the Frame awake meanwhile).
|
||||
Your game files are never changed: the converted copy is temporary.
|
||||
6. **Play**: put the headset on and start the game from the Steam library (or click **Play on Frame**).
|
||||
|
||||

|
||||
|
||||
Each game has **Game settings** in plain words (sharpness, refresh rate, controllers, menus, 360° video, mixed
|
||||
reality), showing only what matters for that game. Changes are kept with the game and reach the Frame right away.
|
||||
|
||||

|
||||
|
||||
FramePort updates itself: when a new version is released, the Library shows **Update now**.
|
||||
|
||||
## What works
|
||||
|
||||
The built-in catalog has tested settings for 38 games (27 work, 5 work with known issues, 6 can't run on the Frame).
|
||||
Other games get suggested patches from detection rules; each suggestion states its reason, and every patch can be
|
||||
switched on or off under **Customize**.
|
||||
switched on or off under **Customize**: described in plain words, with **Show technical details** for the exact
|
||||
effect of each patch.
|
||||
|
||||
Tried an untested game? Its page asks how it runs; **Share working config…** opens a prefilled GitHub issue so your
|
||||
recipe can join the built-in catalog for everyone.
|
||||
|
||||

|
||||
|
||||
- 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 shown as a flat window in the headset |
|
||||
| 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.
|
||||
|
||||

|
||||
|
||||
The **Files** tab manages files on the Frame: upload videos, documents, mods or saves from the computer (buttons or
|
||||
drag-and-drop), download, rename and delete (one entry or a selection), in the shared folders every game sees or in
|
||||
one game's own storage.
|
||||
|
||||

|
||||
|
||||
## Built on
|
||||
|
||||
FramePort is a front end for other projects; most of the functionality comes from them:
|
||||
@@ -91,6 +113,7 @@ The same functions are available as `frameport` (included in the source tree; al
|
||||
- [docs/PLAYBOOK.md](docs/PLAYBOOK.md): symptoms and fixes per game.
|
||||
- [docs/FRAME_RUNTIME.md](docs/FRAME_RUNTIME.md): Steam Frame runtime facts.
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): how the code is organised.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md): contributing code, recipes and translations.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -33,7 +33,7 @@ import sys
|
||||
import time
|
||||
import zlib
|
||||
|
||||
AGENT_VERSION = 26
|
||||
AGENT_VERSION = 30
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -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
|
||||
@@ -114,7 +115,7 @@ def find_app_id(appid):
|
||||
state = re.search(r'"StateFlags"\s+"(\d+)"', text)
|
||||
d = os.path.join(lib, "common", installdir[1]) if installdir else None
|
||||
num = {k: int(m[1]) for k in ("BytesDownloaded", "BytesToDownload", "SizeOnDisk")
|
||||
for m in [re.search(r'"%s"\s+"(\d+)"' % k, text)] if m}
|
||||
for m in [re.search(rf'"{k}"\s+"(\d+)"', text)] if m}
|
||||
return {"appid": str(appid), "name": name[1] if name else "", "dir": d, "state": int(state[1]) if state else 0,
|
||||
"complete": bool(state and int(state[1]) & 4 and d and os.path.isdir(d)),
|
||||
"downloaded": num.get("BytesDownloaded", 0), "to_download": num.get("BytesToDownload", 0)}
|
||||
@@ -232,7 +233,7 @@ def tool_manifest(tool_dir):
|
||||
return {}
|
||||
out = {}
|
||||
for k in ("commandline", "require_tool_appid", "version"):
|
||||
m = re.search(r'"%s"\s+"((?:[^"\\]|\\.)*)"' % k, text)
|
||||
m = re.search(rf'"{k}"\s+"((?:[^"\\]|\\.)*)"', text)
|
||||
if m:
|
||||
out[k] = m[1].replace('\\"', '"')
|
||||
if "require_tool_appid" in out:
|
||||
@@ -466,7 +467,7 @@ def cmd_xr_layer_test(args):
|
||||
try:
|
||||
p = run([sys.executable, os.path.abspath(__file__), "_xr_probe", json.dumps({"loader": loader})],
|
||||
env=env, timeout=60)
|
||||
line = next((l for l in reversed(p.stdout.splitlines()) if l.startswith("{")), None)
|
||||
line = next((ln for ln in reversed(p.stdout.splitlines()) if ln.startswith("{")), None)
|
||||
results[name] = json.loads(line) if line else {"error": (p.stderr or p.stdout)[-800:]}
|
||||
except subprocess.TimeoutExpired:
|
||||
results[name] = {"error": "timed out"}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -711,7 +714,7 @@ def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Fram
|
||||
LaunchOptions=launch_options, IsHidden=0, AllowDesktopConfig=1, AllowOverlay=1, OpenVR=1, Devkit=0,
|
||||
DevkitGameID="", DevkitOverrideAppID=0, FlatpakAppID="")
|
||||
if data:
|
||||
shutil.copy2(vdf_path, f"{vdf_path}.backup-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||
backup_vdf(vdf_path)
|
||||
os.makedirs(os.path.dirname(vdf_path), exist_ok=True)
|
||||
tmp = vdf_path + ".tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
@@ -726,22 +729,48 @@ def prune_shortcuts(vdf_path, title, keep_exe, tag):
|
||||
removed appids."""
|
||||
if not os.path.exists(vdf_path):
|
||||
return []
|
||||
tags = (tag,) if isinstance(tag, str) else tuple(tag) # one tag, or several (current + earlier names)
|
||||
root = vdf_decode(open(vdf_path, "rb").read())
|
||||
sc = root.get("shortcuts", {})
|
||||
removed = [v.get("appid") for v in sc.values() if isinstance(v, dict) and v.get("appname") == title
|
||||
and v.get("Exe") != keep_exe and tag in (v.get("tags") or {}).values()]
|
||||
|
||||
def stale(v):
|
||||
return (isinstance(v, dict) and v.get("appname") == title and v.get("Exe") != keep_exe
|
||||
and any(t in (v.get("tags") or {}).values() for t in tags))
|
||||
removed = [v.get("appid") for v in sc.values() if stale(v)]
|
||||
if not removed:
|
||||
return []
|
||||
keep = [v for v in sc.values() if not (isinstance(v, dict) and v.get("appname") == title
|
||||
and v.get("Exe") != keep_exe and tag in (v.get("tags") or {}).values())]
|
||||
keep = [v for v in sc.values() if not stale(v)]
|
||||
root["shortcuts"] = {str(i): v for i, v in enumerate(keep)}
|
||||
shutil.copy2(vdf_path, f"{vdf_path}.backup-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||
backup_vdf(vdf_path)
|
||||
with open(vdf_path + ".tmp", "wb") as f:
|
||||
f.write(vdf_encode(root))
|
||||
os.replace(vdf_path + ".tmp", vdf_path)
|
||||
return removed
|
||||
|
||||
|
||||
VDF_BACKUPS = 5
|
||||
|
||||
|
||||
def backup_vdf(vdf_path):
|
||||
"""Keep a copy before changing shortcuts.vdf (the last VDF_BACKUPS are kept)."""
|
||||
shutil.copy2(vdf_path, f"{vdf_path}.backup-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||
for old in sorted(glob.glob(f"{vdf_path}.backup-*"))[:-VDF_BACKUPS]:
|
||||
try:
|
||||
os.remove(old)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def grid_files(grid, appid):
|
||||
"""A shortcut's grid artwork (<appid>p.jpg, <appid>_hero.png, …): exact names, never another appid that merely
|
||||
starts with the same digits."""
|
||||
pat = re.compile(rf"^{re.escape(str(appid))}(p|_hero|_logo|_icon)?\.[A-Za-z0-9]+$")
|
||||
try:
|
||||
return [os.path.join(grid, n) for n in os.listdir(grid) if pat.match(n)]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def remove_shortcut(vdf_path, exe):
|
||||
if not os.path.exists(vdf_path):
|
||||
return False
|
||||
@@ -751,7 +780,7 @@ def remove_shortcut(vdf_path, exe):
|
||||
if len(keep) == len(sc):
|
||||
return False
|
||||
root["shortcuts"] = {str(i): v for i, v in enumerate(keep)}
|
||||
shutil.copy2(vdf_path, f"{vdf_path}.backup-{time.strftime('%Y%m%d-%H%M%S')}")
|
||||
backup_vdf(vdf_path)
|
||||
with open(vdf_path + ".tmp", "wb") as f:
|
||||
f.write(vdf_encode(root))
|
||||
os.replace(vdf_path + ".tmp", vdf_path)
|
||||
@@ -766,13 +795,15 @@ 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", [])]
|
||||
if not packages:
|
||||
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)
|
||||
with open(STATUS_FILE, "w") as f:
|
||||
json.dump({"state": "running", "packages": packages, "started": time.time()}, f)
|
||||
unit = f"frameport-shortcuts-{int(time.time())}"
|
||||
payload = json.dumps({"packages": packages, "restart": args.get("restart", True)})
|
||||
payload = json.dumps({"packages": packages, "remove": remove, "restart": args.get("restart", True)})
|
||||
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={unit}", "--setenv=HOME=" + HOME,
|
||||
sys.executable, os.path.abspath(__file__), "_shortcuts_worker", payload])
|
||||
return {"started": True, "unit": unit}
|
||||
@@ -792,6 +823,14 @@ def shortcuts_worker(payload):
|
||||
raise AgentError("Steam did not close; library not modified") from None
|
||||
grid = os.path.join(os.path.dirname(vdf), "grid")
|
||||
os.makedirs(grid, exist_ok=True)
|
||||
for r in args.get("remove", []): # uninstalled games
|
||||
try:
|
||||
if remove_shortcut(vdf, r["exe"]):
|
||||
result.setdefault("removed", []).append(r["exe"])
|
||||
for art in grid_files(grid, r.get("appid") or ""):
|
||||
os.remove(art)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{r.get('exe')}: {exc}")
|
||||
for pkg in args["packages"]:
|
||||
try:
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
@@ -979,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):
|
||||
@@ -1049,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}
|
||||
@@ -1092,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")
|
||||
@@ -1110,6 +1155,16 @@ def data_files_dir(base, pkg):
|
||||
return os.path.join(base, "lepton-data/external/Android/data", pkg, "files")
|
||||
|
||||
|
||||
def set_flatscreen(app_dir, on):
|
||||
"""Lepton shows an app as a flat (2D) window only when its app folder holds this marker
|
||||
(liblepton/app_metadata.sh); otherwise the app runs headless and only OpenXR output reaches the headset."""
|
||||
marker = os.path.join(app_dir, "lepton-show-flatscreen")
|
||||
if on:
|
||||
open(marker, "a").close()
|
||||
elif os.path.exists(marker):
|
||||
os.remove(marker)
|
||||
|
||||
|
||||
def cmd_finalize(args):
|
||||
"""Move uploaded files into place, write launcher/settings/config files/deployment.json. Keeps saves."""
|
||||
pkg = check_pkg(args["package"])
|
||||
@@ -1123,6 +1178,19 @@ def cmd_finalize(args):
|
||||
os.makedirs(os.path.join(app, "obb"), exist_ok=True)
|
||||
os.makedirs(anchor, exist_ok=True)
|
||||
new_apk = os.path.join(incoming, "game.apk")
|
||||
inc_obb = os.path.join(incoming, "obb")
|
||||
expected = args.get("obb_manifest") # rel path -> size
|
||||
if expected: # check the data as it will be after the move, before replacing anything (a failure keeps the old)
|
||||
have = {}
|
||||
for top in (os.path.join(app, "obb"), inc_obb):
|
||||
for root, _, files in os.walk(top):
|
||||
for name in files:
|
||||
if not name.endswith(".part"):
|
||||
p = os.path.join(root, name)
|
||||
have[os.path.relpath(p, top)] = os.path.getsize(p)
|
||||
bad = [k for k, v in expected.items() if have.get(k) != v]
|
||||
if bad:
|
||||
raise AgentError(f"{len(bad)} data file(s) missing or incomplete, e.g. {bad[0]}")
|
||||
if os.path.exists(new_apk):
|
||||
if args.get("apk_sha256") and sha256_file(new_apk) != args["apk_sha256"]:
|
||||
raise AgentError("uploaded APK checksum mismatch (transfer corrupted?)")
|
||||
@@ -1133,7 +1201,6 @@ def cmd_finalize(args):
|
||||
elif not os.path.exists(os.path.join(app, "game.apk")):
|
||||
raise AgentError("no APK uploaded and none installed")
|
||||
moved = 0
|
||||
inc_obb = os.path.join(incoming, "obb")
|
||||
for root, _, files in os.walk(inc_obb):
|
||||
for name in files:
|
||||
if name.endswith(".part"):
|
||||
@@ -1143,17 +1210,9 @@ def cmd_finalize(args):
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
os.replace(src, dst)
|
||||
moved += 1
|
||||
expected = args.get("obb_manifest") # rel path -> size
|
||||
if expected:
|
||||
have = {}
|
||||
for root, _, files in os.walk(os.path.join(app, "obb")):
|
||||
for name in files:
|
||||
p = os.path.join(root, name)
|
||||
have[os.path.relpath(p, os.path.join(app, "obb"))] = os.path.getsize(p)
|
||||
bad = [k for k, v in expected.items() if have.get(k) != v]
|
||||
if bad:
|
||||
raise AgentError(f"{len(bad)} data file(s) missing or incomplete, e.g. {bad[0]}")
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
if "flatscreen" in args: # older clients don't send it: leave the marker as it is
|
||||
set_flatscreen(app, args["flatscreen"])
|
||||
# settings: settings.conf (read by the adapter via LEPTON_ENV_FRAMEBRIDGE_CONFIG) + framebridge.conf copy
|
||||
files_dir = data_files_dir(base, pkg)
|
||||
os.makedirs(files_dir, exist_ok=True)
|
||||
@@ -1190,8 +1249,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")
|
||||
@@ -1333,7 +1393,7 @@ def parse_obj(path):
|
||||
for c in parts[1:]:
|
||||
idx = (c.split("/") + ["", ""])[:3]
|
||||
ref = []
|
||||
for i, n in zip(idx, (len(pos), len(uv), len(nrm))):
|
||||
for i, n in zip(idx, (len(pos), len(uv), len(nrm)), strict=True):
|
||||
ref.append(None if not i else (int(i) - 1 if int(i) > 0 else n + int(i)))
|
||||
corners.append(tuple(ref))
|
||||
for i in range(1, len(corners) - 1): # fan triangulation
|
||||
@@ -1577,7 +1637,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')
|
||||
|
||||
@@ -1665,8 +1726,8 @@ def set_libovr_redirect(base, exe_rel, enabled, bundled=False):
|
||||
exe_dir = os.path.dirname(os.path.join(game, exe_rel))
|
||||
# the runtime: FramePort's Revive (revive/LibReviveXR*), or with bundled=True the repack's own LibRevive*.dll
|
||||
# next to the exe (a repack set up for SteamVR, whose Windows loader hook doesn't take effect under Proton)
|
||||
runtimes = {bits: (os.path.join(exe_dir, "LibRevive%s.dll" % bits) if bundled else
|
||||
os.path.join(base, "revive", "LibReviveXR%s.dll" % bits)) for bits in ("64", "32")}
|
||||
runtimes = {bits: (os.path.join(exe_dir, f"LibRevive{bits}.dll") if bundled else
|
||||
os.path.join(base, "revive", f"LibReviveXR{bits}.dll")) for bits in ("64", "32")}
|
||||
# where the Oculus SDK looks for LibOVRRT: the game exe's dir (monolithic engines carry the shim in the exe) AND
|
||||
# next to every OVRPlugin.dll (Unreal's shim searches its own module dir).
|
||||
dirs = {os.path.dirname(os.path.join(game, exe_rel))}
|
||||
@@ -1676,7 +1737,7 @@ def set_libovr_redirect(base, exe_rel, enabled, bundled=False):
|
||||
dirs.add(root)
|
||||
for d in dirs:
|
||||
for bits, target in runtimes.items():
|
||||
link = os.path.join(d, "LibOVRRT%s_1.dll" % bits)
|
||||
link = os.path.join(d, f"LibOVRRT{bits}_1.dll")
|
||||
ours = os.path.islink(link) and os.path.basename(os.path.realpath(link)).lower().startswith("librevive")
|
||||
if ours and os.path.realpath(link) != os.path.realpath(target):
|
||||
os.remove(link) # switched between FramePort's and the bundled Revive
|
||||
@@ -1749,7 +1810,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},
|
||||
@@ -1759,6 +1821,29 @@ def cmd_finalize_pcvr(args):
|
||||
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "proton": tool["name"]}
|
||||
|
||||
|
||||
AWAKE_UNIT = "frameport-awake"
|
||||
|
||||
|
||||
def cmd_keep_awake(args):
|
||||
"""Keep the Frame from going idle/asleep while FramePort installs games (on=False ends it). An inhibitor lock held
|
||||
by a `sleep` in its own user unit, so it outlives this SSH command and ends by itself after `minutes` if FramePort
|
||||
disappears. Blocking sleep needs a local session (polkit inhibit-block-sleep: auth_admin_keep for others), so
|
||||
when logind refuses it, idle alone is blocked (allowed for any user)."""
|
||||
run(["systemctl", "--user", "stop", f"{AWAKE_UNIT}.service"])
|
||||
run(["systemctl", "--user", "reset-failed", f"{AWAKE_UNIT}.service"])
|
||||
if not args.get("on", True):
|
||||
return {"awake": False}
|
||||
seconds = int(min(max(float(args.get("minutes", 60)), 1), 240) * 60)
|
||||
for what in ("idle:sleep", "idle"):
|
||||
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={AWAKE_UNIT}", "systemd-inhibit",
|
||||
f"--what={what}", "--who=FramePort", "--why=Installing games", "--mode=block", "sleep", str(seconds)])
|
||||
time.sleep(0.7) # a refused lock ends the unit right away
|
||||
if run(["systemctl", "--user", "is-active", f"{AWAKE_UNIT}.service"]).stdout.strip() == "active":
|
||||
return {"awake": True, "what": what, "seconds": seconds}
|
||||
run(["systemctl", "--user", "reset-failed", f"{AWAKE_UNIT}.service"])
|
||||
return {"awake": False}
|
||||
|
||||
|
||||
def cmd_set_settings(args):
|
||||
pkg = check_pkg(args["package"])
|
||||
dep = deployment(pkg) or {}
|
||||
@@ -1808,11 +1893,10 @@ def cmd_uninstall(args):
|
||||
if not keep_data:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
users = steam_users()
|
||||
removed_sc = False
|
||||
if len(users) == 1 and args.get("remove_shortcut"):
|
||||
removed_sc = remove_shortcut(os.path.join(STEAM, "userdata", users[0], "config/shortcuts.vdf"),
|
||||
f'"{anchor}/launch.sh"') # takes effect after the next Steam restart
|
||||
if args.get("remove_shortcut") and len(steam_users()) == 1:
|
||||
# Steam keeps its own copy of shortcuts.vdf and writes it back: change it only with Steam closed (worker)
|
||||
removed_sc = cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"', "appid": dep.get("appid")}]})["started"]
|
||||
if not keep_data or base != anchor:
|
||||
shutil.rmtree(anchor, ignore_errors=True)
|
||||
else: # saves live next to the launcher (Quest games): keep them, drop what marks the game as installed
|
||||
@@ -1865,7 +1949,7 @@ def game_logs(base, since=0.0):
|
||||
raw = open(ctx, "rb").read().decode("utf-8", "replace")
|
||||
fields = []
|
||||
for tag in ("ErrorMessage", "CrashType", "EngineVersion", "CallStack", "SourceContext"):
|
||||
m = re.search(r"<%s>(.*?)</%s>" % (tag, tag), raw, re.S)
|
||||
m = re.search(rf"<{tag}>(.*?)</{tag}>", raw, re.S)
|
||||
if m and m.group(1).strip():
|
||||
fields.append(f"{tag}: {m.group(1).strip()[:3000]}")
|
||||
out.append(f"===== crash {os.path.relpath(os.path.dirname(ctx), base)} (UE4CC)\n" + "\n".join(fields))
|
||||
@@ -1911,7 +1995,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])
|
||||
@@ -1919,7 +2004,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()}
|
||||
|
||||
@@ -2093,7 +2179,7 @@ def purge_worker(payload):
|
||||
try:
|
||||
if remove_shortcut(vdf, f'"{os.path.join(ANCHORS, d["package"])}/launch.sh"'):
|
||||
result["removed"].append(f"Steam shortcut: {d.get('title')}")
|
||||
for art in glob.glob(os.path.join(grid, f"{d['appid']}*")):
|
||||
for art in grid_files(grid, d["appid"]):
|
||||
os.remove(art)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{d.get('title')}: {exc}")
|
||||
@@ -2157,10 +2243,13 @@ def cmd_cleanup(args):
|
||||
removed.append(p)
|
||||
for extra in args.get("paths", []):
|
||||
p = os.path.realpath(os.path.expanduser(extra))
|
||||
if not p.startswith(HOME + os.sep) or p.startswith(ANCHORS) or p == os.path.join(HOME, ".local"):
|
||||
first = os.path.relpath(p, HOME).split(os.sep)[0] if p.startswith(HOME + os.sep) else ""
|
||||
if not first or first.startswith(".") or p == ANCHORS or p.startswith(ANCHORS + os.sep):
|
||||
# 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 +9,11 @@ notes: Some launch warning text is still upside down; the rest of the UI is fixe
|
||||
may not work.
|
||||
details: Ubisoft online login may not work. Data folder holds raw assets (start0..2, .climb, bundles), not .obb
|
||||
files; install to obb as-is.
|
||||
overport_extra:
|
||||
- patch_ac_nexus_no_appsw_90
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 89644a12bf8df956fa3d3a9a40540ba9e1fb62f63494714a05c9f05984383ac8
|
||||
no_appsw_90: '2026-10-02, OVRPort 1.2.5 (owner: 90 Hz felt better than 72 Hz; same known issues)'
|
||||
@@ -5,11 +5,15 @@ source_hint: Nano
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Needed Oculus OS telemetry class stubs.
|
||||
notes: Needs Oculus OS telemetry class stubs and the no-ForceQuit build (the normal build quits right after
|
||||
starting).
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
date: '2026-10-02' # owner-confirmed with the no-ForceQuit build
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 31469d50b8ee292597291db7cc3bc3eca8e851f533cd92bba0d64214893580cc
|
||||
frame:
|
||||
- frame.oculusos
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
use_alt: true
|
||||
@@ -14,6 +14,19 @@
|
||||
`keyring = false` in `~/.config/containers/containers.conf`; a reboot clears already-leaked keys.
|
||||
- Picks the activity with category LAUNCHER (`APP_ACTIVITY`). Game data is visible at `/sdcard/Android/{obb,data}/<pkg>`.
|
||||
- 64-bit only (no AArch32).
|
||||
- `<install>/lepton-shaders/` holds Mesa's shader cache (Lepton sets `mesa.shader.cache.dir=/data/shaders`):
|
||||
`foz_cache.foz` + `foz_cache_idx.foz` per graphics driver (Turnip Vulkan, zink GL), typically 4–6 files and 7–19 MB
|
||||
per game, 2D apps included. It speeds up later starts; deleting it only costs one slower start.
|
||||
- **2D apps** (verified 2026-10-02 with an open-source 2048 game): Lepton runs every app headless
|
||||
(`lepton.headless=true`; only OpenXR output reaches the headset) unless the app folder (`STEAM_COMPAT_INSTALL_PATH`)
|
||||
contains a file `lepton-show-flatscreen` (`liblepton/app_metadata.sh`): then Android (11, Waydroid) gets a Wayland
|
||||
window shown as a flat panel in the headset. FramePort creates the file for Android apps without VR. Android's
|
||||
back/home/recents bar is drawn over the app's own controls; `qemu.hw.mainkeys=1` removes it, but it's only read at
|
||||
boot and Lepton has no setting for extra properties, so patch `device.hide_navbar` (default on for apps without
|
||||
VR) passes it as a second line of a
|
||||
`LEPTON_GFXRECON_*` value (Lepton copies those into the boot properties unescaped). Runtime alternatives didn't
|
||||
work on Android 11: `policy_control` is gone, `cmd statusbar send-disable-flag home recents` has no `back` and
|
||||
moves the back button onto the app's controls, the `sysui_nav_bar` layout and disabling SystemUI had no effect.
|
||||
|
||||
## OpenXR runtime (as seen by games through overport's loader)
|
||||
- Instance extensions present include KHR_android_create_instance (must be enabled; the adapter adds it),
|
||||
|
||||
@@ -8,7 +8,7 @@ runtime, the OVRPort CLI and apksigner into its data folder (Settings → Tools
|
||||
|---|---|---|
|
||||
| Windows 10/11 (x64) | `FramePort-windows-x64.zip` | `FramePort.exe` |
|
||||
| macOS (Apple Silicon) | `FramePort-macos-arm64.zip` | `FramePort.app` |
|
||||
| Linux (x64, GTK 3) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
|
||||
| Linux (x64, GTK 3; Ubuntu 22.04 or newer) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
|
||||
| Command line only (Python 3.11+) | `frameport-<version>-py3-none-any.whl` | `frameport --help` |
|
||||
|
||||
The command-line version installs from the wheel's release link with `uv tool install <link>` (or pipx / pip).
|
||||
@@ -33,6 +33,23 @@ start shows a warning:
|
||||
run the command FramePort shows. It enables SSH, authorises this computer and installs Valve's Android runtime
|
||||
(Lepton) if needed.
|
||||
|
||||
## Installing games
|
||||
|
||||
- **Install on Frame** on a game's page (or select several in the Library and install them together). Installs run
|
||||
one after another in the background; **Activity** shows the current one at the top.
|
||||
- **Update all** reinstalls every game whose build changed (e.g. after a FramePort update). Questions that need an
|
||||
answer (e.g. Oculus games that can't run on the Frame) are asked once, for all games.
|
||||
- If the Frame goes to sleep, turns off or leaves the Wi-Fi, the queue **waits** and continues once it's back; uploads
|
||||
pick up where they stopped. While installs run, FramePort keeps the Frame from going to sleep. Before a large batch
|
||||
it checks the Frame has enough free space.
|
||||
- Your own game files are never changed. The converted copy is temporary: it's removed once the game is on the Frame
|
||||
(Settings → Installing: keep them, or remove all now).
|
||||
- **Game settings…** (game menu or the Steam Frame page): sharpness, refresh rate, controllers, menus, 360° video and
|
||||
mixed-reality options in plain words, only those that matter for the game. Changes are kept with the game and,
|
||||
when it's installed, used the next time it starts.
|
||||
- Ordinary Android apps (no VR) are installed unchanged and shown as a flat window in the headset. Android's
|
||||
back/home/recents buttons are hidden by default (patch **Hide Android's navigation bar**).
|
||||
|
||||
## Updating
|
||||
|
||||
FramePort checks for a new release at start and every 6 hours (it only downloads the release information). When one
|
||||
@@ -47,11 +64,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
|
||||
@@ -60,21 +78,52 @@ unchanged. FramePort uses an installed Revive, or downloads a portable copy.
|
||||
on the game page.
|
||||
- Games that use the Oculus Platform SDK check the licence through the Oculus app, so they run on the PC only.
|
||||
|
||||
## Videos, documents and mods
|
||||
## Files on the Frame (videos, documents, mods, saves)
|
||||
|
||||
Steam Frame → **Send files** (or a game's menu → **Add videos & files…**) copies files to the Frame. **Videos**,
|
||||
**Downloads** and **Documents** appear inside every Quest game as `/sdcard/Movies`, `/sdcard/Download` and
|
||||
`/sdcard/Documents`. Apps find them by browsing folders; Android's media index doesn't work on the Frame. Command
|
||||
line: `frameport frame send <files> --to videos`.
|
||||
The **Files** tab manages files on the Frame over the same connection as installs; no other transfer app is needed.
|
||||
Pick a location, browse folders, and use **Upload files** / **Upload folder**, **New folder**, or the download, rename
|
||||
and delete buttons on each entry. Tick several entries (or the box above the list for all) to download or delete them
|
||||
together. In the downloaded app you can also drag files and folders from Explorer / Finder / your file manager onto
|
||||
the list to upload them into the open folder. Uploads and downloads run in the Activity panel, resume after an interruption and
|
||||
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.
|
||||
- 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> --dest videos` (`frameport frame storage` lists the destinations).
|
||||
|
||||
## Sharing a working game, reporting a problem
|
||||
|
||||
- **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,
|
||||
configs become built-in recipes. Untested games ask on their page once they've been installed or tested: **It
|
||||
works**, **It has issues** or **It doesn't run**.
|
||||
- **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>`.
|
||||
|
||||
## Command line
|
||||
|
||||
Everything the app does is also a `frameport` command (in a source checkout: `uv run frameport`); `frameport --help`
|
||||
and `frameport <command> --help` describe every option. The main ones:
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `scan <folder>` / `list` / `show <game>` | add games, list the library, show a game's analysis and patches |
|
||||
| `recipe <game> --enable/--disable <patch>` | change a game's patches (`patches` lists them all) |
|
||||
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for PC VR on this PC), launch test |
|
||||
| `frame discover` / `frame connect` / `frame info` | find, pair with and describe the Frame |
|
||||
| `frame send` / `frame storage` / `frame cleanup` | copy files to the Frame, show where they go, free space |
|
||||
| `tools status` / `tools install` | the tools FramePort downloads |
|
||||
| `diag report <game>` / `share-recipe <game>` | report a problem / share a working recipe |
|
||||
| `update` | update FramePort |
|
||||
|
||||
Exit codes: 0 done, 1 something failed, 2 wrong usage, 10 (`update --check`) a newer version exists. Errors are
|
||||
one line on stderr; `FRAMEPORT_DEBUG=1` shows the full traceback.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Settings → **Uninstall FramePort…** (or `frameport uninstall-app`) removes its data folder, the Steam shortcuts it
|
||||
|
||||
@@ -47,7 +47,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Video stutters although fps is steady | video frame rate doesn't divide the refresh rate (30 fps at 72 Hz) | `adapter.refresh_rate` (e.g. 90 for 30 fps, 72 for 24 fps); `layer_debug` logs the offered rates and the app's own requests |
|
||||
| PC VR game judders/stutters although the GPU keeps up; vrcompositor.txt "Timed out. N total" high or frames dropped | the game misses the headset's refresh (e.g. 96 Hz = 10.4 ms; Stormland's slow frames take 10.7 ms) | `pcvr.steamvr_tuning` (default on): next Play sets SteamVR per-app `preferredRefreshRate` + `motionSmoothingOverride` via `fp_vrsettings.exe` |
|
||||
| Quest app keeps recentering / snapping the view when you turn your head; log shows session state 5→4→3→4→5 within a second | the Frame briefly takes focus; the app recenters on focus changes | unfixed: hiding the dips (debounce) made AC Nexus stay black, so it was removed |
|
||||
| Video player / app finds no local videos; MediaProvider "Requested path /home/steamos/... doesn't appear under ..." | Lepton's /sdcard is a symlink to a host path, so Android's media index rejects every file | send files with Frame → Send files (~/Videos = /sdcard/Movies) and browse folders in the app |
|
||||
| Video player / app finds no local videos; MediaProvider "Requested path /home/steamos/... doesn't appear under ..." | Lepton's /sdcard is a symlink to a host path, so Android's media index rejects every file | upload in the Files tab (Videos = /sdcard/Movies) and browse folders in the app |
|
||||
| Game (Unity, GLES) freezes, `zink: DEVICE LOST` | multisampled render-to-texture hangs the GPU | `frame.unity_no_msaa`; if it persists: unfixable → PC version |
|
||||
| Setup/intro loops every launch (Espire 2) | save folders created without write permission | launcher repairs permissions every 2 s (built in) |
|
||||
| VrApi bridge: never enters VR | Frame reaches FOCUSED later than Quest; tracking only when worn | bridge waits 30 s; test in the headset |
|
||||
@@ -55,6 +55,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
## Picture problems (headset on)
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| 2D Android app: Steam says it's running but nothing shows | Lepton runs apps headless unless the app folder has `lepton-show-flatscreen` | FramePort adds it for apps without VR (agent v28+); reinstall apps installed before |
|
||||
| 2D Android app: back/home/recents buttons cover the app's own buttons | Android's navigation bar | patch `device.hide_navbar` (on by default for apps without VR) sets `qemu.hw.mainkeys=1`; reinstall to apply |
|
||||
| Black screen, audio works, GLES engine with direct VrApi | Mesa rejects Quest-style GLSL | `frame.gl_shim` (logs `GLShim: SHADER COMPILE FAILED` + source lines) |
|
||||
| Black screen, audio works, log `Unsupported VrApi layer type N` | bridge drops frames containing that layer | extend the bridge (cylinder=3 is converted to a quad already) |
|
||||
| Only some draws visible (e.g. controllers) + `glGetError 0x502` | multiview shaders used on single-view FBOs | GL shim `gl_hide_multiview=1` (default) |
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Writing style for FramePort's interface
|
||||
|
||||
Short rules so every screen, help text and doc sounds the same. Code style is enforced by `ruff`.
|
||||
|
||||
## Words
|
||||
- **Steam Frame** on first mention in a screen or document, **the Frame** after that. **Headset** only for the
|
||||
physical device you wear ("put the headset on").
|
||||
- **Quest game** for Meta Quest apps, **Android app** for ordinary Android apps, **PC VR game** for Windows VR games.
|
||||
Say Oculus or Rift only for games that use Oculus's own SDK.
|
||||
- **Patch** for anything in a game's patch list (never "fix" for the same thing). **Recipe** for a game's chosen
|
||||
patches and settings.
|
||||
- Tools by their names: OVRPort, Revive, Proton, Lepton, SteamVR.
|
||||
- **Install** (first time), **Update** (a newer build), **Reinstall** (the same build again); **Play** starts a game.
|
||||
|
||||
## Form
|
||||
- Buttons and headings in sentence case ("Add games", "Report a problem…"); one label per action everywhere.
|
||||
- "…" at the end of a label when the action asks for more input (a dialog, a file picker).
|
||||
- Sentences end with a period, including tooltips and help texts; labels don't.
|
||||
- "and", not "&", in text. An em dash (—) for asides, a middle dot (·) between short facts.
|
||||
- Sizes in GiB/MiB (`i18n.fmt_size`), dates as YYYY-MM-DD HH:MM (`i18n.fmt_datetime`).
|
||||
- US spelling (customize, analyze, color).
|
||||
- Plain, technical wording: say what happens ("Steam restarts once"), no marketing adjectives.
|
||||
|
||||
## Translations
|
||||
- Every text the GUI shows goes through `tr("…")` or `tr_n("…", "…", n)` (see `src/frameport/i18n.py`); use
|
||||
templates with `.format()`, never f-strings inside `tr()`, and never decide anything from a displayed text.
|
||||
- After changing texts run `python scripts/i18n_extract.py` (a test checks the template is current).
|
||||
- CLI output, logs, diagnostics, catalog notes and launch-test diagnoses stay English.
|
||||
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 917 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 100 KiB |
@@ -1,13 +1,27 @@
|
||||
[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",
|
||||
"flet-desktop>=1.0",
|
||||
"flet-dropzone>=0.4.0", # drag-and-drop uploads in the Files tab (packaged app only; Apache-2.0)
|
||||
"paramiko>=3.4",
|
||||
"zeroconf>=0.132",
|
||||
"psutil>=5.9",
|
||||
@@ -20,11 +34,16 @@ 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", "flet-web>=1.0", "flet-cli>=1.0", "playwright>=1.40"]
|
||||
dev = ["pytest>=8", "pytest-timeout>=2.3", "ruff>=0.6", "flet-web>=1.0", "flet-cli>=1.0", "playwright>=1.40"]
|
||||
|
||||
[project.scripts]
|
||||
frameport = "frameport.cli:app"
|
||||
frameport = "frameport.cli:main"
|
||||
frameport-gui = "frameport.ui.app:main"
|
||||
|
||||
[build-system]
|
||||
@@ -45,8 +64,8 @@ packages = ["src/frameport"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
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)",
|
||||
]
|
||||
@@ -59,3 +78,19 @@ company = "FramePort"
|
||||
[tool.flet.app]
|
||||
path = "src"
|
||||
module = "main"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
extend-exclude = ["native", "src/frameport/_data"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP"]
|
||||
ignore = [
|
||||
"E731", # lambdas assigned to names: used for small Flet callbacks
|
||||
"B008", # function calls in defaults: Typer options
|
||||
"UP007", "UP045", # Optional[...] in Typer signatures (Typer reads the annotations)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["frameport"]
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect every translatable text into src/frameport/locales/template.json (see src/frameport/i18n.py).
|
||||
|
||||
python scripts/i18n_extract.py # rewrite the template
|
||||
python scripts/i18n_extract.py --check # exit 1 if the template is out of date (CI / tests)
|
||||
|
||||
Sources: tr("…") and tr_n("…", "…", n) calls in src/frameport (literal arguments), the GUI help texts, and every
|
||||
patch's title and description. A translation file (locales/<language>.json) copies the template and fills in the
|
||||
values: a string for tr() texts, a list [one, other] for tr_n() texts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC = ROOT / "src" / "frameport"
|
||||
TEMPLATE = SRC / "locales" / "template.json"
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def from_code() -> tuple[dict, list[str]]:
|
||||
"""(texts, problems): tr()/tr_n() literals, and calls whose text isn't a literal (can't be extracted)."""
|
||||
texts: dict = {}
|
||||
problems = []
|
||||
for path in sorted(SRC.rglob("*.py")):
|
||||
if path.name == "i18n.py" and path.parent == SRC:
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("tr", "tr_n")):
|
||||
continue
|
||||
args = node.args[:1] if node.func.id == "tr" else node.args[:2]
|
||||
if all(isinstance(a, ast.Constant) and isinstance(a.value, str) for a in args) and args:
|
||||
if node.func.id == "tr":
|
||||
texts.setdefault(args[0].value, "")
|
||||
else:
|
||||
texts[args[0].value] = ["", ""]
|
||||
elif any(isinstance(a, ast.JoinedStr) for a in args):
|
||||
problems.append(f"{path.relative_to(ROOT)}:{node.lineno}: f-string inside {node.func.id}() "
|
||||
"(use a template and .format())")
|
||||
return texts, problems
|
||||
|
||||
|
||||
def from_data() -> dict:
|
||||
"""Help texts and patch titles/descriptions (defined as data, translated where they're shown)."""
|
||||
from frameport.patches import base
|
||||
from frameport.patches.settings import GROUPS, UI
|
||||
from frameport.ui.help import HELP
|
||||
|
||||
texts = {dict.__getitem__(HELP, k): "" for k in HELP}
|
||||
for p in base.all_patches():
|
||||
for t in (p.title, p.description, p.summary):
|
||||
if t:
|
||||
texts[t] = ""
|
||||
for _, title in GROUPS: # the Game settings dialog
|
||||
texts[title] = ""
|
||||
for ui in UI.values():
|
||||
for t in (ui.get("label"), ui.get("help")):
|
||||
if t:
|
||||
texts[t] = ""
|
||||
if ui["control"][0] == "choice":
|
||||
for _, label in ui["control"][1]:
|
||||
if any(c.isalpha() for c in label):
|
||||
texts[label] = ""
|
||||
return texts
|
||||
|
||||
|
||||
def build() -> tuple[dict, list[str]]:
|
||||
code, problems = from_code()
|
||||
texts = {**from_data(), **code}
|
||||
return dict(sorted(texts.items(), key=lambda kv: kv[0].lower())), problems
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--check", action="store_true", help="only check that the template is up to date")
|
||||
args = ap.parse_args()
|
||||
texts, problems = build()
|
||||
for p in problems:
|
||||
print(p, file=sys.stderr)
|
||||
text = json.dumps(texts, ensure_ascii=False, indent=1) + "\n"
|
||||
if args.check:
|
||||
current = TEMPLATE.read_text(encoding="utf-8") if TEMPLATE.exists() else ""
|
||||
if current != text:
|
||||
print("locales/template.json is out of date: run python scripts/i18n_extract.py", file=sys.stderr)
|
||||
return 1
|
||||
return 1 if problems else 0
|
||||
TEMPLATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
TEMPLATE.write_text(text, encoding="utf-8")
|
||||
print(f"{len(texts)} texts -> {TEMPLATE.relative_to(ROOT)}")
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -53,8 +53,13 @@ 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")]
|
||||
"--add-data", f"{DATA}{sep}frameport/_data",
|
||||
# translations (data files PyInstaller doesn't pick up from the imports)
|
||||
"--add-data", f"{ROOT / 'src/frameport/locales'}{sep}frameport/locales",
|
||||
"--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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Make a screenshot-safe copy of a FramePort data folder (for docs/images):
|
||||
|
||||
python scripts/scrub_library.py <data dir> <new dir> [--sort size]
|
||||
python scripts/scrub_library.py <data dir> <new dir> [--sort size] [--status works,issues]
|
||||
|
||||
Copies library.json + artwork/ only (no Frame pairing, SSH key, tools, logs or builds). In the copy every game's
|
||||
folder name becomes its store title, every local path becomes D:/Games/<Quest|PC VR>/<title>/..., settings other than
|
||||
@@ -66,14 +66,19 @@ def main() -> int:
|
||||
ap.add_argument("src", type=Path)
|
||||
ap.add_argument("dest", type=Path)
|
||||
ap.add_argument("--sort", default="size", choices=["name", "recent", "played", "size", "status"])
|
||||
ap.add_argument("--status", default="", help="keep only games with these recipe statuses, e.g. works,issues")
|
||||
args = ap.parse_args()
|
||||
lib = json.loads((args.src / "library.json").read_text())
|
||||
games = {pkg: scrub_game(g) for pkg, g in lib.get("games", {}).items()}
|
||||
keep = {x for x in args.status.split(",") if x}
|
||||
games = {pkg: scrub_game(g) for pkg, g in lib.get("games", {}).items()
|
||||
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
|
||||
|
||||
@@ -25,6 +25,51 @@ PORT = 8557
|
||||
ERRORS: list[str] = []
|
||||
|
||||
|
||||
class FakeFS:
|
||||
"""--fake-frame's file system for the Files tab: a temp folder with sample files, served like SFTP."""
|
||||
|
||||
def __init__(self):
|
||||
import tempfile
|
||||
|
||||
self.home = tempfile.mkdtemp(prefix="fp-fake-frame-")
|
||||
samples = {"Videos": ["Earth from orbit (360).mp4", "Mars landing (VR180).mkv", "Concert 8K 3D.mp4"],
|
||||
"Downloads": ["Mods/", "readme.txt"], "Documents": ["Saves/", "Notes.pdf"]}
|
||||
sizes = {".mp4": 2_400_000_000, ".mkv": 1_100_000_000}
|
||||
for folder, names in samples.items():
|
||||
base = os.path.join(self.home, folder)
|
||||
os.makedirs(os.path.join(base, "Trips"), exist_ok=True)
|
||||
for n in names:
|
||||
if n.endswith("/"):
|
||||
os.makedirs(os.path.join(base, n), exist_ok=True)
|
||||
else:
|
||||
with open(os.path.join(base, n), "wb") as f:
|
||||
f.truncate(sizes.get(os.path.splitext(n)[1], 48_000)) # sparse: no real disk use
|
||||
|
||||
def agent(self, command, **args):
|
||||
if command == "storage_targets":
|
||||
return {"targets": [{"id": n.lower(), "path": os.path.join(self.home, n), "android": a, "shared": True}
|
||||
for n, a in (("Videos", "/sdcard/Movies"), ("Downloads", "/sdcard/Download"),
|
||||
("Documents", "/sdcard/Documents"))]}
|
||||
raise RuntimeError(f"fake Frame: {command} not available")
|
||||
|
||||
@property
|
||||
def sftp(self):
|
||||
import paramiko
|
||||
|
||||
class SFTP:
|
||||
def listdir_attr(self, path):
|
||||
out = []
|
||||
for name in sorted(os.listdir(path)):
|
||||
a = paramiko.SFTPAttributes.from_stat(os.lstat(os.path.join(path, name)))
|
||||
a.filename = name
|
||||
out.append(a)
|
||||
return out
|
||||
|
||||
def stat(self, path):
|
||||
return paramiko.SFTPAttributes.from_stat(os.stat(path))
|
||||
return SFTP()
|
||||
|
||||
|
||||
class FakeTarget:
|
||||
"""--fake-frame: a pretend Steam Frame with the library's working Quest builds installed (no network, no device),
|
||||
for documentation screenshots."""
|
||||
@@ -33,6 +78,7 @@ class FakeTarget:
|
||||
from frameport.frame.connection import parse_target
|
||||
|
||||
self.label = "steamframe"
|
||||
self.frame = FakeFS()
|
||||
self.target = parse_target("steamos@steamframe.local")
|
||||
works = [g for g in library.games() if g.get("kind") != "rift" and (g.get("build") or {}).get("sha256")
|
||||
and (g.get("recipe") or {}).get("status") in ("works", "issues")]
|
||||
@@ -54,6 +100,9 @@ class FakeTarget:
|
||||
pass
|
||||
|
||||
|
||||
STEP_SECONDS = 4 # per screen (the screenshot is taken ~3 s in)
|
||||
|
||||
|
||||
def driver(app: FramePortApp, steps: list[tuple[str, callable]], ready: threading.Event, done_step: list):
|
||||
for name, action in steps:
|
||||
try:
|
||||
@@ -62,7 +111,7 @@ def driver(app: FramePortApp, steps: list[tuple[str, callable]], ready: threadin
|
||||
ERRORS.append(f"{name}: {traceback.format_exc()}")
|
||||
done_step.append(name)
|
||||
ready.set()
|
||||
time.sleep(4)
|
||||
time.sleep(STEP_SECONDS)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -75,15 +124,23 @@ def main() -> int:
|
||||
ap.add_argument("--viewport", default="1280x820", help="browser size, e.g. 2560x1440")
|
||||
ap.add_argument("--update", action="store_true", help="pretend a new FramePort release exists (update UI)")
|
||||
ap.add_argument("--fake-frame", action="store_true", help="pretend a Steam Frame is connected (no device needed)")
|
||||
ap.add_argument("--hover", default=None, help="x,y: move the mouse there before the Library screenshot")
|
||||
ap.add_argument("--install-questions", nargs="+", metavar="PKG", default=None,
|
||||
help="open the install questions for these games, then click each dialog's main button twice "
|
||||
"(a double click must not duplicate dialogs); nothing is installed")
|
||||
ap.add_argument("--docs", action="store_true", help="only the screens used in the docs (Library, --game, Frame)")
|
||||
args = ap.parse_args()
|
||||
global STEP_SECONDS
|
||||
if args.hover:
|
||||
STEP_SECONDS = 7
|
||||
from frameport.ui import theme
|
||||
|
||||
theme.set_scale(args.scale)
|
||||
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)), ("tools", lambda a: a.navigate(2))]
|
||||
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)))
|
||||
@@ -95,8 +152,30 @@ 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)
|
||||
for e in a.files_view.entries[1:3]]))
|
||||
if args.install_questions:
|
||||
queued: list[str] = []
|
||||
|
||||
def open_questions(a):
|
||||
a._submit_install = lambda p, to: queued.append(p)
|
||||
a.install_many(args.install_questions, "frame")
|
||||
|
||||
def double_click(a):
|
||||
dialogs = [d for d in a.page._dialogs.controls if d.open]
|
||||
print(f"open dialogs: {len(dialogs)}", flush=True)
|
||||
button = dialogs[-1].actions[-1]
|
||||
button.on_click(None)
|
||||
button.on_click(None)
|
||||
|
||||
def report(a):
|
||||
print(f"open dialogs: {len([d for d in a.page._dialogs.controls if d.open])}, queued: {queued}", flush=True)
|
||||
steps = [("library", lambda a: a.navigate(0)), ("questions", open_questions),
|
||||
("after-1st-double", double_click), ("after-2nd-double", double_click), ("end", report)]
|
||||
if args.update:
|
||||
from frameport import updates
|
||||
|
||||
@@ -105,7 +184,7 @@ def main() -> int:
|
||||
notes="## What's new\n- Self-update test release\n- **Bold** and `code` in notes")
|
||||
steps += [("update-banner", lambda a: (a.updater._set(fake), a.navigate(0))),
|
||||
("update-dialog", lambda a: a.updater.show_dialog()),
|
||||
("update-settings", lambda a: (a.page.pop_dialog(), a.navigate(2)))]
|
||||
("update-settings", lambda a: (a.page.pop_dialog(), a.go("settings")))]
|
||||
if args.frame:
|
||||
from frameport.frame.connection import parse_target
|
||||
|
||||
@@ -130,6 +209,10 @@ def main() -> int:
|
||||
steps.append(("launch-test-job", job))
|
||||
ready, done = threading.Event(), []
|
||||
|
||||
if args.fake_frame: # never reach a real Frame (start-up auto-connect, discovery, the 30 s poll)
|
||||
FramePortApp.connect = lambda self, *a, **k: None
|
||||
FramePortApp.refresh_frame = lambda self, *a, **k: None
|
||||
|
||||
def app_main(page: ft.Page):
|
||||
try:
|
||||
app = FramePortApp(page)
|
||||
@@ -155,8 +238,13 @@ def main() -> int:
|
||||
while len(done) < len(steps) and time.time() < deadline:
|
||||
if ready.wait(1):
|
||||
ready.clear()
|
||||
name = done[-1]
|
||||
time.sleep(3) # let Flutter paint
|
||||
page.screenshot(path=str(args.out / f"{shot:02d}-{done[-1]}.png"))
|
||||
if args.hover and name == "library":
|
||||
hx, hy = (float(v) for v in args.hover.split(","))
|
||||
page.mouse.move(hx, hy)
|
||||
time.sleep(1.5)
|
||||
page.screenshot(path=str(args.out / f"{shot:02d}-{name}.png"))
|
||||
shot += 1
|
||||
browser.close()
|
||||
except Exception: # noqa: BLE001
|
||||
|
||||
@@ -14,6 +14,7 @@ import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
@@ -43,11 +44,18 @@ 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"
|
||||
script = updates.apply(new, installed, relaunch=False, pid=999999, platform=platform, wait=True)
|
||||
log = (home / "data/logs/update.log").read_text()
|
||||
# the same launch as the app (detached; apply() checks the script started), then wait for it to finish
|
||||
script = updates.apply(new, installed, relaunch=False, pid=999999, platform=platform)
|
||||
log_file = home / "data/logs/update.log"
|
||||
for _ in range(240):
|
||||
log = log_file.read_text() if log_file.exists() else ""
|
||||
if "not relaunching" in log:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
print(script.read_text()[:400], "...\n--- update.log ---\n" + log)
|
||||
installed_marker = installed / marker.relative_to(new)
|
||||
assert installed_marker.read_text() == "new", "the new files weren't installed"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "0.3.2"
|
||||
__version__ = "0.4.0"
|
||||
@@ -56,6 +56,29 @@ def _features(manifest: bytes) -> dict[str, bool]:
|
||||
return out
|
||||
|
||||
|
||||
def vr_kind(libs: set[str], manifest_strings: list[str]) -> str:
|
||||
"""What kind of Android app this is, for choosing how to port it:
|
||||
quest Meta Quest app (VrApi / OVRPlugin / Meta's OpenXR loader + Oculus manifest entries): OVRPort
|
||||
openxr another headset's OpenXR app (Pico, Khronos loader): OVRPort's loader + the Frame adapter
|
||||
android_xr Android XR (Jetpack XR / spatial) app: needs system services Lepton doesn't have
|
||||
pico_sdk Pico's pre-OpenXR SDK, wave: HTC Vive Wave SDK: no OpenXR path, can't run
|
||||
none an ordinary (2D) Android app or game: installed without the VR translation"""
|
||||
text = " ".join(manifest_strings)
|
||||
meta = (libs & {"libvrapi.so", "libOVRPlugin.so", "libovrplatformloader.so"}
|
||||
or "com.oculus." in text or "oculus.software." in text or "horizonos." in text)
|
||||
if meta:
|
||||
return "quest"
|
||||
if any(lib.startswith("libwvr") for lib in libs) or "com.htc.vr" in text:
|
||||
return "wave"
|
||||
if "libopenxr_loader.so" in libs or "org.khronos.openxr" in text:
|
||||
return "openxr"
|
||||
if libs & {"libPvr_UnitySDK.so", "libpxr_api.so", "libPxr_api.so", "libPvrSDK.so"} or "pvr.app.type" in text:
|
||||
return "pico_sdk"
|
||||
if "android.software.xr" in text or "androidx.xr" in text or "com.google.android.xr" in text:
|
||||
return "android_xr"
|
||||
return "none"
|
||||
|
||||
|
||||
# OpenXR composition-layer extensions the Frame runtime lacks (see docs/FRAME_RUNTIME.md).
|
||||
LAYER_EXTENSIONS = ("XR_KHR_composition_layer_cylinder", "XR_KHR_composition_layer_equirect",
|
||||
"XR_KHR_composition_layer_equirect2", "XR_KHR_composition_layer_cube")
|
||||
@@ -80,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)
|
||||
@@ -113,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
|
||||
@@ -126,6 +151,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
msaa_levels = 0
|
||||
|
||||
cats = axml.categories(manifest)
|
||||
manifest_strings = axml.Axml(manifest).strings()
|
||||
features = _features(manifest)
|
||||
used_perms, _ = axml.used_and_declared_permissions(manifest)
|
||||
return Analysis(
|
||||
@@ -151,13 +177,18 @@ 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),
|
||||
"xr_layer_exts": sorted(layer_exts), # composition-layer extensions the game's own libraries request
|
||||
"vr_kind": vr_kind(libset, manifest_strings),
|
||||
# the base of a split APK set (Play "app bundle" installs): the code/libraries live in split APKs
|
||||
"split_apk": bool({"isSplitRequired", "requiredSplitTypes"} & set(manifest_strings)),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -173,12 +204,14 @@ def missing_ovr_symbols(lib_bytes: dict[str, bytes]) -> set[str]:
|
||||
if not loader or not elf.is_elf(loader):
|
||||
return set()
|
||||
exported = set(elf.dyn_symbols(loader, True))
|
||||
linked = set(elf.needed(loader))
|
||||
for extra in ("libovrplatformcompat.so", "libovrstubs.so"):
|
||||
if extra in lib_bytes:
|
||||
if extra in lib_bytes and extra in linked: # only counts when the loader loads it (a lone file doesn't help)
|
||||
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
|
||||
@@ -219,7 +219,7 @@ def add_needed(data: bytes, library: str) -> bytes:
|
||||
table[note_i] = new_load
|
||||
# PT_LOAD entries must stay sorted by vaddr (bionic); the new one has the highest vaddr.
|
||||
load_slots = [i for i, p in enumerate(table) if p["type"] == PT_LOAD]
|
||||
for i, p in zip(load_slots, sorted((table[i] for i in load_slots), key=lambda p: p["vaddr"])):
|
||||
for i, p in zip(load_slots, sorted((table[i] for i in load_slots), key=lambda p: p["vaddr"]), strict=True):
|
||||
table[i] = p
|
||||
|
||||
buf += b"\0" * (file_end - len(buf))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -267,18 +268,22 @@ def candidates(tree: Tree) -> list[Path]:
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ naming
|
||||
_TAG_WORDS = r"(?:ARMGDDN|VRP|JF|FFA|Repacks?|Shipping|GOG|CODEX|PLAZA|FitGirl|DODI|ElAmigos|Oculus|Rift|PCVR)"
|
||||
# words that describe a build rather than the game (neutral; release tags are removed by their form, not by name)
|
||||
_BUILD_WORDS = r"(?:Shipping|GOG|Oculus|Rift|PCVR|Repacks?|Release)"
|
||||
# a release tag at the end: " -TAG", " -TAG v76", " -[TAG Repacks]" (a space before the dash and none after it, so
|
||||
# subtitles like " - Episode II" and hyphenated words like "Rick-ality" stay)
|
||||
_TRAILING_TAG = r"\s-(?=[\[A-Za-z0-9])(?:\[[^\]]*\]|[A-Za-z0-9]{2,12}\b)(?:\s+v\d+)?.*$"
|
||||
|
||||
|
||||
def clean_title(name: str) -> str:
|
||||
"""'Asgards Wrath v1.6.0 -ARMGDDN' -> 'Asgards Wrath'; 'Arktika 1 (v1.0.0.7) -VRP' -> 'Arktika 1';
|
||||
"""'Asgards Wrath v1.6.0 -TAG' -> 'Asgards Wrath'; 'Arktika 1 (v1.0.0.7) -TAG' -> 'Arktika 1';
|
||||
'Vader Immortal - Episode II v2.0.2+236948 Shipping' -> 'Vader Immortal - Episode II'."""
|
||||
t = re.sub(r"\[[^\]]*\]", " ", name) # [FFA Repacks]
|
||||
t = re.sub(r"\((?:[^)]*\d[^)]*|[^)]*" + _TAG_WORDS + r"[^)]*)\)", " ", t, flags=re.I) # (v1.0.0.7)
|
||||
t = re.sub(r"\s-\s*" + _TAG_WORDS + r"\b.*$", " ", t, flags=re.I) # -ARMGDDN, -VRP v76
|
||||
t = re.sub(_TRAILING_TAG, " ", name)
|
||||
t = re.sub(r"\[[^\]]*\]", " ", t) # [TAG Repacks]
|
||||
t = re.sub(r"\((?:[^)]*\d[^)]*|[^)]*\b" + _BUILD_WORDS + r"\b[^)]*)\)", " ", t, flags=re.I) # (v1.0.0.7)
|
||||
t = re.sub(r"\bv\d[\w.+\-]*", " ", t, flags=re.I) # v1.6.0, v008, v2.0.2+236948
|
||||
t = re.sub(r"(?<![\w.])\d+(?:\.\d+){2,}[\w+\-]*", " ", t) # 4.15.20, 21.11.08.358012
|
||||
t = re.sub(r"\b" + _TAG_WORDS + r"\b", " ", t, flags=re.I)
|
||||
t = re.sub(r"\b" + _BUILD_WORDS + r"\b", " ", t, flags=re.I)
|
||||
t = re.sub(r"[_]+", " ", t)
|
||||
t = re.sub(r"\s+", " ", t).strip(" -_.")
|
||||
return t or name.strip()
|
||||
@@ -356,13 +361,6 @@ def is_ambiguous(ranked: list[dict]) -> bool:
|
||||
return len(ranked) > 1 and ranked[0]["score"] < 1000 and ranked[1]["score"] >= ranked[0]["score"] - AMBIGUITY
|
||||
|
||||
|
||||
def find_exe(folder: Path) -> Path | None:
|
||||
"""The best program to start the game (used by callers that only need one)."""
|
||||
tree = walk(folder)
|
||||
ranked = rank_exes(folder, candidates(tree), tree.manifest)
|
||||
return Path(folder) / ranked[0]["path"] if ranked else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ analysis
|
||||
def fingerprint(folder: Path, exe_rel: str) -> str:
|
||||
p = Path(folder) / exe_rel
|
||||
@@ -432,8 +430,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()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Generate a tiny Android shared library whose exported functions all `return 0` (no compiler needed).
|
||||
|
||||
Used for Meta platform functions (ovr_*) that overport's platform loader lacks: without them the game fails with
|
||||
Used for Meta platform functions (ovr_*) that OVRPort's platform loader lacks: without them the game fails with
|
||||
UnsatisfiedLinkError / dlopen errors. The layout follows what bionic requires: section headers present (.dynsym,
|
||||
.dynstr, .hash, .text, .dynamic, .shstrtab), PT_LOAD segments 16 KiB aligned, PT_DYNAMIC matching the .dynamic
|
||||
section, DT_HASH, DT_SONAME and PT_GNU_STACK non-executable.
|
||||
|
||||
@@ -6,8 +6,8 @@ file stays byte-identical, which keeps diffs against known-good builds meaningfu
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator
|
||||
|
||||
RES_XML = 0x0003
|
||||
RES_STRING_POOL = 0x0001
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Signing (apksigner via the managed JRE) and alignment checks.
|
||||
|
||||
overport creates one keystore per package (password "password", alias "key"). The same key must sign every later
|
||||
OVRPort creates one keystore per package (password "password", alias "key"). The same key must sign every later
|
||||
build of that package, or Android refuses the update and saves are lost on reinstall.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -24,11 +24,30 @@ def _apksigner() -> str:
|
||||
return str(jar)
|
||||
|
||||
|
||||
def ensure_keystore(package: str) -> Path:
|
||||
"""The package's signing key. OVRPort creates it on its first patch; apps built without OVRPort (ordinary
|
||||
Android apps) get one made here with the same layout (password "password", alias "key")."""
|
||||
ks = overport_tool.keystore(package)
|
||||
if ks.exists():
|
||||
return ks
|
||||
java = toolchain.java_path()
|
||||
keytool = java.with_name("keytool.exe" if java and java.name.endswith(".exe") else "keytool") if java else None
|
||||
if not keytool or not keytool.exists():
|
||||
raise RuntimeError(f"no signing key for {package} and no keytool to create one (run `frameport tools install`)")
|
||||
import subprocess
|
||||
|
||||
p = subprocess.run([str(keytool), "-genkeypair", "-keystore", str(ks), "-storetype", "JKS", "-storepass", KS_PASS,
|
||||
"-keypass", KS_PASS, "-alias", KS_ALIAS, "-keyalg", "RSA", "-keysize", "2048",
|
||||
"-validity", "10000", "-dname", f"CN={package}, O=FramePort"],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
if p.returncode or not ks.exists():
|
||||
raise RuntimeError("couldn't create a signing key: " + (p.stdout + p.stderr)[-500:])
|
||||
return ks
|
||||
|
||||
|
||||
def sign(unsigned: Path, final: Path, package: str) -> None:
|
||||
"""Align (4 bytes; .so to 16 KiB pages) and sign v1+v2+v3. apksigner performs the alignment itself."""
|
||||
ks = overport_tool.keystore(package)
|
||||
if not ks.exists():
|
||||
raise RuntimeError(f"no signing key for {package} at {ks} (overport creates it on first patch)")
|
||||
ks = ensure_keystore(package)
|
||||
final.unlink(missing_ok=True)
|
||||
p = toolchain.run_java([
|
||||
"-jar", _apksigner(), "sign", "--ks", str(ks), "--ks-pass", f"pass:{KS_PASS}", "--ks-key-alias", KS_ALIAS,
|
||||
@@ -55,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))
|
||||
|
||||
@@ -61,6 +61,8 @@ def fetch_details(entry: dict, screenshots: bool = True) -> dict:
|
||||
"""Collect details for a library entry (doesn't store them; see pipeline.fetch_details)."""
|
||||
title = entry.get("title") or entry["package"]
|
||||
out: dict = {"sources": [], "fetched": time.time()}
|
||||
if ((entry.get("analysis") or {}).get("extra") or {}).get("vr_kind") == "none":
|
||||
return out # an Android app without VR isn't on a VR store; a title match would be another product
|
||||
try:
|
||||
app = oculusdb_app(entry)
|
||||
except Exception: # noqa: BLE001 - offline
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Store artwork for the Steam library (portrait/landscape/hero/logo/icon).
|
||||
|
||||
Fetched live from overport's image service (Meta store art by package name), cached per package. Falls back to
|
||||
Fetched live from OVRPort's image service (Meta store art by package name), cached per package. Falls back to
|
||||
the APK's launcher icon when the store has nothing (e.g. sideload-only titles).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -20,7 +20,9 @@ KINDS = {
|
||||
"APP_IMG_LOGO_TRANSPARENT": "logo",
|
||||
"APP_IMG_ICON": "icon",
|
||||
}
|
||||
EXTRA_KINDS = ("square",) # OculusDB's square cover (Rift games)
|
||||
# OculusDB's square cover (Rift games); FramePort's own cover/banner (no store art)
|
||||
EXTRA_KINDS = ("square", "cover", "banner")
|
||||
PICKED = ".picked" # marker: the art in this folder was chosen by the user (Find artwork); automatic fetches skip it
|
||||
|
||||
|
||||
def artwork_dir(package: str) -> Path:
|
||||
@@ -36,12 +38,18 @@ def _ext(data: bytes) -> str:
|
||||
def fetch(package: str, apk: Path | None = None, refresh: bool = False,
|
||||
lookup: str | None = None) -> tuple[Path, str | None]:
|
||||
"""Returns (folder with <kind>.<ext> files, store display name or None). `lookup` is the package whose store art
|
||||
to use (a Rift game borrows its Quest version's art); Rift ids without one have no store art."""
|
||||
to use (a Rift game borrows its Quest version's art); Rift ids without one have no store art.
|
||||
Without `refresh` it only fills in missing kinds and never touches art the user picked (PICKED marker): a pick
|
||||
often lacks a kind (Steam has no icon, OculusDB only a square cover), and re-fetching the store art for that
|
||||
replaced the pick at the next install."""
|
||||
out = artwork_dir(package)
|
||||
if refresh:
|
||||
(out / PICKED).unlink(missing_ok=True)
|
||||
have = {p.stem for p in out.iterdir()}
|
||||
title = None
|
||||
lookup = lookup or (None if package.startswith("rift.") else package)
|
||||
if lookup and (refresh or not {"portrait", "landscape", "hero", "icon"} <= have):
|
||||
picked = (out / PICKED).exists()
|
||||
if lookup and not picked and (refresh or not {"portrait", "landscape", "hero", "icon"} <= have):
|
||||
try:
|
||||
data = cache.http_get(API.format(package=lookup), timeout=30).json()
|
||||
except Exception:
|
||||
@@ -52,6 +60,8 @@ def fetch(package: str, apk: Path | None = None, refresh: bool = False,
|
||||
kind = KINDS.get(img.get("image_type"))
|
||||
if not kind:
|
||||
continue
|
||||
if not refresh and kind in have:
|
||||
continue # keep what's there; only fill gaps
|
||||
raw = base64.b64decode(img["uri"])
|
||||
for old in out.glob(f"{kind}.*"):
|
||||
old.unlink()
|
||||
@@ -67,21 +77,43 @@ def fetch(package: str, apk: Path | None = None, refresh: bool = False,
|
||||
return out, title
|
||||
|
||||
|
||||
def apk_icon(apk: Path) -> bytes | None:
|
||||
"""Largest PNG launcher icon inside the APK (mipmap/drawable)."""
|
||||
def apk_icon(apk: Path | str | None) -> bytes | None:
|
||||
"""The app's launcher icon from the APK as PNG bytes: the icon the manifest names (pyaxmlparser), else the
|
||||
largest ic_launcher bitmap. None for vector/adaptive-only icons (no bitmap to use)."""
|
||||
if not apk or not Path(apk).is_file():
|
||||
return None
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
dens = ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi")
|
||||
try:
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
cands = [i for i in z.infolist() if i.filename.startswith("res/") and "ic_launcher" in i.filename
|
||||
and i.filename.endswith((".png", ".webp"))]
|
||||
if not cands:
|
||||
cands = [i for i in z.infolist() if i.filename.startswith("res/mipmap") and i.filename.endswith(".png")]
|
||||
if not cands:
|
||||
return None
|
||||
best = max(cands, key=lambda i: i.file_size)
|
||||
data = z.read(best)
|
||||
return data if data[:4] == b"\x89PNG" else None
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
listed = set(z.namelist())
|
||||
names = []
|
||||
try:
|
||||
from pyaxmlparser import APK
|
||||
|
||||
named = APK(str(apk)).get_app_icon()
|
||||
if named:
|
||||
names.append(named)
|
||||
except Exception: # noqa: BLE001 - unreadable resources: fall back to the usual file names
|
||||
pass
|
||||
names += sorted((n for n in listed if n.startswith("res/") and "ic_launcher" in n
|
||||
and n.endswith((".png", ".webp")) and "foreground" not in n and "background" not in n),
|
||||
key=lambda n: next((i for i, d in enumerate(dens) if d in n), len(dens)))
|
||||
for n in names:
|
||||
if n not in listed or not n.endswith((".png", ".webp")):
|
||||
continue
|
||||
with Image.open(io.BytesIO(z.read(n))) as im:
|
||||
if im.width < 48:
|
||||
continue
|
||||
out = io.BytesIO()
|
||||
im.convert("RGBA").save(out, "PNG")
|
||||
return out.getvalue()
|
||||
except (zipfile.BadZipFile, OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def files(package: str) -> list[Path]:
|
||||
|
||||
@@ -139,10 +139,18 @@ def has_art(package: str) -> bool:
|
||||
|
||||
|
||||
def fetch_rift(package: str, title: str, canonical: str | None = None, quest_package: str | None = None,
|
||||
exe: Path | None = None) -> dict:
|
||||
"""Find and store artwork for a Rift game. Returns what was found: {source, quest_package, oculus_app_id,
|
||||
canonical_name, steam_appid} (only the keys that apply)."""
|
||||
exe: Path | None = None, oculus: bool = True) -> dict:
|
||||
"""Find and store artwork for a PC VR game. Returns what was found: {source, quest_package, oculus_app_id,
|
||||
canonical_name, steam_appid} (only the keys that apply). oculus=False (no Oculus code: a SteamVR/OpenXR game):
|
||||
Steam is asked first, the Oculus store only if Steam has nothing."""
|
||||
found: dict = {}
|
||||
if not oculus:
|
||||
try:
|
||||
st = match_steam(title)
|
||||
except Exception: # noqa: BLE001 - offline
|
||||
st = None
|
||||
if st and apply_steam(package, st["id"]):
|
||||
return {"steam_appid": st["id"], "source": "steam"}
|
||||
app = None
|
||||
try:
|
||||
app = match_rift(title, canonical)
|
||||
@@ -219,9 +227,15 @@ def apply_choice(package: str, choice: dict) -> bool:
|
||||
if choice["source"] == "Steam":
|
||||
apply_steam(tmp, choice["id"])
|
||||
elif choice.get("package"):
|
||||
# the picture the picker showed (OculusDB) is the cover; the store service by package adds the logo
|
||||
# and icon. Its covers can differ from the picked picture (e.g. a "dogfooding" placeholder), so they're
|
||||
# dropped when the picked picture arrived: the Steam shapes are composed from it instead.
|
||||
picked = apply_oculusdb(tmp, choice["app"]) if choice.get("app") else False
|
||||
fetch.fetch(tmp, lookup=choice["package"], refresh=True)
|
||||
if not has_art(tmp):
|
||||
apply_oculusdb(tmp, choice["app"])
|
||||
if picked:
|
||||
for f in stage.iterdir():
|
||||
if f.stem in ("portrait", "landscape", "hero"):
|
||||
f.unlink()
|
||||
else:
|
||||
apply_oculusdb(tmp, choice["app"])
|
||||
if not has_art(tmp):
|
||||
@@ -233,6 +247,7 @@ def apply_choice(package: str, choice: dict) -> bool:
|
||||
for f in stage.iterdir():
|
||||
if f.is_file() and f.stem in ART_STEMS:
|
||||
f.replace(d / f.name)
|
||||
(d / fetch.PICKED).write_text(choice.get("source", ""), encoding="utf-8") # keep it through installs
|
||||
return True
|
||||
finally:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
@@ -4,6 +4,8 @@ Steam wants: portrait 600×900 (library grid), landscape 920×430 (recent/horizo
|
||||
logo (transparent, drawn on the hero) and an icon. Store art rarely covers all of them — Rift games from OculusDB only
|
||||
have a square cover — so missing shapes are composed: the best source image, fitted and centred over a blurred,
|
||||
darkened copy of itself (or a screenshot, for the hero). Results are cached in artwork/<pkg>/steam/.
|
||||
A game without any artwork (e.g. an Android app that isn't on a VR store) gets a placeholder set: its name on a
|
||||
coloured background, with the APK's own launcher icon when it has a bitmap one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -65,15 +67,20 @@ def _ratio_ok(src: Path, size) -> bool:
|
||||
return abs(r - target) / target < 0.18
|
||||
|
||||
|
||||
def steam_set(package: str) -> dict[str, Path]:
|
||||
"""{portrait, landscape, hero, logo?, icon?} files ready for Steam (created once per source change)."""
|
||||
def steam_set(package: str, title: str = "", apk: str | Path | None = None) -> dict[str, Path]:
|
||||
"""{portrait, landscape, hero, logo?, icon?} files ready for Steam (created once per source change). Without any
|
||||
artwork: a placeholder set showing `title` (+ the launcher icon from `apk`), or {} without a title."""
|
||||
src = _sources(package)
|
||||
if not src:
|
||||
if not src and not title:
|
||||
return {}
|
||||
title = title or package
|
||||
out_dir = fetch.artwork_dir(package) / "steam"
|
||||
out_dir.mkdir(exist_ok=True)
|
||||
key = hashlib.sha1("|".join(f"{k}:{p.stat().st_size}:{p.stat().st_mtime_ns}" for k, p in sorted(src.items()))
|
||||
.encode()).hexdigest()[:8]
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
placeholder = not (set(src) - {"icon"}) # no store art (at most the APK's icon): the name on a coloured tile
|
||||
parts = [f"{k}:{p.stat().st_size}:{p.stat().st_mtime_ns}" for k, p in sorted(src.items())]
|
||||
if placeholder:
|
||||
parts.append(f"placeholder:{PLACEHOLDER_VERSION}:{title}:{apk or ''}")
|
||||
key = hashlib.sha1("|".join(parts).encode()).hexdigest()[:8]
|
||||
stamp = out_dir / f".{key}"
|
||||
result: dict[str, Path] = {}
|
||||
if stamp.exists():
|
||||
@@ -83,6 +90,10 @@ def steam_set(package: str) -> dict[str, Path]:
|
||||
return result
|
||||
for p in out_dir.iterdir():
|
||||
p.unlink()
|
||||
if placeholder:
|
||||
result = _placeholder(out_dir, title, src.get("icon"), apk)
|
||||
stamp.write_text("")
|
||||
return result
|
||||
for kind, size in SIZES.items():
|
||||
choice = next((k for k in PREFER[kind] if k in src), None)
|
||||
if not choice:
|
||||
@@ -115,15 +126,192 @@ def steam_set(package: str) -> dict[str, Path]:
|
||||
return result
|
||||
|
||||
|
||||
PLACEHOLDER_VERSION = 2
|
||||
|
||||
|
||||
STORE_KINDS = {"portrait", "square", "landscape", "hero"}
|
||||
|
||||
|
||||
def ensure_cover(package: str) -> Path | None:
|
||||
"""A cover for FramePort's own library when a game has no store art (e.g. a 2D Android app): the same name +
|
||||
APK icon design as the Steam placeholder, saved as artwork/<pkg>/cover.jpg. None when store art exists."""
|
||||
from ..core import library
|
||||
|
||||
d = fetch.artwork_dir(package)
|
||||
if {p.stem for p in fetch.files(package)} & STORE_KINDS:
|
||||
return None
|
||||
g = library.game(package) or {}
|
||||
if not any(d.glob("icon.*")): # the icon also shows in lists (Frame page)
|
||||
icon = fetch.apk_icon(g.get("apk"))
|
||||
if icon:
|
||||
(d / "icon.png").write_bytes(icon)
|
||||
# icon-only (the card and the game page show the name anyway): a tall cover for cards, a wide banner for the page
|
||||
title = g.get("title") or package
|
||||
cover, banner = d / "cover.jpg", d / "banner.jpg"
|
||||
stamp = d / ".cover"
|
||||
key = f"{PLACEHOLDER_VERSION}:{title}:{next((p.stat().st_size for p in d.glob('icon.*')), 0)}"
|
||||
if cover.exists() and banner.exists() and stamp.exists() and stamp.read_text(encoding="utf-8") == key:
|
||||
return cover
|
||||
icon = _load_icon(next(iter(sorted(d.glob("icon.*"))), None), g.get("apk"))
|
||||
background = _background_for(title)
|
||||
# cover: icon a bit above the middle (the card's title sits at the bottom); banner: icon on the right (the game
|
||||
# page draws the title on the left)
|
||||
for path, size, side, centre in ((cover, (600, 900), 300, (0.5, 0.4)), (banner, (1600, 600), 320, (0.75, 0.5))):
|
||||
im = background(size)
|
||||
if icon is not None:
|
||||
ic = icon.resize((side, side))
|
||||
im.paste(ic, (round(size[0] * centre[0] - side / 2), round(size[1] * centre[1] - side / 2)), ic)
|
||||
im.save(path, "JPEG", quality=90, optimize=True)
|
||||
stamp.write_text(key, encoding="utf-8")
|
||||
return cover
|
||||
|
||||
|
||||
def steam_set_for(package: str) -> dict[str, Path]:
|
||||
"""steam_set() with the library entry's title and APK, so a game without artwork gets a placeholder."""
|
||||
from ..core import library
|
||||
|
||||
g = library.game(package) or {}
|
||||
return steam_set(package, title=g.get("title") or package, apk=g.get("apk"))
|
||||
|
||||
|
||||
def _apk_icon(apk: str | Path | None):
|
||||
"""The APK's launcher icon as a PIL image, or None (see fetch.apk_icon)."""
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
data = fetch.apk_icon(apk)
|
||||
if not data:
|
||||
return None
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
return im.convert("RGBA")
|
||||
|
||||
|
||||
def _font(size: int):
|
||||
from PIL import ImageFont
|
||||
|
||||
try:
|
||||
return ImageFont.load_default(size=size) # Pillow's bundled scalable font (needs FreeType)
|
||||
except Exception: # noqa: BLE001
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _wrap(draw, text: str, font, width: int) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for word in text.split():
|
||||
if lines and draw.textlength(f"{lines[-1]} {word}", font=font) <= width:
|
||||
lines[-1] += f" {word}"
|
||||
else:
|
||||
lines.append(word)
|
||||
return lines or [text]
|
||||
|
||||
|
||||
def _title_block(draw, text: str, box: tuple[int, int, int, int], max_size: int, fill) -> None:
|
||||
"""Draw `text` centred in box (x0, y0, x1, y1), as large as fits (wrapped, at most 3 lines)."""
|
||||
x0, y0, x1, y1 = box
|
||||
size = max_size
|
||||
while True:
|
||||
font = _font(size)
|
||||
lines = _wrap(draw, text, font, x1 - x0)
|
||||
line_h = round(size * 1.2)
|
||||
too_wide = any(draw.textlength(line, font=font) > x1 - x0 for line in lines)
|
||||
if size <= 14 or (len(lines) <= 3 and not too_wide and line_h * len(lines) <= y1 - y0):
|
||||
break
|
||||
size = round(size * 0.9)
|
||||
top = y0 + (y1 - y0 - line_h * len(lines)) // 2
|
||||
for i, line in enumerate(lines):
|
||||
w = draw.textlength(line, font=font)
|
||||
draw.text((x0 + (x1 - x0 - w) / 2, top + i * line_h), line, font=font, fill=fill)
|
||||
|
||||
|
||||
def _background_for(title: str):
|
||||
"""A vertical gradient in a colour picked from the title: background(size) -> PIL image."""
|
||||
import colorsys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
hue = int(hashlib.sha1(title.encode()).hexdigest()[:4], 16) / 0xFFFF
|
||||
top = tuple(round(c * 255) for c in colorsys.hls_to_rgb(hue, 0.30, 0.45))
|
||||
bottom = tuple(round(c * 255) for c in colorsys.hls_to_rgb(hue, 0.10, 0.40))
|
||||
|
||||
def background(size):
|
||||
mask = Image.linear_gradient("L").resize(size) # 0 at the top → 255 at the bottom
|
||||
return Image.composite(Image.new("RGB", size, bottom), Image.new("RGB", size, top), mask)
|
||||
return background
|
||||
|
||||
|
||||
def _load_icon(icon_file: Path | None, apk):
|
||||
from PIL import Image
|
||||
|
||||
if icon_file:
|
||||
try:
|
||||
with Image.open(icon_file) as im:
|
||||
return im.convert("RGBA")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return _apk_icon(apk)
|
||||
|
||||
|
||||
def _placeholder(out_dir: Path, title: str, icon_file: Path | None, apk) -> dict[str, Path]:
|
||||
"""Name-on-colour art (portrait, landscape, hero, logo, icon), the hue picked from the title."""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
icon = _load_icon(icon_file, apk)
|
||||
background = _background_for(title)
|
||||
|
||||
result: dict[str, Path] = {}
|
||||
for kind, (w, h) in SIZES.items():
|
||||
im = background((w, h))
|
||||
draw = ImageDraw.Draw(im)
|
||||
if kind != "hero": # Steam draws the logo (the name) over the hero
|
||||
text_box = (w // 10, h // 10, w - w // 10, h - h // 10)
|
||||
if icon is not None:
|
||||
side = min(w, h) // 3
|
||||
ic = icon.resize((side, side), Image.LANCZOS)
|
||||
if kind == "portrait":
|
||||
im.paste(ic, ((w - side) // 2, h // 4 - side // 4), ic)
|
||||
text_box = (w // 10, h // 4 + side, w - w // 10, h - h // 10)
|
||||
else:
|
||||
im.paste(ic, (w // 10, (h - side) // 2), ic)
|
||||
text_box = (w // 10 + side + w // 20, h // 10, w - w // 12, h - h // 10)
|
||||
_title_block(draw, title, text_box, h // 7 if kind == "portrait" else h // 5, (255, 255, 255))
|
||||
path = out_dir / f"{kind}.jpg"
|
||||
im.save(path, "JPEG", quality=90, optimize=True)
|
||||
result[kind] = path
|
||||
logo = Image.new("RGBA", (1200, 400), (0, 0, 0, 0))
|
||||
_title_block(ImageDraw.Draw(logo), title, (20, 20, 1180, 380), 150, (255, 255, 255, 255))
|
||||
logo = logo.crop(logo.getbbox() or (0, 0, 1200, 400))
|
||||
result["logo"] = out_dir / "logo.png"
|
||||
logo.save(result["logo"], "PNG")
|
||||
ic = background((256, 256))
|
||||
if icon is not None:
|
||||
inner = icon.resize((216, 216), Image.LANCZOS)
|
||||
ic.paste(inner, (20, 20), inner)
|
||||
else:
|
||||
_title_block(ImageDraw.Draw(ic), title[:2].upper() if len(title) > 3 else title, (24, 24, 232, 232), 140,
|
||||
(255, 255, 255))
|
||||
result["icon"] = out_dir / "icon.png"
|
||||
ic.save(result["icon"], "PNG")
|
||||
return result
|
||||
|
||||
|
||||
def original_platform(entry: dict) -> str:
|
||||
return "Oculus Rift" if entry.get("kind") == "rift" else "Meta Quest"
|
||||
"""What the game was made for (a Steam tag)."""
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
if entry.get("kind") == "rift":
|
||||
return "Oculus Rift" if extra.get("needs_revive") else "PC VR"
|
||||
kind = extra.get("vr_kind") or "quest"
|
||||
return {"quest": "Meta Quest", "none": "Android"}.get(kind, "Android VR")
|
||||
|
||||
|
||||
def steam_tags(entry: dict, where: str = "frame") -> list[str]:
|
||||
"""Tags for the Steam shortcut (Steam can group them into collections): how it runs, the game's original platform,
|
||||
genres and the user's own tags."""
|
||||
runs = ("PC VR on Frame" if entry.get("kind") == "rift" else "Quest on Frame") if where == "frame" else \
|
||||
"Rift via Revive"
|
||||
from ..targets.pc_revive import TAG
|
||||
|
||||
kind = ((entry.get("analysis") or {}).get("extra") or {}).get("vr_kind") or "quest"
|
||||
runs = TAG if where != "frame" else "PC VR on Frame" if entry.get("kind") == "rift" else \
|
||||
"Quest on Frame" if kind == "quest" else "Android on Frame"
|
||||
base = [runs, original_platform(entry)]
|
||||
genres = ((entry.get("details") or {}).get("genres") or [])[:4]
|
||||
return list(dict.fromkeys(base + genres + list(entry.get("tags") or [])))
|
||||
@@ -12,7 +12,8 @@ from pathlib import Path
|
||||
from ..core.paths import user_data_dir
|
||||
from . import fetch
|
||||
|
||||
WIDTHS = {"portrait": 400, "square": 400, "landscape": 1280, "hero": 1280, "icon": 96, "logo": 600}
|
||||
WIDTHS = {"portrait": 400, "square": 400, "cover": 400, "banner": 1280, "landscape": 1280, "hero": 1280, "icon": 96,
|
||||
"logo": 600}
|
||||
_locks: dict[str, threading.Lock] = {}
|
||||
_guard = threading.Lock()
|
||||
|
||||
@@ -62,13 +63,27 @@ def pick(package: str, kinds: tuple[str, ...]) -> Path | None:
|
||||
return next((files[k] for k in kinds if k in files), None)
|
||||
|
||||
|
||||
def url(package: str, kinds: tuple[str, ...], width: int | None = None) -> str | None:
|
||||
"""Asset URL (for ft.Image / DecorationImage src) of the first available kind, as a thumbnail."""
|
||||
def url(package: str, kinds: tuple[str, ...], width: int | None = None, wait: bool = True) -> str | None:
|
||||
"""Asset URL (for ft.Image / DecorationImage src) of the first available kind, as a thumbnail. wait=False (render
|
||||
paths): if the thumbnail doesn't exist yet it is made in the background and the original is used meanwhile."""
|
||||
src = pick(package, kinds)
|
||||
if not src:
|
||||
return None
|
||||
t = thumb(src, width or WIDTHS.get(src.stem, 600), src.stem)
|
||||
return asset_url(t)
|
||||
width = width or WIDTHS.get(src.stem, 600)
|
||||
if not wait:
|
||||
ready = ready_thumb(src, width, src.stem)
|
||||
if ready is None:
|
||||
threading.Thread(target=thumb, args=(src, width, src.stem), daemon=True).start()
|
||||
return asset_url(src)
|
||||
return asset_url(ready)
|
||||
return asset_url(thumb(src, width, src.stem))
|
||||
|
||||
|
||||
def ready_thumb(path: Path, width: int, kind: str | None = None) -> Path | None:
|
||||
"""The thumbnail if it already exists (no image work)."""
|
||||
kind = kind or path.stem
|
||||
out = path.parent / f"t_{kind}_{width}_{_digest(path)}{'.png' if kind == 'logo' else '.jpg'}"
|
||||
return out if out.exists() else None
|
||||
|
||||
|
||||
def asset_url(path: Path) -> str:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Build stage: overport → Frame fixes (apk-stage patches) → sign → static validation."""
|
||||
"""Build stage: OVRPort → Frame fixes (apk-stage patches) → sign → static validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -25,7 +25,7 @@ def sha256(path: Path) -> str:
|
||||
|
||||
|
||||
def overport_ids(recipe: Recipe, alt: bool = False) -> list[str]:
|
||||
"""overport defaults (overport's order) first, then extras in recipe order, then the alt-build extras."""
|
||||
"""OVRPort defaults (OVRPort's order) first, then extras in recipe order, then the alt-build extras."""
|
||||
chosen = [pid for pid in recipe.patches if base.get(pid).category == "overport"]
|
||||
order = [pid for pid, *_ in OVERPORT_PATCHES]
|
||||
defaults = [pid for pid in order if pid in chosen and base.get(pid).default_on]
|
||||
@@ -64,14 +64,28 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
work.mkdir(parents=True)
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
variants = [("primary", False)] + ([("alt", True)] if recipe.alt_patches else [])
|
||||
# an APK that is already an OVRPort/FramePort build (e.g. a copy from an earlier FramePort) isn't converted again
|
||||
# for the normal build: a second conversion replaces OVRPort's platform loader and drops what the first build
|
||||
# linked to it (Wallace & Gromit / Espire 2 then crashed with "cannot locate symbol ovr_..."). Only the Steam
|
||||
# Frame patches are applied on top. Its alternate build is the copy saved next to it, or (no copy) one more
|
||||
# OVRPort run for the alternate patches; the stand-ins patch relinks what that drops.
|
||||
converted = recipe.overport and analysis.is_overport_output
|
||||
variants = [("primary", False)] + ([("alt", True)] if recipe.alt_patches and recipe.overport else [])
|
||||
alt_copy = Path(source.apk).with_name(f"{pkg}.alt-noforcequit.apk")
|
||||
results = {}
|
||||
all_checks, applied = [], []
|
||||
try:
|
||||
for variant, alt in variants:
|
||||
reporter.stage(f"overport ({variant})")
|
||||
ids = overport_ids(recipe, alt)
|
||||
patched = overport_tool.patch(source.apk, work, f"{pkg}.{variant}.overport.apk", ids, reporter)
|
||||
input_apk = alt_copy if converted and alt and alt_copy.exists() else Path(source.apk)
|
||||
if recipe.overport and (not converted or alt and input_apk == Path(source.apk)):
|
||||
reporter.stage(f"OVRPort ({variant})")
|
||||
ids = overport_ids(recipe, alt)
|
||||
patched = overport_tool.patch(source.apk, work, f"{pkg}.{variant}.overport.apk", ids, reporter)
|
||||
else: # an ordinary Android app, or an already converted build: only the Frame patches it needs
|
||||
if recipe.overport:
|
||||
reporter.log(f"{input_apk.name} is already converted: applying only the Steam Frame patches")
|
||||
patched = work / f"{pkg}.{variant}.original.apk"
|
||||
shutil.copyfile(input_apk, patched)
|
||||
reporter.stage(f"Frame fixes ({variant})")
|
||||
unsigned = work / f"{pkg}.{variant}.unsigned.apk"
|
||||
applied, checks = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter)
|
||||
@@ -81,7 +95,7 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
|
||||
sign.sign(unsigned, final, pkg)
|
||||
unsigned.unlink(missing_ok=True)
|
||||
reporter.stage(f"validate ({variant})")
|
||||
checks += check_apk(final, pkg)
|
||||
checks += check_apk(final, pkg, expect_adapter=recipe.overport)
|
||||
for c in checks:
|
||||
reporter.check(c["name"], c["ok"], c["detail"])
|
||||
results[variant] = final
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""FramePort command line. Same operations as the GUI (for automation and headless use).
|
||||
Exit codes: 0 done, 1 something failed, 2 wrong usage, 10 (`update --check`) a newer version exists.
|
||||
Errors are one line on stderr; FRAMEPORT_DEBUG=1 shows the traceback.
|
||||
|
||||
frameport tools install # portable Java + overport + apksigner
|
||||
frameport tools install # portable Java + OVRPort + apksigner
|
||||
frameport scan "<folder with game dumps>" # analyze + suggest recipes
|
||||
frameport list | show <pkg> | patches
|
||||
frameport recipe <pkg> --enable frame.nodebug --set scale=1.2
|
||||
@@ -8,8 +10,9 @@
|
||||
frameport frame discover | pair | info --frame steamos@frame.local
|
||||
frameport install <pkg> --frame steamos@frame.local [--apk-only]
|
||||
frameport test <pkg> --frame ... # headless launch + triage
|
||||
frameport frame proton [--install] # Proton on the Frame, for Oculus Rift (PC VR) games
|
||||
frameport install rift.<game> --to pc|frame # Rift games: this PC (Revive + Steam) or the Frame (Proton)
|
||||
frameport frame send <files> --dest videos # copy files to the Frame (where every game sees them)
|
||||
frameport frame proton [--install] # Proton on the Frame, for PC VR games
|
||||
frameport install rift.<game> --to pc|frame # PC VR games: this PC (Steam) or the Frame (Proton)
|
||||
frameport pc info # Windows Steam / SteamVR / Revive on this PC
|
||||
frameport parity --known-good <PATCHED dir> # rebuild everything and diff against known-good APKs
|
||||
frameport diag collect <pkg>|--all [--no-frame] # redacted diagnostics zip (attach it to a GitHub issue)
|
||||
@@ -31,10 +34,17 @@ from .core import library
|
||||
from .core.events import printing_reporter
|
||||
from .patches import base
|
||||
|
||||
app = typer.Typer(add_completion=False, no_args_is_help=True, help="Port Meta Quest games to the Steam Frame.")
|
||||
FRAME_HELP = "the Frame as user@host (default: the paired Frame)"
|
||||
ALL_HELP = "every game in the library"
|
||||
JSON_HELP = "print JSON"
|
||||
|
||||
app = typer.Typer(add_completion=False, no_args_is_help=True,
|
||||
help="Run Meta Quest, Android and PC VR games on the Steam Frame.",
|
||||
epilog="Exit codes: 0 done, 1 something failed, 2 wrong usage, 10 (update --check) a newer version "
|
||||
"exists. The GUI is frameport-gui.")
|
||||
tools_app = typer.Typer(help="Manage the portable toolchain.")
|
||||
frame_app = typer.Typer(help="Find and connect to a Steam Frame.")
|
||||
pc_app = typer.Typer(help="This PC as a target for Oculus Rift (PC VR) games via Revive.")
|
||||
pc_app = typer.Typer(help="This PC as a target for PC VR games (Steam and SteamVR; Revive for Oculus games).")
|
||||
diag_app = typer.Typer(help="Diagnostics bundles and problem reports.")
|
||||
app.add_typer(tools_app, name="tools")
|
||||
app.add_typer(frame_app, name="frame")
|
||||
@@ -121,13 +131,12 @@ def update_cmd(check: bool = typer.Option(False, "--check", help="Only say wheth
|
||||
if kind == "wheel" and sys.platform == "win32":
|
||||
# the running frameport.exe can't be replaced while it runs: finish the upgrade right after this exits
|
||||
line = " && ".join(subprocess.list2cmdline(c) for c in cmds)
|
||||
subprocess.Popen(f'cmd /c "timeout /t 2 /nobreak >nul && {line}"', creationflags=0x00000008 | 0x00000200,
|
||||
close_fds=True)
|
||||
updates.spawn_hidden(["cmd", "/c", f"ping -n 3 127.0.0.1 >nul && {line}"])
|
||||
typer.echo(f"Updating to FramePort {up.version} in the background: run `frameport --version` in a few seconds.")
|
||||
return
|
||||
for cmd in cmds:
|
||||
typer.echo("$ " + " ".join(cmd))
|
||||
if subprocess.call(cmd):
|
||||
if subprocess.call(cmd, timeout=updates.UPGRADE_TIMEOUT, env=updates.upgrade_env()):
|
||||
typer.echo("Update failed.", err=True)
|
||||
raise typer.Exit(1)
|
||||
typer.echo(f"Updated to FramePort {up.version}.")
|
||||
@@ -158,27 +167,34 @@ 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:
|
||||
raise typer.BadParameter(f"{package!r} matches {len(matches)} games: {matches[:5]}")
|
||||
if not matches:
|
||||
raise typer.BadParameter(f"no game in the library matches {package!r} (see frameport list)")
|
||||
raise typer.BadParameter(f"{package!r} matches {len(matches)} games: {', '.join(matches[:5])}"
|
||||
+ (" …" if len(matches) > 5 else ""))
|
||||
return matches
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ tools
|
||||
@tools_app.command("status")
|
||||
def tools_status(check_latest: bool = typer.Option(False, "--latest", help="also look up the newest versions")):
|
||||
"""Which tools FramePort has (Java runtime, OVRPort, apksigner, Revive)."""
|
||||
from .tools import toolchain
|
||||
|
||||
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")
|
||||
def tools_install(update: bool = typer.Option(False, help="update to the newest versions"),
|
||||
revive: bool = typer.Option(False, help="also install Revive (for Oculus Rift games)")):
|
||||
revive: bool = typer.Option(False, help="also install Revive (for Oculus PC games)")):
|
||||
"""Download the tools FramePort needs into its own folder (nothing is installed system-wide)."""
|
||||
from .tools import overport as ov
|
||||
from .tools import toolchain
|
||||
|
||||
@@ -190,11 +206,11 @@ def tools_install(update: bool = typer.Option(False, help="update to the newest
|
||||
|
||||
added = op.refresh(ov.list_patches)
|
||||
if added:
|
||||
typer.echo("new overport patches: " + ", ".join(added))
|
||||
typer.echo("new OVRPort patches: " + ", ".join(added))
|
||||
|
||||
|
||||
@tools_app.command("import-keys")
|
||||
def tools_import_keys(dirs: list[Path]):
|
||||
def tools_import_keys(dirs: list[Path] = typer.Argument(..., help="folders with <package>.keystore files")):
|
||||
"""Import existing per-package signing keystores (keeps updates installable over existing installs)."""
|
||||
from .tools import overport as ov
|
||||
|
||||
@@ -203,7 +219,7 @@ def tools_import_keys(dirs: list[Path]):
|
||||
|
||||
# ------------------------------------------------------------------------------------------ library
|
||||
@app.command()
|
||||
def scan(path: Path):
|
||||
def scan(path: Path = typer.Argument(..., help="a folder with game backups (APKs or PC VR game folders), or an APK")):
|
||||
"""Find games under PATH, analyze them and suggest patches."""
|
||||
rep = printing_reporter(verbose=False)
|
||||
for g in pipeline.add_path(path, rep):
|
||||
@@ -213,6 +229,7 @@ def scan(path: Path):
|
||||
|
||||
@app.command("list")
|
||||
def list_games():
|
||||
"""The games in the library."""
|
||||
for g in library.games():
|
||||
a = g["analysis"]
|
||||
built = "built" if g.get("build", {}).get("ok") else ("build-failed" if g.get("build") else "")
|
||||
@@ -221,8 +238,9 @@ def list_games():
|
||||
|
||||
|
||||
@app.command()
|
||||
def show(package: str, as_json: bool = typer.Option(False, "--json"),
|
||||
def show(package: str, as_json: bool = typer.Option(False, "--json", help=JSON_HELP),
|
||||
all_: bool = typer.Option(False, "--all", help="also list patches that don't apply to this game")):
|
||||
"""A game's analysis and recipe (its patches, with the reason for each)."""
|
||||
[pkg] = _pkgs(package, False)
|
||||
g = library.game(pkg)
|
||||
if as_json:
|
||||
@@ -243,7 +261,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()
|
||||
@@ -255,11 +274,13 @@ 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", help="patch id to turn on (repeatable)"),
|
||||
disable: list[str] = typer.Option([], "--disable", help="patch id to turn off (repeatable)"),
|
||||
set_: list[str] = typer.Option([], "--set", help="adapter setting key=value"),
|
||||
use_alt: Optional[bool] = typer.Option(None, "--use-alt/--no-alt"), reset: bool = False,
|
||||
use_alt: Optional[bool] = typer.Option(None, "--use-alt/--no-alt", help="install the alternate build"),
|
||||
reset: bool = typer.Option(False, help="start again from the suggested recipe"),
|
||||
as_is: Optional[bool] = typer.Option(None, "--as-is/--patch", help="install unchanged (already patched)"),
|
||||
exe: Optional[str] = typer.Option(None, help="Rift games: the program that starts the game (relative)")):
|
||||
exe: Optional[str] = typer.Option(None, help="PC VR games: the program that starts the game (relative)")):
|
||||
"""Change a game's patch selection."""
|
||||
[pkg] = _pkgs(package, False)
|
||||
r = pipeline.reset_recipe(pkg) if reset else library.recipe_from_dict(library.game(pkg)["recipe"])
|
||||
@@ -288,8 +309,9 @@ def recipe(package: str, enable: list[str] = typer.Option([], "--enable"), disab
|
||||
|
||||
# ------------------------------------------------------------------------------------------ build / install / test
|
||||
@app.command()
|
||||
def build(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all"),
|
||||
outdir: Optional[Path] = None, verbose: bool = False):
|
||||
def build(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all", help=ALL_HELP),
|
||||
outdir: Optional[Path] = typer.Option(None, help="where to write the APKs (default: FramePort's data)"),
|
||||
verbose: bool = typer.Option(False, help="print every step")):
|
||||
"""Patch and sign (primary + alternate build when the recipe has one)."""
|
||||
failed = []
|
||||
for pkg in _pkgs(package, all_):
|
||||
@@ -300,19 +322,21 @@ def build(package: Optional[str] = typer.Argument(None), all_: bool = typer.Opti
|
||||
if not info["ok"]:
|
||||
failed.append(pkg)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
typer.echo(f"{pkg}: FAILED {exc}")
|
||||
typer.echo(f"{pkg}: FAILED {exc}", err=True)
|
||||
failed.append(pkg)
|
||||
raise typer.Exit(1 if failed else 0)
|
||||
|
||||
|
||||
@app.command()
|
||||
def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all"),
|
||||
frame: Optional[str] = typer.Option(None, help="steamos@host"), password: Optional[str] = None,
|
||||
def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all", help=ALL_HELP),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
password: Optional[str] = typer.Option(None, help="the Frame's password (first connection only)"),
|
||||
apk_only: bool = typer.Option(False, help="reuse game data already on the Frame"),
|
||||
no_library: bool = typer.Option(False, help="don't add to the Steam library now"),
|
||||
to: str = typer.Option("frame", help="frame, or pc (Oculus Rift games only: run on this PC via Revive)"),
|
||||
to: str = typer.Option("frame", help="frame, or pc (PC VR games only: install on this PC)"),
|
||||
apk: Optional[Path] = typer.Option(None, help="install this APK instead of the last build (one game; e.g. "
|
||||
"a test build signed with the game's key)")):
|
||||
"""Install games on the Frame (or PC VR games on this PC) and add them to the Steam library."""
|
||||
target = _target(frame, password, to)
|
||||
pkgs = _pkgs(package, all_)
|
||||
if apk and len(pkgs) != 1:
|
||||
@@ -324,15 +348,16 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
|
||||
|
||||
|
||||
@app.command()
|
||||
def test(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all"),
|
||||
frame: Optional[str] = None, seconds: int = 45,
|
||||
to: str = typer.Option("frame", help="frame, or pc (Rift games installed on this PC)")):
|
||||
def test(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all", help=ALL_HELP),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
seconds: int = typer.Option(45, help="how long the game runs before it's stopped"),
|
||||
to: str = typer.Option("frame", help="frame, or pc (PC VR games installed on this PC)")):
|
||||
"""Headless launch on the Frame (or this PC) + log triage."""
|
||||
target = _target(frame, to=to)
|
||||
installed = {g["package"] for g in target.installed()}
|
||||
for pkg in _pkgs(package, all_):
|
||||
if pkg not in installed:
|
||||
typer.echo(f"{pkg}: not installed on {target.label}")
|
||||
typer.echo(f"{pkg}: not installed on {target.label}", err=True)
|
||||
continue
|
||||
s = pipeline.test_game(pkg, target, printing_reporter(False), seconds)
|
||||
typer.echo(f"{pkg}: {s['state']} ({s['verdict']}) furthest: {s['milestone']}"
|
||||
@@ -340,18 +365,21 @@ def test(package: Optional[str] = typer.Argument(None), all_: bool = typer.Optio
|
||||
|
||||
|
||||
@app.command()
|
||||
def triage(logfile: Path, package: Optional[str] = None):
|
||||
def triage(logfile: Path = typer.Argument(..., help="a launch.log"),
|
||||
package: Optional[str] = typer.Option(None, help="the game's package (keeps only its log lines)")):
|
||||
"""Classify a launch.log offline."""
|
||||
from .validate.triage import triage as run_triage
|
||||
|
||||
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()
|
||||
def settings(package: str, values: list[str], frame: Optional[str] = None):
|
||||
def settings(package: str, values: list[str] = typer.Argument(..., help="key=value pairs"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Change FrameBridge settings of an installed game (no re-patching), e.g. scale=1.2."""
|
||||
target = _target(frame)
|
||||
kv = dict(v.split("=", 1) for v in values)
|
||||
@@ -360,7 +388,8 @@ def settings(package: str, values: list[str], frame: Optional[str] = None):
|
||||
|
||||
# ------------------------------------------------------------------------------------------ frame
|
||||
@frame_app.command("discover")
|
||||
def frame_discover(seconds: float = 4.0):
|
||||
def frame_discover(seconds: float = typer.Option(4.0, help="how long to listen")):
|
||||
"""Find Steam Frames (in Developer Mode) on the local network."""
|
||||
from .frame.discovery import browse
|
||||
|
||||
for f in browse(seconds):
|
||||
@@ -368,7 +397,7 @@ def frame_discover(seconds: float = 4.0):
|
||||
|
||||
|
||||
@frame_app.command("pair")
|
||||
def frame_pair(timeout: float = 600):
|
||||
def frame_pair(timeout: float = typer.Option(600, help="seconds to wait for the Frame")):
|
||||
"""Serve the one-line bootstrap for the Frame and wait until it reports back."""
|
||||
import time
|
||||
|
||||
@@ -390,7 +419,8 @@ def frame_pair(timeout: float = 600):
|
||||
|
||||
|
||||
@frame_app.command("connect")
|
||||
def frame_connect(address: str, password: Optional[str] = typer.Option(None, prompt=False)):
|
||||
def frame_connect(address: str = typer.Argument(..., help="steamos@<host or IP>"),
|
||||
password: Optional[str] = typer.Option(None, prompt=False, help="the Frame's password (once)")):
|
||||
"""Connect with SSH (password once), install FramePort's key and remember the Frame."""
|
||||
from .frame.connection import Frame, parse_target, save_target
|
||||
|
||||
@@ -400,12 +430,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] = typer.Option(None, help=FRAME_HELP),
|
||||
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")
|
||||
@@ -413,12 +446,13 @@ def frame_cleanup(frame: Optional[str] = None, keep_rollback: bool = typer.Optio
|
||||
|
||||
@frame_app.command("send")
|
||||
def frame_send(paths: list[Path] = typer.Argument(..., help="files or folders to send"),
|
||||
to: str = typer.Option("videos", help="destination: videos, downloads, documents, app, app-files"),
|
||||
game: Optional[str] = typer.Option(None, help="package of the game, for --to app / app-files"),
|
||||
to: str = typer.Option("videos", "--dest", "--to",
|
||||
help="destination: videos, downloads, documents, app, app-files"),
|
||||
game: Optional[str] = typer.Option(None, help="package of the game, for --dest app / app-files"),
|
||||
folder: str = typer.Option("", help="sub-folder inside the destination"),
|
||||
app: Optional[str] = typer.Option(None, help="package of a player: also add the files to its own folder "
|
||||
"(e.g. 4XVR lists /sdcard/4XPlayer, not Movies)"),
|
||||
frame: Optional[str] = None):
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Send files to apps on the Frame. videos/downloads/documents are shared by every Quest game (they appear as
|
||||
/sdcard/Movies, /sdcard/Download, /sdcard/Documents); app/app-files are one game's own storage. Apps find the
|
||||
files by browsing folders (Android's media index doesn't work in Lepton)."""
|
||||
@@ -430,7 +464,8 @@ def frame_send(paths: list[Path] = typer.Argument(..., help="files or folders to
|
||||
|
||||
|
||||
@frame_app.command("storage")
|
||||
def frame_storage(game: Optional[str] = None, frame: Optional[str] = None):
|
||||
def frame_storage(game: Optional[str] = typer.Option(None, help="also show this game's own storage"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Where files for Lepton apps go on the Frame (and where the apps see them)."""
|
||||
from .install import files
|
||||
|
||||
@@ -439,19 +474,20 @@ def frame_storage(game: Optional[str] = None, frame: Optional[str] = None):
|
||||
|
||||
|
||||
@frame_app.command("info")
|
||||
def frame_info(frame: Optional[str] = None):
|
||||
def frame_info(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""The Frame's SteamOS build, runtimes, storage and installed games (JSON)."""
|
||||
typer.echo(json.dumps(_target(frame).describe(), indent=1, default=str))
|
||||
|
||||
|
||||
@frame_app.command("controller-models")
|
||||
def frame_controller_models(frame: Optional[str] = None,
|
||||
def frame_controller_models(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
convert: bool = typer.Option(False, help="also convert them (cached on the Frame)")):
|
||||
"""SteamVR render models on the Frame and which ones serve as Steam Frame controller models (controller_models)."""
|
||||
typer.echo(json.dumps(_target(frame).frame.agent("controller_models", convert=convert), indent=1))
|
||||
|
||||
|
||||
@frame_app.command("proton")
|
||||
def frame_proton(frame: Optional[str] = None,
|
||||
def frame_proton(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
install_: bool = typer.Option(False, "--install", help="install it (Steam on the Frame restarts once "
|
||||
"and downloads it; waits until done)"),
|
||||
in_headset: bool = typer.Option(False, help="with --install: only ask Steam (confirm in the headset)"),
|
||||
@@ -485,6 +521,7 @@ def frame_proton(frame: Optional[str] = None,
|
||||
# ------------------------------------------------------------------------------------------ pc (Revive)
|
||||
@pc_app.command("info")
|
||||
def pc_info():
|
||||
"""Steam, SteamVR and Revive on this PC (JSON)."""
|
||||
typer.echo(json.dumps(_target(None, to="pc").describe(), indent=1, default=str))
|
||||
|
||||
|
||||
@@ -501,8 +538,10 @@ def pc_install_revive():
|
||||
@app.command()
|
||||
def parity(known_good: Path = typer.Option(..., help="folder of known-good game folders (PATCHED layout)"),
|
||||
sources: Path = typer.Option(..., help="folder with the original game dumps"),
|
||||
outdir: Path = typer.Option(Path("parity-out")), only: list[str] = typer.Option([]),
|
||||
report: Path = typer.Option(Path("parity-report.md")), keep: bool = False):
|
||||
outdir: Path = typer.Option(Path("parity-out"), help="where the rebuilt APKs go"),
|
||||
only: list[str] = typer.Option([], help="only games whose folder contains this (repeatable)"),
|
||||
report: Path = typer.Option(Path("parity-report.md"), help="the report to write"),
|
||||
keep: bool = typer.Option(False, help="keep work files")):
|
||||
"""Rebuild every catalog game from its source dump and compare with the known-good APKs."""
|
||||
from .parity import run_parity
|
||||
|
||||
@@ -511,7 +550,7 @@ def parity(known_good: Path = typer.Option(..., help="folder of known-good game
|
||||
|
||||
|
||||
@app.command()
|
||||
def report(out: Path = typer.Option(Path("REPORT.md"))):
|
||||
def report(out: Path = typer.Option(Path("REPORT.md"), help="the file to write")):
|
||||
"""Write a status report of every catalog game (markdown)."""
|
||||
from .report import write
|
||||
|
||||
@@ -519,10 +558,13 @@ def report(out: Path = typer.Option(Path("REPORT.md"))):
|
||||
|
||||
|
||||
@app.command("parity-device")
|
||||
def parity_device(results: Path = typer.Option(Path("parity-out/parity.json")), frame: Optional[str] = None,
|
||||
def parity_device(results: Path = typer.Option(Path("parity-out/parity.json"), help="parity.json from `parity`"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
baseline: Optional[Path] = typer.Option(None, help="baseline launch.txt from before the change"),
|
||||
report: Path = typer.Option(Path("parity-device-report.md")), only: list[str] = typer.Option([]),
|
||||
seconds: int = 45, test_only: bool = typer.Option(False, help="only re-run the launch tests")):
|
||||
report: Path = typer.Option(Path("parity-device-report.md"), help="the report to write"),
|
||||
only: list[str] = typer.Option([], help="only these games (repeatable)"),
|
||||
seconds: int = typer.Option(45, help="launch test length"),
|
||||
test_only: bool = typer.Option(False, help="only re-run the launch tests")):
|
||||
"""Install the APKs rebuilt by `parity` on the Frame (APK only) and compare headless launches with a baseline."""
|
||||
from .parity import install_and_test
|
||||
|
||||
@@ -563,7 +605,7 @@ def _diag_target(no_frame: bool, frame: Optional[str], to: str):
|
||||
try:
|
||||
return _target(frame, to=to)
|
||||
except Exception as exc: # noqa: BLE001 (offline Frame: PC-side data only)
|
||||
typer.echo(f"({to} not reachable: {exc}; collecting the PC side only)")
|
||||
typer.echo(f"({to} not reachable: {exc}; collecting the PC side only)", err=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -572,12 +614,13 @@ def _open(url: str, browser: bool) -> None:
|
||||
|
||||
typer.echo(url)
|
||||
if browser and not winhost.open_url(url):
|
||||
typer.echo("(couldn't open a browser: open the link above)")
|
||||
typer.echo("(couldn't open a browser: open the link above)", err=True)
|
||||
|
||||
|
||||
@diag_app.command("collect")
|
||||
def diag_collect(package: Optional[str] = typer.Argument(None, help="a game (omit for app-wide logs only)"),
|
||||
all_: bool = typer.Option(False, "--all"), frame: Optional[str] = None,
|
||||
all_: bool = typer.Option(False, "--all", help=ALL_HELP),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
no_frame: bool = typer.Option(False, "--no-frame", help="don't contact the Frame"),
|
||||
to: str = typer.Option("frame", help="where the game is installed: frame or pc"),
|
||||
out: Optional[Path] = typer.Option(None, help="folder or .zip path (default: Documents)")):
|
||||
@@ -588,7 +631,8 @@ def diag_collect(package: Optional[str] = typer.Argument(None, help="a game (omi
|
||||
|
||||
|
||||
@diag_app.command("inspect")
|
||||
def diag_inspect(bundle: Path, as_json: bool = typer.Option(False, "--json")):
|
||||
def diag_inspect(bundle: Path = typer.Argument(..., help="a diagnostics zip"),
|
||||
as_json: bool = typer.Option(False, "--json", help=JSON_HELP)):
|
||||
"""Summarize a diagnostics zip and re-triage its launch logs with this version's signatures."""
|
||||
from .diag import bundle as b
|
||||
|
||||
@@ -617,10 +661,13 @@ def diag_inspect(bundle: Path, as_json: bool = typer.Option(False, "--json")):
|
||||
|
||||
|
||||
@diag_app.command("report")
|
||||
def diag_report(package: Optional[str] = typer.Argument(None), description: str = typer.Option("", "--text"),
|
||||
frame: Optional[str] = None, no_frame: bool = typer.Option(False, "--no-frame"),
|
||||
to: str = typer.Option("frame"), out: Optional[Path] = None,
|
||||
browser: bool = typer.Option(True, "--browser/--no-browser")):
|
||||
def diag_report(package: Optional[str] = typer.Argument(None, help="the game (omit for an app problem)"),
|
||||
description: str = typer.Option("", "--message", "--text", help="what went wrong"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
no_frame: bool = typer.Option(False, "--no-frame", help="don't contact the Frame"),
|
||||
to: str = typer.Option("frame", help="where the game is installed: frame or pc"),
|
||||
out: Optional[Path] = typer.Option(None, help="folder or .zip path (default: Documents)"),
|
||||
browser: bool = typer.Option(True, "--browser/--no-browser", help="open the issue in a browser")):
|
||||
"""Collect a diagnostics zip and open a prefilled GitHub problem report (attach the zip there)."""
|
||||
from .core import winhost
|
||||
|
||||
@@ -643,7 +690,7 @@ def diag_report(package: Optional[str] = typer.Argument(None), description: str
|
||||
def share_recipe(package: str, status: str = typer.Option("works", help="works or issues"),
|
||||
notes: str = typer.Option("", help="what you checked in the headset, known issues"),
|
||||
frame: Optional[str] = typer.Option(None, help="include the Frame's SteamOS build"),
|
||||
browser: bool = typer.Option(True, "--browser/--no-browser")):
|
||||
browser: bool = typer.Option(True, "--browser/--no-browser", help="open the issue in a browser")):
|
||||
"""Submit a working configuration: saves it as a known-good recipe and opens a prefilled GitHub issue."""
|
||||
if status not in ("works", "issues"):
|
||||
raise typer.BadParameter("--status must be works or issues")
|
||||
@@ -654,9 +701,29 @@ def share_recipe(package: str, status: str = typer.Option("works", help="works o
|
||||
_open(pipeline.share_working_config(pkg, status, notes, info), browser)
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
app()
|
||||
def main() -> None:
|
||||
"""Entry point: errors end in one line on stderr and exit code 1 (FRAMEPORT_DEBUG=1 shows the traceback)."""
|
||||
import os
|
||||
|
||||
try:
|
||||
app()
|
||||
except KeyboardInterrupt:
|
||||
typer.echo("Cancelled.", err=True)
|
||||
sys.exit(130)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if os.environ.get("FRAMEPORT_DEBUG"):
|
||||
raise
|
||||
typer.echo(f"Error: {_describe(exc)}", err=True)
|
||||
typer.echo("(set FRAMEPORT_DEBUG=1 to see the traceback)", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _describe(exc: BaseException) -> str:
|
||||
"""A readable one-line reason (shared with the GUI: frameport.errors)."""
|
||||
from .errors import explain
|
||||
|
||||
return explain(exc)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(app())
|
||||
main()
|
||||
@@ -32,7 +32,9 @@ def app_log_path() -> Path:
|
||||
|
||||
def setup(component: str = "app") -> None:
|
||||
"""Log to <data>/logs/app.log (2 MB × 3). Safe to call more than once; also records uncaught exceptions."""
|
||||
if any(getattr(h, "_frameport", False) for h in log.handlers):
|
||||
from .paths import removed
|
||||
|
||||
if removed() or any(getattr(h, "_frameport", False) for h in log.handlers):
|
||||
return
|
||||
try:
|
||||
handler = logging.handlers.RotatingFileHandler(app_log_path(), maxBytes=2 << 20, backupCount=3,
|
||||
@@ -58,8 +60,19 @@ def _version() -> str:
|
||||
return __version__
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Close the log file (uninstall: an open file can't be deleted on Windows)."""
|
||||
for h in [h for h in log.handlers if getattr(h, "_frameport", False)]:
|
||||
log.removeHandler(h)
|
||||
h.close()
|
||||
|
||||
|
||||
def save_job_log(kind: str, package: str | None, state: str, text: str) -> Path | None:
|
||||
"""Keep a finished job's log (the GUI only holds it in memory); the last KEEP_JOB_LOGS are kept."""
|
||||
from .paths import removed
|
||||
|
||||
if removed():
|
||||
return None
|
||||
try:
|
||||
d = jobs_dir()
|
||||
name = re.sub(r"[^A-Za-z0-9._-]+", "_", f"{kind}-{package or 'app'}-{state}")[:120]
|
||||
|
||||
@@ -11,9 +11,8 @@ from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from .paths import user_data_dir
|
||||
|
||||
from .. import REPO_URL, __version__
|
||||
from .paths import user_data_dir, write_atomic
|
||||
|
||||
USER_AGENT = f"FramePort/{__version__} (+{REPO_URL})"
|
||||
_session = requests.Session()
|
||||
@@ -38,7 +37,7 @@ def cached_text(name: str, url: str, max_age: float = 86400, fallback: str | Non
|
||||
if not fresh:
|
||||
try:
|
||||
text = http_get(url).text
|
||||
path.write_text(text, encoding="utf-8")
|
||||
write_atomic(path, text)
|
||||
return text
|
||||
except Exception:
|
||||
pass
|
||||
@@ -57,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
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -4,13 +4,18 @@ from __future__ import annotations
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Analysis, Recipe
|
||||
from .paths import user_data_dir
|
||||
from .paths import user_data_dir, write_atomic
|
||||
|
||||
_lock = threading.Lock()
|
||||
# One lock for every read-modify-write of library.json: jobs, the poll thread and the UI all change it, and a change
|
||||
# made between another thread's load() and save() would be lost. Re-entrant so edit() can call load()/save().
|
||||
_lock = threading.RLock()
|
||||
_last_retry = 0.0 # when migrations waiting for a game folder were last retried
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
@@ -18,13 +23,23 @@ def _path() -> Path:
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
try:
|
||||
data = json.loads(_path().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {"games": {}, "settings": {}}
|
||||
if _migrate(data):
|
||||
with _lock:
|
||||
try:
|
||||
data = json.loads(_path().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {"games": {}, "settings": {}}
|
||||
if _migrate(data):
|
||||
save(data)
|
||||
return data
|
||||
|
||||
|
||||
@contextmanager
|
||||
def edit() -> Iterator[dict]:
|
||||
"""A read-modify-write of the whole library as one step: `with edit() as data: ...` (saved on exit)."""
|
||||
with _lock:
|
||||
data = load()
|
||||
yield data
|
||||
save(data)
|
||||
return data
|
||||
|
||||
|
||||
def _migrate(data: dict) -> bool:
|
||||
@@ -60,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:
|
||||
@@ -68,7 +84,7 @@ def _migrate(data: dict) -> bool:
|
||||
# VR; OpenXR/SteamVR-native and catalog games keep their own settings). The dump is never modified (as_is).
|
||||
from ..recommend import engine
|
||||
|
||||
for pkg, g in (data.get("games") or {}).items():
|
||||
for g in (data.get("games") or {}).values():
|
||||
a = g.get("analysis") or {}
|
||||
if not (isinstance(g.get("recipe"), dict) and (a.get("extra") or {}).get("kind") == "rift"):
|
||||
continue
|
||||
@@ -85,7 +101,7 @@ def _migrate(data: dict) -> bool:
|
||||
from ..analysis import rift
|
||||
from ..recommend import engine
|
||||
|
||||
for pkg, g in (data.get("games") or {}).items():
|
||||
for g in (data.get("games") or {}).values():
|
||||
a = g.get("analysis") or {}
|
||||
if not (isinstance(g.get("recipe"), dict) and (a.get("extra") or {}).get("kind") == "rift"):
|
||||
continue
|
||||
@@ -118,24 +134,25 @@ def _migrate(data: dict) -> bool:
|
||||
# Rift games get a launch mode from their files (repack with bundled Revive → run directly; SteamVR/OpenXR-
|
||||
# capable → run directly with arguments; Oculus-only → FramePort's Revive). Re-analyze (keeping the chosen
|
||||
# exe) and re-derive the recipe; a repack launched through a second Revive failed (e.g. its entitlement check).
|
||||
from ..analysis import rift
|
||||
from ..recommend import engine
|
||||
|
||||
waiting = []
|
||||
for pkg, g in (data.get("games") or {}).items():
|
||||
a = g.get("analysis") or {}
|
||||
if not (isinstance(g.get("recipe"), dict) and (a.get("extra") or {}).get("kind") == "rift"):
|
||||
continue
|
||||
gd = g.get("game_dir") or (a.get("extra") or {}).get("folder")
|
||||
try:
|
||||
if not (gd and Path(gd).is_dir()):
|
||||
continue
|
||||
an = rift.analyze(Path(gd), exe=g.get("exe") or (a.get("extra") or {}).get("exe"))
|
||||
g["analysis"] = asdict(an)
|
||||
g["recipe"] = recipe_to_dict(engine.suggest(an))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if not _rift_launch_mode(g):
|
||||
waiting.append(pkg) # game folder not reachable now (e.g. a drive not connected): retried later
|
||||
if waiting:
|
||||
data["settings"].setdefault("migrations_waiting", {})["rift_launch_modes"] = waiting
|
||||
done.append("rift_launch_modes")
|
||||
changed = True
|
||||
global _last_retry
|
||||
waiting = (data["settings"].get("migrations_waiting") or {}).get("rift_launch_modes") or []
|
||||
if waiting and time.time() - _last_retry > 600: # games skipped because their folder wasn't there (checked
|
||||
_last_retry = time.time() # every 10 min: load() runs often, a missing drive is slow)
|
||||
still = [p for p in waiting if p in data.get("games", {}) and not _rift_launch_mode(data["games"][p])]
|
||||
if still != waiting:
|
||||
data["settings"]["migrations_waiting"]["rift_launch_modes"] = still
|
||||
changed = True
|
||||
if "quest_binary_fixes_v2" not in done:
|
||||
# Quest recipes gain the default-on binary fixes: the swapchain size guard (every overport build) and the
|
||||
# Vulkan shim (Unreal)
|
||||
@@ -156,6 +173,16 @@ def _migrate(data: dict) -> bool:
|
||||
r.setdefault("reasons", {}).setdefault(pid, s.reason)
|
||||
done.append("quest_binary_fixes_v2")
|
||||
changed = True
|
||||
if "flat_hide_navbar" not in done:
|
||||
# Android apps without VR gain the default-on "Hide Android's navigation bar" patch
|
||||
for g in (data.get("games") or {}).values():
|
||||
a, r = g.get("analysis") or {}, g.get("recipe")
|
||||
if isinstance(r, dict) and (a.get("extra") or {}).get("vr_kind") == "none":
|
||||
r.setdefault("patches", {}).setdefault("device.hide_navbar", {})
|
||||
r.setdefault("reasons", {}).setdefault(
|
||||
"device.hide_navbar", "2D app: Android's navigation buttons would cover the app's own controls.")
|
||||
done.append("flat_hide_navbar")
|
||||
changed = True
|
||||
if "rift_steamvr_tuning" not in done:
|
||||
# PC VR recipes gain automatic SteamVR performance settings (refresh rate / motion smoothing on frame drops)
|
||||
for g in (data.get("games") or {}).values():
|
||||
@@ -169,18 +196,42 @@ def _migrate(data: dict) -> bool:
|
||||
return changed
|
||||
|
||||
|
||||
def _rift_launch_mode(g: dict) -> bool:
|
||||
"""Re-analyze a Rift game (keeping the chosen exe) and re-derive its recipe. False if its folder isn't there."""
|
||||
from ..analysis import rift
|
||||
from ..recommend import engine
|
||||
|
||||
a = g.get("analysis") or {}
|
||||
gd = g.get("game_dir") or (a.get("extra") or {}).get("folder")
|
||||
if not (gd and Path(gd).is_dir()):
|
||||
return False
|
||||
try:
|
||||
an = rift.analyze(Path(gd), exe=g.get("exe") or (a.get("extra") or {}).get("exe"))
|
||||
g["analysis"] = asdict(an)
|
||||
g["recipe"] = recipe_to_dict(engine.suggest(an))
|
||||
except Exception: # noqa: BLE001 - an unreadable game keeps its recipe
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def save(data: dict) -> None:
|
||||
with _lock:
|
||||
tmp = _path().with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=1, default=str), encoding="utf-8")
|
||||
tmp.replace(_path())
|
||||
write_atomic(_path(), json.dumps(data, indent=1, default=str))
|
||||
|
||||
|
||||
def upsert_game(package: str, **fields) -> dict:
|
||||
data = load()
|
||||
entry = data["games"].setdefault(package, {"package": package, "added": time.time()})
|
||||
entry.update(fields)
|
||||
save(data)
|
||||
with edit() as data:
|
||||
entry = data["games"].setdefault(package, {"package": package, "added": time.time()})
|
||||
entry.update(fields)
|
||||
return entry
|
||||
|
||||
|
||||
def update_game(package: str, fn: Callable[[dict], None]) -> dict | None:
|
||||
"""Change one game's entry in place, atomically (fn mutates the entry). None if the game isn't there."""
|
||||
with edit() as data:
|
||||
entry = data["games"].get(package)
|
||||
if entry is not None:
|
||||
fn(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@@ -193,9 +244,8 @@ def games() -> list[dict]:
|
||||
|
||||
|
||||
def remove_game(package: str) -> None:
|
||||
data = load()
|
||||
data["games"].pop(package, None)
|
||||
save(data)
|
||||
with edit() as data:
|
||||
data["games"].pop(package, None)
|
||||
|
||||
|
||||
def setting(key: str, default=None):
|
||||
@@ -203,9 +253,16 @@ def setting(key: str, default=None):
|
||||
|
||||
|
||||
def set_setting(key: str, value) -> None:
|
||||
data = load()
|
||||
data.setdefault("settings", {})[key] = value
|
||||
save(data)
|
||||
with edit() as data:
|
||||
data.setdefault("settings", {})[key] = value
|
||||
|
||||
|
||||
def update_setting(key: str, fn: Callable, default=None):
|
||||
"""Set a setting from its current value, atomically: value = fn(current or default). Returns the new value."""
|
||||
with edit() as data:
|
||||
settings = data.setdefault("settings", {})
|
||||
settings[key] = value = fn(settings.get(key, default))
|
||||
return value
|
||||
|
||||
|
||||
def recipe_to_dict(r: Recipe) -> dict:
|
||||
@@ -228,5 +285,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)
|
||||
@@ -53,6 +53,16 @@ class Analysis:
|
||||
def only_32bit(self) -> bool:
|
||||
return "arm64-v8a" not in self.abis and "armeabi-v7a" in self.abis
|
||||
|
||||
@property
|
||||
def no_arm64(self) -> bool:
|
||||
"""Native code, but none the Frame can run (32-bit ARM or x86 only)."""
|
||||
return bool(self.abis) and "arm64-v8a" not in self.abis
|
||||
|
||||
@property
|
||||
def vr_kind(self) -> str:
|
||||
"""See analysis.detect.vr_kind (entries analysed before it existed were all Quest games)."""
|
||||
return (self.extra or {}).get("vr_kind") or "quest"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@@ -70,6 +80,7 @@ class Recipe:
|
||||
notes: str = ""
|
||||
source: str = "heuristics" # catalog | heuristics | user
|
||||
as_is: bool = False # install the game unchanged (already patched): no overport / Frame fixes / Revive
|
||||
overport: bool = True # False: an ordinary Android app: no VR translation (overport) and no FrameBridge adapter
|
||||
reasons: dict[str, str] = field(default_factory=dict) # patch id -> why it was suggested
|
||||
|
||||
def enabled(self, patch_id: str) -> bool:
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
_PKG = Path(__file__).resolve().parent.parent
|
||||
@@ -37,18 +38,27 @@ def user_data_dir() -> Path:
|
||||
base = Path.home() / "Library/Application Support/FramePort"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "frameport"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
if not _removed.is_set():
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
_removed = threading.Event()
|
||||
|
||||
|
||||
def mark_removed() -> None:
|
||||
"""FramePort is being uninstalled: from now on nothing may (re)create files in the data folder."""
|
||||
_removed.set()
|
||||
|
||||
|
||||
def removed() -> bool:
|
||||
return _removed.is_set()
|
||||
|
||||
|
||||
def tools_dir() -> Path:
|
||||
return _sub("tools")
|
||||
|
||||
|
||||
def keystore_dir() -> Path:
|
||||
return _sub("keystores")
|
||||
|
||||
|
||||
def work_dir() -> Path:
|
||||
return _sub("work")
|
||||
|
||||
@@ -65,3 +75,13 @@ def _sub(name: str) -> Path:
|
||||
path = user_data_dir() / name
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def write_atomic(path: Path, text: str) -> None:
|
||||
"""Replace a file in one step: a reader never sees it half written, and a crash leaves the old one."""
|
||||
path = Path(path)
|
||||
if removed():
|
||||
return
|
||||
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
@@ -190,7 +190,7 @@ def vrsettings(root: Path, ops: list[tuple[str, str, str, str, str | None]]) ->
|
||||
for op, section, key, kind, value in ops:
|
||||
args += [op, section, key, kind] + ([value] if op == "set" else [])
|
||||
p = subprocess.run(args, capture_output=True, text=True, timeout=60, **winhost._no_window())
|
||||
return [l.strip() for l in (p.stdout or "").splitlines() if l.strip()]
|
||||
return [ln.strip() for ln in (p.stdout or "").splitlines() if ln.strip()]
|
||||
|
||||
|
||||
def apply(root: Path, app_key: str, refresh: float | None, smoothing: int | None) -> list[str]:
|
||||
|
||||
@@ -62,7 +62,10 @@ def to_windows(path: Path | str) -> str:
|
||||
m = re.match(r"^/mnt/([a-zA-Z])(/.*)?$", p)
|
||||
if m:
|
||||
return f"{m[1].upper()}:" + (m[2] or "/").replace("/", "\\")
|
||||
r = subprocess.run(["wslpath", "-w", p], capture_output=True, text=True)
|
||||
try:
|
||||
r = subprocess.run(["wslpath", "-w", p], capture_output=True, text=True, timeout=15)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ValueError(f"{p} is not reachable from Windows ({exc})") from None
|
||||
if r.returncode:
|
||||
raise ValueError(f"{p} is not reachable from Windows")
|
||||
return r.stdout.strip()
|
||||
@@ -75,7 +78,12 @@ def to_local(winpath: str) -> Path:
|
||||
m = re.match(r"^([a-zA-Z]):[\\/](.*)$", winpath.strip())
|
||||
if m:
|
||||
return Path(f"/mnt/{m[1].lower()}") / m[2].replace("\\", "/")
|
||||
r = subprocess.run(["wslpath", "-u", winpath], capture_output=True, text=True)
|
||||
try:
|
||||
r = subprocess.run(["wslpath", "-u", winpath], capture_output=True, text=True, timeout=15)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ValueError(f"can't map {winpath} to a local path ({exc})") from None
|
||||
if r.returncode or not r.stdout.strip(): # never Path("") (= the current folder)
|
||||
raise ValueError(f"can't map {winpath} to a local path")
|
||||
return Path(r.stdout.strip())
|
||||
|
||||
|
||||
@@ -115,7 +123,12 @@ def env_path(name: str) -> Path | None:
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
v = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else ""
|
||||
return to_local(v) if v and "%" not in v else None
|
||||
if not v or "%" in v:
|
||||
return None
|
||||
try:
|
||||
return to_local(v)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Steam
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -188,13 +192,13 @@ def package_listing(g: dict) -> dict[str, object]:
|
||||
|
||||
|
||||
def _elf_summary(z: zipfile.ZipFile, info: zipfile.ZipInfo) -> dict:
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from elftools.elf.dynamic import DynamicSection
|
||||
from elftools.elf.elffile import ELFFile
|
||||
from elftools.elf.sections import SymbolTableSection
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
with contextlib.ExitStack() as stack:
|
||||
f = stack.enter_context(z.open(info))
|
||||
@@ -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)
|
||||
|
||||
@@ -8,9 +8,9 @@ from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .. import REPO_URL as REPO
|
||||
from . import redact
|
||||
|
||||
from .. import REPO_URL as REPO
|
||||
MAX_URL = 7500
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Plain explanations for the failures users actually meet (GUI and CLI share them).
|
||||
|
||||
`explain(exc)` turns an exception into one sentence that says what happened and what to do; anything it doesn't
|
||||
recognise keeps its own message. `is_connection_error(exc)` tells whether the Frame dropped off the network (the
|
||||
install queue then waits for it instead of failing every remaining job).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import zipfile
|
||||
|
||||
from .i18n import tr
|
||||
|
||||
|
||||
def _paramiko():
|
||||
try:
|
||||
import paramiko
|
||||
from paramiko.ssh_exception import NoValidConnectionsError
|
||||
return paramiko, NoValidConnectionsError
|
||||
except ImportError: # pragma: no cover - paramiko is a dependency
|
||||
return None, None
|
||||
|
||||
|
||||
def is_connection_error(exc: BaseException) -> bool:
|
||||
"""The Frame can't be reached (off, asleep, out of Wi-Fi), as opposed to a command that failed on it."""
|
||||
paramiko, no_valid = _paramiko()
|
||||
if no_valid and isinstance(exc, no_valid):
|
||||
return True
|
||||
if isinstance(exc, (TimeoutError, EOFError, ConnectionResetError, ConnectionAbortedError,
|
||||
ConnectionRefusedError, BrokenPipeError)):
|
||||
return True
|
||||
if paramiko and isinstance(exc, paramiko.SSHException) and not isinstance(exc, paramiko.AuthenticationException):
|
||||
msg = str(exc).lower()
|
||||
return any(s in msg for s in ("not active", "unable to connect", "error reading ssh protocol banner",
|
||||
"connection reset", "timed out", "socket is closed"))
|
||||
if isinstance(exc, OSError) and exc.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ECONNRESET,
|
||||
errno.ETIMEDOUT, errno.ECONNREFUSED, errno.EPIPE):
|
||||
return True
|
||||
return isinstance(exc, ConnectionError) and "authentication" not in str(exc).lower()
|
||||
|
||||
|
||||
def explain(exc: BaseException) -> str:
|
||||
"""One sentence for the user: what went wrong and what to do (else the exception's own message)."""
|
||||
paramiko, _ = _paramiko()
|
||||
msg = " ".join(str(exc).split()).removeprefix("[Errno None] ")
|
||||
low = msg.lower()
|
||||
if paramiko and isinstance(exc, paramiko.ChannelException):
|
||||
return tr("The connection to the Frame is busy. Try again in a moment.")
|
||||
if "authentication failed" in low:
|
||||
return msg # already says what to do (run the bootstrap / enter the password)
|
||||
if is_connection_error(exc):
|
||||
return tr("Can't reach your Frame. Make sure it's on, awake and on the same network, then try again.")
|
||||
if isinstance(exc, OSError) and exc.errno == errno.ENOSPC:
|
||||
return tr("This PC's disk is full. Free some space and try again.")
|
||||
if "not enough space on the frame" in low:
|
||||
return tr("Not enough space on the Frame ({detail}). Remove games you don't play or use Free up space on "
|
||||
"the Steam Frame page.").format(detail=msg.split(":", 1)[-1].strip())
|
||||
if "is running on the frame" in low or low in ("the game is running", "the game is already running") or \
|
||||
"a frameport game is running" in low:
|
||||
return tr("A game is running on the Frame. Close it in the headset first, then try again.")
|
||||
if "steam did not close" in low or "steam isn't running" in low:
|
||||
return tr("Steam on the Frame isn't responding. Restart the Frame (or its Steam) and try again.")
|
||||
if isinstance(exc, zipfile.BadZipFile) or (isinstance(exc, KeyError) and "androidmanifest" in low):
|
||||
return tr("The APK file is damaged or incomplete. Get the game again and rescan the folder.")
|
||||
if "checksum mismatch" in low:
|
||||
return tr("The upload to the Frame was damaged on the way. Try again (a cable or the Frame's hotspot is "
|
||||
"more reliable than a busy Wi-Fi).")
|
||||
if msg:
|
||||
return msg
|
||||
return type(exc).__name__
|
||||
@@ -5,6 +5,7 @@ the user's SSH agent/keys, then a password if given.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -12,13 +13,12 @@ import posixpath
|
||||
import re
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
from ..core.paths import agent_dir, ssh_dir, user_data_dir
|
||||
from ..core.paths import agent_dir, ssh_dir, user_data_dir, write_atomic
|
||||
|
||||
REMOTE_AGENT_DIR = ".local/share/frameport/agent"
|
||||
|
||||
@@ -30,12 +30,11 @@ def app_key() -> paramiko.Ed25519Key:
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
key = Ed25519PrivateKey.generate()
|
||||
path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH,
|
||||
serialization.NoEncryption()))
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
data = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH,
|
||||
serialization.NoEncryption())
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) # private from the first byte
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
pub = key.public_key().public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH)
|
||||
(ssh_dir() / "id_ed25519.pub").write_text(pub.decode() + " frameport\n")
|
||||
return paramiko.Ed25519Key.from_private_key_file(str(path))
|
||||
@@ -58,18 +57,32 @@ class FrameTarget:
|
||||
return self.name or self.host
|
||||
|
||||
|
||||
_saved_cache: tuple[float, list] = (-1.0, [])
|
||||
|
||||
|
||||
def saved_targets() -> list[FrameTarget]:
|
||||
"""The remembered Frames (read again only when frames.json changed: the sidebar asks several times a second)."""
|
||||
global _saved_cache
|
||||
path = user_data_dir() / "frames.json"
|
||||
try:
|
||||
return [FrameTarget(**d) for d in json.loads(path.read_text())]
|
||||
except (OSError, ValueError, TypeError):
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
return []
|
||||
if mtime != _saved_cache[0]:
|
||||
try:
|
||||
_saved_cache = (mtime, [d for d in json.loads(path.read_text()) if isinstance(d, dict)])
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
try:
|
||||
return [FrameTarget(**d) for d in _saved_cache[1]]
|
||||
except TypeError:
|
||||
return []
|
||||
|
||||
|
||||
def save_target(target: FrameTarget) -> None:
|
||||
items = [t for t in saved_targets() if t.host != target.host]
|
||||
items.insert(0, target)
|
||||
(user_data_dir() / "frames.json").write_text(json.dumps([t.__dict__ for t in items], indent=2))
|
||||
write_atomic(user_data_dir() / "frames.json", json.dumps([t.__dict__ for t in items], indent=2))
|
||||
|
||||
|
||||
# direct links to the Frame, fastest first (interface → what it is)
|
||||
@@ -85,42 +98,161 @@ def bundled_agent_version() -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
RUN_TIMEOUT = 120 # seconds: default bound for one remote command's output
|
||||
|
||||
|
||||
class AgentFailed(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
SFTP_CHANNELS = 3 # OpenSSH allows 10 channels per connection (MaxSessions): leave room for commands
|
||||
|
||||
|
||||
class SftpPool:
|
||||
"""A few SFTP channels shared by all threads, lent out per operation (or while a remote file is open).
|
||||
paramiko's SFTPClient must not be used by two threads at once; one channel per thread was kept open for good and
|
||||
long-lived GUI worker threads ran the connection out of channels ("ChannelException(2, 'Connect failed')").
|
||||
A thread that already holds a channel reuses it for nested calls (no deadlock)."""
|
||||
|
||||
def __init__(self, open_channel, size: int = SFTP_CHANNELS):
|
||||
self._open, self.size = open_channel, size
|
||||
self._free: list = []
|
||||
self._all: list = []
|
||||
self._cv = threading.Condition()
|
||||
self._local = threading.local()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def lease(self):
|
||||
loc = self._local
|
||||
if getattr(loc, "depth", 0):
|
||||
loc.depth += 1
|
||||
try:
|
||||
yield loc.client
|
||||
finally:
|
||||
loc.depth -= 1
|
||||
return
|
||||
with self._cv:
|
||||
while True:
|
||||
while self._free:
|
||||
client = self._free.pop()
|
||||
if not client.sock.closed:
|
||||
break
|
||||
self._all.remove(client)
|
||||
else:
|
||||
client = None
|
||||
if client is not None or len(self._all) < self.size:
|
||||
break
|
||||
self._cv.wait()
|
||||
if client is None:
|
||||
client = self._open()
|
||||
self._all.append(client)
|
||||
loc.client, loc.depth = client, 1
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
loc.client, loc.depth = None, 0
|
||||
with self._cv:
|
||||
self._free.append(client)
|
||||
self._cv.notify()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._cv:
|
||||
clients, self._all, self._free = self._all, [], []
|
||||
self._cv.notify_all()
|
||||
for c in clients:
|
||||
try:
|
||||
c.close()
|
||||
except Exception: # noqa: BLE001 - closing anyway
|
||||
pass
|
||||
|
||||
|
||||
class _LeasedFile:
|
||||
"""A remote file that holds its SFTP channel until it's closed."""
|
||||
|
||||
def __init__(self, f, lease):
|
||||
self._f, self._lease = f, lease
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._f, name)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._f)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._lease is not None:
|
||||
lease, self._lease = self._lease, None
|
||||
try:
|
||||
self._f.close()
|
||||
finally:
|
||||
lease.__exit__(None, None, None)
|
||||
|
||||
|
||||
class SftpProxy:
|
||||
"""frame.sftp: looks like a paramiko SFTPClient; each call borrows a channel from the pool."""
|
||||
|
||||
def __init__(self, pool: SftpPool):
|
||||
self._pool = pool
|
||||
|
||||
def open(self, *args, **kwargs):
|
||||
lease = self._pool.lease()
|
||||
client = lease.__enter__()
|
||||
try:
|
||||
f = client.open(*args, **kwargs)
|
||||
except BaseException:
|
||||
lease.__exit__(None, None, None)
|
||||
raise
|
||||
return _LeasedFile(f, lease)
|
||||
|
||||
def __getattr__(self, name):
|
||||
def call(*args, **kwargs):
|
||||
with self._pool.lease() as client:
|
||||
return getattr(client, name)(*args, **kwargs)
|
||||
return call
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
target: FrameTarget
|
||||
password: str | None = None
|
||||
client: paramiko.SSHClient | None = None
|
||||
_sftp: paramiko.SFTPClient | None = None
|
||||
_pool: SftpPool | None = None # SFTP channels shared by all threads (see sftp)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
_agent_digest: str = "" # the agent version known to be on the Frame (checked once per connection)
|
||||
home: str = ""
|
||||
|
||||
# ------------------------------------------------------------------ connect
|
||||
def connect(self, timeout: float = 10) -> "Frame":
|
||||
client = paramiko.SSHClient()
|
||||
def connect(self, timeout: float = 10) -> Frame:
|
||||
known = ssh_dir() / "known_hosts"
|
||||
known.touch(exist_ok=True)
|
||||
client.load_host_keys(str(known))
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # trust on first use (pairing)
|
||||
kwargs = dict(hostname=self.target.host, port=self.target.port, username=self.target.user, timeout=timeout,
|
||||
banner_timeout=timeout, auth_timeout=timeout)
|
||||
errors = []
|
||||
for attempt in ("app_key", "agent", "password"):
|
||||
if attempt == "password" and not self.password:
|
||||
continue
|
||||
client = paramiko.SSHClient() # a fresh client per attempt: a failed one keeps its transport otherwise
|
||||
client.load_host_keys(str(known))
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # trust on first use (pairing)
|
||||
try:
|
||||
if attempt == "app_key":
|
||||
client.connect(pkey=app_key(), allow_agent=False, look_for_keys=False, **kwargs)
|
||||
elif attempt == "agent":
|
||||
client.connect(allow_agent=True, look_for_keys=True, **kwargs)
|
||||
elif self.password:
|
||||
client.connect(password=self.password, allow_agent=False, look_for_keys=False, **kwargs)
|
||||
else:
|
||||
continue
|
||||
client.connect(password=self.password, allow_agent=False, look_for_keys=False, **kwargs)
|
||||
break
|
||||
except paramiko.AuthenticationException as exc:
|
||||
client.close()
|
||||
errors.append(f"{attempt}: {exc}")
|
||||
except BaseException:
|
||||
client.close()
|
||||
raise
|
||||
else:
|
||||
raise ConnectionError("SSH authentication failed (" + "; ".join(errors) + "). Run the FramePort "
|
||||
"bootstrap on the Frame or enter the steamos password.")
|
||||
@@ -136,7 +268,7 @@ class Frame:
|
||||
out = self.run("ip -4 -o addr show up 2>/dev/null")[1]
|
||||
return re.findall(r"^\d+:\s+(\S+)\s+inet\s+([\d.]+)/", out, re.M)
|
||||
|
||||
def fast_link(self, timeout: float = 1.5) -> tuple["Frame", str]:
|
||||
def fast_link(self, timeout: float = 1.5) -> tuple[Frame, str]:
|
||||
"""A second connection over the fastest direct link, for bulk uploads: the USB cable (usb0) or the Frame's own
|
||||
hotspot (wlanap; the PC joins it with a Wi-Fi adapter, e.g. Valve's USB dongle) — several times faster than
|
||||
both going through the home router (measured 83-97 vs 15-18 MB/s). Used only if this PC can reach it and it
|
||||
@@ -161,19 +293,32 @@ class Frame:
|
||||
return self, ""
|
||||
|
||||
def close(self) -> None:
|
||||
if self._sftp:
|
||||
self._sftp.close()
|
||||
with self._lock:
|
||||
pool, self._pool = self._pool, None
|
||||
if pool:
|
||||
pool.close()
|
||||
if self.client:
|
||||
self.client.close()
|
||||
self.client = self._sftp = None
|
||||
self.client = None
|
||||
self._agent_digest = ""
|
||||
|
||||
def alive(self) -> bool:
|
||||
"""The SSH connection itself still works (a failed agent command doesn't mean the Frame is gone)."""
|
||||
transport = self.client.get_transport() if self.client else None
|
||||
return bool(transport and transport.is_active())
|
||||
|
||||
@property
|
||||
def sftp(self) -> paramiko.SFTPClient:
|
||||
if self._sftp is None:
|
||||
self._sftp = self.client.open_sftp()
|
||||
return self._sftp
|
||||
def sftp(self) -> SftpProxy:
|
||||
"""SFTP for any thread (see SftpPool): the same calls as paramiko's SFTPClient."""
|
||||
with self._lock:
|
||||
if self._pool is None:
|
||||
self._pool = SftpPool(lambda: self.client.open_sftp())
|
||||
return SftpProxy(self._pool)
|
||||
|
||||
def run(self, command: str, stdin: str | None = None, timeout: float | None = None) -> tuple[int, str, str]:
|
||||
def run(self, command: str, stdin: str | None = None,
|
||||
timeout: float | None = RUN_TIMEOUT) -> tuple[int, str, str]:
|
||||
"""Run a shell command. `timeout` (s) bounds waiting for output, so a dead link can't hang the caller;
|
||||
None only for commands that legitimately run long (installs, launch tests)."""
|
||||
chan_in, out, err = self.client.exec_command(command, timeout=timeout)
|
||||
if stdin is not None:
|
||||
chan_in.write(stdin)
|
||||
@@ -196,18 +341,25 @@ class Frame:
|
||||
digest = hashlib.sha256(text).hexdigest()[:16]
|
||||
remote_dir = posixpath.join(self.home, REMOTE_AGENT_DIR)
|
||||
remote = posixpath.join(remote_dir, "frameport_agent.py")
|
||||
if self._agent_digest == digest:
|
||||
return remote
|
||||
code, out, _ = self.run(f"sha256sum {sh_quote(remote)} 2>/dev/null | cut -c1-16")
|
||||
if out.strip() != digest:
|
||||
self.run(f"mkdir -p {sh_quote(remote_dir)}")
|
||||
with self.sftp.open(remote + ".tmp", "wb") as f:
|
||||
tmp = f"{remote}.{os.getpid()}.{threading.get_ident()}.tmp" # two threads never share a temp file
|
||||
with self.sftp.open(tmp, "wb") as f:
|
||||
f.write(text)
|
||||
self.run(f"mv {sh_quote(remote + '.tmp')} {sh_quote(remote)} && chmod 755 {sh_quote(remote)}")
|
||||
code, _, err = self.run(f"mv {sh_quote(tmp)} {sh_quote(remote)} && chmod 755 {sh_quote(remote)}")
|
||||
if code:
|
||||
raise AgentFailed(f"couldn't install the FramePort agent on the Frame: {err.strip()[-300:]}")
|
||||
self._agent_digest = digest
|
||||
return remote
|
||||
|
||||
def agent(self, command: str, timeout: float | None = 600, **args):
|
||||
remote = self.ensure_agent()
|
||||
def agent(self, command: str, timeout: float | None = 600, ensure: bool = True, **args):
|
||||
"""Run an agent command. ensure=False uses the agent already on the Frame (never re-uploads it)."""
|
||||
remote = self.ensure_agent() if ensure else posixpath.join(self.home, REMOTE_AGENT_DIR, "frameport_agent.py")
|
||||
code, out, err = self.run(f"python3 {sh_quote(remote)} {command}", stdin=json.dumps(args), timeout=timeout)
|
||||
line = next((l for l in reversed(out.splitlines()) if l.startswith("{")), "")
|
||||
line = next((ln for ln in reversed(out.splitlines()) if ln.startswith("{")), "")
|
||||
try:
|
||||
reply = json.loads(line)
|
||||
except ValueError:
|
||||
@@ -303,15 +455,6 @@ def sh_quote(s: str) -> str:
|
||||
return "'" + s.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def wait_for(predicate, timeout: float, interval: float = 2.0) -> bool:
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
def parse_target(text: str) -> FrameTarget:
|
||||
m = re.fullmatch(r"(?:(?P<user>[^@]+)@)?(?P<host>[^:]+)(?::(?P<port>\d+))?", text.strip())
|
||||
if not m:
|
||||
|
||||
@@ -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)) 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]:
|
||||
@@ -136,7 +139,7 @@ def browse(seconds: float = 4.0, scan: bool = True) -> list[Found]:
|
||||
with ThreadPoolExecutor(32) as pool:
|
||||
for f in devices.values():
|
||||
cands = list(dict.fromkeys(f.addresses))
|
||||
ok = [a for a, up in zip(cands, pool.map(ssh_open, cands)) if up]
|
||||
ok = [a for a, up in zip(cands, pool.map(ssh_open, cands), strict=True) if up]
|
||||
ips = []
|
||||
for a in ok:
|
||||
try:
|
||||
@@ -153,13 +156,6 @@ def browse(seconds: float = 4.0, scan: bool = True) -> list[Found]:
|
||||
return sorted(out, key=lambda f: (rank.get(f.source, 9), f.name, link_label(f.host) != "network", f.host))
|
||||
|
||||
|
||||
def resolve_hostname(name: str = "frame.local") -> str | None:
|
||||
try:
|
||||
return socket.gethostbyname(name)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def local_ip_towards(host: str = "8.8.8.8") -> str:
|
||||
"""The PC's address on the route towards host (used in the bootstrap one-liner)."""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Pairing server: while the Setup page is open, serve the bootstrap script and FramePort's public key over HTTP on
|
||||
the LAN. The user types one line on the Frame; the script calls back /paired so the UI knows who connected."""
|
||||
the LAN. The user types one line on the Frame; the script calls back /paired so the UI knows who connected.
|
||||
|
||||
Every request needs the pairing code, a random secret that is only in that one line. The server stops after a
|
||||
successful pairing, after MAX_FAILURES wrong codes (someone guessing) and after LIFETIME seconds."""
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import random
|
||||
import secrets
|
||||
import threading
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
@@ -12,11 +15,15 @@ from ..core.paths import bootstrap_dir
|
||||
from .connection import app_public_key
|
||||
from .discovery import local_ip_towards
|
||||
|
||||
MAX_FAILURES = 20
|
||||
LIFETIME = 30 * 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairingServer:
|
||||
port: int = 0
|
||||
code: str = field(default_factory=lambda: f"{random.randint(0, 999999):06d}")
|
||||
code: str = field(default_factory=lambda: secrets.token_hex(8))
|
||||
failures: int = 0
|
||||
paired: list[dict] = field(default_factory=list)
|
||||
on_paired: object = None
|
||||
_httpd: http.server.ThreadingHTTPServer | None = None
|
||||
@@ -29,7 +36,7 @@ class PairingServer:
|
||||
def one_liner(self) -> str:
|
||||
return f"curl -fsSL {self.url}/bootstrap.sh?code={self.code} | bash"
|
||||
|
||||
def start(self) -> "PairingServer":
|
||||
def start(self) -> PairingServer:
|
||||
server = self
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
@@ -46,7 +53,10 @@ class PairingServer:
|
||||
def do_GET(self):
|
||||
url = urllib.parse.urlparse(self.path)
|
||||
q = dict(urllib.parse.parse_qsl(url.query))
|
||||
if q.get("code") != server.code:
|
||||
if not secrets.compare_digest(q.get("code", ""), server.code):
|
||||
server.failures += 1
|
||||
if server.failures >= MAX_FAILURES:
|
||||
server.stop_soon()
|
||||
return self._send(b"wrong or missing pairing code\n", status=403)
|
||||
if url.path == "/bootstrap.sh":
|
||||
text = (bootstrap_dir() / "bootstrap.sh").read_text()
|
||||
@@ -59,15 +69,31 @@ class PairingServer:
|
||||
server.paired.append(info)
|
||||
if callable(server.on_paired):
|
||||
server.on_paired(info)
|
||||
server.stop_soon() # paired: nothing else to serve
|
||||
return self._send(b"ok\n")
|
||||
return self._send(b"not found\n", status=404)
|
||||
|
||||
self._httpd = http.server.ThreadingHTTPServer(("0.0.0.0", self.port), Handler)
|
||||
self.port = self._httpd.server_address[1]
|
||||
threading.Thread(target=self._httpd.serve_forever, daemon=True).start()
|
||||
self._timer = threading.Timer(LIFETIME, self.stop)
|
||||
self._timer.daemon = True
|
||||
self._timer.start()
|
||||
return self
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._httpd is not None
|
||||
|
||||
def stop_soon(self) -> None:
|
||||
"""Stop from inside a request handler (shutdown() waits for the serving thread, so not from that thread)."""
|
||||
threading.Thread(target=self.stop, daemon=True).start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._httpd:
|
||||
self._httpd.shutdown()
|
||||
self._httpd = None
|
||||
httpd, self._httpd = self._httpd, None
|
||||
if httpd:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
timer = getattr(self, "_timer", None)
|
||||
if timer:
|
||||
timer.cancel()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Translations for user-facing text (GUI, help, patch descriptions).
|
||||
|
||||
Text is written in English in the code and wrapped: `tr("Install on Frame")`,
|
||||
`tr("Install {title}").format(title=x)` (templates, never f-strings inside `tr()`, so the English text is the lookup
|
||||
key), and `tr_n("{n} game", "{n} games", n)` for plurals. A translation is a JSON file `locales/<language>.json`
|
||||
mapping each English text to the translated one (plural entries map to a list: [one, other]);
|
||||
`scripts/i18n_extract.py` writes `locales/template.json` with every text that needs translating. Missing
|
||||
translations fall back to English, so a partial translation is fine.
|
||||
|
||||
The language comes from the library setting `ui.language` (default: English). CLI output, logs and diagnostics stay
|
||||
English.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
LOCALES = Path(__file__).with_name("locales")
|
||||
_catalog: dict[str, str | list[str]] = {}
|
||||
_language = "en"
|
||||
|
||||
|
||||
def set_language(language: str | None) -> str:
|
||||
"""Load a translation ("en" or None = English). Returns the language actually in use."""
|
||||
global _catalog, _language
|
||||
language = (language or "en").split("_")[0].split("-")[0].lower()
|
||||
path = LOCALES / f"{language}.json"
|
||||
if language == "en" or not path.exists():
|
||||
_catalog, _language = {}, "en"
|
||||
else:
|
||||
try:
|
||||
_catalog, _language = json.loads(path.read_text(encoding="utf-8")), language
|
||||
except (OSError, ValueError):
|
||||
_catalog, _language = {}, "en"
|
||||
return _language
|
||||
|
||||
|
||||
def language() -> str:
|
||||
return _language
|
||||
|
||||
|
||||
def available() -> list[str]:
|
||||
"""Languages with a translation file, plus English."""
|
||||
return ["en"] + sorted(p.stem for p in LOCALES.glob("*.json") if p.stem not in ("en", "template"))
|
||||
|
||||
|
||||
def language_name(code: str) -> str:
|
||||
"""A language's own name, from its file's "_language" entry ("English" for en)."""
|
||||
if code == "en":
|
||||
return "English"
|
||||
try:
|
||||
return json.loads((LOCALES / f"{code}.json").read_text(encoding="utf-8")).get("_language") or code
|
||||
except (OSError, ValueError):
|
||||
return code
|
||||
|
||||
|
||||
def tr(text: str) -> str:
|
||||
"""The translation of an English text (the text itself when there is none)."""
|
||||
value = _catalog.get(text)
|
||||
return value if isinstance(value, str) and value else text
|
||||
|
||||
|
||||
def tr_n(singular: str, plural: str, n: int, **kw) -> str:
|
||||
"""Plural-aware text, formatted with n (and kw): tr_n("{n} game", "{n} games", count)."""
|
||||
value = _catalog.get(singular)
|
||||
if isinstance(value, list) and len(value) >= 2:
|
||||
text = value[0] if n == 1 else value[1]
|
||||
else:
|
||||
text = singular if n == 1 else plural
|
||||
return text.format(n=n, **kw)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ numbers, sizes, dates (one place to localise)
|
||||
def fmt_size(n: int | float, digits: int = 1) -> str:
|
||||
"""Bytes as GiB / MiB / KiB."""
|
||||
n = float(n or 0)
|
||||
if n >= 2**30:
|
||||
return tr("{v} GiB").format(v=f"{n / 2**30:.{digits}f}")
|
||||
if n >= 2**20:
|
||||
return tr("{v} MiB").format(v=f"{n / 2**20:.0f}")
|
||||
return tr("{v} KiB").format(v=f"{max(n, 0) / 2**10:.0f}")
|
||||
|
||||
|
||||
def fmt_datetime(ts: float) -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) if ts else ""
|
||||
|
||||
|
||||
def fmt_percent(fraction: float) -> str:
|
||||
return f"{fraction:.0%}"
|
||||
@@ -11,10 +11,12 @@ that app's own folder too (agent `link_media`; no copy, no extra space). Uploads
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.events import Reporter
|
||||
from ..frame.connection import Frame
|
||||
from ..frame.connection import Frame, sh_quote
|
||||
from . import installer
|
||||
|
||||
TARGETS_HELP = ("videos (/sdcard/Movies in every app), downloads (/sdcard/Download), documents (/sdcard/Documents), "
|
||||
@@ -55,6 +57,23 @@ def _safe_subdir(subdir: str) -> str:
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def upload(frame: Frame, paths: list[Path], remote_dir: str, reporter: Reporter) -> tuple[list, list, int]:
|
||||
"""Upload files/folders into remote_dir (folders keep their structure). Files already there with the same size are
|
||||
skipped. Returns (sent items, skipped items, bytes sent); items are (local, path relative to remote_dir, size)."""
|
||||
items = _items(paths, "")
|
||||
have = [i for i in items if _remote_size(frame, posixpath.join(remote_dir, i[1])) == i[2]]
|
||||
if have:
|
||||
reporter.log(f"{len(have)} file(s) already on the Frame; not re-sending")
|
||||
items = [i for i in items if i not in have]
|
||||
total = sum(i[2] for i in items)
|
||||
reporter.log(f"{len(items)} file(s), {total / 2**20:.0f} MiB → {remote_dir}")
|
||||
if items:
|
||||
with installer.transfer_link(frame, reporter, total) as xfer:
|
||||
reporter.stage(f"Upload {len(items)} file(s)")
|
||||
installer.upload_files(xfer, items, remote_dir, reporter, max(total, 1))
|
||||
return items, have, total
|
||||
|
||||
|
||||
def send_files(frame: Frame, paths: list[Path], target: str = "videos", package: str | None = None,
|
||||
subdir: str = "", reporter: Reporter | None = None, link_app: str | None = None) -> dict:
|
||||
"""Upload to `target`. With link_app (a shared target only): the files also appear in that app's own folder."""
|
||||
@@ -65,16 +84,11 @@ def send_files(frame: Frame, paths: list[Path], target: str = "videos", package:
|
||||
raise ValueError(f"unknown destination {target!r}; choose one of: {', '.join(dests) or TARGETS_HELP}")
|
||||
dest = dests[target]
|
||||
sub = _safe_subdir(subdir)
|
||||
items = _items(paths, sub)
|
||||
have = [i for i in items if _remote_size(frame, posixpath.join(dest["path"], i[1])) == i[2]]
|
||||
if have:
|
||||
reporter.log(f"{len(have)} file(s) already on the Frame; not re-sending")
|
||||
items = [i for i in items if i not in have]
|
||||
total = sum(i[2] for i in items)
|
||||
reporter.log(f"{len(items)} file(s), {total / 2**20:.0f} MiB → {dest['path']} (apps see {dest['android']})")
|
||||
with installer.transfer_link(frame, reporter, total) as xfer:
|
||||
reporter.stage(f"Upload {len(items)} file(s)")
|
||||
installer.upload_files(xfer, items, dest["path"], reporter, max(total, 1))
|
||||
reporter.log(f"apps see {dest['path']} as {dest['android']}")
|
||||
items, have, total = upload(frame, paths, posixpath.join(dest["path"], sub) if sub else dest["path"], reporter)
|
||||
if sub:
|
||||
items = [(f, posixpath.join(sub, rel), n) for f, rel, n in items]
|
||||
have = [(f, posixpath.join(sub, rel), n) for f, rel, n in have]
|
||||
where = posixpath.join(dest["android"], sub) if sub else dest["android"]
|
||||
linked = None
|
||||
if link_app and dest["shared"]:
|
||||
@@ -87,5 +101,115 @@ 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}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ browsing (the GUI's Files tab)
|
||||
@dataclass
|
||||
class Entry:
|
||||
name: str
|
||||
path: str
|
||||
is_dir: bool
|
||||
size: int
|
||||
mtime: float
|
||||
link: bool = False # a symbolic link (e.g. Lepton's /sdcard/Movies → ~/Videos in a game's storage)
|
||||
|
||||
|
||||
def inside(root: str, path: str) -> str:
|
||||
"""`path` normalised; refuses anything outside `root` (every Files-tab operation stays in the chosen location)."""
|
||||
root = posixpath.normpath(root)
|
||||
path = posixpath.normpath(path if path.startswith("/") else posixpath.join(root, path))
|
||||
if path != root and not path.startswith(root.rstrip("/") + "/"):
|
||||
raise ValueError(f"{path} is outside {root}")
|
||||
return path
|
||||
|
||||
|
||||
def _name(name: str) -> str:
|
||||
name = (name or "").strip()
|
||||
if not name or name in (".", "..") or "/" in name or "\\" in name or "\0" in name:
|
||||
raise ValueError(f"not a valid name: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def list_dir(frame: Frame, root: str, path: str, hidden: bool = False) -> list[Entry]:
|
||||
"""Folders first, then files, by name (case-insensitive). Links to folders count as folders."""
|
||||
path = inside(root, path)
|
||||
out = []
|
||||
for a in frame.sftp.listdir_attr(path):
|
||||
if not hidden and a.filename.startswith("."):
|
||||
continue
|
||||
full = posixpath.join(path, a.filename)
|
||||
link = stat.S_ISLNK(a.st_mode or 0)
|
||||
mode, size = a.st_mode or 0, a.st_size or 0
|
||||
if link:
|
||||
try:
|
||||
target = frame.sftp.stat(full)
|
||||
mode, size = target.st_mode or 0, target.st_size or 0
|
||||
except OSError: # dangling link
|
||||
pass
|
||||
out.append(Entry(a.filename, full, stat.S_ISDIR(mode), size, float(a.st_mtime or 0), link))
|
||||
return sorted(out, key=lambda e: (not e.is_dir, e.name.lower()))
|
||||
|
||||
|
||||
def make_dir(frame: Frame, root: str, parent: str, name: str) -> str:
|
||||
path = inside(root, posixpath.join(inside(root, parent), _name(name)))
|
||||
frame.sftp.mkdir(path)
|
||||
return path
|
||||
|
||||
|
||||
def rename(frame: Frame, root: str, path: str, new_name: str) -> str:
|
||||
path = inside(root, path)
|
||||
if path == posixpath.normpath(root):
|
||||
raise ValueError("the location itself can't be renamed")
|
||||
new = inside(root, posixpath.join(posixpath.dirname(path), _name(new_name)))
|
||||
frame.sftp.rename(path, new)
|
||||
return new
|
||||
|
||||
|
||||
def delete(frame: Frame, root: str, paths: list[str]) -> int:
|
||||
"""Delete files/folders (recursively) inside root. A link is removed, never what it points to."""
|
||||
targets = [inside(root, p) for p in paths]
|
||||
if any(t == posixpath.normpath(root) for t in targets):
|
||||
raise ValueError("the location itself can't be deleted")
|
||||
if targets:
|
||||
code, _, err = frame.run("rm -rf -- " + " ".join(sh_quote(t) for t in targets), timeout=900)
|
||||
if code:
|
||||
raise OSError(err.strip() or f"rm failed ({code})")
|
||||
return len(targets)
|
||||
|
||||
|
||||
def _remote_files(frame: Frame, path: str, rel: str = "") -> list[tuple[str, str, int]]:
|
||||
"""(remote file, path relative to the downloaded item, size), recursively (links are not followed)."""
|
||||
a = frame.sftp.lstat(path)
|
||||
if not stat.S_ISDIR(a.st_mode or 0):
|
||||
return [(path, rel or posixpath.basename(path), a.st_size or 0)]
|
||||
out = []
|
||||
for child in frame.sftp.listdir_attr(path):
|
||||
if stat.S_ISLNK(child.st_mode or 0):
|
||||
continue
|
||||
out += _remote_files(frame, posixpath.join(path, child.filename),
|
||||
posixpath.join(rel or posixpath.basename(path), child.filename))
|
||||
return out
|
||||
|
||||
|
||||
def download(frame: Frame, root: str, paths: list[str], local_dir: Path, reporter: Reporter | None = None) -> dict:
|
||||
"""Copy files/folders from the Frame into local_dir (folders keep their structure). Interruptible (Cancel)."""
|
||||
reporter = reporter or Reporter()
|
||||
files = [f for p in paths for f in _remote_files(frame, inside(root, p))]
|
||||
total = sum(f[2] for f in files)
|
||||
reporter.log(f"{len(files)} file(s), {total / 2**20:.0f} MiB → {local_dir}")
|
||||
done = 0
|
||||
with installer.transfer_link(frame, reporter, total) as xfer:
|
||||
reporter.stage(f"Download {len(files)} file(s)")
|
||||
for remote, rel, size in files:
|
||||
dest = Path(local_dir) / Path(*rel.split("/"))
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def progress(sent, _total, base=done, rel=rel):
|
||||
reporter.check_cancel()
|
||||
reporter.progress((base + sent) / max(total, 1), rel)
|
||||
xfer.sftp.get(remote, str(dest), callback=progress)
|
||||
done += size
|
||||
return {"files": len(files), "bytes": total, "folder": str(local_dir)}
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
|
||||
import posixpath
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -12,7 +11,7 @@ from ..artwork import fetch as artwork
|
||||
from ..build import sha256
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
from ..frame.connection import Frame
|
||||
from ..frame.connection import Frame, sh_quote
|
||||
from ..patches import base
|
||||
from ..patches.settings import adapter_settings
|
||||
|
||||
@@ -92,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:
|
||||
@@ -108,10 +108,11 @@ 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,
|
||||
obb_manifest=manifest or None, flatscreen=_flatscreen(plan.package),
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source, "alt": plan.recipe.use_alt},
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
@@ -144,11 +145,10 @@ class PcvrPlan:
|
||||
|
||||
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
"""Pack a Windows PC VR game + Revive onto the Frame, run by Proton through a generated launch.sh."""
|
||||
from ..core.paths import artifacts_dir
|
||||
from ..patches.pcvr import game_args, launch_env
|
||||
from ..tools import revive as revive_tool
|
||||
|
||||
from ..core.paths import artifacts_dir
|
||||
|
||||
tool = plan.recipe.params("pcvr.proton_tool").get("tool") or None
|
||||
reporter.stage("Prepare Frame")
|
||||
prep = frame.agent("prepare_pcvr", package=plan.package, title=plan.title, dest=plan.dest, tool=tool)
|
||||
@@ -325,14 +325,22 @@ def _tags(package: str) -> list[str]:
|
||||
return steam_tags(entry) if entry else []
|
||||
|
||||
|
||||
def _flatscreen(package: str) -> bool:
|
||||
"""An Android app without VR: Lepton must show it as a flat window (it never draws through OpenXR)."""
|
||||
from ..core import library
|
||||
|
||||
g = library.game(package) or {}
|
||||
return bool(g.get("analysis")) and library.analysis_from_dict(g["analysis"]).vr_kind == "none"
|
||||
|
||||
|
||||
def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter) -> None:
|
||||
"""The complete Steam art set (portrait, wide, hero, logo, icon — composed where the store has no such shape)."""
|
||||
from ..artwork.steam import steam_set
|
||||
from ..artwork.steam import steam_set_for
|
||||
|
||||
reporter.stage("Artwork for the Steam library")
|
||||
art = steam_set(package)
|
||||
art = steam_set_for(package)
|
||||
remote_art = posixpath.join(base, "incoming-artwork")
|
||||
frame.run(f"rm -rf '{remote_art}' && mkdir -p '{remote_art}'")
|
||||
frame.run(f"rm -rf {sh_quote(remote_art)} && mkdir -p {sh_quote(remote_art)}")
|
||||
for kind, f in art.items():
|
||||
frame.put(f, posixpath.join(remote_art, f"{kind}{f.suffix}"), resume=False)
|
||||
reporter.check("Steam artwork", bool(art) or None, ", ".join(sorted(art)) or "none found")
|
||||
@@ -341,19 +349,19 @@ def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter)
|
||||
def update_steam_art(frame: Frame, package: str, reporter: Reporter) -> dict:
|
||||
"""Replace an installed game's Steam library art with the current artwork (after the user picked new art):
|
||||
the art set goes to the game's anchor, then the shortcut is rewritten (Steam restarts once)."""
|
||||
from ..artwork.steam import steam_set
|
||||
from ..artwork.steam import steam_set_for
|
||||
|
||||
dep = next((d for d in frame.agent("list_installed")["games"] if d.get("package") == package), None)
|
||||
if not dep:
|
||||
raise RuntimeError(f"{package} isn't installed on the Frame")
|
||||
if not dep.get("anchor"):
|
||||
raise RuntimeError("the Frame's FramePort agent is too old; reinstall the game to update its art")
|
||||
art = steam_set(package)
|
||||
art = steam_set_for(package)
|
||||
if not art:
|
||||
raise RuntimeError("no artwork to send")
|
||||
reporter.stage("Artwork for the Steam library")
|
||||
remote = posixpath.join(dep["anchor"], "artwork")
|
||||
frame.run(f"rm -rf '{remote}' && mkdir -p '{remote}'")
|
||||
frame.run(f"rm -rf {sh_quote(remote)} && mkdir -p {sh_quote(remote)}")
|
||||
for kind, f in art.items():
|
||||
frame.put(f, posixpath.join(remote, f"{kind}{f.suffix}"), resume=False)
|
||||
reporter.check("Steam artwork", True, ", ".join(sorted(art)))
|
||||
|
||||
@@ -70,6 +70,9 @@ OVRPORT_METADATA = ("android.hardware.xr.input.hand_tracking", "android.hardware
|
||||
"android.permission.FACE_TRACKING", "android.permission.HAND_TRACKING",
|
||||
"android.permission.SCENE_UNDERSTANDING_COARSE", "android.permission.SCENE_UNDERSTANDING_FINE")
|
||||
COMPAT = "libovrplatformcompat.so"
|
||||
# OVRPort's AC Nexus frame-rate patches rewrite this exact libil2cpp.so (build 207706)
|
||||
AC_NEXUS_IL2CPP = "5fa8036b2e6469298c0b3f024a8d89b9c5640233544ac23cd9b46278e5e9baf8"
|
||||
RECIPE_ONLY_PATCHES = {"patch_ac_nexus_no_appsw_72", "patch_ac_nexus_no_appsw_90"} # added to recipes after 09-28
|
||||
|
||||
|
||||
def _manifest_lines(data: bytes) -> list[str]:
|
||||
@@ -88,7 +91,7 @@ def _ovrport_manifest(new: bytes, old: bytes) -> str | None:
|
||||
if not added or any(line not in a for line in b):
|
||||
return None
|
||||
if all(any(f'"{n}"' in line for n in OVRPORT_METADATA) for line in added):
|
||||
return f"overport CLI (OVRPort 1.2.5+) VR metadata: {len(added)} optional tracking feature/permission entries"
|
||||
return f"OVRPort CLI 1.2.5+ VR metadata: {len(added)} optional tracking feature/permission entries"
|
||||
return None
|
||||
|
||||
|
||||
@@ -106,7 +109,11 @@ def _overport_config(new: bytes, old: bytes) -> str | None:
|
||||
if not a or not b:
|
||||
return None
|
||||
by_a, by_b = a.get("patched", {}).pop("by", None), b.get("patched", {}).pop("by", None)
|
||||
return f"written by overport CLI {by_a} (known-good: {by_b}); same settings" if a == b else None
|
||||
added = [p for p in a.get("patched", {}).get("patches", []) if p not in b.get("patched", {}).get("patches", [])]
|
||||
if added and set(added) <= RECIPE_ONLY_PATCHES:
|
||||
a["patched"]["patches"] = [p for p in a["patched"]["patches"] if p not in added]
|
||||
extra = f"; recipe now adds {', '.join(added)}" if added else ""
|
||||
return f"written by OVRPort CLI {by_a} (known-good: {by_b}){extra}; same settings" if a == b else None
|
||||
|
||||
|
||||
def _compat_export(data: bytes) -> bool:
|
||||
@@ -118,19 +125,22 @@ def classify(name: str, new: bytes, old: bytes) -> tuple[str, str]:
|
||||
if base == COMPAT and _compat_export(new) and _compat_export(old):
|
||||
current = (artifacts_dir() / name.split("/")[1] / base) if name.startswith("lib/") else None
|
||||
if not (current and current.exists() and current.read_bytes() == new):
|
||||
return "expected", "platform compat now added by the overport CLI (OVRPort 1.2.5+): same single export"
|
||||
return "expected", "platform compat now added by the OVRPort CLI 1.2.5+: same single export"
|
||||
if base == "AndroidManifest.xml":
|
||||
why = _ovrport_manifest(new, old)
|
||||
if why:
|
||||
return "expected", why
|
||||
if base == "libil2cpp.so" and hashlib.sha256(old).hexdigest() == AC_NEXUS_IL2CPP:
|
||||
return "expected", "OVRPort's AC Nexus frame-rate patch (recipe: no application space warp at 90 Hz)"
|
||||
if base == "liboverport.config.so":
|
||||
why = _overport_config(new, old)
|
||||
if why:
|
||||
return "expected", why
|
||||
if base == "libovrplatformloader.so" and elf.is_elf(new) and elf.is_elf(old) and _text_bytes(new) == _text_bytes(old):
|
||||
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+)"
|
||||
return "expected", "same loader, linked to the platform compat library by the OVRPort CLI 1.2.5+"
|
||||
if base in OWNED:
|
||||
current = (artifacts_dir() / name.split("/")[1] / base) if name.startswith("lib/") else None
|
||||
is_current = current is not None and current.exists() and current.read_bytes() == new
|
||||
@@ -141,7 +151,7 @@ def classify(name: str, new: bytes, old: bytes) -> tuple[str, str]:
|
||||
return "expected", why
|
||||
if base == "libframe_settings.so":
|
||||
def kv(b):
|
||||
return dict(l.split("=", 1) for l in b.decode().split("\n") if "=" in l)
|
||||
return dict(kv.split("=", 1) for kv in b.decode().split("\n") if "=" in kv)
|
||||
a, b = kv(new), kv(old)
|
||||
if a == b:
|
||||
return "equivalent", "same settings, different order"
|
||||
@@ -152,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"
|
||||
|
||||
@@ -173,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)
|
||||
@@ -190,7 +205,7 @@ def compare(new_apk: Path, old_apk: Path) -> dict:
|
||||
if current is not None and current.exists() and current.read_bytes() == _read(new_apk, n):
|
||||
rows.append((n, "expected", f"{OWNED[base]} added (current build)"))
|
||||
elif base == COMPAT and _compat_export(_read(new_apk, n)):
|
||||
rows.append((n, "expected", "platform compat added by the overport CLI (OVRPort 1.2.5+)"))
|
||||
rows.append((n, "expected", "platform compat added by the OVRPort CLI 1.2.5+"))
|
||||
else:
|
||||
rows.append((n, "UNEXPLAINED", "only in the new build"))
|
||||
elif n not in a:
|
||||
@@ -227,7 +242,7 @@ def run_parity(known_good: Path, sources: Path, outdir: Path, report: Path, only
|
||||
previous = outdir / "parity.json"
|
||||
if only and previous.exists(): # partial re-run: keep the other games' earlier results
|
||||
results = [r for r in json.loads(previous.read_text())
|
||||
if not any(o.lower() in r["game"].lower() for o in only)]
|
||||
if not _wanted(r, only)]
|
||||
folders = sorted(p for p in known_good.iterdir() if (p / "game.json").exists())
|
||||
if only:
|
||||
folders = [f for f in folders if any(o.lower() in f.name.lower() for o in only)]
|
||||
@@ -248,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
|
||||
@@ -279,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:
|
||||
@@ -293,11 +310,17 @@ 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")
|
||||
|
||||
|
||||
def _wanted(row: dict, only: list[str]) -> bool:
|
||||
"""--only matches a game's folder name or its package id (case-insensitive substring)."""
|
||||
return any(o.lower() in row["game"].lower() or o.lower() in row.get("package", "").lower() for o in only)
|
||||
|
||||
|
||||
def install_and_test(results_json: Path, target, baseline: Path | None, report: Path, reporter: Reporter,
|
||||
only: list[str] | None = None, seconds: int = 45, test_only: bool = False) -> bool:
|
||||
"""Device half of the parity test: install every rebuilt APK through the normal installer (APK only; the game
|
||||
@@ -317,13 +340,15 @@ def install_and_test(results_json: Path, target, baseline: Path | None, report:
|
||||
previous = {}
|
||||
if report.with_suffix(".json").exists():
|
||||
previous = {r["package"]: r for r in json.loads(report.with_suffix(".json").read_text())}
|
||||
if test_only and not previous:
|
||||
raise ValueError(f"--test-only re-uses an earlier full run's results: {report.with_suffix('.json')} not found")
|
||||
if test_only: # re-run launch tests only; keep install/shortcut results from the previous run
|
||||
out = [dict(previous[r["package"]]) for r in rows if r["package"] in previous
|
||||
and (not only or any(o.lower() in r["game"].lower() for o in only))]
|
||||
and (not only or _wanted(r, only))]
|
||||
rows = []
|
||||
for row in rows:
|
||||
pkg = row["package"]
|
||||
if only and not any(o.lower() in row["game"].lower() for o in only):
|
||||
if only and not _wanted(row, only):
|
||||
continue
|
||||
if "error" in row:
|
||||
out.append({**row, "install": "skipped (build error)"})
|
||||
@@ -336,7 +361,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
|
||||
@@ -366,14 +392,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 [l for l in a.libs if l.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:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""The patch plugin interface.
|
||||
|
||||
A patch is one self-contained, optional change. Stages run in this order:
|
||||
overport -> passed to the overport CLI as `--patches=` (see patches/overport.py)
|
||||
apk -> edits the overport output (FrameBridge adapter, manifest fixes, library fixes)
|
||||
OVRPort -> passed to the OVRPort CLI as `--patches=` (see patches/overport.py)
|
||||
apk -> edits the OVRPort output (FrameBridge adapter, manifest fixes, library fixes)
|
||||
install -> files/env written on the Frame at install time (adapter settings, config files)
|
||||
|
||||
Each patch can suggest itself from an Analysis (`detect`), applies itself (`apply`) and can report checks
|
||||
@@ -10,8 +10,9 @@ Each patch can suggest itself from an Analysis (`detect`), applies itself (`appl
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..apk.workspace import ApkWorkspace
|
||||
@@ -41,10 +42,10 @@ class Param:
|
||||
|
||||
@dataclass
|
||||
class ApkContext:
|
||||
ws: "ApkWorkspace"
|
||||
analysis: "Analysis"
|
||||
ws: ApkWorkspace
|
||||
analysis: Analysis
|
||||
params: dict
|
||||
reporter: "Reporter"
|
||||
reporter: Reporter
|
||||
recipe_patches: dict # full selection, for patches that depend on others
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
@@ -70,11 +71,19 @@ class Patch:
|
||||
requires: tuple[str, ...] = ()
|
||||
default_on: bool = False # part of the always-recommended baseline
|
||||
experimental: bool = False
|
||||
needs_vr: bool = True # only matters for VR apps (hidden for Android apps without VR)
|
||||
|
||||
def detect(self, analysis: "Analysis") -> Suggestion | None:
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
"""One plain sentence for the game page (patches/summaries.py); the description is the technical text."""
|
||||
from .summaries import SUMMARIES
|
||||
|
||||
return SUMMARIES.get(self.id, "")
|
||||
|
||||
def detect(self, analysis: Analysis) -> Suggestion | None:
|
||||
return Suggestion(True, "Recommended for every game.") if self.default_on else None
|
||||
|
||||
def applies(self, analysis: "Analysis") -> bool:
|
||||
def applies(self, analysis: Analysis) -> bool:
|
||||
"""False when the patch can't matter for this game (wrong engine, no VrApi, ...). The UI hides such patches
|
||||
(they stay in the recipe if they are defaults, where they are no-ops)."""
|
||||
return True
|
||||
@@ -117,7 +126,7 @@ def get(patch_id: str) -> Patch:
|
||||
raise KeyError(f"unknown patch {patch_id!r}") from None
|
||||
|
||||
|
||||
def for_game(patch: Patch, analysis: "Analysis") -> bool:
|
||||
def for_game(patch: Patch, analysis: Analysis) -> bool:
|
||||
"""Quest patches only for Quest games, PC VR (Revive) patches only for Rift games."""
|
||||
rift = (analysis.extra or {}).get("kind") == "rift"
|
||||
return (patch.category == "pcvr") == rift
|
||||
@@ -129,13 +138,28 @@ def all_patches() -> list[Patch]:
|
||||
|
||||
|
||||
_loaded = False
|
||||
_load_lock = threading.Lock()
|
||||
_loading = threading.local() # the loading thread's own lookups (from module imports) return early
|
||||
|
||||
|
||||
def load_all() -> None:
|
||||
"""Import every patch module once. Other threads wait until the registry is complete (a GUI background thread
|
||||
once looked up a patch while another thread was still importing them: "unknown patch")."""
|
||||
global _loaded
|
||||
if _loaded:
|
||||
if _loaded or getattr(_loading, "active", False):
|
||||
return
|
||||
_loaded = True
|
||||
with _load_lock:
|
||||
if _loaded:
|
||||
return
|
||||
_loading.active = True
|
||||
try:
|
||||
_import_modules()
|
||||
finally:
|
||||
_loading.active = False
|
||||
_loaded = True
|
||||
|
||||
|
||||
def _import_modules() -> None:
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
@@ -143,16 +167,3 @@ def load_all() -> None:
|
||||
|
||||
for mod in pkgutil.iter_modules(frame.__path__):
|
||||
importlib.import_module(f"{frame.__name__}.{mod.name}")
|
||||
|
||||
|
||||
def simple(patch_id: str, **attrs) -> Callable[[type], type]:
|
||||
"""Class decorator: set attributes and register."""
|
||||
|
||||
def wrap(cls):
|
||||
cls.id = patch_id
|
||||
for k, v in attrs.items():
|
||||
setattr(cls, k, v)
|
||||
register(cls)
|
||||
return cls
|
||||
|
||||
return wrap
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Steam Frame fixes applied to the overport output (stage "apk"). One module per fix; each registers itself."""
|
||||
"""Steam Frame fixes applied to the OVRPort output (stage "apk"). One module per fix; each registers itself."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...core.paths import artifacts_dir
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FrameBridge: wraps overport's generic OpenXR loader to paper over Steam Frame runtime gaps."""
|
||||
"""FrameBridge: wraps OVRPort's generic OpenXR loader to paper over Steam Frame runtime gaps."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
@@ -23,12 +23,12 @@ 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). "
|
||||
"Settings are in the Adapter settings group."
|
||||
"Wraps OVRPort'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). "
|
||||
"Its settings are under Game settings."
|
||||
)
|
||||
category = "frame"
|
||||
order = 10
|
||||
@@ -40,7 +40,7 @@ class FrameBridgeAdapter(Patch):
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if not ws.has(ws.lib(GENERIC)):
|
||||
raise RuntimeError("overport output has no libopenxr_loader_generic.so (not an overport build?)")
|
||||
raise RuntimeError("OVRPort output has no libopenxr_loader_generic.so (not an OVRPort build?)")
|
||||
adapter = artifact(ws.abi, GENERIC)
|
||||
settings = settings_text(ctx.recipe_patches)
|
||||
changed = False
|
||||
@@ -65,7 +65,7 @@ class FrameBridgeAdapter(Patch):
|
||||
|
||||
|
||||
def needs_xrshim(recipe_patches: dict) -> bool:
|
||||
"""Adapter features whose functions overport's dispatcher doesn't forward (it only knows a fixed table)."""
|
||||
"""Adapter features whose functions OVRPort's dispatcher doesn't forward (it only knows a fixed table)."""
|
||||
from ..settings import adapter_settings
|
||||
|
||||
return bool(adapter_settings(recipe_patches).get("controller_models"))
|
||||
@@ -76,7 +76,7 @@ def add_xrshim(ctx: ApkContext) -> bool:
|
||||
|
||||
OVRPlugin gets xrGetInstanceProcAddr with dlopen("libopenxr_loader.so") + dlsym (checked in Toy Master's
|
||||
OVRPlugin), so the loader name string it dlopens is pointed at the shim instead (same length, in place); the shim
|
||||
forwards every other lookup to overport's dispatcher."""
|
||||
forwards every other lookup to OVRPort's dispatcher."""
|
||||
ws = ctx.ws
|
||||
shim = artifact(ws.abi, XRSHIM)
|
||||
changed = False
|
||||
|
||||
@@ -9,6 +9,7 @@ MANIFEST = "AndroidManifest.xml"
|
||||
|
||||
class Launcher(Patch):
|
||||
id = "frame.launcher"
|
||||
needs_vr = False
|
||||
title = "Launcher category (INFO → LAUNCHER)"
|
||||
description = ("Lepton only launches an activity with category LAUNCHER (Quest apps often use INFO). "
|
||||
"Symptom when missing: launch.log says 'APP_ACTIVITY is empty' and nothing starts.")
|
||||
@@ -33,8 +34,9 @@ class Launcher(Patch):
|
||||
|
||||
class NoDebuggable(Patch):
|
||||
id = "frame.nodebug"
|
||||
needs_vr = False
|
||||
title = "Clear android:debuggable"
|
||||
description = ("overport marks apps debuggable; that turns on CheckJNI, which aborts some Unreal games on sloppy "
|
||||
description = ("OVRPort marks apps debuggable; that turns on CheckJNI, which aborts some Unreal games on sloppy "
|
||||
"JNI calls ('JNI DETECTED ERROR', e.g. 'GetStringUTFChars ... NULL' in Time Stall). Clear it for "
|
||||
"those.")
|
||||
order = 21
|
||||
@@ -48,9 +50,9 @@ class NoDebuggable(Patch):
|
||||
if v and v < (4, 22):
|
||||
return Suggestion(True, f"Unreal Engine {v[0]}.{v[1]}: older UE4 makes JNI calls CheckJNI rejects "
|
||||
"(e.g. Time Stall aborted on GetStringUTFChars(NULL)).")
|
||||
if any(l.startswith("libmetaxraudio") for l in a.libs):
|
||||
return Suggestion(True, "Unreal build of Meta XR Audio: its telemetry lookup leaves a pending JNI exception "
|
||||
"that CheckJNI turns into an abort (e.g. NOPE Challenge).")
|
||||
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(False, "Enable if the game aborts with 'JNI DETECTED ERROR' (CheckJNI).")
|
||||
|
||||
def applies(self, a):
|
||||
@@ -74,11 +76,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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Meta platform SDK gaps in overport's libovrplatformloader.so."""
|
||||
"""Meta platform SDK gaps in OVRPort's libovrplatformloader.so."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
@@ -31,7 +31,7 @@ class PlatformCompat(Patch):
|
||||
default_on = True
|
||||
|
||||
def detect(self, a):
|
||||
return Suggestion(True, "Applied automatically when the overport build needs it.")
|
||||
return Suggestion(True, "Applied automatically when the OVRPort build needs it.")
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
@@ -47,23 +47,32 @@ class PlatformCompat(Patch):
|
||||
class OvrStubs(Patch):
|
||||
id = "frame.ovrstubs"
|
||||
title = "Stub missing Meta platform functions"
|
||||
description = ("Generates no-op stubs for ovr_* functions the game imports but overport's platform loader "
|
||||
description = ("Generates no-op stubs for ovr_* functions the game imports but OVRPort's platform loader "
|
||||
"lacks (e.g. Espire 1/2, Wallace & Gromit, The Climb 2). Symptom: UnsatisfiedLinkError / "
|
||||
"'cannot locate symbol ovr_...'. Online/store features stay unavailable.")
|
||||
order = 31
|
||||
default_on = True
|
||||
|
||||
def detect(self, a):
|
||||
return Suggestion(True, "Applied automatically when the overport build needs it.")
|
||||
return Suggestion(True, "Applied automatically when the OVRPort build needs it.")
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if not ws.has(ws.lib(LOADER)) or ws.has(ws.lib(STUBS)):
|
||||
if not ws.has(ws.lib(LOADER)):
|
||||
return False
|
||||
loader = ws.read_lib(LOADER)
|
||||
if ws.has(ws.lib(STUBS)):
|
||||
if STUBS in elf.needed(loader):
|
||||
return False # already linked (an earlier build)
|
||||
# the stand-ins exist but nothing loads them (the loader was replaced, e.g. a converted APK converted
|
||||
# again): link them again
|
||||
ws.put(ws.lib(LOADER), elf.add_needed(loader, STUBS))
|
||||
ctx.notes.append("linked the existing stand-ins again")
|
||||
return True
|
||||
missing = missing_ovr_symbols(_lib_bytes(ws))
|
||||
if not missing:
|
||||
return False
|
||||
ws.put(ws.lib(LOADER), elf.add_needed(ws.read_lib(LOADER), STUBS))
|
||||
ws.put(ws.lib(LOADER), elf.add_needed(loader, STUBS))
|
||||
ws.put(ws.lib(STUBS), build_stub_library(sorted(missing), STUBS, ws.abi))
|
||||
ctx.notes.append(f"stubbed {len(missing)} function(s)")
|
||||
return True
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""overport's dispatcher aborts on swapchains wider or taller than 4096 px; raise that guard so the runtime decides."""
|
||||
"""OVRPort's dispatcher aborts on swapchains wider or taller than 4096 px; raise that guard so the runtime decides."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
@@ -13,7 +13,7 @@ VIDEO_LIBS = ("libavcodec", "libffmpeg", "libvlc", "libmpv", "libijkffmpeg")
|
||||
|
||||
|
||||
def raise_swapchain_limit(data: bytes) -> tuple[bytes | None, int]:
|
||||
"""Rewrite the `cmp wN, #4096` size guards at the start of overport's swapchain entry points."""
|
||||
"""Rewrite the `cmp wN, #4096` size guards at the start of OVRPort's swapchain entry points."""
|
||||
try:
|
||||
dynsym = elf._elf(data).get_section_by_name(".dynsym")
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -39,7 +39,7 @@ def raise_swapchain_limit(data: bytes) -> tuple[bytes | None, int]:
|
||||
class SwapchainLimit(Patch):
|
||||
id = "frame.swapchain_limit"
|
||||
title = "Allow swapchains larger than 4096 px"
|
||||
description = ("overport's OpenXR dispatcher aborts the app ('Wrong createInfo size', SIGABRT in "
|
||||
description = ("OVRPort's OpenXR dispatcher aborts the app ('Wrong createInfo size', SIGABRT in "
|
||||
"libopenxr_loader.so xrCreateSwapchain) when a swapchain is wider or taller than 4096 px, which "
|
||||
"video players do for 8K video or big theatre textures (e.g. 4XVR). Raises the guard to 16384 px so "
|
||||
"the Frame's runtime (8192 px max) decides instead.")
|
||||
@@ -47,11 +47,11 @@ class SwapchainLimit(Patch):
|
||||
default_on = True
|
||||
|
||||
def detect(self, a):
|
||||
video = sorted(l for l in a.libs if l.lower().startswith(VIDEO_LIBS))
|
||||
video = sorted(lib for lib in a.libs if lib.lower().startswith(VIDEO_LIBS))
|
||||
if video:
|
||||
return Suggestion(True, f"Video player ({', '.join(video[:2])}): creates swapchains larger than 4096 px "
|
||||
"(8K video, theatre textures), which overport's dispatcher aborts on.")
|
||||
return Suggestion(True, "Recommended for every game: overport aborts on swapchains larger than 4096 px; "
|
||||
"(8K video, theatre textures), which OVRPort's dispatcher aborts on.")
|
||||
return Suggestion(True, "Recommended for every game: OVRPort aborts on swapchains larger than 4096 px; "
|
||||
"the Frame's runtime handles them.")
|
||||
|
||||
def applies(self, a):
|
||||
|
||||
@@ -59,12 +59,12 @@ class MetaXrTelemetry(Patch):
|
||||
default_on = True
|
||||
|
||||
def detect(self, a):
|
||||
if any(l.startswith("libmetaxraudio") for l in a.libs):
|
||||
if any(lib.startswith("libmetaxraudio") for lib in a.libs):
|
||||
return Suggestion(True, "Meta XR Audio present; patched automatically if it has the telemetry lookup.")
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unreal" and any(l.lower().startswith("libmetaxraudio") for l in a.libs)
|
||||
return a.engine == "Unreal" and any(lib.lower().startswith("libmetaxraudio") for lib in a.libs)
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
changed = False
|
||||
@@ -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,15 +14,15 @@ 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); OVRPort cannot translate those."
|
||||
)
|
||||
order = 60
|
||||
experimental = True
|
||||
|
||||
def detect(self, a):
|
||||
if a.direct_vrapi and "arm64-v8a" in a.abis:
|
||||
return Suggestion(True, "The engine calls libvrapi.so directly; overport can't translate it.")
|
||||
return Suggestion(True, "The engine calls libvrapi.so directly; OVRPort can't translate it.")
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""overport CLI patches. These run inside overport; we only choose which ones. The CLI comes from the downstream fork
|
||||
"""OVRPort CLI patches. These run inside OVRPort; we only choose which ones. The CLI comes from the downstream fork
|
||||
github.com/Android-XR-Bridge/OVRPort (1.2.5+), originally github.com/ovrport/app.
|
||||
|
||||
The patch list is discovered dynamically (`overport patches`), and titles are fetched from 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).
|
||||
The patch list is discovered dynamically (the CLI's `patches` command), 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 OVRPort's recommended
|
||||
set (Patch(..., true) in its sources).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,18 +13,20 @@ from .base import Patch, Suggestion, register
|
||||
|
||||
# id, title, default, detail
|
||||
OVERPORT_PATCHES = [
|
||||
("patch_copy_libraries", "Copy overport libraries", True,
|
||||
"Adds overport's OpenXR loader dispatcher and platform loader. Required: without it nothing is translated."),
|
||||
("patch_copy_libraries", "Copy OVRPort libraries", True,
|
||||
"Adds OVRPort's OpenXR loader dispatcher and platform loader. Required: without it nothing is translated."),
|
||||
("patch_copy_ovrplugin_vrapi", "Copy OVRPlugin if VrApi is present", True,
|
||||
"Swaps in an OpenXR OVRPlugin for games that use VrApi through OVRPlugin (older Unity/Unreal)."),
|
||||
("patch_replace_icon_label", "Replace application label and icon", True,
|
||||
"Uses the store title/icon (overport image service) for the app label."),
|
||||
"Uses the store title/icon (OVRPort image service) for the app label."),
|
||||
("patch_fix_min_android_sdk", "Fix minimal Android SDK", True, "Raises minSdk where the loader needs it."),
|
||||
("patch_generate_config", "Generate overport config", True, "Writes liboverport.config.so with runtime options."),
|
||||
("patch_generate_config", "Generate OVRPort 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,23 +34,26 @@ 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)."),
|
||||
("patch_disable_controller_offset", "Disable controller tracking offset", False,
|
||||
"Removes overport's controller pose offset if controllers look misplaced."),
|
||||
"Removes OVRPort's controller pose offset if controllers look misplaced."),
|
||||
("patch_remove_vrapi", "Remove VrApi library", False,
|
||||
"Not recommended: breaks games that load VrApi through OVRPlugin."),
|
||||
("patch_vrapi_openxr", "VrApi → OpenXR adapter (OVRPort)", False,
|
||||
"OVRPort's own VrApi→OpenXR adapter for engines that call libvrapi.so directly: the same upstream code as "
|
||||
"FramePort's 'VrApi → OpenXR bridge' without its Frame-specific changes. Only in OVRPort's experimental CLI builds "
|
||||
"(the stable CLI lists it but can't apply it)."),
|
||||
"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."),
|
||||
@@ -60,6 +66,10 @@ CONFLICTS = {"patch_vrapi_openxr": ("patch_remove_vrapi", "frame.vrapi_bridge"),
|
||||
"patch_ac_nexus_no_appsw_72": ("patch_ac_nexus_no_appsw_90",),
|
||||
"patch_ac_nexus_no_appsw_90": ("patch_ac_nexus_no_appsw_72",)}
|
||||
AC_NEXUS = "com.Ubisoft.ACNexusVR"
|
||||
AC_NEXUS_BUILD = "MAIN.450412.207706.final" # OVRPort's AC Nexus patches check this build's libil2cpp.so (SHA-256)
|
||||
# patches that fail the whole overport run when used outside their game/build: a catalog recipe only gets them where
|
||||
# applies() is true (recommend/engine.py)
|
||||
STRICT = {"patch_ac_nexus_no_appsw_72", "patch_ac_nexus_no_appsw_90", "patch_vrapi_openxr"}
|
||||
DEFAULT_OVERPORT = [pid for pid, _, default, _ in OVERPORT_PATCHES if default]
|
||||
|
||||
|
||||
@@ -70,15 +80,17 @@ class OverportPatch(Patch):
|
||||
def __init__(self, pid: str, title: str, default: bool, detail: str):
|
||||
self.id, self.title, self.default_on, self.description = pid, title, default, detail
|
||||
self.conflicts = CONFLICTS.get(pid, ())
|
||||
self.strict = pid in STRICT
|
||||
|
||||
def detect(self, analysis: Analysis) -> Suggestion | None:
|
||||
from . import applicability as ap
|
||||
|
||||
a = analysis
|
||||
if self.default_on:
|
||||
return Suggestion(True, "overport default.")
|
||||
return Suggestion(True, "Part of every OVRPort conversion.")
|
||||
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:
|
||||
@@ -100,8 +112,8 @@ class OverportPatch(Patch):
|
||||
"patch_disable_space_warp": lambda a: "libOVRPlugin.so" in a.libs,
|
||||
"patch_vrapi_openxr": lambda a: a.direct_vrapi and "arm64-v8a" in a.abis,
|
||||
"patch_disable_meta_xr_audio_telemetry": lambda a: False, # emulators only; the Frame is arm64
|
||||
"patch_ac_nexus_no_appsw_72": lambda a: a.package == AC_NEXUS,
|
||||
"patch_ac_nexus_no_appsw_90": lambda a: a.package == AC_NEXUS,
|
||||
"patch_ac_nexus_no_appsw_72": lambda a: a.package == AC_NEXUS and a.version == AC_NEXUS_BUILD,
|
||||
"patch_ac_nexus_no_appsw_90": lambda a: a.package == AC_NEXUS and a.version == AC_NEXUS_BUILD,
|
||||
}
|
||||
rule = rules.get(self.id)
|
||||
return rule(analysis) if rule else True
|
||||
@@ -115,7 +127,7 @@ for _pid, _title, _default, _detail in OVERPORT_PATCHES:
|
||||
|
||||
|
||||
def refresh(list_patches=None, fetch_titles: bool = True) -> list[str]:
|
||||
"""Register patches the installed overport CLI offers that this table doesn't know yet, and update titles.
|
||||
"""Register patches the installed OVRPort CLI offers that this table doesn't know yet, and update titles.
|
||||
`list_patches` is a callable returning patch ids (tools.overport.list_patches). Returns newly added ids."""
|
||||
import re
|
||||
|
||||
@@ -131,11 +143,14 @@ def refresh(list_patches=None, fetch_titles: bool = True) -> list[str]:
|
||||
for pid in ids:
|
||||
if pid not in REGISTRY:
|
||||
register(OverportPatch(pid, pid.removeprefix("patch_").replace("_", " ").capitalize(), False,
|
||||
"New overport patch (not yet described by FramePort)."))
|
||||
"New OVRPort patch (not yet described by FramePort)."))
|
||||
added.append(pid)
|
||||
if fetch_titles:
|
||||
text = cached_text("overport-strings.xml", STRINGS_URL, max_age=7 * 86400)
|
||||
known = {row[0] for row in OVERPORT_PATCHES}
|
||||
for pid, title in re.findall(r'<string name="(patch_[a-z0-9_]+)">([^<]+)</string>', text or ""):
|
||||
if pid in REGISTRY and REGISTRY[pid].category == "overport":
|
||||
# only for patches FramePort doesn't describe yet: ours are worded for the Frame and are the keys of
|
||||
# their translations
|
||||
if pid in REGISTRY and REGISTRY[pid].category == "overport" and pid not in known:
|
||||
REGISTRY[pid].title = title.replace("\\'", "'")
|
||||
return added
|
||||
@@ -1,4 +1,4 @@
|
||||
"""PC VR (Oculus Rift) options, shown in the UI as patches like the overport ones.
|
||||
"""PC VR (Oculus Rift) options, shown in the UI as patches like the OVRPort ones.
|
||||
|
||||
They don't edit the game: they decide how FramePort launches it. The analysis (analysis/rift.py launch_mode)
|
||||
picks one of three ways, and the patches below follow it (the user can change each one):
|
||||
@@ -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):
|
||||
@@ -106,7 +107,7 @@ class RepackLauncher(_PcvrPatch):
|
||||
if not _rift(analysis):
|
||||
return None
|
||||
if _mode(analysis) == "repack":
|
||||
dlls = ",".join((analysis.extra.get("loader_dlls") or []))
|
||||
dlls = ",".join(analysis.extra.get("loader_dlls") or [])
|
||||
return Suggestion(True, f"The game folder has its own Revive and loader ({dlls}): run the game directly.",
|
||||
{"dlls": dlls})
|
||||
return None
|
||||
@@ -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 OVRPort'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)."),
|
||||
@@ -69,22 +73,99 @@ SETTINGS = [
|
||||
"Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get "
|
||||
"the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and "
|
||||
"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)."),
|
||||
"it on needs a rebuild (it adds a small library in front of OVRPort'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)."),
|
||||
]
|
||||
|
||||
|
||||
# How the "Game settings" dialog shows each setting to non-technical users: group, level (common settings are always
|
||||
# shown; advanced ones only under "Show advanced settings"), a plain label and one-line help, the control, and the
|
||||
# setting it depends on (only shown while that one is on). The technical title/description above stay for the CLI.
|
||||
GROUPS = [("picture", "Picture"), ("controllers", "Controllers"), ("screens", "Menus and screens"),
|
||||
("room", "Mixed reality"), ("video360", "360° pictures and video"), ("troubleshooting", "Troubleshooting")]
|
||||
REFRESH_CHOICES = [(0.0, "Game's choice"), *((float(hz), f"{hz} Hz") for hz in (72, 80, 90, 96, 108, 120, 144))]
|
||||
UI: dict[str, dict] = {
|
||||
"scale": dict(group="picture", level="common", label="Sharpness",
|
||||
help="Higher looks sharper but needs more power; lower runs smoother. 1.0× is the game's own.",
|
||||
control=("slider", 0.5, 2.0, 0.1, "{:.1f}×")),
|
||||
"refresh_rate": dict(group="picture", level="common", label="Refresh rate",
|
||||
help="How often the picture updates. Higher is smoother if the game can keep up.",
|
||||
control=("choice", REFRESH_CHOICES)),
|
||||
"controller_fix": dict(group="controllers", level="common", label="Use controllers",
|
||||
help="Turn off only for games you play with your hands instead of controllers.",
|
||||
control=("switch",)),
|
||||
"aim_pitch": dict(group="controllers", level="common", label="Pointer angle",
|
||||
help="If the pointer doesn't hit what you aim at, tilt it up or down.",
|
||||
control=("slider", -30.0, 30.0, 1.0, "{:+.0f}°")),
|
||||
"aim_yaw": dict(group="controllers", level="advanced", label="Pointer turn",
|
||||
help="Turns the pointer left or right.", control=("slider", -30.0, 30.0, 1.0, "{:+.0f}°")),
|
||||
"aim_forward": dict(group="controllers", level="advanced", label="Pointer start",
|
||||
help="Moves where the pointer starts, forward or back.",
|
||||
control=("slider", -0.3, 0.3, 0.01, "{:+.2f} m")),
|
||||
"controller_models": dict(group="controllers", level="common", label="Show Steam Frame controllers",
|
||||
help="For games that show the headset's own controllers. Takes effect after a "
|
||||
"reinstall.", control=("switch",), reinstall=True),
|
||||
"cylinder_strips": dict(group="screens", level="common", label="Show curved menus and screens",
|
||||
help="The Frame can't show curved panels; this shows them as gently bent strips.",
|
||||
control=("switch",)),
|
||||
"flip_emul": dict(group="screens", level="common", label="Fix upside-down menus",
|
||||
help="Turns menus and text the right way up.", control=("switch",)),
|
||||
"stable_local": dict(group="screens", level="common", label="Keep menus in place",
|
||||
help="For games whose menus or screens jump to wherever you look.", control=("switch",)),
|
||||
"focus_hold": dict(group="screens", level="common", label="Don't pause on short interruptions",
|
||||
help="For games that pause or recenter when the headset briefly loses focus.",
|
||||
control=("switch",)),
|
||||
"passthrough_emul": dict(group="room", level="common", label="See your room",
|
||||
help="Shows the Frame's cameras where the game expects passthrough.",
|
||||
control=("switch",)),
|
||||
"scene_emul": dict(group="room", level="common", label="Pretend room layout",
|
||||
help="Mixed-reality games that build their level from your room get a simple room with walls.",
|
||||
control=("switch",)),
|
||||
"scene_height": dict(group="room", level="advanced", label="Room height", depends="scene_emul",
|
||||
help="Ceiling height of the pretend room.", control=("slider", 2.0, 4.0, 0.1, "{:.1f} m")),
|
||||
"scene_width": dict(group="room", level="advanced", label="Room width", depends="scene_emul",
|
||||
help="0 = the size of your play area.", control=("slider", 0.0, 10.0, 0.5, "{:.1f} m")),
|
||||
"scene_depth": dict(group="room", level="advanced", label="Room depth", depends="scene_emul",
|
||||
help="0 = the size of your play area.", control=("slider", 0.0, 10.0, 0.5, "{:.1f} m")),
|
||||
"equirect_emul": dict(group="video360", level="common", label="Show 360° backgrounds and videos",
|
||||
help="Video players' virtual theatres and 360° videos would otherwise stay black.",
|
||||
control=("switch",)),
|
||||
"equirect_res": dict(group="video360", level="advanced", label="360° sharpness", depends="equirect_emul",
|
||||
help="Higher is sharper but needs more power.",
|
||||
control=("choice", [(1024, "Low"), (1536, "Normal"), (2048, "High"), (3072, "Very high")])),
|
||||
"equirect_face": dict(group="video360", level="advanced", label="360° detail", depends="equirect_emul",
|
||||
help="Higher keeps more detail but uses more memory.",
|
||||
control=("choice", [(1024, "Low"), (1536, "Normal"), (2048, "High"), (2730, "Maximum")])),
|
||||
"equirect_fps": dict(group="video360", level="advanced", label="360° video frame rate limit",
|
||||
depends="equirect_emul", help="Must be at least the video's own frame rate.",
|
||||
control=("choice", [(0.0, "No limit"), (24.0, "24"), (30.0, "30"), (60.0, "60"),
|
||||
(90.0, "90")])),
|
||||
"equirect_flip": dict(group="video360", level="advanced", label="360° picture orientation", depends="equirect_emul",
|
||||
help="Only if 360° pictures show wrongly.",
|
||||
control=("choice", [(0, "Normal"), (1, "Upside down"), (2, "Mirrored"),
|
||||
(3, "Upside down and mirrored"), (4, "Turned around")])),
|
||||
"equirect_stereo": dict(group="video360", level="advanced", label="360° 3D", depends="equirect_emul",
|
||||
help="Shows 3D 360° pictures flat if they look doubled.",
|
||||
control=("choice", [(0, "As the game sends it"), (2, "Flat")])),
|
||||
**{key: dict(group="troubleshooting", level="advanced", control=("switch",)) for key in (
|
||||
"foveation_fix", "swapchain_fix", "layer_fix", "gl_hide_multiview", "mutable_fix", "flip_quads", "swap_eyes",
|
||||
"strip_depth", "respace_kick", "layer_debug")},
|
||||
}
|
||||
|
||||
|
||||
class AdapterSetting(Patch):
|
||||
category = "adapter"
|
||||
stage = "install"
|
||||
@@ -92,7 +173,7 @@ class AdapterSetting(Patch):
|
||||
def __init__(self, key, kind, default, title, description):
|
||||
self.id = f"adapter.{key}"
|
||||
self.key = key
|
||||
self.title = title
|
||||
self.title = UI.get(key, {}).get("label") or title # the plain label the Game settings dialog uses
|
||||
self.description = description
|
||||
self.params = [Param("value", kind, default, description)]
|
||||
self.default = default
|
||||
@@ -122,15 +203,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
|
||||
@@ -163,11 +245,13 @@ class DeviceFiles(Patch):
|
||||
"features the Frame doesn't support (e.g. a CryEngine user.cfg with r_variable_rate_shading = 0, "
|
||||
"as The Climb 2 needs).")
|
||||
category = "device"
|
||||
needs_vr = False
|
||||
stage = "install"
|
||||
params = [Param("files", "text", {}, "path relative to files/ -> content")]
|
||||
|
||||
def detect(self, a):
|
||||
if a.engine == "CryEngine" and a.graphics.startswith("Vulkan") or (a.engine == "CryEngine" and "libCryRenderVulkan.so" in a.libs):
|
||||
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,8 +265,10 @@ 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"
|
||||
needs_vr = False
|
||||
stage = "install"
|
||||
params = [Param("env", "text", {}, "NAME -> value")]
|
||||
|
||||
@@ -190,7 +276,33 @@ class LeptonEnv(Patch):
|
||||
ctx.env.update({k: str(v) for k, v in (ctx.params.get("env") or {}).items()})
|
||||
|
||||
|
||||
class HideNavBar(Patch):
|
||||
id = "device.hide_navbar"
|
||||
title = "Hide Android's navigation bar"
|
||||
description = ("Removes Android's back/home/recents buttons from a 2D app's window, where they cover the app's own "
|
||||
"controls. Turn it off for an app that needs the on-screen back button.")
|
||||
category = "device"
|
||||
needs_vr = False
|
||||
stage = "install"
|
||||
# qemu.hw.mainkeys=1 ("this device has hardware keys") is only read at boot and Lepton has no setting for extra
|
||||
# Android properties; it copies LEPTON_GFXRECON_* values into the boot properties unescaped, so a second line
|
||||
# rides along (see docs/FRAME_RUNTIME.md). Harmless if a Lepton update changes that: the bar comes back.
|
||||
ENV = {"LEPTON_GFXRECON_FP_PROPS": "0\nqemu.hw.mainkeys=1"}
|
||||
|
||||
def applies(self, a):
|
||||
return a.vr_kind == "none"
|
||||
|
||||
def detect(self, a):
|
||||
if a.vr_kind == "none":
|
||||
return Suggestion(True, "2D app: Android's navigation buttons would cover the app's own controls.")
|
||||
return None
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
ctx.env.update(self.ENV)
|
||||
|
||||
|
||||
for _spec in SETTINGS:
|
||||
register(AdapterSetting(*_spec))
|
||||
register(DeviceFiles)
|
||||
register(LeptonEnv)
|
||||
register(HideNavBar)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""One plain sentence per patch: what it does for the player, no jargon. The game page shows these by default; the
|
||||
technical description (patch.description) is shown with "Show technical details" and in the CLI."""
|
||||
|
||||
SUMMARIES = {
|
||||
# OVRPort (the Quest → OpenXR conversion)
|
||||
"patch_copy_libraries": "Adds the parts that let a Quest game talk to the Steam Frame. Needed for every Quest "
|
||||
"game.",
|
||||
"patch_copy_ovrplugin_vrapi": "Gives older Quest games a newer version of Meta's VR plugin that can be converted.",
|
||||
"patch_disable_controller_offset": "Removes a controller position adjustment, for games whose hands look "
|
||||
"misplaced.",
|
||||
"patch_disable_meta_xr_audio_telemetry": "Stops Meta's audio library from reporting to Meta (only matters on PC "
|
||||
"emulators).",
|
||||
"patch_disable_space_warp": "Turns off Meta's frame-rate trick that causes glitches or hangs outside Quest "
|
||||
"headsets.",
|
||||
"patch_fix_min_android_sdk": "Raises the minimum Android version the game declares, so the new parts load.",
|
||||
"patch_fix_unreal_crash": "Prevents a start-up crash in some Unreal games.",
|
||||
"patch_force_passthrough": "Lets mixed-reality games start even though they insist on a camera view of your room.",
|
||||
"patch_generate_config": "Writes the conversion's settings into the game.",
|
||||
"patch_launcher_entry": "Makes sure the game has an entry that can be started.",
|
||||
"patch_mark_allow_backup": "Allows the game's data to be backed up.",
|
||||
"patch_mark_as_debuggable": "Allows reading the game's logs, which helps find problems.",
|
||||
"patch_meta_xr_audio": "Makes Meta's 3D audio library work outside Quest headsets.",
|
||||
"patch_oculus_unity": "Lets Unity games accept a headset that isn't a Quest.",
|
||||
"patch_oculus_unreal": "Lets Unreal games accept a headset that isn't a Quest.",
|
||||
"patch_remove_localized_names": "Keeps one name for the game, so it shows the same everywhere.",
|
||||
"patch_remove_unreal_force_quit": "Stops some Unreal games from closing themselves right after starting.",
|
||||
"patch_remove_uses_library": "Removes requirements for Quest-only system parts.",
|
||||
"patch_remove_vrapi": "Removes Meta's old VR library when the game doesn't need it.",
|
||||
"patch_replace_icon_label": "Uses the store's name and icon for the game.",
|
||||
"patch_vr_metadata": "Marks the game as a VR app so headsets show it as one.",
|
||||
"patch_vrapi_openxr": "OVRPort's own converter for games that use Meta's oldest VR interface (experimental).",
|
||||
"patch_ac_nexus_no_appsw_72": "Assassin's Creed Nexus: smoother picture at 72 Hz (turns off Meta's frame-rate "
|
||||
"trick).",
|
||||
"patch_ac_nexus_no_appsw_90": "Assassin's Creed Nexus: smoother picture at 90 Hz (turns off Meta's frame-rate "
|
||||
"trick).",
|
||||
"patch_clean_up_frida": "Removes debugging leftovers some game files contain.",
|
||||
# Steam Frame patches
|
||||
"frame.adapter": "FramePort's translator between the game and the Steam Frame: fills in what the Frame lacks "
|
||||
"(controllers, room, passthrough). Needed for every converted game.",
|
||||
"frame.launcher": "Makes the game start from the Frame's Steam library.",
|
||||
"frame.nodebug": "Stops strict debugging checks that make some games quit.",
|
||||
"frame.meta_permissions": "Declares Meta's permissions some mixed-reality games ask for, so they don't stop.",
|
||||
"frame.ovrplatformcompat": "Adds a small piece of Meta's platform library that some games need to start.",
|
||||
"frame.ovrstubs": "Provides stand-ins for Meta store functions the game expects, so it doesn't crash at start.",
|
||||
"frame.unity_no_msaa": "Turns off a smoothing setting that crashes some older Unity games on the Frame.",
|
||||
"frame.swapchain_limit": "Allows very large pictures (8K video, theatres) instead of quitting.",
|
||||
"frame.vrapi_bridge": "Translator for games built on Meta's oldest VR interface.",
|
||||
"frame.gl_shim": "Fixes graphics code that the Frame's drivers reject (black screen with sound).",
|
||||
"frame.metaxr_telemetry": "Skips a Quest-only reporting step in Meta's audio library that crashes some games.",
|
||||
"frame.oculusos": "Provides stand-ins for Quest system reporting some games call at start.",
|
||||
"frame.vk_sanitize": "Cleans up graphics data that crashes some Unreal games on the Frame.",
|
||||
# Files and environment on the Frame
|
||||
"device.files": "Puts settings files next to the game on the Frame (e.g. to turn off an unsupported effect).",
|
||||
"device.hide_navbar": "Hides Android's back/home/recents buttons, which cover the app's own buttons.",
|
||||
"device.lepton_env": "Extra settings for the Android container (for testing).",
|
||||
# PC VR
|
||||
"pcvr.repack_launcher": "Starts the game with the launcher that came with your copy.",
|
||||
"pcvr.launch_args": "Starts the game with options, e.g. to choose SteamVR or OpenXR.",
|
||||
"pcvr.revive": "Lets games made for Oculus PCs run on SteamVR.",
|
||||
"pcvr.libovr_redirect": "Points Oculus games at Revive on the Frame.",
|
||||
"pcvr.no_crash_reporter": "Stops Unreal's crash reporter, which can block the game from starting.",
|
||||
"pcvr.oculus_unreal": "Lets Unreal games accept a headset that isn't an Oculus Rift.",
|
||||
"pcvr.revive_openvr": "On this PC, runs Revive through SteamVR (works with more headsets).",
|
||||
"pcvr.xr_timefix": "Lets games written for newer OpenXR versions run on the Frame.",
|
||||
"pcvr.proton_log": "Writes a detailed Proton log for troubleshooting.",
|
||||
"pcvr.proton_tool": "Chooses which Proton version runs the game on the Frame.",
|
||||
"pcvr.steamvr_tuning": "Picks a refresh rate and motion smoothing that suit the game on this PC.",
|
||||
"pcvr.proton_env": "Extra settings for Proton (for testing).",
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
add (scan/import) → analyze → suggest recipe → [user confirms] → build → static checks
|
||||
→ install on target → add to library → launch test (+ triage suggestions)
|
||||
|
||||
Quest games (APK + OBB) are rebuilt with overport and installed on the Frame (Lepton). Oculus Rift games (Windows PC VR
|
||||
Quest games (APK + OBB) are rebuilt with OVRPort and installed on the Frame (Lepton). Oculus Rift games (Windows PC VR
|
||||
folders, ids "rift.<slug>") aren't modified: "build" checks them and fetches Revive, and they install either on this
|
||||
PC (Revive + local Steam) or on the Frame (Proton + Revive).
|
||||
"""
|
||||
@@ -59,6 +59,9 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
|
||||
if art:
|
||||
try:
|
||||
artwork.fetch(entry["package"], Path(entry["apk"]))
|
||||
from .artwork.steam import ensure_cover
|
||||
|
||||
ensure_cover(entry["package"]) # no store art (2D apps): a cover with its name and icon
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if not entry.get("details"):
|
||||
@@ -152,7 +155,7 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
|
||||
a = rift.analyze(folder, exe=exe, tree=tree)
|
||||
if not a.extra.get("vr_found") and not force and not old:
|
||||
if reporter:
|
||||
reporter.log(f"skipped {folder.name}: no Oculus/OpenXR runtime found in {a.extra['exe']}")
|
||||
reporter.log(f"skipped {folder.name}: no VR support (OpenXR, SteamVR or Oculus) found in {a.extra['exe']}")
|
||||
return None
|
||||
package = old["package"] if old else a.package
|
||||
a.package = package
|
||||
@@ -188,7 +191,8 @@ def _rift_art(entry: dict, reporter: Reporter | None = None) -> dict:
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
try:
|
||||
found = sources.fetch_rift(pkg, entry.get("title") or pkg, extra.get("canonical_name"),
|
||||
entry.get("quest_package"), Path(entry["game_dir"]) / entry["exe"])
|
||||
entry.get("quest_package"), Path(entry["game_dir"]) / entry["exe"],
|
||||
oculus="LibOVR" in ((entry.get("analysis") or {}).get("xr") or ""))
|
||||
except Exception as exc: # noqa: BLE001 - artwork is optional
|
||||
if reporter:
|
||||
reporter.log(f"{entry.get('title')}: no artwork ({exc})")
|
||||
@@ -213,6 +217,7 @@ def _rift_art(entry: dict, reporter: Reporter | None = None) -> dict:
|
||||
def fetch_art(package: str, reporter: Reporter | None = None) -> dict:
|
||||
"""(Re)fetch artwork for any game in the library."""
|
||||
entry = library.game(package)
|
||||
(artwork.artwork_dir(package) / artwork.PICKED).unlink(missing_ok=True) # "find automatically" replaces a pick
|
||||
if entry.get("kind") == "rift":
|
||||
return _rift_art(entry, reporter)
|
||||
artwork.fetch(package, Path(entry["apk"]) if entry.get("apk") else None, refresh=True)
|
||||
@@ -347,14 +352,17 @@ def prepare_as_is(package: str, reporter: Reporter) -> dict:
|
||||
checks.append({"name": name, "ok": ok, "message": msg})
|
||||
reporter.check(name, ok, msg)
|
||||
check("APK", apk.is_file(), str(apk))
|
||||
check("64-bit (arm64-v8a)", "arm64-v8a" in (a.get("abis") or []) or None, ", ".join(a.get("abis") or []))
|
||||
abis = a.get("abis") or []
|
||||
check("64-bit (arm64-v8a)", "arm64-v8a" in abis or not abis or None,
|
||||
", ".join(abis) if abis else "no native code (Java/Kotlin only): runs on any CPU")
|
||||
try:
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
cats = axml.categories(z.read("AndroidManifest.xml"))
|
||||
check("Launcher entry for Lepton", axml.LAUNCHER in cats or None,
|
||||
"present" if axml.LAUNCHER in cats else "missing: Lepton may not find the game (turn off 'Install as is')")
|
||||
"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)
|
||||
@@ -389,20 +397,61 @@ def install_game(package: str, target: Target, reporter: Reporter, apk_only: boo
|
||||
entry = library.game(package)
|
||||
if is_rift(entry):
|
||||
return install_rift(package, target, reporter, add_to_library)
|
||||
test_build = apk is not None
|
||||
b = entry.get("build") or {}
|
||||
recipe = library.recipe_from_dict(entry["recipe"])
|
||||
apk = Path(apk) if apk else Path(b["alt_apk"] if recipe.use_alt and b.get("alt_apk") else b["apk"])
|
||||
if not test_build and not apk.exists(): # the converted copy was removed after an earlier install: make it again
|
||||
build_game(package, reporter)
|
||||
return install_game(package, target, reporter, apk_only, add_to_library)
|
||||
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
|
||||
title = steam_title(entry)
|
||||
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only)
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
installs = entry.get("installs", {})
|
||||
installs[target.label] = {"apk": str(apk), "result": result, "time": time.time()}
|
||||
library.upsert_game(package, installs=installs)
|
||||
_record_install(package, target.label, {"apk": str(apk), "result": result, "time": time.time()})
|
||||
if not test_build:
|
||||
remove_converted_copies(package)
|
||||
return result
|
||||
|
||||
|
||||
def converted_copies_size() -> int:
|
||||
return sum(p.stat().st_size for p in output_dir().rglob("*.apk") if p.is_file())
|
||||
|
||||
|
||||
def remove_all_converted_copies() -> int:
|
||||
"""Remove every converted APK in FramePort's output folder (installs convert again when needed)."""
|
||||
import shutil
|
||||
|
||||
freed = converted_copies_size()
|
||||
for d in output_dir().iterdir():
|
||||
shutil.rmtree(d, ignore_errors=True) if d.is_dir() else d.unlink(missing_ok=True)
|
||||
return freed
|
||||
|
||||
|
||||
def remove_converted_copies(package: str) -> int:
|
||||
"""The converted APKs are only needed until they're on the Frame (every install converts again): remove them
|
||||
unless the user keeps them (setting build.keep_copies). Only files in FramePort's own output folder, never the
|
||||
game files the user added. Returns the bytes freed."""
|
||||
if library.setting("build.keep_copies", False):
|
||||
return 0
|
||||
b = (library.game(package) or {}).get("build") or {}
|
||||
out, freed = output_dir().resolve(), 0
|
||||
for key in ("apk", "alt_apk"):
|
||||
p = Path(b[key]) if b.get(key) else None
|
||||
if p and p.exists() and out in p.resolve().parents:
|
||||
freed += p.stat().st_size
|
||||
p.unlink()
|
||||
if not any(p.parent.iterdir()):
|
||||
p.parent.rmdir()
|
||||
return freed
|
||||
|
||||
|
||||
def _record_install(package: str, where: str, record: dict) -> None:
|
||||
"""Merge into the game's current install records (the entry read before a long install may be stale)."""
|
||||
library.update_game(package, lambda g: g.setdefault("installs", {}).__setitem__(where, record))
|
||||
|
||||
|
||||
def install_rift(package: str, target: Target, reporter: Reporter, add_to_library: bool = True) -> dict:
|
||||
from .tools import revive
|
||||
|
||||
@@ -418,9 +467,7 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
|
||||
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"))
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
installs = entry.get("installs", {})
|
||||
installs[target.label] = {"exe": entry["exe"], "result": result, "time": time.time()}
|
||||
library.upsert_game(package, installs=installs)
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
|
||||
return result
|
||||
|
||||
|
||||
@@ -449,7 +496,7 @@ def useful_suggestions(package: str, suggestions: list[str]) -> list[str]:
|
||||
from .patches.base import REGISTRY
|
||||
|
||||
entry = library.game(package) or {}
|
||||
on = set(((entry.get("recipe") or {}).get("patches") or {}))
|
||||
on = set((entry.get("recipe") or {}).get("patches") or {})
|
||||
out = []
|
||||
for pid in suggestions:
|
||||
p = REGISTRY.get(pid)
|
||||
|
||||
@@ -9,6 +9,7 @@ Sources, highest priority first:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -51,13 +52,13 @@ class CatalogEntry:
|
||||
origin: str = "bundled"
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict, origin: str) -> "CatalogEntry":
|
||||
def from_dict(cls, d: dict, origin: str) -> CatalogEntry:
|
||||
known = {k: v for k, v in d.items() if k in cls.__dataclass_fields__}
|
||||
return cls(**known, origin=origin)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
out = {}
|
||||
for k, f in self.__dataclass_fields__.items():
|
||||
for k in self.__dataclass_fields__:
|
||||
v = getattr(self, k)
|
||||
if k == "origin" or v in (None, "", [], {}, False) or (k == "kind" and v == "quest"):
|
||||
continue
|
||||
@@ -82,6 +83,9 @@ def _load_dir(path: Path, origin: str) -> dict[str, CatalogEntry]:
|
||||
return out
|
||||
|
||||
|
||||
PACKAGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$")
|
||||
|
||||
|
||||
def _load_remote() -> dict[str, CatalogEntry]:
|
||||
base = os.environ.get("FRAMEPORT_CATALOG_URL")
|
||||
if not base:
|
||||
@@ -90,6 +94,8 @@ def _load_remote() -> dict[str, CatalogEntry]:
|
||||
index = cache.cached_json("catalog-index.json", base + "index.json", max_age=6 * 3600, fallback=[])
|
||||
out = {}
|
||||
for pkg in index or []:
|
||||
if not isinstance(pkg, str) or not PACKAGE_RE.match(pkg):
|
||||
continue # names become cache file names and URLs: Android package / rift.<slug> ids only
|
||||
text = cache.cached_text(f"catalog-{pkg}.yaml", f"{base}{pkg}.yaml", max_age=6 * 3600)
|
||||
if text:
|
||||
try:
|
||||
@@ -123,7 +129,7 @@ def save_user_entry(entry: CatalogEntry) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def entry_from_library(g: dict, status: str = "works", notes: str | None = None,
|
||||
def entry_from_library(g: dict, status: str | None = None, notes: str | None = None,
|
||||
verified: dict | None = None) -> CatalogEntry:
|
||||
"""A catalog recipe from a library entry (what "Save as known-good" and "Share working config" publish)."""
|
||||
import time
|
||||
@@ -135,6 +141,8 @@ def entry_from_library(g: dict, status: str = "works", notes: str | None = None,
|
||||
package = g["package"]
|
||||
r = library.recipe_from_dict(g["recipe"])
|
||||
a = g.get("analysis") or {}
|
||||
if status is None: # the game's own status ("works with issues" stays that; untested = works, it's being saved)
|
||||
status = r.status if r.status in ("works", "issues") else "works"
|
||||
common = dict(package=package, title=g.get("title") or package, status=status,
|
||||
notes=r.notes if notes is None else notes,
|
||||
tested_version=a.get("version") or "", engine=a.get("engine") or "", xr=a.get("xr") or "",
|
||||
@@ -158,7 +166,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],
|
||||
@@ -168,7 +177,7 @@ def entry_from_library(g: dict, status: str = "works", notes: str | None = None,
|
||||
|
||||
def generic_source_hint(name: str) -> str:
|
||||
"""The game's title from a download folder name, without what varies between releases: version ("v20022+2.0.22"),
|
||||
bracketed notes ("(Pro + Trial Bypass)", "(English Only)") and release-group suffixes ("-VRP", "-JF")."""
|
||||
bracketed notes ("(Pro)", "(English Only)") and release-tag suffixes ("-TAG")."""
|
||||
import re
|
||||
|
||||
t = re.sub(r"[\(\[][^)\]]*[\)\]]", " ", name or "")
|
||||
@@ -190,13 +199,3 @@ def source_hint_matches(hint: str, folder_name: str) -> bool:
|
||||
|
||||
def to_yaml(entry: CatalogEntry) -> str:
|
||||
return yaml.safe_dump(entry.to_dict(), sort_keys=False, allow_unicode=True, width=110)
|
||||
|
||||
|
||||
def write_index(folder: Path) -> Path:
|
||||
"""Write index.json for publishing a folder of recipes as a remote catalog."""
|
||||
import json
|
||||
|
||||
pkgs = sorted(f.stem for f in folder.glob("*.yaml"))
|
||||
path = folder / "index.json"
|
||||
path.write_text(json.dumps(pkgs, indent=1))
|
||||
return path
|
||||
@@ -33,6 +33,8 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
for pid in entry.overport_remove:
|
||||
recipe.patches.pop(pid, None)
|
||||
for pid in entry.overport_extra + entry.frame:
|
||||
if getattr(base.get(pid), "strict", False) and not base.get(pid).applies(analysis):
|
||||
continue # e.g. a patch for one exact game build: another build of the game would fail to patch
|
||||
recipe.patches.setdefault(pid, {})
|
||||
recipe.reasons[pid] = why
|
||||
# heuristic-only suggestions the catalog didn't choose are dropped for exact reproducibility
|
||||
@@ -60,7 +62,13 @@ 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."
|
||||
if not rift:
|
||||
_non_quest(analysis, recipe)
|
||||
if not rift and not entry and (analysis.extra or {}).get("frame_patched"):
|
||||
# already has FramePort's adapter (e.g. a PATCHED/ build): installing it unchanged is the safe default
|
||||
recipe.as_is = True
|
||||
@@ -71,7 +79,7 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
for req in base.get(pid).requires:
|
||||
if req not in recipe.patches:
|
||||
recipe.patches[req] = {}
|
||||
recipe.reasons[req] = f"Required by {pid}."
|
||||
recipe.reasons[req] = f"Needed by '{base.get(pid).title}'."
|
||||
return recipe
|
||||
|
||||
|
||||
@@ -148,13 +156,47 @@ def warnings(recipe: Recipe) -> list[str]:
|
||||
for r in p.requires:
|
||||
if r not in recipe.patches:
|
||||
out.append(f"{p.title} needs {base.get(r).title}.")
|
||||
if recipe.as_is or not recipe.overport:
|
||||
return out # installed unchanged / an ordinary Android app: no VR translation expected
|
||||
if "patch_copy_libraries" not in recipe.patches:
|
||||
out.append("Without 'Copy overport libraries' nothing is translated to OpenXR.")
|
||||
out.append("Without 'Copy OVRPort libraries' nothing is translated to OpenXR.")
|
||||
if "frame.adapter" not in recipe.patches:
|
||||
out.append("Without the FrameBridge adapter most games fail on the Frame runtime.")
|
||||
return out
|
||||
|
||||
|
||||
def _non_quest(analysis: Analysis, recipe: Recipe) -> None:
|
||||
"""Android apps that aren't Meta Quest apps (analysis.detect.vr_kind)."""
|
||||
kind = analysis.vr_kind
|
||||
if (analysis.extra or {}).get("split_apk"):
|
||||
recipe.notes = _add(recipe.notes, "Split APK: only the base APK was found; the game's code may live in the "
|
||||
"other parts of the set, which FramePort can't install.")
|
||||
if kind == "none":
|
||||
# an ordinary (2D) Android app: no OpenXR to translate. Install it unchanged, or with only the launcher fix
|
||||
recipe.overport = False
|
||||
needed = {"device.hide_navbar"} | ({"frame.launcher"} if analysis.has_info_category else set()) # LAUNCHER
|
||||
recipe.patches = {pid: v for pid, v in recipe.patches.items() if pid in needed}
|
||||
recipe.reasons = {pid: v for pid, v in recipe.reasons.items() if pid in recipe.patches}
|
||||
recipe.alt_patches = []
|
||||
recipe.as_is = not any(base.get(pid).stage == "apk" for pid in recipe.patches) # the APK stays unchanged
|
||||
recipe.notes = _add(recipe.notes, "Android app without VR: installed without FramePort's VR translation.")
|
||||
elif kind in ("pico_sdk", "wave"):
|
||||
recipe.status = "unsupported"
|
||||
recipe.notes = _add(recipe.notes, f"Uses {'Pico' if kind == 'pico_sdk' else 'HTC Vive Wave'}'s own VR SDK "
|
||||
"instead of OpenXR: there is no way to run it on the Steam Frame.")
|
||||
elif kind == "android_xr":
|
||||
recipe.status = "unsupported"
|
||||
recipe.notes = _add(recipe.notes, "Android XR app: it needs Android XR system services that the Steam "
|
||||
"Frame's Android runtime doesn't have.")
|
||||
elif kind == "openxr":
|
||||
recipe.notes = _add(recipe.notes, "OpenXR app for another headset: translated like Quest games; that "
|
||||
"headset's own extensions and store/platform services aren't available.")
|
||||
|
||||
|
||||
def _add(notes: str, text: str) -> str:
|
||||
return f"{notes} {text}".strip() if text not in (notes or "") else notes
|
||||
|
||||
|
||||
def visible_patches(analysis: Analysis, recipe: Recipe | None = None) -> tuple[list, list]:
|
||||
"""(relevant, hidden) patches for this game. Enabled patches are always shown, so a recipe never hides a choice."""
|
||||
shown, hidden = [], []
|
||||
@@ -162,5 +204,8 @@ def visible_patches(analysis: Analysis, recipe: Recipe | None = None) -> tuple[l
|
||||
if not base.for_game(p, analysis):
|
||||
continue # other kind of game entirely (Quest vs Rift): not even offered under "show all"
|
||||
on = recipe is not None and p.id in recipe.patches
|
||||
if p.needs_vr and analysis.vr_kind == "none" and not on:
|
||||
hidden.append(p) # an Android app without VR: VR patches can't take effect
|
||||
continue
|
||||
(shown if on and not p.default_on or p.applies(analysis) else hidden).append(p)
|
||||
return shown, hidden
|
||||
@@ -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
|
||||
@@ -31,8 +33,9 @@ def render() -> str:
|
||||
line += f" PC VR: {e.pcvr_alternative}"
|
||||
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.", ""]
|
||||
out += ["All games also get the OVRPort 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.", ""]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Find Quest games on disk.
|
||||
|
||||
Accepted layouts:
|
||||
<folder>/<something>.apk [+ <folder>/<package>/ (OBB or raw asset data)] e.g. VR CyberDeck / VRP downloads
|
||||
<folder>/<something>.apk [+ <folder>/<package>/ (OBB or raw asset data)] e.g. common downloader layouts
|
||||
<folder>/<package>.apk + <folder>/obb/ FramePort/PATCHED output layout
|
||||
a single .apk file
|
||||
"""
|
||||
@@ -13,11 +13,12 @@ from pathlib import Path
|
||||
|
||||
from ..core.models import SourceGame
|
||||
|
||||
TAG = re.compile(r"\s+-(VRP|JF)\b.*$")
|
||||
# a release tag at the end of a download folder name: " -TAG" or " -TAG v76" (no space after the dash)
|
||||
TAG = re.compile(r"\s+-(?=[A-Za-z])[A-Za-z0-9]{2,12}(?:\s+[A-Za-z]?\d{1,4})?\s*$")
|
||||
|
||||
|
||||
def display_name(folder_name: str) -> str:
|
||||
"""'PowerWash Simulator VR v3055+2.5.0 -VRP v76' -> 'PowerWash Simulator VR v3055+2.5.0'."""
|
||||
"""'PowerWash Simulator VR v3055+2.5.0 -TAG v76' -> 'PowerWash Simulator VR v3055+2.5.0'."""
|
||||
return re.sub(r'[<>:"/\\|?*]', "_", TAG.sub("", folder_name).strip())
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ When scanning, the chosen folder's subfolders are the games (the folder itself o
|
||||
it). A subfolder is **one game** when it has candidate programs (see analysis.rift.candidates), no APKs (those are Quest
|
||||
dumps) and all candidates sit at its top level or under a single subfolder — repacks put the game one or more levels
|
||||
down, next to installers and archives. Otherwise it's a **collection**: every subfolder is one game (or, again, a
|
||||
collection), e.g. ARMGDDN_PCVR_OCULUS/<Game vX -TAG>/<Game>/…/Game.exe. Loose programs in a collection are ignored.
|
||||
collection), e.g. <PC VR games>/<Game vX -TAG>/<Game>/…/Game.exe. Loose programs in a collection are ignored.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,10 +25,6 @@ def classify(path: Path, tree: rift.Tree | None = None) -> tuple[str, rift.Tree]
|
||||
return "collection", tree
|
||||
|
||||
|
||||
def is_game_folder(path: Path) -> bool:
|
||||
return Path(path).is_dir() and classify(Path(path))[0] == "game"
|
||||
|
||||
|
||||
def scan(root: Path, depth: int = 4, trees: dict | None = None) -> list[Path]:
|
||||
"""Game folders under root (root itself if it is one game). `trees` collects the walk results by folder, so the
|
||||
analysis doesn't walk again."""
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
from ..frame.connection import Frame, FrameTarget
|
||||
from ..frame.connection import Frame, FrameTarget, sh_quote
|
||||
from ..install import installer
|
||||
from ..validate import device
|
||||
from .base import Target
|
||||
@@ -20,7 +20,9 @@ class FrameLeptonTarget(Target):
|
||||
self.frame = Frame(target, password)
|
||||
self.label = target.label
|
||||
|
||||
def connect(self) -> "FrameLeptonTarget":
|
||||
def connect(self) -> FrameLeptonTarget:
|
||||
if self.frame.client is not None and not self.frame.alive():
|
||||
self.frame.close() # the link died (Frame asleep, Wi-Fi gone): open a fresh one
|
||||
if self.frame.client is None:
|
||||
self.frame.connect()
|
||||
return self
|
||||
@@ -76,10 +78,11 @@ class FrameLeptonTarget(Target):
|
||||
if opts.pop("layer", False): # also load FramePort's timefix OpenXR layer (as installed games do)
|
||||
from ..core.paths import artifacts_dir
|
||||
|
||||
remote = "/home/" + self.target.user + "/.local/share/frameport/xrlayer"
|
||||
self.frame.run(f"mkdir -p '{remote}'")
|
||||
frame = self.connect().frame
|
||||
remote = f"{frame.home}/.local/share/frameport/xrlayer"
|
||||
frame.run(f"mkdir -p {sh_quote(remote)}")
|
||||
for f in sorted((artifacts_dir() / "linux-arm64").iterdir()):
|
||||
self.frame.put(f, f"{remote}/{f.name}", resume=False)
|
||||
frame.put(f, f"{remote}/{f.name}", resume=False)
|
||||
env = dict(opts.get("env") or {})
|
||||
env.update(XR_API_LAYER_PATH=remote, XR_ENABLE_API_LAYERS="XR_APILAYER_FRAMEPORT_timefix")
|
||||
opts["env"] = env
|
||||
|
||||
@@ -20,7 +20,8 @@ from ..patches.pcvr import game_args
|
||||
from ..validate.triage import triage
|
||||
from .base import Target
|
||||
|
||||
TAG = "Rift via Revive"
|
||||
TAG = "FramePort PC VR" # marks the Steam shortcuts FramePort made (for updates and cleanup)
|
||||
OLD_TAGS = ("Rift via Revive",) # earlier name of the same tag
|
||||
REVIVE_LOG = "Revive/ReviveInjector.txt" # under %LOCALAPPDATA%
|
||||
|
||||
|
||||
@@ -181,7 +182,7 @@ class PcReviveTarget(Target):
|
||||
def add_to_library(self, packages, reporter):
|
||||
from ..artwork import fetch as artwork
|
||||
from ..artwork import sources
|
||||
from ..artwork.steam import steam_set, steam_tags
|
||||
from ..artwork.steam import steam_set_for, steam_tags
|
||||
from ..core import library
|
||||
|
||||
reporter.stage("Add to Steam library (this PC)")
|
||||
@@ -203,9 +204,11 @@ class PcReviveTarget(Target):
|
||||
entry = library.game(pkg) or {"kind": "rift"}
|
||||
if not sources.has_art(pkg):
|
||||
artwork.fetch(pkg, lookup=dep.get("art_lookup"))
|
||||
art = steam_set(pkg)
|
||||
for stale in vdf_mod.prune_shortcuts(str(vdf), dep["title"], exe, TAG):
|
||||
for old in grid.glob(f"{stale}*"):
|
||||
art = steam_set_for(pkg)
|
||||
for stale in vdf_mod.prune_shortcuts(str(vdf), dep["title"], exe, (TAG, *OLD_TAGS)):
|
||||
from ..uninstall import grid_files
|
||||
|
||||
for old in grid_files(grid, stale):
|
||||
old.unlink(missing_ok=True)
|
||||
reporter.log(f"removed the old Steam entry for {dep['title']} (its launch command changed)")
|
||||
ident = vdf_mod.shortcut_appid(exe, dep["title"])
|
||||
@@ -298,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):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""overport CLI wrapper. overport's workspace (runtime versions, per-package signing keys) lives in our data dir."""
|
||||
"""OVRPort CLI wrapper. OVRPort's workspace (runtime versions, per-package signing keys) lives in our data dir."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
@@ -36,7 +36,7 @@ def import_keystores(*dirs: Path) -> int:
|
||||
def _jar() -> str:
|
||||
jar = toolchain.overport_jar()
|
||||
if not jar:
|
||||
raise RuntimeError("overport is not installed; run `frameport tools install`")
|
||||
raise RuntimeError("OVRPort is not installed; run `frameport tools install`")
|
||||
return str(jar)
|
||||
|
||||
|
||||
@@ -51,22 +51,17 @@ def cli_version() -> str | None:
|
||||
return m[1] if m else None
|
||||
|
||||
|
||||
def runtime_versions() -> str:
|
||||
p = toolchain.run_java(["-jar", _jar(), "install", f"--workspace={workspace()}"], timeout=300)
|
||||
return p.stdout
|
||||
|
||||
|
||||
def patch(apk: Path, outdir: Path, name: str, patches: list[str], reporter: Reporter,
|
||||
version: str = "latest") -> Path:
|
||||
"""Run `overport patch`; returns the output APK path. version='latest' always uses the newest runtime."""
|
||||
"""Run `OVRPort patch`; returns the output APK path. version='latest' always uses the newest runtime."""
|
||||
if not patches: # overport 1.2.5+ refuses an empty --patches= (and without patches it only re-signs)
|
||||
raise ValueError("no overport patches selected")
|
||||
raise ValueError("no OVRPort patches selected")
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
target = outdir / name
|
||||
target.unlink(missing_ok=True)
|
||||
args = ["-jar", _jar(), "patch", f"--input={apk}", f"--output={outdir}", f"--output-name={name}",
|
||||
f"--workspace={workspace()}", f"--version={version}", "--patches=" + ";".join(patches)]
|
||||
reporter.log("overport " + " ".join(a for a in args[3:] if not a.startswith("--patches")))
|
||||
reporter.log("OVRPort " + " ".join(a for a in args[3:] if not a.startswith("--patches")))
|
||||
p = toolchain.run_java(args)
|
||||
# overport draws spinners with \r; keep only final line states
|
||||
for line in (p.stdout + p.stderr).splitlines():
|
||||
@@ -74,13 +69,5 @@ def patch(apk: Path, outdir: Path, name: str, patches: list[str], reporter: Repo
|
||||
if line:
|
||||
reporter.log(" " + line)
|
||||
if p.returncode or not target.exists():
|
||||
raise RuntimeError(f"overport failed (exit {p.returncode}); see log")
|
||||
raise RuntimeError(f"OVRPort failed (exit {p.returncode}); see log")
|
||||
return target
|
||||
|
||||
|
||||
def runtime_version_used(log_lines: list[str]) -> str | None:
|
||||
for line in log_lines:
|
||||
m = re.search(r"(\d+\.\d+\.\d+-[0-9a-f]{7})", line)
|
||||
if m:
|
||||
return m[1]
|
||||
return None
|
||||
@@ -18,7 +18,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..core import cache
|
||||
from ..core.paths import tools_dir
|
||||
from ..core.paths import tools_dir, write_atomic
|
||||
|
||||
REVIVE_RELEASE = "https://api.github.com/repos/LibreVR/Revive/releases/latest"
|
||||
INSTALLER = "ReviveInstaller.exe"
|
||||
@@ -154,8 +154,12 @@ def system_revive(max_age: float = 60) -> Path | None:
|
||||
if not found:
|
||||
for hive in ("HKLM", "HKCU"):
|
||||
v = winhost.reg_query(hive + r"\Software\Revive", "")
|
||||
if v and _usable(winhost.to_local(v.strip('"'))):
|
||||
found = winhost.to_local(v.strip('"'))
|
||||
try:
|
||||
candidate = winhost.to_local(v.strip('"')) if v else None
|
||||
except ValueError:
|
||||
candidate = None
|
||||
if candidate and _usable(candidate):
|
||||
found = candidate
|
||||
break
|
||||
_system_cache = (time.time(), found)
|
||||
return found
|
||||
@@ -167,7 +171,7 @@ def release_for(path: Path) -> str | None:
|
||||
import datetime as dt
|
||||
|
||||
try:
|
||||
built = dt.datetime.fromtimestamp((path / "LibReviveXR64.dll").stat().st_mtime, dt.timezone.utc)
|
||||
built = dt.datetime.fromtimestamp((path / "LibReviveXR64.dll").stat().st_mtime, dt.UTC)
|
||||
except OSError:
|
||||
return None
|
||||
rels = cache.cached_json("revive-releases.json", REVIVE_RELEASE.rsplit("/", 1)[0] + "?per_page=50",
|
||||
@@ -243,7 +247,7 @@ def install(progress=None, force: bool = False) -> Path:
|
||||
extract(archive, tmp)
|
||||
tmp.replace(dest)
|
||||
archive.unlink(missing_ok=True)
|
||||
_state_file().write_text(json.dumps({"version": version, "path": str(dest)}, indent=2))
|
||||
write_atomic(_state_file(), json.dumps({"version": version, "path": str(dest)}, indent=2))
|
||||
return dest
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Portable toolchain: FramePort manages its own Java, overport CLI and apksigner (no admin rights, no PATH edits).
|
||||
"""Portable toolchain: FramePort manages its own Java, OVRPort CLI and apksigner (no admin rights, no PATH edits).
|
||||
|
||||
Everything is resolved dynamically (latest versions from their official sources, checksummed) and installed into
|
||||
<user data>/tools/. Overrides for development: FRAMEPORT_JAVA, FRAMEPORT_OVERPORT_JAR, FRAMEPORT_APKSIGNER_JAR.
|
||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from ..core import cache
|
||||
from ..core.paths import tools_dir
|
||||
from ..core.paths import tools_dir, write_atomic
|
||||
|
||||
ADOPTIUM = "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture={arch}&image_type=jre&os={os}"
|
||||
# overport's CLI: maintained in the downstream fork Android-XR-Bridge/OVRPort since 1.2.5 (stable channel = plain
|
||||
@@ -60,7 +60,7 @@ def _state() -> dict:
|
||||
|
||||
|
||||
def _save_state(state: dict) -> None:
|
||||
_state_file().write_text(json.dumps(state, indent=2))
|
||||
write_atomic(_state_file(), json.dumps(state, indent=2))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Java
|
||||
@@ -128,7 +128,7 @@ def _overport_asset(rel: dict) -> dict | None:
|
||||
|
||||
|
||||
def latest_overport() -> tuple[str, str] | None:
|
||||
"""(version, download url) of the newest overport CLI, from the first source that has one."""
|
||||
"""(version, download url) of the newest OVRPort CLI, from the first source that has one."""
|
||||
for cache_name, url in OVERPORT_RELEASES:
|
||||
rel = cache.cached_json(cache_name, url, max_age=6 * 3600)
|
||||
if not rel or rel.get("draft") or rel.get("prerelease"):
|
||||
@@ -142,7 +142,7 @@ def latest_overport() -> tuple[str, str] | None:
|
||||
def install_overport(progress=None) -> ToolStatus:
|
||||
latest = latest_overport()
|
||||
if not latest:
|
||||
raise RuntimeError("could not reach GitHub to find the latest overport release")
|
||||
raise RuntimeError("could not reach GitHub to find the latest OVRPort release")
|
||||
version, url = latest
|
||||
dest = tools_dir() / f"overport-{version}"
|
||||
jar = next(dest.glob("*.jar"), None) if dest.exists() else None
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""The desktop app (Flet). The display language is loaded here, before any view module builds its texts, so
|
||||
module-level texts (navigation, status names) are translated too; changing it takes effect after a restart."""
|
||||
|
||||
|
||||
def _load_language() -> None:
|
||||
try:
|
||||
from ..core import library
|
||||
from ..i18n import set_language
|
||||
|
||||
set_language(library.setting("ui.language"))
|
||||
except Exception: # noqa: BLE001 - English if anything is off
|
||||
pass
|
||||
|
||||
|
||||
_load_language()
|
||||
@@ -1,25 +1,27 @@
|
||||
"""Reusable GUI building blocks (all styling comes from ui.theme)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ..i18n import tr
|
||||
from . import theme as T
|
||||
from .help import HELP
|
||||
|
||||
STATUS_STYLE = {
|
||||
"works": ("Works", T.OK),
|
||||
"issues": ("Works with issues", T.WARN),
|
||||
"unsupported": ("Can't run", T.ERROR),
|
||||
"unknown": ("Untested", T.TEXT_3),
|
||||
"works": (tr("Works"), T.OK),
|
||||
"issues": (tr("Works with issues"), T.WARN),
|
||||
"unsupported": (tr("Can't run"), T.ERROR),
|
||||
"unknown": (tr("Untested"), T.TEXT_3),
|
||||
}
|
||||
# per-game state on a target: label, icon, color
|
||||
INSTALL_STYLE = {
|
||||
"installed": ("On Frame", ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK),
|
||||
"outdated": ("Update ready", ft.Icons.UPDATE_ROUNDED, T.WARN),
|
||||
"missing": ("Not installed", ft.Icons.CLOUD_OFF_ROUNDED, T.TEXT_3),
|
||||
"on_pc": ("On this PC", ft.Icons.COMPUTER_ROUNDED, T.PC),
|
||||
"installed": (tr("On Frame"), ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK),
|
||||
"outdated": (tr("Update ready"), ft.Icons.UPDATE_ROUNDED, T.WARN),
|
||||
"missing": (tr("Not installed"), ft.Icons.CLOUD_OFF_ROUNDED, T.TEXT_3),
|
||||
"on_pc": (tr("On this PC"), ft.Icons.COMPUTER_ROUNDED, T.PC),
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +41,21 @@ def settings_diff(game: dict, frame_info: dict | None) -> tuple[list[str], list[
|
||||
return (added, removed) if added or removed else None
|
||||
|
||||
|
||||
def platform(g: dict) -> tuple[str, str, str]:
|
||||
"""(short label, long label, help key) for a game's platform: what it was made for, not how it runs here."""
|
||||
if g.get("kind") == "rift":
|
||||
extra = ((g.get("analysis") or {}).get("extra") or {})
|
||||
oculus = extra.get("needs_revive") or extra.get("libovr")
|
||||
return tr("PC VR"), tr("PC VR · Oculus") if oculus else tr("PC VR"), "platform_pcvr"
|
||||
kind = (((g.get("analysis") or {}).get("extra") or {}).get("vr_kind")) or "quest"
|
||||
if kind == "quest":
|
||||
return tr("Quest"), tr("Meta Quest"), "platform_quest"
|
||||
if kind == "none":
|
||||
return tr("Android"), tr("Android app"), "platform_android"
|
||||
return tr("Android VR"), {"openxr": "Android VR · OpenXR", "pico_sdk": "Pico", "wave": "HTC Vive (Wave)",
|
||||
"android_xr": "Android XR"}.get(kind, "Android VR"), "platform_android_vr"
|
||||
|
||||
|
||||
def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
"""None when no Frame is connected; else 'installed', 'outdated' (the local build, or the patch settings, differ
|
||||
from what the Frame runs) or 'missing'."""
|
||||
@@ -58,8 +75,8 @@ def pc_outdated(g: dict, dep: dict) -> bool:
|
||||
"""A PC install whose launch settings (pcvr patches + their parameters) differ from the game's recipe now."""
|
||||
installed = set((dep.get("recipe") or {}).get("patches") or [])
|
||||
want = set((g.get("recipe") or {}).get("patches") or {})
|
||||
from ..patches.pcvr import game_args
|
||||
from ..core.library import recipe_from_dict
|
||||
from ..patches.pcvr import game_args
|
||||
|
||||
try:
|
||||
args = game_args(recipe_from_dict(g["recipe"]))
|
||||
@@ -68,6 +85,21 @@ def pc_outdated(g: dict, dep: dict) -> bool:
|
||||
return installed != want or list(dep.get("game_args") or []) != args
|
||||
|
||||
|
||||
def one_choice():
|
||||
"""Wraps the handlers of one dialog (its buttons and its on_dismiss) so that only the first of them acts: a second
|
||||
click while the dialog closes (easy in a slow window) ran the rest of a question chain twice — duplicate dialogs
|
||||
whose buttons no longer worked. Use: pick = one_choice(); on_click=pick(ok), on_dismiss=pick(closed)."""
|
||||
state = {"done": False}
|
||||
|
||||
def wrap(fn):
|
||||
def handler(e=None):
|
||||
if not state["done"]:
|
||||
state["done"] = True
|
||||
return fn(e)
|
||||
return handler
|
||||
return wrap
|
||||
|
||||
|
||||
def update(*controls: ft.Control) -> None:
|
||||
"""Update controls that may not be on the page yet (Flet raises for those)."""
|
||||
for c in controls:
|
||||
@@ -87,10 +119,24 @@ def h2(text: str) -> ft.Text:
|
||||
return ft.Text(text, size=T.T_H2, weight=ft.FontWeight.W_600, color=T.TEXT)
|
||||
|
||||
|
||||
def switch(label: str, **kw) -> ft.Switch:
|
||||
"""A labelled switch in the theme's text colour (Material's default label is dark text)."""
|
||||
def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
|
||||
"""A labelled switch in the theme's text colour (Material's default label is dark text). The label wraps (a
|
||||
Switch's own label is cut off when it's longer than the window); clicking it toggles the switch too.
|
||||
wrap=False keeps the built-in label, for a short label in a Row next to other controls."""
|
||||
kw.setdefault("active_color", T.ACCENT)
|
||||
return ft.Switch(label=label, label_text_style=ft.TextStyle(color=T.TEXT, size=T.px(14)), **kw)
|
||||
if not wrap or not label:
|
||||
return ft.Switch(label=label or None, label_text_style=ft.TextStyle(color=T.TEXT, size=T.px(14)), **kw)
|
||||
sw = ft.Switch(**kw)
|
||||
|
||||
def click_label(e):
|
||||
if sw.disabled:
|
||||
return
|
||||
sw.value = not sw.value
|
||||
update(sw)
|
||||
if sw.on_change:
|
||||
sw.on_change(SimpleNamespace(control=sw))
|
||||
return ft.Row([sw, ft.Container(ft.Text(label, color=T.TEXT, size=T.px(14)), expand=True, on_click=click_label)],
|
||||
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER, data="switch")
|
||||
|
||||
|
||||
def body(text: str, color: str = T.TEXT_2, size: int | None = None, **kw) -> ft.Text:
|
||||
@@ -136,16 +182,24 @@ def tip(text: str) -> ft.Tooltip:
|
||||
padding=ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)), prefer_below=True)
|
||||
|
||||
|
||||
def help_icon(key_or_text: str, size: int = 15) -> ft.Container:
|
||||
"""A small "?" that explains a non-obvious term on hover: a key of ui.help.HELP, or the text itself."""
|
||||
return ft.Container(ft.Icon(ft.Icons.HELP_OUTLINE_ROUNDED, size=size, color=T.TEXT_3),
|
||||
tooltip=tip(HELP.get(key_or_text, key_or_text)), padding=T.px(2), border_radius=size)
|
||||
def help_icon(key_or_text: str, size: int = 18) -> ft.Container:
|
||||
"""A "?" that explains a non-obvious term on hover: a key of ui.help.HELP, or the text itself."""
|
||||
size = T.px(size)
|
||||
return ft.Container(ft.Icon(ft.Icons.HELP_OUTLINE_ROUNDED, size=size, color=T.TEXT_2),
|
||||
tooltip=tip(HELP.get(key_or_text, key_or_text)), padding=T.px(3), border_radius=size)
|
||||
|
||||
|
||||
def with_help(control: ft.Control, key: str | None) -> ft.Control:
|
||||
"""`control` followed by a help icon (or just `control` when there's no key)."""
|
||||
if not key:
|
||||
return control
|
||||
if getattr(control, "data", None) == "switch":
|
||||
# the "?" goes right after the label (which takes only the width it needs and still wraps when long)
|
||||
label = control.controls[1]
|
||||
label.expand, label.expand_loose = True, True
|
||||
control.controls.append(help_icon(key))
|
||||
control.expand = True
|
||||
return control
|
||||
return ft.Row([control, help_icon(key)], spacing=T.px(4), tight=True,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
|
||||
@@ -158,12 +212,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)))
|
||||
|
||||
@@ -219,8 +275,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)),
|
||||
@@ -257,7 +313,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,
|
||||
@@ -278,7 +335,7 @@ def confirm(page: ft.Page, heading: str, text: str, ok_label: str, on_ok: Callab
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(heading, weight=ft.FontWeight.W_600), content=ft.Container(body(text), width=T.px(420)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[ghost("Cancel", on_click=lambda e: page.pop_dialog()),
|
||||
actions=[ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
ft.FilledButton(ok_label, on_click=go, style=ft.ButtonStyle(
|
||||
shape=_shape(), bgcolor=T.ERROR if danger else T.ACCENT, color=T.ON_ACCENT))]))
|
||||
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
"""Help texts for the GUI's non-obvious terms and actions, shown as "?" hints and tooltips (components.help_icon).
|
||||
One place for the wording, so the same term is explained the same way everywhere. Plain text, no Flet."""
|
||||
One place for the wording, so the same term is explained the same way everywhere. Plain text, no Flet.
|
||||
The English texts are the translation keys (frameport.i18n): HELP[key] returns the translated text."""
|
||||
from __future__ import annotations
|
||||
|
||||
HELP: dict[str, str] = {
|
||||
from ..i18n import tr
|
||||
|
||||
|
||||
class _Translated(dict):
|
||||
"""A dict of English texts whose lookups return the translation. An unknown key is an error (a typo would
|
||||
otherwise show the key itself as the help text)."""
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
return tr(super().__getitem__(key))
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
return self[key] if key in self else default
|
||||
|
||||
|
||||
HELP: dict[str, str] = _Translated({
|
||||
# ---- game status / library
|
||||
"status": "How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" come from recipes tested "
|
||||
"on a real Frame. \"Untested\" means FramePort suggested a recipe from the game's engine and VR API that "
|
||||
@@ -13,8 +28,12 @@ HELP: dict[str, str] = {
|
||||
"game (or right-click → Change executable) to pick it.",
|
||||
"platform_quest": "A Meta Quest game (APK). It's rebuilt for the Frame and runs in Lepton, Valve's Android "
|
||||
"container.",
|
||||
"platform_pcvr": "An Oculus Rift PC VR game. It runs through Revive (Oculus → OpenXR), either on this PC or on the "
|
||||
"Frame with Proton.",
|
||||
"platform_pcvr": "A Windows PC VR game (OpenXR, SteamVR or Oculus). It runs on this PC with SteamVR, or on the "
|
||||
"Frame with Proton. Oculus-only games also need Revive (Oculus → OpenXR).",
|
||||
"platform_android": "An ordinary Android app or game (no VR). It's installed unchanged and runs in Lepton, Valve's "
|
||||
"Android container.",
|
||||
"platform_android_vr": "A VR app made for another Android headset. OpenXR apps are translated like Quest games; "
|
||||
"apps built on a headset maker's own SDK (Pico, HTC Wave) can't run on the Frame.",
|
||||
"select": "Pick several games and install them in one go. FramePort asks every question first, then works through "
|
||||
"the queue in the background.",
|
||||
"tags": "Your own tags (filled) can be anything you like. Outlined tags are added automatically from the engine, "
|
||||
@@ -23,14 +42,13 @@ HELP: dict[str, str] = {
|
||||
"recipe": "A recipe is the list of patches FramePort applies to a game before installing it. Known-good recipes "
|
||||
"were tested on a Frame; the others are suggested from the game's engine and VR API. Hover a patch "
|
||||
"for why it's there.",
|
||||
"standard_fixes": "Fixes every game of this kind gets (on by default). Customize lists them all.",
|
||||
"as_is": "Turn this on if your copy is already patched. Quest: the APK is installed unchanged instead of being "
|
||||
"patched again. PC VR: your folder is never modified either way; only the Frame's copy gets launch fixes, "
|
||||
"and Revive still runs when the game uses the Oculus PC SDK (a repack's own Oculus runtime, such as "
|
||||
"Virtual Desktop's launcher, doesn't exist on the Frame). Turn Revive off under Customize for SteamVR "
|
||||
"builds.",
|
||||
"alt_build": "Some games need a second build with different overport patches (for some Unreal games: one with "
|
||||
"standard_fixes": "Patches every game of this kind gets (on by default). Customize lists them all.",
|
||||
"as_is": "Turn this on if your copy is already patched: an APK is installed unchanged instead of being patched "
|
||||
"again. PC VR games are never modified on this PC; the Frame's copy only gets launch fixes.",
|
||||
"alt_build": "Some games need a second build with different OVRPort patches (for some Unreal games: one with "
|
||||
"Unreal's ForceQuit removed). Turn this on to install that build instead.",
|
||||
"patch_details": "Each patch is described in plain words. Turn this on to also see its id, exactly what it "
|
||||
"changes in the game (useful when reporting a problem) and its parameters.",
|
||||
"show_all": "Patches that can't matter for this game (wrong engine or API) are hidden. Show them to force one on "
|
||||
"anyway.",
|
||||
"where": "Steam Frame: installed on the headset with an entry in its Steam library. This PC: a Steam shortcut on "
|
||||
@@ -39,10 +57,6 @@ HELP: dict[str, str] = {
|
||||
"VR session and render frames. It can't check what you'd see: tracking only runs with the "
|
||||
"headset on.",
|
||||
"uninstall": "Removes the game from the Frame. Saves are kept, so a reinstall picks up where you left off.",
|
||||
"known_good": "Saves the current patches as a known-good recipe in your own catalog, so they're used again when "
|
||||
"the game is re-added. Do this after it worked in the headset.",
|
||||
"rebuild": "Applies the patches and runs the checks, without sending anything to the Frame.",
|
||||
"reset_recipe": "Discards your changes to the patches and goes back to the recipe FramePort suggests.",
|
||||
"experimental": "Not verified on many games yet: try it if the game doesn't work without it.",
|
||||
"abis": "The CPU types the game ships code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games "
|
||||
"can't run on it.",
|
||||
@@ -51,14 +65,15 @@ HELP: dict[str, str] = {
|
||||
"recipe_source": "Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by "
|
||||
"FramePort) or user (changed by you).",
|
||||
# ---- patch categories
|
||||
"cat_pcvr": "How a Rift game starts: Revive translates the Oculus API to OpenXR, Proton runs the Windows program on "
|
||||
"the Frame.",
|
||||
"cat_frame": "Fixes for what the Frame's runtime does differently from a Quest (graphics formats, missing OpenXR "
|
||||
"cat_pcvr": "How a PC VR game starts: Proton runs the Windows program on the Frame; for Oculus games, Revive "
|
||||
"translates the Oculus API to OpenXR.",
|
||||
"cat_frame": "Patches for what the Frame's runtime does differently from a Quest (graphics formats, missing OpenXR "
|
||||
"extensions, Lepton's launcher requirements).",
|
||||
"cat_overport": "overport converts Quest games from Meta's own VR APIs to standard OpenXR. These are its "
|
||||
"cat_overport": "OVRPort converts Quest games from Meta's own VR APIs to standard OpenXR. These are its "
|
||||
"optional patches.",
|
||||
"cat_adapter": "Settings of FrameBridge, the adapter FramePort adds to Quest games. They can also be changed "
|
||||
"after installing, from the Frame page.",
|
||||
"cat_adapter": "Game settings: picture, controllers, menus and more, used by the adapter FramePort adds to Quest "
|
||||
"games. Change them here or from the Frame page; an installed game uses them the next time it "
|
||||
"starts.",
|
||||
"cat_device": "Files and environment variables placed next to the game on the Frame.",
|
||||
# ---- Frame
|
||||
"developer_mode": "Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on "
|
||||
@@ -69,14 +84,15 @@ HELP: dict[str, str] = {
|
||||
"that it connects with the key.",
|
||||
"lepton": "Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. "
|
||||
"It needs Developer Mode.",
|
||||
"proton": "Proton is Valve's Windows compatibility layer. On the Frame it runs Oculus Rift games (with Revive). "
|
||||
"proton": "Proton is Valve's Windows compatibility layer. On the Frame it runs PC VR games. "
|
||||
"Its ARM64 build isn't installed by default; FramePort can install it (Steam restarts once).",
|
||||
"openxr": "The VR runtime games talk to. On the Frame that's SteamVR.",
|
||||
"kernel_keys": "Every game start on the Frame used to leak one kernel key, and after about 200 launches every game "
|
||||
"fails to start. FramePort switches that leak off, but keys already used only come back after a "
|
||||
"restart of the Frame.",
|
||||
"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 "
|
||||
@@ -85,17 +101,17 @@ HELP: dict[str, str] = {
|
||||
"installed), one after another.",
|
||||
"rescan": "Scans the folders you added games from again and adds games that appeared since. Games already in the "
|
||||
"library keep their recipe, tags and art; unchanged ones aren't analyzed again.",
|
||||
"send_files": "Copy videos, documents, mods or saves from this PC to the Frame for Quest games. Videos, "
|
||||
"Downloads and Documents are shared by every game (/sdcard/Movies, /sdcard/Download, "
|
||||
"/sdcard/Documents); a game's own storage is in its menu. Apps find the files by browsing folders. "
|
||||
"For video players use the game's \"Add videos & files\": the files also appear in the player's "
|
||||
"own folder (e.g. 4XVR's Internal Storage), without a second copy.",
|
||||
"files": "Browse and manage files on the Frame (Files tab): upload from this PC, download, rename, delete. "
|
||||
"Videos, Downloads and Documents are shared by every Quest game (/sdcard/Movies, /sdcard/Download, "
|
||||
"/sdcard/Documents); each installed game also has its own storage (its /sdcard). Apps find files "
|
||||
"by browsing folders. Video players that list only their own folder (e.g. 4XVR's Internal "
|
||||
"Storage = 4XPlayer): upload into that folder in the game's storage.",
|
||||
"free_space": "Deletes the rollback copies kept from each game's previous install and leftover uploads. The "
|
||||
"games and saves stay.",
|
||||
"adapter_settings": "Change FrameBridge settings of an installed game (render scale, controller mapping and so "
|
||||
"on). They're read when the game starts.",
|
||||
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so Rift games "
|
||||
"can run on the Frame.",
|
||||
"adapter_settings": "Sharpness, refresh rate, controllers, menus and more for this game. Changes are used the "
|
||||
"next time it starts.",
|
||||
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR "
|
||||
"games can run on the Frame.",
|
||||
# ---- settings
|
||||
"data_folder": "FramePort's tools, library, artwork and the signing keys of your rebuilt games. Back it up: an "
|
||||
"update must be signed with the same key, or the game (and its saves) has to be reinstalled.",
|
||||
@@ -106,9 +122,6 @@ HELP: dict[str, str] = {
|
||||
"steamvr_pc": "PC VR games on this PC run through SteamVR, so a headset connected to this PC works with them.",
|
||||
"frame_agent": "The small helper program FramePort runs on the Frame (installs, launch tests, Steam entries). It "
|
||||
"comes with this app and is replaced on the Frame automatically whenever it differs.",
|
||||
"transfer_link": "Uploads use the fastest link to the Frame: a USB cable, or the Frame's own Wi-Fi hotspot when "
|
||||
"this PC is connected to it, else your home network (several times slower: both go through "
|
||||
"the router).",
|
||||
# ---- sharing / diagnostics
|
||||
"share_config": "Sends this game's recipe (the patches and settings it uses) to FramePort's GitHub as a prefilled "
|
||||
"issue, so it can become a built-in recipe for everyone. You review and submit it in the browser; "
|
||||
@@ -116,8 +129,4 @@ HELP: dict[str, str] = {
|
||||
"diag_bundle": "A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's "
|
||||
"runtime details — enough to debug without the game files. IP addresses, host and user names, "
|
||||
"home folders and Steam ids are replaced by placeholders. Attach it to a GitHub issue.",
|
||||
}
|
||||
|
||||
|
||||
def text(key: str) -> str:
|
||||
return HELP[key]
|
||||
})
|
||||
@@ -7,11 +7,15 @@ import itertools
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from ..core import applog
|
||||
from ..core.events import Cancelled, Event, Reporter
|
||||
from ..errors import explain, is_connection_error
|
||||
from ..i18n import tr
|
||||
|
||||
MAX_FRAME_RETRIES = 5 # a job that keeps losing the Frame fails after this many tries
|
||||
|
||||
_ids = itertools.count(1)
|
||||
|
||||
@@ -19,7 +23,7 @@ _ids = itertools.count(1)
|
||||
@dataclass
|
||||
class Job:
|
||||
title: str
|
||||
run: Callable[["Job"], object] # does the work with job.reporter; may return a result
|
||||
run: Callable[[Job], object] # does the work with job.reporter; may return a result
|
||||
package: str | None = None
|
||||
kind: str = "task" # install | test | build | scan | tool | task
|
||||
id: int = field(default_factory=lambda: next(_ids))
|
||||
@@ -41,6 +45,8 @@ class Job:
|
||||
log_path: str | None = None # full launch log saved by a launch test
|
||||
summary: dict | None = None # launch-test summary (install/test jobs)
|
||||
to: str = "frame"
|
||||
needs_frame: bool = False # uses the Frame: a lost connection pauses the queue instead of failing the job
|
||||
retries: int = 0 # times this job went back to the queue after losing the Frame
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
@@ -86,6 +92,7 @@ class JobManager:
|
||||
self._listeners: list[Callable[[Job | None], None]] = [on_change] if on_change else []
|
||||
self._throttle = throttle
|
||||
self._last = 0.0
|
||||
self.paused: str | None = None # why the queue waits (e.g. "frame": the Frame dropped off the network)
|
||||
self._worker = threading.Thread(target=self._loop, daemon=True, name="frameport-jobs")
|
||||
self._worker.start()
|
||||
|
||||
@@ -102,6 +109,21 @@ class JobManager:
|
||||
self._notify(job, force=True)
|
||||
return job
|
||||
|
||||
def pause(self, reason: str) -> None:
|
||||
with self._cv:
|
||||
self.paused = reason
|
||||
self._notify(None, force=True)
|
||||
|
||||
def resume(self) -> None:
|
||||
with self._cv:
|
||||
self.paused = None
|
||||
self._cv.notify()
|
||||
self._notify(None, force=True)
|
||||
|
||||
def has_frame_work(self) -> bool:
|
||||
"""A job that uses the Frame is running or waiting (keep the Frame awake meanwhile)."""
|
||||
return any(j.active and j.needs_frame for j in self.jobs)
|
||||
|
||||
def current(self) -> Job | None:
|
||||
return next((j for j in self.jobs if j.state == "running"), None)
|
||||
|
||||
@@ -112,7 +134,13 @@ class JobManager:
|
||||
return next((j for j in self.jobs if j.active and j.package == package), None)
|
||||
|
||||
def recent(self, limit: int = 20) -> list[Job]:
|
||||
return sorted(self.jobs, key=lambda j: (not j.active, -j.created))[:limit]
|
||||
"""The running job, then every waiting job in queue order, then up to `limit` finished jobs (newest first).
|
||||
Newest-first for all of them put the running job below the whole queue (or past the limit)."""
|
||||
jobs = list(self.jobs)
|
||||
running = [j for j in jobs if j.state == "running"]
|
||||
queued = sorted((j for j in jobs if j.state == "queued"), key=lambda j: j.created)
|
||||
done = sorted((j for j in jobs if not j.active), key=lambda j: -(j.finished or j.created))
|
||||
return running + queued + done[:limit]
|
||||
|
||||
def clear_finished(self) -> None:
|
||||
with self._cv:
|
||||
@@ -123,6 +151,8 @@ class JobManager:
|
||||
with self._cv:
|
||||
if job.state == "queued":
|
||||
self._queue.remove(job)
|
||||
if not self._queue:
|
||||
self.paused = None # nothing left to wait for
|
||||
job.state, job.finished = "cancelled", time.time()
|
||||
job.version += 1
|
||||
job.cancel()
|
||||
@@ -137,6 +167,20 @@ class JobManager:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------ internals
|
||||
def _wait_for_frame(self, job: Job, exc: BaseException) -> None:
|
||||
"""The Frame went away mid-job: put the job back at the front and pause until it's reachable again (the
|
||||
app resumes the queue after reconnecting). Uploads continue where they stopped."""
|
||||
applog.log.info("job %r: lost the Frame (%s: %s); queue paused", job.title, type(exc).__name__, exc)
|
||||
with self._cv:
|
||||
job.retries += 1
|
||||
job.state, job.started, job.fraction, job.speed = "queued", None, None, ""
|
||||
job.stage = tr("Waiting for the Frame")
|
||||
job.log.append(f"lost the Frame ({type(exc).__name__}: {exc}); waiting to continue")
|
||||
job.version += 1
|
||||
self._queue.insert(0, job)
|
||||
self.paused = "frame"
|
||||
self._notify(job, force=True)
|
||||
|
||||
def _event(self, job: Job, ev: Event) -> None:
|
||||
job._on_event(ev)
|
||||
self._notify(job, force=ev.kind in ("stage", "check"))
|
||||
@@ -155,7 +199,7 @@ class JobManager:
|
||||
def _loop(self) -> None:
|
||||
while True:
|
||||
with self._cv:
|
||||
while not self._queue:
|
||||
while not self._queue or self.paused:
|
||||
self._cv.wait()
|
||||
job = self._queue.pop(0)
|
||||
job.state, job.started = "running", time.time()
|
||||
@@ -167,9 +211,14 @@ class JobManager:
|
||||
except Cancelled:
|
||||
job.state = "cancelled"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if job.needs_frame and is_connection_error(exc) and job.retries < MAX_FRAME_RETRIES \
|
||||
and not job.reporter.cancelled.is_set():
|
||||
self._wait_for_frame(job, exc)
|
||||
continue
|
||||
traceback.print_exc()
|
||||
applog.log.exception("job %r failed", job.title)
|
||||
job.state, job.error = "failed", f"{exc}" or type(exc).__name__
|
||||
job.state, job.error = "failed", explain(exc)
|
||||
job.log.append(f"error: {type(exc).__name__}: {exc}") # the exact error stays in the log
|
||||
job.finished = time.time()
|
||||
job.version += 1
|
||||
applog.log.info("job %r (%s, %s): %s", job.title, job.kind, job.package or "-", job.state)
|
||||
|
||||
@@ -102,7 +102,8 @@ def apply(page: ft.Page) -> None:
|
||||
"headline_medium": 28, "headline_small": 24, "title_large": 22, "title_medium": 16, "title_small": 14,
|
||||
"body_large": 16, "body_medium": 14, "body_small": 12, "label_large": 14, "label_medium": 12,
|
||||
"label_small": 11}
|
||||
text_theme = ft.TextTheme(**{k: ft.TextStyle(size=px(v)) for k, v in sizes.items()})
|
||||
# with a colour: without one Flutter draws these styles dark (e.g. radio/checkbox labels, dialog titles)
|
||||
text_theme = ft.TextTheme(**{k: ft.TextStyle(size=px(v), color=TEXT) for k, v in sizes.items()})
|
||||
theme = ft.Theme(color_scheme=scheme, visual_density=ft.VisualDensity.COMFORTABLE, use_material3=True,
|
||||
text_theme=text_theme,
|
||||
divider_theme=ft.DividerTheme(color=BORDER, thickness=1, space=1),
|
||||
|
||||
@@ -12,6 +12,8 @@ import flet as ft
|
||||
|
||||
from .. import __version__, updates
|
||||
from ..core import applog, library
|
||||
from ..errors import explain
|
||||
from ..i18n import tr
|
||||
from . import components as C
|
||||
from . import theme as T
|
||||
|
||||
@@ -22,7 +24,7 @@ FIRST_CHECK_DELAY = 10 # seconds after start: don't compete with the start-up w
|
||||
|
||||
|
||||
class Updater:
|
||||
def __init__(self, app: "FramePortApp"):
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
self.found: updates.Update | None = None
|
||||
self.restart = None # callable: what to do once the job queue is idle (install + quit)
|
||||
@@ -30,10 +32,10 @@ class Updater:
|
||||
self._version = C.meta("", color=T.ON_ACCENT)
|
||||
self.card = ft.Container(
|
||||
ft.Row([ft.Icon(ft.Icons.SYSTEM_UPDATE_ROUNDED, size=T.px(18), color=T.ON_ACCENT),
|
||||
ft.Column([C.body("Update available", T.ON_ACCENT, weight=ft.FontWeight.W_600), self._version],
|
||||
ft.Column([C.body(tr("Update available"), T.ON_ACCENT, weight=ft.FontWeight.W_600), self._version],
|
||||
spacing=0, expand=True)], spacing=T.S2),
|
||||
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.ACCENT, ink=True, visible=False,
|
||||
tooltip="A new FramePort version is ready to install", on_click=lambda e: self.show_dialog())
|
||||
tooltip=tr("A new FramePort version is ready to install"), on_click=lambda e: self.show_dialog())
|
||||
|
||||
# ---------------------------------------------------------------- checking
|
||||
def start(self) -> None:
|
||||
@@ -57,13 +59,13 @@ class Updater:
|
||||
if up:
|
||||
self.app.page.run_thread(self.show_dialog)
|
||||
else:
|
||||
self.app.toast(f"FramePort {__version__} is the latest version")
|
||||
self.app.toast(tr("FramePort {version} is the latest version").format(version=__version__))
|
||||
self.app.run_bg(work)
|
||||
|
||||
def _set(self, up: updates.Update | None) -> None:
|
||||
self.found = up
|
||||
self.card.visible = bool(up)
|
||||
self._version.value = f"FramePort {up.version} · click to install" if up else ""
|
||||
self._version.value = tr("FramePort {version} · click to install").format(version=up.version) if up else ""
|
||||
C.update(self.card)
|
||||
self.app.refresh_view()
|
||||
if up and library.setting("update.auto_install", False) and updates.install_kind() == "bundle":
|
||||
@@ -76,8 +78,9 @@ class Updater:
|
||||
self.preparing = True
|
||||
try:
|
||||
updates.prepare(up)
|
||||
self.app.toast(f"FramePort {up.version} is downloaded and installs the next time FramePort starts",
|
||||
action="Restart now", on_action=lambda e: self.install())
|
||||
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)
|
||||
finally:
|
||||
@@ -94,7 +97,7 @@ class Updater:
|
||||
"settings and Frame connection stay as they are.",
|
||||
"source": "Updates this source checkout (git pull + uv sync), then restarts FramePort.",
|
||||
"wheel": "Reinstalls FramePort from the release, then restarts it."}[kind]
|
||||
notes = up.notes.strip() or "No release notes."
|
||||
notes = up.notes.strip() or tr("No release notes.")
|
||||
|
||||
def later(e):
|
||||
updates.skip(up.version)
|
||||
@@ -105,18 +108,21 @@ class Updater:
|
||||
page.pop_dialog()
|
||||
self.install()
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(f"FramePort {up.version} is available", 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(f"You have {__version__}. {how}", T.TEXT_2),
|
||||
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("Skip this version", on_click=later),
|
||||
C.ghost("Release page", ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
|
||||
C.primary("Update and restart", ft.Icons.SYSTEM_UPDATE_ROUNDED, go)]))
|
||||
actions=[C.ghost(tr("Skip this version"), on_click=later),
|
||||
C.ghost(tr("Release page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
|
||||
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED, go)]))
|
||||
|
||||
# ---------------------------------------------------------------- installing
|
||||
def install(self) -> None:
|
||||
@@ -128,15 +134,16 @@ class Updater:
|
||||
if kind == "bundle":
|
||||
target = updates.bundle_root()
|
||||
if not target or not updates.can_replace(target):
|
||||
self.app.toast(f"FramePort can't replace itself in {target or 'this folder'} (no permission): opening "
|
||||
"the release page to download it", 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("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("Updating works in the desktop app")
|
||||
self.app.toast(tr("Updating works in the desktop app"))
|
||||
return
|
||||
|
||||
def run(job):
|
||||
@@ -148,17 +155,24 @@ class Updater:
|
||||
else:
|
||||
for cmd in updates.upgrade_commands(up, kind):
|
||||
rep.stage(" ".join(cmd[:3]))
|
||||
out = subprocess.run(cmd, capture_output=True, text=True)
|
||||
try:
|
||||
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
|
||||
for line in (out.stdout + out.stderr).splitlines()[-20:]:
|
||||
rep.log(line)
|
||||
if out.returncode:
|
||||
raise RuntimeError(f"{cmd[0]} failed ({out.returncode})")
|
||||
raise RuntimeError(tr("{value} failed ({returncode})")
|
||||
.format(value=cmd[0], returncode=out.returncode))
|
||||
self.restart = self._restart_process
|
||||
rep.stage("Restarting")
|
||||
return f"FramePort {up.version} is ready: restarting"
|
||||
self.app.submit(f"Update FramePort to {up.version}", run, None, kind="app-update", open_panel=True)
|
||||
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)
|
||||
if self.app.jobs.current() and self.app.jobs.current().kind != "app-update":
|
||||
self.app.toast("FramePort updates and restarts after the current job")
|
||||
self.app.toast(tr("FramePort updates and restarts after the current job"))
|
||||
|
||||
def on_jobs_changed(self) -> None:
|
||||
"""Called when a job finishes: once nothing else runs, install and restart."""
|
||||
@@ -170,7 +184,7 @@ class Updater:
|
||||
try:
|
||||
updates.apply(app)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.app.toast(f"Update failed: {exc}", error=True)
|
||||
self.app.toast(tr("Update failed: {exc}").format(exc=explain(exc)), error=True)
|
||||
return
|
||||
self._quit()
|
||||
|
||||
@@ -198,20 +212,22 @@ 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))
|
||||
|
||||
|
||||
def library_bar(app: "FramePortApp") -> ft.Control | None:
|
||||
def library_bar(app: FramePortApp) -> ft.Control | None:
|
||||
"""The Library's "update available" bar (None when there's nothing to show)."""
|
||||
up = app.updater.found if getattr(app, "updater", None) else None
|
||||
if not up:
|
||||
return None
|
||||
return C.callout(ft.Row([
|
||||
C.body(f"FramePort {up.version} is available (you have {__version__}).", T.TEXT, expand=True),
|
||||
C.primary("Update now", ft.Icons.SYSTEM_UPDATE_ROUNDED, lambda e: app.updater.install()),
|
||||
C.ghost("What's new", on_click=lambda e: app.updater.show_dialog()),
|
||||
C.ghost("Later", on_click=lambda e: (updates.skip(up.version), app.updater._set(None))),
|
||||
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))),
|
||||
], spacing=T.S3), "info", ft.Icons.SYSTEM_UPDATE_ROUNDED)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Activity panel (right side): running, queued and finished background jobs with steps, checks and the log."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ...i18n import tr
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..jobs import Job
|
||||
@@ -15,11 +17,11 @@ if TYPE_CHECKING:
|
||||
|
||||
CHECK_ICON = {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.CANCEL_ROUNDED, T.ERROR),
|
||||
None: (ft.Icons.WARNING_AMBER_ROUNDED, T.WARN)}
|
||||
STATE_STYLE = {"queued": (ft.Icons.SCHEDULE_ROUNDED, T.TEXT_3, "Waiting"),
|
||||
"running": (ft.Icons.SYNC_ROUNDED, T.ACCENT, "Working"),
|
||||
"done": (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK, "Done"),
|
||||
"failed": (ft.Icons.ERROR_ROUNDED, T.ERROR, "Failed"),
|
||||
"cancelled": (ft.Icons.DO_NOT_DISTURB_ON_OUTLINED, T.TEXT_3, "Cancelled")}
|
||||
STATE_STYLE = {"queued": (ft.Icons.SCHEDULE_ROUNDED, T.TEXT_3, tr("Waiting")),
|
||||
"running": (ft.Icons.SYNC_ROUNDED, T.ACCENT, tr("Working")),
|
||||
"done": (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK, tr("Done")),
|
||||
"failed": (ft.Icons.ERROR_ROUNDED, T.ERROR, tr("Failed")),
|
||||
"cancelled": (ft.Icons.DO_NOT_DISTURB_ON_OUTLINED, T.TEXT_3, tr("Cancelled"))}
|
||||
|
||||
|
||||
def _dur(job: Job) -> str:
|
||||
@@ -35,22 +37,30 @@ def _progress_text(job: Job) -> str:
|
||||
return " · ".join(x for x in (job.speed, job.message) if x)
|
||||
|
||||
class ActivityPanel:
|
||||
def __init__(self, app: "FramePortApp"):
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
self.expanded: set[int] = set()
|
||||
self.logs: set[int] = set()
|
||||
self._tiles: dict[int, tuple[tuple, ft.Control]] = {} # job id -> (state key, tile)
|
||||
self._live: dict[int, tuple] = {} # running job id -> (progress bar, message, stage text)
|
||||
self._live: dict[int, tuple] = {} # running job id -> (progress bar, message, stage text, log text or None)
|
||||
# job id -> (log text, its scrolling column): kept across tile rebuilds so reading the log doesn't jump
|
||||
self._logviews: dict[int, tuple[ft.Text, ft.Column]] = {}
|
||||
self._lock = threading.Lock() # refresh() runs from job threads and the UI thread
|
||||
# the running job: always visible above the list, scrolling on its own when it's taller than its share
|
||||
self.pinned = ft.Column(spacing=T.S3, scroll=ft.ScrollMode.AUTO)
|
||||
self.pinned_box = ft.Container(self.pinned, expand=3, visible=False)
|
||||
self.list = ft.Column(spacing=T.S3, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
self.root = ft.Container(
|
||||
ft.Column([
|
||||
ft.Row([C.h2("Activity"), ft.Container(expand=True),
|
||||
C.ghost("Clear finished", on_click=lambda e: (app.jobs.clear_finished(), self.refresh())),
|
||||
C.icon_btn(ft.Icons.CLOSE_ROUNDED, "Close", lambda e: app.show_activity(False))]),
|
||||
self.list,
|
||||
ft.Row([C.h2(tr("Activity")), ft.Container(expand=True),
|
||||
C.ghost(tr("Clear finished"), on_click=lambda e: (app.jobs.clear_finished(), self.refresh())),
|
||||
C.icon_btn(ft.Icons.CLOSE_ROUNDED, tr("Close"), lambda e: app.show_activity(False))]),
|
||||
self.pinned_box,
|
||||
ft.Container(self.list, expand=2),
|
||||
], 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
|
||||
@@ -65,36 +75,90 @@ class ActivityPanel:
|
||||
def refresh(self, update: bool = True):
|
||||
if not self.open:
|
||||
return
|
||||
jobs = self.app.jobs.recent(30)
|
||||
tiles = []
|
||||
with self._lock:
|
||||
self._refresh(update)
|
||||
|
||||
def _refresh(self, update: bool):
|
||||
jobs = self.app.jobs.recent(20)
|
||||
tiles, pinned = [], []
|
||||
queued = [j for j in jobs if j.state == "queued"]
|
||||
rebuilt, live = False, []
|
||||
for j in jobs:
|
||||
running = j.state == "running"
|
||||
# a running job's tile is rebuilt only when its structure changes (new stage/check, log shown); progress
|
||||
# updates go to its live controls — rebuilding on every tick swallowed clicks on Cancel
|
||||
key = (j.state, len(j.stages), len(j.checks), j.id in self.expanded, j.id in self.logs,
|
||||
len(j.log) if j.id in self.logs else 0) if running else \
|
||||
# and new log lines go to its live controls — rebuilding on every tick swallowed clicks on Cancel
|
||||
key = (j.state, len(j.stages), len(j.checks), j.id in self.expanded, j.id in self.logs) if running else \
|
||||
(j.version, j.id in self.expanded, j.id in self.logs)
|
||||
cached = self._tiles.get(j.id)
|
||||
if not cached or cached[0] != key: # unchanged tiles are reused, so text selections survive refreshes
|
||||
cached = (key, self.tile(j))
|
||||
self._tiles[j.id] = cached
|
||||
rebuilt = True
|
||||
elif running and j.id in self._live:
|
||||
bar, msg, meta = self._live[j.id]
|
||||
bar, msg, meta, log = self._live[j.id]
|
||||
bar.value = j.fraction
|
||||
msg.value = _progress_text(j)
|
||||
meta.value = j.stage or ""
|
||||
tiles.append(cached[1])
|
||||
live += [bar, msg, meta]
|
||||
if log is not None and len(j.log) != getattr(log, "_lines", -1): # only when it grew
|
||||
log.value = "\n".join(j.log[-400:])
|
||||
log._lines = len(j.log)
|
||||
live.append(log)
|
||||
if running:
|
||||
pinned.append(cached[1])
|
||||
else:
|
||||
if j is (queued[0] if queued else None):
|
||||
tiles.append(self._queue_header(queued))
|
||||
tiles.append(cached[1])
|
||||
layout = (tuple(j.id for j in jobs), self.app.jobs.paused)
|
||||
if not rebuilt and layout == getattr(self, "_layout", None):
|
||||
# nothing but progress changed (several times a second during an upload): update only those controls;
|
||||
# comparing the whole panel each time (long queue, open log) made the app sluggish
|
||||
if update and live:
|
||||
C.update(*live)
|
||||
return
|
||||
self._layout = layout
|
||||
self._tiles = {j.id: self._tiles[j.id] for j in jobs}
|
||||
self.list.controls = tiles or [
|
||||
ft.Container(C.body("Nothing running. Installs, launch tests and downloads show up here.",
|
||||
text_align=ft.TextAlign.CENTER), padding=T.S6, alignment=ft.Alignment.CENTER)]
|
||||
self._logviews = {jid: v for jid, v in self._logviews.items() if jid in self._tiles}
|
||||
if self.app.jobs.paused == "frame":
|
||||
pinned.insert(0, self._paused_notice())
|
||||
self.pinned.controls = pinned
|
||||
self.pinned_box.visible = bool(pinned)
|
||||
if not tiles and not pinned:
|
||||
tiles = [ft.Container(C.body(tr("Nothing running. Installs, launch tests and downloads show up here."),
|
||||
text_align=ft.TextAlign.CENTER), padding=T.S6, alignment=ft.Alignment.CENTER)]
|
||||
self.list.controls = tiles
|
||||
if update:
|
||||
C.update(self.root)
|
||||
|
||||
def _paused_notice(self) -> ft.Control:
|
||||
return C.callout(ft.Column([
|
||||
C.body(tr("Waiting for your Frame"), T.TEXT, weight=ft.FontWeight.W_600),
|
||||
C.body(tr("It can't be reached (asleep, turned off or out of Wi-Fi). Wake it or turn it on: FramePort "
|
||||
"continues by itself, and uploads pick up where they stopped."), T.TEXT_2),
|
||||
C.secondary(tr("Try now"), ft.Icons.REFRESH_ROUNDED,
|
||||
lambda e: self.app.run_bg(lambda: self.app.retry_frame())),
|
||||
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START), "warn", ft.Icons.WIFI_OFF_ROUNDED)
|
||||
|
||||
def _queue_header(self, queued: list[Job]) -> ft.Control:
|
||||
def cancel_all(e):
|
||||
for j in list(queued):
|
||||
self.app.jobs.cancel(j)
|
||||
self.refresh()
|
||||
return ft.Row([C.meta(tr("Waiting ({n})").format(n=len(queued)), T.TEXT_2), ft.Container(expand=True),
|
||||
C.ghost(tr("Cancel all"), ft.Icons.CLOSE_ROUNDED, cancel_all)])
|
||||
|
||||
def tile(self, job: Job) -> ft.Control:
|
||||
if job.state == "queued": # compact: a long queue must not push everything else out of view
|
||||
return ft.Container(ft.Row([
|
||||
ft.Icon(ft.Icons.SCHEDULE_ROUNDED, color=T.TEXT_3, size=T.px(16)),
|
||||
C.body(job.title, T.TEXT_2, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, expand=True),
|
||||
C.icon_btn(ft.Icons.CLOSE_ROUNDED, tr("Cancel"), lambda e: self.app.jobs.cancel(job)),
|
||||
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
padding=ft.Padding(T.S3, T.px(2), T.px(4), T.px(2)), border_radius=T.RADIUS_SM, bgcolor=T.SURFACE)
|
||||
icon, color, word = STATE_STYLE[job.state]
|
||||
if job.state == "done" and (job.summary or {}).get("verdict") == "fail":
|
||||
icon, color, word = ft.Icons.WARNING_AMBER_ROUNDED, T.WARN, "Installed · launch test failed"
|
||||
icon, color, word = ft.Icons.WARNING_AMBER_ROUNDED, T.WARN, tr("Installed · launch test failed")
|
||||
running = job.state == "running"
|
||||
expanded = running or job.id in self.expanded
|
||||
meta = C.meta(job.stage if running and job.stage else
|
||||
@@ -106,14 +170,15 @@ 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, "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:
|
||||
bar = C.progress_bar(job.fraction)
|
||||
msg = C.meta(_progress_text(job), max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||
parts += [bar, msg]
|
||||
self._live[job.id] = (bar, msg, meta)
|
||||
self._live[job.id] = (bar, msg, meta, None)
|
||||
if expanded:
|
||||
if job.stages:
|
||||
parts.append(ft.Column([
|
||||
@@ -134,21 +199,42 @@ class ActivityPanel:
|
||||
parts.append(ft.Row(extra, spacing=T.S2, wrap=True))
|
||||
show_log = job.id in self.logs
|
||||
parts.append(ft.Row([
|
||||
C.ghost("Hide log" if show_log else "Show log", ft.Icons.TERMINAL_ROUNDED,
|
||||
C.ghost(tr("Hide log") if show_log else tr("Show log"), ft.Icons.TERMINAL_ROUNDED,
|
||||
lambda e: self._toggle(self.logs, job.id)),
|
||||
C.ghost("Copy log", ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(self.job_text(job))),
|
||||
*([C.ghost("Full launch log", ft.Icons.DESCRIPTION_ROUNDED,
|
||||
C.ghost(tr("Copy log"), ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(self.job_text(job))),
|
||||
*([C.ghost(tr("Full launch log"), ft.Icons.DESCRIPTION_ROUNDED,
|
||||
lambda e: self.app.show_log_file(job.log_path, job.title))] if job.log_path else []),
|
||||
], spacing=0, wrap=True))
|
||||
if show_log:
|
||||
parts.append(ft.Container(ft.Column([ft.Text(
|
||||
"\n".join(job.log[-400:]), size=T.px(11), font_family="monospace", color=T.TEXT_2, selectable=True)],
|
||||
scroll=ft.ScrollMode.AUTO, auto_scroll=job.state == "running"),
|
||||
bgcolor=T.BG, border_radius=T.RADIUS_SM, padding=T.S2, height=T.px(240)))
|
||||
log, col = self._log_view(job)
|
||||
log.value = "\n".join(job.log[-400:])
|
||||
if running:
|
||||
self._live[job.id] = (*self._live[job.id][:3], log)
|
||||
else:
|
||||
col.auto_scroll = False
|
||||
parts.append(ft.Container(col, bgcolor=T.BG, border_radius=T.RADIUS_SM, padding=T.S2,
|
||||
height=T.px(240)))
|
||||
return ft.Container(ft.Column(parts, spacing=T.S2), bgcolor=T.SURFACE, border_radius=T.RADIUS,
|
||||
border=ft.Border.all(1, T.ACCENT if running else T.BORDER), padding=T.S3,
|
||||
on_click=None if running else lambda e: self._toggle(self.expanded, job.id))
|
||||
|
||||
def _log_view(self, job: Job) -> tuple[ft.Text, ft.Column]:
|
||||
"""The job's log view, the same one for the job's whole life: rebuilding it on every new stage or check reset
|
||||
its scroll position. It follows new lines only while it's scrolled to the bottom."""
|
||||
view = self._logviews.get(job.id)
|
||||
if view is None:
|
||||
log = ft.Text("", size=T.px(11), font_family="monospace", color=T.TEXT_2, selectable=True)
|
||||
col = ft.Column([log], scroll=ft.ScrollMode.AUTO, auto_scroll=True, scroll_interval=100)
|
||||
|
||||
def follow(e, col=col):
|
||||
at_bottom = e.pixels >= (e.max_scroll_extent or 0) - 24
|
||||
if col.auto_scroll != at_bottom:
|
||||
col.auto_scroll = at_bottom # scrolled up to read: stop following; back at the bottom: follow
|
||||
C.update(col)
|
||||
col.on_scroll = follow
|
||||
view = self._logviews[job.id] = (log, col)
|
||||
return view
|
||||
|
||||
@staticmethod
|
||||
def job_text(job: Job) -> str:
|
||||
return job.text()
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Game settings (FrameBridge adapter settings) for non-technical users: only the settings that can matter for this
|
||||
game, in plain words, with switches, sliders and choices instead of number fields; advanced ones on request.
|
||||
Saved into the game's recipe (kept for reinstalls) and, when the game is on the Frame, applied there right away."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ... import pipeline
|
||||
from ...core import library
|
||||
from ...i18n import tr
|
||||
from ...patches import base
|
||||
from ...patches.settings import BASE_SETTINGS, GROUPS, SETTINGS, UI, adapter_settings
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
SPECS = {key: (kind, default) for key, kind, default, _, _ in SETTINGS}
|
||||
|
||||
|
||||
def default(key: str):
|
||||
return BASE_SETTINGS.get(key, SPECS[key][1])
|
||||
|
||||
|
||||
def _num(key: str, value):
|
||||
kind = SPECS[key][0]
|
||||
try:
|
||||
return float(value) if kind == "float" else int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return default(key)
|
||||
|
||||
|
||||
def relevant(analysis, key: str, value) -> bool:
|
||||
"""Shown when the setting can matter for this game (or is already changed from its default)."""
|
||||
if analysis is None:
|
||||
return True
|
||||
return base.get(f"adapter.{key}").applies(analysis) or value != default(key)
|
||||
|
||||
|
||||
def recipe_values(g: dict) -> dict:
|
||||
vals = {key: default(key) for key in SPECS}
|
||||
vals.update({k: _num(k, v) for k, v in adapter_settings(library.recipe_from_dict(g["recipe"]).patches).items()
|
||||
if k in SPECS})
|
||||
return vals
|
||||
|
||||
|
||||
def save_to_recipe(package: str, values: dict) -> None:
|
||||
"""Adapter patches = the settings that differ from their defaults (BASE_SETTINGS are written anyway)."""
|
||||
g = library.game(package)
|
||||
r = library.recipe_from_dict(g["recipe"])
|
||||
for key in SPECS:
|
||||
pid = f"adapter.{key}"
|
||||
v = _num(key, values.get(key, default(key)))
|
||||
if v == default(key):
|
||||
r.patches.pop(pid, None)
|
||||
r.reasons.pop(pid, None)
|
||||
else:
|
||||
if (r.patches.get(pid) or {}).get("value") != v:
|
||||
r.reasons[pid] = tr("Set by you.")
|
||||
r.patches[pid] = {"value": v}
|
||||
r.source = "user"
|
||||
pipeline.set_recipe(package, r)
|
||||
|
||||
|
||||
def show_adapter_dialog(app: FramePortApp, package: str) -> None:
|
||||
g = library.game(package)
|
||||
if g is None: # installed on the Frame but not in this library: read the values there first
|
||||
target = app.target
|
||||
if target is None:
|
||||
app.toast(tr("Connect your Frame first"), error=True)
|
||||
return
|
||||
|
||||
def load():
|
||||
cur = target.set_settings(package, {})["settings"] # no changes: returns the current values
|
||||
vals = {key: default(key) for key in SPECS}
|
||||
vals.update({k: _num(k, v) for k, v in cur.items() if k in SPECS})
|
||||
_open(app, package, None, vals)
|
||||
app.run_bg(load)
|
||||
return
|
||||
_open(app, package, g, recipe_values(g))
|
||||
|
||||
|
||||
def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None:
|
||||
analysis = library.analysis_from_dict(g["analysis"]) if g and g.get("analysis") else None
|
||||
suggested = recipe_values({"recipe": g["suggested"]}) if g and g.get("suggested") else \
|
||||
{key: default(key) for key in SPECS}
|
||||
values = dict(values)
|
||||
controls: dict[str, ft.Control] = {} # key -> the input control
|
||||
rows: dict[str, ft.Container] = {} # key -> its row (visibility)
|
||||
readouts: dict[str, ft.Text] = {} # slider value labels
|
||||
advanced = {"on": any(UI[k]["level"] == "advanced" and values[k] != default(k) for k in SPECS)}
|
||||
|
||||
def set_value(key, v):
|
||||
values[key] = _num(key, v)
|
||||
if key in readouts:
|
||||
readouts[key].value = UI[key]["control"][4].format(values[key])
|
||||
C.update(readouts[key])
|
||||
if any(UI[k].get("depends") == key for k in SPECS):
|
||||
apply_visibility()
|
||||
|
||||
def control_for(key):
|
||||
kind, *spec = UI[key]["control"]
|
||||
v = values[key]
|
||||
if kind == "switch":
|
||||
return ft.Switch(value=bool(v), active_color=T.ACCENT,
|
||||
on_change=lambda e: set_value(key, 1 if e.control.value else 0))
|
||||
if kind == "slider":
|
||||
lo, hi, step, fmt = spec
|
||||
readouts[key] = C.body(fmt.format(v), T.TEXT, weight=ft.FontWeight.W_600)
|
||||
return ft.Row([ft.Slider(min=lo, max=hi, divisions=round((hi - lo) / step), value=min(max(v, lo), hi),
|
||||
active_color=T.ACCENT, width=T.px(200),
|
||||
on_change=lambda e: set_value(key, round(e.control.value / step) * step)),
|
||||
ft.Container(readouts[key], width=T.px(64))], spacing=0, tight=True)
|
||||
choices = list(spec[0])
|
||||
if v not in [c for c, _ in choices]:
|
||||
choices.append((v, str(v))) # a value set elsewhere (CLI, catalog) stays selectable
|
||||
return ft.Dropdown(value=str(v), width=T.px(220), dense=True, border_color=T.BORDER,
|
||||
options=[ft.DropdownOption(key=str(c), text=tr(label)) for c, label in choices],
|
||||
on_select=lambda e: set_value(key, e.control.value))
|
||||
|
||||
def row(key):
|
||||
ui = UI[key]
|
||||
label = tr(ui["label"]) if ui.get("label") else tr(base.get(f"adapter.{key}").title)
|
||||
help_text = tr(ui["help"]) if ui.get("help") else tr(base.get(f"adapter.{key}").description)
|
||||
controls[key] = control_for(key)
|
||||
rows[key] = ft.Container(ft.Row([
|
||||
ft.Column([C.body(label, T.TEXT, weight=ft.FontWeight.W_500), C.meta(help_text, T.TEXT_2)],
|
||||
spacing=T.px(2), expand=True),
|
||||
controls[key]], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
padding=ft.Padding(0, T.px(8), 0, T.px(8)))
|
||||
return rows[key]
|
||||
|
||||
sections: list[tuple[str, ft.Control, list[str]]] = []
|
||||
body = []
|
||||
for group, title in GROUPS:
|
||||
keys = [k for k in UI if UI[k]["group"] == group and relevant(analysis, k, values[k])]
|
||||
if not keys:
|
||||
continue
|
||||
heading = C.body(tr(title), T.TEXT_2, weight=ft.FontWeight.W_600)
|
||||
section = ft.Column([heading, *[row(k) for k in keys]], spacing=0)
|
||||
sections.append((group, section, keys))
|
||||
body.append(section)
|
||||
|
||||
def apply_visibility():
|
||||
for _group, section, keys in sections:
|
||||
shown = 0
|
||||
for k in keys:
|
||||
parent = UI[k].get("depends")
|
||||
vis = (advanced["on"] or UI[k]["level"] == "common") and (not parent or bool(values.get(parent)))
|
||||
rows[k].visible = vis
|
||||
shown += vis
|
||||
section.visible = bool(shown)
|
||||
C.update(*[s for _, s, _ in sections])
|
||||
|
||||
def toggle_advanced(e):
|
||||
advanced["on"] = bool(e.control.value)
|
||||
apply_visibility()
|
||||
|
||||
def reset(e):
|
||||
for k in list(values):
|
||||
values[k] = suggested.get(k, default(k))
|
||||
app.page.pop_dialog()
|
||||
_open(app, package, g, values)
|
||||
|
||||
before = dict(values)
|
||||
|
||||
def save(e):
|
||||
app.page.pop_dialog()
|
||||
if g is not None:
|
||||
save_to_recipe(package, values)
|
||||
reinstall = any(UI[k].get("reinstall") and values[k] != before[k] for k in SPECS)
|
||||
installed = app.target is not None and (g is None or C.install_state(g, app.frame_info) in
|
||||
("installed", "outdated"))
|
||||
if not installed:
|
||||
app.toast(tr("Saved. The settings are used when the game is installed."))
|
||||
app.refresh_view()
|
||||
return
|
||||
target = app.target
|
||||
|
||||
def push():
|
||||
target.set_settings(package, {k: values[k] for k in SPECS})
|
||||
app.toast(tr("Saved. Restart the game to use the new settings.") +
|
||||
(tr(" Reinstall it for the controller models.") if reinstall else ""))
|
||||
app.refresh_view()
|
||||
app.run_bg(push)
|
||||
|
||||
show_adv = any(UI[k]["level"] == "advanced" for _, _, keys in sections for k in keys)
|
||||
content = ft.Column([
|
||||
C.body(tr("Changes are used the next time the game starts. Not sure? Leave a setting as it is."), T.TEXT_2),
|
||||
*body,
|
||||
*([C.switch(tr("Show advanced settings"), value=advanced["on"], on_change=toggle_advanced)]
|
||||
if show_adv else []),
|
||||
], spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True)
|
||||
title = tr("Game settings · {title}").format(title=app._title(package) if g else package)
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(title, weight=ft.FontWeight.W_600), bgcolor=T.SURFACE_2,
|
||||
shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
content=ft.Container(content, width=T.px(640), height=T.px(560)),
|
||||
actions=[C.ghost(tr("Reset to recommended"), ft.Icons.RESTART_ALT_ROUNDED, reset),
|
||||
C.ghost(tr("Cancel"), on_click=lambda e: app.page.pop_dialog()),
|
||||
C.primary(tr("Save"), on_click=save)],
|
||||
actions_alignment=ft.MainAxisAlignment.END))
|
||||
apply_visibility()
|
||||
@@ -8,6 +8,7 @@ import flet as ft
|
||||
from ... import pipeline
|
||||
from ...artwork import sources, thumbs
|
||||
from ...core import library
|
||||
from ...i18n import tr
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
@@ -15,12 +16,13 @@ if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
|
||||
def show_art_dialog(app: "FramePortApp", package: str) -> None:
|
||||
def show_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
g = library.game(package)
|
||||
term = ft.TextField(value=g.get("title") or package, dense=True, expand=True, border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT, focused_border_color=T.ACCENT,
|
||||
content_padding=ft.Padding(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):
|
||||
@@ -28,21 +30,25 @@ def show_art_dialog(app: "FramePortApp", package: str) -> None:
|
||||
|
||||
def work():
|
||||
if not sources.apply_choice(package, choice):
|
||||
app.toast(f"No artwork could be downloaded from that {choice['source']} result; the current artwork "
|
||||
"stays. Try another one.", error=True)
|
||||
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())
|
||||
installed = C.install_state(library.game(package), app.frame_info) in ("installed", "outdated")
|
||||
# until it's sent, the game page reminds that the Frame's Steam library shows the old art
|
||||
library.upsert_game(package, art_source=choice["source"].lower(), **({"steam_art_stale": True}
|
||||
if installed else {}))
|
||||
app.refresh_view()
|
||||
if C.install_state(library.game(package), app.frame_info) in ("installed", "outdated"):
|
||||
app.toast(f"Artwork updated for {g.get('title')}. The Frame's Steam library still shows the old "
|
||||
"art.", action="Update on Frame", on_action=lambda e: app.update_steam_art(package))
|
||||
if installed:
|
||||
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(f"Artwork updated for {g.get('title')}")
|
||||
app.toast(tr("Artwork updated for {get}").format(get=g.get('title')))
|
||||
app.run_bg(work)
|
||||
|
||||
def search(e=None):
|
||||
status.value = "Searching…"
|
||||
status.value = tr("Searching…")
|
||||
results.controls = []
|
||||
C.update(status, results)
|
||||
|
||||
@@ -54,7 +60,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 = f"{len(found)} results" if found else "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)
|
||||
|
||||
@@ -64,24 +71,27 @@ def show_art_dialog(app: "FramePortApp", package: str) -> None:
|
||||
def work():
|
||||
found = pipeline.fetch_art(package)
|
||||
thumbs.prewarm(package) # regenerate the thumbnails the cards/hero use, or the view shows the old art
|
||||
app.refresh_view()
|
||||
src = found.get("source")
|
||||
if src and src != "none" and C.install_state(library.game(package), app.frame_info) in ("installed",
|
||||
"outdated"):
|
||||
app.toast(f"Artwork updated ({src}). The Frame's Steam library still shows the old art.",
|
||||
action="Update on Frame", on_action=lambda e: app.update_steam_art(package))
|
||||
installed = C.install_state(library.game(package), app.frame_info) in ("installed", "outdated")
|
||||
if src and src != "none" and installed:
|
||||
library.upsert_game(package, steam_art_stale=True)
|
||||
app.refresh_view()
|
||||
if src and src != "none" and installed:
|
||||
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(f"Artwork: {src or 'none found'}")
|
||||
app.toast(tr("Artwork: {value}").format(value=src or 'none found'))
|
||||
app.run_bg(work)
|
||||
|
||||
term.on_submit = search
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(f"Artwork for {g.get('title')}", weight=ft.FontWeight.W_600),
|
||||
title=ft.Text(tr("Artwork for {get}").format(get=g.get('title')), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([
|
||||
ft.Row([term, C.secondary("Search", ft.Icons.SEARCH_ROUNDED, search)], spacing=T.S2),
|
||||
ft.Row([term, C.secondary(tr("Search"), ft.Icons.SEARCH_ROUNDED, search)], spacing=T.S2),
|
||||
status, results,
|
||||
], spacing=T.S3, tight=True), width=T.px(660)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost("Find automatically", ft.Icons.AUTO_AWESOME_ROUNDED, auto),
|
||||
C.ghost("Close", on_click=lambda e: app.page.pop_dialog())]))
|
||||
actions=[C.ghost(tr("Find automatically"), ft.Icons.AUTO_AWESOME_ROUNDED, auto),
|
||||
C.ghost(tr("Close"), on_click=lambda e: app.page.pop_dialog())]))
|
||||
search()
|
||||
@@ -1,12 +1,15 @@
|
||||
"""'Which program starts <game>?' — pick the executable of a Rift game when FramePort isn't sure (or to change it)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ... import pipeline
|
||||
from ...core import library
|
||||
from ...errors import explain
|
||||
from ...i18n import fmt_size, tr
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
@@ -15,10 +18,10 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
def _size(n: int) -> str:
|
||||
return f"{n / 2**20:.0f} MB" if n < 2**30 else f"{n / 2**30:.1f} GB"
|
||||
return fmt_size(n)
|
||||
|
||||
|
||||
def show_exe_dialog(app: "FramePortApp", package: str, remaining: int = 0,
|
||||
def show_exe_dialog(app: FramePortApp, package: str, remaining: int = 0,
|
||||
on_done: Callable[[], None] | None = None) -> None:
|
||||
g = library.game(package)
|
||||
extra = (g.get("analysis") or {}).get("extra") or {}
|
||||
@@ -31,14 +34,14 @@ def show_exe_dialog(app: "FramePortApp", package: str, remaining: int = 0,
|
||||
T.WARN if r in ("Steam build", "Unreal launcher (starts the real game build)") else T.TEXT_2)
|
||||
for r in c.get("reasons", [])]
|
||||
if i == 0:
|
||||
tags.insert(0, C.pill("Best guess", T.ACCENT, ft.Icons.AUTO_AWESOME_ROUNDED))
|
||||
tags.insert(0, C.pill(tr("Best guess"), T.ACCENT, ft.Icons.AUTO_AWESOME_ROUNDED))
|
||||
folder, _, name = c["path"].rpartition("/")
|
||||
group.content.controls.append(ft.Container(ft.Row([
|
||||
ft.Radio(value=c["path"], active_color=T.ACCENT),
|
||||
ft.Column([
|
||||
ft.Row([C.body(name, T.TEXT, weight=ft.FontWeight.W_600), C.meta(_size(c.get("size") or 0))],
|
||||
spacing=T.S2),
|
||||
C.meta(folder or "(top folder)", selectable=True),
|
||||
C.meta(folder or tr("(top folder)"), selectable=True),
|
||||
ft.Row(tags, spacing=T.px(6), wrap=True) if tags else ft.Container(),
|
||||
], spacing=T.px(4), expand=True),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.START), padding=ft.Padding(T.S2, T.S2, T.S3, T.S2),
|
||||
@@ -52,9 +55,9 @@ def show_exe_dialog(app: "FramePortApp", package: str, remaining: int = 0,
|
||||
def work():
|
||||
try:
|
||||
pipeline.set_exe(package, choice)
|
||||
app.toast(f"{g.get('title')} starts with {choice.rsplit('/', 1)[-1]}")
|
||||
app.toast(tr("{get} starts with {value}").format(get=g.get('title'), value=choice.rsplit('/', 1)[-1]))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
app.toast(f"Couldn't use {choice}: {exc}", error=True)
|
||||
app.toast(tr("Couldn't use {choice}: {exc}").format(choice=choice, exc=explain(exc)), error=True)
|
||||
app.refresh_view()
|
||||
if on_done:
|
||||
on_done()
|
||||
@@ -65,13 +68,16 @@ def show_exe_dialog(app: "FramePortApp", package: str, remaining: int = 0,
|
||||
if on_done:
|
||||
on_done()
|
||||
|
||||
title = f"Which program starts {g.get('title')}?"
|
||||
lead = ("FramePort found more than one program that could start this game. Pick the one you'd double-click to "
|
||||
"play it. Oculus builds usually work better with Revive than Steam builds.")
|
||||
pick = C.one_choice()
|
||||
title = tr("Which program starts {get}?").format(get=g.get('title'))
|
||||
lead = (tr("FramePort found more than one program that could start this game. Pick the one you'd double-click to "
|
||||
"play it. Oculus builds usually work better with Revive than Steam builds."))
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Row([ft.Text(title, weight=ft.FontWeight.W_600, expand=True)]
|
||||
+ ([C.meta(f"{remaining} more after this")] if remaining else [])),
|
||||
+ ([C.meta(tr("{remaining} more after this").format(remaining=remaining))] if remaining else [])),
|
||||
content=ft.Container(ft.Column([C.body(lead), group], spacing=T.S4, scroll=ft.ScrollMode.AUTO, tight=True),
|
||||
width=T.px(620), height=min(120 + 96 * len(cands), 520)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost("Decide later", on_click=later), C.primary("Use this program", on_click=use)]))
|
||||
modal=True, on_dismiss=pick(lambda e: on_done() if on_done else None), # closed (Esc) = decide later
|
||||
actions=[C.ghost(tr("Decide later"), on_click=pick(later)),
|
||||
C.primary(tr("Use this program"), on_click=pick(use))]))
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Files: browse and manage files on the Steam Frame (instead of a separate file-transfer app).
|
||||
|
||||
Locations are the folders Quest games see (Videos / Downloads / Documents, shared by every game as /sdcard/Movies,
|
||||
/sdcard/Download, /sdcard/Documents), each installed game's own storage, and the Frame's home folder. Everything runs
|
||||
over the app's SSH connection (SFTP); uploads and downloads are jobs in the Activity panel. Persistent like the
|
||||
Library: the chosen location and folder survive switching tabs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ...errors import explain
|
||||
from ...i18n import tr, tr_n
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from .files_dialog import human
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
SHARED_ICONS = {"videos": ft.Icons.MOVIE_OUTLINED, "downloads": ft.Icons.DOWNLOAD_OUTLINED,
|
||||
"documents": ft.Icons.DESCRIPTION_OUTLINED}
|
||||
VIDEO_EXT = {".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v", ".ts"}
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
|
||||
|
||||
|
||||
def dropzone_available() -> bool:
|
||||
"""Drag-and-drop from the file manager needs flet-dropzone's Flutter code, which only a `flet build` app has (not
|
||||
`flet run`/the desktop client used from source, nor the PyInstaller fallback)."""
|
||||
import sys
|
||||
|
||||
from ... import updates
|
||||
|
||||
if getattr(sys, "frozen", False) or updates.install_kind() != "bundle":
|
||||
return False
|
||||
try:
|
||||
import flet_dropzone # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def file_icon(name: str, is_dir: bool) -> str:
|
||||
if is_dir:
|
||||
return ft.Icons.FOLDER_ROUNDED
|
||||
ext = Path(name).suffix.lower()
|
||||
if ext in VIDEO_EXT:
|
||||
return ft.Icons.MOVIE_OUTLINED
|
||||
if ext in IMAGE_EXT:
|
||||
return ft.Icons.IMAGE_OUTLINED
|
||||
if ext in (".zip", ".7z", ".rar", ".tar", ".gz"):
|
||||
return ft.Icons.FOLDER_ZIP_OUTLINED
|
||||
return ft.Icons.INSERT_DRIVE_FILE_OUTLINED
|
||||
|
||||
|
||||
def crumbs(root: str, path: str, root_label: str) -> list[tuple[str, str]]:
|
||||
"""(label, path) for each level from the location down to `path`."""
|
||||
out = [(root_label, root)]
|
||||
rel = posixpath.relpath(path, root) if path != root else ""
|
||||
cur = root
|
||||
for part in [p for p in rel.split("/") if p and p != "."]:
|
||||
cur = posixpath.join(cur, part)
|
||||
out.append((part, cur))
|
||||
return out
|
||||
|
||||
|
||||
class FilesView:
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
self.loc: dict | None = None # {"id", "label", "path", "android", "shared", "package"}
|
||||
self.path = ""
|
||||
self.entries = []
|
||||
self.hidden = False
|
||||
self.game_locs: dict[str, dict] = {} # package -> resolved location (agent storage_targets, cached)
|
||||
self.shared: list[dict] = []
|
||||
self.locations = ft.Column(spacing=T.px(2), scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
self.crumb_row = ft.Row(spacing=T.px(2), wrap=True)
|
||||
self.where = C.meta("")
|
||||
self.listing = ft.Column(spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
self.status = C.meta("")
|
||||
self.toolbar = ft.Row([
|
||||
C.primary(tr("Upload files"), ft.Icons.UPLOAD_FILE_ROUNDED, self.upload_files),
|
||||
C.secondary(tr("Upload folder"), ft.Icons.DRIVE_FOLDER_UPLOAD_ROUNDED, self.upload_folder),
|
||||
C.ghost(tr("New folder"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, lambda e: self.new_folder()),
|
||||
C.icon_btn(ft.Icons.REFRESH_ROUNDED, tr("Refresh"), lambda e: self.load()),
|
||||
], spacing=T.S2, wrap=True, run_spacing=T.S2)
|
||||
self.hidden_switch = C.switch(tr("Show hidden files"), wrap=False, value=False, on_change=self._toggle_hidden)
|
||||
self.selected: set[str] = set() # paths of checked entries in the current folder
|
||||
self.checks: dict[str, ft.Checkbox] = {}
|
||||
self.select_all = ft.Checkbox(value=False, active_color=T.ACCENT, check_color=T.ON_ACCENT,
|
||||
tooltip=tr("Select all"), on_change=self._toggle_all)
|
||||
self.sel_label = C.body("", T.TEXT, weight=ft.FontWeight.W_500)
|
||||
self.sel_bar = ft.Container(ft.Row([
|
||||
self.sel_label, ft.Container(expand=True),
|
||||
C.secondary(tr("Download"), ft.Icons.DOWNLOAD_ROUNDED, self._download_selected),
|
||||
C.ghost(tr("Delete"), ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self._delete_selected()),
|
||||
C.ghost(tr("Clear"), ft.Icons.CLOSE_ROUNDED, lambda e: self._clear_selection()),
|
||||
], spacing=T.S2), padding=ft.Padding(T.S3, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.ACCENT_SOFT, visible=False)
|
||||
self.drop_hint = ft.Container(
|
||||
ft.Column([ft.Icon(ft.Icons.UPLOAD_ROUNDED, size=T.px(48), color=T.ACCENT), C.h2(tr("Drop to upload")),
|
||||
C.meta("")], horizontal_alignment=ft.CrossAxisAlignment.CENTER, tight=True),
|
||||
left=0, right=0, top=0, bottom=0, alignment=ft.Alignment.CENTER, bgcolor=T.soft("#000000", 0.7),
|
||||
border_radius=T.RADIUS, border=ft.Border.all(2, T.ACCENT), visible=False)
|
||||
self.root = None
|
||||
|
||||
# ---------------------------------------------------------------- building
|
||||
def mount(self, package: str | None = None) -> ft.Control:
|
||||
app = self.app
|
||||
if not (app.target and app.frame_state == "connected"):
|
||||
return ft.Column([
|
||||
app.top_bar(tr("Files"), tr("Videos, documents, mods and saves on your Steam Frame")),
|
||||
C.empty_state(ft.Icons.FOLDER_OFF_OUTLINED, tr("Connect your Frame first"),
|
||||
tr("Files on the Frame can be browsed once FramePort is connected to it."),
|
||||
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")))], expand=True)
|
||||
if self.root is None:
|
||||
self.root = ft.Column([
|
||||
app.top_bar(tr("Files"), tr("Videos, documents, mods and saves on your Steam Frame")),
|
||||
ft.Row([
|
||||
C.card(ft.Column([C.meta(tr("Locations").upper()), self.locations], spacing=T.S2, expand=True),
|
||||
padding=T.S3, width=T.px(260), expand=False),
|
||||
ft.Column([
|
||||
# the folder path on its own line: next to the buttons a narrow window squeezed it
|
||||
self.crumb_row,
|
||||
self.toolbar,
|
||||
ft.Row([self.select_all, ft.Container(self.where, expand=True), self.hidden_switch],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self.sel_bar,
|
||||
self._drop_area(ft.Stack([C.card(self.listing, padding=T.px(4), expand=True),
|
||||
self.drop_hint], expand=True)),
|
||||
self.status,
|
||||
], spacing=T.S2, expand=True),
|
||||
], spacing=T.S4, expand=True, vertical_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||
], spacing=T.S3, expand=True)
|
||||
self.app.run_bg(self._load_locations, package)
|
||||
return self.root
|
||||
|
||||
def _drop_area(self, content: ft.Control) -> ft.Control:
|
||||
"""Files and folders dragged in from the computer's file manager are uploaded to the open folder. Needs the
|
||||
flet-dropzone extension, which only the packaged app contains (flet build); elsewhere: no drop area."""
|
||||
if not dropzone_available():
|
||||
return content
|
||||
import flet_dropzone as ftd
|
||||
|
||||
def entered(e):
|
||||
self.drop_hint.content.controls[2].value = tr("into {value}").format(value=self.where.value or self.path)
|
||||
self.drop_hint.visible = True
|
||||
C.update(self.drop_hint)
|
||||
|
||||
def exited(e):
|
||||
self.drop_hint.visible = False
|
||||
C.update(self.drop_hint)
|
||||
|
||||
def dropped(e):
|
||||
exited(e)
|
||||
paths = [Path(f.path) for f in e.files if f.path and not f.path.startswith("blob:")]
|
||||
if paths:
|
||||
self.upload(paths)
|
||||
return ftd.Dropzone(content=content, expand=True, on_entered=entered, on_exited=exited, on_dropped=dropped)
|
||||
|
||||
def _location_row(self, loc: dict, icon: str, sub: str = "") -> ft.Control:
|
||||
selected = self.loc is not None and self.loc["id"] == loc["id"]
|
||||
return ft.Container(
|
||||
ft.Row([ft.Icon(icon, size=T.px(18), color=T.ACCENT if selected else T.TEXT_2),
|
||||
ft.Column([C.body(loc["label"], T.TEXT if selected else T.TEXT_2, weight=ft.FontWeight.W_500),
|
||||
*([C.meta(sub)] if sub else [])], spacing=0, expand=True)], spacing=T.S2),
|
||||
padding=ft.Padding(T.S2, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM, ink=True,
|
||||
bgcolor=T.ACCENT_SOFT if selected else None, on_click=lambda e, loc_=loc: self.open_location(loc_))
|
||||
|
||||
def _render_locations(self) -> None:
|
||||
rows = [self._location_row(loc_, SHARED_ICONS.get(loc_["id"], ft.Icons.FOLDER_OUTLINED), loc_["android"])
|
||||
for loc_ in self.shared]
|
||||
games = self._installed_games()
|
||||
if games:
|
||||
rows.append(ft.Container(C.meta(tr("Game storage").upper()), padding=ft.Padding(T.S2, T.S3, 0, T.px(2))))
|
||||
for pkg, title in games:
|
||||
loc = self.game_locs.get(pkg) or {"id": f"app:{pkg}", "label": title, "package": pkg}
|
||||
rows.append(self._location_row(loc, ft.Icons.SPORTS_ESPORTS_OUTLINED))
|
||||
rows.append(ft.Container(C.meta(tr("Advanced").upper()), padding=ft.Padding(T.S2, T.S3, 0, T.px(2))))
|
||||
home = {"id": "home", "label": tr("Home folder"), "path": self.app.target.frame.home, "android": "",
|
||||
"shared": False}
|
||||
rows.append(self._location_row(home, ft.Icons.HOME_OUTLINED, tr("everything in ~ (not seen by games)")))
|
||||
self.locations.controls = rows
|
||||
C.update(self.locations)
|
||||
|
||||
def _installed_games(self) -> list[tuple[str, str]]:
|
||||
from ...core import library
|
||||
|
||||
out = []
|
||||
for d in (self.app.frame_info or {}).get("installed", []):
|
||||
if d.get("kind") == "pcvr":
|
||||
continue
|
||||
g = library.game(d["package"]) or {}
|
||||
out.append((d["package"], g.get("title") or d.get("title") or d["package"]))
|
||||
return sorted(out, key=lambda x: x[1].lower())
|
||||
|
||||
# ---------------------------------------------------------------- loading (background threads)
|
||||
def _load_locations(self, package: str | None) -> None:
|
||||
from ...install import files
|
||||
|
||||
frame = self.app.target.frame
|
||||
try:
|
||||
if not self.shared:
|
||||
names = {"videos": tr("Videos"), "downloads": tr("Downloads"), "documents": tr("Documents")}
|
||||
self.shared = [{**t, "label": names.get(t["id"], t["id"]), "package": None}
|
||||
for t in files.storage_targets(frame)]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status.value = tr("Couldn't read the Frame's folders: {exc}").format(exc=explain(exc))
|
||||
C.update(self.status)
|
||||
self._render_locations()
|
||||
if package:
|
||||
self.open_location({"id": f"app:{package}", "package": package,
|
||||
"label": dict(self._installed_games()).get(package, package)})
|
||||
elif self.loc is None and self.shared:
|
||||
self.open_location(self.shared[0])
|
||||
elif self.loc is not None:
|
||||
self.load()
|
||||
|
||||
def open_location(self, loc: dict) -> None:
|
||||
def work():
|
||||
nonlocal loc
|
||||
if loc.get("package") and not loc.get("path"):
|
||||
from ...install import files
|
||||
|
||||
try:
|
||||
t = {x["id"]: x for x in files.storage_targets(self.app.target.frame, loc["package"])}["app"]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.app.toast(tr("Couldn't open the game's storage: {exc}").format(exc=explain(exc)), error=True)
|
||||
return
|
||||
loc = {**loc, "path": t["path"], "android": t["android"], "shared": False}
|
||||
self.game_locs[loc["package"]] = loc
|
||||
self.loc, self.path = loc, loc["path"]
|
||||
self._render_locations()
|
||||
self.load()
|
||||
self.app.run_bg(work)
|
||||
|
||||
def load(self) -> None:
|
||||
if not self.loc:
|
||||
return
|
||||
loc, path = self.loc, self.path
|
||||
self.status.value = tr("Loading…")
|
||||
C.update(self.status)
|
||||
|
||||
def work():
|
||||
from ...install import files
|
||||
|
||||
try:
|
||||
entries = files.list_dir(self.app.target.frame, loc["path"], path, hidden=self.hidden)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status.value = tr("Couldn't list {path}: {exc}").format(path=path, exc=explain(exc))
|
||||
C.update(self.status)
|
||||
return
|
||||
if (self.loc, self.path) != (loc, path):
|
||||
return # the user moved on meanwhile
|
||||
self.entries = entries
|
||||
self.selected &= {e.path for e in entries}
|
||||
self._render_listing()
|
||||
self.app.run_bg(work)
|
||||
|
||||
# ---------------------------------------------------------------- listing
|
||||
def _render_listing(self) -> None:
|
||||
loc = self.loc
|
||||
self.crumb_row.controls = []
|
||||
for i, (label, p) in enumerate(crumbs(loc["path"], self.path, loc["label"])):
|
||||
if i:
|
||||
self.crumb_row.controls.append(ft.Icon(ft.Icons.CHEVRON_RIGHT_ROUNDED, size=T.px(16), color=T.TEXT_3))
|
||||
self.crumb_row.controls.append(ft.TextButton(label, on_click=lambda e, p=p: self.cd(p),
|
||||
style=ft.ButtonStyle(color=T.TEXT if p == self.path
|
||||
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)
|
||||
if android else self.path)
|
||||
rows = []
|
||||
self.checks = {}
|
||||
if self.path != loc["path"]:
|
||||
rows.append(self._row(None))
|
||||
rows += [self._row(e) for e in self.entries]
|
||||
if not self.entries:
|
||||
rows.append(ft.Container(C.meta(tr("This folder is empty. Upload files with the buttons above.")),
|
||||
padding=T.S4))
|
||||
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._update_selection(render=False)
|
||||
for c in (self.crumb_row, self.where, self.listing, self.status):
|
||||
C.update(c)
|
||||
|
||||
def _row(self, e) -> ft.Control:
|
||||
if e is None: # ".."
|
||||
return ft.Container(ft.Row([ft.Icon(ft.Icons.ARROW_UPWARD_ROUNDED, size=T.px(20), color=T.TEXT_2),
|
||||
C.body("..", T.TEXT_2)], spacing=T.S3),
|
||||
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]))]
|
||||
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)),
|
||||
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),
|
||||
disabled=self._protected(e))
|
||||
self.checks[e.path] = check
|
||||
return ft.Container(
|
||||
ft.Row([check,
|
||||
ft.Icon(file_icon(e.name, e.is_dir), size=T.px(20), color=T.ACCENT if e.is_dir else T.TEXT_2),
|
||||
ft.Column([C.body(e.name, T.TEXT, weight=ft.FontWeight.W_500, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS), C.meta(info)], spacing=0, expand=True),
|
||||
*actions], spacing=T.S3),
|
||||
padding=ft.Padding(T.S3, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM, ink=e.is_dir,
|
||||
on_click=(lambda ev, p=e.path: self.cd(p)) if e.is_dir else None)
|
||||
|
||||
def cd(self, path: str) -> None:
|
||||
self.path = path
|
||||
self.selected.clear()
|
||||
self.load()
|
||||
|
||||
def _toggle(self, path: str, on: bool) -> None:
|
||||
(self.selected.add if on else self.selected.discard)(path)
|
||||
check = self.checks.get(path)
|
||||
if check is not None and check.value != on:
|
||||
check.value = on
|
||||
C.update(check)
|
||||
self._update_selection()
|
||||
|
||||
def _toggle_all(self, e) -> None:
|
||||
self.selected = {x.path for x in self.entries if self._selectable(x)} if e.control.value else set()
|
||||
self._render_listing()
|
||||
|
||||
def _clear_selection(self) -> None:
|
||||
self.selected.clear()
|
||||
self._render_listing()
|
||||
|
||||
def _selectable(self, e) -> bool:
|
||||
return not self._protected(e)
|
||||
|
||||
def _protected(self, e) -> bool:
|
||||
"""Lepton's links at the top of a game's storage (Movies → ~/Videos, …): renaming/deleting them breaks them."""
|
||||
return e.link and posixpath.dirname(e.path) == posixpath.normpath(self.loc["path"])
|
||||
|
||||
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_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:
|
||||
for c in (self.sel_bar, self.select_all):
|
||||
C.update(c)
|
||||
|
||||
def _chosen(self) -> list:
|
||||
return [x for x in self.entries if x.path in self.selected]
|
||||
|
||||
async def _download_selected(self, e=None):
|
||||
if self.selected:
|
||||
await self.download(self._chosen())
|
||||
|
||||
def _delete_selected(self) -> None:
|
||||
items = [x for x in self._chosen() if not self._protected(x)]
|
||||
if items:
|
||||
self.delete(items)
|
||||
|
||||
def _toggle_hidden(self, e) -> None:
|
||||
self.hidden = bool(e.control.value)
|
||||
self.load()
|
||||
|
||||
# ---------------------------------------------------------------- actions
|
||||
async def upload_files(self, e=None):
|
||||
files = await ft.FilePicker().pick_files(allow_multiple=True)
|
||||
paths = [Path(f.path) for f in files or [] if f.path]
|
||||
if paths:
|
||||
self.upload(paths)
|
||||
|
||||
async def upload_folder(self, e=None):
|
||||
path = await ft.FilePicker().get_directory_path(dialog_title=tr("Folder to upload to the Frame"))
|
||||
if path:
|
||||
self.upload([Path(path)])
|
||||
|
||||
def upload(self, paths: list[Path]) -> None:
|
||||
if not self.loc:
|
||||
return
|
||||
app, loc, dest = self.app, self.loc, self.path
|
||||
|
||||
def run(job):
|
||||
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)
|
||||
|
||||
async def download(self, items: list) -> None:
|
||||
folder = await ft.FilePicker().get_directory_path(dialog_title=tr("Download to which folder on this PC?"))
|
||||
if not folder:
|
||||
return
|
||||
app, root = self.app, self.loc["path"]
|
||||
|
||||
def run(job):
|
||||
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"])
|
||||
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:
|
||||
self._ask_name(tr("New folder"), tr("Folder name"), "", tr("Create"), lambda name: self._fs(
|
||||
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)))
|
||||
|
||||
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),
|
||||
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]))),
|
||||
danger=True)
|
||||
|
||||
def _fs(self, op) -> None:
|
||||
"""A quick file operation in the background, then reload the folder (errors become a toast)."""
|
||||
def work():
|
||||
from ...install import files
|
||||
|
||||
try:
|
||||
op(files, self.app.target.frame)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.app.toast(explain(exc), error=True)
|
||||
self.load()
|
||||
self.app.run_bg(work)
|
||||
|
||||
def _ask_name(self, heading: str, label: str, value: str, ok: str, on_ok) -> None:
|
||||
page = self.app.page
|
||||
field = ft.TextField(label=label, value=value, autofocus=True, width=T.px(380))
|
||||
|
||||
def go(e=None):
|
||||
page.pop_dialog()
|
||||
if (field.value or "").strip():
|
||||
on_ok(field.value.strip())
|
||||
field.on_submit = go
|
||||
page.show_dialog(ft.AlertDialog(title=ft.Text(heading, color=T.TEXT, weight=ft.FontWeight.W_600),
|
||||
bgcolor=T.SURFACE_2, content=field,
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.primary(ok, on_click=go)]))
|
||||