mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 17:00:43 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e2502a552 | ||
|
|
a57d89e874 | ||
|
|
aade29982f | ||
|
|
d94cab6048 | ||
|
|
f17fdbf271 | ||
|
|
fb70c09d72 | ||
|
|
69bf2700e1 | ||
|
|
0b28da5521 | ||
|
|
9e9a4c0f6c | ||
|
|
bb3f6b9e7c | ||
|
|
dc7533e4e1 | ||
|
|
3660167bd8 | ||
|
|
6a47fad8f7 | ||
|
|
0f8062f881 | ||
|
|
65369f154c | ||
|
|
aefb5adc07 |
No files matched your search
@@ -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"
|
||||
@@ -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>"`,
|
||||
|
||||
@@ -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.
|
||||
[](https://github.com/spoopyghosty0/frameport/releases/latest)
|
||||
[](https://github.com/spoopyghosty0/frameport/actions/workflows/build.yml)
|
||||
[](https://github.com/spoopyghosty0/frameport/releases)
|
||||
[](LICENSE)
|
||||

|
||||

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

|
||||
|
||||
## Purpose
|
||||
> **Notice:** FramePort explicitly does NOT download, share, or unlock games. You must provide legally obtained game
|
||||
> executables. Core features of FramePort simply download and wrap other published tools (see [Built on](#built-on))
|
||||
> with patches provided by FramePort adding a hardware compatibility layer. This enables users to use games/apps legally
|
||||
> purchased on sites like [SideQuest](https://sidequestvr.com/).
|
||||
|
||||
FramePort is a **proof of concept**: it shows that VR software built for other platforms (Meta Quest, Oculus Rift) can
|
||||
run on the Steam Frame with a translation layer and a few targeted fixes. Most of the work is done by the projects
|
||||
FramePort wraps (see [Built on](#built-on)); FramePort selects and applies their fixes per game, adds Steam
|
||||
Frame-specific patches, and handles installing, testing and launching.
|
||||
## Features
|
||||
|
||||
FramePort is **not a piracy tool**. It does not download, share or unlock games, and it does not remove DRM, licence or
|
||||
entitlement checks. Use it only with games you own. Games that check their licence through the Oculus Platform SDK are
|
||||
marked as not runnable on the Frame.
|
||||
- **Painless setup:** one short command on the Frame. No root, no `sudo`, no password.
|
||||
[What it changes](docs/FRAME_SETUP.md).
|
||||
- **Type on Frame:** use your computer's keyboard on the Frame: in VR apps, Android apps, Steam and the desktop.
|
||||
[More](#type-on-frame).
|
||||
- **One click per game:** convert, patch, sign, upload, add to Steam with artwork, launch test.
|
||||
- **Per-game recipes:** a tested catalog plus detection rules; every patch explained in plain words.
|
||||
- **FrameBridge:** FramePort's OpenXR adapter emulates what the Frame natively lacks (passthrough, room, controller models,
|
||||
curved and 360° layers); game settings as simple switches.
|
||||
- **Beyond Quest:** Android apps as windows, PC VR via Proton or Revive, 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
|
||||
|
||||

|
||||
Typing in VR is painful, so FramePort turns your computer's keyboard into a keyboard for the Frame. Open **Type on
|
||||
Frame** (keyboard icon on the sidebar's Frame card, the Steam Frame page, or a game's menu), select a text field in
|
||||
the headset and type: searches, logins, chat, in any app, in Steam or on the desktop. Paste longer text to type it in
|
||||
one go. Nothing to install: FramePort adds a virtual keyboard on the Frame while the window is open, without root.
|
||||
|
||||
Each game has **Game settings** in plain words (sharpness, refresh rate, controllers, menus, 360° video, mixed
|
||||
reality), showing only what matters for that game. Changes are kept with the game and reach the Frame right away.
|
||||

|
||||
|
||||

|
||||
Unity apps whose text fields close the moment you select them on the Frame (no system keyboard there) get a per-game
|
||||
fix, so Steam's on-screen keyboard and Type on Frame work in them too.
|
||||
[Details](docs/INSTALL.md#typing-on-the-frame).
|
||||
|
||||
FramePort updates itself: when a new version is released, the Library shows **Update now**.
|
||||
## Compatibility
|
||||
|
||||
## What works
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
| Kind of app | On the Steam Frame |
|
||||
|---|---|
|
||||
| Meta Quest games (APK) | Translated to OpenXR (OVRPort) and patched for the Frame; run in Valve's Android runtime (Lepton) |
|
||||
| Other Android VR apps using OpenXR (e.g. Pico builds) | Translated the same way; the other headset's own extensions and store services aren't available |
|
||||
| Ordinary Android apps and games (no VR) | Installed unchanged and shown as a flat window in the headset |
|
||||
| PC VR games (Windows; OpenXR, SteamVR or Oculus) | Run through Proton on the Frame (experimental), or on a Windows PC with SteamVR and streamed to the Frame; Oculus-only games use Revive |
|
||||
| Can't run | 32-bit-only or x86-only APKs, Pico/HTC Wave SDK apps, Android XR apps, and games that check an Oculus licence (they need the Oculus app on a PC) |
|
||||
|
||||
Automated launch tests confirm that a game starts; visuals can only be checked in the headset.
|
||||
|
||||

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

|
||||
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
@@ -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
@@ -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."
|
||||
@@ -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
|
||||
@@ -84,3 +84,21 @@ update [--check] [--yes]`. CI runs `scripts/update_smoke.py` on every OS with th
|
||||
overport CLI release (Android-XR-Bridge/OVRPort, fallback ovrport/app) + patch list + titles, Temurin JRE (Adoptium API), apksigner (Google repository index),
|
||||
store artwork/titles (overport image API), catalog (optional remote), Lepton location/appid (Frame appmanifests),
|
||||
Steam user (Frame userdata).
|
||||
|
||||
## Built on
|
||||
|
||||
FramePort is a front end for other projects; most of the functionality comes from them:
|
||||
|
||||
| Project | Used for |
|
||||
|---|---|
|
||||
| [OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (overport, originally [ovrport/app](https://github.com/ovrport/app)) | Converts Quest games to OpenXR: its CLI applies the game patches and supplies the OpenXR loader; FramePort also includes its VrApi→OpenXR adapter |
|
||||
| Valve Lepton, Proton and SteamVR | Run Android games, Windows games and OpenXR on the Frame |
|
||||
| [Revive](https://github.com/LibreVR/Revive) (LibreVR) | Runs Oculus Rift games on OpenXR / SteamVR |
|
||||
| Mesa (Zink) | OpenGL ES on Vulkan on the Frame |
|
||||
| [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK) | OpenXR headers for the native layers |
|
||||
| Eclipse Temurin, Android apksigner, Android NDK | Java runtime, APK signing, building the native layers |
|
||||
| [Flet](https://flet.dev) | The desktop app |
|
||||
| OculusDB, Steam store | Game descriptions, genres and artwork |
|
||||
|
||||
FramePort's own parts: game detection and recipes, the Steam Frame OpenXR adapter (FrameBridge) and the other native
|
||||
fixes in `native/`, the installer agent that runs on the Frame, and the desktop/command-line app.
|
||||
@@ -0,0 +1,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.
|
||||
|
||||

|
||||
|
||||
| Kind of app | On the Steam Frame |
|
||||
|---|---|
|
||||
| Meta Quest games (APK) | Translated to OpenXR (OVRPort) and patched for the Frame; run in Valve's Android runtime (Lepton) |
|
||||
| Other Android VR apps using OpenXR (e.g. Pico builds) | Translated the same way; the other headset's own extensions and store services aren't available |
|
||||
| Ordinary Android apps and games (no VR) | Installed unchanged and shown as a flat window in the headset |
|
||||
| PC VR games (Windows; OpenXR, SteamVR or Oculus) | Run through Proton on the Frame (experimental), or on a Windows PC with SteamVR and streamed to the Frame; Oculus-only games use Revive |
|
||||
| Can't run | 32-bit-only or x86-only APKs, Pico/HTC Wave SDK apps, Android XR apps, and games that check an Oculus licence (they need the Oculus app on a PC) |
|
||||
|
||||
Automated launch tests confirm that a game starts; visuals can only be checked in the headset.
|
||||
|
||||

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

|
||||
@@ -105,3 +105,14 @@
|
||||
- Processes started from Steam (Konsole, SSH sessions?) share steam.service's cgroup: use `systemd-run --user`.
|
||||
- SSH: `sshd` must be enabled (`sudo systemctl enable --now sshd`), which needs a user password (`passwd`).
|
||||
- mDNS: avahi-daemon runs by default; hostname `frame` → `frame.local`.
|
||||
|
||||
## Text input
|
||||
|
||||
- Lepton's Android has no on-screen keyboard (IME) and VR apps run headless, so no Android window has keyboard focus
|
||||
and key presses (Steam's keyboard, USB/Bluetooth keyboards, `input text`) don't reach VR apps. The app folder's
|
||||
`lepton-show-flatscreen` marker shows the window (VR keeps working) and gives it focus; Steam's on-screen keyboard
|
||||
then opens for its text fields (FramePort patch `device.text_input_window`).
|
||||
- `/dev/uinput` is writable by the steamos user (ACL for Steam Input): a uinput virtual keyboard works like a real
|
||||
one everywhere (FramePort's "Type on Frame", agent `_keyboard`).
|
||||
- Unity text fields close without a system keyboard; see `frame.unity_text_input` in PLAYBOOK.md.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Frame setup: what changes, networks, undoing it
|
||||
|
||||
For the steps themselves see [INSTALL.md](INSTALL.md#connecting-the-steam-frame).
|
||||
|
||||
## What the setup changes
|
||||
|
||||
The setup command runs [`bootstrap/bootstrap.sh`](../bootstrap/bootstrap.sh), served by the app over your
|
||||
local network. Everything it changes:
|
||||
|
||||
| Change | Where | How to undo |
|
||||
|---|---|---|
|
||||
| Turns on **Developer Mode** (only if it's off). Steam restarts once, which closes Desktop Mode; the rest of the setup finishes on its own as a user service (log: `~/.cache/frameport-setup.log`). | `"DevModeEnabled" "1"` in `~/.local/share/Steam/config/config.vdf` (old file kept as `config.vdf.before-frameport`), then Valve's own `steamos-polkit-helpers/steamos-devkit-mode --enable`. That helper enables the SSH server (`sshd`), the devkit service that makes the Frame findable on the network, the remote-desktop and debug services, and system crash dumps. | Settings → System → Developer → Developer Mode off. Valve's helper switches all of those services off again. |
|
||||
| Lets the app's SSH key in. | One line ending in `frameport` in `~/.ssh/authorized_keys`. The folder and file are created if missing. | Delete that line. |
|
||||
| Configures podman for Lepton. Rootless podman leaks one kernel keyring per container start, and after about 200 game starts every game fails. | `[containers]` / `keyring = false` in `~/.config/containers/containers.conf`. | Remove those lines. |
|
||||
| Asks Steam to install **Lepton** (Valve's Android runtime, Steam app 3029110) if it's missing. You confirm it in Steam. | Steam library | Uninstall it in Steam. |
|
||||
|
||||
The script runs as your user: no root, no `sudo`, no password. The only system-level change, Developer Mode, is
|
||||
made by Valve's own helper, the same one the Settings switch uses. If Developer Mode can't be turned on
|
||||
automatically, the script asks you to turn it on in Settings → System → Developer and run the command again.
|
||||
|
||||
Nothing else on the system is touched: no packages, no read-only-filesystem changes, no polkit rules. The script
|
||||
also leaves two files: `~/.cache/frameport-setup.sh` (the part that runs on its own) and its log.
|
||||
|
||||
**Later, the app adds** (all as your user, no root, no `sudo`):
|
||||
- FramePort's helper in `~/.local/share/frameport/`;
|
||||
- the games, each with a launcher and its data in `~/Applications/quest-frame/<package>/`;
|
||||
- their Steam library entries and artwork (`shortcuts.vdf` + `config/grid/`);
|
||||
- for PC VR games: an OpenXR layer (`~/.local/share/openxr/1/api_layers/explicit.d/XR_APILAYER_FRAMEPORT_timefix.json`)
|
||||
and, when the first PC VR game is installed, Valve's ARM64 Proton and its Steam Linux Runtime (Steam downloads them;
|
||||
Steam restarts once).
|
||||
|
||||
**Settings → Uninstall FramePort → Also remove from the Frame** deletes the games, their Steam entries, the helper
|
||||
folder, the OpenXR layer and the setup script's files. Developer Mode, the SSH key line, the podman setting, Lepton
|
||||
and Proton stay. Undo them as shown above or in Steam.
|
||||
|
||||
## Network and firewalls
|
||||
|
||||
The setup command is the only time the Frame connects to your computer: it downloads the script from FramePort on
|
||||
TCP port 8765 (8766/8767 if taken), only while the setup command is shown and for at most 30 minutes. Everything
|
||||
else goes from the computer to the Frame. If the command just says "timed out", the setup page shows what is likely
|
||||
blocking it after about 45 seconds:
|
||||
|
||||
- **Windows:** allow FramePort (or Python, when running from source) when Windows asks. On a network Windows treats as
|
||||
**Public** it stays blocked unless you allow public networks; set your home network to Private in Windows'
|
||||
network settings instead.
|
||||
- **macOS:** with the firewall on (System Settings → Network → Firewall), allow incoming connections for FramePort
|
||||
when asked.
|
||||
- **Linux:** firewalld: `sudo firewall-cmd --add-port=8765/tcp` (until the next restart). ufw:
|
||||
`sudo ufw allow 8765/tcp`, afterwards `sudo ufw delete allow 8765/tcp`.
|
||||
- **WSL:** Windows' Hyper-V firewall blocks connections into WSL without asking. FramePort adds a temporary rule for
|
||||
the setup ports (one admin prompt) and removes it again when setup is done or after 35 minutes. WSL must use
|
||||
mirrored networking: `networkingMode=mirrored` under `[wsl2]` in `%UserProfile%\.wslconfig`, then `wsl --shutdown`.
|
||||
- Or skip the setup command and use the devkit pairing (see [INSTALL.md](INSTALL.md#connecting-the-steam-frame)): it needs no connection into your computer.
|
||||
+46
-6
@@ -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**).
|
||||
|
||||

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

|
||||
|
||||
## Typing on the Frame
|
||||
|
||||
- **Type on Frame** (Steam Frame page, a game's menu, or the keyboard icon on the sidebar's Frame card): while the
|
||||
window is open, this computer's keyboard works as a keyboard plugged into the Frame. Select a text field in the
|
||||
headset (in an app, Steam or the desktop) and type; Esc and shortcuts go to the Frame too. Paste longer text into
|
||||
the box to type it in one go (US keyboard layout). Click **Done** to disconnect.
|
||||
- **Steam's on-screen keyboard** opens for text fields of apps shown as a window (2D apps, and VR apps with
|
||||
**Show the app's Android window**). Steam lists that window as **Gamescope** (the Frame's display compositor);
|
||||
leave it open: it's what receives the typing, the VR view isn't affected.
|
||||
- **Unity apps whose text fields close at once** (a caret flashes, nothing can be typed): FramePort suggests
|
||||
**Make Unity text fields work** for them. The first time, it downloads Cpp2IL (a tool that finds the right spot in the
|
||||
game's code, ~17 MB). Games added before this version: open the game's menu → **Analyze again**, then reinstall.
|
||||
|
||||
## Updating
|
||||
|
||||
FramePort checks for a new release at start and every 6 hours (it only downloads the release information). When one
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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)
|
||||
|
||||
@@ -100,6 +100,35 @@ class FakeTarget:
|
||||
pass
|
||||
|
||||
|
||||
class FakeKeyboardSession:
|
||||
"""--fake-frame: Type on Frame connects at once (nothing is sent anywhere)."""
|
||||
|
||||
def __init__(self, frame):
|
||||
self.closed = False
|
||||
|
||||
def key(self, name, action="down"):
|
||||
return True
|
||||
|
||||
def text(self, text):
|
||||
return ""
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def open_type_dialog(app: FramePortApp) -> None:
|
||||
"""Type on Frame over the Steam Frame page, with one key 'pressed' so the screenshot shows it working."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
app.navigate(1)
|
||||
time.sleep(1.5)
|
||||
app.type_on_frame()
|
||||
time.sleep(1.5)
|
||||
dialog = [d for d in app.page._dialogs.controls if d.open][-1]
|
||||
listener = dialog.content.controls[1]
|
||||
listener.on_key_down(SimpleNamespace(key="Enter"))
|
||||
|
||||
|
||||
STEP_SECONDS = 4 # per screen (the screenshot is taken ~3 s in)
|
||||
|
||||
|
||||
@@ -158,6 +187,7 @@ def main() -> int:
|
||||
steps.append(("files", lambda a: a.go("files")))
|
||||
steps.append(("files-select", lambda a: [a.files_view._toggle(e.path, True)
|
||||
for e in a.files_view.entries[1:3]]))
|
||||
steps.append(("type-on-frame", open_type_dialog))
|
||||
if args.install_questions:
|
||||
queued: list[str] = []
|
||||
|
||||
@@ -210,6 +240,9 @@ def main() -> int:
|
||||
ready, done = threading.Event(), []
|
||||
|
||||
if args.fake_frame: # never reach a real Frame (start-up auto-connect, discovery, the 30 s poll)
|
||||
from frameport.frame import keyboard
|
||||
|
||||
keyboard.KeyboardSession = FakeKeyboardSession
|
||||
FramePortApp.connect = lambda self, *a, **k: None
|
||||
FramePortApp.refresh_frame = lambda self, *a, **k: None
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.6.0"
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -24,6 +24,17 @@ def agent_dir() -> Path:
|
||||
return DATA_ROOT / "agent"
|
||||
|
||||
|
||||
AGENT_SOURCE_COPY = "frameport_agent.py.txt" # scripts/package.py stages this next to the .py
|
||||
|
||||
|
||||
def agent_file() -> Path:
|
||||
"""The Frame agent's source (uploaded to the Frame and run there with python3). `flet build` compiles every .py
|
||||
of the app to .pyc and drops the source, bundled data included (issue #2: no connection from the release
|
||||
bundles), so bundles also carry a copy under a non-.py name."""
|
||||
path = agent_dir() / "frameport_agent.py"
|
||||
return path if path.exists() else agent_dir() / AGENT_SOURCE_COPY
|
||||
|
||||
|
||||
def bootstrap_dir() -> Path:
|
||||
return DATA_ROOT / "bootstrap"
|
||||
|
||||
|
||||
@@ -287,3 +287,93 @@ def oculus_platform_dir() -> Path | None:
|
||||
if (d / "LibOVRPlatform64_1.dll").is_file():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ firewall (pairing)
|
||||
WSL_VM_CREATOR = "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" # Hyper-V firewall id of WSL's VM
|
||||
|
||||
|
||||
def powershell(command: str, timeout: float = 30) -> subprocess.CompletedProcess:
|
||||
"""Run Windows PowerShell 5.1 (from Windows or WSL). PSModulePath is dropped: inherited from PowerShell 7 it keeps
|
||||
5.1 from loading its own modules (see updates._powershell)."""
|
||||
if is_windows():
|
||||
exe = str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe")
|
||||
cwd = None
|
||||
else:
|
||||
exe = system32("WindowsPowerShell/v1.0/powershell.exe")
|
||||
cwd = "/mnt/c" if Path("/mnt/c").is_dir() else None # a \\wsl$ working dir makes Windows programs complain
|
||||
env = {k: v for k, v in os.environ.items() if k.upper() != "PSMODULEPATH"}
|
||||
return subprocess.run([exe, "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, text=True,
|
||||
errors="replace", timeout=timeout, cwd=cwd, env=env, **_no_window())
|
||||
|
||||
|
||||
def wsl_networking_mode() -> str | None:
|
||||
""""mirrored" or "nat" (WSL's default, where nothing on the network can reach WSL at all); None if unknown."""
|
||||
if not is_wsl():
|
||||
return None
|
||||
try:
|
||||
out = subprocess.run(["wslinfo", "--networking-mode"], capture_output=True, text=True, timeout=10).stdout
|
||||
if out.strip():
|
||||
return out.strip().lower()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def wsl_inbound_blocked(rule: str) -> bool:
|
||||
"""WSL with mirrored networking: Windows' Hyper-V firewall blocks connections from the network to WSL by default
|
||||
(no "Allow access?" prompt as for Windows programs), so a Frame's request to our pairing server just times out.
|
||||
True when that block is on and our allow rule `rule` isn't there."""
|
||||
if not is_wsl():
|
||||
return False
|
||||
try:
|
||||
out = powershell(
|
||||
f"$s = Get-NetFirewallHyperVVMSetting -PolicyStore ActiveStore -Name '{WSL_VM_CREATOR}';"
|
||||
f"$r = Get-NetFirewallHyperVRule -PolicyStore ActiveStore -Name '{rule}' -ErrorAction SilentlyContinue;"
|
||||
"\"$($s.Enabled) $($s.DefaultInboundAction) $([bool]$r)\"").stdout.split()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return out[:3] == ["True", "Block", "False"]
|
||||
|
||||
|
||||
def open_wsl_inbound(rule: str, title: str, ports: str, flag: Path, minutes: int = 35) -> bool:
|
||||
"""Let the network reach WSL on TCP `ports` only while `flag` exists (at most `minutes`): one admin prompt (UAC)
|
||||
starts a hidden elevated PowerShell that adds a Hyper-V firewall rule, waits, and removes the rule again (only if
|
||||
it added it). Returns True once the rule is in place."""
|
||||
import base64
|
||||
|
||||
script = f"""
|
||||
$name = '{rule}'
|
||||
$added = $false
|
||||
if (-not (Get-NetFirewallHyperVRule -PolicyStore ActiveStore -Name $name -ErrorAction SilentlyContinue)) {{
|
||||
New-NetFirewallHyperVRule -Name $name -DisplayName '{title}' -Direction Inbound -VMCreatorId '{WSL_VM_CREATOR}' `
|
||||
-Protocol TCP -LocalPorts {ports} -Action Allow | Out-Null
|
||||
$added = $true
|
||||
}}
|
||||
$end = (Get-Date).AddMinutes({minutes})
|
||||
while ((Test-Path -LiteralPath '{to_windows(flag)}') -and ((Get-Date) -lt $end)) {{ Start-Sleep -Seconds 2 }}
|
||||
if ($added) {{ Remove-NetFirewallHyperVRule -Name $name -ErrorAction SilentlyContinue }}
|
||||
"""
|
||||
encoded = base64.b64encode(script.encode("utf-16-le")).decode()
|
||||
try:
|
||||
powershell("Start-Process powershell -Verb RunAs -WindowStyle Hidden "
|
||||
f"-ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand','{encoded}'", timeout=300)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
for _ in range(20): # the elevated script needs a moment to add the rule
|
||||
if not wsl_inbound_blocked(rule):
|
||||
return True
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def network_category(ip: str) -> str | None:
|
||||
"""Windows: the firewall profile of the network that has `ip` ("Public", "Private", "DomainAuthenticated")."""
|
||||
if not is_windows():
|
||||
return None
|
||||
try:
|
||||
out = powershell(f"(Get-NetConnectionProfile -InterfaceIndex (Get-NetIPAddress -IPAddress '{ip}' "
|
||||
"-ErrorAction Stop).InterfaceIndex).NetworkCategory").stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return out or None
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Valve's devkit pairing: with Developer Mode on, the Frame's steamos-devkit-service (TCP 32000) accepts an SSH public
|
||||
key at POST /register while Steam is in pairing mode (Settings → Developer → Pair new host; otherwise it refuses at
|
||||
once), asks in Steam to approve it, and Valve's root hook installs the key for the steamos user and enables sshd.
|
||||
Every connection goes from the PC to the Frame, so no firewall on the PC is involved: the fallback when the Frame
|
||||
can't reach FramePort's setup server. The service only accepts ssh-rsa keys
|
||||
(connection.devkit_key)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .connection import devkit_public_key
|
||||
|
||||
PORT = 32000
|
||||
APPROVE_TIMEOUT = 30 # Valve's approve hook waits this long for the answer in the headset
|
||||
|
||||
|
||||
class PairingRefused(RuntimeError): # not a ConnectionError: the Frame is reachable, explain() keeps the message
|
||||
pass
|
||||
|
||||
|
||||
def available(host: str, timeout: float = 5) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://{host}:{PORT}/login-name", timeout=timeout) as r:
|
||||
return r.status == 200
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def register(host: str) -> None:
|
||||
"""Send FramePort's RSA key; returns once it was approved in the headset and installed, raises PairingRefused."""
|
||||
req = urllib.request.Request(f"http://{host}:{PORT}/register", data=devkit_public_key().encode(), method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=APPROVE_TIMEOUT + 30) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", "replace") # Valve's service mixes the hook's log lines into it
|
||||
m = re.search(r'"error":\s*"([^"]*)"', body)
|
||||
msg = (m.group(1) if m else (body.strip() or str(exc))).replace("\\n", " ").strip()
|
||||
if "pairing mode" in msg.lower(): # Steam takes requests only while "Pair new host" is open
|
||||
msg = "on the Frame open Settings → Developer → Pair new host first, then click Connect again"
|
||||
elif "timeout" in msg.lower():
|
||||
msg = f"it wasn't approved in the headset within {APPROVE_TIMEOUT} seconds"
|
||||
elif "steam is not running" in msg.lower():
|
||||
msg = "Steam isn't running on the Frame"
|
||||
raise PairingRefused(f"The Frame didn't pair: {msg}") from None
|
||||
except OSError as exc:
|
||||
raise PairingRefused(f"Couldn't reach the Frame's pairing service (Developer Mode on?): {exc}") from None
|
||||
if "Registered" not in body:
|
||||
raise PairingRefused(f"The Frame didn't pair: {body.strip()}")
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Type on the Frame from this computer's keyboard: the agent's `_keyboard` session creates a virtual keyboard on the
|
||||
Frame (Linux uinput) and this side streams key presses to it as JSON lines over one SSH channel. Keys are sent as
|
||||
physical keys (Linux key codes), so modifiers, shortcuts and key repeat behave like a keyboard plugged into the Frame;
|
||||
pasted text is typed with the Frame's US layout."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import posixpath
|
||||
import threading
|
||||
|
||||
from ..core import applog
|
||||
|
||||
# Flet/Flutter key names, normalised (lowercase, no spaces) -> Linux input key codes (input-event-codes.h)
|
||||
_NAMED = {
|
||||
"escape": 1, "esc": 1, "backspace": 14, "tab": 15, "enter": 28, "return": 28, "space": 57, " ": 57,
|
||||
"controlleft": 29, "control": 29, "ctrl": 29, "controlright": 97, "shiftleft": 42, "shift": 42,
|
||||
"shiftright": 54, "altleft": 56, "alt": 56, "altright": 100, "altgraph": 100, "metaleft": 125, "meta": 125,
|
||||
"metaright": 126, "capslock": 58, "numlock": 69, "scrolllock": 70, "printscreen": 99, "pause": 119,
|
||||
"contextmenu": 127, "home": 102, "arrowup": 103, "up": 103, "pageup": 104, "arrowleft": 105, "left": 105,
|
||||
"arrowright": 106, "right": 106, "end": 107, "arrowdown": 108, "down": 108, "pagedown": 109, "insert": 110,
|
||||
"delete": 111, "numpadenter": 96, "numpadadd": 78, "numpadsubtract": 74, "numpadmultiply": 55,
|
||||
"numpaddivide": 98, "numpaddecimal": 83, "numpadequal": 117,
|
||||
"audiovolumemute": 113, "audiovolumedown": 114, "audiovolumeup": 115,
|
||||
"mediaplaypause": 164, "mediatracknext": 163, "mediatrackprevious": 165, "mediastop": 166,
|
||||
}
|
||||
_ROWS = (("1234567890-=", "!@#$%^&*()_+", 2), ("qwertyuiop[]", "QWERTYUIOP{}", 16),
|
||||
("asdfghjkl;'`", 'ASDFGHJKL:"~', 30), ("\\zxcvbnm,./", "|ZXCVBNM<>?", 43))
|
||||
_CHARS: dict[str, int] = {}
|
||||
for _plain, _shifted, _first in _ROWS:
|
||||
for _i, (_a, _b) in enumerate(zip(_plain, _shifted, strict=True)):
|
||||
_CHARS[_a] = _CHARS[_b] = _first + _i
|
||||
_FKEYS = {f"f{n}": c for n, c in zip(range(1, 13), (59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88), strict=True)}
|
||||
_NUMPAD = {f"numpad{d}": c for d, c in zip("1234567890", (79, 80, 81, 75, 76, 77, 71, 72, 73, 82), strict=True)}
|
||||
_warned: set[str] = set()
|
||||
|
||||
|
||||
def linux_key(name: str) -> int | None:
|
||||
"""Linux key code for a Flet key name ("A", "Enter", "Arrow Left", "Shift Left", "F5", "Key A", "Digit 1", "!")."""
|
||||
if not name:
|
||||
return None
|
||||
if not name.strip():
|
||||
return 57 # " " = space (normalising below would leave nothing)
|
||||
if len(name) == 1 and name in _CHARS:
|
||||
return _CHARS[name.lower()] if name.isalpha() else _CHARS[name]
|
||||
norm = name.lower().replace(" ", "").replace("_", "")
|
||||
if norm.startswith("key") and len(norm) == 4: # physical names: "Key A"
|
||||
norm = norm[3:]
|
||||
elif norm.startswith("digit") and len(norm) == 6: # "Digit 1"
|
||||
norm = norm[5:]
|
||||
if len(norm) == 1 and norm in _CHARS:
|
||||
return _CHARS[norm]
|
||||
code = _NAMED.get(norm) or _FKEYS.get(norm) or _NUMPAD.get(norm)
|
||||
if code is None and name not in _warned:
|
||||
_warned.add(name)
|
||||
applog.log.info("Type on Frame: no key code for %r", name)
|
||||
return code
|
||||
|
||||
|
||||
class KeyboardRefused(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class KeyboardSession:
|
||||
"""One virtual keyboard on the Frame for as long as this session is open (the agent removes it, releasing any
|
||||
held key, when the SSH channel closes)."""
|
||||
|
||||
def __init__(self, frame, timeout: float = 15):
|
||||
self.frame = frame
|
||||
self._lock = threading.Lock()
|
||||
self.closed = False
|
||||
frame.ensure_agent()
|
||||
remote = posixpath.join(frame.home, ".local/share/frameport/agent/frameport_agent.py")
|
||||
self._stdin, self._stdout, _err = frame.client.exec_command(f"python3 {remote} _keyboard", timeout=timeout)
|
||||
ready = json.loads(self._stdout.readline() or "{}")
|
||||
if not ready.get("ready"):
|
||||
self.close()
|
||||
raise KeyboardRefused(ready.get("error") or "the Frame didn't start the virtual keyboard")
|
||||
self._stdout.channel.settimeout(None)
|
||||
|
||||
def _send(self, msg: dict) -> None:
|
||||
with self._lock:
|
||||
if self.closed:
|
||||
return
|
||||
self._stdin.write(json.dumps(msg) + "\n")
|
||||
self._stdin.flush()
|
||||
|
||||
def key(self, name: str, action: str = "down") -> bool:
|
||||
"""action: down | up | repeat. False when the key has no Linux key code."""
|
||||
code = linux_key(name)
|
||||
if code is None:
|
||||
return False
|
||||
self._send({"k": code, "v": {"down": 1, "up": 0, "repeat": 2}[action]})
|
||||
return True
|
||||
|
||||
def text(self, text: str) -> str:
|
||||
"""Type text with the Frame's US layout; returns the characters it couldn't type."""
|
||||
self._send({"text": text})
|
||||
reply = json.loads(self._stdout.readline() or "{}")
|
||||
return reply.get("skipped", "")
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self.closed = True
|
||||
try:
|
||||
self._stdin.channel.shutdown_write() # EOF: the agent releases held keys and removes the device
|
||||
self._stdin.close()
|
||||
except Exception: # noqa: BLE001 - the connection may be gone already
|
||||
pass
|
||||
@@ -2,7 +2,9 @@
|
||||
the LAN. The user types one line on the Frame; the script calls back /paired so the UI knows who connected.
|
||||
|
||||
Every request needs the pairing code, a random secret that is only in that one line. The server stops after a
|
||||
successful pairing, after MAX_FAILURES wrong codes (someone guessing) and after LIFETIME seconds."""
|
||||
successful pairing, after MAX_FAILURES wrong codes (someone guessing) and after LIFETIME seconds, so a short code
|
||||
(32 bits) is plenty. The line is typed by hand on the Frame, so it's kept short: no scheme (curl defaults to http),
|
||||
the code is the path, and a fixed port when it's free."""
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
@@ -10,6 +12,7 @@ import secrets
|
||||
import threading
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.paths import bootstrap_dir
|
||||
from .connection import app_public_key
|
||||
@@ -17,15 +20,88 @@ from .discovery import local_ip_towards
|
||||
|
||||
MAX_FAILURES = 20
|
||||
LIFETIME = 30 * 60
|
||||
PORTS = (8765, 8766, 8767, 0) # 0 = any free port
|
||||
FIREWALL_RULE = "FramePort-Pairing"
|
||||
HINT_AFTER = 45 # seconds without any request from the Frame before the UI suggests what may block it
|
||||
|
||||
|
||||
def ensure_reachable(server: PairingServer) -> str:
|
||||
"""Under WSL, let the Frame reach the pairing ports through Windows' Hyper-V firewall while `server` runs (one
|
||||
admin prompt; the rule is removed again when the server stops, see winhost.open_wsl_inbound).
|
||||
Returns "ok" (nothing blocks), "opened" or "failed" (declined / no admin)."""
|
||||
from ..core import winhost
|
||||
|
||||
if not winhost.wsl_inbound_blocked(FIREWALL_RULE):
|
||||
return "ok"
|
||||
temp = winhost.env_path("TEMP")
|
||||
if temp is None:
|
||||
return "failed"
|
||||
server.flag = temp / f"frameport-pairing-{server.code}.flag"
|
||||
try:
|
||||
server.flag.write_text("FramePort's setup page is open: the firewall rule stays while this file exists\n")
|
||||
except OSError:
|
||||
return "failed"
|
||||
ports = f"{PORTS[0]}-{PORTS[-2]}"
|
||||
return "opened" if winhost.open_wsl_inbound(FIREWALL_RULE, "FramePort setup (WSL, temporary)", ports,
|
||||
server.flag) else "failed"
|
||||
|
||||
|
||||
def firewall_hint(port: int) -> str:
|
||||
"""What may keep the Frame from reaching this PC, for this OS (shown when no request came in after HINT_AFTER).
|
||||
Empty when nothing specific is known."""
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from ..core import winhost
|
||||
from ..i18n import tr
|
||||
|
||||
def out(*cmd) -> str:
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.lower()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
if winhost.is_wsl():
|
||||
if winhost.wsl_networking_mode() == "nat":
|
||||
return tr("FramePort runs in WSL with its default NAT network, which other devices can't reach. Set "
|
||||
"networkingMode=mirrored under [wsl2] in %UserProfile%\\.wslconfig, run wsl --shutdown and "
|
||||
"start FramePort again.")
|
||||
if winhost.wsl_inbound_blocked(FIREWALL_RULE):
|
||||
return tr("Windows' firewall for WSL blocks the Frame. Open the setup command again and allow the "
|
||||
"change when Windows asks (admin).")
|
||||
return ""
|
||||
if winhost.is_windows():
|
||||
if winhost.network_category(local_ip_towards()) == "Public":
|
||||
return tr("Windows treats this network as public and its firewall blocks the Frame. In Windows' "
|
||||
"network settings set this network to Private (or allow FramePort on public networks when "
|
||||
"Windows asks).")
|
||||
return tr("If Windows asked whether FramePort may use the network, allow it (Private networks).")
|
||||
if platform.system() == "Darwin":
|
||||
fw = out("/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate")
|
||||
if "enabled" in fw:
|
||||
return tr("macOS' firewall is on: allow incoming connections when macOS asks about FramePort (or "
|
||||
"System Settings → Network → Firewall → Options).")
|
||||
return ""
|
||||
if shutil.which("firewall-cmd") and "running" in out("firewall-cmd", "--state"):
|
||||
return tr("firewalld is active. Allow the setup port until the next restart with: "
|
||||
"sudo firewall-cmd --add-port={port}/tcp").format(port=port)
|
||||
if "active" == out("systemctl", "is-active", "ufw").strip():
|
||||
return tr("ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (and afterwards: "
|
||||
"sudo ufw delete allow {port}/tcp)").format(port=port)
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairingServer:
|
||||
port: int = 0
|
||||
code: str = field(default_factory=lambda: secrets.token_hex(8))
|
||||
code: str = field(default_factory=lambda: secrets.token_hex(4))
|
||||
failures: int = 0
|
||||
paired: list[dict] = field(default_factory=list)
|
||||
on_paired: object = None
|
||||
requests: int = 0 # requests from the network (right or wrong code): the Frame can reach us
|
||||
flag: Path | None = None # WSL: the temporary firewall rule stays while this file exists
|
||||
hint: str = "" # set by the UI when nothing reached us after HINT_AFTER seconds: what may block the Frame
|
||||
_httpd: http.server.ThreadingHTTPServer | None = None
|
||||
|
||||
@property
|
||||
@@ -34,7 +110,7 @@ class PairingServer:
|
||||
|
||||
@property
|
||||
def one_liner(self) -> str:
|
||||
return f"curl -fsSL {self.url}/bootstrap.sh?code={self.code} | bash"
|
||||
return f"curl -fsS {self.url.removeprefix('http://')}/{self.code} | bash"
|
||||
|
||||
def start(self) -> PairingServer:
|
||||
server = self
|
||||
@@ -51,20 +127,24 @@ class PairingServer:
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
server.requests += 1
|
||||
url = urllib.parse.urlparse(self.path)
|
||||
q = dict(urllib.parse.parse_qsl(url.query))
|
||||
if not secrets.compare_digest(q.get("code", ""), server.code):
|
||||
path, code = url.path, q.get("code", "")
|
||||
if not code and path.count("/") == 1: # the short form: /<code> = the script
|
||||
path, code = "/bootstrap.sh", path[1:]
|
||||
if not secrets.compare_digest(code, server.code):
|
||||
server.failures += 1
|
||||
if server.failures >= MAX_FAILURES:
|
||||
server.stop_soon()
|
||||
return self._send(b"wrong or missing pairing code\n", status=403)
|
||||
if url.path == "/bootstrap.sh":
|
||||
if path == "/bootstrap.sh":
|
||||
text = (bootstrap_dir() / "bootstrap.sh").read_text()
|
||||
text = text.replace("__PC_URL__", server.url).replace("__PAIR_CODE__", server.code)
|
||||
return self._send(text.encode(), "text/x-shellscript")
|
||||
if url.path == "/key":
|
||||
if path == "/key":
|
||||
return self._send((app_public_key() + "\n").encode())
|
||||
if url.path == "/paired":
|
||||
if path == "/paired":
|
||||
info = {"host": self.client_address[0], "user": q.get("user", "steamos"), "name": q.get("host", "")}
|
||||
server.paired.append(info)
|
||||
if callable(server.on_paired):
|
||||
@@ -73,7 +153,13 @@ class PairingServer:
|
||||
return self._send(b"ok\n")
|
||||
return self._send(b"not found\n", status=404)
|
||||
|
||||
self._httpd = http.server.ThreadingHTTPServer(("0.0.0.0", self.port), Handler)
|
||||
for port in ((self.port,) if self.port else PORTS):
|
||||
try:
|
||||
self._httpd = http.server.ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
||||
break
|
||||
except OSError:
|
||||
if port == PORTS[-1]:
|
||||
raise
|
||||
self.port = self._httpd.server_address[1]
|
||||
threading.Thread(target=self._httpd.serve_forever, daemon=True).start()
|
||||
self._timer = threading.Timer(LIFETIME, self.stop)
|
||||
@@ -97,3 +183,5 @@ class PairingServer:
|
||||
timer = getattr(self, "_timer", None)
|
||||
if timer:
|
||||
timer.cancel()
|
||||
if self.flag is not None: # lets the temporary firewall rule go
|
||||
self.flag.unlink(missing_ok=True)
|
||||
@@ -112,7 +112,7 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
tags=_tags(plan.package),
|
||||
apk_name=plan.apk.name, settings=ctx.adapter_settings,
|
||||
files={k: v.decode() if isinstance(v, bytes) else v for k, v in ctx.files.items()}, env=ctx.env,
|
||||
obb_manifest=manifest or None, flatscreen=_flatscreen(plan.package),
|
||||
obb_manifest=manifest or None, flatscreen=_flatscreen(plan.package) or ctx.flatscreen,
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source, "alt": plan.recipe.use_alt},
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
|
||||
@@ -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})": "",
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {}))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,6 +6,7 @@ Works on native Windows and from WSL (Windows programs via interop). Revive is F
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
@@ -15,7 +16,7 @@ from pathlib import Path
|
||||
from ..core import winhost
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
from ..core.paths import agent_dir, user_data_dir
|
||||
from ..core.paths import agent_file, user_data_dir
|
||||
from ..patches.pcvr import game_args
|
||||
from ..validate.triage import triage
|
||||
from .base import Target
|
||||
@@ -27,7 +28,9 @@ REVIVE_LOG = "Revive/ReviveInjector.txt" # under %LOCALAPPDATA%
|
||||
|
||||
def _vdf():
|
||||
"""The agent's binary-VDF/shortcut code (stdlib only), reused so there's a single implementation."""
|
||||
spec = importlib.util.spec_from_file_location("frameport_agent_vdf", agent_dir() / "frameport_agent.py")
|
||||
path = str(agent_file()) # an explicit loader: in release bundles the source has a .txt name (paths.agent_file)
|
||||
spec = importlib.util.spec_from_loader("frameport_agent_vdf",
|
||||
importlib.machinery.SourceFileLoader("frameport_agent_vdf", path))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Cpp2IL (SamboyCoding/Cpp2IL, MIT): reads an IL2CPP Unity game's libil2cpp.so + global-metadata.dat and lists every
|
||||
method with its address. FramePort needs it only for Unity games whose text fields it fixes (frame.unity_text_input),
|
||||
so it is downloaded on first use into <user data>/tools/ (a self-contained executable per OS; no .NET install).
|
||||
|
||||
Its current releases are all pre-releases (the 2022.1 line is the one that reads Unity 6 / metadata v31), so the
|
||||
newest release with an asset for this computer is used. Override: FRAMEPORT_CPP2IL=<path to the executable>."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..core import cache
|
||||
from ..core.paths import tools_dir
|
||||
|
||||
RELEASES = "https://api.github.com/repos/SamboyCoding/Cpp2IL/releases"
|
||||
|
||||
|
||||
def asset_suffix() -> str:
|
||||
"""Release asset suffix for this computer, e.g. Linux, Linux-ARM64, OSX-ARM64, Windows.exe."""
|
||||
arm = platform.machine().lower() in ("arm64", "aarch64")
|
||||
if sys.platform == "win32":
|
||||
return "Windows-ARM64.exe" if arm else "Windows.exe"
|
||||
if sys.platform == "darwin":
|
||||
return "OSX-ARM64" if arm else "OSX"
|
||||
return "Linux-ARM64" if arm else "Linux"
|
||||
|
||||
|
||||
def pick_release(releases: list[dict], suffix: str) -> tuple[str, str] | None:
|
||||
"""(version, download url) of the newest release that has an executable for this computer."""
|
||||
for rel in releases:
|
||||
for asset in rel.get("assets", []):
|
||||
if asset.get("name", "").endswith("-" + suffix):
|
||||
return rel.get("tag_name", "?"), asset["browser_download_url"]
|
||||
return None
|
||||
|
||||
|
||||
def path() -> Path | None:
|
||||
if os.environ.get("FRAMEPORT_CPP2IL"):
|
||||
return Path(os.environ["FRAMEPORT_CPP2IL"])
|
||||
found = sorted(tools_dir().glob("cpp2il-*/Cpp2IL*"))
|
||||
return found[-1] if found else None
|
||||
|
||||
|
||||
def installed_version() -> str | None:
|
||||
p = path()
|
||||
return p.parent.name.removeprefix("cpp2il-") if p is not None and p.parent.name.startswith("cpp2il-") else None
|
||||
|
||||
|
||||
def ensure(progress=None) -> Path:
|
||||
"""The Cpp2IL executable, downloading it first if needed."""
|
||||
existing = path()
|
||||
if existing is not None and existing.exists():
|
||||
return existing
|
||||
releases = cache.cached_json("cpp2il-releases.json", RELEASES, max_age=7 * 86400, fallback=[]) or []
|
||||
picked = pick_release(releases, asset_suffix())
|
||||
if picked is None:
|
||||
raise RuntimeError("couldn't find a Cpp2IL download for this computer")
|
||||
version, url = picked
|
||||
exe = tools_dir() / f"cpp2il-{version}" / ("Cpp2IL.exe" if sys.platform == "win32" else "Cpp2IL")
|
||||
cache.download(url, exe, progress)
|
||||
exe.chmod(exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return exe
|
||||
+121
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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.")
|
||||
@@ -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. "
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,40 @@
|
||||
from frameport.ui import battery as B
|
||||
|
||||
|
||||
def bat(pct, plugged=False):
|
||||
return {"percent": pct, "status": "Charging" if plugged else "Discharging", "plugged": plugged}
|
||||
|
||||
|
||||
def test_warn_once_then_pause_then_resume_when_plugged_in():
|
||||
assert B.advice(bat(80), True, None, False) is None
|
||||
assert B.advice(bat(30), True, None, False) == "warn"
|
||||
assert B.advice(bat(28), True, None, True) is None # warned already
|
||||
assert B.advice(bat(15), True, None, True) == "pause"
|
||||
assert B.advice(bat(14), True, "battery", True) is None # still low and unplugged: stay paused
|
||||
assert B.advice(bat(14, plugged=True), True, "battery", True) == "resume"
|
||||
assert B.advice(bat(25), True, "battery", True) == "resume"
|
||||
|
||||
|
||||
def test_no_action_without_frame_work_or_while_charging_or_paused_for_another_reason():
|
||||
assert B.advice(bat(5), False, None, False) is None
|
||||
assert B.advice(bat(5, plugged=True), True, None, False) is None
|
||||
assert B.advice(bat(5), True, "frame", False) is None # waiting for the Frame to come back: not ours
|
||||
assert B.advice(bat(5), False, "battery", True) == "resume" # queue emptied (cancelled): don't stay paused
|
||||
assert B.advice(None, True, "battery", True) == "resume" # no reading any more (old agent): don't block forever
|
||||
assert B.advice(None, True, None, False) is None
|
||||
|
||||
|
||||
def test_plugged_in_but_draining_counts_as_not_charging():
|
||||
weak = {"percent": 12, "status": "Discharging", "plugged": True, "draining": True}
|
||||
assert B.advice(weak, True, None, True) == "pause" and B.low(weak) and B.label(weak) == "12 %"
|
||||
|
||||
|
||||
def test_label():
|
||||
assert B.label(bat(76)) == "76 %" and B.label(bat(76, True)) == "76 % ⚡" and B.label(None) == ""
|
||||
assert B.low(bat(20)) and not B.low(bat(20, True)) and not B.low(bat(60))
|
||||
|
||||
|
||||
def test_icon_follows_level_and_charging():
|
||||
assert B.icon(bat(7, True)) == "BATTERY_CHARGING_FULL_ROUNDED"
|
||||
assert B.icon(bat(7)) == "BATTERY_ALERT_ROUNDED" and B.icon(bat(100)) == "BATTERY_FULL_ROUNDED"
|
||||
assert B.icon(bat(50)) == "BATTERY_3_BAR_ROUNDED" and B.icon(bat(90)) == "BATTERY_6_BAR_ROUNDED"
|
||||
@@ -0,0 +1,46 @@
|
||||
"""bootstrap.sh: valid bash, placeholders for the pairing server, and the config.vdf edit that turns on Developer
|
||||
Mode."""
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core.paths import bootstrap_dir
|
||||
|
||||
SCRIPT = (bootstrap_dir() / "bootstrap.sh").read_text()
|
||||
|
||||
|
||||
def devmode_edit() -> str:
|
||||
return re.search(r"python3 - \"\$CONFIG\" <<'PY'[^\n]*\n(.*?)\nPY\n", SCRIPT, re.S).group(1)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash")
|
||||
def test_bash_syntax():
|
||||
subprocess.run(["bash", "-n"], input=SCRIPT, text=True, check=True)
|
||||
|
||||
|
||||
def test_placeholders_and_steps():
|
||||
assert "__PC_URL__" in SCRIPT and "__PAIR_CODE__" in SCRIPT
|
||||
assert "steamos-devkit-mode" in SCRIPT and "/etc/steamos-devkit-enabled" in SCRIPT
|
||||
assert "sudo" not in SCRIPT.split("set -euo pipefail", 1)[1] and "passwd" not in SCRIPT # no password, ever
|
||||
# Desktop Mode is a nested session inside steam.service: the Developer Mode job (it stops Steam) must run as a
|
||||
# user unit reached through the real user bus, logging to a file that outlives the terminal
|
||||
assert 'DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus"' in SCRIPT
|
||||
assert "user_systemd systemd-run --user" in SCRIPT and ".cache/frameport-setup.log" in SCRIPT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("before", [
|
||||
'"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n\t\t}\n\t}\n'
|
||||
'\t"developer"\n\t{\n\t\t"Other"\t\t"x"\n\t}\n}\n',
|
||||
'"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t}\n}\n',
|
||||
'"InstallConfigStore"\n{\n\t"developer"\n\t{\n\t\t"DevModeEnabled"\t\t"0"\n\t}\n}\n',
|
||||
])
|
||||
def test_devmode_config_edit(tmp_path, before):
|
||||
cfg = tmp_path / "config.vdf"
|
||||
cfg.write_text(before)
|
||||
subprocess.run([sys.executable, "-", str(cfg)], input=devmode_edit(), text=True, check=True)
|
||||
after = cfg.read_text()
|
||||
assert re.search(r'\t"developer"\n\t\{\n(?:.*\n)*?\t\t"DevModeEnabled"\t\t"1"\n', after)
|
||||
assert after.count("DevModeEnabled") == 1 and after.count("{") == after.count("}")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Issue #2: `flet build` compiles every .py of the app to .pyc, bundled data included, so release bundles had no Frame
|
||||
agent source to upload. Bundles carry it as frameport_agent.py.txt too; everything reads it through paths.agent_file."""
|
||||
import importlib.util
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core import paths
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bundle_like(tmp_path, monkeypatch):
|
||||
"""A data folder like the one in a release bundle: the agent only compiled, plus the .txt copy."""
|
||||
(tmp_path / "agent").mkdir()
|
||||
(tmp_path / "agent" / "frameport_agent.pyc").write_bytes(b"compiled")
|
||||
shutil.copy(ROOT / "agent/frameport_agent.py", tmp_path / "agent" / paths.AGENT_SOURCE_COPY)
|
||||
monkeypatch.setattr(paths, "DATA_ROOT", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_agent_file_falls_back_to_the_txt_copy(bundle_like):
|
||||
assert paths.agent_file().name == "frameport_agent.py.txt"
|
||||
from frameport.frame.connection import bundled_agent_version
|
||||
|
||||
assert bundled_agent_version() and bundled_agent_version() > 30
|
||||
|
||||
|
||||
def test_pc_shortcut_code_loads_from_the_txt_copy(bundle_like):
|
||||
from frameport.targets import pc_revive
|
||||
|
||||
assert callable(pc_revive._vdf().vdf_decode)
|
||||
|
||||
|
||||
def test_source_checkout_uses_the_py():
|
||||
assert paths.agent_file() == paths.agent_dir() / "frameport_agent.py"
|
||||
|
||||
|
||||
def test_package_script_checks_the_bundle(tmp_path):
|
||||
spec = importlib.util.spec_from_file_location("package", ROOT / "scripts/package.py")
|
||||
package = importlib.util.module_from_spec(spec)
|
||||
sys.modules["package"] = package
|
||||
spec.loader.exec_module(package)
|
||||
data = tmp_path / "app/frameport/_data/agent"
|
||||
data.mkdir(parents=True)
|
||||
(data / "frameport_agent.pyc").write_bytes(b"x")
|
||||
with pytest.raises(SystemExit, match="agent's source is missing"):
|
||||
package.check_bundle(tmp_path)
|
||||
(data / "frameport_agent.py.txt").write_text("x")
|
||||
package.check_bundle(tmp_path)
|
||||
@@ -0,0 +1,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")
|
||||
@@ -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
@@ -229,9 +229,11 @@ def test_refresh_keeps_a_working_connection_when_a_status_query_fails():
|
||||
|
||||
|
||||
def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monkeypatch):
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from frameport.frame import pairing
|
||||
|
||||
@@ -248,7 +250,16 @@ def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monke
|
||||
|
||||
seen = []
|
||||
s = pairing.PairingServer(on_paired=seen.append).start()
|
||||
assert len(s.code) == 16
|
||||
other = pairing.PairingServer().start() # a second server (port taken) falls back to another port
|
||||
assert other.port != s.port
|
||||
other.stop()
|
||||
assert len(s.code) == 8
|
||||
assert s.one_liner == f"curl -fsS 127.0.0.1:{s.port}/{s.code} | bash" # typed by hand on the Frame: short
|
||||
assert s.requests == 0
|
||||
assert get(s, f"/{s.code}") == 200 and get(s, "/deadbeef") == 403 # the short form serves the script
|
||||
assert s.requests == 2 # any request (even a wrong code) proves the Frame can reach us: no firewall hint
|
||||
script = urllib.request.urlopen(f"http://127.0.0.1:{s.port}/{s.code}", timeout=5).read().decode()
|
||||
assert f"http://127.0.0.1:{s.port}" in script and s.code in script and "__PAIR_CODE__" not in script
|
||||
assert get(s, "/key?code=000000") == 403
|
||||
assert get(s, f"/paired?code={s.code}&user=steamos&host=frame") == 200 and seen
|
||||
for _ in range(50):
|
||||
@@ -256,6 +267,12 @@ def test_pairing_server_needs_the_code_and_stops_after_pairing_or_guessing(monke
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert not s.running
|
||||
flag = Path(tempfile.mkdtemp()) / "pairing.flag" # WSL: the temporary firewall rule lives while this exists
|
||||
flag.write_text("x")
|
||||
f = pairing.PairingServer().start()
|
||||
f.flag = flag
|
||||
f.stop()
|
||||
assert not flag.exists()
|
||||
g = pairing.PairingServer().start()
|
||||
for _ in range(3):
|
||||
get(g, "/key?code=guess")
|
||||
@@ -440,3 +457,35 @@ def test_activity_progress_ticks_only_touch_the_progress_controls(monkeypatch):
|
||||
bar = panel._live[run.id][0]
|
||||
assert bar.value == 0.5 and panel.root not in updated[-1] and bar in updated[-1]
|
||||
assert panel.list.controls == first_list # nothing rebuilt
|
||||
|
||||
|
||||
def test_flet_updates_are_serialized():
|
||||
import threading
|
||||
|
||||
from flet.messaging.session import Session
|
||||
|
||||
from frameport.ui.app import serialize_flet_updates
|
||||
|
||||
original = Session.patch_control
|
||||
try:
|
||||
active, peak = [0], [0]
|
||||
guard = threading.Lock()
|
||||
|
||||
def fake(self, *a, **k):
|
||||
with guard:
|
||||
active[0] += 1
|
||||
peak[0] = max(peak[0], active[0])
|
||||
threading.Event().wait(0.01)
|
||||
with guard:
|
||||
active[0] -= 1
|
||||
Session.patch_control = fake
|
||||
serialize_flet_updates()
|
||||
serialize_flet_updates() # idempotent
|
||||
threads = [threading.Thread(target=Session.patch_control, args=(None,)) for _ in range(8)]
|
||||
for th in threads:
|
||||
th.start()
|
||||
for th in threads:
|
||||
th.join()
|
||||
assert peak[0] == 1
|
||||
finally:
|
||||
Session.patch_control = original
|
||||
@@ -0,0 +1,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"]
|
||||
Reference in new issue
Block a user