Compare commits

..
18 Commits
Author SHA1 Message Date
spoopyghosty0 6ed95684dc FramePort 0.6.1: Linux ARM64 build (CI ubuntu-22.04-arm, PyInstaller one-folder fallback; the updater picks FramePort-linux-arm64.tar.gz on ARM); tested games list docs/GAMES.md generated from the catalog (scripts/compat_list.py) 2026-10-03 12:05:59 -04:00
spoopyghosty0 a6ca8385a9 COMPATIBILITY: no game counts (they change often); point to the catalog recipes 2026-10-03 11:38:56 -04:00
spoopyghosty0 210410f125 Catalog: Stremio VR works (confirmed by the owner with frame.unity_text_input + device.text_input_window); compatibility count 28/5/6 2026-10-03 11:37:59 -04:00
spoopyghosty0 7e2502a552 FramePort 0.6.0: Type on Frame screenshot in the README; sidebar keyboard icon compact next to the Frame name; docs: the shown app window is listed as Gamescope 2026-10-03 10:44:17 -04:00
spoopyghosty0 a57d89e874 README: highlight Type on Frame (second feature bullet + its own section) 2026-10-03 10:36:03 -04:00
spoopyghosty0 aade29982f Docs: Typing on the Frame (INSTALL), README feature, PLAYBOOK row, FRAME_RUNTIME/CLAUDE.md text-input facts; catalog count 39 2026-10-03 10:34:58 -04:00
spoopyghosty0 d94cab6048 Unity text fields work on the Frame: frame.unity_text_input finds TMP_InputField/uGUI InputField keyboard methods per game with Cpp2IL (downloaded on first use, cached per libil2cpp) and makes them edit in place; device.text_input_window shows the Android window so Steam's keyboard / Type on Frame reach it. Analysis records text_fields + unity_version; catalog recipes can list device toggles; Stremio VR recipe; 'Analyze again' game action 2026-10-03 10:33:47 -04:00
spoopyghosty0 f17fdbf271 Type on Frame: this computer's keyboard becomes a keyboard on the Frame (agent v33 _keyboard session: Linux uinput virtual keyboard, no root; keys streamed live over one SSH channel, released when it closes). Dialog with live keys + paste; entry points on the Frame page, game menus and the sidebar Frame card 2026-10-03 10:22:33 -04:00
spoopyghosty0 fb70c09d72 CLAUDE.md: Lepton intercepts every VIEW intent with data (companion-app hand-offs like Stremio → 4XVR can't work) 2026-10-03 08:56:27 -04:00
spoopyghosty0 69bf2700e1 Sidebar: Frame battery as an icon + percentage next to the name (level bars, charging/alert icons, tooltip) instead of a suffix that wrapped 2026-10-03 08:35:09 -04:00
spoopyghosty0 0b28da5521 Battery: 'charging' means plugged in and the gauge not draining (a weak charger can supply less than the Frame uses; the Frame reports Discharging briefly after boot); sidebar says 'battery N %' (the font has no battery emoji) 2026-10-03 08:33:01 -04:00
spoopyghosty0 9e9a4c0f6c Frame battery: level in the sidebar and on the Frame page (agent v32 battery_state/battery); during installs on battery power FramePort warns at 30 %, pauses the queue at 15 % (instead of the Frame switching itself off mid-upload) and continues when it's plugged in or back at 25 % (ui/battery.py) 2026-10-03 08:29:23 -04:00
spoopyghosty0 bb3f6b9e7c Release bundles: ship the Frame agent's source (fixes #2). flet build compiles every .py of the app to .pyc, bundled data included, so the agent couldn't be uploaded and connecting failed. Bundles carry frameport_agent.py.txt as well (paths.agent_file reads either); scripts/package.py fails a build whose bundle lacks the source 2026-10-03 08:18:32 -04:00
spoopyghosty0 dc7533e4e1 Add AI Usage Notice 2026-10-03 08:16:53 -04:00
spoopyghosty0 3660167bd8 Adjust README wording 2026-10-03 08:12:20 -04:00
spoopyghosty0 6a47fad8f7 README/INSTALL: step-by-step setup command for non-technical users (Desktop mode, Konsole, typing it, what happens next); Steam Frame badge instead of the status badge 2026-10-02 23:40:14 -04:00
spoopyghosty0 0f8062f881 README: short and to the point (badges, features, quick start, links); details moved to docs/FRAME_SETUP.md (what setup changes, networks and firewalls), docs/COMPATIBILITY.md, credits table to docs/ARCHITECTURE.md, game page screenshots to docs/INSTALL.md 2026-10-02 23:14:29 -04:00
spoopyghosty0 65369f154c Release notes: a short install footer (packaging/release-footer.md) instead of all of docs/INSTALL.md from First launch; the update dialog shows these notes 2026-10-02 23:07:03 -04:00
45 changed files with 1723 additions and 226 deletions

No files matched your search

+11 -7
View File
@@ -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"
+23 -1
View File
@@ -393,6 +393,27 @@ 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.
**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.
@@ -405,7 +426,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>"`,
+71 -176
View File
@@ -1,211 +1,106 @@
# 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.
[![Release](https://img.shields.io/github/v/release/spoopyghosty0/frameport)](https://github.com/spoopyghosty0/frameport/releases/latest)
[![Build](https://img.shields.io/github/actions/workflow/status/spoopyghosty0/frameport/build.yml?branch=main)](https://github.com/spoopyghosty0/frameport/actions/workflows/build.yml)
[![Downloads](https://img.shields.io/github/downloads/spoopyghosty0/frameport/total)](https://github.com/spoopyghosty0/frameport/releases)
[![License](https://img.shields.io/github/license/spoopyghosty0/frameport)](LICENSE)
![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-blue)
![Steam Frame](https://img.shields.io/badge/Steam%20Frame-supported-1b2838?logo=steam&logoColor=white)
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.
![Library](docs/images/library.png)
## 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, a Files tab with drag and drop.
- **Self-updating** releases, redacted diagnostics, one-click problem reports and working-config sharing.
## Highlights
## Quick start
- **Painless setup, no root.** One command in the Frame's Konsole connects it: it turns on Developer Mode with Valve's
own helper and lets FramePort in. No `sudo`, no password, no packages, and SteamOS's read-only system stays
untouched. Nothing is installed system-wide on your computer either.
- **One click from game backup to Steam library.** FramePort converts, patches, signs, checks, uploads and adds the
game to the Frame's Steam library with full artwork, then runs a short launch test and reads the logs for known
problems.
- **Knows what each game needs.** Recipes come from a catalog of games tested on the Frame, plus detection rules for
everything else. Every patch is explained in plain words, with **Show technical details** for the exact change.
- **Fills in what the Frame lacks.** FramePort's own OpenXR adapter (FrameBridge) emulates what Quest games expect
and the Frame doesn't provide: passthrough, a room for mixed-reality games, controller models, curved screens,
360° video layers. **Game settings** (sharpness, refresh rate, controllers, menus) are switches and sliders,
shown only when they matter for that game.
- **Long queues that finish.** Installs queue up and keep the Frame awake. If the Frame sleeps or drops off the
Wi-Fi, the queue pauses and resumes, and uploads continue where they stopped. A USB cable or the Frame's own
hotspot is used automatically for faster uploads.
- **Your files stay yours.** Your game files are never modified: the converted copy is temporary. Each game keeps
its own signing key, so updates keep your saves.
- **Looks at home in Steam.** Store artwork, descriptions and genres become Steam art and tags. Apps without store
art get generated artwork from their icon.
- **More than Quest games.** Ordinary Android apps open as windows in the headset, with Android's navigation bar
hidden. PC VR games run through Proton on the Frame or through Revive and SteamVR on your PC. The **Files** tab
sends videos and other files into the Frame's shared folders or a game's own storage, with drag and drop.
- **Easy to keep current and to report.** Signed releases update themselves. Diagnostics are redacted (no IP
addresses, user names or paths), and problem reports and working configs open as prefilled GitHub issues. The
interface is ready for translation.
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 press the **Steam button → Power → Switch to Desktop**.
3. Open the app menu (bottom-left corner), search for **Konsole** and open it.
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 and the Frame returns to its normal view; 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.
What runs, and how well, is listed in [What works](#what-works).
Full guide, firewalls and troubleshooting: [docs/INSTALL.md](docs/INSTALL.md).
## Getting started
## Type on Frame
1. **Download** the archive for your computer from the
[latest release](https://github.com/spoopyghosty0/frameport/releases/latest) and extract it anywhere:
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.
| 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` |
![Type on Frame](docs/images/type-on-frame.png)
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**: in FramePort open **Steam Frame** → **Show setup command**, then on the Frame switch to
Desktop mode, open Konsole and type that one short command. It turns on Developer Mode and lets FramePort in:
no root, no `sudo`, no password. This is needed once. Everything it changes is listed in
[What FramePort changes on the Frame](#what-frameport-changes-on-the-frame); firewall notes are in
[Network and firewalls](#network-and-firewalls).
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**).
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).
![Game page](docs/images/game.png)
## Compatibility
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.
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!
![Game settings](docs/images/game-settings.png)
FramePort updates itself: when a new version is released, the Library shows **Update now**.
### Network and firewalls
During first-time setup the Frame downloads the setup script from FramePort on your computer: TCP ports 8765–8767,
open only while the setup command is shown and for at most 30 minutes. After that, every connection goes from your
computer to the Frame (SSH), and nothing needs to reach your computer.
- **Windows:** allow FramePort when Windows asks (Private networks). If Windows treats your network as Public, allow
Public too or switch the network to Private.
- **macOS:** if the macOS firewall is on, allow incoming connections when it asks.
- **Linux:** if firewalld or ufw blocks the port, FramePort shows the command that opens it (firewalld: until the next
restart).
- **WSL:** FramePort adds a temporary Hyper-V firewall rule (one admin prompt) and removes it when setup is done. WSL
needs mirrored networking (`networkingMode=mirrored` in `.wslconfig`).
- If nothing reaches FramePort within about 45 seconds, the setup page says what is likely blocking it on your system.
- **No inbound connection at all:** turn on Developer Mode in the Frame's settings first. The Frame then appears under
**On your network**. On the Frame open Settings → Developer → **Pair new host**, click **Connect** in FramePort and
approve it (Valve's own devkit pairing). FramePort sets up the rest over SSH.
## What FramePort changes on the Frame
The setup command (step 3) 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.
## What works
The built-in catalog has tested settings for 38 games (27 work, 5 work with known issues, 6 can't run on the Frame).
Other games get suggested patches from detection rules; each suggestion states its reason, and every patch can be
switched on or off under **Customize**: 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.
![Patches](docs/images/patches.png)
| 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.
![Steam Frame](docs/images/frame.png)
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.
![Files](docs/images/files.png)
**[List of tested games](docs/GAMES.md)**
## 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.
+155 -1
View File
@@ -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 = 31
AGENT_VERSION = 33
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -602,6 +604,43 @@ 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
def cmd_battery(args):
return {"battery": battery_state()}
def cmd_info(args):
lepton, app = lepton_path()
osr = {}
@@ -621,6 +660,7 @@ 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,
"battery": battery_state(),
}
@@ -2294,10 +2334,124 @@ def cmd_cleanup(args):
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
+21
View File
@@ -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
+18
View File
@@ -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.
+30
View File
@@ -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.
![Patches](images/patches.png)
| 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.
![Steam Frame](images/frame.png)
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.
![Files](images/files.png)
+11
View File
@@ -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.
+53
View File
@@ -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.
+46
View File
@@ -0,0 +1,46 @@
# 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…**).
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 | |
| Carve Snowboarding | Quest | ✅ Works | |
| Demeter | Quest | ✅ Works | |
| Espire 2 | Quest | ✅ Works | |
| Genotype | Quest | ✅ Works | |
| H.U.N.T | Quest | ✅ Works | |
| In Death: Unchained | 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 | |
| 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. |
| 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. |
+37 -24
View File
@@ -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,44 +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
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 switch to Desktop mode (Steam button → Power → Switch to Desktop), open Konsole and
run the command FramePort shows. It authorises this computer, turns on **Developer Mode** (which includes
SSH) and asks Steam to install Valve's Android runtime (Lepton) if needed: confirm that download. Turning on
Developer Mode restarts Steam, which closes Desktop Mode and returns to the normal view; the setup finishes on its
own. No password is needed. The app connects by itself when it's done. The full list of changes is in the
[README](../README.md#what-frameport-changes-on-the-frame).
2. First time only, on the Frame:
1. Press the **Steam button → Power → Switch to Desktop**.
2. Open the app menu (bottom-left corner), search for **Konsole** and open it.
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 and the Frame returns to its normal view; 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.
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
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 above: it needs no connection into your computer.
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
@@ -78,6 +71,26 @@ blocking it after about 45 seconds:
- 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**).
![Game page](images/game.png)
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.
![Game settings](images/game-settings.png)
## 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
+1
View File
@@ -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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+8
View File
@@ -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`.
+42
View File
@@ -0,0 +1,42 @@
"""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}
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"
"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
View File
@@ -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)
+33
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
__version__ = "0.5.0"
__version__ = "0.6.1"
+33
View File
@@ -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,41 @@ 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 [],
"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())
+86
View File
@@ -0,0 +1,86 @@
"""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 libil2cpp.so (sha256), so a rebuild of the same game doesn't run Cpp2IL again."""
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) -> Path:
return cache_dir() / "il2cpp" / (hashlib.sha256(lib).hexdigest()[:32] + ".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)
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
+11
View File
@@ -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"
+3 -3
View File
@@ -19,7 +19,7 @@ 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"
@@ -124,7 +124,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
@@ -369,7 +369,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)
+108
View File
@@ -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
+1 -1
View File
@@ -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']})")
+28
View File
@@ -13,6 +13,7 @@
""
],
" Reinstall it for the controller models.": "",
" · battery {value}": "",
" · downloaded when first needed": "",
" · Frame not connected": "",
" · link": "",
@@ -99,6 +100,8 @@
"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": "",
@@ -158,6 +161,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": "",
@@ -174,6 +178,7 @@
"Connected": "",
"Connected to {label}": "",
"Connected to {label}.": "",
"Connecting the keyboard…": "",
"Connecting to {value}…": "",
"Connecting…": "",
"Continue": "",
@@ -289,6 +294,8 @@
"Force enable passthrough": "",
"Found": "",
"Frame agent": "",
"Frame battery: charging": "",
"Frame battery: not charging": "",
"Frame OpenXR compatibility layer": "",
"FrameBridge OpenXR adapter": "",
"FramePort": "",
@@ -407,6 +414,8 @@
"Keep menus in place": "",
"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": "",
@@ -426,13 +435,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.": "",
@@ -444,6 +456,7 @@
"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.": "",
@@ -532,6 +545,7 @@
"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 above. Open Settings → Developer → Pair new host on the Frame, click Connect here 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).": "",
@@ -552,6 +566,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)": "",
@@ -724,6 +739,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.": "",
@@ -794,9 +810,12 @@
"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 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)": "",
@@ -815,6 +834,7 @@
"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 game can't run on the Steam Frame.": "",
@@ -845,6 +865,11 @@
"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": "",
@@ -856,6 +881,7 @@
"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": "",
"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).": "",
@@ -889,6 +915,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.": "",
@@ -1005,6 +1032,7 @@
"{on} on your Frame": "",
"{remaining} more after this": "",
"{title} failed: {error}": "",
"{title}: analyzed again": "",
"{title}: details from {value}": "",
"{title}: done": "",
"{title}: launch test {verdict} (furthest: {value})": "",
+1
View File
@@ -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,102 @@
"""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]]]) -> tuple[bytes | None, list[str]]:
"""Write the return values over the methods' first two instructions. found = il2cpp.find_methods' result.
Returns (patched library or None when nothing changed, notes)."""
out, notes, changed = bytearray(lib), [], False
code = _executable(lib)
for cls, methods in 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' if new == RET_TRUE else 'false'} (at {off:#x})")
return (bytes(out) if changed else None), notes
class UnityTextInput(Patch):
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
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
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("Unity text fields", False, "not fixed: unknown Unity version (rescan the game)")
return False
lib = ws.read(lib_name)
ctx.reporter.log("finding the text field code (Cpp2IL; the first time can take a minute)")
try:
found = find_methods(lib, ws.read(METADATA), version, {c: list(m) for c, m in TARGETS.items()})
patched, notes = patch_methods(lib, found)
except Exception as exc: # noqa: BLE001 - optional fix: the game still builds, its text fields as before
ctx.reporter.check("Unity text fields", False, f"not fixed: {exc}")
return False
ctx.notes.extend(notes or ["no Unity text field code found"])
if patched is not None:
ws.put(lib_name, patched)
return patched is not None
register(UnityTextInput)
+25
View File
@@ -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)
+3
View File
@@ -48,10 +48,13 @@ SUMMARIES = {
"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.",
+17
View File
@@ -282,6 +282,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,
+6
View File
@@ -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)
@@ -129,6 +130,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 +176,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", {}))
+5 -3
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
from ..core.models import Analysis, Recipe
from ..patches import base
from . import catalog
from .catalog import TOGGLED_DEVICE
def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
@@ -32,16 +33,17 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
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:
+5 -2
View File
@@ -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
+65
View File
@@ -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
+88 -4
View File
@@ -158,11 +158,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 +205,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 +222,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 +350,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:
@@ -540,6 +571,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 +1245,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 +1323,54 @@ 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 _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
@@ -1326,7 +1410,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):
+64
View File
@@ -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.")
+6 -1
View File
@@ -10,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:
@@ -35,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)
+117
View File
@@ -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)
+4 -1
View File
@@ -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)
+60
View File
@@ -733,3 +733,63 @@ def test_purge_without_saves_removes_container_workdirs_and_stale_shortcuts(monk
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
+40
View File
@@ -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"
+53
View File
@@ -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)
+75
View File
@@ -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)
+132
View File
@@ -0,0 +1,132 @@
"""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"]
+8
View File
@@ -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"