Compare commits

..
16 Commits
Author SHA1 Message Date
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
spoopyghosty0 aefb5adc07 FramePort 0.5.0
Setup without root or password: bootstrap.sh turns Developer Mode on itself (Valve's steamos-devkit-mode helper,
config.vdf set with Steam stopped) and finishes as a user unit, since Desktop Mode is a nested Plasma inside
steam.service (stopping Steam ends it; systemd-run needs the real user bus). Shorter setup command
(curl -fsS <ip>:8765/<code> | bash). Valve's devkit pairing (POST :32000/register, ssh-rsa key, Settings → Developer
→ Pair new host) as a fallback that needs no connection into the PC. Firewall handling: temporary Hyper-V rule on WSL
(one UAC prompt, removed after setup), per-OS hints when nothing reaches the setup server. Flet updates serialized
(a dialog shown during a scan redraw never closed). Setup command survives page redraws. Uninstall: removes Lepton's
overlayfs work dirs (mode 000) and stale shortcuts (agent v31). Tests no longer touch the real data folder. README:
highlights, what the setup changes on the Frame, network and firewalls.
2026-10-02 22:53:10 -04:00
50 changed files with 2354 additions and 197 deletions

No files matched your search

+2 -2
View File
@@ -140,7 +140,7 @@ jobs:
{ echo "FramePort ${tag}: Windows (x64), macOS (Apple Silicon) and Linux (x64)."; 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"
+40 -1
View File
@@ -225,6 +225,23 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
- **Tracking only works with the headset worn.** SSH/headless launches never reach VISIBLE/FOCUSED and poses have
flags 0x3. So automated tests prove startup (process alive, instance/session created, frames paced), never visuals.
**Setup / pairing (2026-10-02, verified on the device):** Developer Mode = `"DevModeEnabled"` in
`~/.local/share/Steam/config/config.vdf` (InstallConfigStore/developer), applied by Valve's
`/usr/bin/steamos-polkit-helpers/steamos-devkit-mode --enable|--disable` (polkit allow_any: no password; enables/
disables sshd, xrdp, steamos-devkit-service, debug port forwards; sentinel `/etc/steamos-devkit-enabled`). Steam
re-asserts the config value at every start and rewrites config.vdf on exit → stop Steam, edit, helper, start.
**Desktop Mode is a nested Plasma inside steam.service** (own XDG_RUNTIME_DIR `/run/user/1000/nested_plasma` + private
D-Bus): `systemd-run --user` from Konsole fails ("Failed to connect to user scope bus") unless XDG_RUNTIME_DIR/
DBUS_SESSION_BUS_ADDRESS point at `/run/user/$UID`, and stopping Steam ends the desktop and everything started in
it → bootstrap.sh runs the Dev Mode job + Lepton request + `/paired` as a user unit (log `~/.cache/frameport-setup.log`);
no sudo/password anywhere (verified: naive Frame → connected, password never set). Valve's devkit pairing (fallback,
PC → Frame only): `POST :32000/register` with an **ssh-rsa** key (`connection.devkit_key`, `frame/devkit.py`) works only
while Steam is in pairing mode (Settings → Developer → Pair new host), else 403 at once; approve hook waits 30 s.
The Frame runs firewalld (22 and 32000 open). PC side: the setup server needs inbound TCP 8765–8767 — WSL's Hyper-V
firewall blocks it silently (`DefaultInboundAction Block`): `pairing.ensure_reachable` adds a temporary rule via one
UAC prompt, removed when the server stops (flag file in %TEMP%, max 35 min); hints per OS after 45 s without a request
(`pairing.firewall_hint`). Flet 1.0 patches aren't thread-safe → `app.serialize_flet_updates()` (a dialog shown while a
scan redraw ran never closed: "dropped a patch for unknown control").
**Lepton:** needs an activity with category **LAUNCHER** (Quest apps often only have INFO → "APP_ACTIVITY is empty").
**2D apps:** Lepton runs every app headless (`lepton.headless=true`, only OpenXR output reaches the headset) unless the app folder (`<base>/lepton-app/`) has a `lepton-show-flatscreen` file (liblepton/app_metadata.sh) → Waydroid window on gamescope; agent v28 `set_flatscreen` at finalize for `vr_kind == "none"`. Android 11's navbar covered the
app's controls → patch `device.hide_navbar` (default on for vr_kind none, migration `flat_hide_navbar`) exports
@@ -376,6 +393,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.
@@ -388,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>"`,
+69 -96
View File
@@ -1,130 +1,103 @@
# 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.
## Getting started
## Quick start
1. **Download** the archive for your computer from the
[latest release](https://github.com/spoopyghosty0/frameport/releases/latest) and extract it anywhere:
1. [Download](https://github.com/spoopyghosty0/frameport/releases/latest) and unzip the build for Windows, macOS
(Apple Silicon) or Linux, then start FramePort.
2. **Connect the Frame** (once):
1. In FramePort click **Steam Frame → Show setup command**. Keep FramePort open; the Frame and your computer must
be on the same Wi-Fi.
2. On the Frame 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.
| Computer | File | Start |
|---|---|---|
| Windows 10/11 (x64) | `FramePort-windows-x64.zip` | `FramePort.exe` |
| macOS (Apple Silicon) | `FramePort-macos-arm64.zip` | `FramePort.app` |
| Linux (x64) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
Full guide, firewalls and troubleshooting: [docs/INSTALL.md](docs/INSTALL.md).
The builds are self-signed, so the first start shows a warning: Windows → **More info → Run anyway**;
macOS → right-click the app → **Open**. Details: [docs/INSTALL.md](docs/INSTALL.md).
2. **Get the tools**: on first start FramePort downloads what it needs (Java runtime, OVRPort, apksigner) into its own
folder. Nothing is installed system-wide.
3. **Connect the Frame**: turn on Developer Mode on the Frame (Settings → System → Developer). In FramePort open
**Steam Frame**; if the Frame isn't listed, switch it to Desktop mode, open Konsole and paste the one command
FramePort shows. This is needed once.
4. **Add games**: **Add games → Scan a folder** with your game backups: Android/Quest games (APK + OBB) or PC VR
game folders.
FramePort identifies each game and fetches its artwork and store details.
5. **Install**: open a game and click **Install on Frame**. FramePort patches, checks, uploads and adds the game to the
Frame's Steam library, then runs a short launch test. Several installs queue up; if the Frame goes to sleep or
drops off the Wi-Fi, the queue waits and continues where it stopped (FramePort keeps the Frame awake meanwhile).
Your game files are never changed: the converted copy is temporary.
6. **Play**: put the headset on and start the game from the Steam library (or click **Play on Frame**).
## Type on Frame
![Game page](docs/images/game.png)
Typing in VR is painful, so FramePort turns your computer's keyboard into a keyboard for the Frame. Open **Type on
Frame** (keyboard icon on the sidebar's Frame card, the Steam Frame page, or a game's menu), select a text field in
the headset and type: searches, logins, chat, in any app, in Steam or on the desktop. Paste longer text to type it in
one go. Nothing to install: FramePort adds a virtual keyboard on the Frame while the window is open, without root.
Each game has **Game settings** in plain words (sharpness, refresh rate, controllers, menus, 360° video, mixed
reality), showing only what matters for that game. Changes are kept with the game and reach the Frame right away.
![Type on Frame](docs/images/type-on-frame.png)
![Game settings](docs/images/game-settings.png)
Unity apps whose text fields close the moment you select them on the Frame (no system keyboard there) get a per-game
fix, so Steam's on-screen keyboard and Type on Frame work in them too.
[Details](docs/INSTALL.md#typing-on-the-frame).
FramePort updates itself: when a new version is released, the Library shows **Update now**.
## Compatibility
## What works
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)
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!
## 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 |
| [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.
+205 -12
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 = 30
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(),
}
@@ -761,6 +801,28 @@ def backup_vdf(vdf_path):
pass
def remove_tree(path):
"""Remove a file or folder tree, including what Lepton's containers leave in a game's data: overlayfs work dirs
with mode 000 (and whiteout device files in them), which shutil.rmtree(ignore_errors=True) silently skipped, so
"remove saves" left lepton-data behind. Last resort: podman unshare (files owned by the container's user ids)."""
if not os.path.lexists(path):
return
if os.path.islink(path) or not os.path.isdir(path):
os.remove(path)
return
for root, dirs, _files in os.walk(path): # top-down: fix a folder's mode before walking into it
for d in dirs:
p = os.path.join(root, d)
if not os.path.islink(p):
try:
os.chmod(p, 0o700)
except OSError:
pass
shutil.rmtree(path, ignore_errors=True)
if os.path.lexists(path) and shutil.which("podman"):
run(["podman", "unshare", "rm", "-rf", path])
def grid_files(grid, appid):
"""A shortcut's grid artwork (<appid>p.jpg, <appid>_hero.png, …): exact names, never another appid that merely
starts with the same digits."""
@@ -1887,22 +1949,22 @@ def cmd_uninstall(args):
for name in names:
p = os.path.join(base, name)
if os.path.isdir(p):
shutil.rmtree(p, ignore_errors=True)
remove_tree(p)
elif os.path.exists(p):
os.remove(p)
if not keep_data:
shutil.rmtree(base, ignore_errors=True)
remove_tree(base)
anchor = os.path.join(ANCHORS, pkg)
removed_sc = False
if args.get("remove_shortcut") and len(steam_users()) == 1:
# Steam keeps its own copy of shortcuts.vdf and writes it back: change it only with Steam closed (worker)
removed_sc = cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"', "appid": dep.get("appid")}]})["started"]
if not keep_data or base != anchor:
shutil.rmtree(anchor, ignore_errors=True)
remove_tree(anchor)
else: # saves live next to the launcher (Quest games): keep them, drop what marks the game as installed
for name in ("deployment.json", "launch.sh", "artwork", "launch.log", "launch-test.log"):
p = os.path.join(anchor, name)
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else (os.path.exists(p) and os.remove(p))
remove_tree(p)
return {"removed": True, "kept_saves": keep_data, "shortcut_removed": removed_sc}
@@ -2183,6 +2245,16 @@ def purge_worker(payload):
os.remove(art)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{d.get('title')}: {exc}")
try: # shortcuts left from games without an install record (older versions, interrupted removals)
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
for sc in list(root.get("shortcuts", {}).values()):
exe = sc.get("Exe", "") if isinstance(sc, dict) else ""
if exe.startswith(f'"{ANCHORS}/') and remove_shortcut(vdf, exe):
result["removed"].append(f"Steam shortcut: {sc.get('AppName') or sc.get('appname')}")
for art in grid_files(grid, sc.get("appid", 0) & 0xFFFFFFFF):
os.remove(art)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"shortcuts: {exc}")
for d in games:
base = d["base"]
saves = [os.path.join(base, n) for n in ("lepton-data", "compatdata")]
@@ -2190,23 +2262,30 @@ def purge_worker(payload):
for name in os.listdir(base) if os.path.isdir(base) else []:
p = os.path.join(base, name)
if p not in saves:
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else os.remove(p)
remove_tree(p)
result["kept"].append(base)
else:
shutil.rmtree(base, ignore_errors=True)
remove_tree(base)
if not keep and os.path.lexists(base):
result["errors"].append(f"couldn't remove {base}")
result["removed"].append(d.get("title") or d["package"])
if not keep or not result["kept"]:
shutil.rmtree(ANCHORS, ignore_errors=True)
remove_tree(ANCHORS)
else: # anchors hold only launchers/artwork; saves live in the bases
for d in games:
anchor = os.path.join(ANCHORS, d["package"])
if os.path.realpath(anchor) not in [os.path.realpath(k) for k in result["kept"]]:
shutil.rmtree(anchor, ignore_errors=True)
shutil.rmtree(AGENT_HOME, ignore_errors=True)
remove_tree(anchor)
remove_tree(AGENT_HOME)
result["removed"].append(AGENT_HOME)
if os.path.exists(XR_LAYER_MANIFEST):
os.remove(XR_LAYER_MANIFEST)
result["removed"].append(XR_LAYER_MANIFEST)
for name in ("frameport-setup.sh", "frameport-setup.log"): # left by bootstrap.sh
p = os.path.join(HOME, ".cache", name)
if os.path.exists(p):
os.remove(p)
result["removed"].append(p)
except Exception as exc: # noqa: BLE001
result["state"] = "failed"
result["errors"].append(str(exc))
@@ -2235,7 +2314,7 @@ def cmd_cleanup(args):
p = os.path.join(base, name)
if os.path.isdir(p):
freed += sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs)
shutil.rmtree(p, ignore_errors=True)
remove_tree(p)
removed.append(p)
elif os.path.exists(p):
freed += os.path.getsize(p)
@@ -2250,15 +2329,129 @@ def cmd_cleanup(args):
if os.path.exists(p):
freed += (sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(p) for f in fs)
if os.path.isdir(p) else os.path.getsize(p))
shutil.rmtree(p, ignore_errors=True) if os.path.isdir(p) else os.remove(p)
remove_tree(p)
removed.append(p)
return {"removed": removed, "freed_bytes": freed}
# ------------------------------------------------------------------------------------------ virtual keyboard
# "Type on Frame": the PC's key presses become a real keyboard on the Frame (Linux uinput; the steamos user may open
# /dev/uinput, an ACL entry made for Steam Input, no root). A real input device reaches everything that has focus:
# Android windows (Lepton's wayland_keyboard), the Steam UI, the desktop, Proton games.
UINPUT = "/dev/uinput"
UI_SET_EVBIT, UI_SET_KEYBIT, UI_DEV_SETUP, UI_DEV_CREATE, UI_DEV_DESTROY = (0x40045564, 0x40045565, 0x405C5503,
0x5501, 0x5502)
EV_SYN, EV_KEY, SYN_REPORT = 0, 1, 0
KEY_LAST = 248 # KEY_ESC (1) .. KEY_MICMUTE (248): every key a PC keyboard sends
KEY_LEFTSHIFT = 42
def text_keys():
"""US layout: character -> (Linux key code, shift)."""
keys = {"\n": (28, False), "\t": (15, False), " ": (57, False), "\b": (14, False)}
for plain, shifted, first in (("1234567890-=", "!@#$%^&*()_+", 2), ("qwertyuiop[]", "QWERTYUIOP{}", 16),
("asdfghjkl;'`", 'ASDFGHJKL:"~', 30), ("\\zxcvbnm,./", "|ZXCVBNM<>?", 43)):
for i, (a, b) in enumerate(zip(plain, shifted, strict=True)):
keys[a] = (first + i, False)
keys[b] = (first + i, True)
return keys
TEXT_KEYS = text_keys()
class VirtualKeyboard:
"""A uinput keyboard. `fd`/`write` can be replaced in tests; the device goes away with close()."""
def __init__(self, fd=None, write=os.write, settle=0.8):
self.write, self.held = write, set()
self.fd = fd
if fd is None:
self.fd = os.open(UINPUT, os.O_WRONLY | os.O_NONBLOCK)
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
for code in range(1, KEY_LAST + 1):
fcntl.ioctl(self.fd, UI_SET_KEYBIT, code)
fcntl.ioctl(self.fd, UI_DEV_SETUP, struct.pack("HHHH80sI", 0x03, 0x1209, 0x4650, 1,
b"FramePort keyboard", 0))
fcntl.ioctl(self.fd, UI_DEV_CREATE)
time.sleep(settle) # let gamescope/libinput pick the new keyboard up before the first key
def _event(self, kind, code, value):
self.write(self.fd, struct.pack("llHHi", 0, 0, kind, code, value))
def key(self, code, value):
"""value 1 = down, 0 = up, 2 = autorepeat."""
if not 0 < int(code) <= KEY_LAST or value not in (0, 1, 2):
return
self._event(EV_KEY, int(code), value)
self._event(EV_SYN, SYN_REPORT, 0)
(self.held.add if value else self.held.discard)(int(code))
def type_text(self, text, delay=0.008):
"""Type characters of the US layout; returns the characters it couldn't type."""
skipped = ""
for ch in text.replace("\r\n", "\n"):
if ch not in TEXT_KEYS:
skipped += ch
continue
code, shift = TEXT_KEYS[ch]
if shift:
self.key(KEY_LEFTSHIFT, 1)
self.key(code, 1)
self.key(code, 0)
if shift:
self.key(KEY_LEFTSHIFT, 0)
time.sleep(delay)
return skipped
def close(self):
for code in list(self.held): # never leave a key stuck when the PC goes away mid-press
self.key(code, 0)
if self.fd is not None and isinstance(self.fd, int):
try:
fcntl.ioctl(self.fd, UI_DEV_DESTROY)
except OSError:
pass
os.close(self.fd)
self.fd = None
def keyboard_session(stdin, stdout, keyboard=None):
"""Long-lived: prints {"ready": true} once the virtual keyboard exists, then reads one JSON object per line:
{"k": code, "v": 1|0|2} (key down/up/repeat) or {"text": "..."}; ends (keyboard removed) at EOF."""
try:
kb = keyboard or VirtualKeyboard()
except OSError as exc:
stdout.write(json.dumps({"ready": False, "error": f"can't create a virtual keyboard: {exc}"}) + "\n")
stdout.flush()
return 1
stdout.write(json.dumps({"ready": True}) + "\n")
stdout.flush()
try:
for line in stdin:
try:
msg = json.loads(line)
except ValueError:
continue
if "k" in msg:
kb.key(msg["k"], msg.get("v", 1))
elif "text" in msg:
skipped = kb.type_text(str(msg["text"]))
stdout.write(json.dumps({"typed": True, "skipped": skipped}) + "\n")
stdout.flush()
except (OSError, ValueError):
pass
finally:
kb.close()
return 0
COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
def main():
if len(sys.argv) >= 2 and sys.argv[1] == "_keyboard":
return keyboard_session(sys.stdin, sys.stdout)
if len(sys.argv) >= 3 and sys.argv[1] == "_shortcuts_worker":
shortcuts_worker(sys.argv[2])
return 0
+109 -35
View File
@@ -1,59 +1,133 @@
#!/usr/bin/env bash
# FramePort one-time Steam Frame setup. Run in the Frame's Desktop Mode terminal (Konsole):
# curl -fsSL http://<pc>:<port>/bootstrap.sh | bash
# curl -fsS <pc-ip>:<port>/<code> | bash
# (the FramePort app shows the exact line and serves this script while its Setup page is open).
#
# What it does (asks before anything that needs sudo):
# 1. sets a password for the 'steamos' user if it has none (sudo needs one)
# 2. enables the SSH server (sshd)
# 3. authorizes the FramePort app's SSH key (no password needed afterwards)
# 4. announces this Frame on the local network (avahi service "_frameport._tcp") so the app finds it
# 5. checks for Valve's Lepton Android runtime and asks Steam to install it if missing
# What it does (no password, no sudo):
# 1. authorizes the FramePort app's SSH key (no password needed afterwards)
# 2. configures podman for Lepton (avoids a kernel keyring leak after ~200 game starts)
# 3. turns on Developer Mode if it's off (Valve's own helper; it also enables the SSH server and announces the
# Frame on the network as a SteamOS devkit). Steam restarts for that, which closes Desktop Mode: the rest
# runs on its own as a user service and the Frame returns to its normal view.
# 4. asks Steam to install Valve's Lepton Android runtime if it's missing, then tells the app it's done
# Log of the part that runs on its own: ~/.cache/frameport-setup.log
set -euo pipefail
PC_URL="__PC_URL__" # filled in by the app when serving the script
PAIR_CODE="__PAIR_CODE__"
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
DEVKIT_HELPER=/usr/bin/steamos-polkit-helpers/steamos-devkit-mode
STEAM_CONFIG=~/.local/share/Steam/config/config.vdf
JOB=~/.cache/frameport-setup.sh
LOG=~/.cache/frameport-setup.log
# Desktop Mode on SteamOS is a nested Plasma session inside steam.service, with its own XDG_RUNTIME_DIR and D-Bus:
# systemctl/systemd-run --user only reach the user's systemd through the real runtime dir.
user_systemd() {
XDG_RUNTIME_DIR="/run/user/$(id -u)" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus" "$@"
}
say "FramePort setup for $(hostname) ($(. /etc/os-release; echo "$NAME $VERSION_ID"))"
if ! passwd -S "$USER" 2>/dev/null | grep -q ' P '; then
say "Your user has no password yet. SteamOS needs one for sudo (and as an SSH fallback)."
passwd
fi
say "Enabling the SSH server (sudo)"
sudo systemctl enable --now sshd
say "Authorizing the FramePort app's key"
key=$(curl -fsSL "$PC_URL/key?code=$PAIR_CODE")
key=$(curl -fsS "$PC_URL/key?code=$PAIR_CODE")
[[ "$key" == ssh-ed25519\ * ]] || { echo "Could not fetch the app's key from $PC_URL (is the app still open?)"; exit 1; }
mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
grep -qxF "$key" ~/.ssh/authorized_keys || echo "$key" >> ~/.ssh/authorized_keys
say "Announcing this Frame on the network (avahi)"
svc="<?xml version=\"1.0\" standalone='no'?>
<!DOCTYPE service-group SYSTEM \"avahi-service.dtd\">
<service-group>
<name replace-wildcards=\"yes\">FramePort on %h</name>
<service><type>_frameport._tcp</type><port>22</port><txt-record>user=$USER</txt-record><txt-record>pair=$PAIR_CODE</txt-record></service>
</service-group>"
echo "$svc" | sudo tee /etc/avahi/services/frameport.service >/dev/null || true
sudo systemctl enable --now avahi-daemon >/dev/null 2>&1 || true
sudo systemctl reload avahi-daemon >/dev/null 2>&1 || true
say "Configuring podman for Lepton"
# rootless podman leaks one kernel keyring per container start; ~200 game launches would exhaust the quota
mkdir -p ~/.config/containers
grep -qs '^ *keyring *=' ~/.config/containers/containers.conf || printf '[containers]\nkeyring = false\n' >> ~/.config/containers/containers.conf
say "Checking for Lepton (Valve's Android runtime)"
if ls ~/.local/share/Steam/steamapps/common/Lepton/lepton >/dev/null 2>&1; then
echo "Lepton found."
# The rest: Developer Mode (if needed), Lepton, telling the app. As a file, so it can run as its own user service:
# stopping Steam ends Desktop Mode and every program started in it, this terminal included.
mkdir -p ~/.cache
cat >"$JOB" <<'JOB'
set -u
MODE=$1 PC_URL=$2 PAIR_CODE=$3 CONFIG=$4 HELPER=$5
log() { echo "$(date +%T) $*"; }
if [[ $MODE == devmode ]]; then
sleep 5 # let the user read the terminal before Desktop Mode closes
# Steam re-applies "DevModeEnabled" from config.vdf at every start (calling Valve's helper) and rewrites
# config.vdf when it exits: stop Steam, set the value, run the helper (pkexec, allowed without a password), start.
log "stopping Steam"
systemctl --user stop steam.service
for _ in $(seq 40); do pgrep -x steam >/dev/null || break; sleep 1; done
if pgrep -x steam >/dev/null; then
log "Steam didn't stop"
systemctl --user start steam.service
exit 1
fi
cp "$CONFIG" "$CONFIG.before-frameport"
python3 - "$CONFIG" <<'PY'
import re, sys
path = sys.argv[1]
text = open(path, encoding="utf-8").read()
if re.search(r'"DevModeEnabled"\s+"\d+"', text):
text = re.sub(r'("DevModeEnabled"\s+)"\d+"', r'\1"1"', text)
else: # Steam keeps it in InstallConfigStore/developer
lines, keys, key, at = text.splitlines(keepends=True), [], None, None
for i, line in enumerate(lines):
s = line.strip()
if s == "{":
keys.append((key or "").lower())
if keys == ["installconfigstore", "developer"]:
at = ("in", i, line[:len(line) - len(line.lstrip())] + "\t")
break
elif s == "}":
if keys == ["installconfigstore"]: # end of the top section without a developer block
at = ("new", i, line[:len(line) - len(line.lstrip())] + "\t")
break
keys.pop()
elif re.fullmatch(r'"[^"]*"', s):
key = s.strip('"')
if at is None:
sys.exit("unexpected config.vdf layout")
kind, i, ind = at
if kind == "in":
lines.insert(i + 1, f'{ind}"DevModeEnabled"\t\t"1"\n')
else:
lines[i:i] = [f'{ind}"developer"\n', f"{ind}{{\n", f'{ind}\t"DevModeEnabled"\t\t"1"\n', f"{ind}}}\n"]
text = "".join(lines)
open(path, "w", encoding="utf-8").write(text)
PY
rc=$?
[[ $rc == 0 ]] && { "$HELPER" --enable; rc=$?; }
log "Developer Mode: $([[ $rc == 0 && -f /etc/steamos-devkit-enabled ]] && echo on || echo "failed ($rc)")"
systemctl --user start steam.service
[[ $rc == 0 ]] || exit 1
for _ in $(seq 90); do pgrep -x steam >/dev/null && break; sleep 1; done
sleep 20 # let Steam finish starting before asking it for anything
fi
if [[ -e ~/.local/share/Steam/steamapps/common/Lepton/lepton ]]; then
log "Lepton found"
else
echo "Lepton is missing. Make sure Developer Mode is on (Settings > System > Developer)."
echo "Asking Steam to install it now; confirm in Steam, or launch 'Lepton Development' from your library once."
(steam steam://install/3029110 >/dev/null 2>&1 &) || true
log "asking Steam to install Lepton (confirm it in Steam)"
steam steam://install/3029110 >/dev/null 2>&1 &
fi
if curl -fsS "$PC_URL/paired?code=$PAIR_CODE&user=$(id -un)&host=$(hostname)" >/dev/null; then
log "told FramePort: done"
else
log "couldn't reach FramePort at $PC_URL"
fi
JOB
if [[ -f /etc/steamos-devkit-enabled ]]; then
say "Developer Mode is on"
bash "$JOB" finish "$PC_URL" "$PAIR_CODE" "$STEAM_CONFIG" "$DEVKIT_HELPER" 2>&1 | tee "$LOG"
say "Done. Return to FramePort on your PC: this Frame should now appear as connected."
exit 0
fi
curl -fsS "$PC_URL/paired?code=$PAIR_CODE&user=$USER&host=$(hostname)" >/dev/null 2>&1 || true
say "Done. Return to FramePort on your PC: this Frame should now appear as connected."
say "Turning on Developer Mode"
if [[ ! -x "$DEVKIT_HELPER" || ! -f "$STEAM_CONFIG" ]] || \
! user_systemd systemd-run --user --collect --quiet --unit="frameport-setup-$$" \
bash -c 'bash "$0" "$@" >"$HOME/.cache/frameport-setup.log" 2>&1' \
"$JOB" devmode "$PC_URL" "$PAIR_CODE" "$STEAM_CONFIG" "$DEVKIT_HELPER"; then
echo "Couldn't turn it on automatically. Turn it on in Settings > System > Developer, then run this command again."
exit 1
fi
echo "Steam restarts to turn it on. That closes Desktop Mode in a few seconds and the Frame returns to its"
echo "normal view; setup finishes on its own. If Steam asks to install Lepton, confirm it."
echo "Then return to FramePort on your PC: the Frame appears as connected within a minute."
+20
View File
@@ -0,0 +1,20 @@
package: com.stremio.vrone
title: Stremio
status: issues
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). Known issue: the add-ons page stays 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.
+29
View File
@@ -0,0 +1,29 @@
# Compatibility
The built-in catalog has tested settings for 39 games (27 work, 6 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](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 -6
View File
@@ -26,12 +26,32 @@ start shows a warning:
## Connecting the Steam Frame
1. On the Frame: Settings → System → Developer → turn on **Developer Mode**. The Frame and the computer must be on the
same network.
2. In FramePort open **Steam Frame**. A Frame in Developer Mode appears in the list.
3. First time only: on the Frame switch to Desktop mode (Steam button → Power → Switch to Desktop), open Konsole and
run the command FramePort shows. It enables SSH, authorises this computer and installs Valve's Android runtime
(Lepton) if needed.
The Frame and the computer must be on the same network.
1. In FramePort open **Steam Frame** and click **Show setup command**.
2. First time only, on the Frame:
1. 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.
### Firewalls
If the setup command only says "timed out", a firewall on your computer blocks the Frame; the setup page
says which after about 45 seconds. Details per system: [FRAME_SETUP.md](FRAME_SETUP.md#network-and-firewalls).
## Installing games
@@ -50,6 +70,26 @@ start shows a warning:
- Ordinary Android apps (no VR) are installed unchanged and shown as a flat window in the headset. Android's
back/home/recents buttons are hidden by default (patch **Hide Android's navigation bar**).
![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`.
Full guide: [docs/INSTALL.md](https://github.com/spoopyghosty0/frameport/blob/main/docs/INSTALL.md) · checksums in
`SHA256SUMS.txt`.
+14 -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")
@@ -67,7 +77,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.4.0"
__version__ = "0.6.0"
+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"
+90
View File
@@ -287,3 +287,93 @@ def oculus_platform_dir() -> Path | None:
if (d / "LibOVRPlatform64_1.dll").is_file():
return d
return None
# ------------------------------------------------------------------------------------------ firewall (pairing)
WSL_VM_CREATOR = "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" # Hyper-V firewall id of WSL's VM
def powershell(command: str, timeout: float = 30) -> subprocess.CompletedProcess:
"""Run Windows PowerShell 5.1 (from Windows or WSL). PSModulePath is dropped: inherited from PowerShell 7 it keeps
5.1 from loading its own modules (see updates._powershell)."""
if is_windows():
exe = str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe")
cwd = None
else:
exe = system32("WindowsPowerShell/v1.0/powershell.exe")
cwd = "/mnt/c" if Path("/mnt/c").is_dir() else None # a \\wsl$ working dir makes Windows programs complain
env = {k: v for k, v in os.environ.items() if k.upper() != "PSMODULEPATH"}
return subprocess.run([exe, "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, text=True,
errors="replace", timeout=timeout, cwd=cwd, env=env, **_no_window())
def wsl_networking_mode() -> str | None:
""""mirrored" or "nat" (WSL's default, where nothing on the network can reach WSL at all); None if unknown."""
if not is_wsl():
return None
try:
out = subprocess.run(["wslinfo", "--networking-mode"], capture_output=True, text=True, timeout=10).stdout
if out.strip():
return out.strip().lower()
except (OSError, subprocess.SubprocessError):
pass
return None
def wsl_inbound_blocked(rule: str) -> bool:
"""WSL with mirrored networking: Windows' Hyper-V firewall blocks connections from the network to WSL by default
(no "Allow access?" prompt as for Windows programs), so a Frame's request to our pairing server just times out.
True when that block is on and our allow rule `rule` isn't there."""
if not is_wsl():
return False
try:
out = powershell(
f"$s = Get-NetFirewallHyperVVMSetting -PolicyStore ActiveStore -Name '{WSL_VM_CREATOR}';"
f"$r = Get-NetFirewallHyperVRule -PolicyStore ActiveStore -Name '{rule}' -ErrorAction SilentlyContinue;"
"\"$($s.Enabled) $($s.DefaultInboundAction) $([bool]$r)\"").stdout.split()
except (OSError, subprocess.SubprocessError):
return False
return out[:3] == ["True", "Block", "False"]
def open_wsl_inbound(rule: str, title: str, ports: str, flag: Path, minutes: int = 35) -> bool:
"""Let the network reach WSL on TCP `ports` only while `flag` exists (at most `minutes`): one admin prompt (UAC)
starts a hidden elevated PowerShell that adds a Hyper-V firewall rule, waits, and removes the rule again (only if
it added it). Returns True once the rule is in place."""
import base64
script = f"""
$name = '{rule}'
$added = $false
if (-not (Get-NetFirewallHyperVRule -PolicyStore ActiveStore -Name $name -ErrorAction SilentlyContinue)) {{
New-NetFirewallHyperVRule -Name $name -DisplayName '{title}' -Direction Inbound -VMCreatorId '{WSL_VM_CREATOR}' `
-Protocol TCP -LocalPorts {ports} -Action Allow | Out-Null
$added = $true
}}
$end = (Get-Date).AddMinutes({minutes})
while ((Test-Path -LiteralPath '{to_windows(flag)}') -and ((Get-Date) -lt $end)) {{ Start-Sleep -Seconds 2 }}
if ($added) {{ Remove-NetFirewallHyperVRule -Name $name -ErrorAction SilentlyContinue }}
"""
encoded = base64.b64encode(script.encode("utf-16-le")).decode()
try:
powershell("Start-Process powershell -Verb RunAs -WindowStyle Hidden "
f"-ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand','{encoded}'", timeout=300)
except (OSError, subprocess.SubprocessError):
return False
for _ in range(20): # the elevated script needs a moment to add the rule
if not wsl_inbound_blocked(rule):
return True
time.sleep(1)
return False
def network_category(ip: str) -> str | None:
"""Windows: the firewall profile of the network that has `ip` ("Public", "Private", "DomainAuthenticated")."""
if not is_windows():
return None
try:
out = powershell(f"(Get-NetConnectionProfile -InterfaceIndex (Get-NetIPAddress -IPAddress '{ip}' "
"-ErrorAction Stop).InterfaceIndex).NetworkCategory").stdout.strip()
except (OSError, subprocess.SubprocessError):
return None
return out or None
+40 -7
View File
@@ -1,7 +1,8 @@
"""SSH connection to a Steam Frame and the remote agent protocol.
Authentication: FramePort's own key (<user data>/ssh/id_ed25519, installed by the bootstrap script) first, then
the user's SSH agent/keys, then a password if given.
Authentication: FramePort's own keys first (<user data>/ssh/id_ed25519, installed by the bootstrap script, and
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then the user's SSH agent/keys, then a
password if given.
"""
from __future__ import annotations
@@ -18,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"
@@ -45,6 +46,37 @@ def app_public_key() -> str:
return (ssh_dir() / "id_ed25519.pub").read_text().strip()
def devkit_key() -> paramiko.RSAKey:
"""FramePort's RSA key for Valve's devkit pairing (frame/devkit.py): its service accepts only ssh-rsa keys."""
path = ssh_dir() / "id_rsa"
if not path.exists():
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
data = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH,
serialization.NoEncryption())
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(data)
pub = key.public_key().public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH)
(ssh_dir() / "id_rsa.pub").write_text(pub.decode() + " frameport\n")
return paramiko.RSAKey.from_private_key_file(str(path))
def devkit_public_key() -> str:
devkit_key()
return (ssh_dir() / "id_rsa.pub").read_text().strip()
def app_keys() -> list[paramiko.PKey]:
"""The keys FramePort logs in with: the Ed25519 key, plus the RSA key once devkit pairing created it."""
keys: list[paramiko.PKey] = [app_key()]
if (ssh_dir() / "id_rsa").exists():
keys.append(devkit_key())
return keys
@dataclass
class FrameTarget:
host: str
@@ -92,7 +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
@@ -233,7 +265,8 @@ class Frame:
kwargs = dict(hostname=self.target.host, port=self.target.port, username=self.target.user, timeout=timeout,
banner_timeout=timeout, auth_timeout=timeout)
errors = []
for attempt in ("app_key", "agent", "password"):
attempts = [("app_key", k) for k in app_keys()] + [("agent", None), ("password", None)]
for attempt, pkey in attempts:
if attempt == "password" and not self.password:
continue
client = paramiko.SSHClient() # a fresh client per attempt: a failed one keeps its transport otherwise
@@ -241,7 +274,7 @@ class Frame:
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # trust on first use (pairing)
try:
if attempt == "app_key":
client.connect(pkey=app_key(), allow_agent=False, look_for_keys=False, **kwargs)
client.connect(pkey=pkey, allow_agent=False, look_for_keys=False, **kwargs)
elif attempt == "agent":
client.connect(allow_agent=True, look_for_keys=True, **kwargs)
else:
@@ -336,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)
+51
View File
@@ -0,0 +1,51 @@
"""Valve's devkit pairing: with Developer Mode on, the Frame's steamos-devkit-service (TCP 32000) accepts an SSH public
key at POST /register while Steam is in pairing mode (Settings → Developer → Pair new host; otherwise it refuses at
once), asks in Steam to approve it, and Valve's root hook installs the key for the steamos user and enables sshd.
Every connection goes from the PC to the Frame, so no firewall on the PC is involved: the fallback when the Frame
can't reach FramePort's setup server. The service only accepts ssh-rsa keys
(connection.devkit_key)."""
from __future__ import annotations
import re
import urllib.error
import urllib.request
from .connection import devkit_public_key
PORT = 32000
APPROVE_TIMEOUT = 30 # Valve's approve hook waits this long for the answer in the headset
class PairingRefused(RuntimeError): # not a ConnectionError: the Frame is reachable, explain() keeps the message
pass
def available(host: str, timeout: float = 5) -> bool:
try:
with urllib.request.urlopen(f"http://{host}:{PORT}/login-name", timeout=timeout) as r:
return r.status == 200
except OSError:
return False
def register(host: str) -> None:
"""Send FramePort's RSA key; returns once it was approved in the headset and installed, raises PairingRefused."""
req = urllib.request.Request(f"http://{host}:{PORT}/register", data=devkit_public_key().encode(), method="POST")
try:
with urllib.request.urlopen(req, timeout=APPROVE_TIMEOUT + 30) as r:
body = r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace") # Valve's service mixes the hook's log lines into it
m = re.search(r'"error":\s*"([^"]*)"', body)
msg = (m.group(1) if m else (body.strip() or str(exc))).replace("\\n", " ").strip()
if "pairing mode" in msg.lower(): # Steam takes requests only while "Pair new host" is open
msg = "on the Frame open Settings → Developer → Pair new host first, then click Connect again"
elif "timeout" in msg.lower():
msg = f"it wasn't approved in the headset within {APPROVE_TIMEOUT} seconds"
elif "steam is not running" in msg.lower():
msg = "Steam isn't running on the Frame"
raise PairingRefused(f"The Frame didn't pair: {msg}") from None
except OSError as exc:
raise PairingRefused(f"Couldn't reach the Frame's pairing service (Developer Mode on?): {exc}") from None
if "Registered" not in body:
raise PairingRefused(f"The Frame didn't pair: {body.strip()}")
+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
+96 -8
View File
@@ -2,7 +2,9 @@
the LAN. The user types one line on the Frame; the script calls back /paired so the UI knows who connected.
Every request needs the pairing code, a random secret that is only in that one line. The server stops after a
successful pairing, after MAX_FAILURES wrong codes (someone guessing) and after LIFETIME seconds."""
successful pairing, after MAX_FAILURES wrong codes (someone guessing) and after LIFETIME seconds, so a short code
(32 bits) is plenty. The line is typed by hand on the Frame, so it's kept short: no scheme (curl defaults to http),
the code is the path, and a fixed port when it's free."""
from __future__ import annotations
import http.server
@@ -10,6 +12,7 @@ import secrets
import threading
import urllib.parse
from dataclasses import dataclass, field
from pathlib import Path
from ..core.paths import bootstrap_dir
from .connection import app_public_key
@@ -17,15 +20,88 @@ from .discovery import local_ip_towards
MAX_FAILURES = 20
LIFETIME = 30 * 60
PORTS = (8765, 8766, 8767, 0) # 0 = any free port
FIREWALL_RULE = "FramePort-Pairing"
HINT_AFTER = 45 # seconds without any request from the Frame before the UI suggests what may block it
def ensure_reachable(server: PairingServer) -> str:
"""Under WSL, let the Frame reach the pairing ports through Windows' Hyper-V firewall while `server` runs (one
admin prompt; the rule is removed again when the server stops, see winhost.open_wsl_inbound).
Returns "ok" (nothing blocks), "opened" or "failed" (declined / no admin)."""
from ..core import winhost
if not winhost.wsl_inbound_blocked(FIREWALL_RULE):
return "ok"
temp = winhost.env_path("TEMP")
if temp is None:
return "failed"
server.flag = temp / f"frameport-pairing-{server.code}.flag"
try:
server.flag.write_text("FramePort's setup page is open: the firewall rule stays while this file exists\n")
except OSError:
return "failed"
ports = f"{PORTS[0]}-{PORTS[-2]}"
return "opened" if winhost.open_wsl_inbound(FIREWALL_RULE, "FramePort setup (WSL, temporary)", ports,
server.flag) else "failed"
def firewall_hint(port: int) -> str:
"""What may keep the Frame from reaching this PC, for this OS (shown when no request came in after HINT_AFTER).
Empty when nothing specific is known."""
import platform
import shutil
import subprocess
from ..core import winhost
from ..i18n import tr
def out(*cmd) -> str:
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.lower()
except (OSError, subprocess.SubprocessError):
return ""
if winhost.is_wsl():
if winhost.wsl_networking_mode() == "nat":
return tr("FramePort runs in WSL with its default NAT network, which other devices can't reach. Set "
"networkingMode=mirrored under [wsl2] in %UserProfile%\\.wslconfig, run wsl --shutdown and "
"start FramePort again.")
if winhost.wsl_inbound_blocked(FIREWALL_RULE):
return tr("Windows' firewall for WSL blocks the Frame. Open the setup command again and allow the "
"change when Windows asks (admin).")
return ""
if winhost.is_windows():
if winhost.network_category(local_ip_towards()) == "Public":
return tr("Windows treats this network as public and its firewall blocks the Frame. In Windows' "
"network settings set this network to Private (or allow FramePort on public networks when "
"Windows asks).")
return tr("If Windows asked whether FramePort may use the network, allow it (Private networks).")
if platform.system() == "Darwin":
fw = out("/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate")
if "enabled" in fw:
return tr("macOS' firewall is on: allow incoming connections when macOS asks about FramePort (or "
"System Settings → Network → Firewall → Options).")
return ""
if shutil.which("firewall-cmd") and "running" in out("firewall-cmd", "--state"):
return tr("firewalld is active. Allow the setup port until the next restart with: "
"sudo firewall-cmd --add-port={port}/tcp").format(port=port)
if "active" == out("systemctl", "is-active", "ufw").strip():
return tr("ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (and afterwards: "
"sudo ufw delete allow {port}/tcp)").format(port=port)
return ""
@dataclass
class PairingServer:
port: int = 0
code: str = field(default_factory=lambda: secrets.token_hex(8))
code: str = field(default_factory=lambda: secrets.token_hex(4))
failures: int = 0
paired: list[dict] = field(default_factory=list)
on_paired: object = None
requests: int = 0 # requests from the network (right or wrong code): the Frame can reach us
flag: Path | None = None # WSL: the temporary firewall rule stays while this file exists
hint: str = "" # set by the UI when nothing reached us after HINT_AFTER seconds: what may block the Frame
_httpd: http.server.ThreadingHTTPServer | None = None
@property
@@ -34,7 +110,7 @@ class PairingServer:
@property
def one_liner(self) -> str:
return f"curl -fsSL {self.url}/bootstrap.sh?code={self.code} | bash"
return f"curl -fsS {self.url.removeprefix('http://')}/{self.code} | bash"
def start(self) -> PairingServer:
server = self
@@ -51,20 +127,24 @@ class PairingServer:
self.wfile.write(body)
def do_GET(self):
server.requests += 1
url = urllib.parse.urlparse(self.path)
q = dict(urllib.parse.parse_qsl(url.query))
if not secrets.compare_digest(q.get("code", ""), server.code):
path, code = url.path, q.get("code", "")
if not code and path.count("/") == 1: # the short form: /<code> = the script
path, code = "/bootstrap.sh", path[1:]
if not secrets.compare_digest(code, server.code):
server.failures += 1
if server.failures >= MAX_FAILURES:
server.stop_soon()
return self._send(b"wrong or missing pairing code\n", status=403)
if url.path == "/bootstrap.sh":
if path == "/bootstrap.sh":
text = (bootstrap_dir() / "bootstrap.sh").read_text()
text = text.replace("__PC_URL__", server.url).replace("__PAIR_CODE__", server.code)
return self._send(text.encode(), "text/x-shellscript")
if url.path == "/key":
if path == "/key":
return self._send((app_public_key() + "\n").encode())
if url.path == "/paired":
if path == "/paired":
info = {"host": self.client_address[0], "user": q.get("user", "steamos"), "name": q.get("host", "")}
server.paired.append(info)
if callable(server.on_paired):
@@ -73,7 +153,13 @@ class PairingServer:
return self._send(b"ok\n")
return self._send(b"not found\n", status=404)
self._httpd = http.server.ThreadingHTTPServer(("0.0.0.0", self.port), Handler)
for port in ((self.port,) if self.port else PORTS):
try:
self._httpd = http.server.ThreadingHTTPServer(("0.0.0.0", port), Handler)
break
except OSError:
if port == PORTS[-1]:
raise
self.port = self._httpd.server_address[1]
threading.Thread(target=self._httpd.serve_forever, daemon=True).start()
self._timer = threading.Timer(LIFETIME, self.stop)
@@ -97,3 +183,5 @@ class PairingServer:
timer = getattr(self, "_timer", None)
if timer:
timer.cancel()
if self.flag is not None: # lets the temporary firewall rule go
self.flag.unlink(missing_ok=True)
+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']})")
+44 -4
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": "",
@@ -147,6 +150,7 @@
"Check for new versions automatically": "",
"Check for updates": "",
"Check game files": "",
"Check that the Frame and this computer are on the same network.": "",
"Checking this PC…": "",
"Checking tools…": "",
"Chooses which Proton version runs the game on the Frame.": "",
@@ -157,6 +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": "",
@@ -173,6 +178,7 @@
"Connected": "",
"Connected to {label}": "",
"Connected to {label}.": "",
"Connecting the keyboard…": "",
"Connecting to {value}…": "",
"Connecting…": "",
"Continue": "",
@@ -207,7 +213,7 @@
"Delete {names}?": "",
"Deletes the rollback copies kept from each game's previous install and leftover uploads. The games and saves stay.": "",
"Details": "",
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH.": "",
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you.": "",
"Didn't finish ({value}): {error}": "",
"Disable application space warp if used": "",
"Disable controller tracking offset": "",
@@ -266,6 +272,7 @@
"Find artwork and store details": "",
"Find artwork…": "",
"Find automatically": "",
"firewalld is active. Allow the setup port until the next restart with: sudo firewall-cmd --add-port={port}/tcp": "",
"First-time setup": "",
"Fix launcher icon entry": "",
"Fix minimal Android SDK": "",
@@ -287,6 +294,8 @@
"Force enable passthrough": "",
"Found": "",
"Frame agent": "",
"Frame battery: charging": "",
"Frame battery: not charging": "",
"Frame OpenXR compatibility layer": "",
"FrameBridge OpenXR adapter": "",
"FramePort": "",
@@ -298,6 +307,7 @@
"FramePort installs games on the Frame over your network.": "",
"FramePort manages its own copies; nothing is installed system-wide": "",
"FramePort picked {value} to start this game, but there are other candidates. Check it before installing.": "",
"FramePort runs in WSL with its default NAT network, which other devices can't reach. Set networkingMode=mirrored under [wsl2] in %UserProfile%\\.wslconfig, run wsl --shutdown and start FramePort again.": "",
"FramePort saves a diagnostics zip (logs, recipe, device info; no game files, personal data removed) and opens a prefilled GitHub issue. Drag the zip into it, check the text, submit.": "",
"FramePort updates and restarts after the current job": "",
"FramePort was removed": "",
@@ -360,6 +370,7 @@
"How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" come from recipes tested on a real Frame. \"Untested\" means FramePort suggested a recipe from the game's engine and VR API that nobody has confirmed yet. \"Can't run\" means there's a known blocker (for example a 32-bit-only game).": "",
"I played it in the headset with this recipe": "",
"If the pointer doesn't hit what you aim at, tilt it up or down.": "",
"If Windows asked whether FramePort may use the network, allow it (Private networks).": "",
"If you know the Frame's address.": "",
"In your Steam library": "",
"In your Steam library · launch settings changed — update it": "",
@@ -392,7 +403,7 @@
"It has a known blocker on the Steam Frame.": "",
"It has issues: share…": "",
"it stopped at '{value}'": "",
"It turns on SSH, trusts this app, makes the Frame findable on your network and installs Lepton if needed. You only do this once.": "",
"It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on Developer Mode closes Desktop Mode; the setup finishes on its own and FramePort connects by itself. You only do this once.": "",
"It was removed from the library.": "",
"It works: share…": "",
"Java runtime": "",
@@ -403,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": "",
@@ -422,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.": "",
@@ -439,6 +455,8 @@
"Looking for your Frame…": "",
"Looking up the store description and screenshots…": "",
"Low": "",
"macOS' firewall is on: allow incoming connections when macOS asks about FramePort (or System Settings → Network → Firewall → Options).": "",
"Make Unity text fields work without a system keyboard": "",
"Makes Meta's 3D audio library work outside Quest headsets.": "",
"Makes sure the game has an entry that can be started.": "",
"Makes the game start from the Frame's Steam library.": "",
@@ -475,7 +493,7 @@
"No activity": "",
"No artwork could be downloaded from that {source} result; the current artwork stays. Try another one.": "",
"No folders to rescan yet: add games with Scan a folder": "",
"No Frames found. Turn on Developer Mode on the Frame (Settings → System → Developer) and make sure it's on the same network, or use first-time setup below.": "",
"No Frames found. New Frame? Use first-time setup below. Otherwise make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
"No games found in that folder": "",
"No limit": "",
"No matching files": "",
@@ -494,6 +512,7 @@
"Not verified on many games yet: try it if the game doesn't work without it.": "",
"Notes (what you checked, known issues)": "",
"Nothing found. Try a shorter or different name.": "",
"Nothing has reached FramePort from the Frame yet (curl says “timed out”)?": "",
"Nothing installed yet. Pick a game in the Library and click Install.": "",
"Nothing running. Installs, launch tests and downloads show up here.": "",
"Nothing to install": "",
@@ -526,6 +545,8 @@
"Opens a prefilled GitHub issue with this game's recipe, so it can join the built-in catalog (no account token needed; you review and submit it on GitHub). No game files or personal data are sent.": "",
"Opens this player's storage on the Frame (Files tab): upload videos into the folder it lists": "",
"OpenXR runtime": "",
"Or paste text to type it in one go": "",
"Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows up 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).": "",
"OVRPort converts Quest games from Meta's own VR APIs to standard OpenXR. These are its optional patches.": "",
@@ -535,6 +556,7 @@
"OVRPort's own converter for games that use Meta's oldest VR interface (experimental).": "",
"OVRPort's own VrApi→OpenXR adapter for engines that call libvrapi.so directly: the same upstream code as FramePort's 'VrApi → OpenXR bridge' without its Frame-specific changes. Only in OVRPort's experimental CLI builds (the stable CLI lists it but can't apply it).": "",
"Package": "",
"Pairing: on the Frame open Settings → Developer → Pair new host, then approve FramePort.": "",
"Password (first time only)": "",
"Patch Meta XR Audio": "",
"Patch Oculus detection for Unity": "",
@@ -544,6 +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)": "",
@@ -716,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.": "",
@@ -780,15 +804,18 @@
"Test Proton on the Frame": "",
"Text and layout size": "",
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
"The command fetches a small setup script from this app over your local network. It turns on SSH, lets this app's key in, makes the Frame findable and installs Lepton if needed.": "",
"The command fetches a small setup script from this app over your local network. It turns on Developer Mode (which includes SSH), lets this app's key in and installs Lepton if needed. No password needed.": "",
"The connection to the Frame is busy. Try again in a moment.": "",
"The copy on your Frame differs from what FramePort would install now (the recipe or your game files changed). Update to install the new build; saves are kept.": "",
"The CPU types the game ships code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games can't run on it.": "",
"The download didn't finish. Check your internet connection.": "",
"The Frame can't show curved panels; this shows them as gently bent strips.": "",
"The Frame is charging: continuing the installs.": "",
"the Frame isn't connected": "",
"The Frame isn't connected": "",
"The Frame went offline: {exc}": "",
"The Frame's battery is at {pct} % and not charging. Plug it in (a strong charger, not a PC port): long installs can drain it, and FramePort pauses them at {pause} %.": "",
"The Frame's battery is at {pct} %: installs paused so it doesn't switch off mid-upload. Plug it in; the queue continues by itself.": "",
"The Frame's desktop password, only needed the first time so FramePort can add its own key. After that it connects with the key.": "",
"The Frame's Steam library still shows the old artwork.": "",
"the game didn't pass its checks (see the list above)": "",
@@ -807,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.": "",
@@ -837,6 +865,12 @@
"Turns the controllers' pointing ray left (+) or right (−).": "",
"Turns the pointer left or right.": "",
"Type": "",
"Type it": "",
"Type on Frame": "",
"Type on Frame: use this keyboard on the Frame": "",
"Type on Frame…": "",
"Typed it, except characters the Frame's US keyboard layout doesn't have: {chars}": "",
"ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (and afterwards: sudo ufw delete allow {port}/tcp)": "",
"Uninstall": "",
"Uninstall FramePort": "",
"Uninstall FramePort?": "",
@@ -847,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).": "",
@@ -880,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.": "",
@@ -911,6 +947,9 @@
"Which program starts {get}?": "",
"Which Proton build runs the game on the Frame (a Steam compat tool name from the Frame's ARM64 compat list, e.g. proton_11-arm64 or proton-experimental-arm64). Empty = newest installed.": "",
"Windows not detected": "",
"Windows treats this network as public and its firewall blocks the Frame. In Windows' network settings set this network to Private (or allow FramePort on public networks when Windows asks).": "",
"Windows' firewall for WSL blocks the Frame from reaching FramePort. Open the setup command again and allow the change when Windows asks (admin).": "",
"Windows' firewall for WSL blocks the Frame. Open the setup command again and allow the change when Windows asks (admin).": "",
"Working": "",
"Working…": "",
"Works": "",
@@ -993,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
+121 -6
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):
@@ -1346,7 +1430,9 @@ class FramePortApp:
self._awake_busy = False
threading.Thread(target=work, daemon=True).start()
def connect(self, target, password=None, quiet=False):
def connect(self, target, password=None, quiet=False, devkit=False):
"""devkit: the Frame was found in Developer Mode; if FramePort's keys aren't on it yet, pair through Valve's
devkit service (approve in the headset) instead of failing."""
from ..frame.connection import save_target
from ..targets.frame_lepton import FrameLeptonTarget
@@ -1355,7 +1441,17 @@ class FramePortApp:
def work():
try:
t = FrameLeptonTarget(target, password).connect()
try:
t = FrameLeptonTarget(target, password).connect()
except ConnectionError as exc:
if not devkit or "authentication failed" not in str(exc).lower():
raise
from ..frame import devkit as dk
self.toast(tr("Pairing: on the Frame open Settings → Developer → Pair new host, then approve "
"FramePort."))
dk.register(target.host)
t = FrameLeptonTarget(target, password).connect()
if password:
t.frame.install_key()
info = t.describe()
@@ -1612,8 +1708,27 @@ def assets_dir() -> str:
return str(user_data_dir())
def serialize_flet_updates() -> None:
"""Send page updates one at a time. Flet 1.0 diffs and sends a control's patch without a lock, and FramePort
updates the page from several threads (jobs, connection checks, the library loader, dialogs): two patches computed
at once desynchronised the window ("dropped a patch for unknown control … needs a reload"), e.g. a dialog shown
while a finished scan redrew the view never closed again."""
from flet.messaging.session import Session
if getattr(Session.patch_control, "_serialized", False):
return
original, lock = Session.patch_control, threading.RLock() # re-entrant: did_mount() may update again
def patch_control(self, *args, **kwargs):
with lock:
return original(self, *args, **kwargs)
patch_control._serialized = True
Session.patch_control = patch_control
def main(argv=None):
applog.setup("gui")
serialize_flet_updates()
from ..core import library
from .updater import apply_pending_at_start
+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.")
+3 -2
View File
@@ -77,9 +77,10 @@ HELP: dict[str, str] = _Translated({
"cat_device": "Files and environment variables placed next to the game on the Frame.",
# ---- Frame
"developer_mode": "Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on "
"your network and connect to it over SSH.",
"your network and connect to it over SSH. The first-time setup command turns it on for you.",
"first_time_setup": "The command fetches a small setup script from this app over your local network. It turns on "
"SSH, lets this app's key in, makes the Frame findable and installs Lepton if needed.",
"Developer Mode (which includes SSH), lets this app's key in and installs Lepton if needed. "
"No password needed.",
"password": "The Frame's desktop password, only needed the first time so FramePort can add its own key. After "
"that it connects with the key.",
"lepton": "Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. "
+53 -9
View File
@@ -1,6 +1,7 @@
"""Frame page: the connected device with a readiness checklist and installed games, or a connect wizard."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
import flet as ft
@@ -9,6 +10,7 @@ from ...core import library
from ...i18n import tr
from .. import components as C
from .. import theme as T
from ..battery import label as battery_label
from ..help import HELP
if TYPE_CHECKING:
@@ -34,10 +36,14 @@ class FrameView:
.format(user=t.target.user, host=t.target.host, get=info.get('os'),
get2=info.get('os_version'), get3=info.get('build_id'))),
C.meta(tr("{free:.0f} GiB free · {len} games installed")
.format(free=free, len=len(info.get('installed') or []))),
.format(free=free, len=len(info.get('installed') or []))
+ (tr(" · battery {value}").format(value=battery_label(info["battery"]))
if info.get("battery") else "")),
], spacing=T.px(4), expand=True),
ft.Column([
C.secondary(tr("Refresh"), ft.Icons.REFRESH_ROUNDED, lambda e: app.refresh_frame()),
C.secondary(tr("Type on Frame"), ft.Icons.KEYBOARD_ROUNDED, lambda e: app.type_on_frame(),
tooltip=tr("Use this computer's keyboard on the Frame")),
C.ghost(tr("Switch Frame…"), ft.Icons.SWAP_HORIZ_ROUNDED, lambda e: app.disconnect()),
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.END),
], spacing=T.S4), padding=T.S5)
@@ -163,12 +169,13 @@ class FrameView:
color=T.ACCENT),
ft.Column([C.body(f.name if f.source != "scan" else f.host, T.TEXT, weight=ft.FontWeight.W_500),
C.meta(f"{f.host} · {label}")], spacing=T.px(2), expand=True),
C.primary(tr("Connect"), on_click=lambda e, f=f: app.connect(parse_target(f"{f.user}@{f.host}"))),
C.primary(tr("Connect"), on_click=lambda e, f=f: app.connect(parse_target(f"{f.user}@{f.host}"),
devkit=f.source == "devkit")),
], spacing=T.S3), padding=ft.Padding(T.S3, T.px(8), T.S2, T.px(8)), bgcolor=T.SURFACE_2,
border_radius=T.RADIUS_SM))
found.controls = items or [C.body(tr("No Frames found. Turn on Developer Mode on the Frame (Settings → "
"System → Developer) and make sure it's on the same network, or use "
"first-time setup below."))]
found.controls = items or [C.body(tr("No Frames found. New Frame? Use first-time setup below. Otherwise "
"make sure Developer Mode is on (Settings → System → Developer) and "
"the Frame is on the same network."))]
found.controls.append(C.ghost(tr("Search again"), ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
C.update(found)
@@ -183,7 +190,29 @@ class FrameView:
def on_paired(info):
app.connect(FrameTarget(info["host"], info["user"], 22, info["name"]))
app.pairing = PairingServer(on_paired=on_paired).start()
server = app.pairing = PairingServer(on_paired=on_paired).start()
show_command()
def check_network():
from ...core import winhost
from ...frame.pairing import HINT_AFTER, ensure_reachable, firewall_hint
if winhost.is_wsl() and ensure_reachable(server) == "failed":
app.toast(tr("Windows' firewall for WSL blocks the Frame from reaching FramePort. Open the setup "
"command again and allow the change when Windows asks (admin)."), error=True)
for _ in range(HINT_AFTER):
time.sleep(1)
if server.requests or not server.running or app.pairing is not server:
return
server.hint = firewall_hint(server.port) or tr(
"Check that the Frame and this computer are on the same network.")
if app.route[0] == "frame" and app.pairing is server:
show_command()
app.run_bg(check_network)
def show_command(update=True):
"""The setup command of the running pairing server: also when the page is redrawn (connection checks,
discovery), which used to close it."""
line = app.pairing.one_liner
pair_box.controls = [
C.body(tr("On the Frame: Steam button → Power → Switch to Desktop, open Konsole and run:"), T.TEXT),
@@ -195,10 +224,25 @@ class FrameView:
ft.Row([ft.ProgressRing(width=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT),
C.meta(tr("Waiting for your Frame… (code {code})").format(code=app.pairing.code))],
spacing=T.S2),
C.meta(tr("It turns on SSH, trusts this app, makes the Frame findable on your network and installs "
"Lepton if needed. You only do this once.")),
C.meta(tr("It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on "
"Developer Mode closes Desktop Mode; the setup finishes on its own and FramePort connects "
"by itself. You only do this once.")),
]
pair_box.update()
hint = getattr(app.pairing, "hint", "")
if hint and not app.pairing.requests: # nothing reached us yet: what may block it, and the other way in
pair_box.controls.append(C.callout(ft.Column([
C.body(tr("Nothing has reached FramePort from the Frame yet (curl says “timed out”)?"), T.TEXT,
weight=ft.FontWeight.W_600),
C.body(hint, T.TEXT),
C.meta(tr("Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows "
"up above. Open Settings → Developer → Pair new host on the Frame, click Connect here "
"and approve FramePort. That way needs no connection into this computer.")),
], spacing=T.S2), "warn"))
if update:
pair_box.update()
if app.pairing and app.pairing.running:
show_command(update=False)
style = dict(dense=True, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(10), T.px(12), T.px(10)),
+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)
+3
View File
@@ -1,11 +1,14 @@
import os
import struct
import sys
import tempfile
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
# modules that read settings at import time (UI) run during collection, before the per-test fixture below
os.environ["FRAMEPORT_HOME"] = tempfile.mkdtemp(prefix="frameport-tests-")
@pytest.fixture(autouse=True)
+91
View File
@@ -1,6 +1,7 @@
"""The Frame-side agent (stdlib only) — pieces that don't need a Frame."""
import importlib.util
import json
import os
import subprocess
import sys
from pathlib import Path
@@ -702,3 +703,93 @@ def test_keep_awake_falls_back_to_idle_when_sleep_is_refused(monkeypatch, tmp_pa
calls.clear()
assert a.cmd_keep_awake({"on": False}) == {"awake": False}
assert calls and all(c[0] == "systemctl" for c in calls) # only stops the unit
def test_purge_without_saves_removes_container_workdirs_and_stale_shortcuts(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
fake_steam_tools(a, tmp_path)
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
monkeypatch.setattr(a, "stop_steam", lambda: True)
monkeypatch.setattr(a, "start_steam", lambda s: None)
users = tmp_path / ".local/share/Steam/userdata/42/config"
(users / "grid").mkdir(parents=True)
q = Path(a.ANCHORS) / "com.x.y"
work = q / "lepton-data" / "baked" / "data_workdir" / "work" # overlayfs leaves these with mode 000
(work / "inner").mkdir(parents=True)
(work / "inner" / "f").write_text("x")
(q / "launch.sh").write_text("#!/bin/sh")
(q / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 7, "base": str(q), "title": "Q"}))
os.chmod(work / "inner", 0)
os.chmod(work, 0)
vdf = str(users / "shortcuts.vdf")
a.upsert_shortcut(vdf, f'"{q}/launch.sh"', "Q", str(q))
stale_exe = f'"{a.ANCHORS}/com.gone/launch.sh"' # no install record any more
stale = a.upsert_shortcut(vdf, stale_exe, "Gone", "/x")
a.upsert_shortcut(vdf, '"/usr/bin/other"', "Not ours", "/x")
(users / "grid" / f"{stale}p.jpg").write_bytes(b"x")
a.purge_worker(json.dumps({"keep_saves": False, "status": str(tmp_path / "st.json")}))
st = json.loads((tmp_path / "st.json").read_text())
assert st["state"] == "done" and not st["errors"], st
assert not Path(a.ANCHORS).exists()
names = [s["appname"] for s in a.vdf_decode((users / "shortcuts.vdf").read_bytes())["shortcuts"].values()]
assert names == ["Not ours"] and not (users / "grid" / f"{stale}p.jpg").exists()
def test_battery_state(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
ps = tmp_path / "power_supply"
def supply(name, **files):
(ps / name).mkdir(parents=True)
for k, v in files.items():
(ps / name / k).write_text(v + "\n")
monkeypatch.setattr(a, "POWER_SUPPLY", str(ps))
assert a.battery_state() is None # no power_supply folder (or no battery): nothing to show
supply("battery", type="Battery", capacity="42", status="Discharging")
supply("usb", type="USB", online="0")
assert a.battery_state() == {"percent": 42, "status": "Discharging", "plugged": False, "draining": True}
(ps / "usb" / "online").write_text("1\n") # cable in, battery not charging yet ("Not charging"): still plugged
assert a.battery_state()["plugged"] is True
assert a.cmd_battery({})["battery"]["percent"] == 42
def test_virtual_keyboard_events_text_and_release(monkeypatch, tmp_path):
import io
import struct as st
a = load_agent(monkeypatch, tmp_path)
monkeypatch.setattr(a.time, "sleep", lambda s: None)
written = []
kb = a.VirtualKeyboard(fd="fake", write=lambda fd, data: written.append(st.unpack("llHHi", data)[2:]))
kb.key(30, 1) # 'a' down: EV_KEY then SYN_REPORT
assert written == [(1, 30, 1), (0, 0, 0)] and kb.held == {30}
written.clear()
assert kb.type_text("A!\N{SNOWMAN}") == "\N{SNOWMAN}" # US layout; what it can't type is reported
keys = [(c, v) for t, c, v in written if t == 1]
assert keys == [(42, 1), (30, 1), (30, 0), (42, 0), (42, 1), (2, 1), (2, 0), (42, 0)]
kb.key(57, 1) # space held down when the PC goes away
written.clear()
kb.close() # released before the device goes away
assert (1, 57, 0) in written and kb.held == set()
# session protocol: ready line, keys and text from JSON lines, keyboard closed at EOF
out = io.StringIO()
kb2 = a.VirtualKeyboard(fd="fake", write=lambda fd, data: None)
closed = []
kb2.close = lambda: closed.append(1)
lines = io.StringIO('{"k": 28, "v": 1}\n{"k": 28, "v": 0}\nnot json\n{"text": "hi"}\n')
assert a.keyboard_session(lines, out, keyboard=kb2) == 0
replies = [json.loads(x) for x in out.getvalue().splitlines()]
assert replies[0] == {"ready": True} and replies[1] == {"typed": True, "skipped": ""} and closed
def test_keyboard_session_reports_a_refused_device(monkeypatch, tmp_path):
import io
a = load_agent(monkeypatch, tmp_path)
def refuse(*args, **kw):
raise PermissionError(13, "Permission denied", "/dev/uinput")
monkeypatch.setattr(a, "VirtualKeyboard", refuse)
out = io.StringIO()
assert a.keyboard_session(io.StringIO(""), out) == 1
assert json.loads(out.getvalue())["ready"] is False
+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"
+46
View File
@@ -0,0 +1,46 @@
"""bootstrap.sh: valid bash, placeholders for the pairing server, and the config.vdf edit that turns on Developer
Mode."""
import re
import shutil
import subprocess
import sys
import pytest
from frameport.core.paths import bootstrap_dir
SCRIPT = (bootstrap_dir() / "bootstrap.sh").read_text()
def devmode_edit() -> str:
return re.search(r"python3 - \"\$CONFIG\" <<'PY'[^\n]*\n(.*?)\nPY\n", SCRIPT, re.S).group(1)
@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash")
def test_bash_syntax():
subprocess.run(["bash", "-n"], input=SCRIPT, text=True, check=True)
def test_placeholders_and_steps():
assert "__PC_URL__" in SCRIPT and "__PAIR_CODE__" in SCRIPT
assert "steamos-devkit-mode" in SCRIPT and "/etc/steamos-devkit-enabled" in SCRIPT
assert "sudo" not in SCRIPT.split("set -euo pipefail", 1)[1] and "passwd" not in SCRIPT # no password, ever
# Desktop Mode is a nested session inside steam.service: the Developer Mode job (it stops Steam) must run as a
# user unit reached through the real user bus, logging to a file that outlives the terminal
assert 'DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus"' in SCRIPT
assert "user_systemd systemd-run --user" in SCRIPT and ".cache/frameport-setup.log" in SCRIPT
@pytest.mark.parametrize("before", [
'"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n\t\t}\n\t}\n'
'\t"developer"\n\t{\n\t\t"Other"\t\t"x"\n\t}\n}\n',
'"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t}\n}\n',
'"InstallConfigStore"\n{\n\t"developer"\n\t{\n\t\t"DevModeEnabled"\t\t"0"\n\t}\n}\n',
])
def test_devmode_config_edit(tmp_path, before):
cfg = tmp_path / "config.vdf"
cfg.write_text(before)
subprocess.run([sys.executable, "-", str(cfg)], input=devmode_edit(), text=True, check=True)
after = cfg.read_text()
assert re.search(r'\t"developer"\n\t\{\n(?:.*\n)*?\t\t"DevModeEnabled"\t\t"1"\n', after)
assert after.count("DevModeEnabled") == 1 and after.count("{") == after.count("}")
+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)
+58
View File
@@ -0,0 +1,58 @@
"""Valve's devkit pairing (frame/devkit.py) against a stand-in for the Frame's steamos-devkit-service."""
import http.server
import threading
import pytest
from frameport.frame import connection, devkit
@pytest.fixture
def fake_service(monkeypatch):
seen, reply = [], {"status": 200, "body": b"Registered\n"}
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_POST(self):
seen.append(self.rfile.read(int(self.headers["Content-Length"])).decode())
self.send_response(reply["status"])
self.end_headers()
self.wfile.write(reply["body"])
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
monkeypatch.setattr(devkit, "PORT", httpd.server_address[1])
yield seen, reply
httpd.shutdown()
def test_register_sends_the_rsa_key_and_accepts_registered(fake_service):
seen, _ = fake_service
devkit.register("127.0.0.1")
assert seen[0].startswith("ssh-rsa ") and seen[0].endswith(" frameport") # the service only takes ssh-rsa
assert any(k.get_name() == "ssh-rsa" for k in connection.app_keys()) # and FramePort logs in with it
def test_register_explains_a_missed_approval(fake_service):
_, reply = fake_service
# Valve's service mixes the hook's log lines into the JSON answer
reply.update(status=403, body=b'approve-ssh-key:Sending pairing request to Steam\n'
b'{"error": "timeout - Steam did not respond to the pairing request"}')
with pytest.raises(devkit.PairingRefused, match="wasn't approved in the headset"):
devkit.register("127.0.0.1")
def test_ed25519_key_is_tried_first_and_rsa_only_once_created():
assert [k.get_name() for k in connection.app_keys()] == ["ssh-ed25519"]
connection.devkit_key()
assert [k.get_name() for k in connection.app_keys()] == ["ssh-ed25519", "ssh-rsa"]
def test_register_explains_pairing_mode(fake_service):
_, reply = fake_service
reply.update(status=403, body=b'{"error": "devkit approve-ssh-key: please put the Steam client in pairing mode: '
b'Settings -> Developer -> Pair new host\\n"}')
with pytest.raises(devkit.PairingRefused, match="Pair new host first"):
devkit.register("127.0.0.1")
+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)
+50 -1
View File
@@ -229,9 +229,11 @@ def test_refresh_keeps_a_working_connection_when_a_status_query_fails():
def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monkeypatch):
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from frameport.frame import pairing
@@ -248,7 +250,16 @@ def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monke
seen = []
s = pairing.PairingServer(on_paired=seen.append).start()
assert len(s.code) == 16
other = pairing.PairingServer().start() # a second server (port taken) falls back to another port
assert other.port != s.port
other.stop()
assert len(s.code) == 8
assert s.one_liner == f"curl -fsS 127.0.0.1:{s.port}/{s.code} | bash" # typed by hand on the Frame: short
assert s.requests == 0
assert get(s, f"/{s.code}") == 200 and get(s, "/deadbeef") == 403 # the short form serves the script
assert s.requests == 2 # any request (even a wrong code) proves the Frame can reach us: no firewall hint
script = urllib.request.urlopen(f"http://127.0.0.1:{s.port}/{s.code}", timeout=5).read().decode()
assert f"http://127.0.0.1:{s.port}" in script and s.code in script and "__PAIR_CODE__" not in script
assert get(s, "/key?code=000000") == 403
assert get(s, f"/paired?code={s.code}&user=steamos&host=frame") == 200 and seen
for _ in range(50):
@@ -256,6 +267,12 @@ def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monke
break
time.sleep(0.05)
assert not s.running
flag = Path(tempfile.mkdtemp()) / "pairing.flag" # WSL: the temporary firewall rule lives while this exists
flag.write_text("x")
f = pairing.PairingServer().start()
f.flag = flag
f.stop()
assert not flag.exists()
g = pairing.PairingServer().start()
for _ in range(3):
get(g, "/key?code=guess")
@@ -440,3 +457,35 @@ def test_activity_progress_ticks_only_touch_the_progress_controls(monkeypatch):
bar = panel._live[run.id][0]
assert bar.value == 0.5 and panel.root not in updated[-1] and bar in updated[-1]
assert panel.list.controls == first_list # nothing rebuilt
def test_flet_updates_are_serialized():
import threading
from flet.messaging.session import Session
from frameport.ui.app import serialize_flet_updates
original = Session.patch_control
try:
active, peak = [0], [0]
guard = threading.Lock()
def fake(self, *a, **k):
with guard:
active[0] += 1
peak[0] = max(peak[0], active[0])
threading.Event().wait(0.01)
with guard:
active[0] -= 1
Session.patch_control = fake
serialize_flet_updates()
serialize_flet_updates() # idempotent
threads = [threading.Thread(target=Session.patch_control, args=(None,)) for _ in range(8)]
for th in threads:
th.start()
for th in threads:
th.join()
assert peak[0] == 1
finally:
Session.patch_control = original
+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"]