mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 13:00:36 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed95684dc | ||
|
|
a6ca8385a9 | ||
|
|
210410f125 | ||
|
|
7e2502a552 | ||
|
|
a57d89e874 | ||
|
|
aade29982f | ||
|
|
d94cab6048 | ||
|
|
f17fdbf271 | ||
|
|
fb70c09d72 | ||
|
|
69bf2700e1 | ||
|
|
0b28da5521 | ||
|
|
9e9a4c0f6c | ||
|
|
bb3f6b9e7c | ||
|
|
dc7533e4e1 | ||
|
|
3660167bd8 | ||
|
|
6a47fad8f7 | ||
|
|
0f8062f881 | ||
|
|
65369f154c |
No files matched your search
@@ -40,6 +40,8 @@ jobs:
|
||||
archive: FramePort-macos-arm64.zip
|
||||
- os: ubuntu-22.04 # oldest supported: the bundle needs the build host's GLib/glibc or newer
|
||||
archive: FramePort-linux-x64.tar.gz
|
||||
- os: ubuntu-22.04-arm # Linux ARM64 (same base as x64)
|
||||
archive: FramePort-linux-arm64.tar.gz
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -50,9 +52,9 @@ jobs:
|
||||
- run: uv sync --extra dev
|
||||
- id: flet
|
||||
run: uv run python scripts/package.py
|
||||
continue-on-error: ${{ runner.os == 'macOS' }}
|
||||
- name: macOS fallback (PyInstaller bundle)
|
||||
if: runner.os == 'macOS' && steps.flet.outcome == 'failure'
|
||||
continue-on-error: ${{ runner.os == 'macOS' || runner.arch == 'ARM64' }}
|
||||
- name: macOS / Linux ARM64 fallback (PyInstaller bundle)
|
||||
if: (runner.os == 'macOS' || runner.arch == 'ARM64') && steps.flet.outcome == 'failure'
|
||||
run: uv run --with pyinstaller python scripts/package.py --pyinstaller
|
||||
|
||||
# ---- signing: self-signed certificate on Windows, ad-hoc on macOS ----
|
||||
@@ -95,7 +97,9 @@ jobs:
|
||||
run: ditto -c -k --keepParent "$(find dist -maxdepth 3 -name '*.app' -type d | head -1)" ${{ matrix.archive }}
|
||||
- name: Package (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: tar czf ${{ matrix.archive }} -C dist --transform 's,^linux,FramePort,' linux
|
||||
run: | # flet build -> dist/linux; the PyInstaller fallback -> dist/FramePort (one folder)
|
||||
if [ -d dist/linux ]; then tar czf ${{ matrix.archive }} -C dist --transform 's,^linux,FramePort,' linux
|
||||
else tar czf ${{ matrix.archive }} -C dist FramePort; fi
|
||||
# self-update with this archive: unpack, verify the layout (+ the signer on Windows), run the real swap script
|
||||
- name: Update smoke test
|
||||
run: uv run python scripts/update_smoke.py ${{ matrix.archive }}
|
||||
@@ -137,10 +141,10 @@ jobs:
|
||||
git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}"
|
||||
whatsnew=$(git tag -l --format='%(contents:body)' "$tag")
|
||||
[ -n "$whatsnew" ] || whatsnew=$(git tag -l --format='%(contents:subject)' "$tag")
|
||||
{ echo "FramePort ${tag}: Windows (x64), macOS (Apple Silicon) and Linux (x64)."; echo;
|
||||
{ echo "FramePort ${tag}: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64)."; echo;
|
||||
if [ -n "$whatsnew" ] && [ "$(git cat-file -t "$tag")" = tag ]; then
|
||||
echo "## What's new"; echo; echo "$whatsnew"; echo; fi
|
||||
echo "Already have FramePort? It offers this update itself (Library → **Update now**, or \`frameport update\`)."
|
||||
echo; sed -n '/^## First launch/,$p' docs/INSTALL.md; } > "$notes"
|
||||
echo "Already have FramePort? It offers this update itself (Library → **Update now**)."
|
||||
echo; cat packaging/release-footer.md; } > "$notes" # short: the update dialog shows these notes
|
||||
gh release delete "$tag" --yes 2>/dev/null || true
|
||||
gh release create "$tag" out/* packaging/FramePort-selfsigned.cer --title "FramePort ${tag}" --notes-file "$notes"
|
||||
@@ -393,6 +393,27 @@ swapchain was halved (1536/eye) in case memory is the limit (unverified). Frame
|
||||
(SteamVR runtimes, Lepton scripts, logs; never commit Valve binaries): `~/frameport-research/frame-data-2026-09-30/`.
|
||||
Not yet verified in the headset.
|
||||
|
||||
**Companion apps / intents (2026-10-03, tested with Stremio + 4XVR):** a second APK can be installed into a running
|
||||
Lepton container (`podman exec -i lepton-steamlaunch-<appid> pm install -g -S <size> < apk`) and runs there, VR
|
||||
included (4XVR in Stremio's flatscreen container: FrameBridge 72 fps, settings via LEPTON_ENV_FRAMEBRIDGE_CONFIG). But
|
||||
Lepton's services.jar (`ActivityStarter.execute`) intercepts **every** `android.intent.action.VIEW` with data (any
|
||||
scheme, explicit component or not, no property to disable): it writes `steam://openurl/<uri>` to `/lepton/steam.pipe`
|
||||
(= the host Steam client's `~/.steam/steam.pipe`) and starts nothing. So "open in external player" hand-offs (Stremio
|
||||
→ 4XVR) can't work inside Lepton; don't retry without Valve changing it. Lepton installs exactly one `*.apk` per app
|
||||
folder (two break `get_apk_path`).
|
||||
**Text input (2026-10-03, verified in the headset with Stremio VR):** Lepton's Android has no IME (`ime list` empty)
|
||||
and runs VR apps headless (`lepton.headless=true`): no Android window has input focus (`dumpsys input` FocusedWindows
|
||||
empty), so neither `input text`, a USB keyboard nor Steam's keyboard reach the app. `lepton-show-flatscreen` on a VR
|
||||
app keeps VR working (FrameBridge 72 fps) and gives the window focus; Steam's on-screen keyboard then opens for text
|
||||
fields. Unity's TMP_InputField on non-Quest Android waits for the system keyboard (`TouchScreenKeyboardShouldBeUsed`)
|
||||
and deselects a frame later unless `isKeyboardUsingEvents` (Android: `InPlaceEditing() && m_HideSoftKeyboard`);
|
||||
uGUI InputField's LateUpdate keeps the field when `InPlaceEditing()`. Patch `frame.unity_text_input` rewrites them
|
||||
(`mov w0,#0|#1; ret`) at Cpp2IL's **Offset** (= file offset; RVA differs by 0x4000 in Stremio's lib), Cpp2IL
|
||||
2022.1 pre-release (Unity 6 / metadata v31; Il2CppDumper can't), cached per libil2cpp sha. `/dev/uinput` has an ACL
|
||||
for steamos (Steam Input) → agent v33 `_keyboard` uinput keyboard ("Type on Frame") reaches everything with focus.
|
||||
Installing a second APK into a container with `pm install` re-runs Lepton's post-install hook on the **main** app
|
||||
(`lepton.active_app_id`; moves its files to /data/steam_app) and corrupts it on the next start (fix: touch the APK →
|
||||
re-bake); `cmd_real package install` skips the hook.
|
||||
**Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `<base>/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Files: `install/files.py`, `frameport frame send|storage`, GUI Files tab (formerly Frame → Send files).
|
||||
**SteamVR per-app settings (2026-09-30):** editing steamvr.vrsettings while SteamVR runs is lost; the web API (127.0.0.1:27062 /app/setsettings) needs `x-steamvr-secret`. `native/vrsettings` = `fp_vrsettings.exe` (freestanding, OpenVR `FnTable:IVRSettings_003` as a Utility app, loads SteamVR's bin/win64/openvr_api.dll) sets them live and SteamVR persists them: section `steam.app.<shortcut appid>`, keys `preferredRefreshRate` (float) and `motionSmoothingOverride` (0 global, 1 on, 2 off, 3 always). Steam Link (vrlink) lists the Frame's rates 72/80/90/96/108/120/144 in vrserver.txt and follows the per-app preference ("host preferred N Hz"; whether the key is honoured is unverified in-headset yet). Judder metric: vrcompositor.txt session summary dropped + "Timed out. N total" (Stormland: 0 dropped but 313 timeouts in 2 min); fpsVR (`%LOCALAPPDATA%\fpsVR\*.json`, 0.1 ms histograms) gives p99 CPU/GPU ms. `pcvr.steamvr_tuning` (default on, PC only) applies on Play: highest rate whose budget ≥ p99×1.05, at least one step down, smoothing on.
|
||||
|
||||
@@ -405,7 +426,8 @@ Maintainer-only notes (accounts, credentials, key locations) live in the git-ign
|
||||
Public repo `github.com/spoopyghosty0/frameport` (branch `main`). Push a `v*` tag → CI (`.github/workflows/build.yml`)
|
||||
tests, builds Windows x64 / macOS arm64 / Linux x64 bundles, signs, attests and publishes a GitHub Release
|
||||
(`FramePort-*.zip/.tar.gz`, the CLI wheel `frameport-<ver>-py3-none-any.whl`, `SHA256SUMS.txt`,
|
||||
`FramePort-selfsigned.cer`; notes = "What's new" from the annotated tag message + `docs/INSTALL.md` from "First launch").
|
||||
`FramePort-selfsigned.cer`; notes = "What's new" from the annotated tag message + the short `packaging/release-footer.md`; owner: keep release
|
||||
notes short — a few "What's new" bullets, nothing long after them).
|
||||
Installed apps find the release themselves (self-update), so the notes are what users see in the update dialog.
|
||||
- **Release checklist:** bump `src/frameport/_version.py` (the only version; `scripts/package.py` fails a tag build
|
||||
whose tag ≠ `v<_version>`), commit, `git tag -a vX.Y.Z -m "FramePort X.Y.Z" -m "<What's new, Markdown bullets>"`,
|
||||
|
||||
@@ -1,211 +1,106 @@
|
||||
# FramePort
|
||||
|
||||
FramePort installs Meta Quest standalone games, other Android apps and PC VR games on the **Valve Steam Frame**. It
|
||||
patches a game so it runs on the Frame's runtimes, copies it to the headset over Wi-Fi and adds it to the Frame's Steam
|
||||
library with artwork.
|
||||
[](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.
|
||||
|
||||
## Highlights
|
||||
## Quick start
|
||||
|
||||
- **Painless setup, no root.** One command in the Frame's Konsole connects it: it turns on Developer Mode with Valve's
|
||||
own helper and lets FramePort in. No `sudo`, no password, no packages, and SteamOS's read-only system stays
|
||||
untouched. Nothing is installed system-wide on your computer either.
|
||||
- **One click from game backup to Steam library.** FramePort converts, patches, signs, checks, uploads and adds the
|
||||
game to the Frame's Steam library with full artwork, then runs a short launch test and reads the logs for known
|
||||
problems.
|
||||
- **Knows what each game needs.** Recipes come from a catalog of games tested on the Frame, plus detection rules for
|
||||
everything else. Every patch is explained in plain words, with **Show technical details** for the exact change.
|
||||
- **Fills in what the Frame lacks.** FramePort's own OpenXR adapter (FrameBridge) emulates what Quest games expect
|
||||
and the Frame doesn't provide: passthrough, a room for mixed-reality games, controller models, curved screens,
|
||||
360° video layers. **Game settings** (sharpness, refresh rate, controllers, menus) are switches and sliders,
|
||||
shown only when they matter for that game.
|
||||
- **Long queues that finish.** Installs queue up and keep the Frame awake. If the Frame sleeps or drops off the
|
||||
Wi-Fi, the queue pauses and resumes, and uploads continue where they stopped. A USB cable or the Frame's own
|
||||
hotspot is used automatically for faster uploads.
|
||||
- **Your files stay yours.** Your game files are never modified: the converted copy is temporary. Each game keeps
|
||||
its own signing key, so updates keep your saves.
|
||||
- **Looks at home in Steam.** Store artwork, descriptions and genres become Steam art and tags. Apps without store
|
||||
art get generated artwork from their icon.
|
||||
- **More than Quest games.** Ordinary Android apps open as windows in the headset, with Android's navigation bar
|
||||
hidden. PC VR games run through Proton on the Frame or through Revive and SteamVR on your PC. The **Files** tab
|
||||
sends videos and other files into the Frame's shared folders or a game's own storage, with drag and drop.
|
||||
- **Easy to keep current and to report.** Signed releases update themselves. Diagnostics are redacted (no IP
|
||||
addresses, user names or paths), and problem reports and working configs open as prefilled GitHub issues. The
|
||||
interface is ready for translation.
|
||||
1. [Download](https://github.com/spoopyghosty0/frameport/releases/latest) and unzip the build for Windows, macOS
|
||||
(Apple Silicon) or Linux, then start FramePort.
|
||||
2. **Connect the Frame** (once):
|
||||
1. In FramePort click **Steam Frame → Show setup command**. Keep FramePort open; the Frame and your computer must
|
||||
be on the same Wi-Fi.
|
||||
2. On the Frame press the **Steam button → Power → Switch to Desktop**.
|
||||
3. Open the app menu (bottom-left corner), search for **Konsole** and open it.
|
||||
4. Type the command FramePort shows exactly as shown (on-screen keyboard or any USB/Bluetooth keyboard) and press
|
||||
**Enter**. This will run the following [bash setup script](bootstrap/bootstrap.sh).
|
||||
5. After a few seconds the desktop closes by itself and the Frame returns to its normal view; that's expected. If
|
||||
Steam asks to install **Lepton**, confirm it. FramePort shows the Frame as connected within a minute. No
|
||||
password needed.
|
||||
3. **Add games → Scan a folder** with your game backups (APK + OBB, or PC VR game folders).
|
||||
4. Open a game → **Install on Frame**, then play it from the Frame's Steam library.
|
||||
|
||||
What runs, and how well, is listed in [What works](#what-works).
|
||||
Full guide, firewalls and troubleshooting: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
## Getting started
|
||||
## Type on Frame
|
||||
|
||||
1. **Download** the archive for your computer from the
|
||||
[latest release](https://github.com/spoopyghosty0/frameport/releases/latest) and extract it anywhere:
|
||||
Typing in VR is painful, so FramePort turns your computer's keyboard into a keyboard for the Frame. Open **Type on
|
||||
Frame** (keyboard icon on the sidebar's Frame card, the Steam Frame page, or a game's menu), select a text field in
|
||||
the headset and type: searches, logins, chat, in any app, in Steam or on the desktop. Paste longer text to type it in
|
||||
one go. Nothing to install: FramePort adds a virtual keyboard on the Frame while the window is open, without root.
|
||||
|
||||
| Computer | File | Start |
|
||||
|---|---|---|
|
||||
| Windows 10/11 (x64) | `FramePort-windows-x64.zip` | `FramePort.exe` |
|
||||
| macOS (Apple Silicon) | `FramePort-macos-arm64.zip` | `FramePort.app` |
|
||||
| Linux (x64) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
|
||||

|
||||
|
||||
The builds are self-signed, so the first start shows a warning: Windows → **More info → Run anyway**;
|
||||
macOS → right-click the app → **Open**. Details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
2. **Get the tools**: on first start FramePort downloads what it needs (Java runtime, OVRPort, apksigner) into its own
|
||||
folder. Nothing is installed system-wide.
|
||||
3. **Connect the Frame**: in FramePort open **Steam Frame** → **Show setup command**, then on the Frame switch to
|
||||
Desktop mode, open Konsole and type that one short command. It turns on Developer Mode and lets FramePort in:
|
||||
no root, no `sudo`, no password. This is needed once. Everything it changes is listed in
|
||||
[What FramePort changes on the Frame](#what-frameport-changes-on-the-frame); firewall notes are in
|
||||
[Network and firewalls](#network-and-firewalls).
|
||||
4. **Add games**: **Add games → Scan a folder** with your game backups: Android/Quest games (APK + OBB) or PC VR
|
||||
game folders.
|
||||
FramePort identifies each game and fetches its artwork and store details.
|
||||
5. **Install**: open a game and click **Install on Frame**. FramePort patches, checks, uploads and adds the game to the
|
||||
Frame's Steam library, then runs a short launch test. Several installs queue up; if the Frame goes to sleep or
|
||||
drops off the Wi-Fi, the queue waits and continues where it stopped (FramePort keeps the Frame awake meanwhile).
|
||||
Your game files are never changed: the converted copy is temporary.
|
||||
6. **Play**: put the headset on and start the game from the Steam library (or click **Play on Frame**).
|
||||
Unity apps whose text fields close the moment you select them on the Frame (no system keyboard there) get a per-game
|
||||
fix, so Steam's on-screen keyboard and Type on Frame work in them too.
|
||||
[Details](docs/INSTALL.md#typing-on-the-frame).
|
||||
|
||||

|
||||
## Compatibility
|
||||
|
||||
Each game has **Game settings** in plain words (sharpness, refresh rate, controllers, menus, 360° video, mixed
|
||||
reality), showing only what matters for that game. Changes are kept with the game and reach the Frame right away.
|
||||
If a game has already been tested with FramePort, it will automatically use the optimal game config. Otherwise, FramePort
|
||||
will attempt to guess key patches. If you find a new config that works for an app you are testing, please consider submitting it to the community!
|
||||
|
||||

|
||||
|
||||
FramePort updates itself: when a new version is released, the Library shows **Update now**.
|
||||
|
||||
### Network and firewalls
|
||||
|
||||
During first-time setup the Frame downloads the setup script from FramePort on your computer: TCP ports 8765–8767,
|
||||
open only while the setup command is shown and for at most 30 minutes. After that, every connection goes from your
|
||||
computer to the Frame (SSH), and nothing needs to reach your computer.
|
||||
|
||||
- **Windows:** allow FramePort when Windows asks (Private networks). If Windows treats your network as Public, allow
|
||||
Public too or switch the network to Private.
|
||||
- **macOS:** if the macOS firewall is on, allow incoming connections when it asks.
|
||||
- **Linux:** if firewalld or ufw blocks the port, FramePort shows the command that opens it (firewalld: until the next
|
||||
restart).
|
||||
- **WSL:** FramePort adds a temporary Hyper-V firewall rule (one admin prompt) and removes it when setup is done. WSL
|
||||
needs mirrored networking (`networkingMode=mirrored` in `.wslconfig`).
|
||||
- If nothing reaches FramePort within about 45 seconds, the setup page says what is likely blocking it on your system.
|
||||
- **No inbound connection at all:** turn on Developer Mode in the Frame's settings first. The Frame then appears under
|
||||
**On your network**. On the Frame open Settings → Developer → **Pair new host**, click **Connect** in FramePort and
|
||||
approve it (Valve's own devkit pairing). FramePort sets up the rest over SSH.
|
||||
|
||||
## What FramePort changes on the Frame
|
||||
|
||||
The setup command (step 3) runs [`bootstrap/bootstrap.sh`](bootstrap/bootstrap.sh), served by the app over your
|
||||
local network. Everything it changes:
|
||||
|
||||
| Change | Where | How to undo |
|
||||
|---|---|---|
|
||||
| Turns on **Developer Mode** (only if it's off). Steam restarts once, which closes Desktop Mode; the rest of the setup finishes on its own as a user service (log: `~/.cache/frameport-setup.log`). | `"DevModeEnabled" "1"` in `~/.local/share/Steam/config/config.vdf` (old file kept as `config.vdf.before-frameport`), then Valve's own `steamos-polkit-helpers/steamos-devkit-mode --enable`. That helper enables the SSH server (`sshd`), the devkit service that makes the Frame findable on the network, the remote-desktop and debug services, and system crash dumps. | Settings → System → Developer → Developer Mode off. Valve's helper switches all of those services off again. |
|
||||
| Lets the app's SSH key in. | One line ending in `frameport` in `~/.ssh/authorized_keys`. The folder and file are created if missing. | Delete that line. |
|
||||
| Configures podman for Lepton. Rootless podman leaks one kernel keyring per container start, and after about 200 game starts every game fails. | `[containers]` / `keyring = false` in `~/.config/containers/containers.conf`. | Remove those lines. |
|
||||
| Asks Steam to install **Lepton** (Valve's Android runtime, Steam app 3029110) if it's missing. You confirm it in Steam. | Steam library | Uninstall it in Steam. |
|
||||
|
||||
The script runs as your user: no root, no `sudo`, no password. The only system-level change, Developer Mode, is
|
||||
made by Valve's own helper, the same one the Settings switch uses. If Developer Mode can't be turned on
|
||||
automatically, the script asks you to turn it on in Settings → System → Developer and run the command again.
|
||||
|
||||
Nothing else on the system is touched: no packages, no read-only-filesystem changes, no polkit rules. The script
|
||||
also leaves two files: `~/.cache/frameport-setup.sh` (the part that runs on its own) and its log.
|
||||
|
||||
**Later, the app adds** (all as your user, no root, no `sudo`):
|
||||
- FramePort's helper in `~/.local/share/frameport/`;
|
||||
- the games, each with a launcher and its data in `~/Applications/quest-frame/<package>/`;
|
||||
- their Steam library entries and artwork (`shortcuts.vdf` + `config/grid/`);
|
||||
- for PC VR games: an OpenXR layer (`~/.local/share/openxr/1/api_layers/explicit.d/XR_APILAYER_FRAMEPORT_timefix.json`)
|
||||
and, when the first PC VR game is installed, Valve's ARM64 Proton and its Steam Linux Runtime (Steam downloads them;
|
||||
Steam restarts once).
|
||||
|
||||
**Settings → Uninstall FramePort → Also remove from the Frame** deletes the games, their Steam entries, the helper
|
||||
folder, the OpenXR layer and the setup script's files. Developer Mode, the SSH key line, the podman setting, Lepton
|
||||
and Proton stay. Undo them as shown above or in Steam.
|
||||
|
||||
## What works
|
||||
|
||||
The built-in catalog has tested settings for 38 games (27 work, 5 work with known issues, 6 can't run on the Frame).
|
||||
Other games get suggested patches from detection rules; each suggestion states its reason, and every patch can be
|
||||
switched on or off under **Customize**: described in plain words, with **Show technical details** for the exact
|
||||
effect of each patch.
|
||||
|
||||
Tried an untested game? Its page asks how it runs; **Share working config…** opens a prefilled GitHub issue so your
|
||||
recipe can join the built-in catalog for everyone.
|
||||
|
||||

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

|
||||
**[List of tested games](docs/GAMES.md)**
|
||||
|
||||
## Built on
|
||||
|
||||
FramePort is a front end for other projects; most of the functionality comes from them:
|
||||
|
||||
| Project | Used for |
|
||||
|---|---|
|
||||
| [OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (overport, originally [ovrport/app](https://github.com/ovrport/app)) | Converts Quest games to OpenXR: its CLI applies the game patches and supplies the OpenXR loader; FramePort also includes its VrApi→OpenXR adapter |
|
||||
| Valve Lepton, Proton and SteamVR | Run Android games, Windows games and OpenXR on the Frame |
|
||||
| [Revive](https://github.com/LibreVR/Revive) (LibreVR) | Runs Oculus Rift games on OpenXR / SteamVR |
|
||||
| Mesa (Zink) | OpenGL ES on Vulkan on the Frame |
|
||||
| [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK) | OpenXR headers for the native layers |
|
||||
| Eclipse Temurin, Android apksigner, Android NDK | Java runtime, APK signing, building the native layers |
|
||||
| [Flet](https://flet.dev) | The desktop app |
|
||||
| OculusDB, Steam store | Game descriptions, genres and artwork |
|
||||
|
||||
FramePort's own parts: game detection and recipes, the Steam Frame OpenXR adapter (FrameBridge) and the other native
|
||||
fixes in `native/`, the installer agent that runs on the Frame, and the desktop/command-line app.
|
||||
|
||||
## Command line
|
||||
|
||||
The same functions are available as `frameport` (included in the source tree; also released as a Python wheel):
|
||||
`frameport scan`, `frameport build`, `frameport install`, `frameport test`, `frameport update`. Run
|
||||
`frameport --help` for the full list.
|
||||
[OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (Quest → OpenXR, originally
|
||||
[ovrport/app](https://github.com/ovrport/app)) · Valve Lepton, Proton and SteamVR ·
|
||||
[Revive](https://github.com/LibreVR/Revive) · Mesa (Zink) · [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK)
|
||||
· Eclipse Temurin, Android apksigner and NDK · [Flet](https://flet.dev) · OculusDB and Steam store data.
|
||||
What FramePort adds itself: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
- [docs/INSTALL.md](docs/INSTALL.md): installing, first launch, updating, Rift games, sending files, reporting problems.
|
||||
- [docs/PLAYBOOK.md](docs/PLAYBOOK.md): symptoms and fixes per game.
|
||||
- [docs/FRAME_RUNTIME.md](docs/FRAME_RUNTIME.md): Steam Frame runtime facts.
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): how the code is organised.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md): contributing code, recipes and translations.
|
||||
| | |
|
||||
|---|---|
|
||||
| [INSTALL.md](docs/INSTALL.md) | Install, connect, update, PC VR, files, problem reports |
|
||||
| [FRAME_SETUP.md](docs/FRAME_SETUP.md) | What setup changes on the Frame, networks and firewalls, undoing it |
|
||||
| [COMPATIBILITY.md](docs/COMPATIBILITY.md) | What runs and how well |
|
||||
| [GAMES.md](docs/GAMES.md) | Tested games and how well they run |
|
||||
| [PLAYBOOK.md](docs/PLAYBOOK.md) | Symptoms and fixes per game |
|
||||
| [FRAME_RUNTIME.md](docs/FRAME_RUNTIME.md) | Steam Frame runtime facts |
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | How the code is organised |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | Code, recipes, translations |
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
uv sync --extra dev
|
||||
uv run frameport-gui # the app
|
||||
uv run frameport --help # command line
|
||||
uv run pytest # tests
|
||||
```
|
||||
|
||||
See `CLAUDE.md` for project notes and `native/README.md` for the native components.
|
||||
## AI Usage Notice
|
||||
While I would like to program everything manually, I no longer have much free time for personal projects. As a result I make use of AI tools to make it significantly quicker to debug compatibility issues.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0-only (it includes GPL-3.0 code from OVRPort). Not affiliated with Valve or Meta.
|
||||
GPL-3.0-only (includes GPL-3.0 code from OVRPort). Not affiliated with Valve or Meta.
|
||||
+155
-1
@@ -7,6 +7,7 @@ The PC app uploads this file to ~/.local/share/frameport/agent/ and calls:
|
||||
Commands: info, prepare, finalize, shortcuts, shortcut_status, launch_test, stop, set_settings, uninstall,
|
||||
install_lepton, list_installed, proton_status, install_proton, prepare_pcvr, finalize_pcvr,
|
||||
controller_models.
|
||||
Streaming: python3 frameport_agent.py _keyboard (a virtual keyboard: JSON lines on stdin, see keyboard_session)
|
||||
|
||||
Install layout (one Lepton container per game; same as the manual installs from 2026-09):
|
||||
~/Applications/quest-frame/<pkg>/ anchor: launch.sh, deployment.json, artwork/ (always internal storage)
|
||||
@@ -20,6 +21,7 @@ PC VR (Oculus Rift) games packed for the Frame (id "rift.<slug>"), run by Proton
|
||||
<dest>/<id>/revive/ Revive (ReviveInjector.exe + DLLs)
|
||||
<dest>/<id>/compatdata/ Proton prefix = saves (kept across reinstalls), launch.log
|
||||
"""
|
||||
import fcntl
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
@@ -33,7 +35,7 @@ import sys
|
||||
import time
|
||||
import zlib
|
||||
|
||||
AGENT_VERSION = 31
|
||||
AGENT_VERSION = 33
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -602,6 +604,43 @@ def key_usage():
|
||||
return {}
|
||||
|
||||
|
||||
POWER_SUPPLY = "/sys/class/power_supply"
|
||||
CHARGER_TYPES = ("Mains", "USB", "USB_C", "USB_PD", "USB_PD_DRP", "USB_DCP", "USB_CDP", "USB_ACA", "Wireless")
|
||||
|
||||
|
||||
def battery_state():
|
||||
"""The Frame's battery: {"percent", "status" (Charging/Discharging/Full/Not charging), "plugged", "draining"};
|
||||
None without one. "plugged" = a charger reports online (or the battery says it's charging/full); "draining" = the
|
||||
battery's own gauge says Discharging (with a charger: it supplies less than the Frame uses, or just booted).
|
||||
On the Frame (2026-10-03): max1720x_bat (Battery), pm8550b-charger (Unknown), tcpm …typec (USB, online=1)."""
|
||||
def read(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
battery, plugged = None, False
|
||||
try:
|
||||
names = sorted(os.listdir(POWER_SUPPLY))
|
||||
except OSError:
|
||||
return None
|
||||
for name in names:
|
||||
d = os.path.join(POWER_SUPPLY, name)
|
||||
kind = read(os.path.join(d, "type"))
|
||||
if kind == "Battery" and battery is None and read(os.path.join(d, "capacity")).isdigit():
|
||||
battery = {"percent": int(read(os.path.join(d, "capacity"))), "status": read(os.path.join(d, "status"))}
|
||||
elif kind in CHARGER_TYPES and read(os.path.join(d, "online")) == "1":
|
||||
plugged = True
|
||||
if battery is not None:
|
||||
battery["plugged"] = plugged or battery["status"] in ("Charging", "Full")
|
||||
battery["draining"] = battery["status"] == "Discharging"
|
||||
return battery
|
||||
|
||||
|
||||
def cmd_battery(args):
|
||||
return {"battery": battery_state()}
|
||||
|
||||
|
||||
def cmd_info(args):
|
||||
lepton, app = lepton_path()
|
||||
osr = {}
|
||||
@@ -621,6 +660,7 @@ def cmd_info(args):
|
||||
"steam_running": run(["pgrep", "-x", "steam"]).returncode == 0,
|
||||
"host_fixes": ensure_host_fixes(), "kernel_keys": key_usage(),
|
||||
"proton": cmd_proton_status({}) if args.get("proton", True) else None,
|
||||
"battery": battery_state(),
|
||||
}
|
||||
|
||||
|
||||
@@ -2294,10 +2334,124 @@ def cmd_cleanup(args):
|
||||
return {"removed": removed, "freed_bytes": freed}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ virtual keyboard
|
||||
# "Type on Frame": the PC's key presses become a real keyboard on the Frame (Linux uinput; the steamos user may open
|
||||
# /dev/uinput, an ACL entry made for Steam Input, no root). A real input device reaches everything that has focus:
|
||||
# Android windows (Lepton's wayland_keyboard), the Steam UI, the desktop, Proton games.
|
||||
UINPUT = "/dev/uinput"
|
||||
UI_SET_EVBIT, UI_SET_KEYBIT, UI_DEV_SETUP, UI_DEV_CREATE, UI_DEV_DESTROY = (0x40045564, 0x40045565, 0x405C5503,
|
||||
0x5501, 0x5502)
|
||||
EV_SYN, EV_KEY, SYN_REPORT = 0, 1, 0
|
||||
KEY_LAST = 248 # KEY_ESC (1) .. KEY_MICMUTE (248): every key a PC keyboard sends
|
||||
KEY_LEFTSHIFT = 42
|
||||
|
||||
|
||||
def text_keys():
|
||||
"""US layout: character -> (Linux key code, shift)."""
|
||||
keys = {"\n": (28, False), "\t": (15, False), " ": (57, False), "\b": (14, False)}
|
||||
for plain, shifted, first in (("1234567890-=", "!@#$%^&*()_+", 2), ("qwertyuiop[]", "QWERTYUIOP{}", 16),
|
||||
("asdfghjkl;'`", 'ASDFGHJKL:"~', 30), ("\\zxcvbnm,./", "|ZXCVBNM<>?", 43)):
|
||||
for i, (a, b) in enumerate(zip(plain, shifted, strict=True)):
|
||||
keys[a] = (first + i, False)
|
||||
keys[b] = (first + i, True)
|
||||
return keys
|
||||
|
||||
|
||||
TEXT_KEYS = text_keys()
|
||||
|
||||
|
||||
class VirtualKeyboard:
|
||||
"""A uinput keyboard. `fd`/`write` can be replaced in tests; the device goes away with close()."""
|
||||
|
||||
def __init__(self, fd=None, write=os.write, settle=0.8):
|
||||
self.write, self.held = write, set()
|
||||
self.fd = fd
|
||||
if fd is None:
|
||||
self.fd = os.open(UINPUT, os.O_WRONLY | os.O_NONBLOCK)
|
||||
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
|
||||
for code in range(1, KEY_LAST + 1):
|
||||
fcntl.ioctl(self.fd, UI_SET_KEYBIT, code)
|
||||
fcntl.ioctl(self.fd, UI_DEV_SETUP, struct.pack("HHHH80sI", 0x03, 0x1209, 0x4650, 1,
|
||||
b"FramePort keyboard", 0))
|
||||
fcntl.ioctl(self.fd, UI_DEV_CREATE)
|
||||
time.sleep(settle) # let gamescope/libinput pick the new keyboard up before the first key
|
||||
|
||||
def _event(self, kind, code, value):
|
||||
self.write(self.fd, struct.pack("llHHi", 0, 0, kind, code, value))
|
||||
|
||||
def key(self, code, value):
|
||||
"""value 1 = down, 0 = up, 2 = autorepeat."""
|
||||
if not 0 < int(code) <= KEY_LAST or value not in (0, 1, 2):
|
||||
return
|
||||
self._event(EV_KEY, int(code), value)
|
||||
self._event(EV_SYN, SYN_REPORT, 0)
|
||||
(self.held.add if value else self.held.discard)(int(code))
|
||||
|
||||
def type_text(self, text, delay=0.008):
|
||||
"""Type characters of the US layout; returns the characters it couldn't type."""
|
||||
skipped = ""
|
||||
for ch in text.replace("\r\n", "\n"):
|
||||
if ch not in TEXT_KEYS:
|
||||
skipped += ch
|
||||
continue
|
||||
code, shift = TEXT_KEYS[ch]
|
||||
if shift:
|
||||
self.key(KEY_LEFTSHIFT, 1)
|
||||
self.key(code, 1)
|
||||
self.key(code, 0)
|
||||
if shift:
|
||||
self.key(KEY_LEFTSHIFT, 0)
|
||||
time.sleep(delay)
|
||||
return skipped
|
||||
|
||||
def close(self):
|
||||
for code in list(self.held): # never leave a key stuck when the PC goes away mid-press
|
||||
self.key(code, 0)
|
||||
if self.fd is not None and isinstance(self.fd, int):
|
||||
try:
|
||||
fcntl.ioctl(self.fd, UI_DEV_DESTROY)
|
||||
except OSError:
|
||||
pass
|
||||
os.close(self.fd)
|
||||
self.fd = None
|
||||
|
||||
|
||||
def keyboard_session(stdin, stdout, keyboard=None):
|
||||
"""Long-lived: prints {"ready": true} once the virtual keyboard exists, then reads one JSON object per line:
|
||||
{"k": code, "v": 1|0|2} (key down/up/repeat) or {"text": "..."}; ends (keyboard removed) at EOF."""
|
||||
try:
|
||||
kb = keyboard or VirtualKeyboard()
|
||||
except OSError as exc:
|
||||
stdout.write(json.dumps({"ready": False, "error": f"can't create a virtual keyboard: {exc}"}) + "\n")
|
||||
stdout.flush()
|
||||
return 1
|
||||
stdout.write(json.dumps({"ready": True}) + "\n")
|
||||
stdout.flush()
|
||||
try:
|
||||
for line in stdin:
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if "k" in msg:
|
||||
kb.key(msg["k"], msg.get("v", 1))
|
||||
elif "text" in msg:
|
||||
skipped = kb.type_text(str(msg["text"]))
|
||||
stdout.write(json.dumps({"typed": True, "skipped": skipped}) + "\n")
|
||||
stdout.flush()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
kb.close()
|
||||
return 0
|
||||
|
||||
|
||||
COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_keyboard":
|
||||
return keyboard_session(sys.stdin, sys.stdout)
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "_shortcuts_worker":
|
||||
shortcuts_worker(sys.argv[2])
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package: com.stremio.vrone
|
||||
title: Stremio
|
||||
status: works
|
||||
notes: 'Unity 6 VR media centre (its interface is web pages in Vuplex 3D WebView). Text fields closed at once on the
|
||||
Frame (no system keyboard): frame.unity_text_input keeps them selected and device.text_input_window shows the
|
||||
Android window so Steam''s on-screen keyboard (or Type on Frame from the PC) types into them; confirmed in the
|
||||
headset by the owner (2026-10-03), who confirmed it working. Note: the add-ons page can stay blank in VR (the
|
||||
add-on sync itself succeeds). "Play in an external player" can''t reach another app: Lepton sends every link to
|
||||
Steam''s browser.'
|
||||
details: 'Patches: frame.unity_text_input (TMP_InputField.TouchScreenKeyboardShouldBeUsed -> false,
|
||||
isKeyboardUsingEvents -> true; uGUI InputField too), device.text_input_window (lepton-show-flatscreen).'
|
||||
tested_version: 0.7.5
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
source_hint: Stremio
|
||||
@@ -84,3 +84,21 @@ update [--check] [--yes]`. CI runs `scripts/update_smoke.py` on every OS with th
|
||||
overport CLI release (Android-XR-Bridge/OVRPort, fallback ovrport/app) + patch list + titles, Temurin JRE (Adoptium API), apksigner (Google repository index),
|
||||
store artwork/titles (overport image API), catalog (optional remote), Lepton location/appid (Frame appmanifests),
|
||||
Steam user (Frame userdata).
|
||||
|
||||
## Built on
|
||||
|
||||
FramePort is a front end for other projects; most of the functionality comes from them:
|
||||
|
||||
| Project | Used for |
|
||||
|---|---|
|
||||
| [OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (overport, originally [ovrport/app](https://github.com/ovrport/app)) | Converts Quest games to OpenXR: its CLI applies the game patches and supplies the OpenXR loader; FramePort also includes its VrApi→OpenXR adapter |
|
||||
| Valve Lepton, Proton and SteamVR | Run Android games, Windows games and OpenXR on the Frame |
|
||||
| [Revive](https://github.com/LibreVR/Revive) (LibreVR) | Runs Oculus Rift games on OpenXR / SteamVR |
|
||||
| Mesa (Zink) | OpenGL ES on Vulkan on the Frame |
|
||||
| [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK) | OpenXR headers for the native layers |
|
||||
| Eclipse Temurin, Android apksigner, Android NDK | Java runtime, APK signing, building the native layers |
|
||||
| [Flet](https://flet.dev) | The desktop app |
|
||||
| OculusDB, Steam store | Game descriptions, genres and artwork |
|
||||
|
||||
FramePort's own parts: game detection and recipes, the Steam Frame OpenXR adapter (FrameBridge) and the other native
|
||||
fixes in `native/`, the installer agent that runs on the Frame, and the desktop/command-line app.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Compatibility
|
||||
|
||||
The built-in catalog has tested settings for games, each marked as working, working with known issues, or not
|
||||
running on the Frame: see the [list of tested games](GAMES.md) (recipes in [catalog/games](../catalog/games)).
|
||||
Other games get suggested patches from detection rules; each suggestion states its reason, and every patch can be
|
||||
switched on or off under **Customize**: described in plain words, with **Show technical details** for the exact
|
||||
effect of each patch.
|
||||
|
||||
Tried an untested game? Its page asks how it runs; **Share working config…** opens a prefilled GitHub issue so your
|
||||
recipe can join the built-in catalog for everyone.
|
||||
|
||||

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

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

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

|
||||
|
||||
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` (ARM64: `FramePort-linux-arm64.tar.gz`).
|
||||
|
||||
Full guide: [docs/INSTALL.md](https://github.com/spoopyghosty0/frameport/blob/main/docs/INSTALL.md) · checksums in
|
||||
`SHA256SUMS.txt`.
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Write docs/GAMES.md: the tested games from the bundled catalog as a simple table to share.
|
||||
Run after catalog changes: python scripts/compat_list.py"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from frameport.recommend import catalog # noqa: E402
|
||||
|
||||
STATUS = {"works": "✅ Works", "issues": "⚠️ Works with issues", "unsupported": "❌ Doesn't run"}
|
||||
ORDER = {"works": 0, "issues": 1, "unsupported": 2}
|
||||
|
||||
|
||||
def short(note: str) -> str:
|
||||
"""The note's first sentence, without technical asides."""
|
||||
first = re.split(r"(?<=[.!?])\s", " ".join((note or "").split()), maxsplit=1)[0]
|
||||
return first if len(first) <= 160 else first[:157].rstrip() + "…"
|
||||
|
||||
|
||||
def render() -> str:
|
||||
entries = [e for e in catalog.load().values() if e.origin == "bundled" and e.status in STATUS]
|
||||
entries.sort(key=lambda e: (ORDER[e.status], e.title.lower()))
|
||||
rows = ["| Game | Platform | Status | Notes |", "|---|---|---|---|"]
|
||||
for e in entries:
|
||||
platform = "PC VR" if e.kind == "rift" else "Quest"
|
||||
note = "" if e.status == "works" else short(e.notes).replace("|", "/")
|
||||
rows.append(f"| {e.title} | {platform} | {STATUS[e.status]} | {note} |")
|
||||
return ("# Tested games\n\n"
|
||||
"Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort "
|
||||
"suggests patches for them, and a working config can be shared from the app (**Share working config…**).\n"
|
||||
"Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.\n\n"
|
||||
+ "\n".join(rows) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = ROOT / "docs" / "GAMES.md"
|
||||
out.write_text(render(), encoding="utf-8")
|
||||
print(f"wrote {out}")
|
||||
+16
-1
@@ -40,9 +40,19 @@ def stage_data():
|
||||
shutil.rmtree(DATA, ignore_errors=True)
|
||||
for name in ("catalog", "artifacts", "agent", "bootstrap"):
|
||||
shutil.copytree(ROOT / name, DATA / name)
|
||||
# the Frame agent is uploaded as source; `flet build` turns every .py into .pyc (issue #2): keep a non-.py copy
|
||||
shutil.copy2(ROOT / "agent/frameport_agent.py", DATA / "agent/frameport_agent.py.txt")
|
||||
print(f"staged data in {DATA}")
|
||||
|
||||
|
||||
def check_bundle(out: Path) -> None:
|
||||
"""The finished bundle must contain the agent's source (the app uploads it to the Frame)."""
|
||||
found = [p for p in out.rglob("frameport_agent.py*") if p.suffix != ".pyc"]
|
||||
if not found:
|
||||
raise SystemExit(f"{out}: the Frame agent's source is missing from the bundle (only compiled?)")
|
||||
print("agent source in bundle:", ", ".join(str(p.relative_to(out)) for p in found))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--pyinstaller", action="store_true")
|
||||
@@ -60,6 +70,8 @@ def main() -> int:
|
||||
# translations (data files PyInstaller doesn't pick up from the imports)
|
||||
"--add-data", f"{ROOT / 'src/frameport/locales'}{sep}frameport/locales",
|
||||
"--distpath", str(ROOT / "dist"), "--yes"]
|
||||
if TARGET == "linux":
|
||||
cmd.append("--onedir") # a folder: the bundle check sees the agent source, the archive the app
|
||||
else:
|
||||
# --yes: install the Flutter SDK etc. without prompting; --no-rich-output: plain logs (CI, Windows consoles)
|
||||
cmd = ["flet", "build", TARGET, str(ROOT), "--project", "FramePort", "--product", "FramePort",
|
||||
@@ -67,7 +79,10 @@ def main() -> int:
|
||||
if TARGET == "macos": # flet's default bundled Python lacks prebuilt cryptography wheels for both Mac archs
|
||||
cmd += ["--python-version", "3.12", "--arch", "arm64"] # Apple Silicon; x86_64 cross-build fails
|
||||
print(" ".join(cmd))
|
||||
return subprocess.call(cmd, cwd=ROOT)
|
||||
rc = subprocess.call(cmd, cwd=ROOT)
|
||||
if rc == 0:
|
||||
check_bundle(ROOT / "dist" if args.pyinstaller else ROOT / "dist" / TARGET)
|
||||
return rc
|
||||
finally:
|
||||
if not args.keep_data:
|
||||
shutil.rmtree(DATA, ignore_errors=True)
|
||||
|
||||
@@ -100,6 +100,35 @@ class FakeTarget:
|
||||
pass
|
||||
|
||||
|
||||
class FakeKeyboardSession:
|
||||
"""--fake-frame: Type on Frame connects at once (nothing is sent anywhere)."""
|
||||
|
||||
def __init__(self, frame):
|
||||
self.closed = False
|
||||
|
||||
def key(self, name, action="down"):
|
||||
return True
|
||||
|
||||
def text(self, text):
|
||||
return ""
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def open_type_dialog(app: FramePortApp) -> None:
|
||||
"""Type on Frame over the Steam Frame page, with one key 'pressed' so the screenshot shows it working."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
app.navigate(1)
|
||||
time.sleep(1.5)
|
||||
app.type_on_frame()
|
||||
time.sleep(1.5)
|
||||
dialog = [d for d in app.page._dialogs.controls if d.open][-1]
|
||||
listener = dialog.content.controls[1]
|
||||
listener.on_key_down(SimpleNamespace(key="Enter"))
|
||||
|
||||
|
||||
STEP_SECONDS = 4 # per screen (the screenshot is taken ~3 s in)
|
||||
|
||||
|
||||
@@ -158,6 +187,7 @@ def main() -> int:
|
||||
steps.append(("files", lambda a: a.go("files")))
|
||||
steps.append(("files-select", lambda a: [a.files_view._toggle(e.path, True)
|
||||
for e in a.files_view.entries[1:3]]))
|
||||
steps.append(("type-on-frame", open_type_dialog))
|
||||
if args.install_questions:
|
||||
queued: list[str] = []
|
||||
|
||||
@@ -210,6 +240,9 @@ def main() -> int:
|
||||
ready, done = threading.Event(), []
|
||||
|
||||
if args.fake_frame: # never reach a real Frame (start-up auto-connect, discovery, the 30 s poll)
|
||||
from frameport.frame import keyboard
|
||||
|
||||
keyboard.KeyboardSession = FakeKeyboardSession
|
||||
FramePortApp.connect = lambda self, *a, **k: None
|
||||
FramePortApp.refresh_frame = lambda self, *a, **k: None
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.6.1"
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
from ..core.paths import agent_dir, ssh_dir, user_data_dir, write_atomic
|
||||
from ..core.paths import agent_file, ssh_dir, user_data_dir, write_atomic
|
||||
|
||||
REMOTE_AGENT_DIR = ".local/share/frameport/agent"
|
||||
|
||||
@@ -124,7 +124,7 @@ FAST_LINKS = {"usb0": "USB cable", "wlanap": "the Frame's own Wi-Fi hotspot"}
|
||||
def bundled_agent_version() -> int | None:
|
||||
"""AGENT_VERSION of the agent this app ships (it's uploaded to the Frame whenever it differs)."""
|
||||
try:
|
||||
m = re.search(r"^AGENT_VERSION = (\d+)", (agent_dir() / "frameport_agent.py").read_text(), re.M)
|
||||
m = re.search(r"^AGENT_VERSION = (\d+)", agent_file().read_text(), re.M)
|
||||
return int(m.group(1)) if m else None
|
||||
except OSError:
|
||||
return None
|
||||
@@ -369,7 +369,7 @@ class Frame:
|
||||
|
||||
# ------------------------------------------------------------------ agent
|
||||
def ensure_agent(self) -> str:
|
||||
local = agent_dir() / "frameport_agent.py"
|
||||
local = agent_file()
|
||||
text = local.read_bytes()
|
||||
digest = hashlib.sha256(text).hexdigest()[:16]
|
||||
remote_dir = posixpath.join(self.home, REMOTE_AGENT_DIR)
|
||||
|
||||
@@ -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
|
||||
@@ -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": "",
|
||||
@@ -158,6 +161,7 @@
|
||||
"Clear finished": "",
|
||||
"Clear search": "",
|
||||
"Clear tags": "",
|
||||
"Click here, then type. Everything you type goes to the Frame (Esc and shortcuts too).": "",
|
||||
"Click to open · right-click for quick actions": "",
|
||||
"Close": "",
|
||||
"Close FramePort": "",
|
||||
@@ -174,6 +178,7 @@
|
||||
"Connected": "",
|
||||
"Connected to {label}": "",
|
||||
"Connected to {label}.": "",
|
||||
"Connecting the keyboard…": "",
|
||||
"Connecting to {value}…": "",
|
||||
"Connecting…": "",
|
||||
"Continue": "",
|
||||
@@ -289,6 +294,8 @@
|
||||
"Force enable passthrough": "",
|
||||
"Found": "",
|
||||
"Frame agent": "",
|
||||
"Frame battery: charging": "",
|
||||
"Frame battery: not charging": "",
|
||||
"Frame OpenXR compatibility layer": "",
|
||||
"FrameBridge OpenXR adapter": "",
|
||||
"FramePort": "",
|
||||
@@ -407,6 +414,8 @@
|
||||
"Keep menus in place": "",
|
||||
"Keeps one label so the title is stable.": "",
|
||||
"Keeps one name for the game, so it shows the same everywhere.": "",
|
||||
"Keyboard connected to {label}.": "",
|
||||
"Keyboard disconnected: {error}": "",
|
||||
"KiB": "",
|
||||
"Language": "",
|
||||
"Last build": "",
|
||||
@@ -426,13 +435,16 @@
|
||||
"Lepton is installed": "",
|
||||
"Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. It needs Developer Mode.": "",
|
||||
"Lepton only launches an activity with category LAUNCHER (Quest apps often use INFO). Symptom when missing: launch.log says 'APP_ACTIVITY is empty' and nothing starts.": "",
|
||||
"Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as \"Gamescope\" (the Frame's compositor). Pairs with \"Make Unity text fields work without a system keyboard\".": "",
|
||||
"Lets games made for Oculus PCs run on SteamVR.": "",
|
||||
"Lets games written for newer OpenXR versions run on the Frame.": "",
|
||||
"Lets mixed-reality games start even though they insist on a camera view of your room.": "",
|
||||
"Lets typing reach this app: Steam's on-screen keyboard or your computer's keyboard (VR is unaffected).": "",
|
||||
"Lets Unity games accept a headset that isn't a Quest.": "",
|
||||
"Lets Unreal games accept a headset that isn't a Quest.": "",
|
||||
"Lets Unreal games accept a headset that isn't an Oculus Rift.": "",
|
||||
"Lets you read logs/attach. Some Unreal games abort under CheckJNI when debuggable; the Frame 'nodebug' fix undoes it.": "",
|
||||
"Lets you type into this app's text fields on the Frame (they'd close at once).": "",
|
||||
"Library": "",
|
||||
"Loading…": "",
|
||||
"Loads FramePort's OpenXR layer under Proton on the Frame. The Frame's SteamVR runtime only accepts OpenXR 1.0 apps, but Proton's VR helper asks for 1.1, so without the layer VR never starts (the game shows as a flat window, or Revive fails with 'Unable to load LibOVRRT DLL'); the layer retries as 1.0. It also emulates xrConvertTimespecTimeToTimeKHR if a runtime refuses it. Ignored on this PC.": "",
|
||||
@@ -444,6 +456,7 @@
|
||||
"Looking up the store description and screenshots…": "",
|
||||
"Low": "",
|
||||
"macOS' firewall is on: allow incoming connections when macOS asks about FramePort (or System Settings → Network → Firewall → Options).": "",
|
||||
"Make Unity text fields work without a system keyboard": "",
|
||||
"Makes Meta's 3D audio library work outside Quest headsets.": "",
|
||||
"Makes sure the game has an entry that can be started.": "",
|
||||
"Makes the game start from the Frame's Steam library.": "",
|
||||
@@ -532,6 +545,7 @@
|
||||
"Opens a prefilled GitHub issue with this game's recipe, so it can join the built-in catalog (no account token needed; you review and submit it on GitHub). No game files or personal data are sent.": "",
|
||||
"Opens this player's storage on the Frame (Files tab): upload videos into the folder it lists": "",
|
||||
"OpenXR runtime": "",
|
||||
"Or paste text to type it in one go": "",
|
||||
"Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows up above. Open Settings → Developer → Pair new host on the Frame, click Connect here and approve FramePort. That way needs no connection into this computer.": "",
|
||||
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
|
||||
"Override the guardian width (0 = use guardian, min 1.5 m).": "",
|
||||
@@ -552,6 +566,7 @@
|
||||
"Patches every game of this kind gets (on by default). Customize lists them all.": "",
|
||||
"Patches for what the Frame's runtime does differently from a Quest (graphics formats, missing OpenXR extensions, Lepton's launcher requirements).": "",
|
||||
"Patches that can't matter for this game (wrong engine or API) are hidden. Show them to force one on anyway.": "",
|
||||
"Paused: Frame battery low, plug it in": "",
|
||||
"Paused: waiting for your Frame": "",
|
||||
"PC VR": "",
|
||||
"PC VR (Revive / Proton)": "",
|
||||
@@ -724,6 +739,7 @@
|
||||
"Show setup command": "",
|
||||
"Show Steam Frame controllers": "",
|
||||
"Show technical details (patch ids, exact effects, parameters)": "",
|
||||
"Show the app's Android window (for typing)": "",
|
||||
"Showing {len} of {n}": "",
|
||||
"Shows 3D 360° pictures flat if they look doubled.": "",
|
||||
"Shows the Frame's cameras where the game expects passthrough.": "",
|
||||
@@ -794,9 +810,12 @@
|
||||
"The CPU types the game ships code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games can't run on it.": "",
|
||||
"The download didn't finish. Check your internet connection.": "",
|
||||
"The Frame can't show curved panels; this shows them as gently bent strips.": "",
|
||||
"The Frame is charging: continuing the installs.": "",
|
||||
"the Frame isn't connected": "",
|
||||
"The Frame isn't connected": "",
|
||||
"The Frame went offline: {exc}": "",
|
||||
"The Frame's battery is at {pct} % and not charging. Plug it in (a strong charger, not a PC port): long installs can drain it, and FramePort pauses them at {pause} %.": "",
|
||||
"The Frame's battery is at {pct} %: installs paused so it doesn't switch off mid-upload. Plug it in; the queue continues by itself.": "",
|
||||
"The Frame's desktop password, only needed the first time so FramePort can add its own key. After that it connects with the key.": "",
|
||||
"The Frame's Steam library still shows the old artwork.": "",
|
||||
"the game didn't pass its checks (see the list above)": "",
|
||||
@@ -815,6 +834,7 @@
|
||||
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app — it isn't installed on this PC, so they may quit right after starting. FramePort doesn't change how a game checks its license. Untick the ones you'd rather skip.": "",
|
||||
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app. It doesn't exist on the Steam Frame, so there they crash right at startup (seen with Robo Recall, Lies Beneath and Lone Echo) — play them on this PC. Tick any you still want to try on the Frame.": "",
|
||||
"They're Oculus games that need Revive to reach VR, and Revive can't run on the Frame. Play them on this PC instead (Install on this PC — SteamVR + Revive). Tick any you still want to put on the Frame to experiment (they'll likely run flat or crash).": "",
|
||||
"This computer's keyboard works as a keyboard on your Frame while this window is open. In the headset, select a text field (in a game or app, in Steam or on the desktop), then type here.": "",
|
||||
"This deletes the {what} on the Frame. It can't be undone.": "",
|
||||
"This folder is empty. Upload files with the buttons above.": "",
|
||||
"This game can't run on the Steam Frame.": "",
|
||||
@@ -845,6 +865,11 @@
|
||||
"Turns the controllers' pointing ray left (+) or right (−).": "",
|
||||
"Turns the pointer left or right.": "",
|
||||
"Type": "",
|
||||
"Type it": "",
|
||||
"Type on Frame": "",
|
||||
"Type on Frame: use this keyboard on the Frame": "",
|
||||
"Type on Frame…": "",
|
||||
"Typed it, except characters the Frame's US keyboard layout doesn't have: {chars}": "",
|
||||
"ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (and afterwards: sudo ufw delete allow {port}/tcp)": "",
|
||||
"Uninstall": "",
|
||||
"Uninstall FramePort": "",
|
||||
@@ -856,6 +881,7 @@
|
||||
"Uninstall {title}?": "",
|
||||
"Uninstall. ": "",
|
||||
"Uninstalled {title}": "",
|
||||
"Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with \"Show the app window\") or Type on Frame from the PC.": "",
|
||||
"Unity: disable MSAA": "",
|
||||
"Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on non-Quest devices. Patches that one JNI FindClass call so the library continues (e.g. NOPE Challenge).": "",
|
||||
"Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of CrashReportClient.exe so it can't start (your game files on this PC aren't changed).": "",
|
||||
@@ -889,6 +915,7 @@
|
||||
"Use controllers": "",
|
||||
"Use tags to group and filter your library": "",
|
||||
"Use the repack's launcher (bundled Revive)": "",
|
||||
"Use this computer's keyboard on the Frame": "",
|
||||
"Use this program": "",
|
||||
"Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta.": "",
|
||||
"Uses the Oculus Platform SDK: it checks your Oculus license. Normally that needs the Oculus app on this PC with a license you own, so it may quit right after starting on the headset. You can still try it.": "",
|
||||
@@ -1005,6 +1032,7 @@
|
||||
"{on} on your Frame": "",
|
||||
"{remaining} more after this": "",
|
||||
"{title} failed: {error}": "",
|
||||
"{title}: analyzed again": "",
|
||||
"{title}: details from {value}": "",
|
||||
"{title}: done": "",
|
||||
"{title}: launch test {verdict} (furthest: {value})": "",
|
||||
|
||||
@@ -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
|
||||
+88
-4
@@ -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):
|
||||
|
||||
@@ -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.")
|
||||
@@ -10,6 +10,7 @@ from ...core import library
|
||||
from ...i18n import tr
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..battery import label as battery_label
|
||||
from ..help import HELP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -35,10 +36,14 @@ class FrameView:
|
||||
.format(user=t.target.user, host=t.target.host, get=info.get('os'),
|
||||
get2=info.get('os_version'), get3=info.get('build_id'))),
|
||||
C.meta(tr("{free:.0f} GiB free · {len} games installed")
|
||||
.format(free=free, len=len(info.get('installed') or []))),
|
||||
.format(free=free, len=len(info.get('installed') or []))
|
||||
+ (tr(" · battery {value}").format(value=battery_label(info["battery"]))
|
||||
if info.get("battery") else "")),
|
||||
], spacing=T.px(4), expand=True),
|
||||
ft.Column([
|
||||
C.secondary(tr("Refresh"), ft.Icons.REFRESH_ROUNDED, lambda e: app.refresh_frame()),
|
||||
C.secondary(tr("Type on Frame"), ft.Icons.KEYBOARD_ROUNDED, lambda e: app.type_on_frame(),
|
||||
tooltip=tr("Use this computer's keyboard on the Frame")),
|
||||
C.ghost(tr("Switch Frame…"), ft.Icons.SWAP_HORIZ_ROUNDED, lambda e: app.disconnect()),
|
||||
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.END),
|
||||
], spacing=T.S4), padding=T.S5)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Type on Frame: this computer's keyboard becomes a keyboard on the Frame while the dialog is open (frame/keyboard.py).
|
||||
Keys go to whatever has focus on the Frame: an app's text field, Steam, the desktop, a PC game."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ...errors import explain
|
||||
from ...i18n import tr
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
|
||||
def show_type_dialog(app: FramePortApp) -> None:
|
||||
target = app.target
|
||||
if target is None or app.frame_state != "connected":
|
||||
app.toast(tr("Connect your Frame first"), error=True)
|
||||
return
|
||||
state = {"session": None, "closed": False}
|
||||
status = C.meta(tr("Connecting the keyboard…"))
|
||||
last = ft.Text("", size=T.px(28), weight=ft.FontWeight.W_600, color=T.ACCENT)
|
||||
hint = C.body(tr("Click here, then type. Everything you type goes to the Frame (Esc and shortcuts too)."), T.TEXT)
|
||||
|
||||
def forward(action):
|
||||
def handler(e):
|
||||
s = state["session"]
|
||||
if s is None or state["closed"]:
|
||||
return
|
||||
try:
|
||||
if s.key(e.key, action) and action == "down":
|
||||
last.value = e.key if len(e.key) > 1 else e.key.upper()
|
||||
C.update(last)
|
||||
except Exception as exc: # noqa: BLE001 - the connection dropped
|
||||
fail(exc)
|
||||
return handler
|
||||
|
||||
pad = ft.Container(ft.Column([hint, last], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
padding=T.S5, border_radius=T.RADIUS, bgcolor=T.BG, border=ft.Border.all(1, T.BORDER),
|
||||
alignment=ft.Alignment.CENTER, height=T.px(150), ink=True)
|
||||
listener = ft.KeyboardListener(pad, autofocus=True, on_key_down=forward("down"), on_key_up=forward("up"),
|
||||
on_key_repeat=forward("repeat"))
|
||||
pad.on_click = lambda e: focus_keys()
|
||||
paste = ft.TextField(hint_text=tr("Or paste text to type it in one go"), expand=True, dense=True,
|
||||
border_color=T.BORDER, on_submit=lambda e: send_text(None))
|
||||
|
||||
def focus_keys():
|
||||
try:
|
||||
app.page.run_task(listener.focus)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def send_text(e):
|
||||
s, text = state["session"], paste.value or ""
|
||||
if s is None or not text:
|
||||
return
|
||||
|
||||
def work():
|
||||
skipped = s.text(text)
|
||||
paste.value = ""
|
||||
C.update(paste)
|
||||
focus_keys()
|
||||
if skipped:
|
||||
app.toast(tr("Typed it, except characters the Frame's US keyboard layout doesn't have: {chars}")
|
||||
.format(chars=skipped), error=True)
|
||||
app.run_bg(work)
|
||||
|
||||
def fail(exc):
|
||||
status.value = tr("Keyboard disconnected: {error}").format(error=explain(exc))
|
||||
status.color = T.ERROR
|
||||
C.update(status)
|
||||
|
||||
def close(e=None):
|
||||
state["closed"] = True
|
||||
app._typing_on_frame = False
|
||||
s = state["session"]
|
||||
if s is not None:
|
||||
s.close()
|
||||
if e is not None:
|
||||
app.page.pop_dialog()
|
||||
|
||||
pick = C.one_choice()
|
||||
app._typing_on_frame = True # app._on_key leaves Esc / Ctrl+F alone: they go to the Frame
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
modal=True, bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
title=ft.Row([ft.Icon(ft.Icons.KEYBOARD_ROUNDED, color=T.ACCENT),
|
||||
ft.Text(tr("Type on Frame"), weight=ft.FontWeight.W_600)], spacing=T.S2),
|
||||
content=ft.Column([
|
||||
C.body(tr("This computer's keyboard works as a keyboard on your Frame while this window is open. In the "
|
||||
"headset, select a text field (in a game or app, in Steam or on the desktop), then type here."),
|
||||
T.TEXT_2),
|
||||
listener,
|
||||
ft.Row([paste, C.secondary(tr("Type it"), ft.Icons.SEND_ROUNDED, send_text)], spacing=T.S2),
|
||||
status,
|
||||
], tight=True, spacing=T.S3, width=T.px(560)),
|
||||
on_dismiss=pick(lambda e: close()),
|
||||
actions=[C.primary(tr("Done"), ft.Icons.CHECK_ROUNDED, on_click=pick(close))]))
|
||||
|
||||
def connect():
|
||||
from ...frame.keyboard import KeyboardSession
|
||||
|
||||
try:
|
||||
state["session"] = KeyboardSession(target.frame)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
fail(exc)
|
||||
return
|
||||
if state["closed"]: # closed while connecting
|
||||
state["session"].close()
|
||||
return
|
||||
status.value = tr("Keyboard connected to {label}.").format(label=target.label)
|
||||
status.color = T.OK
|
||||
C.update(status)
|
||||
focus_keys()
|
||||
app.run_bg(connect)
|
||||
@@ -18,6 +18,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -39,7 +40,7 @@ REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
|
||||
LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
CHECK_EVERY = 6 * 3600 # seconds between automatic checks
|
||||
ASSETS = {"win32": "FramePort-windows-x64.zip", "darwin": "FramePort-macos-arm64.zip",
|
||||
"linux": "FramePort-linux-x64.tar.gz"}
|
||||
"linux": "FramePort-linux-x64.tar.gz", "linux-arm64": "FramePort-linux-arm64.tar.gz"} # never rename
|
||||
SUMS = "SHA256SUMS.txt"
|
||||
|
||||
|
||||
@@ -75,6 +76,8 @@ def is_newer(candidate: str, current: str = __version__) -> bool:
|
||||
|
||||
def platform_asset() -> str | None:
|
||||
key = "linux" if sys.platform.startswith("linux") else sys.platform
|
||||
if key == "linux" and platform.machine().lower() in ("aarch64", "arm64"):
|
||||
key = "linux-arm64" # the ARM64 bundle (from 0.6.0)
|
||||
return ASSETS.get(key)
|
||||
|
||||
|
||||
|
||||
@@ -733,3 +733,63 @@ def test_purge_without_saves_removes_container_workdirs_and_stale_shortcuts(monk
|
||||
assert not Path(a.ANCHORS).exists()
|
||||
names = [s["appname"] for s in a.vdf_decode((users / "shortcuts.vdf").read_bytes())["shortcuts"].values()]
|
||||
assert names == ["Not ours"] and not (users / "grid" / f"{stale}p.jpg").exists()
|
||||
|
||||
|
||||
def test_battery_state(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
ps = tmp_path / "power_supply"
|
||||
|
||||
def supply(name, **files):
|
||||
(ps / name).mkdir(parents=True)
|
||||
for k, v in files.items():
|
||||
(ps / name / k).write_text(v + "\n")
|
||||
monkeypatch.setattr(a, "POWER_SUPPLY", str(ps))
|
||||
assert a.battery_state() is None # no power_supply folder (or no battery): nothing to show
|
||||
supply("battery", type="Battery", capacity="42", status="Discharging")
|
||||
supply("usb", type="USB", online="0")
|
||||
assert a.battery_state() == {"percent": 42, "status": "Discharging", "plugged": False, "draining": True}
|
||||
(ps / "usb" / "online").write_text("1\n") # cable in, battery not charging yet ("Not charging"): still plugged
|
||||
assert a.battery_state()["plugged"] is True
|
||||
assert a.cmd_battery({})["battery"]["percent"] == 42
|
||||
|
||||
|
||||
def test_virtual_keyboard_events_text_and_release(monkeypatch, tmp_path):
|
||||
import io
|
||||
import struct as st
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
||||
written = []
|
||||
kb = a.VirtualKeyboard(fd="fake", write=lambda fd, data: written.append(st.unpack("llHHi", data)[2:]))
|
||||
kb.key(30, 1) # 'a' down: EV_KEY then SYN_REPORT
|
||||
assert written == [(1, 30, 1), (0, 0, 0)] and kb.held == {30}
|
||||
written.clear()
|
||||
assert kb.type_text("A!\N{SNOWMAN}") == "\N{SNOWMAN}" # US layout; what it can't type is reported
|
||||
keys = [(c, v) for t, c, v in written if t == 1]
|
||||
assert keys == [(42, 1), (30, 1), (30, 0), (42, 0), (42, 1), (2, 1), (2, 0), (42, 0)]
|
||||
kb.key(57, 1) # space held down when the PC goes away
|
||||
written.clear()
|
||||
kb.close() # released before the device goes away
|
||||
assert (1, 57, 0) in written and kb.held == set()
|
||||
# session protocol: ready line, keys and text from JSON lines, keyboard closed at EOF
|
||||
out = io.StringIO()
|
||||
kb2 = a.VirtualKeyboard(fd="fake", write=lambda fd, data: None)
|
||||
closed = []
|
||||
kb2.close = lambda: closed.append(1)
|
||||
lines = io.StringIO('{"k": 28, "v": 1}\n{"k": 28, "v": 0}\nnot json\n{"text": "hi"}\n')
|
||||
assert a.keyboard_session(lines, out, keyboard=kb2) == 0
|
||||
replies = [json.loads(x) for x in out.getvalue().splitlines()]
|
||||
assert replies[0] == {"ready": True} and replies[1] == {"typed": True, "skipped": ""} and closed
|
||||
|
||||
|
||||
def test_keyboard_session_reports_a_refused_device(monkeypatch, tmp_path):
|
||||
import io
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
|
||||
def refuse(*args, **kw):
|
||||
raise PermissionError(13, "Permission denied", "/dev/uinput")
|
||||
monkeypatch.setattr(a, "VirtualKeyboard", refuse)
|
||||
out = io.StringIO()
|
||||
assert a.keyboard_session(io.StringIO(""), out) == 1
|
||||
assert json.loads(out.getvalue())["ready"] is False
|
||||
@@ -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,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,75 @@
|
||||
"""Type on Frame: key names -> Linux key codes, and the line protocol of a keyboard session."""
|
||||
import io
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.frame import keyboard as K
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,code", [
|
||||
("A", 30), ("a", 30), ("Key A", 30), ("1", 2), ("Digit 1", 2), ("!", 2), ("Enter", 28), ("Backspace", 14),
|
||||
("Arrow Left", 105), ("ArrowLeft", 105), ("Shift Left", 42), ("Control Left", 29), ("Alt Right", 100),
|
||||
("Meta Left", 125), (" ", 57), ("Space", 57), ("F5", 63), ("F12", 88), ("Numpad 7", 71), ("Page Down", 109),
|
||||
("Escape", 1), ("/", 53), ("?", 53), ("`", 41), ("\\", 43), ("Tab", 15), ("Delete", 111),
|
||||
])
|
||||
def test_linux_key(name, code):
|
||||
assert K.linux_key(name) == code
|
||||
|
||||
|
||||
def test_unknown_key_is_skipped():
|
||||
assert K.linux_key("Launch Mail Application") is None and K.linux_key("") is None
|
||||
|
||||
|
||||
class _Chan:
|
||||
def __init__(self):
|
||||
self.shut = False
|
||||
|
||||
def settimeout(self, t):
|
||||
pass
|
||||
|
||||
def shutdown_write(self):
|
||||
self.shut = True
|
||||
|
||||
|
||||
class _In(io.StringIO):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.channel = _Chan()
|
||||
|
||||
def close(self): # keep the value readable for the test
|
||||
pass
|
||||
|
||||
|
||||
class _Out(io.StringIO):
|
||||
def __init__(self, text):
|
||||
super().__init__(text)
|
||||
self.channel = _Chan()
|
||||
|
||||
|
||||
def _frame(replies):
|
||||
stdin, stdout = _In(), _Out(replies)
|
||||
client = SimpleNamespace(exec_command=lambda cmd, timeout=None: (stdin, stdout, None))
|
||||
return SimpleNamespace(client=client, home="/home/steamos", ensure_agent=lambda: None), stdin
|
||||
|
||||
|
||||
def test_session_streams_keys_and_text_then_closes():
|
||||
frame, stdin = _frame('{"ready": true}\n{"typed": true, "skipped": "☃"}\n')
|
||||
s = K.KeyboardSession(frame)
|
||||
assert s.key("Shift Left") and s.key("A") and s.key("A", "up") and s.key("Arrow Down", "repeat")
|
||||
assert not s.key("Launch Mail Application")
|
||||
assert s.text("Hi ☃") == "☃"
|
||||
s.close()
|
||||
sent = [json.loads(x) for x in stdin.getvalue().splitlines()]
|
||||
assert sent == [{"k": 42, "v": 1}, {"k": 30, "v": 1}, {"k": 30, "v": 0}, {"k": 108, "v": 2},
|
||||
{"text": "Hi ☃"}]
|
||||
assert stdin.channel.shut and s.closed
|
||||
s.key("A") # after close: ignored, no error
|
||||
assert len(stdin.getvalue().splitlines()) == 5
|
||||
|
||||
|
||||
def test_session_refused():
|
||||
frame, _ = _frame('{"ready": false, "error": "can\'t create a virtual keyboard: denied"}\n')
|
||||
with pytest.raises(K.KeyboardRefused, match="denied"):
|
||||
K.KeyboardSession(frame)
|
||||
@@ -0,0 +1,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"]
|
||||
@@ -246,3 +246,11 @@ def test_apply_reports_a_script_that_never_starts(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(updates.subprocess, "Popen", lambda *a, **k: type("P", (), {"poll": lambda self: None})())
|
||||
with pytest.raises(updates.UpdateError):
|
||||
updates.apply(staged, installed, relaunch=False, pid=1, platform="linux")
|
||||
|
||||
|
||||
def test_platform_asset_picks_the_arm64_linux_bundle(monkeypatch):
|
||||
monkeypatch.setattr(updates.sys, "platform", "linux")
|
||||
monkeypatch.setattr(updates.platform, "machine", lambda: "aarch64")
|
||||
assert updates.platform_asset() == "FramePort-linux-arm64.tar.gz"
|
||||
monkeypatch.setattr(updates.platform, "machine", lambda: "x86_64")
|
||||
assert updates.platform_asset() == "FramePort-linux-x64.tar.gz"
|
||||
Reference in new issue
Block a user