mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-25 19:00:43 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2017194f3 | ||
|
|
7a11706b3a | ||
|
|
76d4390b19 | ||
|
|
2022d9986a | ||
|
|
9ff7edf93b | ||
|
|
a5722b50a0 | ||
|
|
0a13ca72a2 | ||
|
|
c53d754007 | ||
|
|
a1662da17d | ||
|
|
0141bad259 | ||
|
|
6ed95684dc | ||
|
|
a6ca8385a9 | ||
|
|
210410f125 | ||
|
|
7e2502a552 | ||
|
|
a57d89e874 | ||
|
|
aade29982f | ||
|
|
d94cab6048 | ||
|
|
f17fdbf271 | ||
|
|
fb70c09d72 | ||
|
|
69bf2700e1 | ||
|
|
0b28da5521 | ||
|
|
9e9a4c0f6c | ||
|
|
bb3f6b9e7c | ||
|
|
dc7533e4e1 | ||
|
|
3660167bd8 | ||
|
|
6a47fad8f7 | ||
|
|
0f8062f881 | ||
|
|
65369f154c | ||
|
|
aefb5adc07 |
No files matched your search
@@ -40,6 +40,8 @@ jobs:
|
||||
archive: FramePort-macos-arm64.zip
|
||||
- os: ubuntu-22.04 # oldest supported: the bundle needs the build host's GLib/glibc or newer
|
||||
archive: FramePort-linux-x64.tar.gz
|
||||
- os: ubuntu-22.04-arm # Linux ARM64 (same base as x64)
|
||||
archive: FramePort-linux-arm64.tar.gz
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -50,9 +52,9 @@ jobs:
|
||||
- run: uv sync --extra dev
|
||||
- id: flet
|
||||
run: uv run python scripts/package.py
|
||||
continue-on-error: ${{ runner.os == 'macOS' }}
|
||||
- name: macOS fallback (PyInstaller bundle)
|
||||
if: runner.os == 'macOS' && steps.flet.outcome == 'failure'
|
||||
continue-on-error: ${{ runner.os == 'macOS' || runner.arch == 'ARM64' }}
|
||||
- name: macOS / Linux ARM64 fallback (PyInstaller bundle)
|
||||
if: (runner.os == 'macOS' || runner.arch == 'ARM64') && steps.flet.outcome == 'failure'
|
||||
run: uv run --with pyinstaller python scripts/package.py --pyinstaller
|
||||
|
||||
# ---- signing: self-signed certificate on Windows, ad-hoc on macOS ----
|
||||
@@ -95,7 +97,9 @@ jobs:
|
||||
run: ditto -c -k --keepParent "$(find dist -maxdepth 3 -name '*.app' -type d | head -1)" ${{ matrix.archive }}
|
||||
- name: Package (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: tar czf ${{ matrix.archive }} -C dist --transform 's,^linux,FramePort,' linux
|
||||
run: | # flet build -> dist/linux; the PyInstaller fallback -> dist/FramePort (one folder)
|
||||
if [ -d dist/linux ]; then tar czf ${{ matrix.archive }} -C dist --transform 's,^linux,FramePort,' linux
|
||||
else tar czf ${{ matrix.archive }} -C dist FramePort; fi
|
||||
# self-update with this archive: unpack, verify the layout (+ the signer on Windows), run the real swap script
|
||||
- name: Update smoke test
|
||||
run: uv run python scripts/update_smoke.py ${{ matrix.archive }}
|
||||
@@ -137,10 +141,10 @@ jobs:
|
||||
git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}"
|
||||
whatsnew=$(git tag -l --format='%(contents:body)' "$tag")
|
||||
[ -n "$whatsnew" ] || whatsnew=$(git tag -l --format='%(contents:subject)' "$tag")
|
||||
{ echo "FramePort ${tag}: Windows (x64), macOS (Apple Silicon) and Linux (x64)."; echo;
|
||||
{ echo "FramePort ${tag}: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64)."; echo;
|
||||
if [ -n "$whatsnew" ] && [ "$(git cat-file -t "$tag")" = tag ]; then
|
||||
echo "## What's new"; echo; echo "$whatsnew"; echo; fi
|
||||
echo "Already have FramePort? It offers this update itself (Library → **Update now**, or \`frameport update\`)."
|
||||
echo; sed -n '/^## First launch/,$p' docs/INSTALL.md; } > "$notes"
|
||||
echo "Already have FramePort? It offers this update itself (Library → **Update now**)."
|
||||
echo; cat packaging/release-footer.md; } > "$notes" # short: the update dialog shows these notes
|
||||
gh release delete "$tag" --yes 2>/dev/null || true
|
||||
gh release create "$tag" out/* packaging/FramePort-selfsigned.cer --title "FramePort ${tag}" --notes-file "$notes"
|
||||
@@ -225,6 +225,23 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
|
||||
- **Tracking only works with the headset worn.** SSH/headless launches never reach VISIBLE/FOCUSED and poses have
|
||||
flags 0x3. So automated tests prove startup (process alive, instance/session created, frames paced), never visuals.
|
||||
|
||||
**Setup / pairing (2026-10-02, verified on the device):** Developer Mode = `"DevModeEnabled"` in
|
||||
`~/.local/share/Steam/config/config.vdf` (InstallConfigStore/developer), applied by Valve's
|
||||
`/usr/bin/steamos-polkit-helpers/steamos-devkit-mode --enable|--disable` (polkit allow_any: no password; enables/
|
||||
disables sshd, xrdp, steamos-devkit-service, debug port forwards; sentinel `/etc/steamos-devkit-enabled`). Steam
|
||||
re-asserts the config value at every start and rewrites config.vdf on exit → stop Steam, edit, helper, start.
|
||||
**Desktop Mode is a nested Plasma inside steam.service** (own XDG_RUNTIME_DIR `/run/user/1000/nested_plasma` + private
|
||||
D-Bus): `systemd-run --user` from Konsole fails ("Failed to connect to user scope bus") unless XDG_RUNTIME_DIR/
|
||||
DBUS_SESSION_BUS_ADDRESS point at `/run/user/$UID`, and stopping Steam ends the desktop and everything started in
|
||||
it → bootstrap.sh runs the Dev Mode job + Lepton request + `/paired` as a user unit (log `~/.cache/frameport-setup.log`);
|
||||
no sudo/password anywhere (verified: naive Frame → connected, password never set). Valve's devkit pairing (fallback,
|
||||
PC → Frame only): `POST :32000/register` with an **ssh-rsa** key (`connection.devkit_key`, `frame/devkit.py`) works only
|
||||
while Steam is in pairing mode (Settings → Developer → Pair new host), else 403 at once; approve hook waits 30 s.
|
||||
The Frame runs firewalld (22 and 32000 open). PC side: the setup server needs inbound TCP 8765–8767 — WSL's Hyper-V
|
||||
firewall blocks it silently (`DefaultInboundAction Block`): `pairing.ensure_reachable` adds a temporary rule via one
|
||||
UAC prompt, removed when the server stops (flag file in %TEMP%, max 35 min); hints per OS after 45 s without a request
|
||||
(`pairing.firewall_hint`). Flet 1.0 patches aren't thread-safe → `app.serialize_flet_updates()` (a dialog shown while a
|
||||
scan redraw ran never closed: "dropped a patch for unknown control").
|
||||
**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
|
||||
@@ -376,6 +393,39 @@ 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.
|
||||
|
||||
**Companion apps / intents (2026-10-03, tested with Stremio + 4XVR):** a second APK can be installed into a running
|
||||
Lepton container (`podman exec -i lepton-steamlaunch-<appid> pm install -g -S <size> < apk`) and runs there, VR
|
||||
included (4XVR in Stremio's flatscreen container: FrameBridge 72 fps, settings via LEPTON_ENV_FRAMEBRIDGE_CONFIG). But
|
||||
Lepton's services.jar (`ActivityStarter.execute`) intercepts **every** `android.intent.action.VIEW` with data (any
|
||||
scheme, explicit component or not, no property to disable): it writes `steam://openurl/<uri>` to `/lepton/steam.pipe`
|
||||
(= the host Steam client's `~/.steam/steam.pipe`) and starts nothing. So "open in external player" hand-offs (Stremio
|
||||
→ 4XVR) can't work inside Lepton; don't retry without Valve changing it. Lepton installs exactly one `*.apk` per app
|
||||
folder (two break `get_apk_path`).
|
||||
**Text input (2026-10-03, verified in the headset with Stremio VR):** Lepton's Android has no IME (`ime list` empty)
|
||||
and runs VR apps headless (`lepton.headless=true`): no Android window has input focus (`dumpsys input` FocusedWindows
|
||||
empty), so neither `input text`, a USB keyboard nor Steam's keyboard reach the app. `lepton-show-flatscreen` on a VR
|
||||
app keeps VR working (FrameBridge 72 fps) and gives the window focus; Steam's on-screen keyboard then opens for text
|
||||
fields. Unity's TMP_InputField on non-Quest Android waits for the system keyboard (`TouchScreenKeyboardShouldBeUsed`)
|
||||
and deselects a frame later unless `isKeyboardUsingEvents` (Android: `InPlaceEditing() && m_HideSoftKeyboard`);
|
||||
uGUI InputField's LateUpdate keeps the field when `InPlaceEditing()`. Patch `frame.unity_text_input` rewrites them
|
||||
(`mov w0,#0|#1; ret`) at Cpp2IL's **Offset** (= file offset; RVA differs by 0x4000 in Stremio's lib), Cpp2IL
|
||||
2022.1 pre-release (Unity 6 / metadata v31; Il2CppDumper can't), cached per libil2cpp sha. `/dev/uinput` has an ACL
|
||||
for steamos (Steam Input) → agent v33 `_keyboard` uinput keyboard ("Type on Frame") reaches everything with focus.
|
||||
Installing a second APK into a container with `pm install` re-runs Lepton's post-install hook on the **main** app
|
||||
(`lepton.active_app_id`; moves its files to /data/steam_app) and corrupts it on the next start (fix: touch the APK →
|
||||
re-bake); `cmd_real package install` skips the hook.
|
||||
**User reports (2026-10-04, issues #4-#10):** Steam shortcuts go to the signed-in account (`loginusers.vdf`
|
||||
MostRecent) else every account (agent v35 `library_users`; it used to refuse with >1 account → Play gave Steam's "Game
|
||||
configuration unavailable"); `launch` uses the shortcut's own appid and errors `NOT_IN_LIBRARY`, the app then adds it
|
||||
and plays. Unity IL2CPP fixes share one Cpp2IL run (`unity_text_input.Il2cppReturnPatch`, `ALL_TARGETS`, cache keyed by
|
||||
global-metadata.dat): `frame.unity_runtime_msaa_off` (OVRManager raises MSAA to 4x at runtime: "Switching to the
|
||||
recommended level" → Lucky's Tale restarted the headset; `OVRDisplay.get_recommendedMSAALevel` → 0) and
|
||||
`frame.unity_multipass` (Oculus XR Plugin multiview → MultiPass via `OculusSettings.GetStereoRenderingMode`; I Am Cat's
|
||||
right eye grey). Both checked against Toy Master's Cpp2IL output, not yet in a headset. VR4 quits itself (System.exit
|
||||
after the intro movie) → catalog `use_alt`. **Catalog changes reach existing games** without hardcoded migrations:
|
||||
recipes store `catalog_rev` (= `CatalogEntry.rev()`), `library._follow_catalog` re-derives non-user recipes whose entry
|
||||
changed; a maintained entry beats the user's shared copy when its `updated`/verified date is later (`catalog._newer`). Unity `boot.config` "vulkan" substring mislabels GLES games as Vulkan
|
||||
(I Am Cat ran GLES); OVRPlugin's "Unavailable OpenXR extension: XR_FB_scene" is routine (no longer triaged).
|
||||
**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.
|
||||
|
||||
@@ -388,7 +438,8 @@ Maintainer-only notes (accounts, credentials, key locations) live in the git-ign
|
||||
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`,
|
||||
`FramePort-selfsigned.cer`; notes = "What's new" from the annotated tag message + `docs/INSTALL.md` from "First launch").
|
||||
`FramePort-selfsigned.cer`; notes = "What's new" from the annotated tag message + the short `packaging/release-footer.md`; owner: keep release
|
||||
notes short — a few "What's new" bullets, nothing long after them).
|
||||
Installed apps find the release themselves (self-update), so the notes are what users see in the update dialog.
|
||||
- **Release checklist:** bump `src/frameport/_version.py` (the only version; `scripts/package.py` fails a tag build
|
||||
whose tag ≠ `v<_version>`), commit, `git tag -a vX.Y.Z -m "FramePort X.Y.Z" -m "<What's new, Markdown bullets>"`,
|
||||
|
||||
@@ -23,6 +23,14 @@ uv run ruff check . # lint
|
||||
row and a unit test.
|
||||
- Native binaries in `artifacts/` are rebuilt with `python native/build.py` (downloads the NDK).
|
||||
|
||||
## Adding a tested game
|
||||
|
||||
Tested a game on the Steam Frame? The easiest way is in FramePort: open the game → **…** → **Share working config…**.
|
||||
It opens a prefilled GitHub issue with the exact recipe; after review a workflow turns it into
|
||||
`catalog/games/<package>.yaml`. Without the app: [working config form](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml). Something doesn't run: the game's
|
||||
**Report a problem…** (diagnostics zip, personal data removed) or the [problem report form](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml).
|
||||
After catalog changes, regenerate the games list: `python scripts/compat_list.py`.
|
||||
|
||||
## 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,130 +1,112 @@
|
||||
# FramePort
|
||||
|
||||
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.
|
||||
[](https://github.com/spoopyghosty0/frameport/releases/latest)
|
||||
[](https://github.com/spoopyghosty0/frameport/actions/workflows/build.yml)
|
||||
[](https://github.com/spoopyghosty0/frameport/releases)
|
||||
[](LICENSE)
|
||||

|
||||

|
||||
|
||||
Install games that target the Meta Quest, Android, or general PCVR onto your **Valve Steam Frame**. FramePort handles everything from uploading game files, setting up your Frame, injecting compatibility patches, and adding shortcuts to your Steam library. FramePort aims to be as simple as possible by taking advantage of the fact that the Steam Frame runs on Linux.
|
||||
|
||||

|
||||
|
||||
## Purpose
|
||||
> **Notice:** FramePort explicitly does NOT download, share, or unlock games. You must provide legally obtained game
|
||||
> executables. Core features of FramePort simply download and wrap other published tools (see [Built on](#built-on))
|
||||
> with patches provided by FramePort adding a hardware compatibility layer. This enables users to use games/apps legally
|
||||
> purchased on sites like [SideQuest](https://sidequestvr.com/).
|
||||
|
||||
FramePort is a **proof of concept**: it shows that VR software built for other platforms (Meta Quest, Oculus Rift) can
|
||||
run on the Steam Frame with a translation layer and a few targeted fixes. Most of the work is done by the projects
|
||||
FramePort wraps (see [Built on](#built-on)); FramePort selects and applies their fixes per game, adds Steam
|
||||
Frame-specific patches, and handles installing, testing and launching.
|
||||
## Features
|
||||
|
||||
FramePort is **not a piracy tool**. It does not download, share or unlock games, and it does not remove DRM, licence or
|
||||
entitlement checks. Use it only with games you own. Games that check their licence through the Oculus Platform SDK are
|
||||
marked as not runnable on the Frame.
|
||||
- **Painless setup:** one short command on the Frame. No root, no `sudo`, no password.
|
||||
[What it changes](docs/FRAME_SETUP.md).
|
||||
- **Type on Frame:** use your computer's keyboard on the Frame: in VR apps, Android apps, Steam and the desktop.
|
||||
[More](#type-on-frame).
|
||||
- **One click per game:** convert, patch, sign, upload, add to Steam with artwork, launch test.
|
||||
- **Per-game recipes:** a tested catalog plus detection rules; every patch explained in plain words.
|
||||
- **FrameBridge:** FramePort's OpenXR adapter emulates what the Frame natively lacks (passthrough, room, controller models,
|
||||
curved and 360° layers); game settings as simple switches.
|
||||
- **Beyond Quest:** Android apps as windows, PC VR via Proton or Revive, Windows (non-VR) games via Proton, a Files
|
||||
tab with drag and drop.
|
||||
- **Self-updating** releases, redacted diagnostics, one-click problem reports and working-config sharing.
|
||||
|
||||
## Getting started
|
||||
## Quick start
|
||||
|
||||
1. **Download** the archive for your computer from the
|
||||
[latest release](https://github.com/spoopyghosty0/frameport/releases/latest) and extract it anywhere:
|
||||
1. [Download](https://github.com/spoopyghosty0/frameport/releases/latest) and unzip the build for Windows, macOS
|
||||
(Apple Silicon) or Linux, then start FramePort.
|
||||
2. **Connect the Frame** (once):
|
||||
1. In FramePort click **Steam Frame → Show setup command**. Keep FramePort open; the Frame and your computer must
|
||||
be on the same Wi-Fi.
|
||||
2. On the Frame open the **SteamVR dashboard → Launch a program → Desktop**: the Linux desktop opens on a virtual
|
||||
screen.
|
||||
3. Open the app menu (bottom-left corner of that desktop) → **System → Konsole** (or search for Konsole).
|
||||
4. Type the command FramePort shows exactly as shown (on-screen keyboard or any USB/Bluetooth keyboard) and press
|
||||
**Enter**. This will run the following [bash setup script](bootstrap/bootstrap.sh).
|
||||
5. After a few seconds the desktop closes by itself (Steam restarts once); that's expected. If
|
||||
Steam asks to install **Lepton**, confirm it. FramePort shows the Frame as connected within a minute. No
|
||||
password needed.
|
||||
3. **Add games → Scan a folder** with your game backups (APK + OBB, or PC VR game folders).
|
||||
4. Open a game → **Install on Frame**, then play it from the Frame's Steam library.
|
||||
|
||||
| Computer | File | Start |
|
||||
|---|---|---|
|
||||
| Windows 10/11 (x64) | `FramePort-windows-x64.zip` | `FramePort.exe` |
|
||||
| macOS (Apple Silicon) | `FramePort-macos-arm64.zip` | `FramePort.app` |
|
||||
| Linux (x64) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
|
||||
Full guide, firewalls and troubleshooting: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
The builds are self-signed, so the first start shows a warning: Windows → **More info → Run anyway**;
|
||||
macOS → right-click the app → **Open**. Details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
2. **Get the tools**: on first start FramePort downloads what it needs (Java runtime, OVRPort, apksigner) into its own
|
||||
folder. Nothing is installed system-wide.
|
||||
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 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. 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**).
|
||||
## Type on Frame
|
||||
|
||||

|
||||
Typing in VR is painful, so FramePort turns your computer's keyboard into a keyboard for the Frame. Open **Type on
|
||||
Frame** (keyboard icon on the sidebar's Frame card, the Steam Frame page, or a game's menu), select a text field in
|
||||
the headset and type: searches, logins, chat, in any app, in Steam or on the desktop. Paste longer text to type it in
|
||||
one go. Nothing to install: FramePort adds a virtual keyboard on the Frame while the window is open, without root.
|
||||
|
||||
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.
|
||||

|
||||
|
||||

|
||||
Unity apps whose text fields close the moment you select them on the Frame (no system keyboard there) get a per-game
|
||||
fix, so Steam's on-screen keyboard and Type on Frame work in them too.
|
||||
[Details](docs/INSTALL.md#typing-on-the-frame).
|
||||
|
||||
FramePort updates itself: when a new version is released, the Library shows **Update now**.
|
||||
## Compatibility
|
||||
|
||||
## What works
|
||||
If a game has already been tested with FramePort, it will automatically use the optimal game config. Otherwise, FramePort
|
||||
will attempt to guess key patches. If you find a new config that works for an app you are testing, please consider submitting it to the community!
|
||||
|
||||
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**: described in plain words, with **Show technical details** for the exact
|
||||
effect of each patch.
|
||||
**[List of tested games](docs/GAMES.md)**
|
||||
|
||||
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.
|
||||
|
||||

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

|
||||
**Tested something? Share it.** In FramePort open the game → **…** → **Share working config…** (it fills in the
|
||||
recipe for you) or **Report a problem…** (attaches a diagnostics zip with personal data removed). Without the app:
|
||||
[share a working config](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml) · [report a problem](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml). Shared configs become built-in recipes for everyone.
|
||||
|
||||
## Built on
|
||||
|
||||
FramePort is a front end for other projects; most of the functionality comes from them:
|
||||
|
||||
| Project | Used for |
|
||||
|---|---|
|
||||
| [OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (overport, originally [ovrport/app](https://github.com/ovrport/app)) | Converts Quest games to OpenXR: its CLI applies the game patches and supplies the OpenXR loader; FramePort also includes its VrApi→OpenXR adapter |
|
||||
| Valve Lepton, Proton and SteamVR | Run Android games, Windows games and OpenXR on the Frame |
|
||||
| [Revive](https://github.com/LibreVR/Revive) (LibreVR) | Runs Oculus Rift games on OpenXR / SteamVR |
|
||||
| Mesa (Zink) | OpenGL ES on Vulkan on the Frame |
|
||||
| [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK) | OpenXR headers for the native layers |
|
||||
| Eclipse Temurin, Android apksigner, Android NDK | Java runtime, APK signing, building the native layers |
|
||||
| [Flet](https://flet.dev) | The desktop app |
|
||||
| OculusDB, Steam store | Game descriptions, genres and artwork |
|
||||
|
||||
FramePort's own parts: game detection and recipes, the Steam Frame OpenXR adapter (FrameBridge) and the other native
|
||||
fixes in `native/`, the installer agent that runs on the Frame, and the desktop/command-line app.
|
||||
|
||||
## Command line
|
||||
|
||||
The same functions are available as `frameport` (included in the source tree; also released as a Python wheel):
|
||||
`frameport scan`, `frameport build`, `frameport install`, `frameport test`, `frameport update`. Run
|
||||
`frameport --help` for the full list.
|
||||
[OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (Quest → OpenXR, originally
|
||||
[ovrport/app](https://github.com/ovrport/app)) · Valve Lepton, Proton and SteamVR ·
|
||||
[Revive](https://github.com/LibreVR/Revive) · Mesa (Zink) · [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK)
|
||||
· Eclipse Temurin, Android apksigner and NDK · [Flet](https://flet.dev) · OculusDB and Steam store data.
|
||||
What FramePort adds itself: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
- [docs/INSTALL.md](docs/INSTALL.md): installing, first launch, updating, Rift games, sending files, reporting problems.
|
||||
- [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.
|
||||
| | |
|
||||
|---|---|
|
||||
| [INSTALL.md](docs/INSTALL.md) | Install, connect, update, PC VR, files, problem reports |
|
||||
| [FRAME_SETUP.md](docs/FRAME_SETUP.md) | What setup changes on the Frame, networks and firewalls, undoing it |
|
||||
| [COMPATIBILITY.md](docs/COMPATIBILITY.md) | What runs and how well |
|
||||
| [GAMES.md](docs/GAMES.md) | Tested games and how well they run |
|
||||
| [PLAYBOOK.md](docs/PLAYBOOK.md) | Symptoms and fixes per game |
|
||||
| [FRAME_RUNTIME.md](docs/FRAME_RUNTIME.md) | Steam Frame runtime facts |
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | How the code is organised |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | Code, recipes, translations |
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
uv sync --extra dev
|
||||
uv run frameport-gui # the app
|
||||
uv run frameport --help # command line
|
||||
uv run pytest # tests
|
||||
```
|
||||
|
||||
See `CLAUDE.md` for project notes and `native/README.md` for the native components.
|
||||
## AI Usage Notice
|
||||
While I would like to program everything manually, I no longer have much free time for personal projects. As a result I make use of AI tools to make it significantly quicker to debug compatibility issues.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0-only (it includes GPL-3.0 code from OVRPort). Not affiliated with Valve or Meta.
|
||||
GPL-3.0-only (includes GPL-3.0 code from OVRPort). Not affiliated with Valve or Meta.
|
||||
+376
-54
@@ -7,6 +7,7 @@ The PC app uploads this file to ~/.local/share/frameport/agent/ and calls:
|
||||
Commands: info, prepare, finalize, shortcuts, shortcut_status, launch_test, stop, set_settings, uninstall,
|
||||
install_lepton, list_installed, proton_status, install_proton, prepare_pcvr, finalize_pcvr,
|
||||
controller_models.
|
||||
Streaming: python3 frameport_agent.py _keyboard (a virtual keyboard: JSON lines on stdin, see keyboard_session)
|
||||
|
||||
Install layout (one Lepton container per game; same as the manual installs from 2026-09):
|
||||
~/Applications/quest-frame/<pkg>/ anchor: launch.sh, deployment.json, artwork/ (always internal storage)
|
||||
@@ -20,6 +21,7 @@ PC VR (Oculus Rift) games packed for the Frame (id "rift.<slug>"), run by Proton
|
||||
<dest>/<id>/revive/ Revive (ReviveInjector.exe + DLLs)
|
||||
<dest>/<id>/compatdata/ Proton prefix = saves (kept across reinstalls), launch.log
|
||||
"""
|
||||
import fcntl
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
@@ -33,7 +35,7 @@ import sys
|
||||
import time
|
||||
import zlib
|
||||
|
||||
AGENT_VERSION = 30
|
||||
AGENT_VERSION = 35
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -562,6 +564,48 @@ def steam_users():
|
||||
if os.path.isdir(os.path.join(STEAM, "userdata")) else []
|
||||
|
||||
|
||||
STEAMID64_BASE = 76561197960265728
|
||||
|
||||
|
||||
def active_steam_user():
|
||||
"""userdata folder (account id) of the most recent Steam login (config/loginusers.vdf), if it has one."""
|
||||
try:
|
||||
text = open(os.path.join(STEAM, "config/loginusers.vdf"), encoding="utf-8", errors="replace").read()
|
||||
except OSError:
|
||||
return None
|
||||
users = steam_users()
|
||||
for sid, body in re.findall(r'"(\d{17})"\s*\{([^}]*)\}', text):
|
||||
if re.search(r'"MostRecent"\s+"1"', body) and str(int(sid) - STEAMID64_BASE) in users:
|
||||
return str(int(sid) - STEAMID64_BASE)
|
||||
return None
|
||||
|
||||
|
||||
def library_users():
|
||||
"""Steam accounts whose library gets FramePort's shortcuts: the signed-in one, else every account on the Frame
|
||||
(several accounts used to stop the shortcut step: "found 2 Steam users", and Play then failed with Steam's "Game
|
||||
configuration unavailable")."""
|
||||
users = steam_users()
|
||||
if not users:
|
||||
raise AgentError("Steam has no signed-in account on this Frame yet; sign in to Steam on the Frame first")
|
||||
active = active_steam_user()
|
||||
return [active] if active else users
|
||||
|
||||
|
||||
def shortcut_appid_for(exe):
|
||||
"""appid of the shortcut whose Exe is `exe` in the signed-in account's library (any account if unknown); None
|
||||
when it isn't in the library."""
|
||||
for u in library_users():
|
||||
vdf = os.path.join(STEAM, "userdata", u, "config/shortcuts.vdf")
|
||||
try:
|
||||
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
|
||||
except (OSError, AgentError):
|
||||
continue
|
||||
for v in (root.get("shortcuts") or {}).values():
|
||||
if isinstance(v, dict) and v.get("Exe") == exe and v.get("appid"):
|
||||
return v["appid"] & 0xFFFFFFFF
|
||||
return None
|
||||
|
||||
|
||||
def container_running(appid):
|
||||
p = run(["podman", "ps", "--format", "{{.Names}}"])
|
||||
return f"lepton-steamlaunch-{appid}" in p.stdout.split()
|
||||
@@ -602,6 +646,95 @@ def key_usage():
|
||||
return {}
|
||||
|
||||
|
||||
POWER_SUPPLY = "/sys/class/power_supply"
|
||||
CHARGER_TYPES = ("Mains", "USB", "USB_C", "USB_PD", "USB_PD_DRP", "USB_DCP", "USB_CDP", "USB_ACA", "Wireless")
|
||||
|
||||
|
||||
def battery_state():
|
||||
"""The Frame's battery: {"percent", "status" (Charging/Discharging/Full/Not charging), "plugged", "draining"};
|
||||
None without one. "plugged" = a charger reports online (or the battery says it's charging/full); "draining" = the
|
||||
battery's own gauge says Discharging (with a charger: it supplies less than the Frame uses, or just booted).
|
||||
On the Frame (2026-10-03): max1720x_bat (Battery), pm8550b-charger (Unknown), tcpm …typec (USB, online=1)."""
|
||||
def read(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
battery, plugged = None, False
|
||||
try:
|
||||
names = sorted(os.listdir(POWER_SUPPLY))
|
||||
except OSError:
|
||||
return None
|
||||
for name in names:
|
||||
d = os.path.join(POWER_SUPPLY, name)
|
||||
kind = read(os.path.join(d, "type"))
|
||||
if kind == "Battery" and battery is None and read(os.path.join(d, "capacity")).isdigit():
|
||||
battery = {"percent": int(read(os.path.join(d, "capacity"))), "status": read(os.path.join(d, "status"))}
|
||||
elif kind in CHARGER_TYPES and read(os.path.join(d, "online")) == "1":
|
||||
plugged = True
|
||||
if battery is not None:
|
||||
battery["plugged"] = plugged or battery["status"] in ("Charging", "Full")
|
||||
battery["draining"] = battery["status"] == "Discharging"
|
||||
return battery
|
||||
|
||||
|
||||
BOOT_STATE = os.path.join(HOME, ".cache/frameport-boot.json")
|
||||
# what the last lines of a boot's journal say when it was shut down or rebooted on purpose (a crash, a GPU hang that
|
||||
# reset the Frame or a pulled battery leaves none of these)
|
||||
CLEAN_SHUTDOWN = ("Reached target System Power Off", "Reached target System Reboot", "Reached target Shutdown",
|
||||
"System is powering down", "System is rebooting", "systemd-shutdown", "Power-Off", "Rebooting.")
|
||||
|
||||
|
||||
def boot_state():
|
||||
"""This boot and how the previous one ended: {"boot_id", "boot_time", "prev_clean" (True/False/None = unknown),
|
||||
"last_launch" ({"package", "title", "time"}: the FramePort game started last before this boot)}. Worked out once
|
||||
per boot (cached), so the app can say "your Frame restarted while <game> was running"."""
|
||||
try:
|
||||
boot_id = open("/proc/sys/kernel/random/boot_id").read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
cached = json.load(open(BOOT_STATE))
|
||||
if cached.get("boot_id") == boot_id:
|
||||
return cached
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
boot_time = None
|
||||
try:
|
||||
for line in open("/proc/stat"):
|
||||
if line.startswith("btime "):
|
||||
boot_time = int(line.split()[1])
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
tail = run(["journalctl", "-b", "-1", "-n", "120", "-q", "--no-pager", "-o", "cat"]).stdout
|
||||
except OSError:
|
||||
tail = ""
|
||||
prev_clean = any(m in tail for m in CLEAN_SHUTDOWN) if tail.strip() else None
|
||||
last = None
|
||||
for d in cmd_list_installed({})["games"]:
|
||||
log = os.path.join(d["base"], "launch.log")
|
||||
try:
|
||||
t = os.path.getmtime(log)
|
||||
except OSError:
|
||||
continue
|
||||
if (boot_time is None or t < boot_time) and (last is None or t > last["time"]):
|
||||
last = {"package": d["package"], "title": d.get("title") or d["package"], "time": t}
|
||||
state = {"boot_id": boot_id, "boot_time": boot_time, "prev_clean": prev_clean, "last_launch": last}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(BOOT_STATE), exist_ok=True)
|
||||
with open(BOOT_STATE, "w") as f:
|
||||
json.dump(state, f)
|
||||
except OSError:
|
||||
pass
|
||||
return state
|
||||
|
||||
|
||||
def cmd_battery(args):
|
||||
return {"battery": battery_state()}
|
||||
|
||||
|
||||
def cmd_info(args):
|
||||
lepton, app = lepton_path()
|
||||
osr = {}
|
||||
@@ -621,6 +754,8 @@ def cmd_info(args):
|
||||
"steam_running": run(["pgrep", "-x", "steam"]).returncode == 0,
|
||||
"host_fixes": ensure_host_fixes(), "kernel_keys": key_usage(),
|
||||
"proton": cmd_proton_status({}) if args.get("proton", True) else None,
|
||||
"boot": boot_state(),
|
||||
"battery": battery_state(),
|
||||
}
|
||||
|
||||
|
||||
@@ -695,7 +830,8 @@ def vdf_encode(obj):
|
||||
return bytes(out) + bytes([TYPE_END])
|
||||
|
||||
|
||||
def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Frame", launch_options="", tags=None):
|
||||
def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Frame", launch_options="", tags=None,
|
||||
openvr=True):
|
||||
"""Add/update a non-Steam shortcut (matched by Exe, so its appid never changes). `tags` (genres, the user's tags)
|
||||
are merged with tags already on the shortcut, so ones set in Steam are kept."""
|
||||
data = open(vdf_path, "rb").read() if os.path.exists(vdf_path) else b""
|
||||
@@ -711,8 +847,8 @@ def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Fram
|
||||
merged = list(dict.fromkeys(have + [t for t in [tag] + list(tags) if t]))
|
||||
entry["tags"] = {str(i): t for i, t in enumerate(merged)}
|
||||
entry.update(appname=title, Exe=exe, StartDir=start_dir, icon=icon or entry.get("icon", ""), ShortcutPath="",
|
||||
LaunchOptions=launch_options, IsHidden=0, AllowDesktopConfig=1, AllowOverlay=1, OpenVR=1, Devkit=0,
|
||||
DevkitGameID="", DevkitOverrideAppID=0, FlatpakAppID="")
|
||||
LaunchOptions=launch_options, IsHidden=0, AllowDesktopConfig=1, AllowOverlay=1,
|
||||
OpenVR=1 if openvr else 0, Devkit=0, DevkitGameID="", DevkitOverrideAppID=0, FlatpakAppID="")
|
||||
if data:
|
||||
backup_vdf(vdf_path)
|
||||
os.makedirs(os.path.dirname(vdf_path), exist_ok=True)
|
||||
@@ -761,6 +897,28 @@ def backup_vdf(vdf_path):
|
||||
pass
|
||||
|
||||
|
||||
def remove_tree(path):
|
||||
"""Remove a file or folder tree, including what Lepton's containers leave in a game's data: overlayfs work dirs
|
||||
with mode 000 (and whiteout device files in them), which shutil.rmtree(ignore_errors=True) silently skipped, so
|
||||
"remove saves" left lepton-data behind. Last resort: podman unshare (files owned by the container's user ids)."""
|
||||
if not os.path.lexists(path):
|
||||
return
|
||||
if os.path.islink(path) or not os.path.isdir(path):
|
||||
os.remove(path)
|
||||
return
|
||||
for root, dirs, _files in os.walk(path): # top-down: fix a folder's mode before walking into it
|
||||
for d in dirs:
|
||||
p = os.path.join(root, d)
|
||||
if not os.path.islink(p):
|
||||
try:
|
||||
os.chmod(p, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
if os.path.lexists(path) and shutil.which("podman"):
|
||||
run(["podman", "unshare", "rm", "-rf", path])
|
||||
|
||||
|
||||
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."""
|
||||
@@ -813,42 +971,15 @@ def shortcuts_worker(payload):
|
||||
args = json.loads(payload)
|
||||
result = {"state": "done", "added": [], "errors": [], "finished": None}
|
||||
try:
|
||||
users = steam_users()
|
||||
if len(users) != 1:
|
||||
raise AgentError(f"found {len(users)} Steam users; not guessing which library to edit")
|
||||
vdf = os.path.join(STEAM, "userdata", users[0], "config/shortcuts.vdf")
|
||||
users = library_users()
|
||||
try:
|
||||
service = stop_steam()
|
||||
except AgentError:
|
||||
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)
|
||||
dep = json.load(open(os.path.join(anchor, "deployment.json")))
|
||||
icon = next(iter(glob.glob(os.path.join(anchor, "artwork/icon.*"))), "")
|
||||
tag = "PC VR on Frame" if dep.get("kind") == "pcvr" else "Quest on Frame"
|
||||
got = upsert_shortcut(vdf, f'"{anchor}/launch.sh"', dep["title"], anchor, icon, tag,
|
||||
tags=dep.get("tags") or [])
|
||||
for kind, suffix in (("portrait", "p"), ("landscape", ""), ("hero", "_hero"), ("logo", "_logo")):
|
||||
img = next(iter(glob.glob(os.path.join(anchor, f"artwork/{kind}.*"))), None)
|
||||
if not img:
|
||||
continue
|
||||
for old in glob.glob(os.path.join(grid, f"{got}{suffix}.*")):
|
||||
os.remove(old)
|
||||
shutil.copy(img, os.path.join(grid, f"{got}{suffix}{os.path.splitext(img)[1]}"))
|
||||
result["added"].append({"package": pkg, "appid": got, "expected": dep["appid"]})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{pkg}: {exc}")
|
||||
for user in steam_users(): # uninstalled games leave every account's library
|
||||
remove_from_library(user, args.get("remove", []), result)
|
||||
for user in users:
|
||||
update_library(user, args["packages"], result)
|
||||
if service or args.get("restart", True):
|
||||
start_steam(service)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -860,6 +991,48 @@ def shortcuts_worker(payload):
|
||||
json.dump(result, f)
|
||||
|
||||
|
||||
def remove_from_library(user, remove, result):
|
||||
"""Remove uninstalled games' shortcuts + grid art from one Steam account (Steam is closed)."""
|
||||
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
|
||||
grid = os.path.join(os.path.dirname(vdf), "grid")
|
||||
for r in remove:
|
||||
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}")
|
||||
|
||||
|
||||
def update_library(user, packages, result):
|
||||
"""Add/update installed games' shortcuts + grid art in one Steam account (Steam is closed)."""
|
||||
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
|
||||
grid = os.path.join(os.path.dirname(vdf), "grid")
|
||||
os.makedirs(grid, exist_ok=True)
|
||||
for pkg in packages:
|
||||
try:
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
dep = json.load(open(os.path.join(anchor, "deployment.json")))
|
||||
icon = next(iter(glob.glob(os.path.join(anchor, "artwork/icon.*"))), "")
|
||||
flat = dep.get("vr") is False
|
||||
tag = ("Windows game on Frame" if flat else "PC VR on Frame") if dep.get("kind") == "pcvr" \
|
||||
else "Quest on Frame"
|
||||
got = upsert_shortcut(vdf, f'"{anchor}/launch.sh"', dep["title"], anchor, icon, tag,
|
||||
tags=dep.get("tags") or [], openvr=not flat)
|
||||
for kind, suffix in (("portrait", "p"), ("landscape", ""), ("hero", "_hero"), ("logo", "_logo")):
|
||||
img = next(iter(glob.glob(os.path.join(anchor, f"artwork/{kind}.*"))), None)
|
||||
if not img:
|
||||
continue
|
||||
for old in glob.glob(os.path.join(grid, f"{got}{suffix}.*")):
|
||||
os.remove(old)
|
||||
shutil.copy(img, os.path.join(grid, f"{got}{suffix}{os.path.splitext(img)[1]}"))
|
||||
if not any(a["package"] == pkg for a in result["added"]):
|
||||
result["added"].append({"package": pkg, "appid": got, "expected": dep["appid"]})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{pkg}: {exc}")
|
||||
|
||||
|
||||
def cmd_shortcut_status(args):
|
||||
try:
|
||||
return json.load(open(STATUS_FILE))
|
||||
@@ -903,6 +1076,9 @@ def steam_gameid(appid):
|
||||
return (int(appid) << 32) | 0x02000000
|
||||
|
||||
|
||||
NOT_IN_LIBRARY = "not in the Frame's Steam library"
|
||||
|
||||
|
||||
def cmd_launch(args):
|
||||
"""Start an installed game the way the headset's library does: ask the running Steam to launch its shortcut, so
|
||||
it gets Steam's VR session, overlay and controller setup (unlike launch_test's direct, headless start)."""
|
||||
@@ -912,7 +1088,10 @@ def cmd_launch(args):
|
||||
raise AgentError(f"{pkg} is not installed")
|
||||
if run(["pgrep", "-x", "steam"]).returncode != 0:
|
||||
raise AgentError("Steam isn't running on the Frame")
|
||||
gid = steam_gameid(dep["appid"])
|
||||
appid = shortcut_appid_for(f'"{os.path.join(ANCHORS, pkg)}/launch.sh"')
|
||||
if appid is None: # Steam would only say "Game configuration unavailable"
|
||||
raise AgentError(f"{NOT_IN_LIBRARY}: {dep.get('title') or pkg}")
|
||||
gid = steam_gameid(appid) # the shortcut's own id (it keeps its first one when the title changes)
|
||||
# systemd-run: the launch request must outlive this SSH session
|
||||
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit=frameport-launch-{int(time.time())}",
|
||||
"steam", "-ifrunning", f"steam://rungameid/{gid}"])
|
||||
@@ -1603,8 +1782,7 @@ set -euo pipefail
|
||||
base={base_q}
|
||||
[[ -d "$base/game" ]] || {{ echo "Game files missing at $base (storage not mounted?)" >&2; exit 1; }}
|
||||
export SteamAppId={appid}
|
||||
export SteamGameId={appid}
|
||||
export STEAM_COMPAT_APP_ID={appid}
|
||||
{steam_game_id}export STEAM_COMPAT_APP_ID={appid}
|
||||
export STEAM_COMPAT_DATA_PATH="$base/compatdata"
|
||||
export STEAM_COMPAT_CLIENT_INSTALL_PATH={steam_q}
|
||||
export STEAM_COMPAT_INSTALL_PATH="$base/game"
|
||||
@@ -1671,7 +1849,7 @@ OCULUS_HMD_HELPER = "fp_oculushmd.exe"
|
||||
|
||||
|
||||
def write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive, env, xr_layer=False, game_args=(),
|
||||
oculus_hmd=False):
|
||||
oculus_hmd=False, vr=True):
|
||||
exe = os.path.join(base, "game", exe_rel)
|
||||
prefix = compat_command(tool["dir"])
|
||||
injector = os.path.join(base, "revive", "ReviveInjector.exe")
|
||||
@@ -1692,6 +1870,8 @@ def write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive
|
||||
text = LAUNCH_PROTON_SH.format(
|
||||
title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid, steam_q=shlex.quote(STEAM),
|
||||
tool=tool["name"], extra_env=extra, workdir=("/" + shlex.quote(workdir)) if workdir else "",
|
||||
# Proton sets up VR (vrclient, wineopenxr) only when SteamGameId is set: a flat Windows game goes without
|
||||
steam_game_id=f"export SteamGameId={appid}\n" if vr else "",
|
||||
xr_layer=XR_LAYER_ENV if xr_layer else "",
|
||||
command=" ".join(shlex.quote(a) for a in argv))
|
||||
path = os.path.join(anchor, "launch.sh")
|
||||
@@ -1801,8 +1981,9 @@ def cmd_finalize_pcvr(args):
|
||||
raise AgentError(f"{OCULUS_HMD_HELPER} missing")
|
||||
set_crash_reporter(base, enabled=not args.get("no_crash_reporter"))
|
||||
set_libovr_redirect(base, exe_rel, enabled=bool(args.get("libovr_redirect")), bundled=not revive)
|
||||
vr = args.get("vr", True) is not False # False: a flat Windows game (no VR at all)
|
||||
write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive, args.get("env"), xr_layer,
|
||||
args.get("game_args") or [], oculus_hmd)
|
||||
args.get("game_args") or [], oculus_hmd, vr=vr)
|
||||
art_in = os.path.join(base, "incoming-artwork")
|
||||
if os.path.isdir(art_in):
|
||||
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
|
||||
@@ -1810,7 +1991,7 @@ 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,
|
||||
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd, "vr": vr,
|
||||
"libovr_redirect": bool(args.get("libovr_redirect")),
|
||||
"recipe": args.get("recipe"),
|
||||
"installed_by": "frameport",
|
||||
@@ -1887,22 +2068,22 @@ def cmd_uninstall(args):
|
||||
for name in names:
|
||||
p = os.path.join(base, name)
|
||||
if os.path.isdir(p):
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
remove_tree(p)
|
||||
elif os.path.exists(p):
|
||||
os.remove(p)
|
||||
if not keep_data:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
remove_tree(base)
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
removed_sc = False
|
||||
if args.get("remove_shortcut") and len(steam_users()) == 1:
|
||||
if args.get("remove_shortcut") and steam_users():
|
||||
# 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)
|
||||
remove_tree(anchor)
|
||||
else: # saves live next to the launcher (Quest games): keep them, drop what marks the game as installed
|
||||
for name in ("deployment.json", "launch.sh", "artwork", "launch.log", "launch-test.log"):
|
||||
p = os.path.join(anchor, name)
|
||||
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else (os.path.exists(p) and os.remove(p))
|
||||
remove_tree(p)
|
||||
return {"removed": True, "kept_saves": keep_data, "shortcut_removed": removed_sc}
|
||||
|
||||
|
||||
@@ -2082,6 +2263,16 @@ def cmd_collect_diag(args):
|
||||
"{{.Names}} {{.Status}}"]).stdout[-20000:]
|
||||
host["steam_running"] = run(["pgrep", "-x", "steam"]).returncode == 0
|
||||
host["uptime"] = _tail("/proc/uptime", 200)
|
||||
host["boot"] = boot_state()
|
||||
# how the previous boot ended: errors and kernel (GPU/msm/kgsl, OOM, panic) warnings before a crash or reset
|
||||
for name, extra in (("previous-boot-errors.txt", ["-p", "err"]),
|
||||
("previous-boot-kernel.txt", ["-k", "-p", "warning"])):
|
||||
try:
|
||||
text = run(["journalctl", "-b", "-1", *extra, "-n", "400", "-q", "--no-pager"]).stdout[-max_bytes:]
|
||||
except OSError:
|
||||
text = ""
|
||||
if text.strip():
|
||||
files[name] = text
|
||||
host["installed"] = [{k: g.get(k) for k in ("package", "title", "kind", "appid", "version", "agent_version")}
|
||||
for g in cmd_list_installed({})["games"]]
|
||||
out = {"agent_version": AGENT_VERSION, "host": host, "files": files}
|
||||
@@ -2183,6 +2374,16 @@ def purge_worker(payload):
|
||||
os.remove(art)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{d.get('title')}: {exc}")
|
||||
try: # shortcuts left from games without an install record (older versions, interrupted removals)
|
||||
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
|
||||
for sc in list(root.get("shortcuts", {}).values()):
|
||||
exe = sc.get("Exe", "") if isinstance(sc, dict) else ""
|
||||
if exe.startswith(f'"{ANCHORS}/') and remove_shortcut(vdf, exe):
|
||||
result["removed"].append(f"Steam shortcut: {sc.get('AppName') or sc.get('appname')}")
|
||||
for art in grid_files(grid, sc.get("appid", 0) & 0xFFFFFFFF):
|
||||
os.remove(art)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"shortcuts: {exc}")
|
||||
for d in games:
|
||||
base = d["base"]
|
||||
saves = [os.path.join(base, n) for n in ("lepton-data", "compatdata")]
|
||||
@@ -2190,23 +2391,30 @@ def purge_worker(payload):
|
||||
for name in os.listdir(base) if os.path.isdir(base) else []:
|
||||
p = os.path.join(base, name)
|
||||
if p not in saves:
|
||||
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else os.remove(p)
|
||||
remove_tree(p)
|
||||
result["kept"].append(base)
|
||||
else:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
remove_tree(base)
|
||||
if not keep and os.path.lexists(base):
|
||||
result["errors"].append(f"couldn't remove {base}")
|
||||
result["removed"].append(d.get("title") or d["package"])
|
||||
if not keep or not result["kept"]:
|
||||
shutil.rmtree(ANCHORS, ignore_errors=True)
|
||||
remove_tree(ANCHORS)
|
||||
else: # anchors hold only launchers/artwork; saves live in the bases
|
||||
for d in games:
|
||||
anchor = os.path.join(ANCHORS, d["package"])
|
||||
if os.path.realpath(anchor) not in [os.path.realpath(k) for k in result["kept"]]:
|
||||
shutil.rmtree(anchor, ignore_errors=True)
|
||||
shutil.rmtree(AGENT_HOME, ignore_errors=True)
|
||||
remove_tree(anchor)
|
||||
remove_tree(AGENT_HOME)
|
||||
result["removed"].append(AGENT_HOME)
|
||||
if os.path.exists(XR_LAYER_MANIFEST):
|
||||
os.remove(XR_LAYER_MANIFEST)
|
||||
result["removed"].append(XR_LAYER_MANIFEST)
|
||||
for name in ("frameport-setup.sh", "frameport-setup.log"): # left by bootstrap.sh
|
||||
p = os.path.join(HOME, ".cache", name)
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
result["removed"].append(p)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["state"] = "failed"
|
||||
result["errors"].append(str(exc))
|
||||
@@ -2235,7 +2443,7 @@ def cmd_cleanup(args):
|
||||
p = os.path.join(base, name)
|
||||
if os.path.isdir(p):
|
||||
freed += sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs)
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
remove_tree(p)
|
||||
removed.append(p)
|
||||
elif os.path.exists(p):
|
||||
freed += os.path.getsize(p)
|
||||
@@ -2250,15 +2458,129 @@ def cmd_cleanup(args):
|
||||
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))
|
||||
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else os.remove(p)
|
||||
remove_tree(p)
|
||||
removed.append(p)
|
||||
return {"removed": removed, "freed_bytes": freed}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ virtual keyboard
|
||||
# "Type on Frame": the PC's key presses become a real keyboard on the Frame (Linux uinput; the steamos user may open
|
||||
# /dev/uinput, an ACL entry made for Steam Input, no root). A real input device reaches everything that has focus:
|
||||
# Android windows (Lepton's wayland_keyboard), the Steam UI, the desktop, Proton games.
|
||||
UINPUT = "/dev/uinput"
|
||||
UI_SET_EVBIT, UI_SET_KEYBIT, UI_DEV_SETUP, UI_DEV_CREATE, UI_DEV_DESTROY = (0x40045564, 0x40045565, 0x405C5503,
|
||||
0x5501, 0x5502)
|
||||
EV_SYN, EV_KEY, SYN_REPORT = 0, 1, 0
|
||||
KEY_LAST = 248 # KEY_ESC (1) .. KEY_MICMUTE (248): every key a PC keyboard sends
|
||||
KEY_LEFTSHIFT = 42
|
||||
|
||||
|
||||
def text_keys():
|
||||
"""US layout: character -> (Linux key code, shift)."""
|
||||
keys = {"\n": (28, False), "\t": (15, False), " ": (57, False), "\b": (14, False)}
|
||||
for plain, shifted, first in (("1234567890-=", "!@#$%^&*()_+", 2), ("qwertyuiop[]", "QWERTYUIOP{}", 16),
|
||||
("asdfghjkl;'`", 'ASDFGHJKL:"~', 30), ("\\zxcvbnm,./", "|ZXCVBNM<>?", 43)):
|
||||
for i, (a, b) in enumerate(zip(plain, shifted, strict=True)):
|
||||
keys[a] = (first + i, False)
|
||||
keys[b] = (first + i, True)
|
||||
return keys
|
||||
|
||||
|
||||
TEXT_KEYS = text_keys()
|
||||
|
||||
|
||||
class VirtualKeyboard:
|
||||
"""A uinput keyboard. `fd`/`write` can be replaced in tests; the device goes away with close()."""
|
||||
|
||||
def __init__(self, fd=None, write=os.write, settle=0.8):
|
||||
self.write, self.held = write, set()
|
||||
self.fd = fd
|
||||
if fd is None:
|
||||
self.fd = os.open(UINPUT, os.O_WRONLY | os.O_NONBLOCK)
|
||||
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
|
||||
for code in range(1, KEY_LAST + 1):
|
||||
fcntl.ioctl(self.fd, UI_SET_KEYBIT, code)
|
||||
fcntl.ioctl(self.fd, UI_DEV_SETUP, struct.pack("HHHH80sI", 0x03, 0x1209, 0x4650, 1,
|
||||
b"FramePort keyboard", 0))
|
||||
fcntl.ioctl(self.fd, UI_DEV_CREATE)
|
||||
time.sleep(settle) # let gamescope/libinput pick the new keyboard up before the first key
|
||||
|
||||
def _event(self, kind, code, value):
|
||||
self.write(self.fd, struct.pack("llHHi", 0, 0, kind, code, value))
|
||||
|
||||
def key(self, code, value):
|
||||
"""value 1 = down, 0 = up, 2 = autorepeat."""
|
||||
if not 0 < int(code) <= KEY_LAST or value not in (0, 1, 2):
|
||||
return
|
||||
self._event(EV_KEY, int(code), value)
|
||||
self._event(EV_SYN, SYN_REPORT, 0)
|
||||
(self.held.add if value else self.held.discard)(int(code))
|
||||
|
||||
def type_text(self, text, delay=0.008):
|
||||
"""Type characters of the US layout; returns the characters it couldn't type."""
|
||||
skipped = ""
|
||||
for ch in text.replace("\r\n", "\n"):
|
||||
if ch not in TEXT_KEYS:
|
||||
skipped += ch
|
||||
continue
|
||||
code, shift = TEXT_KEYS[ch]
|
||||
if shift:
|
||||
self.key(KEY_LEFTSHIFT, 1)
|
||||
self.key(code, 1)
|
||||
self.key(code, 0)
|
||||
if shift:
|
||||
self.key(KEY_LEFTSHIFT, 0)
|
||||
time.sleep(delay)
|
||||
return skipped
|
||||
|
||||
def close(self):
|
||||
for code in list(self.held): # never leave a key stuck when the PC goes away mid-press
|
||||
self.key(code, 0)
|
||||
if self.fd is not None and isinstance(self.fd, int):
|
||||
try:
|
||||
fcntl.ioctl(self.fd, UI_DEV_DESTROY)
|
||||
except OSError:
|
||||
pass
|
||||
os.close(self.fd)
|
||||
self.fd = None
|
||||
|
||||
|
||||
def keyboard_session(stdin, stdout, keyboard=None):
|
||||
"""Long-lived: prints {"ready": true} once the virtual keyboard exists, then reads one JSON object per line:
|
||||
{"k": code, "v": 1|0|2} (key down/up/repeat) or {"text": "..."}; ends (keyboard removed) at EOF."""
|
||||
try:
|
||||
kb = keyboard or VirtualKeyboard()
|
||||
except OSError as exc:
|
||||
stdout.write(json.dumps({"ready": False, "error": f"can't create a virtual keyboard: {exc}"}) + "\n")
|
||||
stdout.flush()
|
||||
return 1
|
||||
stdout.write(json.dumps({"ready": True}) + "\n")
|
||||
stdout.flush()
|
||||
try:
|
||||
for line in stdin:
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if "k" in msg:
|
||||
kb.key(msg["k"], msg.get("v", 1))
|
||||
elif "text" in msg:
|
||||
skipped = kb.type_text(str(msg["text"]))
|
||||
stdout.write(json.dumps({"typed": True, "skipped": skipped}) + "\n")
|
||||
stdout.flush()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
kb.close()
|
||||
return 0
|
||||
|
||||
|
||||
COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_keyboard":
|
||||
return keyboard_session(sys.stdin, sys.stdout)
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "_shortcuts_worker":
|
||||
shortcuts_worker(sys.argv[2])
|
||||
return 0
|
||||
|
||||
+111
-36
@@ -1,59 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# FramePort one-time Steam Frame setup. Run in the Frame's Desktop Mode terminal (Konsole):
|
||||
# curl -fsSL http://<pc>:<port>/bootstrap.sh | bash
|
||||
# FramePort one-time Steam Frame setup. Run in the Frame's desktop terminal (SteamVR dashboard -> Launch a program
|
||||
# -> Desktop, then System -> Konsole):
|
||||
# curl -fsS <pc-ip>:<port>/<code> | bash
|
||||
# (the FramePort app shows the exact line and serves this script while its Setup page is open).
|
||||
#
|
||||
# What it does (asks before anything that needs sudo):
|
||||
# 1. sets a password for the 'steamos' user if it has none (sudo needs one)
|
||||
# 2. enables the SSH server (sshd)
|
||||
# 3. authorizes the FramePort app's SSH key (no password needed afterwards)
|
||||
# 4. announces this Frame on the local network (avahi service "_frameport._tcp") so the app finds it
|
||||
# 5. checks for Valve's Lepton Android runtime and asks Steam to install it if missing
|
||||
# What it does (no password, no sudo):
|
||||
# 1. authorizes the FramePort app's SSH key (no password needed afterwards)
|
||||
# 2. configures podman for Lepton (avoids a kernel keyring leak after ~200 game starts)
|
||||
# 3. turns on Developer Mode if it's off (Valve's own helper; it also enables the SSH server and announces the
|
||||
# Frame on the network as a SteamOS devkit). Steam restarts for that, which closes Desktop Mode: the rest
|
||||
# runs on its own as a user service and the Frame returns to its normal view.
|
||||
# 4. asks Steam to install Valve's Lepton Android runtime if it's missing, then tells the app it's done
|
||||
# Log of the part that runs on its own: ~/.cache/frameport-setup.log
|
||||
set -euo pipefail
|
||||
PC_URL="__PC_URL__" # filled in by the app when serving the script
|
||||
PAIR_CODE="__PAIR_CODE__"
|
||||
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||
|
||||
DEVKIT_HELPER=/usr/bin/steamos-polkit-helpers/steamos-devkit-mode
|
||||
STEAM_CONFIG=~/.local/share/Steam/config/config.vdf
|
||||
JOB=~/.cache/frameport-setup.sh
|
||||
LOG=~/.cache/frameport-setup.log
|
||||
|
||||
# Desktop Mode on SteamOS is a nested Plasma session inside steam.service, with its own XDG_RUNTIME_DIR and D-Bus:
|
||||
# systemctl/systemd-run --user only reach the user's systemd through the real runtime dir.
|
||||
user_systemd() {
|
||||
XDG_RUNTIME_DIR="/run/user/$(id -u)" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus" "$@"
|
||||
}
|
||||
|
||||
say "FramePort setup for $(hostname) ($(. /etc/os-release; echo "$NAME $VERSION_ID"))"
|
||||
|
||||
if ! passwd -S "$USER" 2>/dev/null | grep -q ' P '; then
|
||||
say "Your user has no password yet. SteamOS needs one for sudo (and as an SSH fallback)."
|
||||
passwd
|
||||
fi
|
||||
|
||||
say "Enabling the SSH server (sudo)"
|
||||
sudo systemctl enable --now sshd
|
||||
|
||||
say "Authorizing the FramePort app's key"
|
||||
key=$(curl -fsSL "$PC_URL/key?code=$PAIR_CODE")
|
||||
key=$(curl -fsS "$PC_URL/key?code=$PAIR_CODE")
|
||||
[[ "$key" == ssh-ed25519\ * ]] || { echo "Could not fetch the app's key from $PC_URL (is the app still open?)"; exit 1; }
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
|
||||
grep -qxF "$key" ~/.ssh/authorized_keys || echo "$key" >> ~/.ssh/authorized_keys
|
||||
|
||||
say "Announcing this Frame on the network (avahi)"
|
||||
svc="<?xml version=\"1.0\" standalone='no'?>
|
||||
<!DOCTYPE service-group SYSTEM \"avahi-service.dtd\">
|
||||
<service-group>
|
||||
<name replace-wildcards=\"yes\">FramePort on %h</name>
|
||||
<service><type>_frameport._tcp</type><port>22</port><txt-record>user=$USER</txt-record><txt-record>pair=$PAIR_CODE</txt-record></service>
|
||||
</service-group>"
|
||||
echo "$svc" | sudo tee /etc/avahi/services/frameport.service >/dev/null || true
|
||||
sudo systemctl enable --now avahi-daemon >/dev/null 2>&1 || true
|
||||
sudo systemctl reload avahi-daemon >/dev/null 2>&1 || true
|
||||
|
||||
say "Configuring podman for Lepton"
|
||||
# rootless podman leaks one kernel keyring per container start; ~200 game launches would exhaust the quota
|
||||
mkdir -p ~/.config/containers
|
||||
grep -qs '^ *keyring *=' ~/.config/containers/containers.conf || printf '[containers]\nkeyring = false\n' >> ~/.config/containers/containers.conf
|
||||
|
||||
say "Checking for Lepton (Valve's Android runtime)"
|
||||
if ls ~/.local/share/Steam/steamapps/common/Lepton/lepton >/dev/null 2>&1; then
|
||||
echo "Lepton found."
|
||||
# The rest: Developer Mode (if needed), Lepton, telling the app. As a file, so it can run as its own user service:
|
||||
# stopping Steam ends Desktop Mode and every program started in it, this terminal included.
|
||||
mkdir -p ~/.cache
|
||||
cat >"$JOB" <<'JOB'
|
||||
set -u
|
||||
MODE=$1 PC_URL=$2 PAIR_CODE=$3 CONFIG=$4 HELPER=$5
|
||||
log() { echo "$(date +%T) $*"; }
|
||||
if [[ $MODE == devmode ]]; then
|
||||
sleep 5 # let the user read the terminal before Desktop Mode closes
|
||||
# Steam re-applies "DevModeEnabled" from config.vdf at every start (calling Valve's helper) and rewrites
|
||||
# config.vdf when it exits: stop Steam, set the value, run the helper (pkexec, allowed without a password), start.
|
||||
log "stopping Steam"
|
||||
systemctl --user stop steam.service
|
||||
for _ in $(seq 40); do pgrep -x steam >/dev/null || break; sleep 1; done
|
||||
if pgrep -x steam >/dev/null; then
|
||||
log "Steam didn't stop"
|
||||
systemctl --user start steam.service
|
||||
exit 1
|
||||
fi
|
||||
cp "$CONFIG" "$CONFIG.before-frameport"
|
||||
python3 - "$CONFIG" <<'PY'
|
||||
import re, sys
|
||||
path = sys.argv[1]
|
||||
text = open(path, encoding="utf-8").read()
|
||||
if re.search(r'"DevModeEnabled"\s+"\d+"', text):
|
||||
text = re.sub(r'("DevModeEnabled"\s+)"\d+"', r'\1"1"', text)
|
||||
else: # Steam keeps it in InstallConfigStore/developer
|
||||
lines, keys, key, at = text.splitlines(keepends=True), [], None, None
|
||||
for i, line in enumerate(lines):
|
||||
s = line.strip()
|
||||
if s == "{":
|
||||
keys.append((key or "").lower())
|
||||
if keys == ["installconfigstore", "developer"]:
|
||||
at = ("in", i, line[:len(line) - len(line.lstrip())] + "\t")
|
||||
break
|
||||
elif s == "}":
|
||||
if keys == ["installconfigstore"]: # end of the top section without a developer block
|
||||
at = ("new", i, line[:len(line) - len(line.lstrip())] + "\t")
|
||||
break
|
||||
keys.pop()
|
||||
elif re.fullmatch(r'"[^"]*"', s):
|
||||
key = s.strip('"')
|
||||
if at is None:
|
||||
sys.exit("unexpected config.vdf layout")
|
||||
kind, i, ind = at
|
||||
if kind == "in":
|
||||
lines.insert(i + 1, f'{ind}"DevModeEnabled"\t\t"1"\n')
|
||||
else:
|
||||
lines[i:i] = [f'{ind}"developer"\n', f"{ind}{{\n", f'{ind}\t"DevModeEnabled"\t\t"1"\n', f"{ind}}}\n"]
|
||||
text = "".join(lines)
|
||||
open(path, "w", encoding="utf-8").write(text)
|
||||
PY
|
||||
rc=$?
|
||||
[[ $rc == 0 ]] && { "$HELPER" --enable; rc=$?; }
|
||||
log "Developer Mode: $([[ $rc == 0 && -f /etc/steamos-devkit-enabled ]] && echo on || echo "failed ($rc)")"
|
||||
systemctl --user start steam.service
|
||||
[[ $rc == 0 ]] || exit 1
|
||||
for _ in $(seq 90); do pgrep -x steam >/dev/null && break; sleep 1; done
|
||||
sleep 20 # let Steam finish starting before asking it for anything
|
||||
fi
|
||||
if [[ -e ~/.local/share/Steam/steamapps/common/Lepton/lepton ]]; then
|
||||
log "Lepton found"
|
||||
else
|
||||
echo "Lepton is missing. Make sure Developer Mode is on (Settings > System > Developer)."
|
||||
echo "Asking Steam to install it now; confirm in Steam, or launch 'Lepton Development' from your library once."
|
||||
(steam steam://install/3029110 >/dev/null 2>&1 &) || true
|
||||
log "asking Steam to install Lepton (confirm it in Steam)"
|
||||
steam steam://install/3029110 >/dev/null 2>&1 &
|
||||
fi
|
||||
if curl -fsS "$PC_URL/paired?code=$PAIR_CODE&user=$(id -un)&host=$(hostname)" >/dev/null; then
|
||||
log "told FramePort: done"
|
||||
else
|
||||
log "couldn't reach FramePort at $PC_URL"
|
||||
fi
|
||||
JOB
|
||||
|
||||
if [[ -f /etc/steamos-devkit-enabled ]]; then
|
||||
say "Developer Mode is on"
|
||||
bash "$JOB" finish "$PC_URL" "$PAIR_CODE" "$STEAM_CONFIG" "$DEVKIT_HELPER" 2>&1 | tee "$LOG"
|
||||
say "Done. Return to FramePort on your PC: this Frame should now appear as connected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -fsS "$PC_URL/paired?code=$PAIR_CODE&user=$USER&host=$(hostname)" >/dev/null 2>&1 || true
|
||||
say "Done. Return to FramePort on your PC: this Frame should now appear as connected."
|
||||
say "Turning on Developer Mode"
|
||||
if [[ ! -x "$DEVKIT_HELPER" || ! -f "$STEAM_CONFIG" ]] || \
|
||||
! user_systemd systemd-run --user --collect --quiet --unit="frameport-setup-$$" \
|
||||
bash -c 'bash "$0" "$@" >"$HOME/.cache/frameport-setup.log" 2>&1' \
|
||||
"$JOB" devmode "$PC_URL" "$PAIR_CODE" "$STEAM_CONFIG" "$DEVKIT_HELPER"; then
|
||||
echo "Couldn't turn it on automatically. Turn it on in Settings > System > Developer, then run this command again."
|
||||
exit 1
|
||||
fi
|
||||
echo "Steam restarts to turn it on. That closes the desktop in a few seconds and the Frame returns to its"
|
||||
echo "normal view; setup finishes on its own. If Steam asks to install Lepton, confirm it."
|
||||
echo "Then return to FramePort on your PC: the Frame appears as connected within a minute."
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.Armature.VR4
|
||||
title: VR4
|
||||
status: works
|
||||
notes: Installs the no-ForceQuit build (the regular one quits after the opening cutscene).
|
||||
details: The regular build quits itself when the first level loads after the opening cutscene (System.exit, GitHub
|
||||
issue 10), so the no-ForceQuit build is installed.
|
||||
tested_version: '2.3'
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
use_alt: true
|
||||
verified:
|
||||
date: '2026-10-02'
|
||||
known_good_sha256: 40ff74e37264b28ae8826923b69d0731d9620094c76462be34159643d6ae91b3
|
||||
app: 0.3.4
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 26
|
||||
issue: 1
|
||||
updated: '2026-10-04'
|
||||
source_hint: Resident Evil 4
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.NewFolderGames.IAmCat
|
||||
title: I Am Cat
|
||||
status: issues
|
||||
notes: Renders each eye separately; with the default single-pass rendering the right eye showed only effects on grey.
|
||||
details: Unity 2022.3 with the Oculus XR Plugin falls back to GLES (Zink) on the Frame, where its multiview pass drew
|
||||
the right eye's slice wrong (GitHub issue 8). frame.unity_multipass switches the game to multi-pass rendering; not
|
||||
yet confirmed in a headset.
|
||||
tested_version: 1.4.0.0
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.unity_multipass
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
issue: 8
|
||||
updated: '2026-10-04'
|
||||
source_hint: I Am Cat
|
||||
@@ -0,0 +1,19 @@
|
||||
package: com.beatgames.beatsaber
|
||||
title: Beat Saber
|
||||
status: works
|
||||
tested_version: 1.40.8_7379
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
known_good_sha256: ee720e040931f2547f7611e58ec5ad72d86e800dd4cf3135f576019683f2f359
|
||||
app: 0.6.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 33
|
||||
issue: 6
|
||||
source_hint: Beat Saber
|
||||
@@ -0,0 +1,19 @@
|
||||
package: com.lakento.dinosquest
|
||||
title: Dinosaur Island
|
||||
status: works
|
||||
tested_version: 1.7.5
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
known_good_sha256: 41ecbc728dc533eebcc3980e140fe1764fbd1958735d8a299c63fa9cda13a923
|
||||
app: 0.6.2
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 33
|
||||
issue: 12
|
||||
source_hint: DINOSAUR ISLAND
|
||||
@@ -0,0 +1,19 @@
|
||||
package: com.owlchemylabs.jobsimulator
|
||||
title: Job Simulator
|
||||
status: works
|
||||
tested_version: 1.4.0.6515
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
known_good_sha256: 64638d7f93bb93cc61f422a43a08bb0d35b9bb4b463e90a42da7002147f03d1b
|
||||
app: 0.6.2
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 33
|
||||
issue: 11
|
||||
source_hint: Job Simulator
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.playful.LuckysTale
|
||||
title: Lucky's Tale
|
||||
status: issues
|
||||
notes: Keeps MSAA off; the game turned it on by itself and the whole Frame restarted in a menu.
|
||||
details: Meta's OVRManager raises MSAA to 4x at runtime ("Switching to the recommended level"); multisampled
|
||||
render-to-texture on GLES (Zink) hung the Frame's GPU (GitHub issue 9). frame.unity_runtime_msaa_off keeps it at 0;
|
||||
not yet confirmed in a headset.
|
||||
tested_version: 1.0.55
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.unity_runtime_msaa_off
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
issue: 9
|
||||
updated: '2026-10-04'
|
||||
source_hint: Luckys Tale
|
||||
@@ -0,0 +1,19 @@
|
||||
package: com.resolutiongames.cookinggame
|
||||
title: Cook-Out
|
||||
status: works
|
||||
tested_version: 1.20.0.10625-release
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
known_good_sha256: 7217649c503adf76f3a959fc72858c446c2a42a9ce9e209286977b1950397915
|
||||
app: 0.6.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 33
|
||||
issue: 3
|
||||
source_hint: com.resolutiongames.cookinggame
|
||||
@@ -0,0 +1,21 @@
|
||||
package: com.stremio.vrone
|
||||
title: Stremio
|
||||
status: works
|
||||
notes: 'Unity 6 VR media centre (its interface is web pages in Vuplex 3D WebView). Text fields closed at once on the
|
||||
Frame (no system keyboard): frame.unity_text_input keeps them selected and device.text_input_window shows the
|
||||
Android window so Steam''s on-screen keyboard (or Type on Frame from the PC) types into them; confirmed in the
|
||||
headset by the owner (2026-10-03), who confirmed it working. Note: the add-ons page can stay blank in VR (the
|
||||
add-on sync itself succeeds). "Play in an external player" can''t reach another app: Lepton sends every link to
|
||||
Steam''s browser.'
|
||||
details: 'Patches: frame.unity_text_input (TMP_InputField.TouchScreenKeyboardShouldBeUsed -> false,
|
||||
isKeyboardUsingEvents -> true; uGUI InputField too), device.text_input_window (lepton-show-flatscreen).'
|
||||
tested_version: 0.7.5
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
source_hint: Stremio
|
||||
+9
-2
@@ -44,7 +44,13 @@ signatures:
|
||||
pattern: 'zink.*DEVICE[_ ]LOST|VK_ERROR_DEVICE_LOST'
|
||||
severity: fatal
|
||||
diagnosis: The GPU hung (Zink/Mesa). For GLES Unity games MSAA render-to-texture is the usual cause.
|
||||
suggest: [frame.unity_no_msaa]
|
||||
suggest: [frame.unity_no_msaa, frame.unity_runtime_msaa_off]
|
||||
- id: unity-runtime-msaa
|
||||
pattern: 'recommended MSAA level is [248]\. Switching to the recommended level'
|
||||
severity: info
|
||||
diagnosis: The game's OVRManager turns MSAA on at runtime (QualitySettings can't keep it off). On GLES this can
|
||||
hang the Frame's GPU or restart the headset; if it does, keep it off in the game's code.
|
||||
suggest: [frame.unity_runtime_msaa_off]
|
||||
- id: gl-shader-failed
|
||||
pattern: 'GLShim\s*: SHADER COMPILE FAILED|#extension directive is not allowed in the middle|could not implicitly convert operands'
|
||||
severity: error
|
||||
@@ -79,7 +85,8 @@ signatures:
|
||||
agent's controller_models command).
|
||||
suggest: []
|
||||
- id: scene-missing
|
||||
pattern: 'XR_FB_scene|XR_FB_spatial_entity.*(not supported|UNSUPPORTED)|xrQuerySpacesFB'
|
||||
# not OVRPlugin's routine "Unavailable OpenXR extension: XR_FB_scene" (printed by every OVRPlugin game)
|
||||
pattern: '(?<!Unavailable OpenXR extension: )XR_FB_scene|XR_FB_spatial_entity.*(not supported|UNSUPPORTED)|xrQuerySpacesFB'
|
||||
severity: warning
|
||||
diagnosis: The game uses the Meta scene (room) API; enable the adapter's guardian-based room emulation.
|
||||
suggest: [adapter.scene_emul]
|
||||
|
||||
@@ -84,3 +84,21 @@ update [--check] [--yes]`. CI runs `scripts/update_smoke.py` on every OS with th
|
||||
overport CLI release (Android-XR-Bridge/OVRPort, fallback ovrport/app) + patch list + titles, Temurin JRE (Adoptium API), apksigner (Google repository index),
|
||||
store artwork/titles (overport image API), catalog (optional remote), Lepton location/appid (Frame appmanifests),
|
||||
Steam user (Frame userdata).
|
||||
|
||||
## Built on
|
||||
|
||||
FramePort is a front end for other projects; most of the functionality comes from them:
|
||||
|
||||
| Project | Used for |
|
||||
|---|---|
|
||||
| [OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (overport, originally [ovrport/app](https://github.com/ovrport/app)) | Converts Quest games to OpenXR: its CLI applies the game patches and supplies the OpenXR loader; FramePort also includes its VrApi→OpenXR adapter |
|
||||
| Valve Lepton, Proton and SteamVR | Run Android games, Windows games and OpenXR on the Frame |
|
||||
| [Revive](https://github.com/LibreVR/Revive) (LibreVR) | Runs Oculus Rift games on OpenXR / SteamVR |
|
||||
| Mesa (Zink) | OpenGL ES on Vulkan on the Frame |
|
||||
| [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK) | OpenXR headers for the native layers |
|
||||
| Eclipse Temurin, Android apksigner, Android NDK | Java runtime, APK signing, building the native layers |
|
||||
| [Flet](https://flet.dev) | The desktop app |
|
||||
| OculusDB, Steam store | Game descriptions, genres and artwork |
|
||||
|
||||
FramePort's own parts: game detection and recipes, the Steam Frame OpenXR adapter (FrameBridge) and the other native
|
||||
fixes in `native/`, the installer agent that runs on the Frame, and the desktop/command-line app.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Compatibility
|
||||
|
||||
The built-in catalog has tested settings for games, each marked as working, working with known issues, or not
|
||||
running on the Frame: see the [list of tested games](GAMES.md) (recipes in [catalog/games](../catalog/games)).
|
||||
Other games get suggested patches from detection rules; each suggestion states its reason, and every patch can be
|
||||
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.
|
||||
|
||||

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

|
||||
@@ -105,3 +105,14 @@
|
||||
- Processes started from Steam (Konsole, SSH sessions?) share steam.service's cgroup: use `systemd-run --user`.
|
||||
- SSH: `sshd` must be enabled (`sudo systemctl enable --now sshd`), which needs a user password (`passwd`).
|
||||
- mDNS: avahi-daemon runs by default; hostname `frame` → `frame.local`.
|
||||
|
||||
## Text input
|
||||
|
||||
- Lepton's Android has no on-screen keyboard (IME) and VR apps run headless, so no Android window has keyboard focus
|
||||
and key presses (Steam's keyboard, USB/Bluetooth keyboards, `input text`) don't reach VR apps. The app folder's
|
||||
`lepton-show-flatscreen` marker shows the window (VR keeps working) and gives it focus; Steam's on-screen keyboard
|
||||
then opens for its text fields (FramePort patch `device.text_input_window`).
|
||||
- `/dev/uinput` is writable by the steamos user (ACL for Steam Input): a uinput virtual keyboard works like a real
|
||||
one everywhere (FramePort's "Type on Frame", agent `_keyboard`).
|
||||
- Unity text fields close without a system keyboard; see `frame.unity_text_input` in PLAYBOOK.md.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Frame setup: what changes, networks, undoing it
|
||||
|
||||
For the steps themselves see [INSTALL.md](INSTALL.md#connecting-the-steam-frame).
|
||||
|
||||
## What the setup changes
|
||||
|
||||
The setup command runs [`bootstrap/bootstrap.sh`](../bootstrap/bootstrap.sh), served by the app over your
|
||||
local network. Everything it changes:
|
||||
|
||||
| Change | Where | How to undo |
|
||||
|---|---|---|
|
||||
| Turns on **Developer Mode** (only if it's off). Steam restarts once, which closes Desktop Mode; the rest of the setup finishes on its own as a user service (log: `~/.cache/frameport-setup.log`). | `"DevModeEnabled" "1"` in `~/.local/share/Steam/config/config.vdf` (old file kept as `config.vdf.before-frameport`), then Valve's own `steamos-polkit-helpers/steamos-devkit-mode --enable`. That helper enables the SSH server (`sshd`), the devkit service that makes the Frame findable on the network, the remote-desktop and debug services, and system crash dumps. | Settings → System → Developer → Developer Mode off. Valve's helper switches all of those services off again. |
|
||||
| Lets the app's SSH key in. | One line ending in `frameport` in `~/.ssh/authorized_keys`. The folder and file are created if missing. | Delete that line. |
|
||||
| Configures podman for Lepton. Rootless podman leaks one kernel keyring per container start, and after about 200 game starts every game fails. | `[containers]` / `keyring = false` in `~/.config/containers/containers.conf`. | Remove those lines. |
|
||||
| Asks Steam to install **Lepton** (Valve's Android runtime, Steam app 3029110) if it's missing. You confirm it in Steam. | Steam library | Uninstall it in Steam. |
|
||||
|
||||
The script runs as your user: no root, no `sudo`, no password. The only system-level change, Developer Mode, is
|
||||
made by Valve's own helper, the same one the Settings switch uses. If Developer Mode can't be turned on
|
||||
automatically, the script asks you to turn it on in Settings → System → Developer and run the command again.
|
||||
|
||||
Nothing else on the system is touched: no packages, no read-only-filesystem changes, no polkit rules. The script
|
||||
also leaves two files: `~/.cache/frameport-setup.sh` (the part that runs on its own) and its log.
|
||||
|
||||
**Later, the app adds** (all as your user, no root, no `sudo`):
|
||||
- FramePort's helper in `~/.local/share/frameport/`;
|
||||
- the games, each with a launcher and its data in `~/Applications/quest-frame/<package>/`;
|
||||
- their Steam library entries and artwork (`shortcuts.vdf` + `config/grid/`);
|
||||
- for PC VR games: an OpenXR layer (`~/.local/share/openxr/1/api_layers/explicit.d/XR_APILAYER_FRAMEPORT_timefix.json`)
|
||||
and, when the first PC VR game is installed, Valve's ARM64 Proton and its Steam Linux Runtime (Steam downloads them;
|
||||
Steam restarts once).
|
||||
|
||||
**Settings → Uninstall FramePort → Also remove from the Frame** deletes the games, their Steam entries, the helper
|
||||
folder, the OpenXR layer and the setup script's files. Developer Mode, the SSH key line, the podman setting, Lepton
|
||||
and Proton stay. Undo them as shown above or in Steam.
|
||||
|
||||
## Network and firewalls
|
||||
|
||||
The setup command is the only time the Frame connects to your computer: it downloads the script from FramePort on
|
||||
TCP port 8765 (8766/8767 if taken), only while the setup command is shown and for at most 30 minutes. Everything
|
||||
else goes from the computer to the Frame. If the command just says "timed out", the setup page shows what is likely
|
||||
blocking it after about 45 seconds:
|
||||
|
||||
- **Windows:** allow FramePort (or Python, when running from source) when Windows asks. On a network Windows treats as
|
||||
**Public** it stays blocked unless you allow public networks; set your home network to Private in Windows'
|
||||
network settings instead.
|
||||
- **macOS:** with the firewall on (System Settings → Network → Firewall), allow incoming connections for FramePort
|
||||
when asked.
|
||||
- **Linux:** firewalld: `sudo firewall-cmd --add-port=8765/tcp` (until the next restart). ufw:
|
||||
`sudo ufw allow 8765/tcp`, afterwards `sudo ufw delete allow 8765/tcp`.
|
||||
- **WSL:** Windows' Hyper-V firewall blocks connections into WSL without asking. FramePort adds a temporary rule for
|
||||
the setup ports (one admin prompt) and removes it again when setup is done or after 35 minutes. WSL must use
|
||||
mirrored networking: `networkingMode=mirrored` under `[wsl2]` in `%UserProfile%\.wslconfig`, then `wsl --shutdown`.
|
||||
- Or skip the setup command and use the devkit pairing (see [INSTALL.md](INSTALL.md#connecting-the-steam-frame)): it needs no connection into your computer.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Tested games
|
||||
|
||||
Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort suggests patches for them, and a working config can be shared from the app (**Share working config…**).
|
||||
Tested a game? [Share a working config](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml) or [report a problem](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml) (in the app: the game's **…** menu does both and fills in the details).
|
||||
Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
|
||||
| Game | Platform | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 4XVR Video Player | Quest | ✅ Works | |
|
||||
| Asgard's Wrath 2 | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
|
||||
| Batman: Arkham Shadow | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
| Carve Snowboarding | Quest | ✅ Works | |
|
||||
| Cook-Out | Quest | ✅ Works | |
|
||||
| Demeter | Quest | ✅ Works | |
|
||||
| Dinosaur Island | Quest | ✅ Works | |
|
||||
| Espire 2 | Quest | ✅ Works | |
|
||||
| Genotype | Quest | ✅ Works | |
|
||||
| H.U.N.T | Quest | ✅ Works | |
|
||||
| In Death: Unchained | Quest | ✅ Works | |
|
||||
| Job Simulator | Quest | ✅ Works | |
|
||||
| LEGO® Bricktales | Quest | ✅ Works | |
|
||||
| Marvel's Deadpool VR | Quest | ✅ Works | |
|
||||
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
|
||||
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
|
||||
| Nano | Quest | ✅ Works | |
|
||||
| NEX Player | Quest | ✅ Works | |
|
||||
| NOPE CHALLENGE | Quest | ✅ Works | |
|
||||
| Path of the Warrior | Quest | ✅ Works | |
|
||||
| PowerWash Simulator VR | Quest | ✅ Works | |
|
||||
| Rick and Morty: Virtual Rick-ality | PC VR | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Stremio | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
| Toy Master | Quest | ✅ Works | |
|
||||
| Under Cover | Quest | ✅ Works | |
|
||||
| VR4 | Quest | ✅ Works | |
|
||||
| Wallace & Gromit in The Grand Getaway | Quest | ✅ Works | |
|
||||
| Arcsmith | Quest | ⚠️ Works with issues | Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out). |
|
||||
| Assassin's Creed Nexus | Quest | ⚠️ Works with issues | Some launch warning text is still upside down; the rest of the UI is fixed by flip emulation. |
|
||||
| I Am Cat | Quest | ⚠️ Works with issues | Renders each eye separately; with the default single-pass rendering the right eye showed only effects on grey. |
|
||||
| Lucky's Tale | Quest | ⚠️ Works with issues | Keeps MSAA off; the game turned it on by itself and the whole Frame restarted in a menu. |
|
||||
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
|
||||
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
|
||||
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
|
||||
| Journey of the Gods | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
| Shadow Point | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
| Sniper Elite VR | Quest | ❌ Doesn't run | GPU hang (zink: DEVICE LOST) even with MSAA off. |
|
||||
| Sports Scramble (Santa Cruz) | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
+53
-7
@@ -9,6 +9,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; Ubuntu 22.04 or newer) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
|
||||
| Linux (ARM64, GTK 3; Ubuntu 22.04 or newer) | `FramePort-linux-arm64.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).
|
||||
@@ -22,16 +23,36 @@ start shows a warning:
|
||||
`FramePort-selfsigned.cer` (attached to each release) into *Trusted Root Certification Authorities* (Current User) to
|
||||
show FramePort as the publisher; the certificate can only sign code. Remove it with `certmgr.msc`.
|
||||
- **macOS:** right-click `FramePort.app` → **Open** → **Open** (once), or `xattr -dr com.apple.quarantine FramePort.app`.
|
||||
- **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort`.
|
||||
- **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort` (ARM64: `FramePort-linux-arm64.tar.gz`).
|
||||
|
||||
## Connecting the Steam Frame
|
||||
|
||||
1. On the Frame: Settings → System → Developer → turn on **Developer Mode**. The Frame and the computer must be on the
|
||||
same network.
|
||||
2. In FramePort open **Steam Frame**. A Frame in Developer Mode appears in the list.
|
||||
3. First time only: on the Frame switch to Desktop mode (Steam button → Power → Switch to Desktop), open Konsole and
|
||||
run the command FramePort shows. It enables SSH, authorises this computer and installs Valve's Android runtime
|
||||
(Lepton) if needed.
|
||||
The Frame and the computer must be on the same network.
|
||||
|
||||
1. In FramePort open **Steam Frame** and click **Show setup command**.
|
||||
2. First time only, on the Frame:
|
||||
1. Open the **SteamVR dashboard → Launch a program → Desktop**: the Linux desktop opens on a virtual screen.
|
||||
2. Open the app menu (bottom-left corner of that desktop) → **System → Konsole** (or search for Konsole).
|
||||
3. Type the command FramePort shows exactly as shown (on-screen keyboard or any USB/Bluetooth keyboard) and press
|
||||
**Enter**. It looks like `curl -fsS 192.168.1.20:8765/1a2b3c4d | bash`: your computer's address, then a
|
||||
one-time code.
|
||||
4. After a few seconds the desktop closes by itself (Steam restarts once); that's expected. If
|
||||
Steam asks to install **Lepton** (Valve's Android runtime), confirm it.
|
||||
|
||||
FramePort connects by itself within a minute. No password is needed. The command lets FramePort in and turns on
|
||||
**Developer Mode** (which includes SSH); everything it changes is listed in [FRAME_SETUP.md](FRAME_SETUP.md).
|
||||
3. Later starts: a Frame in Developer Mode appears in the list and FramePort connects to it automatically. (If you
|
||||
turn Developer Mode off in Settings → System → Developer, turn it on again there.)
|
||||
|
||||
**Without Konsole:** turn on Developer Mode yourself (Settings → System → Developer). The Frame then appears under
|
||||
**On your network**. On the Frame open Settings → Developer → **Pair new host**, then click **Connect** in FramePort
|
||||
and approve it on the Frame (Valve's own devkit pairing; it only sends this computer's key to the Frame). Install
|
||||
Lepton from the Steam Frame page afterwards if it's missing.
|
||||
|
||||
### Firewalls
|
||||
|
||||
If the setup command only says "timed out", a firewall on your computer blocks the Frame; the setup page
|
||||
says which after about 45 seconds. Details per system: [FRAME_SETUP.md](FRAME_SETUP.md#network-and-firewalls).
|
||||
|
||||
## Installing games
|
||||
|
||||
@@ -50,6 +71,26 @@ start shows a warning:
|
||||
- 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**).
|
||||
|
||||

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

|
||||
|
||||
## Typing on the Frame
|
||||
|
||||
- **Type on Frame** (Steam Frame page, a game's menu, or the keyboard icon on the sidebar's Frame card): while the
|
||||
window is open, this computer's keyboard works as a keyboard plugged into the Frame. Select a text field in the
|
||||
headset (in an app, Steam or the desktop) and type; Esc and shortcuts go to the Frame too. Paste longer text into
|
||||
the box to type it in one go (US keyboard layout). Click **Done** to disconnect.
|
||||
- **Steam's on-screen keyboard** opens for text fields of apps shown as a window (2D apps, and VR apps with
|
||||
**Show the app's Android window**). Steam lists that window as **Gamescope** (the Frame's display compositor);
|
||||
leave it open: it's what receives the typing, the VR view isn't affected.
|
||||
- **Unity apps whose text fields close at once** (a caret flashes, nothing can be typed): FramePort suggests
|
||||
**Make Unity text fields work** for them. The first time, it downloads Cpp2IL (a tool that finds the right spot in the
|
||||
game's code, ~17 MB). Games added before this version: open the game's menu → **Analyze again**, then reinstall.
|
||||
|
||||
## Updating
|
||||
|
||||
FramePort checks for a new release at start and every 6 hours (it only downloads the release information). When one
|
||||
@@ -78,6 +119,11 @@ 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.
|
||||
|
||||
**Windows games without VR:** **Add games → Add one game folder…** with the game's folder (pick the program that
|
||||
starts it if asked). FramePort installs it on the Frame and Proton runs it as a window, like Steam's own Windows games;
|
||||
it shows up in the Frame's Steam library tagged "Windows game on Frame". Whether a game runs depends on Proton on ARM
|
||||
(x86 games run through emulation).
|
||||
|
||||
## Files on the Frame (videos, documents, mods, saves)
|
||||
|
||||
The **Files** tab manages files on the Frame over the same connection as installs; no other transfer app is needed.
|
||||
|
||||
@@ -14,6 +14,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Every game suddenly fails to start: `crun: create keyring …: Disk quota exceeded`, `is not a running context` | rootless podman leaked a kernel keyring per launch; 200-key quota exhausted | `keyring = false` in `~/.config/containers/containers.conf` (FramePort agent does it), then reboot the Frame once |
|
||||
| A Unity app's text field shows a caret for a moment and loses focus; no keyboard appears (e.g. Stremio VR login) | Unity's TMP_InputField/InputField wait for Android's on-screen keyboard and close themselves without one (Lepton has none); headless VR apps also have no focused Android window, so no key press reaches them | `frame.unity_text_input` (Cpp2IL finds `TouchScreenKeyboardShouldBeUsed`/`isKeyboardUsingEvents`, rewritten to false/true) + `device.text_input_window` (lepton-show-flatscreen: Steam's keyboard and Type on Frame work) |
|
||||
| `APP_ACTIVITY is empty`, nothing starts | Manifest has category INFO only; Lepton needs LAUNCHER | `frame.launcher` (automatic) |
|
||||
| PC VR game on the Frame shows as a flat window / Revive: `Unable to load LibOVRRT DLL` / `LoaderInstance::CreateInstance chained CreateInstance call failed` | Frame SteamVR runtime rejects OpenXR apiVersion 1.1 (`XR_ERROR_API_VERSION_UNSUPPORTED`), which Proton 11's VR helper requests | `pcvr.xr_timefix` (Frame OpenXR layer, default on): retries xrCreateInstance as 1.0 |
|
||||
| Unreal PC VR game on the Frame runs as a flat window although OpenXR works (no `LogHMD` OVRPlugin lines; Unreal logs nothing when it skips the Oculus plugin) / launch.log: `FramePort oculushmd: could not create the OculusHMDConnected event` | UE's OculusHMD (and LibOVR's `ovr_Detect`) only start when the Windows event `OculusHMDConnected` exists and is signalled; on a PC the Oculus service creates it. Revive hooks `OpenEventW` for it, but that relies on Detours patching Wine's (ARM64EC) kernelbase | `pcvr.oculus_unreal` (default for Unreal Rift games; PC VR counterpart of overport's `patch_oculus_unreal`): launch.sh runs the injector through `fp_oculushmd.exe`, which provides the real event until the game exits |
|
||||
@@ -49,6 +50,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| 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 | 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 |
|
||||
| Unity game hangs or the whole Frame restarts (e.g. in a menu); log: `The current MSAA level is 0, but the recommended MSAA level is 4. Switching to the recommended level.` (e.g. Lucky's Tale) | Meta's OVRManager (`useRecommendedMSAALevel`) turns 4x MSAA on at runtime, past QualitySettings; multisampled render-to-texture on GLES/Zink hangs the GPU | `frame.unity_runtime_msaa_off` (Cpp2IL: `OVRDisplay.get_recommendedMSAALevel` → 0) |
|
||||
| Unity game: one eye shows only effects or grey, the other is fine (e.g. I Am Cat, Oculus XR Plugin on GLES) | the game's single-pass multiview rendering goes wrong for array slice 1 (the adapter submits both slices correctly) | `frame.unity_multipass` (Cpp2IL: `OculusSettings.GetStereoRenderingMode` → MultiPass); `swap_eyes=1` tells whether the game or the adapter is at fault |
|
||||
| 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 |
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,8 @@
|
||||
## Install
|
||||
|
||||
- **Windows:** unzip `FramePort-windows-x64.zip`, run `FramePort.exe` (warning → **More info → Run anyway**).
|
||||
- **macOS:** unzip `FramePort-macos-arm64.zip`, right-click `FramePort.app` → **Open**.
|
||||
- **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort` (ARM64: `FramePort-linux-arm64.tar.gz`).
|
||||
|
||||
Full guide: [docs/INSTALL.md](https://github.com/spoopyghosty0/frameport/blob/main/docs/INSTALL.md) · checksums in
|
||||
`SHA256SUMS.txt`.
|
||||
@@ -21,7 +21,10 @@ from frameport.recommend.catalog import CatalogEntry, to_yaml # noqa: E402
|
||||
|
||||
PKG_RE = re.compile(r"^(rift\.[a-z0-9_]{1,80}|[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+){1,8})$")
|
||||
STATUSES = {"works", "issues", "unsupported", "unknown"}
|
||||
LIST_FIELDS = {"overport_extra", "overport_remove", "alt_overport", "frame", "frame_remove", "pcvr", "pcvr_remove"}
|
||||
LIST_FIELDS = {"overport_extra", "overport_remove", "alt_overport", "frame", "frame_remove", "pcvr", "pcvr_remove",
|
||||
"device"}
|
||||
# FramePort's own patch ids must exist (OVRPort's are listed by its CLI at runtime, so not checked here)
|
||||
OWN_PATCH_FIELDS = ("frame", "frame_remove", "pcvr", "pcvr_remove", "device")
|
||||
DICT_FIELDS = {"adapter", "device_files", "lepton_env", "proton_env", "verified"}
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,80}$")
|
||||
|
||||
@@ -64,6 +67,13 @@ def validate(d: dict) -> CatalogEntry:
|
||||
for k in LIST_FIELDS & set(d):
|
||||
if not isinstance(d[k], list) or not all(isinstance(x, str) and ID_RE.match(x) for x in d[k]):
|
||||
raise Invalid(f"{k} must be a list of patch ids")
|
||||
from frameport.patches import base
|
||||
|
||||
base.load_all()
|
||||
for k in OWN_PATCH_FIELDS:
|
||||
unknown_ids = [x for x in d.get(k) or [] if x not in base.REGISTRY]
|
||||
if unknown_ids:
|
||||
raise Invalid(f"{k}: unknown patch id(s) {', '.join(unknown_ids)}")
|
||||
for k in DICT_FIELDS & set(d):
|
||||
if not isinstance(d[k], dict):
|
||||
raise Invalid(f"{k} must be a mapping")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Write docs/GAMES.md: the tested games from the bundled catalog as a simple table to share.
|
||||
Run after catalog changes: python scripts/compat_list.py"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from frameport.recommend import catalog # noqa: E402
|
||||
|
||||
STATUS = {"works": "✅ Works", "issues": "⚠️ Works with issues", "unsupported": "❌ Doesn't run"}
|
||||
ORDER = {"works": 0, "issues": 1, "unsupported": 2}
|
||||
ISSUES = "https://github.com/spoopyghosty0/frameport/issues/new?template="
|
||||
SHARE, REPORT = ISSUES + "working-config.yml", ISSUES + "bug-report.yml"
|
||||
|
||||
|
||||
def short(note: str) -> str:
|
||||
"""The note's first sentence, without technical asides."""
|
||||
first = re.split(r"(?<=[.!?])\s", " ".join((note or "").split()), maxsplit=1)[0]
|
||||
return first if len(first) <= 160 else first[:157].rstrip() + "…"
|
||||
|
||||
|
||||
def render() -> str:
|
||||
entries = [e for e in catalog.load().values() if e.origin == "bundled" and e.status in STATUS]
|
||||
entries.sort(key=lambda e: (ORDER[e.status], e.title.lower()))
|
||||
rows = ["| Game | Platform | Status | Notes |", "|---|---|---|---|"]
|
||||
for e in entries:
|
||||
platform = "PC VR" if e.kind == "rift" else "Quest"
|
||||
note = "" if e.status == "works" else short(e.notes).replace("|", "/")
|
||||
rows.append(f"| {e.title} | {platform} | {STATUS[e.status]} | {note} |")
|
||||
return ("# Tested games\n\n"
|
||||
"Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort "
|
||||
"suggests patches for them, and a working config can be shared from the app (**Share working config…**).\n"
|
||||
f"Tested a game? [Share a working config]({SHARE}) or [report a problem]({REPORT}) (in the app: the game's "
|
||||
"**…** menu does both and fills in the details).\n"
|
||||
"Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.\n\n"
|
||||
+ "\n".join(rows) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = ROOT / "docs" / "GAMES.md"
|
||||
out.write_text(render(), encoding="utf-8")
|
||||
print(f"wrote {out}")
|
||||
+16
-1
@@ -40,9 +40,19 @@ def stage_data():
|
||||
shutil.rmtree(DATA, ignore_errors=True)
|
||||
for name in ("catalog", "artifacts", "agent", "bootstrap"):
|
||||
shutil.copytree(ROOT / name, DATA / name)
|
||||
# the Frame agent is uploaded as source; `flet build` turns every .py into .pyc (issue #2): keep a non-.py copy
|
||||
shutil.copy2(ROOT / "agent/frameport_agent.py", DATA / "agent/frameport_agent.py.txt")
|
||||
print(f"staged data in {DATA}")
|
||||
|
||||
|
||||
def check_bundle(out: Path) -> None:
|
||||
"""The finished bundle must contain the agent's source (the app uploads it to the Frame)."""
|
||||
found = [p for p in out.rglob("frameport_agent.py*") if p.suffix != ".pyc"]
|
||||
if not found:
|
||||
raise SystemExit(f"{out}: the Frame agent's source is missing from the bundle (only compiled?)")
|
||||
print("agent source in bundle:", ", ".join(str(p.relative_to(out)) for p in found))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--pyinstaller", action="store_true")
|
||||
@@ -60,6 +70,8 @@ def main() -> int:
|
||||
# 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"]
|
||||
if TARGET == "linux":
|
||||
cmd.append("--onedir") # a folder: the bundle check sees the agent source, the archive the app
|
||||
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",
|
||||
@@ -67,7 +79,10 @@ def main() -> int:
|
||||
if TARGET == "macos": # flet's default bundled Python lacks prebuilt cryptography wheels for both Mac archs
|
||||
cmd += ["--python-version", "3.12", "--arch", "arm64"] # Apple Silicon; x86_64 cross-build fails
|
||||
print(" ".join(cmd))
|
||||
return subprocess.call(cmd, cwd=ROOT)
|
||||
rc = subprocess.call(cmd, cwd=ROOT)
|
||||
if rc == 0:
|
||||
check_bundle(ROOT / "dist" if args.pyinstaller else ROOT / "dist" / TARGET)
|
||||
return rc
|
||||
finally:
|
||||
if not args.keep_data:
|
||||
shutil.rmtree(DATA, ignore_errors=True)
|
||||
|
||||
@@ -100,6 +100,35 @@ class FakeTarget:
|
||||
pass
|
||||
|
||||
|
||||
class FakeKeyboardSession:
|
||||
"""--fake-frame: Type on Frame connects at once (nothing is sent anywhere)."""
|
||||
|
||||
def __init__(self, frame):
|
||||
self.closed = False
|
||||
|
||||
def key(self, name, action="down"):
|
||||
return True
|
||||
|
||||
def text(self, text):
|
||||
return ""
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def open_type_dialog(app: FramePortApp) -> None:
|
||||
"""Type on Frame over the Steam Frame page, with one key 'pressed' so the screenshot shows it working."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
app.navigate(1)
|
||||
time.sleep(1.5)
|
||||
app.type_on_frame()
|
||||
time.sleep(1.5)
|
||||
dialog = [d for d in app.page._dialogs.controls if d.open][-1]
|
||||
listener = dialog.content.controls[1]
|
||||
listener.on_key_down(SimpleNamespace(key="Enter"))
|
||||
|
||||
|
||||
STEP_SECONDS = 4 # per screen (the screenshot is taken ~3 s in)
|
||||
|
||||
|
||||
@@ -158,6 +187,7 @@ def main() -> int:
|
||||
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]]))
|
||||
steps.append(("type-on-frame", open_type_dialog))
|
||||
if args.install_questions:
|
||||
queued: list[str] = []
|
||||
|
||||
@@ -210,6 +240,9 @@ def main() -> int:
|
||||
ready, done = threading.Event(), []
|
||||
|
||||
if args.fake_frame: # never reach a real Frame (start-up auto-connect, discovery, the 30 s poll)
|
||||
from frameport.frame import keyboard
|
||||
|
||||
keyboard.KeyboardSession = FakeKeyboardSession
|
||||
FramePortApp.connect = lambda self, *a, **k: None
|
||||
FramePortApp.refresh_frame = lambda self, *a, **k: None
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.6.3"
|
||||
@@ -13,6 +13,7 @@ from . import elf
|
||||
logging.getLogger("pyaxmlparser").setLevel(logging.ERROR)
|
||||
|
||||
UNITY_GGM = "assets/bin/Data/globalgamemanagers"
|
||||
IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
|
||||
|
||||
def _read_manifest_info(path: Path) -> tuple[str, str, str, str | None]:
|
||||
@@ -106,6 +107,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
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
|
||||
il2cpp_meta = z.read(IL2CPP_METADATA) if deep and IL2CPP_METADATA in names and "libil2cpp.so" in libs else None
|
||||
|
||||
package, version, label, activity = _read_manifest_info(path)
|
||||
libset = set(libs)
|
||||
@@ -189,10 +191,44 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
"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)),
|
||||
# Unity (IL2CPP) text fields: they close at once on the Frame (frame.unity_text_input)
|
||||
"text_fields": unity_text_fields(il2cpp_meta) if il2cpp_meta else [],
|
||||
# Meta's OVRManager raises MSAA at runtime (frame.unity_runtime_msaa_off); Oculus XR Plugin (multiview)
|
||||
"ovr_runtime_msaa": bool(il2cpp_meta) and b"\0useRecommendedMSAALevel\0" in il2cpp_meta,
|
||||
"oculus_xr_plugin": bool(il2cpp_meta) and b"\0m_StereoRenderingModeAndroid\0" in il2cpp_meta,
|
||||
"unity_version": unity_version(ggm, lib_bytes.get("libunity.so")) if engine == "Unity" else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def unity_text_fields(metadata: bytes) -> list[str]:
|
||||
"""The Unity text field classes an IL2CPP game contains (names from its global-metadata.dat string table)."""
|
||||
out = []
|
||||
if b"\0TMP_InputField\0" in metadata:
|
||||
out.append("TMP_InputField")
|
||||
if b"\0InputField\0" in metadata and b"\0UnityEngine.UI\0" in metadata: # not the tail of TMP_InputField
|
||||
out.append("InputField")
|
||||
return out
|
||||
|
||||
|
||||
UNITY_VERSION = re.compile(rb"(?<![\d.])(\d{4}\.\d+\.\d+[abfpx]\d+)")
|
||||
|
||||
|
||||
def unity_version(ggm: bytes | None = None, libunity: bytes | None = None) -> str | None:
|
||||
"""Unity version (e.g. 6000.2.7f2): from the header of globalgamemanagers, else the version string libunity.so
|
||||
repeats most (games packed into data.unity3d have no loose globalgamemanagers)."""
|
||||
m = UNITY_VERSION.search(ggm[:512]) if ggm else None
|
||||
if m:
|
||||
return m.group(1).decode()
|
||||
if libunity:
|
||||
from collections import Counter
|
||||
|
||||
found = Counter(x.group(1) for x in UNITY_VERSION.finditer(libunity))
|
||||
if found:
|
||||
return found.most_common(1)[0][0].decode()
|
||||
return None
|
||||
|
||||
|
||||
def _uses_feature(manifest: bytes, feature: str) -> bool:
|
||||
x = axml.Axml(manifest)
|
||||
return any(el.name == "uses-feature" and (x.attr_str(el, "name") or "").startswith(feature) for el in x.elements())
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Find methods in an IL2CPP Unity game: Cpp2IL lists every method with its address ([Address(... Offset = "0x...")]
|
||||
in its "diffable C#" output with the attribute injector). Offset is the method's position in the libil2cpp.so FILE
|
||||
(not the virtual address: those differ, e.g. by 0x4000 in Stremio VR's library), which is what a byte patch needs.
|
||||
|
||||
Results are cached per game build (its global-metadata.dat), so a rebuild of the same game doesn't run Cpp2IL again,
|
||||
and neither does the next patch of the same build (an earlier one has already changed libil2cpp.so's bytes, never its
|
||||
layout)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.cache import cache_dir
|
||||
|
||||
_ADDRESS = re.compile(r'Offset = "0x([0-9A-Fa-f]+)", Length = "0x([0-9A-Fa-f]+)"')
|
||||
|
||||
|
||||
def parse_methods(cs_text: str, names: list[str]) -> dict[str, tuple[int, int]]:
|
||||
"""{method name: (file offset, length)} for the named methods of one Cpp2IL diffable-C# class file."""
|
||||
lines = cs_text.splitlines()
|
||||
found: dict[str, tuple[int, int]] = {}
|
||||
for i, line in enumerate(lines):
|
||||
for name in names:
|
||||
if name in found or not re.search(rf"\s{re.escape(name)}\s*\(", line) or "Token" in line:
|
||||
continue
|
||||
for back in range(i - 1, max(i - 4, -1), -1):
|
||||
m = _ADDRESS.search(lines[back])
|
||||
if m:
|
||||
found[name] = (int(m.group(1), 16), int(m.group(2), 16))
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def _plain_version(version: str) -> str:
|
||||
"""6000.2.7f2 -> 6000.2.7 (what Cpp2IL's --force-unity-version takes)."""
|
||||
m = re.match(r"\d+\.\d+\.\d+", version)
|
||||
return m.group(0) if m else version
|
||||
|
||||
|
||||
def _cache_file(lib: bytes, metadata: bytes) -> Path:
|
||||
key = hashlib.sha256(metadata + len(lib).to_bytes(8, "little")).hexdigest()[:32]
|
||||
return cache_dir() / "il2cpp" / (key + ".json")
|
||||
|
||||
|
||||
def find_methods(lib: bytes, metadata: bytes, unity_version: str, wanted: dict[str, list[str]],
|
||||
cpp2il: Path | None = None, timeout: float = 900) -> dict[str, dict[str, tuple[int, int]]]:
|
||||
"""wanted: {class file (relative to Cpp2IL's DiffableCs folder, e.g. "Unity.TextMeshPro/TMPro/TMP_InputField.cs"):
|
||||
[method names]} -> {class file: {method: (file offset, length)}}; classes or methods not in the game are left
|
||||
out."""
|
||||
cache_file = _cache_file(lib, metadata)
|
||||
try:
|
||||
cached = json.loads(cache_file.read_text())
|
||||
if all(cls in cached.get("classes", {}) or cls in cached.get("missing", []) for cls in wanted):
|
||||
return {cls: {m: tuple(v) for m, v in cached["classes"][cls].items() if m in names}
|
||||
for cls, names in wanted.items() if cls in cached.get("classes", {})}
|
||||
except (OSError, ValueError, KeyError):
|
||||
pass
|
||||
if cpp2il is None:
|
||||
from ..tools import cpp2il as tool
|
||||
|
||||
cpp2il = tool.ensure()
|
||||
with tempfile.TemporaryDirectory(prefix="frameport-il2cpp-") as tmp:
|
||||
t = Path(tmp)
|
||||
(t / "libil2cpp.so").write_bytes(lib)
|
||||
(t / "global-metadata.dat").write_bytes(metadata)
|
||||
proc = subprocess.run([str(cpp2il), "--force-binary-path", str(t / "libil2cpp.so"),
|
||||
"--force-metadata-path", str(t / "global-metadata.dat"),
|
||||
"--force-unity-version", _plain_version(unity_version),
|
||||
"--use-processor", "attributeinjector", "--output-as", "diffable-cs",
|
||||
"--output-to", str(t / "out")], capture_output=True, text=True, errors="replace",
|
||||
timeout=timeout, cwd=tmp)
|
||||
root = t / "out" / "DiffableCs"
|
||||
if not root.is_dir():
|
||||
raise RuntimeError("Cpp2IL couldn't read this game's code: " + (proc.stdout + proc.stderr)[-400:].strip())
|
||||
out, missing = {}, []
|
||||
for cls, names in wanted.items():
|
||||
f = root / cls
|
||||
if f.exists():
|
||||
out[cls] = parse_methods(f.read_text(encoding="utf-8", errors="replace"), names)
|
||||
else:
|
||||
missing.append(cls)
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(json.dumps({"unity_version": unity_version, "missing": missing,
|
||||
"classes": {c: {m: list(v) for m, v in ms.items()} for c, ms in out.items()}}))
|
||||
return out
|
||||
@@ -299,6 +299,8 @@ def original_platform(entry: dict) -> str:
|
||||
"""What the game was made for (a Steam tag)."""
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
if entry.get("kind") == "rift":
|
||||
if extra.get("flat"):
|
||||
return "Windows"
|
||||
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")
|
||||
@@ -310,7 +312,9 @@ def steam_tags(entry: dict, where: str = "frame") -> list[str]:
|
||||
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 \
|
||||
flat = bool(((entry.get("analysis") or {}).get("extra") or {}).get("flat"))
|
||||
runs = TAG if where != "frame" else ("Windows game on Frame" if flat 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]
|
||||
|
||||
@@ -405,7 +405,8 @@ def frame_pair(timeout: float = typer.Option(600, help="seconds to wait for the
|
||||
from .frame.pairing import PairingServer
|
||||
|
||||
srv = PairingServer().start()
|
||||
typer.echo("On the Frame, open Desktop Mode → Konsole and run:\n\n " + srv.one_liner + "\n")
|
||||
typer.echo("On the Frame, open the SteamVR dashboard → Launch a program → Desktop, then System → Konsole, and "
|
||||
"run:\n\n " + srv.one_liner + "\n")
|
||||
end = time.time() + timeout
|
||||
while time.time() < end and not srv.paired:
|
||||
time.sleep(1)
|
||||
|
||||
@@ -28,7 +28,7 @@ def load() -> dict:
|
||||
data = json.loads(_path().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {"games": {}, "settings": {}}
|
||||
if _migrate(data):
|
||||
if _migrate(data) | _follow_catalog(data):
|
||||
save(data)
|
||||
return data
|
||||
|
||||
@@ -42,6 +42,27 @@ def edit() -> Iterator[dict]:
|
||||
save(data)
|
||||
|
||||
|
||||
def _follow_catalog(data: dict) -> bool:
|
||||
"""Recipes FramePort derived (not ones the user edited) follow their catalog entry: when an entry is new or
|
||||
changed (e.g. a fix shipped with an update), the game's recipe is derived again; Update on Frame applies it."""
|
||||
from ..recommend import catalog, engine
|
||||
|
||||
changed = False
|
||||
for pkg, g in (data.get("games") or {}).items():
|
||||
r, a = g.get("recipe"), g.get("analysis")
|
||||
if not isinstance(r, dict) or not isinstance(a, dict) or r.get("source") == "user":
|
||||
continue
|
||||
entry = catalog.lookup(pkg)
|
||||
if entry is None or r.get("catalog_rev") == entry.rev():
|
||||
continue
|
||||
try:
|
||||
g["recipe"] = recipe_to_dict(engine.suggest(analysis_from_dict(a)))
|
||||
except Exception: # noqa: BLE001 - a malformed entry keeps its recipe
|
||||
continue
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _migrate(data: dict) -> bool:
|
||||
"""One-time updates of stored recipes when a new default is introduced (each runs once per library)."""
|
||||
done = data.setdefault("settings", {}).setdefault("migrations", [])
|
||||
|
||||
@@ -82,6 +82,7 @@ class Recipe:
|
||||
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
|
||||
catalog_rev: str = "" # CatalogEntry.rev() it was derived from: a changed catalog entry re-derives the recipe
|
||||
|
||||
def enabled(self, patch_id: str) -> bool:
|
||||
return patch_id in self.patches
|
||||
|
||||
@@ -24,6 +24,17 @@ def agent_dir() -> Path:
|
||||
return DATA_ROOT / "agent"
|
||||
|
||||
|
||||
AGENT_SOURCE_COPY = "frameport_agent.py.txt" # scripts/package.py stages this next to the .py
|
||||
|
||||
|
||||
def agent_file() -> Path:
|
||||
"""The Frame agent's source (uploaded to the Frame and run there with python3). `flet build` compiles every .py
|
||||
of the app to .pyc and drops the source, bundled data included (issue #2: no connection from the release
|
||||
bundles), so bundles also carry a copy under a non-.py name."""
|
||||
path = agent_dir() / "frameport_agent.py"
|
||||
return path if path.exists() else agent_dir() / AGENT_SOURCE_COPY
|
||||
|
||||
|
||||
def bootstrap_dir() -> Path:
|
||||
return DATA_ROOT / "bootstrap"
|
||||
|
||||
|
||||
@@ -287,3 +287,93 @@ def oculus_platform_dir() -> Path | None:
|
||||
if (d / "LibOVRPlatform64_1.dll").is_file():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ firewall (pairing)
|
||||
WSL_VM_CREATOR = "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" # Hyper-V firewall id of WSL's VM
|
||||
|
||||
|
||||
def powershell(command: str, timeout: float = 30) -> subprocess.CompletedProcess:
|
||||
"""Run Windows PowerShell 5.1 (from Windows or WSL). PSModulePath is dropped: inherited from PowerShell 7 it keeps
|
||||
5.1 from loading its own modules (see updates._powershell)."""
|
||||
if is_windows():
|
||||
exe = str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe")
|
||||
cwd = None
|
||||
else:
|
||||
exe = system32("WindowsPowerShell/v1.0/powershell.exe")
|
||||
cwd = "/mnt/c" if Path("/mnt/c").is_dir() else None # a \\wsl$ working dir makes Windows programs complain
|
||||
env = {k: v for k, v in os.environ.items() if k.upper() != "PSMODULEPATH"}
|
||||
return subprocess.run([exe, "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, text=True,
|
||||
errors="replace", timeout=timeout, cwd=cwd, env=env, **_no_window())
|
||||
|
||||
|
||||
def wsl_networking_mode() -> str | None:
|
||||
""""mirrored" or "nat" (WSL's default, where nothing on the network can reach WSL at all); None if unknown."""
|
||||
if not is_wsl():
|
||||
return None
|
||||
try:
|
||||
out = subprocess.run(["wslinfo", "--networking-mode"], capture_output=True, text=True, timeout=10).stdout
|
||||
if out.strip():
|
||||
return out.strip().lower()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def wsl_inbound_blocked(rule: str) -> bool:
|
||||
"""WSL with mirrored networking: Windows' Hyper-V firewall blocks connections from the network to WSL by default
|
||||
(no "Allow access?" prompt as for Windows programs), so a Frame's request to our pairing server just times out.
|
||||
True when that block is on and our allow rule `rule` isn't there."""
|
||||
if not is_wsl():
|
||||
return False
|
||||
try:
|
||||
out = powershell(
|
||||
f"$s = Get-NetFirewallHyperVVMSetting -PolicyStore ActiveStore -Name '{WSL_VM_CREATOR}';"
|
||||
f"$r = Get-NetFirewallHyperVRule -PolicyStore ActiveStore -Name '{rule}' -ErrorAction SilentlyContinue;"
|
||||
"\"$($s.Enabled) $($s.DefaultInboundAction) $([bool]$r)\"").stdout.split()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return out[:3] == ["True", "Block", "False"]
|
||||
|
||||
|
||||
def open_wsl_inbound(rule: str, title: str, ports: str, flag: Path, minutes: int = 35) -> bool:
|
||||
"""Let the network reach WSL on TCP `ports` only while `flag` exists (at most `minutes`): one admin prompt (UAC)
|
||||
starts a hidden elevated PowerShell that adds a Hyper-V firewall rule, waits, and removes the rule again (only if
|
||||
it added it). Returns True once the rule is in place."""
|
||||
import base64
|
||||
|
||||
script = f"""
|
||||
$name = '{rule}'
|
||||
$added = $false
|
||||
if (-not (Get-NetFirewallHyperVRule -PolicyStore ActiveStore -Name $name -ErrorAction SilentlyContinue)) {{
|
||||
New-NetFirewallHyperVRule -Name $name -DisplayName '{title}' -Direction Inbound -VMCreatorId '{WSL_VM_CREATOR}' `
|
||||
-Protocol TCP -LocalPorts {ports} -Action Allow | Out-Null
|
||||
$added = $true
|
||||
}}
|
||||
$end = (Get-Date).AddMinutes({minutes})
|
||||
while ((Test-Path -LiteralPath '{to_windows(flag)}') -and ((Get-Date) -lt $end)) {{ Start-Sleep -Seconds 2 }}
|
||||
if ($added) {{ Remove-NetFirewallHyperVRule -Name $name -ErrorAction SilentlyContinue }}
|
||||
"""
|
||||
encoded = base64.b64encode(script.encode("utf-16-le")).decode()
|
||||
try:
|
||||
powershell("Start-Process powershell -Verb RunAs -WindowStyle Hidden "
|
||||
f"-ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand','{encoded}'", timeout=300)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
for _ in range(20): # the elevated script needs a moment to add the rule
|
||||
if not wsl_inbound_blocked(rule):
|
||||
return True
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def network_category(ip: str) -> str | None:
|
||||
"""Windows: the firewall profile of the network that has `ip` ("Public", "Private", "DomainAuthenticated")."""
|
||||
if not is_windows():
|
||||
return None
|
||||
try:
|
||||
out = powershell(f"(Get-NetConnectionProfile -InterfaceIndex (Get-NetIPAddress -IPAddress '{ip}' "
|
||||
"-ErrorAction Stop).InterfaceIndex).NetworkCategory").stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return out or None
|
||||
@@ -46,8 +46,12 @@ def explain(exc: BaseException) -> str:
|
||||
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 "authentication failed" in low or "no authentication methods available" in low:
|
||||
if "password was refused" in low:
|
||||
return tr("The Frame refused that password. Check it, or run the first-time setup instead: Steam Frame → "
|
||||
"Show setup command.")
|
||||
return tr("This Frame isn't set up for FramePort yet. Run the first-time setup: Steam Frame → Show setup "
|
||||
"command.")
|
||||
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:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""SSH connection to a Steam Frame and the remote agent protocol.
|
||||
|
||||
Authentication: FramePort's own key (<user data>/ssh/id_ed25519, installed by the bootstrap script) first, then
|
||||
the user's SSH agent/keys, then a password if given.
|
||||
Authentication: FramePort's own keys first (<user data>/ssh/id_ed25519, installed by the bootstrap script, and
|
||||
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then the user's SSH agent/keys, then a
|
||||
password if given.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,11 +19,30 @@ from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
from ..core.paths import agent_dir, ssh_dir, user_data_dir, write_atomic
|
||||
from ..core.paths import agent_file, ssh_dir, user_data_dir, write_atomic
|
||||
|
||||
REMOTE_AGENT_DIR = ".local/share/frameport/agent"
|
||||
|
||||
|
||||
class FrameNotPaired(ConnectionError):
|
||||
"""SSH works but none of FramePort's ways in was accepted: the Frame hasn't run the first-time setup (or the
|
||||
password was wrong). The message keeps "authentication failed" for older callers."""
|
||||
|
||||
def __init__(self, tried_password: bool, details: list[str]):
|
||||
self.tried_password = tried_password
|
||||
self.details = details
|
||||
reason = ("the password was refused" if tried_password
|
||||
else "this Frame doesn't know FramePort yet")
|
||||
super().__init__(f"SSH authentication failed: {reason}. Run the first-time setup on the Frame (Steam Frame → "
|
||||
"Show setup command)." + (f" ({'; '.join(details)})" if details else ""))
|
||||
|
||||
|
||||
def _no_auth_methods(exc: BaseException) -> bool:
|
||||
"""paramiko ends a key/agent attempt that had nothing to offer (no SSH keys on this PC, no agent) with a plain
|
||||
SSHException, not an AuthenticationException: it's a failed login all the same, not a network problem."""
|
||||
return isinstance(exc, paramiko.SSHException) and "no authentication methods available" in str(exc).lower()
|
||||
|
||||
|
||||
def app_key() -> paramiko.Ed25519Key:
|
||||
path = ssh_dir() / "id_ed25519"
|
||||
if not path.exists():
|
||||
@@ -45,6 +65,37 @@ def app_public_key() -> str:
|
||||
return (ssh_dir() / "id_ed25519.pub").read_text().strip()
|
||||
|
||||
|
||||
def devkit_key() -> paramiko.RSAKey:
|
||||
"""FramePort's RSA key for Valve's devkit pairing (frame/devkit.py): its service accepts only ssh-rsa keys."""
|
||||
path = ssh_dir() / "id_rsa"
|
||||
if not path.exists():
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
|
||||
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)
|
||||
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_rsa.pub").write_text(pub.decode() + " frameport\n")
|
||||
return paramiko.RSAKey.from_private_key_file(str(path))
|
||||
|
||||
|
||||
def devkit_public_key() -> str:
|
||||
devkit_key()
|
||||
return (ssh_dir() / "id_rsa.pub").read_text().strip()
|
||||
|
||||
|
||||
def app_keys() -> list[paramiko.PKey]:
|
||||
"""The keys FramePort logs in with: the Ed25519 key, plus the RSA key once devkit pairing created it."""
|
||||
keys: list[paramiko.PKey] = [app_key()]
|
||||
if (ssh_dir() / "id_rsa").exists():
|
||||
keys.append(devkit_key())
|
||||
return keys
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameTarget:
|
||||
host: str
|
||||
@@ -92,7 +143,7 @@ FAST_LINKS = {"usb0": "USB cable", "wlanap": "the Frame's own Wi-Fi hotspot"}
|
||||
def bundled_agent_version() -> int | None:
|
||||
"""AGENT_VERSION of the agent this app ships (it's uploaded to the Frame whenever it differs)."""
|
||||
try:
|
||||
m = re.search(r"^AGENT_VERSION = (\d+)", (agent_dir() / "frameport_agent.py").read_text(), re.M)
|
||||
m = re.search(r"^AGENT_VERSION = (\d+)", agent_file().read_text(), re.M)
|
||||
return int(m.group(1)) if m else None
|
||||
except OSError:
|
||||
return None
|
||||
@@ -101,6 +152,9 @@ def bundled_agent_version() -> int | None:
|
||||
RUN_TIMEOUT = 120 # seconds: default bound for one remote command's output
|
||||
|
||||
|
||||
NOT_IN_LIBRARY = "not in the Frame's Steam library" # agent cmd_launch's error when the shortcut is missing
|
||||
|
||||
|
||||
class AgentFailed(RuntimeError):
|
||||
pass
|
||||
|
||||
@@ -233,7 +287,8 @@ class Frame:
|
||||
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"):
|
||||
attempts = [("app_key", k) for k in app_keys()] + [("agent", None), ("password", None)]
|
||||
for attempt, pkey in attempts:
|
||||
if attempt == "password" and not self.password:
|
||||
continue
|
||||
client = paramiko.SSHClient() # a fresh client per attempt: a failed one keeps its transport otherwise
|
||||
@@ -241,7 +296,7 @@ class Frame:
|
||||
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)
|
||||
client.connect(pkey=pkey, allow_agent=False, look_for_keys=False, **kwargs)
|
||||
elif attempt == "agent":
|
||||
client.connect(allow_agent=True, look_for_keys=True, **kwargs)
|
||||
else:
|
||||
@@ -250,12 +305,17 @@ class Frame:
|
||||
except paramiko.AuthenticationException as exc:
|
||||
client.close()
|
||||
errors.append(f"{attempt}: {exc}")
|
||||
except paramiko.SSHException as exc:
|
||||
client.close()
|
||||
if not _no_auth_methods(exc):
|
||||
raise
|
||||
# (Reddit, 2026-10-03: this ended the loop before the password was tried)
|
||||
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.")
|
||||
raise FrameNotPaired(bool(self.password), errors)
|
||||
client.save_host_keys(str(known))
|
||||
transport = client.get_transport()
|
||||
transport.set_keepalive(15)
|
||||
@@ -336,7 +396,7 @@ class Frame:
|
||||
|
||||
# ------------------------------------------------------------------ agent
|
||||
def ensure_agent(self) -> str:
|
||||
local = agent_dir() / "frameport_agent.py"
|
||||
local = agent_file()
|
||||
text = local.read_bytes()
|
||||
digest = hashlib.sha256(text).hexdigest()[:16]
|
||||
remote_dir = posixpath.join(self.home, REMOTE_AGENT_DIR)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Valve's devkit pairing: with Developer Mode on, the Frame's steamos-devkit-service (TCP 32000) accepts an SSH public
|
||||
key at POST /register while Steam is in pairing mode (Settings → Developer → Pair new host; otherwise it refuses at
|
||||
once), asks in Steam to approve it, and Valve's root hook installs the key for the steamos user and enables sshd.
|
||||
Every connection goes from the PC to the Frame, so no firewall on the PC is involved: the fallback when the Frame
|
||||
can't reach FramePort's setup server. The service only accepts ssh-rsa keys
|
||||
(connection.devkit_key)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .connection import devkit_public_key
|
||||
|
||||
PORT = 32000
|
||||
APPROVE_TIMEOUT = 30 # Valve's approve hook waits this long for the answer in the headset
|
||||
|
||||
|
||||
class PairingRefused(RuntimeError): # not a ConnectionError: the Frame is reachable, explain() keeps the message
|
||||
pass
|
||||
|
||||
|
||||
def available(host: str, timeout: float = 5) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://{host}:{PORT}/login-name", timeout=timeout) as r:
|
||||
return r.status == 200
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def register(host: str) -> None:
|
||||
"""Send FramePort's RSA key; returns once it was approved in the headset and installed, raises PairingRefused."""
|
||||
req = urllib.request.Request(f"http://{host}:{PORT}/register", data=devkit_public_key().encode(), method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=APPROVE_TIMEOUT + 30) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", "replace") # Valve's service mixes the hook's log lines into it
|
||||
m = re.search(r'"error":\s*"([^"]*)"', body)
|
||||
msg = (m.group(1) if m else (body.strip() or str(exc))).replace("\\n", " ").strip()
|
||||
if "pairing mode" in msg.lower(): # Steam takes requests only while "Pair new host" is open
|
||||
msg = "on the Frame open Settings → Developer → Pair new host first, then click Connect again"
|
||||
elif "timeout" in msg.lower():
|
||||
msg = f"it wasn't approved in the headset within {APPROVE_TIMEOUT} seconds"
|
||||
elif "steam is not running" in msg.lower():
|
||||
msg = "Steam isn't running on the Frame"
|
||||
raise PairingRefused(f"The Frame didn't pair: {msg}") from None
|
||||
except OSError as exc:
|
||||
raise PairingRefused(f"Couldn't reach the Frame's pairing service (Developer Mode on?): {exc}") from None
|
||||
if "Registered" not in body:
|
||||
raise PairingRefused(f"The Frame didn't pair: {body.strip()}")
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Type on the Frame from this computer's keyboard: the agent's `_keyboard` session creates a virtual keyboard on the
|
||||
Frame (Linux uinput) and this side streams key presses to it as JSON lines over one SSH channel. Keys are sent as
|
||||
physical keys (Linux key codes), so modifiers, shortcuts and key repeat behave like a keyboard plugged into the Frame;
|
||||
pasted text is typed with the Frame's US layout."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import posixpath
|
||||
import threading
|
||||
|
||||
from ..core import applog
|
||||
|
||||
# Flet/Flutter key names, normalised (lowercase, no spaces) -> Linux input key codes (input-event-codes.h)
|
||||
_NAMED = {
|
||||
"escape": 1, "esc": 1, "backspace": 14, "tab": 15, "enter": 28, "return": 28, "space": 57, " ": 57,
|
||||
"controlleft": 29, "control": 29, "ctrl": 29, "controlright": 97, "shiftleft": 42, "shift": 42,
|
||||
"shiftright": 54, "altleft": 56, "alt": 56, "altright": 100, "altgraph": 100, "metaleft": 125, "meta": 125,
|
||||
"metaright": 126, "capslock": 58, "numlock": 69, "scrolllock": 70, "printscreen": 99, "pause": 119,
|
||||
"contextmenu": 127, "home": 102, "arrowup": 103, "up": 103, "pageup": 104, "arrowleft": 105, "left": 105,
|
||||
"arrowright": 106, "right": 106, "end": 107, "arrowdown": 108, "down": 108, "pagedown": 109, "insert": 110,
|
||||
"delete": 111, "numpadenter": 96, "numpadadd": 78, "numpadsubtract": 74, "numpadmultiply": 55,
|
||||
"numpaddivide": 98, "numpaddecimal": 83, "numpadequal": 117,
|
||||
"audiovolumemute": 113, "audiovolumedown": 114, "audiovolumeup": 115,
|
||||
"mediaplaypause": 164, "mediatracknext": 163, "mediatrackprevious": 165, "mediastop": 166,
|
||||
}
|
||||
_ROWS = (("1234567890-=", "!@#$%^&*()_+", 2), ("qwertyuiop[]", "QWERTYUIOP{}", 16),
|
||||
("asdfghjkl;'`", 'ASDFGHJKL:"~', 30), ("\\zxcvbnm,./", "|ZXCVBNM<>?", 43))
|
||||
_CHARS: dict[str, int] = {}
|
||||
for _plain, _shifted, _first in _ROWS:
|
||||
for _i, (_a, _b) in enumerate(zip(_plain, _shifted, strict=True)):
|
||||
_CHARS[_a] = _CHARS[_b] = _first + _i
|
||||
_FKEYS = {f"f{n}": c for n, c in zip(range(1, 13), (59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88), strict=True)}
|
||||
_NUMPAD = {f"numpad{d}": c for d, c in zip("1234567890", (79, 80, 81, 75, 76, 77, 71, 72, 73, 82), strict=True)}
|
||||
_warned: set[str] = set()
|
||||
|
||||
|
||||
def linux_key(name: str) -> int | None:
|
||||
"""Linux key code for a Flet key name ("A", "Enter", "Arrow Left", "Shift Left", "F5", "Key A", "Digit 1", "!")."""
|
||||
if not name:
|
||||
return None
|
||||
if not name.strip():
|
||||
return 57 # " " = space (normalising below would leave nothing)
|
||||
if len(name) == 1 and name in _CHARS:
|
||||
return _CHARS[name.lower()] if name.isalpha() else _CHARS[name]
|
||||
norm = name.lower().replace(" ", "").replace("_", "")
|
||||
if norm.startswith("key") and len(norm) == 4: # physical names: "Key A"
|
||||
norm = norm[3:]
|
||||
elif norm.startswith("digit") and len(norm) == 6: # "Digit 1"
|
||||
norm = norm[5:]
|
||||
if len(norm) == 1 and norm in _CHARS:
|
||||
return _CHARS[norm]
|
||||
code = _NAMED.get(norm) or _FKEYS.get(norm) or _NUMPAD.get(norm)
|
||||
if code is None and name not in _warned:
|
||||
_warned.add(name)
|
||||
applog.log.info("Type on Frame: no key code for %r", name)
|
||||
return code
|
||||
|
||||
|
||||
class KeyboardRefused(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class KeyboardSession:
|
||||
"""One virtual keyboard on the Frame for as long as this session is open (the agent removes it, releasing any
|
||||
held key, when the SSH channel closes)."""
|
||||
|
||||
def __init__(self, frame, timeout: float = 15):
|
||||
self.frame = frame
|
||||
self._lock = threading.Lock()
|
||||
self.closed = False
|
||||
frame.ensure_agent()
|
||||
remote = posixpath.join(frame.home, ".local/share/frameport/agent/frameport_agent.py")
|
||||
self._stdin, self._stdout, _err = frame.client.exec_command(f"python3 {remote} _keyboard", timeout=timeout)
|
||||
ready = json.loads(self._stdout.readline() or "{}")
|
||||
if not ready.get("ready"):
|
||||
self.close()
|
||||
raise KeyboardRefused(ready.get("error") or "the Frame didn't start the virtual keyboard")
|
||||
self._stdout.channel.settimeout(None)
|
||||
|
||||
def _send(self, msg: dict) -> None:
|
||||
with self._lock:
|
||||
if self.closed:
|
||||
return
|
||||
self._stdin.write(json.dumps(msg) + "\n")
|
||||
self._stdin.flush()
|
||||
|
||||
def key(self, name: str, action: str = "down") -> bool:
|
||||
"""action: down | up | repeat. False when the key has no Linux key code."""
|
||||
code = linux_key(name)
|
||||
if code is None:
|
||||
return False
|
||||
self._send({"k": code, "v": {"down": 1, "up": 0, "repeat": 2}[action]})
|
||||
return True
|
||||
|
||||
def text(self, text: str) -> str:
|
||||
"""Type text with the Frame's US layout; returns the characters it couldn't type."""
|
||||
self._send({"text": text})
|
||||
reply = json.loads(self._stdout.readline() or "{}")
|
||||
return reply.get("skipped", "")
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self.closed = True
|
||||
try:
|
||||
self._stdin.channel.shutdown_write() # EOF: the agent releases held keys and removes the device
|
||||
self._stdin.close()
|
||||
except Exception: # noqa: BLE001 - the connection may be gone already
|
||||
pass
|
||||
@@ -2,7 +2,9 @@
|
||||
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."""
|
||||
successful pairing, after MAX_FAILURES wrong codes (someone guessing) and after LIFETIME seconds, so a short code
|
||||
(32 bits) is plenty. The line is typed by hand on the Frame, so it's kept short: no scheme (curl defaults to http),
|
||||
the code is the path, and a fixed port when it's free."""
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
@@ -10,6 +12,7 @@ import secrets
|
||||
import threading
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.paths import bootstrap_dir
|
||||
from .connection import app_public_key
|
||||
@@ -17,15 +20,88 @@ from .discovery import local_ip_towards
|
||||
|
||||
MAX_FAILURES = 20
|
||||
LIFETIME = 30 * 60
|
||||
PORTS = (8765, 8766, 8767, 0) # 0 = any free port
|
||||
FIREWALL_RULE = "FramePort-Pairing"
|
||||
HINT_AFTER = 45 # seconds without any request from the Frame before the UI suggests what may block it
|
||||
|
||||
|
||||
def ensure_reachable(server: PairingServer) -> str:
|
||||
"""Under WSL, let the Frame reach the pairing ports through Windows' Hyper-V firewall while `server` runs (one
|
||||
admin prompt; the rule is removed again when the server stops, see winhost.open_wsl_inbound).
|
||||
Returns "ok" (nothing blocks), "opened" or "failed" (declined / no admin)."""
|
||||
from ..core import winhost
|
||||
|
||||
if not winhost.wsl_inbound_blocked(FIREWALL_RULE):
|
||||
return "ok"
|
||||
temp = winhost.env_path("TEMP")
|
||||
if temp is None:
|
||||
return "failed"
|
||||
server.flag = temp / f"frameport-pairing-{server.code}.flag"
|
||||
try:
|
||||
server.flag.write_text("FramePort's setup page is open: the firewall rule stays while this file exists\n")
|
||||
except OSError:
|
||||
return "failed"
|
||||
ports = f"{PORTS[0]}-{PORTS[-2]}"
|
||||
return "opened" if winhost.open_wsl_inbound(FIREWALL_RULE, "FramePort setup (WSL, temporary)", ports,
|
||||
server.flag) else "failed"
|
||||
|
||||
|
||||
def firewall_hint(port: int) -> str:
|
||||
"""What may keep the Frame from reaching this PC, for this OS (shown when no request came in after HINT_AFTER).
|
||||
Empty when nothing specific is known."""
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from ..core import winhost
|
||||
from ..i18n import tr
|
||||
|
||||
def out(*cmd) -> str:
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.lower()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
if winhost.is_wsl():
|
||||
if winhost.wsl_networking_mode() == "nat":
|
||||
return tr("FramePort runs in WSL with its default NAT network, which other devices can't reach. Set "
|
||||
"networkingMode=mirrored under [wsl2] in %UserProfile%\\.wslconfig, run wsl --shutdown and "
|
||||
"start FramePort again.")
|
||||
if winhost.wsl_inbound_blocked(FIREWALL_RULE):
|
||||
return tr("Windows' firewall for WSL blocks the Frame. Open the setup command again and allow the "
|
||||
"change when Windows asks (admin).")
|
||||
return ""
|
||||
if winhost.is_windows():
|
||||
if winhost.network_category(local_ip_towards()) == "Public":
|
||||
return tr("Windows treats this network as public and its firewall blocks the Frame. In Windows' "
|
||||
"network settings set this network to Private (or allow FramePort on public networks when "
|
||||
"Windows asks).")
|
||||
return tr("If Windows asked whether FramePort may use the network, allow it (Private networks).")
|
||||
if platform.system() == "Darwin":
|
||||
fw = out("/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate")
|
||||
if "enabled" in fw:
|
||||
return tr("macOS' firewall is on: allow incoming connections when macOS asks about FramePort (or "
|
||||
"System Settings → Network → Firewall → Options).")
|
||||
return ""
|
||||
if shutil.which("firewall-cmd") and "running" in out("firewall-cmd", "--state"):
|
||||
return tr("firewalld is active. Allow the setup port until the next restart with: "
|
||||
"sudo firewall-cmd --add-port={port}/tcp").format(port=port)
|
||||
if "active" == out("systemctl", "is-active", "ufw").strip():
|
||||
return tr("ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (and afterwards: "
|
||||
"sudo ufw delete allow {port}/tcp)").format(port=port)
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairingServer:
|
||||
port: int = 0
|
||||
code: str = field(default_factory=lambda: secrets.token_hex(8))
|
||||
code: str = field(default_factory=lambda: secrets.token_hex(4))
|
||||
failures: int = 0
|
||||
paired: list[dict] = field(default_factory=list)
|
||||
on_paired: object = None
|
||||
requests: int = 0 # requests from the network (right or wrong code): the Frame can reach us
|
||||
flag: Path | None = None # WSL: the temporary firewall rule stays while this file exists
|
||||
hint: str = "" # set by the UI when nothing reached us after HINT_AFTER seconds: what may block the Frame
|
||||
_httpd: http.server.ThreadingHTTPServer | None = None
|
||||
|
||||
@property
|
||||
@@ -34,7 +110,7 @@ class PairingServer:
|
||||
|
||||
@property
|
||||
def one_liner(self) -> str:
|
||||
return f"curl -fsSL {self.url}/bootstrap.sh?code={self.code} | bash"
|
||||
return f"curl -fsS {self.url.removeprefix('http://')}/{self.code} | bash"
|
||||
|
||||
def start(self) -> PairingServer:
|
||||
server = self
|
||||
@@ -51,20 +127,24 @@ class PairingServer:
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
server.requests += 1
|
||||
url = urllib.parse.urlparse(self.path)
|
||||
q = dict(urllib.parse.parse_qsl(url.query))
|
||||
if not secrets.compare_digest(q.get("code", ""), server.code):
|
||||
path, code = url.path, q.get("code", "")
|
||||
if not code and path.count("/") == 1: # the short form: /<code> = the script
|
||||
path, code = "/bootstrap.sh", path[1:]
|
||||
if not secrets.compare_digest(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":
|
||||
if path == "/bootstrap.sh":
|
||||
text = (bootstrap_dir() / "bootstrap.sh").read_text()
|
||||
text = text.replace("__PC_URL__", server.url).replace("__PAIR_CODE__", server.code)
|
||||
return self._send(text.encode(), "text/x-shellscript")
|
||||
if url.path == "/key":
|
||||
if path == "/key":
|
||||
return self._send((app_public_key() + "\n").encode())
|
||||
if url.path == "/paired":
|
||||
if path == "/paired":
|
||||
info = {"host": self.client_address[0], "user": q.get("user", "steamos"), "name": q.get("host", "")}
|
||||
server.paired.append(info)
|
||||
if callable(server.on_paired):
|
||||
@@ -73,7 +153,13 @@ class PairingServer:
|
||||
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)
|
||||
for port in ((self.port,) if self.port else PORTS):
|
||||
try:
|
||||
self._httpd = http.server.ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
||||
break
|
||||
except OSError:
|
||||
if port == PORTS[-1]:
|
||||
raise
|
||||
self.port = self._httpd.server_address[1]
|
||||
threading.Thread(target=self._httpd.serve_forever, daemon=True).start()
|
||||
self._timer = threading.Timer(LIFETIME, self.stop)
|
||||
@@ -97,3 +183,5 @@ class PairingServer:
|
||||
timer = getattr(self, "_timer", None)
|
||||
if timer:
|
||||
timer.cancel()
|
||||
if self.flag is not None: # lets the temporary firewall rule go
|
||||
self.flag.unlink(missing_ok=True)
|
||||
@@ -112,7 +112,7 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
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, flatscreen=_flatscreen(plan.package),
|
||||
obb_manifest=manifest or None, flatscreen=_flatscreen(plan.package) or ctx.flatscreen,
|
||||
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']})")
|
||||
@@ -210,7 +210,7 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
env=launch_env(plan.recipe),
|
||||
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
|
||||
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
|
||||
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version,
|
||||
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source},
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
|
||||
@@ -325,6 +325,14 @@ def _tags(package: str) -> list[str]:
|
||||
return steam_tags(entry) if entry else []
|
||||
|
||||
|
||||
def is_flat_windows(package: str) -> bool:
|
||||
"""A Windows game without VR (added with "Add one game folder…"): Proton runs it as a window, no VR setup."""
|
||||
from ..core import library
|
||||
|
||||
g = library.game(package) or {}
|
||||
return bool(((g.get("analysis") or {}).get("extra") or {}).get("flat"))
|
||||
|
||||
|
||||
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
|
||||
@@ -384,8 +392,13 @@ def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: fl
|
||||
continue
|
||||
if status.get("state") in ("done", "failed"):
|
||||
break
|
||||
added = {a["package"] for a in status.get("added", [])}
|
||||
for a in status.get("added", []):
|
||||
reporter.check(f"Steam library: {a['package']}", True, f"shortcut {a['appid']}")
|
||||
for e in status.get("errors", []):
|
||||
reporter.check("Steam library", False, e)
|
||||
if not status.get("errors"):
|
||||
for pkg in packages:
|
||||
if pkg not in added: # no answer in time: say so (Play adds a missing shortcut itself)
|
||||
reporter.check(f"Steam library: {pkg}", False, "the Frame didn't confirm the library entry in time")
|
||||
return status
|
||||
@@ -13,6 +13,7 @@
|
||||
""
|
||||
],
|
||||
" Reinstall it for the controller models.": "",
|
||||
" · battery {value}": "",
|
||||
" · downloaded when first needed": "",
|
||||
" · Frame not connected": "",
|
||||
" · link": "",
|
||||
@@ -55,6 +56,7 @@
|
||||
"A new FramePort version is ready to install": "",
|
||||
"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.": "",
|
||||
"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.": "",
|
||||
"A Windows game without VR, added with \"Add one game folder…\". On the Frame, Proton runs it as a window (like Steam's own Windows games); it's in the Frame's Steam library.": "",
|
||||
"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).": "",
|
||||
"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.": "",
|
||||
"ABIs": "",
|
||||
@@ -69,6 +71,7 @@
|
||||
"Add games": "",
|
||||
"Add one game folder…": "",
|
||||
"Add this tag": "",
|
||||
"Add to Steam library: {title}": "",
|
||||
"Add videos": "",
|
||||
"Add videos and files…": "",
|
||||
"Add your games": "",
|
||||
@@ -96,9 +99,12 @@
|
||||
"Allows very large pictures (8K video, theatres) instead of quitting.": "",
|
||||
"Already patched: don't change the game's files": "",
|
||||
"Already patched: install as is (skip patching)": "",
|
||||
"Already set up: on your network": "",
|
||||
"Also in your library: {value} version": "",
|
||||
"Also remove FramePort's games and files from the Frame": "",
|
||||
"An ordinary Android app or game (no VR). It's installed unchanged and runs in Lepton, Valve's Android container.": "",
|
||||
"Analyze again": "",
|
||||
"Analyze {title} again": "",
|
||||
"Android": "",
|
||||
"Android app": "",
|
||||
"Android app without VR: installed unchanged and shown as a flat window": "",
|
||||
@@ -147,6 +153,7 @@
|
||||
"Check for new versions automatically": "",
|
||||
"Check for updates": "",
|
||||
"Check game files": "",
|
||||
"Check that the Frame and this computer are on the same network.": "",
|
||||
"Checking this PC…": "",
|
||||
"Checking tools…": "",
|
||||
"Chooses which Proton version runs the game on the Frame.": "",
|
||||
@@ -157,6 +164,7 @@
|
||||
"Clear finished": "",
|
||||
"Clear search": "",
|
||||
"Clear tags": "",
|
||||
"Click here, then type. Everything you type goes to the Frame (Esc and shortcuts too).": "",
|
||||
"Click to open · right-click for quick actions": "",
|
||||
"Close": "",
|
||||
"Close FramePort": "",
|
||||
@@ -173,6 +181,7 @@
|
||||
"Connected": "",
|
||||
"Connected to {label}": "",
|
||||
"Connected to {label}.": "",
|
||||
"Connecting the keyboard…": "",
|
||||
"Connecting to {value}…": "",
|
||||
"Connecting…": "",
|
||||
"Continue": "",
|
||||
@@ -207,7 +216,7 @@
|
||||
"Delete {names}?": "",
|
||||
"Deletes the rollback copies kept from each game's previous install and leftover uploads. The games and saves stay.": "",
|
||||
"Details": "",
|
||||
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH.": "",
|
||||
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you.": "",
|
||||
"Didn't finish ({value}): {error}": "",
|
||||
"Disable application space warp if used": "",
|
||||
"Disable controller tracking offset": "",
|
||||
@@ -229,6 +238,7 @@
|
||||
],
|
||||
"Downloading…": "",
|
||||
"Downloads": "",
|
||||
"Draws each eye separately; try it when one eye shows a grey or broken picture.": "",
|
||||
"Drop invalid layers": "",
|
||||
"Drop layers whose swapchain failed or whose extension isn't enabled.": "",
|
||||
"Drop to upload": "",
|
||||
@@ -266,6 +276,7 @@
|
||||
"Find artwork and store details": "",
|
||||
"Find artwork…": "",
|
||||
"Find automatically": "",
|
||||
"firewalld is active. Allow the setup port until the next restart with: sudo firewall-cmd --add-port={port}/tcp": "",
|
||||
"First-time setup": "",
|
||||
"Fix launcher icon entry": "",
|
||||
"Fix minimal Android SDK": "",
|
||||
@@ -287,6 +298,8 @@
|
||||
"Force enable passthrough": "",
|
||||
"Found": "",
|
||||
"Frame agent": "",
|
||||
"Frame battery: charging": "",
|
||||
"Frame battery: not charging": "",
|
||||
"Frame OpenXR compatibility layer": "",
|
||||
"FrameBridge OpenXR adapter": "",
|
||||
"FramePort": "",
|
||||
@@ -298,6 +311,7 @@
|
||||
"FramePort installs games on the Frame over your network.": "",
|
||||
"FramePort manages its own copies; nothing is installed system-wide": "",
|
||||
"FramePort picked {value} to start this game, but there are other candidates. Check it before installing.": "",
|
||||
"FramePort runs in WSL with its default NAT network, which other devices can't reach. Set networkingMode=mirrored under [wsl2] in %UserProfile%\\.wslconfig, run wsl --shutdown and start FramePort again.": "",
|
||||
"FramePort saves a diagnostics zip (logs, recipe, device info; no game files, personal data removed) and opens a prefilled GitHub issue. Drag the zip into it, check the text, submit.": "",
|
||||
"FramePort updates and restarts after the current job": "",
|
||||
"FramePort was removed": "",
|
||||
@@ -360,6 +374,7 @@
|
||||
"How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" come from recipes tested on a real Frame. \"Untested\" means FramePort suggested a recipe from the game's engine and VR API that nobody has confirmed yet. \"Can't run\" means there's a known blocker (for example a 32-bit-only game).": "",
|
||||
"I played it in the headset with this recipe": "",
|
||||
"If the pointer doesn't hit what you aim at, tilt it up or down.": "",
|
||||
"If Windows asked whether FramePort may use the network, allow it (Private networks).": "",
|
||||
"If you know the Frame's address.": "",
|
||||
"In your Steam library": "",
|
||||
"In your Steam library · launch settings changed — update it": "",
|
||||
@@ -392,7 +407,7 @@
|
||||
"It has a known blocker on the Steam Frame.": "",
|
||||
"It has issues: share…": "",
|
||||
"it stopped at '{value}'": "",
|
||||
"It turns on SSH, trusts this app, makes the Frame findable on your network and installs Lepton if needed. You only do this once.": "",
|
||||
"It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on Developer Mode closes the desktop; the setup finishes on its own and FramePort connects by itself. You only do this once.": "",
|
||||
"It was removed from the library.": "",
|
||||
"It works: share…": "",
|
||||
"Java runtime": "",
|
||||
@@ -401,8 +416,11 @@
|
||||
"Keep converted copies on this PC after installing (FramePort converts again for every install, so they're only needed for inspecting a build)": "",
|
||||
"Keep game saves on the Frame": "",
|
||||
"Keep menus in place": "",
|
||||
"Keeps a smoothing setting off that the game turns on itself and that can freeze the Frame.": "",
|
||||
"Keeps one label so the title is stable.": "",
|
||||
"Keeps one name for the game, so it shows the same everywhere.": "",
|
||||
"Keyboard connected to {label}.": "",
|
||||
"Keyboard disconnected: {error}": "",
|
||||
"KiB": "",
|
||||
"Language": "",
|
||||
"Last build": "",
|
||||
@@ -422,13 +440,16 @@
|
||||
"Lepton is installed": "",
|
||||
"Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. It needs Developer Mode.": "",
|
||||
"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.": "",
|
||||
"Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as \"Gamescope\" (the Frame's compositor). Pairs with \"Make Unity text fields work without a system keyboard\".": "",
|
||||
"Lets games made for Oculus PCs run on SteamVR.": "",
|
||||
"Lets games written for newer OpenXR versions run on the Frame.": "",
|
||||
"Lets mixed-reality games start even though they insist on a camera view of your room.": "",
|
||||
"Lets typing reach this app: Steam's on-screen keyboard or your computer's keyboard (VR is unaffected).": "",
|
||||
"Lets Unity games accept a headset that isn't a Quest.": "",
|
||||
"Lets Unreal games accept a headset that isn't a Quest.": "",
|
||||
"Lets Unreal games accept a headset that isn't an Oculus Rift.": "",
|
||||
"Lets you read logs/attach. Some Unreal games abort under CheckJNI when debuggable; the Frame 'nodebug' fix undoes it.": "",
|
||||
"Lets you type into this app's text fields on the Frame (they'd close at once).": "",
|
||||
"Library": "",
|
||||
"Loading…": "",
|
||||
"Loads FramePort's OpenXR layer under Proton on the Frame. The Frame's SteamVR runtime only accepts OpenXR 1.0 apps, but Proton's VR helper asks for 1.1, so without the layer VR never starts (the game shows as a flat window, or Revive fails with 'Unable to load LibOVRRT DLL'); the layer retries as 1.0. It also emulates xrConvertTimespecTimeToTimeKHR if a runtime refuses it. Ignored on this PC.": "",
|
||||
@@ -439,6 +460,8 @@
|
||||
"Looking for your Frame…": "",
|
||||
"Looking up the store description and screenshots…": "",
|
||||
"Low": "",
|
||||
"macOS' firewall is on: allow incoming connections when macOS asks about FramePort (or System Settings → Network → Firewall → Options).": "",
|
||||
"Make Unity text fields work without a system keyboard": "",
|
||||
"Makes Meta's 3D audio library work outside Quest headsets.": "",
|
||||
"Makes sure the game has an entry that can be started.": "",
|
||||
"Makes the game start from the Frame's Steam library.": "",
|
||||
@@ -453,6 +476,7 @@
|
||||
"Menus and screens": "",
|
||||
"Meta Quest": "",
|
||||
"Meta XR Audio: skip Quest telemetry class": "",
|
||||
"Meta's OVRManager switches Unity to the headset's recommended MSAA level (4x) while the game runs, whatever its quality settings say (log: \"Switching to the recommended level\"). Multisampled render-to-texture on GLES can hang the Frame's GPU, up to a restart of the whole headset (e.g. Lucky's Tale in a menu). Rewrites OVRDisplay.recommendedMSAALevel -> 0 in libil2cpp.so (found with Cpp2IL), so MSAA stays off.": "",
|
||||
"MiB": "",
|
||||
"Mirrored": "",
|
||||
"Mixed reality": "",
|
||||
@@ -469,13 +493,14 @@
|
||||
"Needs Windows (or WSL on Windows)": "",
|
||||
"Neutralises Meta XR Audio's Quest-only calls (Unity/Wwise).": "",
|
||||
"New folder": "",
|
||||
"New Frame? Run one command on it and FramePort does the rest.": "",
|
||||
"New Frame? Run one command on it and FramePort does the rest. Did this once already? Your Frame appears under step 2.": "",
|
||||
"New name": "",
|
||||
"Next": "",
|
||||
"No activity": "",
|
||||
"no answer from the Frame": "",
|
||||
"No artwork could be downloaded from that {source} result; the current artwork stays. Try another one.": "",
|
||||
"No folders to rescan yet: add games with Scan a folder": "",
|
||||
"No Frames found. Turn on Developer Mode on the Frame (Settings → System → Developer) and make sure it's on the same network, or use first-time setup below.": "",
|
||||
"No Frames found. New Frame? Use first-time setup (step 1). Otherwise make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
|
||||
"No games found in that folder": "",
|
||||
"No limit": "",
|
||||
"No matching files": "",
|
||||
@@ -494,6 +519,7 @@
|
||||
"Not verified on many games yet: try it if the game doesn't work without it.": "",
|
||||
"Notes (what you checked, known issues)": "",
|
||||
"Nothing found. Try a shorter or different name.": "",
|
||||
"Nothing has reached FramePort from the Frame yet (curl says “timed out”)?": "",
|
||||
"Nothing installed yet. Pick a game in the Library and click Install.": "",
|
||||
"Nothing running. Installs, launch tests and downloads show up here.": "",
|
||||
"Nothing to install": "",
|
||||
@@ -504,16 +530,16 @@
|
||||
"Now {scale:.0%}; {new:.0%} after restarting FramePort.": "",
|
||||
"Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR).": "",
|
||||
"Oculus OS telemetry class stubs": "",
|
||||
"Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only effects or grey (e.g. I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. Rewrites OculusSettings.GetStereoRenderingMode -> MultiPass in libil2cpp.so (found with Cpp2IL). Try it when one eye is wrong.": "",
|
||||
"off: {names}": "",
|
||||
"Offline": "",
|
||||
"Older workaround for upside-down quads: rotates them 180° (quads are single-sided; prefer flip_emul).": "",
|
||||
"On Frame": "",
|
||||
"On the Frame, place Revive's runtime (FramePort's Revive, or the repack's bundled LibRevive) where the game's Oculus SDK looks for LibOVRRT so it can load a VR runtime (pure runtime substitution — what Revive's LoadLibrary hook does on Windows; that hook doesn't work under Proton on the Frame). It does NOT bypass the Oculus runtime signature check: a game that verifies the runtime's signature will still refuse it. Ignored on this PC (Revive's hook handles it there).": "",
|
||||
"On the Frame: Steam button → Power → Switch to Desktop, open Konsole and run:": "",
|
||||
"On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole, and run:": "",
|
||||
"On this PC": "",
|
||||
"On this PC, launch the game through Revive's OpenVR backend, which talks straight to SteamVR — the original, most reliable Revive path. On by default for PC installs. Turn it off to use Revive's newer OpenXR backend instead. Ignored on the Frame (which always uses OpenXR).": "",
|
||||
"On this PC, runs Revive through SteamVR (works with more headsets).": "",
|
||||
"On your network": "",
|
||||
"on: {names}": "",
|
||||
"One PC VR game folder": "",
|
||||
"Only FramePort's entry is removed. Your game files and anything installed stay.": "",
|
||||
@@ -526,6 +552,8 @@
|
||||
"Opens a prefilled GitHub issue with this game's recipe, so it can join the built-in catalog (no account token needed; you review and submit it on GitHub). No game files or personal data are sent.": "",
|
||||
"Opens this player's storage on the Frame (Files tab): upload videos into the folder it lists": "",
|
||||
"OpenXR runtime": "",
|
||||
"Or paste text to type it in one go": "",
|
||||
"Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows up under step 2. Open Settings → Developer → Pair new host on the Frame, click Connect there and approve FramePort. That way needs no connection into this computer.": "",
|
||||
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
|
||||
"Override the guardian width (0 = use guardian, min 1.5 m).": "",
|
||||
"OVRPort converts Quest games from Meta's own VR APIs to standard OpenXR. These are its optional patches.": "",
|
||||
@@ -535,6 +563,7 @@
|
||||
"OVRPort's own converter for games that use Meta's oldest VR interface (experimental).": "",
|
||||
"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).": "",
|
||||
"Package": "",
|
||||
"Pairing: on the Frame open Settings → Developer → Pair new host, then approve FramePort.": "",
|
||||
"Password (first time only)": "",
|
||||
"Patch Meta XR Audio": "",
|
||||
"Patch Oculus detection for Unity": "",
|
||||
@@ -544,6 +573,7 @@
|
||||
"Patches every game of this kind gets (on by default). Customize lists them all.": "",
|
||||
"Patches for what the Frame's runtime does differently from a Quest (graphics formats, missing OpenXR extensions, Lepton's launcher requirements).": "",
|
||||
"Patches that can't matter for this game (wrong engine or API) are hidden. Show them to force one on anyway.": "",
|
||||
"Paused: Frame battery low, plug it in": "",
|
||||
"Paused: waiting for your Frame": "",
|
||||
"PC VR": "",
|
||||
"PC VR (Revive / Proton)": "",
|
||||
@@ -716,6 +746,7 @@
|
||||
"Show setup command": "",
|
||||
"Show Steam Frame controllers": "",
|
||||
"Show technical details (patch ids, exact effects, parameters)": "",
|
||||
"Show the app's Android window (for typing)": "",
|
||||
"Showing {len} of {n}": "",
|
||||
"Shows 3D 360° pictures flat if they look doubled.": "",
|
||||
"Shows the Frame's cameras where the game expects passthrough.": "",
|
||||
@@ -780,15 +811,19 @@
|
||||
"Test Proton on the Frame": "",
|
||||
"Text and layout size": "",
|
||||
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
|
||||
"The command fetches a small setup script from this app over your local network. It turns on SSH, lets this app's key in, makes the Frame findable and installs Lepton if needed.": "",
|
||||
"The command fetches a small setup script from this app over your local network. It turns on Developer Mode (which includes SSH), lets this app's key in and installs Lepton if needed. No password needed.": "",
|
||||
"The connection to the Frame is busy. Try again in a moment.": "",
|
||||
"The copy on your Frame differs from what FramePort would install now (the recipe or your game files changed). Update to install the new build; saves are kept.": "",
|
||||
"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.": "",
|
||||
"The download didn't finish. Check your internet connection.": "",
|
||||
"The Frame can't show curved panels; this shows them as gently bent strips.": "",
|
||||
"The Frame is charging: continuing the installs.": "",
|
||||
"the Frame isn't connected": "",
|
||||
"The Frame isn't connected": "",
|
||||
"The Frame refused that password. Check it, or run the first-time setup instead: Steam Frame → Show setup command.": "",
|
||||
"The Frame went offline: {exc}": "",
|
||||
"The Frame's battery is at {pct} % and not charging. Plug it in (a strong charger, not a PC port): long installs can drain it, and FramePort pauses them at {pause} %.": "",
|
||||
"The Frame's battery is at {pct} %: installs paused so it doesn't switch off mid-upload. Plug it in; the queue continues by itself.": "",
|
||||
"The Frame's desktop password, only needed the first time so FramePort can add its own key. After that it connects with the key.": "",
|
||||
"The Frame's Steam library still shows the old artwork.": "",
|
||||
"the game didn't pass its checks (see the list above)": "",
|
||||
@@ -807,8 +842,10 @@
|
||||
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app — it isn't installed on this PC, so they may quit right after starting. FramePort doesn't change how a game checks its license. Untick the ones you'd rather skip.": "",
|
||||
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app. It doesn't exist on the Steam Frame, so there they crash right at startup (seen with Robo Recall, Lies Beneath and Lone Echo) — play them on this PC. Tick any you still want to try on the Frame.": "",
|
||||
"They're Oculus games that need Revive to reach VR, and Revive can't run on the Frame. Play them on this PC instead (Install on this PC — SteamVR + Revive). Tick any you still want to put on the Frame to experiment (they'll likely run flat or crash).": "",
|
||||
"This computer's keyboard works as a keyboard on your Frame while this window is open. In the headset, select a text field (in a game or app, in Steam or on the desktop), then type here.": "",
|
||||
"This deletes the {what} on the Frame. It can't be undone.": "",
|
||||
"This folder is empty. Upload files with the buttons above.": "",
|
||||
"This Frame isn't set up for FramePort yet. Run the first-time setup: Steam Frame → Show setup command.": "",
|
||||
"This game can't run on the Steam Frame.": "",
|
||||
"This game is marked \"Can't run\" on the Steam Frame:": "",
|
||||
"This game's folder has several programs and FramePort isn't sure which one starts the game. Open the game (or right-click → Change executable) to pick it.": "",
|
||||
@@ -837,6 +874,12 @@
|
||||
"Turns the controllers' pointing ray left (+) or right (−).": "",
|
||||
"Turns the pointer left or right.": "",
|
||||
"Type": "",
|
||||
"Type it": "",
|
||||
"Type on Frame": "",
|
||||
"Type on Frame: use this keyboard on the Frame": "",
|
||||
"Type on Frame…": "",
|
||||
"Typed it, except characters the Frame's US keyboard layout doesn't have: {chars}": "",
|
||||
"ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (and afterwards: sudo ufw delete allow {port}/tcp)": "",
|
||||
"Uninstall": "",
|
||||
"Uninstall FramePort": "",
|
||||
"Uninstall FramePort?": "",
|
||||
@@ -847,7 +890,10 @@
|
||||
"Uninstall {title}?": "",
|
||||
"Uninstall. ": "",
|
||||
"Uninstalled {title}": "",
|
||||
"Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with \"Show the app window\") or Type on Frame from the PC.": "",
|
||||
"Unity: disable MSAA": "",
|
||||
"Unity: keep MSAA off at runtime (OVRManager)": "",
|
||||
"Unity: render each eye separately (no multiview)": "",
|
||||
"Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on non-Quest devices. Patches that one JNI FindClass call so the library continues (e.g. NOPE Challenge).": "",
|
||||
"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).": "",
|
||||
"Untested": "",
|
||||
@@ -880,6 +926,7 @@
|
||||
"Use controllers": "",
|
||||
"Use tags to group and filter your library": "",
|
||||
"Use the repack's launcher (bundled Revive)": "",
|
||||
"Use this computer's keyboard on the Frame": "",
|
||||
"Use this program": "",
|
||||
"Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta.": "",
|
||||
"Uses the Oculus Platform SDK: it checks your Oculus license. Normally that needs the Oculus app on this PC with a license you own, so it may quit right after starting on the headset. You can still try it.": "",
|
||||
@@ -910,7 +957,12 @@
|
||||
"Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by FramePort) or user (changed by you).": "",
|
||||
"Which program starts {get}?": "",
|
||||
"Which Proton build runs the game on the Frame (a Steam compat tool name from the Frame's ARM64 compat list, e.g. proton_11-arm64 or proton-experimental-arm64). Empty = newest installed.": "",
|
||||
"Windows": "",
|
||||
"Windows game (Proton)": "",
|
||||
"Windows not detected": "",
|
||||
"Windows treats this network as public and its firewall blocks the Frame. In Windows' network settings set this network to Private (or allow FramePort on public networks when Windows asks).": "",
|
||||
"Windows' firewall for WSL blocks the Frame from reaching FramePort. Open the setup command again and allow the change when Windows asks (admin).": "",
|
||||
"Windows' firewall for WSL blocks the Frame. Open the setup command again and allow the change when Windows asks (admin).": "",
|
||||
"Working": "",
|
||||
"Working…": "",
|
||||
"Works": "",
|
||||
@@ -930,6 +982,8 @@
|
||||
"Your custom recipe": "",
|
||||
"your Frame": "",
|
||||
"Your Frame is back: continuing": "",
|
||||
"Your Frame isn't set up for FramePort yet. Run the first-time setup once (step 1).": "",
|
||||
"Your Frame restarted unexpectedly while {title} was running (or soon after). If that game crashed it, please report it.": "",
|
||||
"Your headset, what it's ready for and what's installed": "",
|
||||
"Your own game files aren't touched.": "",
|
||||
"Your own tags (filled) can be anything you like. Outlined tags are added automatically from the engine, VR API and store genres. Filter the library by either.": "",
|
||||
@@ -992,7 +1046,10 @@
|
||||
],
|
||||
"{on} on your Frame": "",
|
||||
"{remaining} more after this": "",
|
||||
"{title} couldn't be added to the Frame's Steam library: {why}": "",
|
||||
"{title} failed: {error}": "",
|
||||
"{title} isn't in the Frame's Steam library yet: adding it (Steam restarts once)": "",
|
||||
"{title}: analyzed again": "",
|
||||
"{title}: details from {value}": "",
|
||||
"{title}: done": "",
|
||||
"{title}: launch test {verdict} (furthest: {value})": "",
|
||||
|
||||
@@ -57,6 +57,7 @@ class InstallContext:
|
||||
files: dict[str, bytes] # path relative to Android/data/<pkg>/files -> content
|
||||
env: dict[str, str] # extra Lepton env exports
|
||||
adapter_settings: dict[str, Any]
|
||||
flatscreen: bool = False # show the app's Android window (Lepton's lepton-show-flatscreen) even for a VR app
|
||||
|
||||
|
||||
class Patch:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Unity XR settings the game's code chooses at runtime, fixed in libil2cpp.so (found per game with Cpp2IL, like
|
||||
frame.unity_text_input).
|
||||
|
||||
- MSAA: Meta's OVRManager (useRecommendedMSAALevel) raises Unity's antiAliasing to the headset's recommended level
|
||||
(4x) at runtime ("The current MSAA level is 0, but the recommended MSAA level is 4. Switching to the recommended
|
||||
level."), whatever QualitySettings say, so frame.unity_no_msaa can't keep it off. Multisampled render-to-texture on
|
||||
GLES through Zink hangs the Frame's GPU (Lucky's Tale: the whole headset restarted in a menu, 2026-10-03).
|
||||
OVRDisplay.recommendedMSAALevel -> 0 keeps it off.
|
||||
- Multiview: Oculus XR Plugin games render both eyes in one pass (Multiview) on Android. I Am Cat (Unity 2022.3, GLES
|
||||
through Zink) drew only effects into the right eye's array slice; OculusSettings.GetStereoRenderingMode -> 0
|
||||
(MultiPass) renders each eye in its own pass."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import Suggestion, register
|
||||
from .unity_text_input import RET_FALSE, Il2cppReturnPatch
|
||||
|
||||
RET_ZERO = RET_FALSE # mov w0, #0 ; ret
|
||||
|
||||
|
||||
class UnityRuntimeMsaa(Il2cppReturnPatch):
|
||||
id = "frame.unity_runtime_msaa_off"
|
||||
title = "Unity: keep MSAA off at runtime (OVRManager)"
|
||||
description = ("Meta's OVRManager switches Unity to the headset's recommended MSAA level (4x) while the game runs, "
|
||||
"whatever its quality settings say (log: \"Switching to the recommended level\"). Multisampled "
|
||||
"render-to-texture on GLES can hang the Frame's GPU, up to a restart of the whole headset (e.g. "
|
||||
"Lucky's Tale in a menu). Rewrites OVRDisplay.recommendedMSAALevel -> 0 in libil2cpp.so (found with "
|
||||
"Cpp2IL), so MSAA stays off.")
|
||||
order = 47
|
||||
targets = {"Oculus.VR/OVRDisplay.cs": {"get_recommendedMSAALevel": RET_ZERO},
|
||||
"Assembly-CSharp/OVRDisplay.cs": {"get_recommendedMSAALevel": RET_ZERO}} # older Oculus Integration
|
||||
check_name = "Unity MSAA"
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unity" and "libil2cpp.so" in a.libs and not a.only_32bit
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a) and (a.extra or {}).get("ovr_runtime_msaa") and "GLES" in a.graphics:
|
||||
return Suggestion(True, "GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled "
|
||||
"render-to-texture can hang the Frame's GPU.")
|
||||
return None
|
||||
|
||||
|
||||
class UnityMultiPass(Il2cppReturnPatch):
|
||||
id = "frame.unity_multipass"
|
||||
title = "Unity: render each eye separately (no multiview)"
|
||||
description = ("Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only "
|
||||
"effects or grey (e.g. I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. "
|
||||
"Rewrites OculusSettings.GetStereoRenderingMode -> MultiPass in libil2cpp.so (found with Cpp2IL). "
|
||||
"Try it when one eye is wrong.")
|
||||
order = 48
|
||||
targets = {"Unity.XR.Oculus/Unity/XR/Oculus/OculusSettings.cs": {"GetStereoRenderingMode": RET_ZERO}}
|
||||
check_name = "Unity stereo mode"
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unity" and "libil2cpp.so" in a.libs and not a.only_32bit \
|
||||
and (a.extra or {}).get("oculus_xr_plugin", True) is not False
|
||||
|
||||
def detect(self, a):
|
||||
return None # only for a game that shows the symptom (catalog recipe or the user's choice)
|
||||
|
||||
|
||||
register(UnityRuntimeMsaa)
|
||||
register(UnityMultiPass)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Unity text fields that close at once on the Frame: make them edit in place, like on a PC.
|
||||
|
||||
Unity's text fields (TextMeshPro TMP_InputField, and uGUI's older InputField) open the system on-screen keyboard on
|
||||
Android and close themselves a frame later when none is visible. Lepton's Android has no on-screen keyboard (Meta's
|
||||
system keyboard does this job on a Quest), so the field shows a caret for a moment and loses focus: nothing can be
|
||||
typed (found with Stremio VR, 2026-10-03). Two return values are rewritten so the field behaves as on a PC: it doesn't
|
||||
wait for a system keyboard and takes key presses as events, from Steam's keyboard (with device.text_input_window) or
|
||||
the PC keyboard (Type on Frame). The methods are found per game with Cpp2IL (analysis/il2cpp.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
RET_FALSE = bytes.fromhex("00008052c0035fd6") # mov w0, #0 ; ret
|
||||
RET_TRUE = bytes.fromhex("20008052c0035fd6") # mov w0, #1 ; ret
|
||||
# Cpp2IL class file -> {method: what it should return}
|
||||
TARGETS = {
|
||||
"Unity.TextMeshPro/TMPro/TMP_InputField.cs": {"TouchScreenKeyboardShouldBeUsed": RET_FALSE,
|
||||
"isKeyboardUsingEvents": RET_TRUE},
|
||||
# uGUI's InputField: its LateUpdate returns early (keeps the field) when InPlaceEditing() is true
|
||||
"UnityEngine.UI/UnityEngine/UI/InputField.cs": {"TouchScreenKeyboardShouldBeUsed": RET_FALSE,
|
||||
"InPlaceEditing": RET_TRUE},
|
||||
}
|
||||
|
||||
|
||||
def _executable(data: bytes) -> list[tuple[int, int]]:
|
||||
return [(s["p_offset"], s["p_offset"] + s["p_filesz"]) for s in elf._elf(data).iter_segments()
|
||||
if s["p_type"] == "PT_LOAD" and s["p_flags"] & 1]
|
||||
|
||||
|
||||
def patch_methods(lib: bytes, found: dict[str, dict[str, tuple[int, int]]],
|
||||
targets: dict[str, dict[str, bytes]] | None = None) -> tuple[bytes | None, list[str]]:
|
||||
"""Write the return values over the methods' first two instructions. found = il2cpp.find_methods' result,
|
||||
targets = {class file: {method: RET_*}} (default: the text field methods).
|
||||
Returns (patched library or None when nothing changed, notes)."""
|
||||
out, notes, changed = bytearray(lib), [], False
|
||||
code = _executable(lib)
|
||||
for cls, methods in (targets or TARGETS).items():
|
||||
for method, new in methods.items():
|
||||
off, length = found.get(cls, {}).get(method, (None, 0))
|
||||
if off is None:
|
||||
continue
|
||||
name = f"{cls.rsplit('/', 1)[-1][:-3]}.{method}"
|
||||
if off % 4 or length < len(new) or not any(a <= off and off + len(new) <= b for a, b in code):
|
||||
raise RuntimeError(f"{name}: offset {off:#x} isn't code in libil2cpp.so (Cpp2IL mismatch)")
|
||||
if bytes(out[off:off + len(new)]) == new:
|
||||
notes.append(f"{name} already patched")
|
||||
continue
|
||||
out[off:off + len(new)] = new
|
||||
changed = True
|
||||
notes.append(f"{name} -> {'true/1' if new == RET_TRUE else 'false/0'} (at {off:#x})")
|
||||
return (bytes(out) if changed else None), notes
|
||||
|
||||
|
||||
ALL_TARGETS: dict[str, list[str]] = {} # every IL2CPP patch's methods: one Cpp2IL run (and cache entry) serves all
|
||||
|
||||
|
||||
class Il2cppReturnPatch(Patch):
|
||||
"""Make IL2CPP methods return a constant (targets), found per game with Cpp2IL (analysis/il2cpp.py)."""
|
||||
targets: dict[str, dict[str, bytes]] = {}
|
||||
check_name = ""
|
||||
|
||||
def __init_subclass__(cls, **kw):
|
||||
super().__init_subclass__(**kw)
|
||||
for c, methods in cls.targets.items():
|
||||
ALL_TARGETS.setdefault(c, [])
|
||||
ALL_TARGETS[c] += [m for m in methods if m not in ALL_TARGETS[c]]
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
from ...analysis.il2cpp import find_methods
|
||||
|
||||
ws = ctx.ws
|
||||
lib_name = ws.lib("libil2cpp.so")
|
||||
if ws.abi != "arm64-v8a" or not ws.has(lib_name) or not ws.has(METADATA):
|
||||
return False
|
||||
version = (ctx.analysis.extra or {}).get("unity_version")
|
||||
if not version:
|
||||
ctx.reporter.check(self.check_name, False, "not fixed: unknown Unity version (rescan the game)")
|
||||
return False
|
||||
lib = ws.read(lib_name)
|
||||
ctx.reporter.log(f"{self.check_name}: finding the code (Cpp2IL; the first time can take a minute)")
|
||||
try:
|
||||
found = find_methods(lib, ws.read(METADATA), version, ALL_TARGETS)
|
||||
patched, notes = patch_methods(lib, found, self.targets)
|
||||
except Exception as exc: # noqa: BLE001 - optional fix: the game still builds, unchanged
|
||||
ctx.reporter.check(self.check_name, False, f"not fixed: {exc}")
|
||||
return False
|
||||
ctx.notes.extend(notes or [f"{self.check_name}: code not found in this game"])
|
||||
if patched is not None:
|
||||
ws.put(lib_name, patched)
|
||||
return patched is not None
|
||||
|
||||
|
||||
class UnityTextInput(Il2cppReturnPatch):
|
||||
id = "frame.unity_text_input"
|
||||
title = "Make Unity text fields work without a system keyboard"
|
||||
description = ("Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a "
|
||||
"frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does "
|
||||
"this on a Quest), so a selected text field only flashes a caret. Rewrites "
|
||||
"TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing "
|
||||
"(uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields "
|
||||
"stay selected and take key presses: Steam's keyboard (with \"Show the app window\") or Type on "
|
||||
"Frame from the PC.")
|
||||
order = 46
|
||||
needs_vr = False
|
||||
targets = TARGETS
|
||||
check_name = "Unity text fields"
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unity" and "libil2cpp.so" in a.libs and bool((a.extra or {}).get("text_fields"))
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a):
|
||||
kinds = " and ".join((a.extra or {}).get("text_fields"))
|
||||
return Suggestion(True, f"Unity app with text fields ({kinds}): they close at once on the Frame because "
|
||||
"it has no system keyboard.")
|
||||
return None
|
||||
|
||||
|
||||
register(UnityTextInput)
|
||||
@@ -301,8 +301,33 @@ class HideNavBar(Patch):
|
||||
ctx.env.update(self.ENV)
|
||||
|
||||
|
||||
class TextInputWindow(Patch):
|
||||
id = "device.text_input_window"
|
||||
title = "Show the app's Android window (for typing)"
|
||||
description = ("Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard "
|
||||
"focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) "
|
||||
"reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown "
|
||||
"(behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. "
|
||||
"Steam lists the shown window as \"Gamescope\" (the Frame's compositor). Pairs with \"Make Unity "
|
||||
"text fields work without a system keyboard\".")
|
||||
category = "device"
|
||||
stage = "install"
|
||||
|
||||
def applies(self, a):
|
||||
return a.vr_kind != "none" and bool((a.extra or {}).get("text_fields"))
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a):
|
||||
return Suggestion(True, "App with text fields: key presses only reach a shown Android window.")
|
||||
return None
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
ctx.flatscreen = True
|
||||
|
||||
|
||||
for _spec in SETTINGS:
|
||||
register(AdapterSetting(*_spec))
|
||||
register(DeviceFiles)
|
||||
register(LeptonEnv)
|
||||
register(HideNavBar)
|
||||
register(TextInputWindow)
|
||||
@@ -43,15 +43,21 @@ SUMMARIES = {
|
||||
"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.unity_runtime_msaa_off": "Keeps a smoothing setting off that the game turns on itself and that can freeze "
|
||||
"the Frame.",
|
||||
"frame.unity_multipass": "Draws each eye separately; try it when one eye shows a grey or broken picture.",
|
||||
"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.unity_text_input": "Lets you type into this app's text fields on the Frame (they'd close at once).",
|
||||
"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.text_input_window": "Lets typing reach this app: Steam's on-screen keyboard or your computer's keyboard "
|
||||
"(VR is unaffected).",
|
||||
"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.",
|
||||
|
||||
@@ -153,7 +153,9 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {folder.name}")
|
||||
a = rift.analyze(folder, exe=exe, tree=tree)
|
||||
if not a.extra.get("vr_found") and not force and not old:
|
||||
# no VR runtime in it: added on purpose ("Add one game folder…") = a flat Windows game run by Proton
|
||||
a.extra["flat"] = not a.extra.get("vr_found")
|
||||
if a.extra["flat"] and not force and not old:
|
||||
if reporter:
|
||||
reporter.log(f"skipped {folder.name}: no VR support (OpenXR, SteamVR or Oculus) found in {a.extra['exe']}")
|
||||
return None
|
||||
@@ -282,6 +284,23 @@ def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
|
||||
)
|
||||
|
||||
|
||||
def reanalyze(package: str, reporter: Reporter | None = None) -> dict:
|
||||
"""Read a Quest/Android game's APK again (e.g. after FramePort learned to detect something new). The suggestion is
|
||||
refreshed; the recipe too unless the user changed it (then their choices stay)."""
|
||||
entry = library.game(package)
|
||||
if entry is None or is_rift(entry):
|
||||
raise ValueError("only Quest/Android games can be analyzed again")
|
||||
src = source_of(entry)
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {src.apk.name}")
|
||||
a = analyze(src.apk, data_bytes=src.data_bytes())
|
||||
suggested = engine.suggest(a)
|
||||
keep = library.recipe_from_dict(entry["recipe"]).source == "user"
|
||||
return library.upsert_game(package, analysis=a.to_dict(), suggested=library.recipe_to_dict(suggested),
|
||||
**({} if keep else {"recipe": library.recipe_to_dict(suggested),
|
||||
"status": suggested.status}))
|
||||
|
||||
|
||||
def source_of(entry: dict) -> SourceGame:
|
||||
return SourceGame(entry.get("name") or entry["package"], Path(entry["apk"]),
|
||||
Path(entry["data_dir"]) if entry.get("data_dir") else None,
|
||||
|
||||
@@ -35,6 +35,7 @@ class CatalogEntry:
|
||||
use_alt: bool = False
|
||||
frame: list[str] = field(default_factory=list)
|
||||
frame_remove: list[str] = field(default_factory=list)
|
||||
device: list[str] = field(default_factory=list) # device.* toggles, e.g. device.text_input_window
|
||||
adapter: dict = field(default_factory=dict)
|
||||
device_files: dict = field(default_factory=dict)
|
||||
lepton_env: dict = field(default_factory=dict)
|
||||
@@ -48,6 +49,7 @@ class CatalogEntry:
|
||||
proton_tool: str = ""
|
||||
as_is: bool = False # Rift: the dump runs unchanged (e.g. a SteamVR build: no Revive)
|
||||
verified: dict = field(default_factory=dict)
|
||||
updated: str = "" # date of the last maintainer change without a new headset test (e.g. a fix from a report)
|
||||
source_hint: str = ""
|
||||
origin: str = "bundled"
|
||||
|
||||
@@ -56,6 +58,14 @@ class CatalogEntry:
|
||||
known = {k: v for k, v in d.items() if k in cls.__dataclass_fields__}
|
||||
return cls(**known, origin=origin)
|
||||
|
||||
def rev(self) -> str:
|
||||
"""Fingerprint of the recipe content: library recipes derived from this entry follow it when it changes."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
d = {k: v for k, v in self.to_dict().items() if k != "origin"}
|
||||
return hashlib.sha256(json.dumps(d, sort_keys=True, default=str).encode()).hexdigest()[:16]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
out = {}
|
||||
for k in self.__dataclass_fields__:
|
||||
@@ -113,11 +123,21 @@ def load(refresh: bool = False) -> dict[str, CatalogEntry]:
|
||||
if _cache is None or refresh:
|
||||
entries = _load_dir(catalog_dir() / "games", "bundled")
|
||||
entries.update(_load_remote())
|
||||
entries.update(_load_dir(user_catalog_dir(), "user"))
|
||||
for pkg, e in _load_dir(user_catalog_dir(), "user").items():
|
||||
if not _newer(entries.get(pkg), e): # the user's own recipe, unless a maintained one was verified later
|
||||
entries[pkg] = e
|
||||
_cache = entries
|
||||
return _cache
|
||||
|
||||
|
||||
def _newer(maintained: CatalogEntry | None, user: CatalogEntry) -> bool:
|
||||
"""True when the bundled/remote entry was verified after the user's (e.g. a fix for a config the user shared)."""
|
||||
if maintained is None:
|
||||
return False
|
||||
when = max(str(maintained.updated or ""), str(maintained.verified.get("date") or ""))
|
||||
return when > str(user.verified.get("date") or "")
|
||||
|
||||
|
||||
def lookup(package: str) -> CatalogEntry | None:
|
||||
return load().get(package)
|
||||
|
||||
@@ -129,6 +149,10 @@ def save_user_entry(entry: CatalogEntry) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
# device.* patches a recipe switches on by id (device.files / device.lepton_env carry their own catalog fields)
|
||||
TOGGLED_DEVICE = ("device.text_input_window",)
|
||||
|
||||
|
||||
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)."""
|
||||
@@ -171,6 +195,7 @@ def entry_from_library(g: dict, status: str | None = None, notes: str | None = N
|
||||
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],
|
||||
device=[p for p in r.patches if p in TOGGLED_DEVICE],
|
||||
adapter={p.split(".", 1)[1]: v.get("value") for p, v in r.patches.items() if p.startswith("adapter.")},
|
||||
device_files=r.params("device.files").get("files", {}))
|
||||
|
||||
|
||||
@@ -7,11 +7,15 @@ from __future__ import annotations
|
||||
from ..core.models import Analysis, Recipe
|
||||
from ..patches import base
|
||||
from . import catalog
|
||||
from .catalog import TOGGLED_DEVICE
|
||||
|
||||
# what a flat (non-VR) Windows game keeps of the PC VR patches
|
||||
FLAT_WINDOWS_PATCHES = ("pcvr.proton_tool", "pcvr.proton_env", "pcvr.proton_log", "pcvr.no_crash_reporter")
|
||||
|
||||
|
||||
def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
entry = catalog.lookup(analysis.package) if use_catalog else None
|
||||
recipe = Recipe(analysis.package, title=analysis.label)
|
||||
recipe = Recipe(analysis.package, title=analysis.label, catalog_rev=entry.rev() if entry else "")
|
||||
# 1. heuristics / defaults from every patch module
|
||||
rift = (analysis.extra or {}).get("kind") == "rift"
|
||||
for patch in base.all_patches():
|
||||
@@ -26,22 +30,27 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
# 2. known-good catalog recipe overrides heuristics
|
||||
if rift:
|
||||
_rift_recipe(analysis, recipe, entry)
|
||||
if (analysis.extra or {}).get("flat"): # a Windows game without VR: Proton only, no VR parts
|
||||
for pid in [p for p in recipe.patches if p not in FLAT_WINDOWS_PATCHES]:
|
||||
recipe.patches.pop(pid)
|
||||
recipe.reasons.pop(pid, None)
|
||||
elif entry:
|
||||
recipe.source = f"catalog ({entry.origin})"
|
||||
recipe.status, recipe.notes, recipe.title = entry.status, entry.notes, entry.title or recipe.title
|
||||
why = f"Known-good recipe for {entry.title} (tested {entry.verified.get('date', '?')})."
|
||||
for pid in entry.overport_remove:
|
||||
recipe.patches.pop(pid, None)
|
||||
for pid in entry.overport_extra + entry.frame:
|
||||
for pid in entry.overport_extra + entry.frame + entry.device:
|
||||
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
|
||||
chosen = set(entry.overport_extra) | set(entry.frame)
|
||||
chosen = set(entry.overport_extra) | set(entry.frame) | set(entry.device)
|
||||
for pid in list(recipe.patches):
|
||||
p = base.get(pid)
|
||||
if pid not in chosen and not p.default_on and p.category in ("overport", "frame"):
|
||||
if pid not in chosen and not p.default_on and (p.category in ("overport", "frame")
|
||||
or pid in TOGGLED_DEVICE):
|
||||
recipe.patches.pop(pid)
|
||||
recipe.reasons.pop(pid, None)
|
||||
for pid in entry.frame_remove:
|
||||
|
||||
@@ -6,6 +6,7 @@ Works on native Windows and from WSL (Windows programs via interop). Revive is F
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
@@ -15,7 +16,7 @@ from pathlib import Path
|
||||
from ..core import winhost
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
from ..core.paths import agent_dir, user_data_dir
|
||||
from ..core.paths import agent_file, user_data_dir
|
||||
from ..patches.pcvr import game_args
|
||||
from ..validate.triage import triage
|
||||
from .base import Target
|
||||
@@ -27,7 +28,9 @@ REVIVE_LOG = "Revive/ReviveInjector.txt" # under %LOCALAPPDATA%
|
||||
|
||||
def _vdf():
|
||||
"""The agent's binary-VDF/shortcut code (stdlib only), reused so there's a single implementation."""
|
||||
spec = importlib.util.spec_from_file_location("frameport_agent_vdf", agent_dir() / "frameport_agent.py")
|
||||
path = str(agent_file()) # an explicit loader: in release bundles the source has a .txt name (paths.agent_file)
|
||||
spec = importlib.util.spec_from_loader("frameport_agent_vdf",
|
||||
importlib.machinery.SourceFileLoader("frameport_agent_vdf", path))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Cpp2IL (SamboyCoding/Cpp2IL, MIT): reads an IL2CPP Unity game's libil2cpp.so + global-metadata.dat and lists every
|
||||
method with its address. FramePort needs it only for Unity games whose text fields it fixes (frame.unity_text_input),
|
||||
so it is downloaded on first use into <user data>/tools/ (a self-contained executable per OS; no .NET install).
|
||||
|
||||
Its current releases are all pre-releases (the 2022.1 line is the one that reads Unity 6 / metadata v31), so the
|
||||
newest release with an asset for this computer is used. Override: FRAMEPORT_CPP2IL=<path to the executable>."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..core import cache
|
||||
from ..core.paths import tools_dir
|
||||
|
||||
RELEASES = "https://api.github.com/repos/SamboyCoding/Cpp2IL/releases"
|
||||
|
||||
|
||||
def asset_suffix() -> str:
|
||||
"""Release asset suffix for this computer, e.g. Linux, Linux-ARM64, OSX-ARM64, Windows.exe."""
|
||||
arm = platform.machine().lower() in ("arm64", "aarch64")
|
||||
if sys.platform == "win32":
|
||||
return "Windows-ARM64.exe" if arm else "Windows.exe"
|
||||
if sys.platform == "darwin":
|
||||
return "OSX-ARM64" if arm else "OSX"
|
||||
return "Linux-ARM64" if arm else "Linux"
|
||||
|
||||
|
||||
def pick_release(releases: list[dict], suffix: str) -> tuple[str, str] | None:
|
||||
"""(version, download url) of the newest release that has an executable for this computer."""
|
||||
for rel in releases:
|
||||
for asset in rel.get("assets", []):
|
||||
if asset.get("name", "").endswith("-" + suffix):
|
||||
return rel.get("tag_name", "?"), asset["browser_download_url"]
|
||||
return None
|
||||
|
||||
|
||||
def path() -> Path | None:
|
||||
if os.environ.get("FRAMEPORT_CPP2IL"):
|
||||
return Path(os.environ["FRAMEPORT_CPP2IL"])
|
||||
found = sorted(tools_dir().glob("cpp2il-*/Cpp2IL*"))
|
||||
return found[-1] if found else None
|
||||
|
||||
|
||||
def installed_version() -> str | None:
|
||||
p = path()
|
||||
return p.parent.name.removeprefix("cpp2il-") if p is not None and p.parent.name.startswith("cpp2il-") else None
|
||||
|
||||
|
||||
def ensure(progress=None) -> Path:
|
||||
"""The Cpp2IL executable, downloading it first if needed."""
|
||||
existing = path()
|
||||
if existing is not None and existing.exists():
|
||||
return existing
|
||||
releases = cache.cached_json("cpp2il-releases.json", RELEASES, max_age=7 * 86400, fallback=[]) or []
|
||||
picked = pick_release(releases, asset_suffix())
|
||||
if picked is None:
|
||||
raise RuntimeError("couldn't find a Cpp2IL download for this computer")
|
||||
version, url = picked
|
||||
exe = tools_dir() / f"cpp2il-{version}" / ("Cpp2IL.exe" if sys.platform == "win32" else "Cpp2IL")
|
||||
cache.download(url, exe, progress)
|
||||
exe.chmod(exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return exe
|
||||
+185
-10
@@ -19,6 +19,7 @@ import flet as ft
|
||||
from .. import pipeline
|
||||
from ..core import applog, library
|
||||
from ..errors import explain
|
||||
from ..frame.connection import NOT_IN_LIBRARY, AgentFailed
|
||||
from ..i18n import fmt_size, tr, tr_n
|
||||
from ..recommend import catalog
|
||||
from . import components as C
|
||||
@@ -158,11 +159,24 @@ class FramePortApp:
|
||||
self._conn_name = C.body(tr("Steam Frame"), T.TEXT, weight=ft.FontWeight.W_600, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS)
|
||||
self._conn_line = C.meta(tr("Not set up"))
|
||||
# battery: icon + "7 %" next to the name, like a phone's status bar (a text suffix wrapped in the sidebar)
|
||||
self._conn_bat_icon = ft.Icon(ft.Icons.BATTERY_FULL_ROUNDED, size=T.px(15), color=T.TEXT_2)
|
||||
self._conn_bat_text = C.meta("")
|
||||
self._conn_bat = ft.Container(ft.Row([self._conn_bat_icon, self._conn_bat_text], spacing=T.px(2), tight=True,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER), visible=False)
|
||||
self._conn_extra = ft.Container(C.meta(""), visible=False, tooltip=C.tip(C.HELP["frame_summary"]))
|
||||
# compact (no button padding) and next to the name: as a separate column it squeezed "Quest ✓ PC VR ✓"
|
||||
self._conn_type = ft.IconButton(ft.Icons.KEYBOARD_ROUNDED, icon_size=T.px(16), icon_color=T.TEXT_2,
|
||||
tooltip=tr("Type on Frame: use this keyboard on the Frame"), visible=False,
|
||||
on_click=lambda e: self.type_on_frame(), padding=0,
|
||||
width=T.px(22), height=T.px(22),
|
||||
style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=T.px(6))))
|
||||
self.conn_card.content = ft.Container(ft.Row([
|
||||
ft.Stack([ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(22), color=T.TEXT_2),
|
||||
ft.Container(self._conn_dot, right=0, bottom=0)], width=T.px(24), height=T.px(24)),
|
||||
ft.Column([self._conn_name, self._conn_line, self._conn_extra], spacing=1, expand=True),
|
||||
ft.Column([ft.Row([ft.Container(self._conn_name, expand=True), self._conn_bat, self._conn_type],
|
||||
spacing=T.px(6), vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self._conn_line, self._conn_extra], spacing=1, expand=True),
|
||||
], spacing=T.S3), padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE, ink=True,
|
||||
border=ft.Border.all(1, T.BORDER), on_click=lambda e: self.go("frame"))
|
||||
self._nav = nav # last: _refresh_sidebar (also called from job threads) treats it as "all built"
|
||||
@@ -192,6 +206,9 @@ class FramePortApp:
|
||||
elif self.jobs.paused == "frame":
|
||||
self._act_idle_text.value = tr("Paused: waiting for your Frame")
|
||||
self._act_idle_text.color = T.WARN
|
||||
elif self.jobs.paused == "battery":
|
||||
self._act_idle_text.value = tr("Paused: Frame battery low, plug it in")
|
||||
self._act_idle_text.color = T.WARN
|
||||
else:
|
||||
recent = next((j for j in self.jobs.recent(1)), None)
|
||||
self._act_idle_text.value = tr("No activity") if not recent else \
|
||||
@@ -206,6 +223,19 @@ class FramePortApp:
|
||||
"offline": tr("Offline")}.get(
|
||||
st, "Not set up")
|
||||
self._conn_line.color = color
|
||||
bat = (self.frame_info or {}).get("battery") if st == "connected" else None
|
||||
self._conn_bat.visible = bool(bat)
|
||||
self._conn_type.visible = st == "connected"
|
||||
if bat:
|
||||
from .battery import charging, icon, low
|
||||
|
||||
warn = low(bat)
|
||||
self._conn_bat_icon.icon = getattr(ft.Icons, icon(bat))
|
||||
self._conn_bat_icon.color = T.WARN if warn else T.OK if charging(bat) else T.TEXT_2
|
||||
self._conn_bat_text.value = f"{bat.get('percent', 0)} %"
|
||||
self._conn_bat_text.color = T.WARN if warn else T.TEXT_2
|
||||
self._conn_bat.tooltip = tr("Frame battery: charging") if charging(bat) else \
|
||||
tr("Frame battery: not charging")
|
||||
if st == "connected" and self.frame_info:
|
||||
pr = (self.frame_info.get("proton") or {}).get("ready")
|
||||
quest = tr("Quest ✓") if self.frame_info.get("lepton") else tr("Quest ✗")
|
||||
@@ -321,6 +351,8 @@ class FramePortApp:
|
||||
self.toast(tr("Copy failed; select the text instead"), error=True)
|
||||
|
||||
def _on_key(self, e: ft.KeyboardEvent) -> None:
|
||||
if getattr(self, "_typing_on_frame", False):
|
||||
return # Type on Frame is open: Esc and shortcuts belong to the Frame
|
||||
if e.key == "Escape" and self.activity.open:
|
||||
self.show_activity(False)
|
||||
elif e.key.upper() == "F" and (e.ctrl or e.meta) and self.route[0] == "library" and self.search_field:
|
||||
@@ -362,8 +394,13 @@ class FramePortApp:
|
||||
else (lambda e: self.show_activity(True)))
|
||||
elif job.state == "failed":
|
||||
msg = tr("{title} failed: {error}").format(title=job.title, error=job.error)
|
||||
self.toast(msg, error=True, action=tr("Details"),
|
||||
on_action=lambda e: self.show_activity(True))
|
||||
pkg = job.package
|
||||
if job.kind in ("install", "test") and pkg: # one click to the diagnostics + prefilled issue
|
||||
self.toast(msg, error=True, action=tr("Report a problem"),
|
||||
on_action=lambda e: self.report_problem_dialog(pkg))
|
||||
else:
|
||||
self.toast(msg, error=True, action=tr("Details"),
|
||||
on_action=lambda e: self.show_activity(True))
|
||||
self.refresh_view()
|
||||
if job.kind == "scan" and self.exe_queue:
|
||||
self.page.run_thread(self.next_exe_choice)
|
||||
@@ -463,7 +500,8 @@ class FramePortApp:
|
||||
if self.frame_state == "connected" and C.install_state(g, self.frame_info) in ("installed", "outdated"):
|
||||
out.append((tr("Play on Frame"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: self.play(pkg, "frame"), False,
|
||||
tr("Starts the game through the Frame's Steam — put the headset on")))
|
||||
if g.get("kind") == "rift" and pkg in self.pc_installs():
|
||||
if g.get("kind") == "rift" and pkg in self.pc_installs() and \
|
||||
not ((g.get("analysis") or {}).get("extra") or {}).get("flat"):
|
||||
out.append((tr("Play on this PC"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: self.play(pkg, "pc"), False,
|
||||
tr("Starts the game through Steam on this PC (SteamVR + Revive)")))
|
||||
return out
|
||||
@@ -472,7 +510,13 @@ class FramePortApp:
|
||||
title = self._title(pkg)
|
||||
|
||||
def work():
|
||||
res = self._target_for(to).launch(pkg) or {}
|
||||
try:
|
||||
res = self._target_for(to).launch(pkg) or {}
|
||||
except AgentFailed as exc:
|
||||
if to != "frame" or NOT_IN_LIBRARY not in str(exc):
|
||||
raise
|
||||
self._add_then_play(pkg, title) # e.g. the shortcut step failed during the install
|
||||
return
|
||||
library.upsert_game(pkg, last_played=time.time())
|
||||
if to == "frame":
|
||||
self.toast(tr("Starting {title} on the Frame — put the headset on").format(title=title))
|
||||
@@ -484,6 +528,21 @@ class FramePortApp:
|
||||
self.toast(tr("Starting SteamVR and {title} — put your headset on").format(title=title))
|
||||
self.run_bg(work)
|
||||
|
||||
def _add_then_play(self, pkg: str, title: str) -> None:
|
||||
def run(reporter):
|
||||
target = self._target_for("frame")
|
||||
status = target.add_to_library([pkg], reporter)
|
||||
if not any(a.get("package") == pkg for a in status.get("added", [])):
|
||||
raise RuntimeError(tr("{title} couldn't be added to the Frame's Steam library: {why}").format(
|
||||
title=title, why="; ".join(status.get("errors") or []) or tr("no answer from the Frame")))
|
||||
time.sleep(15) # Steam restarted: give it time to load the library before asking it to launch
|
||||
target.launch(pkg)
|
||||
library.upsert_game(pkg, last_played=time.time())
|
||||
return tr("Starting {title} on the Frame — put the headset on").format(title=title)
|
||||
self.toast(tr("{title} isn't in the Frame's Steam library yet: adding it (Steam restarts once)").format(
|
||||
title=title))
|
||||
self.submit(tr("Add to Steam library: {title}").format(title=title), run, pkg, "tool-frame")
|
||||
|
||||
def quick_action(self, g: dict) -> tuple[str | None, str | None]:
|
||||
"""(tooltip, kind) for a library card's round button; kind: play | install | update | reinstall (None: no
|
||||
button, e.g. while a job runs for the game or before a Frame is connected)."""
|
||||
@@ -540,6 +599,10 @@ class FramePortApp:
|
||||
if on_frame and not rift:
|
||||
out.append((tr("Add videos and files…"), ft.Icons.VIDEO_LIBRARY_OUTLINED,
|
||||
lambda e: self.go("files", pkg)))
|
||||
if on_frame:
|
||||
out.append((tr("Type on Frame…"), ft.Icons.KEYBOARD_ROUNDED, lambda e: self.type_on_frame()))
|
||||
if not rift:
|
||||
out.append((tr("Analyze again"), ft.Icons.MANAGE_SEARCH_ROUNDED, lambda e: self.reanalyze(pkg)))
|
||||
if on_frame:
|
||||
out.append((tr("Uninstall from Frame"), ft.Icons.DELETE_OUTLINE_ROUNDED,
|
||||
lambda e: self.uninstall(pkg, "frame")))
|
||||
@@ -1210,6 +1273,17 @@ class FramePortApp:
|
||||
show_exe_dialog(self, pkg, remaining=len(self.exe_queue), on_done=self.next_exe_choice)
|
||||
return
|
||||
|
||||
def reanalyze(self, pkg: str) -> None:
|
||||
def run(job: Job):
|
||||
pipeline.reanalyze(pkg, job.reporter)
|
||||
return tr("{title}: analyzed again").format(title=self._title(pkg))
|
||||
self.submit(tr("Analyze {title} again").format(title=self._title(pkg)), run, pkg, "task")
|
||||
|
||||
def type_on_frame(self) -> None:
|
||||
from .views.type_dialog import show_type_dialog
|
||||
|
||||
show_type_dialog(self)
|
||||
|
||||
def choose_exe(self, pkg: str) -> None:
|
||||
from .views.exe_dialog import show_exe_dialog
|
||||
|
||||
@@ -1277,16 +1351,78 @@ class FramePortApp:
|
||||
if self.jobs.paused == "frame":
|
||||
self.retry_frame(quiet=True)
|
||||
continue
|
||||
if self.jobs.current():
|
||||
continue # the job is using the connection
|
||||
if self.jobs.current() or self.jobs.paused == "battery":
|
||||
self._battery_check(fetch=True) # the job is using the connection: only ask for the battery
|
||||
continue
|
||||
try:
|
||||
if self.frame_state == "connected":
|
||||
self.refresh_frame(quiet=True, background=False)
|
||||
self._battery_check(fetch=False)
|
||||
elif self.frame_state == "offline" and saved_targets():
|
||||
self.connect(saved_targets()[0], quiet=True)
|
||||
except Exception: # noqa: BLE001
|
||||
traceback.print_exc()
|
||||
|
||||
def _check_frame_restart(self, info: dict | None) -> None:
|
||||
"""The Frame restarted without shutting down (a crash, a GPU reset) soon after a FramePort game started: say
|
||||
so, with Report a problem (Reddit 2026-10-03: "Resident Evil 4 crashes the Frame after the opening cutscene").
|
||||
Uses the last boot seen (kept in settings, so a crash while FramePort was closed counts too)."""
|
||||
from . import restart
|
||||
|
||||
boot = (info or {}).get("boot") or {}
|
||||
if not boot.get("boot_id"):
|
||||
return
|
||||
seen = library.setting("frame.boot_id")
|
||||
if seen == boot["boot_id"]:
|
||||
return
|
||||
library.set_setting("frame.boot_id", boot["boot_id"])
|
||||
game = restart.crashed_game(boot, seen)
|
||||
if game is None:
|
||||
return
|
||||
applog.log.info("Frame restarted unexpectedly after %s was started: %s", game["package"], boot)
|
||||
library.upsert_game(game["package"], frame_restart=boot.get("boot_time")) if library.game(game["package"]) \
|
||||
else None
|
||||
pkg = game["package"]
|
||||
self.toast(tr("Your Frame restarted unexpectedly while {title} was running (or soon after). If that game "
|
||||
"crashed it, please report it.").format(title=game.get("title") or pkg), error=True,
|
||||
action=tr("Report a problem"), on_action=lambda e: self.report_problem_dialog(pkg))
|
||||
|
||||
def _battery_check(self, fetch: bool) -> None:
|
||||
"""Battery level for the sidebar, and the queue on battery power: warn once, pause before the Frame would
|
||||
switch itself off mid-upload, continue when it charges (ui/battery.py)."""
|
||||
from . import battery
|
||||
|
||||
target = self.target
|
||||
if self.frame_state != "connected" or target is None:
|
||||
return
|
||||
b = (self.frame_info or {}).get("battery")
|
||||
if fetch:
|
||||
try:
|
||||
b = target.frame.agent("battery", timeout=20, ensure=False).get("battery")
|
||||
except Exception: # noqa: BLE001 - losing the Frame is the job's business; an old agent has no reading
|
||||
frame = getattr(target, "frame", None)
|
||||
if self.jobs.paused == "battery" and not (frame is not None and frame.alive()):
|
||||
self.jobs.pause("frame") # it switched off after all: reconnect and continue when it's back
|
||||
return
|
||||
if self.frame_info is not None:
|
||||
self.frame_info["battery"] = b
|
||||
act = battery.advice(b, self.jobs.has_frame_work(), self.jobs.paused, getattr(self, "_battery_warned", False))
|
||||
if act in ("warn", "pause"):
|
||||
self._battery_warned = True
|
||||
if act == "pause":
|
||||
self.jobs.pause("battery")
|
||||
applog.log.info("Frame battery %s: %s", act, b)
|
||||
self.toast(battery.message(act, b), error=True)
|
||||
elif act == "resume":
|
||||
had_work = self.jobs.has_frame_work()
|
||||
self.jobs.resume()
|
||||
applog.log.info("Frame battery: continuing (%s)", b)
|
||||
if had_work:
|
||||
self.toast(battery.message("resume", b or {}))
|
||||
if not battery.low(b):
|
||||
self._battery_warned = False # warn again the next time it runs low
|
||||
self._refresh_sidebar()
|
||||
|
||||
def retry_frame(self, quiet: bool = False) -> bool:
|
||||
"""The queue waits for the Frame: reconnect, and continue the queue if it answers."""
|
||||
target = self.target
|
||||
@@ -1300,6 +1436,7 @@ class FramePortApp:
|
||||
self.toast(explain(exc), error=True)
|
||||
return False
|
||||
self.frame_info, self.frame_state = info, "connected"
|
||||
self._check_frame_restart(info)
|
||||
self._awake_at = 0.0 # the lock went away with the old connection's Frame session: take it again
|
||||
self.jobs.resume()
|
||||
self.toast(tr("Your Frame is back: continuing"))
|
||||
@@ -1326,7 +1463,7 @@ class FramePortApp:
|
||||
"""Hold a wake lock on the Frame while jobs that use it run or wait (renewed every 30 min; it expires on its
|
||||
own after an hour if FramePort goes away), release it when they're done."""
|
||||
want = self.jobs.has_frame_work() and self.target is not None and self.frame_state == "connected" \
|
||||
and not self.jobs.paused
|
||||
and self.jobs.paused != "frame" # paused for the battery: stay awake to see it charging
|
||||
now = time.time()
|
||||
held = getattr(self, "_awake_at", 0.0)
|
||||
if want == bool(held) and (not want or now - held < 1800) or getattr(self, "_awake_busy", False):
|
||||
@@ -1346,7 +1483,9 @@ class FramePortApp:
|
||||
self._awake_busy = False
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def connect(self, target, password=None, quiet=False):
|
||||
def connect(self, target, password=None, quiet=False, devkit=False):
|
||||
"""devkit: the Frame was found in Developer Mode; if FramePort's keys aren't on it yet, pair through Valve's
|
||||
devkit service (approve in the headset) instead of failing."""
|
||||
from ..frame.connection import save_target
|
||||
from ..targets.frame_lepton import FrameLeptonTarget
|
||||
|
||||
@@ -1355,7 +1494,17 @@ class FramePortApp:
|
||||
|
||||
def work():
|
||||
try:
|
||||
t = FrameLeptonTarget(target, password).connect()
|
||||
try:
|
||||
t = FrameLeptonTarget(target, password).connect()
|
||||
except ConnectionError as exc:
|
||||
if not devkit or "authentication failed" not in str(exc).lower():
|
||||
raise
|
||||
from ..frame import devkit as dk
|
||||
|
||||
self.toast(tr("Pairing: on the Frame open Settings → Developer → Pair new host, then approve "
|
||||
"FramePort."))
|
||||
dk.register(target.host)
|
||||
t = FrameLeptonTarget(target, password).connect()
|
||||
if password:
|
||||
t.frame.install_key()
|
||||
info = t.describe()
|
||||
@@ -1363,6 +1512,7 @@ class FramePortApp:
|
||||
t.label = target.label
|
||||
save_target(target)
|
||||
old, self.target, self.frame_info, self.frame_state = self.target, t, info, "connected"
|
||||
self._check_frame_restart(info)
|
||||
if old is not None and old is not t and not self.jobs.current():
|
||||
try: # the connection it replaces (a running job keeps using its own until it ends)
|
||||
old.close()
|
||||
@@ -1370,9 +1520,14 @@ class FramePortApp:
|
||||
pass
|
||||
if not quiet:
|
||||
self.toast(tr("Connected to {label}").format(label=target.label))
|
||||
self._not_paired = False
|
||||
except Exception as exc: # noqa: BLE001
|
||||
from ..frame.connection import FrameNotPaired
|
||||
|
||||
self.frame_state = "offline"
|
||||
self.frame_info = None
|
||||
# reachable, but it doesn't let FramePort in: the Frame page points at the first-time setup
|
||||
self._not_paired = isinstance(exc, FrameNotPaired)
|
||||
if not quiet:
|
||||
self.toast(tr("Couldn't connect: {exc}").format(exc=explain(exc)), error=True)
|
||||
if self.route[0] in ("frame", "library", "game", "welcome"):
|
||||
@@ -1406,6 +1561,7 @@ class FramePortApp:
|
||||
changed = info.get("installed") != (self.frame_info or {}).get("installed") or \
|
||||
self.frame_state != "connected"
|
||||
self.frame_info, self.frame_state = info, "connected"
|
||||
self._check_frame_restart(info)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
frame = getattr(target, "frame", None)
|
||||
if frame is not None and frame.alive():
|
||||
@@ -1612,8 +1768,27 @@ def assets_dir() -> str:
|
||||
return str(user_data_dir())
|
||||
|
||||
|
||||
def serialize_flet_updates() -> None:
|
||||
"""Send page updates one at a time. Flet 1.0 diffs and sends a control's patch without a lock, and FramePort
|
||||
updates the page from several threads (jobs, connection checks, the library loader, dialogs): two patches computed
|
||||
at once desynchronised the window ("dropped a patch for unknown control … needs a reload"), e.g. a dialog shown
|
||||
while a finished scan redrew the view never closed again."""
|
||||
from flet.messaging.session import Session
|
||||
|
||||
if getattr(Session.patch_control, "_serialized", False):
|
||||
return
|
||||
original, lock = Session.patch_control, threading.RLock() # re-entrant: did_mount() may update again
|
||||
|
||||
def patch_control(self, *args, **kwargs):
|
||||
with lock:
|
||||
return original(self, *args, **kwargs)
|
||||
patch_control._serialized = True
|
||||
Session.patch_control = patch_control
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
applog.setup("gui")
|
||||
serialize_flet_updates()
|
||||
from ..core import library
|
||||
from .updater import apply_pending_at_start
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""The Frame's battery during installs (no Flet): when to warn, pause the queue and continue. A long queue on battery
|
||||
power can drain the Frame until it shuts down mid-upload (owner, 2026-10-03: the charging cable came out at night)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..i18n import tr
|
||||
|
||||
WARN_AT = 30 # % on battery power: warn once while Frame work is queued
|
||||
PAUSE_AT = 15 # % on battery power: pause the queue before the Frame shuts itself down
|
||||
RESUME_AT = 25 # % on battery power at which a paused queue continues by itself (or as soon as it's plugged in)
|
||||
|
||||
|
||||
def charging(battery: dict) -> bool:
|
||||
"""Gaining charge: a charger is connected and the battery isn't draining anyway (a weak charger, e.g. a PC port,
|
||||
can supply less than the Frame uses while it installs)."""
|
||||
return bool(battery.get("plugged")) and not battery.get("draining")
|
||||
|
||||
|
||||
def advice(battery: dict | None, frame_work: bool, paused: str | None, warned: bool) -> str | None:
|
||||
""""pause", "resume", "warn" or None. `paused` is the queue's pause reason ("battery" = paused by us)."""
|
||||
if not battery:
|
||||
return "resume" if paused == "battery" else None
|
||||
pct, plugged = battery.get("percent", 100), charging(battery)
|
||||
if paused == "battery":
|
||||
return "resume" if plugged or pct >= RESUME_AT or not frame_work else None
|
||||
if not frame_work or plugged or paused:
|
||||
return None
|
||||
if pct <= PAUSE_AT:
|
||||
return "pause"
|
||||
if pct <= WARN_AT and not warned:
|
||||
return "warn"
|
||||
return None
|
||||
|
||||
|
||||
def label(battery: dict | None) -> str:
|
||||
""" "76 %" / "76 % ⚡" (charging or plugged in); "" without a battery reading."""
|
||||
if not battery:
|
||||
return ""
|
||||
return f"{battery.get('percent', 0)} %" + (" ⚡" if charging(battery) else "")
|
||||
|
||||
|
||||
def icon(battery: dict) -> str:
|
||||
"""Material battery icon name for the level (bars like a phone's status bar)."""
|
||||
if charging(battery):
|
||||
return "BATTERY_CHARGING_FULL_ROUNDED"
|
||||
pct = battery.get("percent", 0)
|
||||
if pct <= PAUSE_AT:
|
||||
return "BATTERY_ALERT_ROUNDED"
|
||||
return "BATTERY_FULL_ROUNDED" if pct >= 95 else f"BATTERY_{min(6, pct * 7 // 100)}_BAR_ROUNDED"
|
||||
|
||||
|
||||
def low(battery: dict | None) -> bool:
|
||||
return bool(battery) and not charging(battery) and battery.get("percent", 100) <= WARN_AT
|
||||
|
||||
|
||||
def message(kind: str, battery: dict) -> str:
|
||||
pct = battery.get("percent", 0)
|
||||
if kind == "pause":
|
||||
return tr("The Frame's battery is at {pct} %: installs paused so it doesn't switch off mid-upload. Plug it "
|
||||
"in; the queue continues by itself.").format(pct=pct)
|
||||
if kind == "warn":
|
||||
return tr("The Frame's battery is at {pct} % and not charging. Plug it in (a strong charger, not a PC "
|
||||
"port): long installs can drain it, and FramePort pauses them at {pause} %.").format(
|
||||
pct=pct, pause=PAUSE_AT)
|
||||
return tr("The Frame is charging: continuing the installs.")
|
||||
@@ -45,6 +45,8 @@ 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 {})
|
||||
if extra.get("flat"):
|
||||
return tr("Windows"), tr("Windows game (Proton)"), "platform_windows"
|
||||
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"
|
||||
|
||||
@@ -28,6 +28,8 @@ HELP: dict[str, str] = _Translated({
|
||||
"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_windows": "A Windows game without VR, added with \"Add one game folder…\". On the Frame, Proton runs it "
|
||||
"as a window (like Steam's own Windows games); it's in the Frame's Steam library.",
|
||||
"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 "
|
||||
@@ -77,9 +79,10 @@ HELP: dict[str, str] = _Translated({
|
||||
"cat_device": "Files and environment variables placed next to the game on the Frame.",
|
||||
# ---- Frame
|
||||
"developer_mode": "Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on "
|
||||
"your network and connect to it over SSH.",
|
||||
"your network and connect to it over SSH. The first-time setup command turns it on for you.",
|
||||
"first_time_setup": "The command fetches a small setup script from this app over your local network. It turns on "
|
||||
"SSH, lets this app's key in, makes the Frame findable and installs Lepton if needed.",
|
||||
"Developer Mode (which includes SSH), lets this app's key in and installs Lepton if needed. "
|
||||
"No password needed.",
|
||||
"password": "The Frame's desktop password, only needed the first time so FramePort can add its own key. After "
|
||||
"that it connects with the key.",
|
||||
"lepton": "Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. "
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Did the Frame crash because of a game? (no Flet) From the agent's boot state: a new boot whose predecessor didn't
|
||||
shut down cleanly, with a FramePort game started shortly before it."""
|
||||
from __future__ import annotations
|
||||
|
||||
SOON = 3 * 3600 # a game started up to 3 h before the restart may have caused it
|
||||
|
||||
|
||||
def crashed_game(boot: dict, previous_boot_id: str | None) -> dict | None:
|
||||
"""The game to blame ({"package", "title", "time"}) or None. Only for a boot FramePort hasn't seen before whose
|
||||
previous boot ended without a clean shutdown (a battery shutdown or a normal power-off is clean)."""
|
||||
if not previous_boot_id or boot.get("boot_id") == previous_boot_id or boot.get("prev_clean") is not False:
|
||||
return None
|
||||
last, boot_time = boot.get("last_launch"), boot.get("boot_time")
|
||||
if not last or not boot_time or not 0 <= boot_time - last.get("time", 0) <= SOON:
|
||||
return None
|
||||
return last
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Frame page: the connected device with a readiness checklist and installed games, or a connect wizard."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
@@ -9,6 +10,7 @@ from ...core import library
|
||||
from ...i18n import tr
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..battery import label as battery_label
|
||||
from ..help import HELP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -34,10 +36,14 @@ class FrameView:
|
||||
.format(user=t.target.user, host=t.target.host, get=info.get('os'),
|
||||
get2=info.get('os_version'), get3=info.get('build_id'))),
|
||||
C.meta(tr("{free:.0f} GiB free · {len} games installed")
|
||||
.format(free=free, len=len(info.get('installed') or []))),
|
||||
.format(free=free, len=len(info.get('installed') or []))
|
||||
+ (tr(" · battery {value}").format(value=battery_label(info["battery"]))
|
||||
if info.get("battery") else "")),
|
||||
], spacing=T.px(4), expand=True),
|
||||
ft.Column([
|
||||
C.secondary(tr("Refresh"), ft.Icons.REFRESH_ROUNDED, lambda e: app.refresh_frame()),
|
||||
C.secondary(tr("Type on Frame"), ft.Icons.KEYBOARD_ROUNDED, lambda e: app.type_on_frame(),
|
||||
tooltip=tr("Use this computer's keyboard on the Frame")),
|
||||
C.ghost(tr("Switch Frame…"), ft.Icons.SWAP_HORIZ_ROUNDED, lambda e: app.disconnect()),
|
||||
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.END),
|
||||
], spacing=T.S4), padding=T.S5)
|
||||
@@ -163,12 +169,13 @@ class FrameView:
|
||||
color=T.ACCENT),
|
||||
ft.Column([C.body(f.name if f.source != "scan" else f.host, T.TEXT, weight=ft.FontWeight.W_500),
|
||||
C.meta(f"{f.host} · {label}")], spacing=T.px(2), expand=True),
|
||||
C.primary(tr("Connect"), on_click=lambda e, f=f: app.connect(parse_target(f"{f.user}@{f.host}"))),
|
||||
C.primary(tr("Connect"), on_click=lambda e, f=f: app.connect(parse_target(f"{f.user}@{f.host}"),
|
||||
devkit=f.source == "devkit")),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S3, T.px(8), T.S2, T.px(8)), bgcolor=T.SURFACE_2,
|
||||
border_radius=T.RADIUS_SM))
|
||||
found.controls = items or [C.body(tr("No Frames found. Turn on Developer Mode on the Frame (Settings → "
|
||||
"System → Developer) and make sure it's on the same network, or use "
|
||||
"first-time setup below."))]
|
||||
found.controls = items or [C.body(tr("No Frames found. New Frame? Use first-time setup (step 1). Otherwise "
|
||||
"make sure Developer Mode is on (Settings → System → Developer) and "
|
||||
"the Frame is on the same network."))]
|
||||
found.controls.append(C.ghost(tr("Search again"), ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
|
||||
C.update(found)
|
||||
|
||||
@@ -183,10 +190,33 @@ class FrameView:
|
||||
|
||||
def on_paired(info):
|
||||
app.connect(FrameTarget(info["host"], info["user"], 22, info["name"]))
|
||||
app.pairing = PairingServer(on_paired=on_paired).start()
|
||||
server = app.pairing = PairingServer(on_paired=on_paired).start()
|
||||
show_command()
|
||||
|
||||
def check_network():
|
||||
from ...core import winhost
|
||||
from ...frame.pairing import HINT_AFTER, ensure_reachable, firewall_hint
|
||||
|
||||
if winhost.is_wsl() and ensure_reachable(server) == "failed":
|
||||
app.toast(tr("Windows' firewall for WSL blocks the Frame from reaching FramePort. Open the setup "
|
||||
"command again and allow the change when Windows asks (admin)."), error=True)
|
||||
for _ in range(HINT_AFTER):
|
||||
time.sleep(1)
|
||||
if server.requests or not server.running or app.pairing is not server:
|
||||
return
|
||||
server.hint = firewall_hint(server.port) or tr(
|
||||
"Check that the Frame and this computer are on the same network.")
|
||||
if app.route[0] == "frame" and app.pairing is server:
|
||||
show_command()
|
||||
app.run_bg(check_network)
|
||||
|
||||
def show_command(update=True):
|
||||
"""The setup command of the running pairing server: also when the page is redrawn (connection checks,
|
||||
discovery), which used to close it."""
|
||||
line = app.pairing.one_liner
|
||||
pair_box.controls = [
|
||||
C.body(tr("On the Frame: Steam button → Power → Switch to Desktop, open Konsole and run:"), T.TEXT),
|
||||
C.body(tr("On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → "
|
||||
"Konsole, and run:"), T.TEXT),
|
||||
ft.Container(ft.Row([ft.Text(line, font_family="monospace", selectable=True, size=T.px(12),
|
||||
color=T.TEXT, expand=True),
|
||||
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, tr("Copy"), lambda e: app.copy(line))]),
|
||||
@@ -195,10 +225,25 @@ class FrameView:
|
||||
ft.Row([ft.ProgressRing(width=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT),
|
||||
C.meta(tr("Waiting for your Frame… (code {code})").format(code=app.pairing.code))],
|
||||
spacing=T.S2),
|
||||
C.meta(tr("It turns on SSH, trusts this app, makes the Frame findable on your network and installs "
|
||||
"Lepton if needed. You only do this once.")),
|
||||
C.meta(tr("It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on "
|
||||
"Developer Mode closes the desktop; the setup finishes on its own and FramePort connects "
|
||||
"by itself. You only do this once.")),
|
||||
]
|
||||
pair_box.update()
|
||||
hint = getattr(app.pairing, "hint", "")
|
||||
if hint and not app.pairing.requests: # nothing reached us yet: what may block it, and the other way in
|
||||
pair_box.controls.append(C.callout(ft.Column([
|
||||
C.body(tr("Nothing has reached FramePort from the Frame yet (curl says “timed out”)?"), T.TEXT,
|
||||
weight=ft.FontWeight.W_600),
|
||||
C.body(hint, T.TEXT),
|
||||
C.meta(tr("Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows "
|
||||
"up under step 2. Open Settings → Developer → Pair new host on the Frame, click Connect "
|
||||
"there and approve FramePort. That way needs no connection into this computer.")),
|
||||
], spacing=T.S2), "warn"))
|
||||
if update:
|
||||
pair_box.update()
|
||||
|
||||
if app.pairing and app.pairing.running:
|
||||
show_command(update=False)
|
||||
|
||||
style = dict(dense=True, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(10), T.px(12), T.px(10)),
|
||||
@@ -208,7 +253,12 @@ class FrameView:
|
||||
width=T.px(240), tooltip=C.tip(HELP["password"]), **style)
|
||||
saved = saved_targets()
|
||||
offline = None
|
||||
if saved and app.frame_state == "offline":
|
||||
if getattr(app, "_not_paired", False): # it answered, but doesn't know FramePort yet
|
||||
offline = C.callout(ft.Row([
|
||||
C.body(tr("Your Frame isn't set up for FramePort yet. Run the first-time setup once (step 1)."), T.TEXT,
|
||||
expand=True),
|
||||
C.primary(tr("Show setup command"), ft.Icons.TERMINAL_ROUNDED, pair)]), "warn")
|
||||
elif saved and app.frame_state == "offline":
|
||||
unreachable = tr("{label} ({host}) isn't reachable. Make sure it's switched on and on the same "
|
||||
"network.").format(label=saved[0].label, host=saved[0].host)
|
||||
offline = C.callout(ft.Row([C.body(unreachable, T.TEXT, expand=True),
|
||||
@@ -236,10 +286,12 @@ class FrameView:
|
||||
return ft.Column([
|
||||
app.top_bar(tr("Connect your Steam Frame"), tr("FramePort installs games on the Frame over your network")),
|
||||
*([offline] if offline else []),
|
||||
step(1, tr("On your network"), tr("Frames in Developer Mode show up here."), found, help="developer_mode"),
|
||||
step(2, tr("First-time setup"), tr("New Frame? Run one command on it and FramePort does the rest."),
|
||||
C.secondary(tr("Show setup command"), ft.Icons.TERMINAL_ROUNDED, pair), pair_box,
|
||||
step(1, tr("First-time setup"), tr("New Frame? Run one command on it and FramePort does the rest. Did this "
|
||||
"once already? Your Frame appears under step 2."),
|
||||
C.primary(tr("Show setup command"), ft.Icons.TERMINAL_ROUNDED, pair), pair_box,
|
||||
help="first_time_setup"),
|
||||
step(2, tr("Already set up: on your network"), tr("Frames in Developer Mode show up here."), found,
|
||||
help="developer_mode"),
|
||||
step(3, tr("Enter the address"), tr("If you know the Frame's address."),
|
||||
ft.Row([addr, pw, C.primary(tr("Connect"),
|
||||
on_click=lambda e: app.connect_manual(addr.value, pw.value))],
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Type on Frame: this computer's keyboard becomes a keyboard on the Frame while the dialog is open (frame/keyboard.py).
|
||||
Keys go to whatever has focus on the Frame: an app's text field, Steam, the desktop, a PC game."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ...errors import explain
|
||||
from ...i18n import tr
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
|
||||
def show_type_dialog(app: FramePortApp) -> None:
|
||||
target = app.target
|
||||
if target is None or app.frame_state != "connected":
|
||||
app.toast(tr("Connect your Frame first"), error=True)
|
||||
return
|
||||
state = {"session": None, "closed": False}
|
||||
status = C.meta(tr("Connecting the keyboard…"))
|
||||
last = ft.Text("", size=T.px(28), weight=ft.FontWeight.W_600, color=T.ACCENT)
|
||||
hint = C.body(tr("Click here, then type. Everything you type goes to the Frame (Esc and shortcuts too)."), T.TEXT)
|
||||
|
||||
def forward(action):
|
||||
def handler(e):
|
||||
s = state["session"]
|
||||
if s is None or state["closed"]:
|
||||
return
|
||||
try:
|
||||
if s.key(e.key, action) and action == "down":
|
||||
last.value = e.key if len(e.key) > 1 else e.key.upper()
|
||||
C.update(last)
|
||||
except Exception as exc: # noqa: BLE001 - the connection dropped
|
||||
fail(exc)
|
||||
return handler
|
||||
|
||||
pad = ft.Container(ft.Column([hint, last], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
padding=T.S5, border_radius=T.RADIUS, bgcolor=T.BG, border=ft.Border.all(1, T.BORDER),
|
||||
alignment=ft.Alignment.CENTER, height=T.px(150), ink=True)
|
||||
listener = ft.KeyboardListener(pad, autofocus=True, on_key_down=forward("down"), on_key_up=forward("up"),
|
||||
on_key_repeat=forward("repeat"))
|
||||
pad.on_click = lambda e: focus_keys()
|
||||
paste = ft.TextField(hint_text=tr("Or paste text to type it in one go"), expand=True, dense=True,
|
||||
border_color=T.BORDER, on_submit=lambda e: send_text(None))
|
||||
|
||||
def focus_keys():
|
||||
try:
|
||||
app.page.run_task(listener.focus)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def send_text(e):
|
||||
s, text = state["session"], paste.value or ""
|
||||
if s is None or not text:
|
||||
return
|
||||
|
||||
def work():
|
||||
skipped = s.text(text)
|
||||
paste.value = ""
|
||||
C.update(paste)
|
||||
focus_keys()
|
||||
if skipped:
|
||||
app.toast(tr("Typed it, except characters the Frame's US keyboard layout doesn't have: {chars}")
|
||||
.format(chars=skipped), error=True)
|
||||
app.run_bg(work)
|
||||
|
||||
def fail(exc):
|
||||
status.value = tr("Keyboard disconnected: {error}").format(error=explain(exc))
|
||||
status.color = T.ERROR
|
||||
C.update(status)
|
||||
|
||||
def close(e=None):
|
||||
state["closed"] = True
|
||||
app._typing_on_frame = False
|
||||
s = state["session"]
|
||||
if s is not None:
|
||||
s.close()
|
||||
if e is not None:
|
||||
app.page.pop_dialog()
|
||||
|
||||
pick = C.one_choice()
|
||||
app._typing_on_frame = True # app._on_key leaves Esc / Ctrl+F alone: they go to the Frame
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
modal=True, bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
title=ft.Row([ft.Icon(ft.Icons.KEYBOARD_ROUNDED, color=T.ACCENT),
|
||||
ft.Text(tr("Type on Frame"), weight=ft.FontWeight.W_600)], spacing=T.S2),
|
||||
content=ft.Column([
|
||||
C.body(tr("This computer's keyboard works as a keyboard on your Frame while this window is open. In the "
|
||||
"headset, select a text field (in a game or app, in Steam or on the desktop), then type here."),
|
||||
T.TEXT_2),
|
||||
listener,
|
||||
ft.Row([paste, C.secondary(tr("Type it"), ft.Icons.SEND_ROUNDED, send_text)], spacing=T.S2),
|
||||
status,
|
||||
], tight=True, spacing=T.S3, width=T.px(560)),
|
||||
on_dismiss=pick(lambda e: close()),
|
||||
actions=[C.primary(tr("Done"), ft.Icons.CHECK_ROUNDED, on_click=pick(close))]))
|
||||
|
||||
def connect():
|
||||
from ...frame.keyboard import KeyboardSession
|
||||
|
||||
try:
|
||||
state["session"] = KeyboardSession(target.frame)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
fail(exc)
|
||||
return
|
||||
if state["closed"]: # closed while connecting
|
||||
state["session"].close()
|
||||
return
|
||||
status.value = tr("Keyboard connected to {label}.").format(label=target.label)
|
||||
status.color = T.OK
|
||||
C.update(status)
|
||||
focus_keys()
|
||||
app.run_bg(connect)
|
||||
@@ -18,6 +18,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -39,7 +40,7 @@ REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
|
||||
LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
CHECK_EVERY = 6 * 3600 # seconds between automatic checks
|
||||
ASSETS = {"win32": "FramePort-windows-x64.zip", "darwin": "FramePort-macos-arm64.zip",
|
||||
"linux": "FramePort-linux-x64.tar.gz"}
|
||||
"linux": "FramePort-linux-x64.tar.gz", "linux-arm64": "FramePort-linux-arm64.tar.gz"} # never rename
|
||||
SUMS = "SHA256SUMS.txt"
|
||||
|
||||
|
||||
@@ -75,6 +76,8 @@ def is_newer(candidate: str, current: str = __version__) -> bool:
|
||||
|
||||
def platform_asset() -> str | None:
|
||||
key = "linux" if sys.platform.startswith("linux") else sys.platform
|
||||
if key == "linux" and platform.machine().lower() in ("aarch64", "arm64"):
|
||||
key = "linux-arm64" # the ARM64 bundle (from 0.6.0)
|
||||
return ASSETS.get(key)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
# modules that read settings at import time (UI) run during collection, before the per-test fixture below
|
||||
os.environ["FRAMEPORT_HOME"] = tempfile.mkdtemp(prefix="frameport-tests-")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
+158
-1
@@ -1,6 +1,7 @@
|
||||
"""The Frame-side agent (stdlib only) — pieces that don't need a Frame."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -368,11 +369,32 @@ def test_launch_uses_steam_shortcut(monkeypatch, tmp_path):
|
||||
(anchor / "deployment.json").write_text(json.dumps(deployment))
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **k: (calls.append(cmd), SimpleNamespace(returncode=0, stdout=""))[1])
|
||||
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
|
||||
cfg.mkdir(parents=True)
|
||||
with pytest.raises(a.AgentError, match="not in the Frame's Steam library"):
|
||||
a.cmd_launch({"package": "com.x.y"})
|
||||
exe = f'"{anchor}/launch.sh"'
|
||||
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, "Old title", str(anchor)) # keeps its first id
|
||||
r = a.cmd_launch({"package": "com.x.y"})
|
||||
assert r["gameid"] == (2546384938 << 32) | 0x02000000
|
||||
assert r["gameid"] == (a.shortcut_appid(exe, "Old title") << 32) | 0x02000000
|
||||
assert calls[-1][-1] == f"steam://rungameid/{r['gameid']}" and calls[-1][0] == "systemd-run"
|
||||
|
||||
|
||||
def test_library_users_prefers_the_signed_in_account(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
steam = tmp_path / ".local/share/Steam"
|
||||
with pytest.raises(a.AgentError, match="signed-in"):
|
||||
a.library_users()
|
||||
for u in ("12345", "999"):
|
||||
(steam / "userdata" / u / "config").mkdir(parents=True)
|
||||
assert a.library_users() == ["12345", "999"] # unknown: every account (used to fail with 2 accounts)
|
||||
(steam / "config").mkdir()
|
||||
(steam / "config/loginusers.vdf").write_text(
|
||||
'"users"\n{\n\t"76561197960278073"\n\t{\n\t\t"MostRecent"\t\t"1"\n\t}\n'
|
||||
'\t"76561197960266727"\n\t{\n\t\t"MostRecent"\t\t"0"\n\t}\n}\n')
|
||||
assert a.library_users() == ["12345"]
|
||||
|
||||
|
||||
def test_uninstall_quest_keeping_saves_drops_the_install_record(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
||||
@@ -702,3 +724,138 @@ def test_keep_awake_falls_back_to_idle_when_sleep_is_refused(monkeypatch, tmp_pa
|
||||
calls.clear()
|
||||
assert a.cmd_keep_awake({"on": False}) == {"awake": False}
|
||||
assert calls and all(c[0] == "systemctl" for c in calls) # only stops the unit
|
||||
|
||||
|
||||
def test_purge_without_saves_removes_container_workdirs_and_stale_shortcuts(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, tmp_path)
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: True)
|
||||
monkeypatch.setattr(a, "start_steam", lambda s: None)
|
||||
users = tmp_path / ".local/share/Steam/userdata/42/config"
|
||||
(users / "grid").mkdir(parents=True)
|
||||
q = Path(a.ANCHORS) / "com.x.y"
|
||||
work = q / "lepton-data" / "baked" / "data_workdir" / "work" # overlayfs leaves these with mode 000
|
||||
(work / "inner").mkdir(parents=True)
|
||||
(work / "inner" / "f").write_text("x")
|
||||
(q / "launch.sh").write_text("#!/bin/sh")
|
||||
(q / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 7, "base": str(q), "title": "Q"}))
|
||||
os.chmod(work / "inner", 0)
|
||||
os.chmod(work, 0)
|
||||
vdf = str(users / "shortcuts.vdf")
|
||||
a.upsert_shortcut(vdf, f'"{q}/launch.sh"', "Q", str(q))
|
||||
stale_exe = f'"{a.ANCHORS}/com.gone/launch.sh"' # no install record any more
|
||||
stale = a.upsert_shortcut(vdf, stale_exe, "Gone", "/x")
|
||||
a.upsert_shortcut(vdf, '"/usr/bin/other"', "Not ours", "/x")
|
||||
(users / "grid" / f"{stale}p.jpg").write_bytes(b"x")
|
||||
a.purge_worker(json.dumps({"keep_saves": False, "status": str(tmp_path / "st.json")}))
|
||||
st = json.loads((tmp_path / "st.json").read_text())
|
||||
assert st["state"] == "done" and not st["errors"], st
|
||||
assert not Path(a.ANCHORS).exists()
|
||||
names = [s["appname"] for s in a.vdf_decode((users / "shortcuts.vdf").read_bytes())["shortcuts"].values()]
|
||||
assert names == ["Not ours"] and not (users / "grid" / f"{stale}p.jpg").exists()
|
||||
|
||||
|
||||
def test_battery_state(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
ps = tmp_path / "power_supply"
|
||||
|
||||
def supply(name, **files):
|
||||
(ps / name).mkdir(parents=True)
|
||||
for k, v in files.items():
|
||||
(ps / name / k).write_text(v + "\n")
|
||||
monkeypatch.setattr(a, "POWER_SUPPLY", str(ps))
|
||||
assert a.battery_state() is None # no power_supply folder (or no battery): nothing to show
|
||||
supply("battery", type="Battery", capacity="42", status="Discharging")
|
||||
supply("usb", type="USB", online="0")
|
||||
assert a.battery_state() == {"percent": 42, "status": "Discharging", "plugged": False, "draining": True}
|
||||
(ps / "usb" / "online").write_text("1\n") # cable in, battery not charging yet ("Not charging"): still plugged
|
||||
assert a.battery_state()["plugged"] is True
|
||||
assert a.cmd_battery({})["battery"]["percent"] == 42
|
||||
|
||||
|
||||
def test_virtual_keyboard_events_text_and_release(monkeypatch, tmp_path):
|
||||
import io
|
||||
import struct as st
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
||||
written = []
|
||||
kb = a.VirtualKeyboard(fd="fake", write=lambda fd, data: written.append(st.unpack("llHHi", data)[2:]))
|
||||
kb.key(30, 1) # 'a' down: EV_KEY then SYN_REPORT
|
||||
assert written == [(1, 30, 1), (0, 0, 0)] and kb.held == {30}
|
||||
written.clear()
|
||||
assert kb.type_text("A!\N{SNOWMAN}") == "\N{SNOWMAN}" # US layout; what it can't type is reported
|
||||
keys = [(c, v) for t, c, v in written if t == 1]
|
||||
assert keys == [(42, 1), (30, 1), (30, 0), (42, 0), (42, 1), (2, 1), (2, 0), (42, 0)]
|
||||
kb.key(57, 1) # space held down when the PC goes away
|
||||
written.clear()
|
||||
kb.close() # released before the device goes away
|
||||
assert (1, 57, 0) in written and kb.held == set()
|
||||
# session protocol: ready line, keys and text from JSON lines, keyboard closed at EOF
|
||||
out = io.StringIO()
|
||||
kb2 = a.VirtualKeyboard(fd="fake", write=lambda fd, data: None)
|
||||
closed = []
|
||||
kb2.close = lambda: closed.append(1)
|
||||
lines = io.StringIO('{"k": 28, "v": 1}\n{"k": 28, "v": 0}\nnot json\n{"text": "hi"}\n')
|
||||
assert a.keyboard_session(lines, out, keyboard=kb2) == 0
|
||||
replies = [json.loads(x) for x in out.getvalue().splitlines()]
|
||||
assert replies[0] == {"ready": True} and replies[1] == {"typed": True, "skipped": ""} and closed
|
||||
|
||||
|
||||
def test_keyboard_session_reports_a_refused_device(monkeypatch, tmp_path):
|
||||
import io
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
|
||||
def refuse(*args, **kw):
|
||||
raise PermissionError(13, "Permission denied", "/dev/uinput")
|
||||
monkeypatch.setattr(a, "VirtualKeyboard", refuse)
|
||||
out = io.StringIO()
|
||||
assert a.keyboard_session(io.StringIO(""), out) == 1
|
||||
assert json.loads(out.getvalue())["ready"] is False
|
||||
|
||||
|
||||
def test_boot_state_finds_unclean_previous_boot_and_last_launch(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
base = tmp_path / "game"
|
||||
base.mkdir()
|
||||
(base / "launch.log").write_text("x")
|
||||
os.utime(base / "launch.log", (1000, 1000))
|
||||
monkeypatch.setattr(a, "cmd_list_installed", lambda args: {"games": [{"package": "com.x", "title": "X",
|
||||
"base": str(base)}]})
|
||||
real_open = open
|
||||
|
||||
def fake_open(path, *args, **kw):
|
||||
if path == "/proc/sys/kernel/random/boot_id":
|
||||
from io import StringIO
|
||||
return StringIO("abc\n")
|
||||
if path == "/proc/stat":
|
||||
from io import StringIO
|
||||
return StringIO("cpu 1 2 3\nbtime 2000\n")
|
||||
return real_open(path, *args, **kw)
|
||||
monkeypatch.setattr(a, "open", fake_open, raising=False)
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(stdout="kernel: msm_dpu ... fault\n"))
|
||||
st = a.boot_state()
|
||||
assert st == {"boot_id": "abc", "boot_time": 2000, "prev_clean": False,
|
||||
"last_launch": {"package": "com.x", "title": "X", "time": 1000.0}}
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(stdout="Reached target System Power Off\n"))
|
||||
assert a.boot_state()["prev_clean"] is False # cached for this boot: worked out once
|
||||
|
||||
|
||||
def test_flat_windows_game_launcher_and_shortcut(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "compat_command", lambda d: ["/proton", "waitforexitandrun"])
|
||||
anchor, base = tmp_path / "anchor", tmp_path / "base"
|
||||
anchor.mkdir()
|
||||
tool = {"dir": "/tools/proton", "name": "Proton 11"}
|
||||
a.write_proton_launcher(str(anchor), str(base), "rift.game", "Game", 123, tool, "Game.exe", False, {}, vr=False)
|
||||
flat = (anchor / "launch.sh").read_text()
|
||||
a.write_proton_launcher(str(anchor), str(base), "rift.game", "Game", 123, tool, "Game.exe", False, {})
|
||||
vr = (anchor / "launch.sh").read_text()
|
||||
assert "SteamGameId" not in flat and "export SteamGameId=123" in vr # Proton sets up VR only with it
|
||||
assert "STEAM_COMPAT_APP_ID=123" in flat
|
||||
vdf = str(tmp_path / "shortcuts.vdf")
|
||||
a.upsert_shortcut(vdf, '"/x/launch.sh"', "Game", "/x", tag="Windows game on Frame", openvr=False)
|
||||
entry = a.vdf_decode(open(vdf, "rb").read())["shortcuts"]["0"]
|
||||
assert entry["OpenVR"] == 0 and entry["tags"]["0"] == "Windows game on Frame"
|
||||
@@ -0,0 +1,40 @@
|
||||
from frameport.ui import battery as B
|
||||
|
||||
|
||||
def bat(pct, plugged=False):
|
||||
return {"percent": pct, "status": "Charging" if plugged else "Discharging", "plugged": plugged}
|
||||
|
||||
|
||||
def test_warn_once_then_pause_then_resume_when_plugged_in():
|
||||
assert B.advice(bat(80), True, None, False) is None
|
||||
assert B.advice(bat(30), True, None, False) == "warn"
|
||||
assert B.advice(bat(28), True, None, True) is None # warned already
|
||||
assert B.advice(bat(15), True, None, True) == "pause"
|
||||
assert B.advice(bat(14), True, "battery", True) is None # still low and unplugged: stay paused
|
||||
assert B.advice(bat(14, plugged=True), True, "battery", True) == "resume"
|
||||
assert B.advice(bat(25), True, "battery", True) == "resume"
|
||||
|
||||
|
||||
def test_no_action_without_frame_work_or_while_charging_or_paused_for_another_reason():
|
||||
assert B.advice(bat(5), False, None, False) is None
|
||||
assert B.advice(bat(5, plugged=True), True, None, False) is None
|
||||
assert B.advice(bat(5), True, "frame", False) is None # waiting for the Frame to come back: not ours
|
||||
assert B.advice(bat(5), False, "battery", True) == "resume" # queue emptied (cancelled): don't stay paused
|
||||
assert B.advice(None, True, "battery", True) == "resume" # no reading any more (old agent): don't block forever
|
||||
assert B.advice(None, True, None, False) is None
|
||||
|
||||
|
||||
def test_plugged_in_but_draining_counts_as_not_charging():
|
||||
weak = {"percent": 12, "status": "Discharging", "plugged": True, "draining": True}
|
||||
assert B.advice(weak, True, None, True) == "pause" and B.low(weak) and B.label(weak) == "12 %"
|
||||
|
||||
|
||||
def test_label():
|
||||
assert B.label(bat(76)) == "76 %" and B.label(bat(76, True)) == "76 % ⚡" and B.label(None) == ""
|
||||
assert B.low(bat(20)) and not B.low(bat(20, True)) and not B.low(bat(60))
|
||||
|
||||
|
||||
def test_icon_follows_level_and_charging():
|
||||
assert B.icon(bat(7, True)) == "BATTERY_CHARGING_FULL_ROUNDED"
|
||||
assert B.icon(bat(7)) == "BATTERY_ALERT_ROUNDED" and B.icon(bat(100)) == "BATTERY_FULL_ROUNDED"
|
||||
assert B.icon(bat(50)) == "BATTERY_3_BAR_ROUNDED" and B.icon(bat(90)) == "BATTERY_6_BAR_ROUNDED"
|
||||
@@ -0,0 +1,46 @@
|
||||
"""bootstrap.sh: valid bash, placeholders for the pairing server, and the config.vdf edit that turns on Developer
|
||||
Mode."""
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core.paths import bootstrap_dir
|
||||
|
||||
SCRIPT = (bootstrap_dir() / "bootstrap.sh").read_text()
|
||||
|
||||
|
||||
def devmode_edit() -> str:
|
||||
return re.search(r"python3 - \"\$CONFIG\" <<'PY'[^\n]*\n(.*?)\nPY\n", SCRIPT, re.S).group(1)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash")
|
||||
def test_bash_syntax():
|
||||
subprocess.run(["bash", "-n"], input=SCRIPT, text=True, check=True)
|
||||
|
||||
|
||||
def test_placeholders_and_steps():
|
||||
assert "__PC_URL__" in SCRIPT and "__PAIR_CODE__" in SCRIPT
|
||||
assert "steamos-devkit-mode" in SCRIPT and "/etc/steamos-devkit-enabled" in SCRIPT
|
||||
assert "sudo" not in SCRIPT.split("set -euo pipefail", 1)[1] and "passwd" not in SCRIPT # no password, ever
|
||||
# Desktop Mode is a nested session inside steam.service: the Developer Mode job (it stops Steam) must run as a
|
||||
# user unit reached through the real user bus, logging to a file that outlives the terminal
|
||||
assert 'DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus"' in SCRIPT
|
||||
assert "user_systemd systemd-run --user" in SCRIPT and ".cache/frameport-setup.log" in SCRIPT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("before", [
|
||||
'"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n\t\t}\n\t}\n'
|
||||
'\t"developer"\n\t{\n\t\t"Other"\t\t"x"\n\t}\n}\n',
|
||||
'"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t}\n}\n',
|
||||
'"InstallConfigStore"\n{\n\t"developer"\n\t{\n\t\t"DevModeEnabled"\t\t"0"\n\t}\n}\n',
|
||||
])
|
||||
def test_devmode_config_edit(tmp_path, before):
|
||||
cfg = tmp_path / "config.vdf"
|
||||
cfg.write_text(before)
|
||||
subprocess.run([sys.executable, "-", str(cfg)], input=devmode_edit(), text=True, check=True)
|
||||
after = cfg.read_text()
|
||||
assert re.search(r'\t"developer"\n\t\{\n(?:.*\n)*?\t\t"DevModeEnabled"\t\t"1"\n', after)
|
||||
assert after.count("DevModeEnabled") == 1 and after.count("{") == after.count("}")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Issue #2: `flet build` compiles every .py of the app to .pyc, bundled data included, so release bundles had no Frame
|
||||
agent source to upload. Bundles carry it as frameport_agent.py.txt too; everything reads it through paths.agent_file."""
|
||||
import importlib.util
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core import paths
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bundle_like(tmp_path, monkeypatch):
|
||||
"""A data folder like the one in a release bundle: the agent only compiled, plus the .txt copy."""
|
||||
(tmp_path / "agent").mkdir()
|
||||
(tmp_path / "agent" / "frameport_agent.pyc").write_bytes(b"compiled")
|
||||
shutil.copy(ROOT / "agent/frameport_agent.py", tmp_path / "agent" / paths.AGENT_SOURCE_COPY)
|
||||
monkeypatch.setattr(paths, "DATA_ROOT", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_agent_file_falls_back_to_the_txt_copy(bundle_like):
|
||||
assert paths.agent_file().name == "frameport_agent.py.txt"
|
||||
from frameport.frame.connection import bundled_agent_version
|
||||
|
||||
assert bundled_agent_version() and bundled_agent_version() > 30
|
||||
|
||||
|
||||
def test_pc_shortcut_code_loads_from_the_txt_copy(bundle_like):
|
||||
from frameport.targets import pc_revive
|
||||
|
||||
assert callable(pc_revive._vdf().vdf_decode)
|
||||
|
||||
|
||||
def test_source_checkout_uses_the_py():
|
||||
assert paths.agent_file() == paths.agent_dir() / "frameport_agent.py"
|
||||
|
||||
|
||||
def test_package_script_checks_the_bundle(tmp_path):
|
||||
spec = importlib.util.spec_from_file_location("package", ROOT / "scripts/package.py")
|
||||
package = importlib.util.module_from_spec(spec)
|
||||
sys.modules["package"] = package
|
||||
spec.loader.exec_module(package)
|
||||
data = tmp_path / "app/frameport/_data/agent"
|
||||
data.mkdir(parents=True)
|
||||
(data / "frameport_agent.pyc").write_bytes(b"x")
|
||||
with pytest.raises(SystemExit, match="agent's source is missing"):
|
||||
package.check_bundle(tmp_path)
|
||||
(data / "frameport_agent.py.txt").write_text("x")
|
||||
package.check_bundle(tmp_path)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Logging in to the Frame (Reddit 2026-10-03: "Couldn't connect: no authentication methods available" even with the
|
||||
right password). paramiko ends the key/agent attempt with a plain SSHException when this PC has no SSH keys; that must
|
||||
not stop the password attempt, and the final error must say what to do."""
|
||||
import paramiko
|
||||
import pytest
|
||||
|
||||
from frameport.errors import explain
|
||||
from frameport.frame import connection as C
|
||||
|
||||
|
||||
class _Client:
|
||||
"""Stands in for paramiko.SSHClient: `script` maps attempt kind -> exception (or None = success)."""
|
||||
script: dict = {}
|
||||
tried: list = []
|
||||
|
||||
def load_host_keys(self, path):
|
||||
pass
|
||||
|
||||
def set_missing_host_key_policy(self, policy):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def save_host_keys(self, path):
|
||||
pass
|
||||
|
||||
def connect(self, pkey=None, password=None, allow_agent=False, look_for_keys=False, **kw):
|
||||
kind = "password" if password else "agent" if allow_agent else "app_key"
|
||||
_Client.tried.append(kind)
|
||||
exc = _Client.script.get(kind)
|
||||
if exc is not None:
|
||||
raise exc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_ssh(monkeypatch):
|
||||
monkeypatch.setattr(C.paramiko, "SSHClient", _Client)
|
||||
monkeypatch.setattr(C, "app_keys", lambda: ["key"])
|
||||
_Client.tried = []
|
||||
return _Client
|
||||
|
||||
|
||||
NO_METHODS = paramiko.SSHException("No authentication methods available")
|
||||
|
||||
|
||||
def test_no_ssh_keys_on_the_pc_still_tries_the_password(fake_ssh, monkeypatch):
|
||||
fake_ssh.script = {"app_key": paramiko.AuthenticationException("Authentication failed."), "agent": NO_METHODS,
|
||||
"password": paramiko.AuthenticationException("Authentication failed.")}
|
||||
frame = C.Frame(C.FrameTarget("10.0.0.2"), password="secret")
|
||||
with pytest.raises(C.FrameNotPaired) as err:
|
||||
frame.connect()
|
||||
assert fake_ssh.tried == ["app_key", "agent", "password"]
|
||||
assert err.value.tried_password and "password was refused" in str(err.value)
|
||||
assert "refused that password" in explain(err.value)
|
||||
|
||||
|
||||
def test_unpaired_frame_without_password_explains_the_setup(fake_ssh):
|
||||
fake_ssh.script = {"app_key": paramiko.AuthenticationException("Authentication failed."), "agent": NO_METHODS}
|
||||
with pytest.raises(C.FrameNotPaired) as err:
|
||||
C.Frame(C.FrameTarget("10.0.0.2")).connect()
|
||||
assert "authentication failed" in str(err.value).lower() # app.connect's devkit fallback looks for this
|
||||
assert "first-time setup" in explain(err.value) and "isn't set up" in explain(err.value)
|
||||
assert explain(NO_METHODS).startswith("This Frame isn't set up")
|
||||
|
||||
|
||||
def test_network_errors_still_end_the_attempts(fake_ssh):
|
||||
fake_ssh.script = {"app_key": paramiko.SSHException("Error reading SSH protocol banner")}
|
||||
with pytest.raises(paramiko.SSHException, match="banner"):
|
||||
C.Frame(C.FrameTarget("10.0.0.2"), password="x").connect()
|
||||
assert fake_ssh.tried == ["app_key"]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Valve's devkit pairing (frame/devkit.py) against a stand-in for the Frame's steamos-devkit-service."""
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.frame import connection, devkit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_service(monkeypatch):
|
||||
seen, reply = [], {"status": 200, "body": b"Registered\n"}
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
seen.append(self.rfile.read(int(self.headers["Content-Length"])).decode())
|
||||
self.send_response(reply["status"])
|
||||
self.end_headers()
|
||||
self.wfile.write(reply["body"])
|
||||
|
||||
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
monkeypatch.setattr(devkit, "PORT", httpd.server_address[1])
|
||||
yield seen, reply
|
||||
httpd.shutdown()
|
||||
|
||||
|
||||
def test_register_sends_the_rsa_key_and_accepts_registered(fake_service):
|
||||
seen, _ = fake_service
|
||||
devkit.register("127.0.0.1")
|
||||
assert seen[0].startswith("ssh-rsa ") and seen[0].endswith(" frameport") # the service only takes ssh-rsa
|
||||
assert any(k.get_name() == "ssh-rsa" for k in connection.app_keys()) # and FramePort logs in with it
|
||||
|
||||
|
||||
def test_register_explains_a_missed_approval(fake_service):
|
||||
_, reply = fake_service
|
||||
# Valve's service mixes the hook's log lines into the JSON answer
|
||||
reply.update(status=403, body=b'approve-ssh-key:Sending pairing request to Steam\n'
|
||||
b'{"error": "timeout - Steam did not respond to the pairing request"}')
|
||||
with pytest.raises(devkit.PairingRefused, match="wasn't approved in the headset"):
|
||||
devkit.register("127.0.0.1")
|
||||
|
||||
|
||||
def test_ed25519_key_is_tried_first_and_rsa_only_once_created():
|
||||
assert [k.get_name() for k in connection.app_keys()] == ["ssh-ed25519"]
|
||||
connection.devkit_key()
|
||||
assert [k.get_name() for k in connection.app_keys()] == ["ssh-ed25519", "ssh-rsa"]
|
||||
|
||||
|
||||
def test_register_explains_pairing_mode(fake_service):
|
||||
_, reply = fake_service
|
||||
reply.update(status=403, body=b'{"error": "devkit approve-ssh-key: please put the Steam client in pairing mode: '
|
||||
b'Settings -> Developer -> Pair new host\\n"}')
|
||||
with pytest.raises(devkit.PairingRefused, match="Pair new host first"):
|
||||
devkit.register("127.0.0.1")
|
||||
+9
-2
@@ -216,12 +216,17 @@ def test_catalog_from_issue(tmp_path):
|
||||
assert s.main([str(_write(tmp_path, BODY)), "--issue", "7", "--out", str(tmp_path)]) == 0
|
||||
d = yaml.safe_load((tmp_path / "com.example.game.yaml").read_text())
|
||||
assert d["frame"] == ["frame.ovrstubs"] and d["verified"]["issue"] == 7
|
||||
with_device = BODY.replace("- frame.ovrstubs", "- frame.ovrstubs\ndevice:\n- device.text_input_window")
|
||||
assert s.main([str(_write(tmp_path, with_device)), "--out", str(tmp_path)]) == 0
|
||||
assert yaml.safe_load((tmp_path / "com.example.game.yaml").read_text())["device"] == ["device.text_input_window"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["package: ../../etc/passwd\ntitle: x", "package: com.a.b\ntitle: x\nevil: 1",
|
||||
"package: com.a.b\ntitle: x\nstatus: great",
|
||||
"package: com.a.b\ntitle: x\nframe: [\"$(rm)\"]",
|
||||
"package: com.a.b\ntitle: x\ndevice_files: {'../x': 1}", "- a list"])
|
||||
"package: com.a.b\ntitle: x\ndevice_files: {'../x': 1}", "- a list",
|
||||
"package: com.a.b\ntitle: x\nframe: [frame.no_such_patch]",
|
||||
"package: com.a.b\ntitle: x\ndevice: device.text_input_window"])
|
||||
def test_catalog_from_issue_rejects(tmp_path, bad):
|
||||
s = _script()
|
||||
body = BODY.split("```yaml")[0] + "```yaml\n" + bad + "\n```\n"
|
||||
@@ -260,7 +265,9 @@ def test_agent_collect_diag(monkeypatch, tmp_path):
|
||||
(xr / "XRService-12-31-50.log").write_text("xr")
|
||||
host = a.cmd_collect_diag({})
|
||||
assert host["agent_version"] == a.AGENT_VERSION and "openxr_layers" in host["host"]
|
||||
assert host["files"] == {"XRService-12-31-50.log": "xr"}
|
||||
# (plus the previous boot's journal excerpts when this machine has a journal)
|
||||
assert {k: v for k, v in host["files"].items() if not k.startswith("previous-boot")} == \
|
||||
{"XRService-12-31-50.log": "xr"}
|
||||
res = a.cmd_collect_diag({"package": "com.x.y", "max_bytes": 1000})
|
||||
f = res["files"]
|
||||
assert res["installed"] and {"deployment.json", "launch.sh", "settings.conf", "launch.log", "logcat-main.log",
|
||||
|
||||
@@ -37,6 +37,7 @@ def test_known_failures(exc, start):
|
||||
|
||||
def test_other_errors_keep_their_message():
|
||||
auth = ConnectionError("SSH authentication failed (bad key). Run the FramePort bootstrap on the Frame.")
|
||||
assert explain(auth) == str(auth) and not is_connection_error(auth) # already says what to do
|
||||
# login refused: points at the first-time setup, and isn't a "Frame went away" (the queue mustn't wait for it)
|
||||
assert explain(auth).startswith("This Frame isn't set up") and not is_connection_error(auth)
|
||||
assert explain(AgentFailed("Lepton is not installed (Developer Mode → …)")).startswith("Lepton is not installed")
|
||||
assert explain(RuntimeError()) == "RuntimeError"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Type on Frame: key names -> Linux key codes, and the line protocol of a keyboard session."""
|
||||
import io
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.frame import keyboard as K
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,code", [
|
||||
("A", 30), ("a", 30), ("Key A", 30), ("1", 2), ("Digit 1", 2), ("!", 2), ("Enter", 28), ("Backspace", 14),
|
||||
("Arrow Left", 105), ("ArrowLeft", 105), ("Shift Left", 42), ("Control Left", 29), ("Alt Right", 100),
|
||||
("Meta Left", 125), (" ", 57), ("Space", 57), ("F5", 63), ("F12", 88), ("Numpad 7", 71), ("Page Down", 109),
|
||||
("Escape", 1), ("/", 53), ("?", 53), ("`", 41), ("\\", 43), ("Tab", 15), ("Delete", 111),
|
||||
])
|
||||
def test_linux_key(name, code):
|
||||
assert K.linux_key(name) == code
|
||||
|
||||
|
||||
def test_unknown_key_is_skipped():
|
||||
assert K.linux_key("Launch Mail Application") is None and K.linux_key("") is None
|
||||
|
||||
|
||||
class _Chan:
|
||||
def __init__(self):
|
||||
self.shut = False
|
||||
|
||||
def settimeout(self, t):
|
||||
pass
|
||||
|
||||
def shutdown_write(self):
|
||||
self.shut = True
|
||||
|
||||
|
||||
class _In(io.StringIO):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.channel = _Chan()
|
||||
|
||||
def close(self): # keep the value readable for the test
|
||||
pass
|
||||
|
||||
|
||||
class _Out(io.StringIO):
|
||||
def __init__(self, text):
|
||||
super().__init__(text)
|
||||
self.channel = _Chan()
|
||||
|
||||
|
||||
def _frame(replies):
|
||||
stdin, stdout = _In(), _Out(replies)
|
||||
client = SimpleNamespace(exec_command=lambda cmd, timeout=None: (stdin, stdout, None))
|
||||
return SimpleNamespace(client=client, home="/home/steamos", ensure_agent=lambda: None), stdin
|
||||
|
||||
|
||||
def test_session_streams_keys_and_text_then_closes():
|
||||
frame, stdin = _frame('{"ready": true}\n{"typed": true, "skipped": "☃"}\n')
|
||||
s = K.KeyboardSession(frame)
|
||||
assert s.key("Shift Left") and s.key("A") and s.key("A", "up") and s.key("Arrow Down", "repeat")
|
||||
assert not s.key("Launch Mail Application")
|
||||
assert s.text("Hi ☃") == "☃"
|
||||
s.close()
|
||||
sent = [json.loads(x) for x in stdin.getvalue().splitlines()]
|
||||
assert sent == [{"k": 42, "v": 1}, {"k": 30, "v": 1}, {"k": 30, "v": 0}, {"k": 108, "v": 2},
|
||||
{"text": "Hi ☃"}]
|
||||
assert stdin.channel.shut and s.closed
|
||||
s.key("A") # after close: ignored, no error
|
||||
assert len(stdin.getvalue().splitlines()) == 5
|
||||
|
||||
|
||||
def test_session_refused():
|
||||
frame, _ = _frame('{"ready": false, "error": "can\'t create a virtual keyboard: denied"}\n')
|
||||
with pytest.raises(K.KeyboardRefused, match="denied"):
|
||||
K.KeyboardSession(frame)
|
||||
@@ -0,0 +1,20 @@
|
||||
from frameport.ui.restart import crashed_game
|
||||
|
||||
LAUNCH = {"package": "com.x", "title": "X", "time": 1000.0}
|
||||
|
||||
|
||||
def boot(**kw):
|
||||
return {"boot_id": "b2", "boot_time": 2000, "prev_clean": False, "last_launch": LAUNCH, **kw}
|
||||
|
||||
|
||||
def test_unclean_restart_soon_after_a_launch_blames_the_game():
|
||||
assert crashed_game(boot(), "b1") == LAUNCH
|
||||
|
||||
|
||||
def test_no_blame_for_clean_shutdowns_first_sight_old_launches_or_same_boot():
|
||||
assert crashed_game(boot(prev_clean=True), "b1") is None # powered off / battery shutdown
|
||||
assert crashed_game(boot(prev_clean=None), "b1") is None # journal unknown
|
||||
assert crashed_game(boot(), None) is None # never seen this Frame boot before: nothing to compare
|
||||
assert crashed_game(boot(), "b2") is None # same boot
|
||||
assert crashed_game(boot(boot_time=1000 + 4 * 3600), "b1") is None # the game ran hours before
|
||||
assert crashed_game(boot(last_launch=None), "b1") is None
|
||||
@@ -60,3 +60,10 @@ def test_crash_logcat_signatures():
|
||||
"(xrCreateSwapchain+424)\n")
|
||||
r = triage(log.replace("11 (SIGSEGV)", "6 (SIGABRT)"), "EXITED", "com.example.game", crash=abort)
|
||||
assert [(f.id, f.suggest) for f in r.findings] == [("swapchain-size-abort", ["frame.swapchain_limit"])]
|
||||
|
||||
|
||||
def test_ovrplugin_extension_probe_is_not_a_scene_game():
|
||||
probe = LOG_OK + "09-28 17:39:02.200 1147 1174 I OVRPlugin: Unavailable OpenXR extension: XR_FB_scene\n"
|
||||
assert "scene-missing" not in {f.id for f in triage(probe, "RUNNING", "com.example.game").findings}
|
||||
used = LOG_OK + "09-28 17:39:04.000 1147 1174 E Unity : xrQuerySpacesFB failed: -12\n"
|
||||
assert "scene-missing" in {f.id for f in triage(used, "RUNNING", "com.example.game").findings}
|
||||
+50
-1
@@ -229,9 +229,11 @@ def test_refresh_keeps_a_working_connection_when_a_status_query_fails():
|
||||
|
||||
|
||||
def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monkeypatch):
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from frameport.frame import pairing
|
||||
|
||||
@@ -248,7 +250,16 @@ def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monke
|
||||
|
||||
seen = []
|
||||
s = pairing.PairingServer(on_paired=seen.append).start()
|
||||
assert len(s.code) == 16
|
||||
other = pairing.PairingServer().start() # a second server (port taken) falls back to another port
|
||||
assert other.port != s.port
|
||||
other.stop()
|
||||
assert len(s.code) == 8
|
||||
assert s.one_liner == f"curl -fsS 127.0.0.1:{s.port}/{s.code} | bash" # typed by hand on the Frame: short
|
||||
assert s.requests == 0
|
||||
assert get(s, f"/{s.code}") == 200 and get(s, "/deadbeef") == 403 # the short form serves the script
|
||||
assert s.requests == 2 # any request (even a wrong code) proves the Frame can reach us: no firewall hint
|
||||
script = urllib.request.urlopen(f"http://127.0.0.1:{s.port}/{s.code}", timeout=5).read().decode()
|
||||
assert f"http://127.0.0.1:{s.port}" in script and s.code in script and "__PAIR_CODE__" not in script
|
||||
assert get(s, "/key?code=000000") == 403
|
||||
assert get(s, f"/paired?code={s.code}&user=steamos&host=frame") == 200 and seen
|
||||
for _ in range(50):
|
||||
@@ -256,6 +267,12 @@ def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monke
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert not s.running
|
||||
flag = Path(tempfile.mkdtemp()) / "pairing.flag" # WSL: the temporary firewall rule lives while this exists
|
||||
flag.write_text("x")
|
||||
f = pairing.PairingServer().start()
|
||||
f.flag = flag
|
||||
f.stop()
|
||||
assert not flag.exists()
|
||||
g = pairing.PairingServer().start()
|
||||
for _ in range(3):
|
||||
get(g, "/key?code=guess")
|
||||
@@ -440,3 +457,35 @@ def test_activity_progress_ticks_only_touch_the_progress_controls(monkeypatch):
|
||||
bar = panel._live[run.id][0]
|
||||
assert bar.value == 0.5 and panel.root not in updated[-1] and bar in updated[-1]
|
||||
assert panel.list.controls == first_list # nothing rebuilt
|
||||
|
||||
|
||||
def test_flet_updates_are_serialized():
|
||||
import threading
|
||||
|
||||
from flet.messaging.session import Session
|
||||
|
||||
from frameport.ui.app import serialize_flet_updates
|
||||
|
||||
original = Session.patch_control
|
||||
try:
|
||||
active, peak = [0], [0]
|
||||
guard = threading.Lock()
|
||||
|
||||
def fake(self, *a, **k):
|
||||
with guard:
|
||||
active[0] += 1
|
||||
peak[0] = max(peak[0], active[0])
|
||||
threading.Event().wait(0.01)
|
||||
with guard:
|
||||
active[0] -= 1
|
||||
Session.patch_control = fake
|
||||
serialize_flet_updates()
|
||||
serialize_flet_updates() # idempotent
|
||||
threads = [threading.Thread(target=Session.patch_control, args=(None,)) for _ in range(8)]
|
||||
for th in threads:
|
||||
th.start()
|
||||
for th in threads:
|
||||
th.join()
|
||||
assert peak[0] == 1
|
||||
finally:
|
||||
Session.patch_control = original
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Unity XR settings fixed in libil2cpp.so: OVRManager's runtime MSAA and the Oculus XR Plugin's multiview."""
|
||||
from pathlib import Path
|
||||
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.analysis import il2cpp
|
||||
from frameport.patches import base
|
||||
from frameport.patches.frame import unity_runtime as R
|
||||
from frameport.patches.frame import unity_text_input as U
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
|
||||
def _lib() -> bytes:
|
||||
return sorted(Path(__file__).parent.joinpath("fixtures").glob("libfake*_arm64.so"))[0].read_bytes()
|
||||
|
||||
|
||||
def test_all_il2cpp_patches_share_one_cpp2il_lookup():
|
||||
base.load_all()
|
||||
for p in (U.UnityTextInput, R.UnityRuntimeMsaa, R.UnityMultiPass):
|
||||
for cls, methods in p.targets.items():
|
||||
assert set(methods) <= set(U.ALL_TARGETS[cls])
|
||||
# keyed by the build's metadata: an earlier patch's bytes in libil2cpp.so don't force another Cpp2IL run
|
||||
assert il2cpp._cache_file(b"a" * 10, b"meta") == il2cpp._cache_file(b"b" * 10, b"meta")
|
||||
assert il2cpp._cache_file(b"a" * 10, b"meta") != il2cpp._cache_file(b"a" * 10, b"other")
|
||||
|
||||
|
||||
def test_runtime_patches_write_zero_returns():
|
||||
lib = _lib()
|
||||
lo, _hi = U._executable(lib)[0]
|
||||
a, b = (lo + 0x40) & ~3, (lo + 0x80) & ~3
|
||||
found = {"Assembly-CSharp/OVRDisplay.cs": {"get_recommendedMSAALevel": (a, 0x5C)},
|
||||
"Unity.XR.Oculus/Unity/XR/Oculus/OculusSettings.cs": {"GetStereoRenderingMode": (b, 8)}}
|
||||
msaa, notes = U.patch_methods(lib, found, R.UnityRuntimeMsaa.targets)
|
||||
assert msaa[a:a + 8] == R.RET_ZERO and msaa[b:b + 8] == lib[b:b + 8] and "OVRDisplay" in notes[0]
|
||||
multi, _ = U.patch_methods(lib, found, R.UnityMultiPass.targets)
|
||||
assert multi[b:b + 8] == R.RET_ZERO and multi[a:a + 8] == lib[a:a + 8]
|
||||
|
||||
|
||||
def test_runtime_msaa_suggested_for_gles_ovr_games_only():
|
||||
base.load_all()
|
||||
p = base.get("frame.unity_runtime_msaa_off")
|
||||
gles = _analysis(libs=["libil2cpp.so", "libunity.so"], graphics="GLES or unknown (no Vulkan declaration)",
|
||||
extra={"ovr_runtime_msaa": True})
|
||||
assert p.detect(gles).recommended
|
||||
assert p.detect(_analysis(libs=["libil2cpp.so"], extra={"ovr_runtime_msaa": True})) is None # Vulkan
|
||||
gles.extra["ovr_runtime_msaa"] = False
|
||||
assert p.detect(gles) is None and p.applies(gles)
|
||||
assert base.get("frame.unity_multipass").detect(gles) is None # opt-in: only for the symptom
|
||||
assert not base.get("frame.unity_multipass").applies(_analysis(libs=["libunity.so"])) # Mono: no Cpp2IL
|
||||
|
||||
|
||||
def test_triage_points_runtime_msaa_to_the_patch():
|
||||
log = ("09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1147:com.example.game/u0a55 for activity\n"
|
||||
"09-28 17:39:04.000 1147 1174 I Unity : The current MSAA level is 0, but the recommended MSAA level is "
|
||||
"4. Switching to the recommended level.\n")
|
||||
f = {x.id: x for x in triage(log, "RUNNING", "com.example.game").findings}
|
||||
assert "frame.unity_runtime_msaa_off" in f["unity-runtime-msaa"].suggest
|
||||
|
||||
|
||||
|
||||
def test_library_recipes_follow_their_catalog_entry(tmp_path, monkeypatch):
|
||||
import json
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.recommend import catalog, engine
|
||||
|
||||
monkeypatch.setattr(library, "_path", lambda: tmp_path / "library.json")
|
||||
entry = catalog.CatalogEntry.from_dict({"package": "com.x.game", "title": "X", "status": "issues",
|
||||
"frame": ["frame.unity_multipass"], "updated": "2026-10-04"}, "bundled")
|
||||
monkeypatch.setattr(catalog, "lookup", lambda pkg: entry if pkg in ("com.x.game", "com.x.mine") else None)
|
||||
an = {"package": "com.x.game", "engine": "Unity", "libs": ["libil2cpp.so", "libunity.so"], "abis": ["arm64-v8a"]}
|
||||
games = {"com.x.game": {"analysis": an, "recipe": {"package": "com.x.game", "source": "heuristics"}},
|
||||
"com.x.mine": {"analysis": dict(an, package="com.x.mine"),
|
||||
"recipe": {"package": "com.x.mine", "source": "user", "patches": {"frame.adapter": {}}}}}
|
||||
(tmp_path / "library.json").write_text(json.dumps({"games": games, "settings": {}}))
|
||||
got = library.load()["games"]
|
||||
r = got["com.x.game"]["recipe"]
|
||||
assert "frame.unity_multipass" in r["patches"] and r["status"] == "issues" and r["catalog_rev"] == entry.rev()
|
||||
mine = got["com.x.mine"]["recipe"] # the user's own choice stays (one-time migrations aside)
|
||||
assert "frame.adapter" in mine["patches"] and "frame.unity_multipass" not in mine["patches"]
|
||||
assert not library._follow_catalog(library.load()) # nothing changed since: no re-derive on every load
|
||||
assert engine.suggest(library.analysis_from_dict(an)).catalog_rev == entry.rev()
|
||||
|
||||
|
||||
def test_maintained_entry_verified_later_beats_the_users_shared_one():
|
||||
from frameport.recommend import catalog
|
||||
|
||||
def e(origin, **kw):
|
||||
return catalog.CatalogEntry.from_dict({"package": "p.q", "title": "P", **kw}, origin)
|
||||
user = e("user", verified={"date": "2026-10-03"})
|
||||
fixed, old = e("bundled", updated="2026-10-04"), e("bundled", verified={"date": "2026-09-28"})
|
||||
assert catalog._newer(fixed, user) and not catalog._newer(old, user) and not catalog._newer(None, user)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Unity text fields on the Frame: Cpp2IL output parsing, the release pick, the byte patch and the detection."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.analysis.detect import unity_text_fields, unity_version
|
||||
from frameport.analysis.il2cpp import _plain_version, parse_methods
|
||||
from frameport.patches.frame import unity_text_input as U
|
||||
from frameport.tools.cpp2il import pick_release
|
||||
|
||||
CS = '''public class TMP_InputField : Selectable
|
||||
{
|
||||
[Address(RVA = "0x71946E0", Offset = "0x71946E0", Length = "0x98")]
|
||||
[Token(Token = "0x6000305")]
|
||||
public bool get_shouldHideMobileInput() { }
|
||||
|
||||
[Address(RVA = "0x7194A78", Offset = "0x7194A78", Length = "0x10C")]
|
||||
[Token(Token = "0x6000383")]
|
||||
private bool isKeyboardUsingEvents() { }
|
||||
|
||||
[Address(RVA = "0x71988F0", Offset = "0x71988F0", Length = "0xB8")]
|
||||
[Token(Token = "0x6000386")]
|
||||
private bool TouchScreenKeyboardShouldBeUsed() { }
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def test_parse_methods_reads_file_offsets():
|
||||
found = parse_methods(CS, ["TouchScreenKeyboardShouldBeUsed", "isKeyboardUsingEvents", "Missing"])
|
||||
assert found == {"TouchScreenKeyboardShouldBeUsed": (0x71988F0, 0xB8), "isKeyboardUsingEvents": (0x7194A78, 0x10C)}
|
||||
assert _plain_version("6000.2.7f2") == "6000.2.7" and _plain_version("2021.3.45f1") == "2021.3.45"
|
||||
|
||||
|
||||
def test_pick_release_takes_the_newest_with_an_asset_for_this_computer():
|
||||
releases = [{"tag_name": "2022.1.0-pre-release.21", "assets": [
|
||||
{"name": "Cpp2IL-2022.1.0-pre-release.21-Linux", "browser_download_url": "u/linux"},
|
||||
{"name": "Cpp2IL-2022.1.0-pre-release.21-Linux-ARM64", "browser_download_url": "u/arm"},
|
||||
{"name": "Cpp2IL-2022.1.0-pre-release.21-Windows.exe", "browser_download_url": "u/win"}]},
|
||||
{"tag_name": "old", "assets": [{"name": "Cpp2IL-old-OSX", "browser_download_url": "u/osx"}]}]
|
||||
assert pick_release(releases, "Linux") == ("2022.1.0-pre-release.21", "u/linux")
|
||||
assert pick_release(releases, "Linux-ARM64")[1] == "u/arm"
|
||||
assert pick_release(releases, "Windows.exe")[1] == "u/win"
|
||||
assert pick_release(releases, "OSX") == ("old", "u/osx")
|
||||
assert pick_release(releases, "OSX-ARM64") is None
|
||||
|
||||
|
||||
def _lib(tmp_path) -> bytes:
|
||||
"""A small arm64 ELF with an executable segment (tests/fixtures), padded to hold the fake methods."""
|
||||
fx = sorted(Path(__file__).parent.joinpath("fixtures").glob("libfake*_arm64.so"))[0]
|
||||
return fx.read_bytes()
|
||||
|
||||
|
||||
def test_patch_methods_writes_return_values_once(tmp_path):
|
||||
lib = _lib(tmp_path)
|
||||
(lo, hi), = U._executable(lib)[:1]
|
||||
a, b = (lo + 0x40) & ~3, (lo + 0x80) & ~3
|
||||
assert b + 8 <= hi
|
||||
found = {"Unity.TextMeshPro/TMPro/TMP_InputField.cs": {"TouchScreenKeyboardShouldBeUsed": (a, 0xB8),
|
||||
"isKeyboardUsingEvents": (b, 0x10C)}}
|
||||
patched, notes = U.patch_methods(lib, found)
|
||||
assert patched[a:a + 8] == U.RET_FALSE and patched[b:b + 8] == U.RET_TRUE and len(patched) == len(lib)
|
||||
assert len(notes) == 2 and "TMP_InputField.isKeyboardUsingEvents -> true" in notes[1]
|
||||
again, notes2 = U.patch_methods(patched, found) # idempotent
|
||||
assert again is None and all("already patched" in n for n in notes2)
|
||||
|
||||
|
||||
def test_patch_methods_refuses_offsets_outside_code(tmp_path):
|
||||
lib = _lib(tmp_path)
|
||||
bad = {"Unity.TextMeshPro/TMPro/TMP_InputField.cs": {"TouchScreenKeyboardShouldBeUsed": (len(lib) + 64, 8)}}
|
||||
with pytest.raises(RuntimeError, match="isn't code"):
|
||||
U.patch_methods(lib, bad)
|
||||
misaligned = {"Unity.TextMeshPro/TMPro/TMP_InputField.cs": {"isKeyboardUsingEvents": (U._executable(lib)[0][0] + 2,
|
||||
64)}}
|
||||
with pytest.raises(RuntimeError):
|
||||
U.patch_methods(lib, misaligned)
|
||||
|
||||
|
||||
def test_detection_of_text_fields_and_unity_version():
|
||||
meta = b"\0Selectable\0TMP_InputField\0TMPro\0UnityEngine.UI\0"
|
||||
assert unity_text_fields(meta) == ["TMP_InputField"] # "InputField" only as the tail of TMP_InputField
|
||||
assert unity_text_fields(meta + b"InputField\0") == ["TMP_InputField", "InputField"]
|
||||
assert unity_text_fields(b"\0Button\0") == []
|
||||
assert unity_version(b"\0" * 40 + b"2021.3.45f1\0") == "2021.3.45f1"
|
||||
assert unity_version(None, b"x 6000.2.7f2 y 6000.2.7f2 z 6000.2.7f1 2018.3.0a1") == "6000.2.7f2"
|
||||
|
||||
|
||||
def test_patches_suggested_for_unity_apps_with_text_fields():
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.patches import base
|
||||
|
||||
base.load_all()
|
||||
a = _analysis(libs=["libil2cpp.so", "libunity.so"], extra={"text_fields": ["TMP_InputField"], "vr_kind": "quest"})
|
||||
assert base.get("frame.unity_text_input").detect(a).recommended
|
||||
assert base.get("device.text_input_window").detect(a).recommended
|
||||
a.extra["text_fields"] = []
|
||||
assert base.get("frame.unity_text_input").detect(a) is None and not base.get("frame.unity_text_input").applies(a)
|
||||
|
||||
|
||||
def test_catalog_device_toggles_round_trip():
|
||||
from frameport.recommend.catalog import CatalogEntry
|
||||
|
||||
e = CatalogEntry.from_dict({"package": "com.x", "title": "X", "frame": ["frame.unity_text_input"],
|
||||
"device": ["device.text_input_window"]}, "bundled")
|
||||
assert e.device == ["device.text_input_window"] and e.to_dict()["device"] == ["device.text_input_window"]
|
||||
|
||||
|
||||
def test_reanalyze_refreshes_the_suggestion_and_keeps_a_users_recipe(monkeypatch, tmp_path):
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.core import library
|
||||
from frameport.core.models import Recipe
|
||||
|
||||
apk = tmp_path / "game.apk"
|
||||
apk.write_bytes(b"x")
|
||||
a = _analysis(package="com.x", libs=["libil2cpp.so", "libunity.so"],
|
||||
extra={"text_fields": ["TMP_InputField"], "vr_kind": "quest", "unity_version": "6000.2.7f2"})
|
||||
monkeypatch.setattr(pipeline, "analyze", lambda path, data_bytes=0: a)
|
||||
old = Recipe(package="com.x", patches={"frame.adapter": {}}, source="heuristics")
|
||||
library.upsert_game("com.x", title="X", apk=str(apk), analysis=_analysis(package="com.x").to_dict(),
|
||||
recipe=library.recipe_to_dict(old), suggested=library.recipe_to_dict(old))
|
||||
pipeline.reanalyze("com.x")
|
||||
g = library.game("com.x")
|
||||
assert g["analysis"]["extra"]["text_fields"] == ["TMP_InputField"]
|
||||
assert "frame.unity_text_input" in g["recipe"]["patches"] # heuristic recipe: replaced by the new suggestion
|
||||
user = Recipe(package="com.x", patches={"frame.adapter": {}}, source="user")
|
||||
library.upsert_game("com.x", recipe=library.recipe_to_dict(user))
|
||||
pipeline.reanalyze("com.x")
|
||||
g = library.game("com.x")
|
||||
assert "frame.unity_text_input" not in g["recipe"]["patches"] # the user's own choices stay
|
||||
assert "frame.unity_text_input" in g["suggested"]["patches"]
|
||||
|
||||
|
||||
def test_flat_windows_game_keeps_only_proton_patches():
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.patches import base
|
||||
from frameport.recommend import engine
|
||||
|
||||
base.load_all()
|
||||
a = _analysis(package="rift.somegame", engine="Unity", xr="?", libs=[],
|
||||
extra={"kind": "rift", "flat": True, "vr_found": False, "exe": "Game.exe"})
|
||||
vr = engine.suggest(_analysis(package="rift.somegame", engine="Unity", xr="OpenXR", libs=[],
|
||||
extra={"kind": "rift", "vr_found": True, "openxr": True, "exe": "Game.exe"}),
|
||||
use_catalog=False)
|
||||
r = engine.suggest(a, use_catalog=False)
|
||||
assert set(r.patches) <= set(engine.FLAT_WINDOWS_PATCHES)
|
||||
assert set(vr.patches) - set(engine.FLAT_WINDOWS_PATCHES) # the same game with VR gets VR patches
|
||||
@@ -246,3 +246,11 @@ def test_apply_reports_a_script_that_never_starts(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(updates.subprocess, "Popen", lambda *a, **k: type("P", (), {"poll": lambda self: None})())
|
||||
with pytest.raises(updates.UpdateError):
|
||||
updates.apply(staged, installed, relaunch=False, pid=1, platform="linux")
|
||||
|
||||
|
||||
def test_platform_asset_picks_the_arm64_linux_bundle(monkeypatch):
|
||||
monkeypatch.setattr(updates.sys, "platform", "linux")
|
||||
monkeypatch.setattr(updates.platform, "machine", lambda: "aarch64")
|
||||
assert updates.platform_asset() == "FramePort-linux-arm64.tar.gz"
|
||||
monkeypatch.setattr(updates.platform, "machine", lambda: "x86_64")
|
||||
assert updates.platform_asset() == "FramePort-linux-x64.tar.gz"
|
||||
Reference in new issue
Block a user