mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87884f382b | ||
|
|
f98f3e728b | ||
|
|
90034540ae | ||
|
|
cc9af9eba4 | ||
|
|
bc0e0fc043 | ||
|
|
5b4efc87b3 | ||
|
|
c2c1cf0168 | ||
|
|
01ba796881 | ||
|
|
b9f96866e5 | ||
|
|
3fb876ad5a | ||
|
|
9bd6890fe8 | ||
|
|
0d181b676e | ||
|
|
8de0292568 | ||
|
|
8e8c24f370 | ||
|
|
79dda60bde | ||
|
|
0bb2f9eb03 | ||
|
|
30509da71f | ||
|
|
d4b34e1bad | ||
|
|
22548bc42f | ||
|
|
1863c92101 | ||
|
|
497b5d9ae6 | ||
|
|
04995c066f | ||
|
|
0bceb5c928 | ||
|
|
22bb9c6469 | ||
|
|
fab04f0558 | ||
|
|
c0ae75d56b | ||
|
|
0520082586 | ||
|
|
36dd5a05f2 | ||
|
|
002c040223 | ||
|
|
4b1bb1557d | ||
|
|
b151ae691e | ||
|
|
36db6b1ac6 | ||
|
|
12aaec7888 | ||
|
|
7ea999d5b4 | ||
|
|
9e25ae9025 | ||
|
|
ed4a0ad5cd | ||
|
|
cb0a90a9c3 | ||
|
|
32fbbfe475 |
No files matched your search
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
||||
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
|
||||
diagnostics with personal details removed, shows you exactly what's included,
|
||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||
@@ -217,7 +217,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
|
||||
|
||||
## Assistant panel
|
||||
|
||||
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
||||
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
|
||||
To put the same page in the headset, with the HTTP server still running:
|
||||
|
||||
```sh
|
||||
|
||||
+16
-4
@@ -3,8 +3,9 @@
|
||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||
reached at more than one address: a LAN IP at home, another at the office, its
|
||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||
tab (key 5) lists them, and the connection pill in the header shows what the
|
||||
app is doing to reach the one in use, step by step, as it happens.
|
||||
tab (key 5) lists them. The connection pill in the header says whether the one in
|
||||
use is connected and how (Tailscale, or the network's name); click it to add,
|
||||
edit or reorder that headset's addresses, or to see each step of connecting.
|
||||
|
||||
The code is in three modules, all stdlib-only Python on your computer:
|
||||
|
||||
@@ -57,7 +58,7 @@ and ranks them:
|
||||
5. addresses that only ever worked on other networks;
|
||||
6. Tailscale addresses while Tailscale is off.
|
||||
|
||||
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
||||
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
|
||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||
still trying. If SSH to the winner fails in a way another address could fix
|
||||
(a different device answered there, or the link dropped), the next one that
|
||||
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
|
||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||
services and checks `ALIAS.local` and `frame.local`.
|
||||
|
||||
**The pill's dialog** lists the same addresses, with what each one answered, and
|
||||
can add, edit, reorder and remove them without leaving the page you're on. When
|
||||
the headset reports a LAN IP on the same network as this computer and that IP
|
||||
isn't saved, it offers to add it. The offer puts the address first in the list,
|
||||
so on that network it wins over the Tailscale name; away from home the Tailscale
|
||||
name still leads. A new or edited address is tested straight away. While
|
||||
connected, **Reconnect** applies your changes now rather than at the next
|
||||
connection: it tries the addresses again, ranked as above, and the best-ranked
|
||||
one that answers promptly wins. It doesn't pick a particular address.
|
||||
|
||||
## Networks
|
||||
|
||||
A network is told apart by its default gateway: the router's IP address plus its
|
||||
@@ -122,7 +133,8 @@ file per headset instead, so saving or forgetting one headset's key never touche
|
||||
another's: a different device answering at one of its
|
||||
addresses is refused, and the pill says so. A headset's first connection trusts
|
||||
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
|
||||
headset has a new key; **Forget identity** on the Devices tab lets the next
|
||||
headset has a new key; **Forget identity** (Devices tab → **Advanced**, with the
|
||||
SSH alias, user and port) lets the next
|
||||
connection save the new one.
|
||||
|
||||
## One server at a time
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
# UEVR from Frame Control, 2026-09-29
|
||||
|
||||
**Verified observations**, with limits below. Same device and software as
|
||||
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
|
||||
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
|
||||
Frame's AEST clock. Headset tests ran under the shared
|
||||
`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40,
|
||||
12:44–12:52 and 13:01–13:03. Battery 45 % →
|
||||
93 %, on charge throughout.
|
||||
|
||||
**Not observed:** the VR image, head tracking and controller input. SteamVR
|
||||
reported `activity_level` 3 (user interaction timeout; the headset was not
|
||||
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
|
||||
capture was a uniform dark frame. Frame submits prove the app is presenting
|
||||
to SteamVR, not that the image is correct.
|
||||
|
||||
## Manual injection (11:13–11:28)
|
||||
|
||||
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
|
||||
started with the game process's Wine variables, and its WPF window drew
|
||||
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
|
||||
(64-bit, PCD3D_SM5))`, a Wine-side process id.
|
||||
|
||||
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
|
||||
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
|
||||
was not restored on the next start.
|
||||
- XTest clicks landed on the game: `XQueryPointer` reported child
|
||||
`0x2000001` (the game) at the injector's coordinates, and
|
||||
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
|
||||
- `XSendEvent` button events to the injector window worked. After OpenVR and
|
||||
Inject, `/proc/<game>/maps` contained
|
||||
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
|
||||
Proton's `vrclient_x64.dll`. UEVR's log:
|
||||
|
||||
```text
|
||||
[11:17:32.948] Hooking D3D11
|
||||
[11:17:33.020] Hooked DirectX 11
|
||||
[11:17:34.263] Framework initialized
|
||||
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
|
||||
[11:17:39.791] UGameViewportClient::Draw called for the first time.
|
||||
[11:17:39.792] is stereo enabled called!
|
||||
```
|
||||
|
||||
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
|
||||
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
|
||||
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
|
||||
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
|
||||
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
|
||||
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
|
||||
|
||||
A second session's injector exited within about 20 seconds. The third,
|
||||
started fresh, injected again (`frame_submits` 1412). After the game was
|
||||
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
|
||||
files were removed.
|
||||
|
||||
## Through Frame Control (11:33–11:49)
|
||||
|
||||
Local `ui/server.py` against the `frame` alias. First the API, then the
|
||||
page's **VR mod** dialog, driven with a headless Chromium:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
|
||||
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
|
||||
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
|
||||
| `uninstall` while running | Refused: "the game is running; quit it first" |
|
||||
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
|
||||
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
|
||||
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) |
|
||||
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
|
||||
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
|
||||
|
||||
## The GUI injector wasn't reliable (12:02–12:40)
|
||||
|
||||
After review fixes, cold starts through the API with UEVR's own injector:
|
||||
|
||||
- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
|
||||
relaunched the injector didn't rescue a bad session. In both failures of
|
||||
the last batch, the first injector ignored the clicks and the next two
|
||||
never showed a window within 60 s.
|
||||
- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
|
||||
with OpenVR still unselected. The injector's log had no error.
|
||||
|
||||
Lock released at 12:40 before redesigning.
|
||||
|
||||
## Frame Control's own injector (12:44–12:52)
|
||||
|
||||
`start` now runs `frame_inject.py` under python.org's embeddable Python
|
||||
3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
|
||||
the game's Wine session. The old install was removed and the new one
|
||||
installed without the headset lock, since neither launches anything. The
|
||||
prefix listing after removal matched the pre-install state; the new install
|
||||
is 47 MB.
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
|
||||
| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
|
||||
| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
|
||||
| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
|
||||
| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
|
||||
|
||||
Battery 98–100 % on charge. Lock released at 12:52.
|
||||
|
||||
## After the second review (13:01–13:03)
|
||||
|
||||
`frame_inject.py` now declares every Win32 signature, checks each result,
|
||||
frees its remote buffers and closes its handles; uninstall accepts only our
|
||||
folder and UEVR's own settings folders. Rechecked: install; three cold starts
|
||||
injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging
|
||||
`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall
|
||||
left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache,
|
||||
receipts or processes. Frame Control's injector total: 12 of 12 cold starts
|
||||
through the API, plus the UI run.
|
||||
|
||||
## Left on the Frame
|
||||
|
||||
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
|
||||
installed; nothing else from this work is left.
|
||||
- No `UEVRInjector.exe` or game processes were left running. Steam and
|
||||
SteamVR were running at the end.
|
||||
- The test lock was released at 13:03.
|
||||
@@ -58,7 +58,7 @@ not been verified and is not guaranteed.
|
||||
|
||||
## Casting
|
||||
|
||||
**Cast headset view** starts the existing headset Live view and requests full
|
||||
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
|
||||
screen where supported. Show that screen to people in the room, or use the
|
||||
computer/phone's own screen mirroring. It creates no new stream transport,
|
||||
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
||||
|
||||
+14
-9
@@ -17,14 +17,19 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
The window has five tabs: **Home** (what the headset's doing and what you'd do
|
||||
next, the headset view, screenshots, family and comfort, the keyboard and
|
||||
trackpad, and VR performance, folded away), **Games** (installed games,
|
||||
sideloaded titles, getting games), **Android** (apps, the catalogue, display
|
||||
settings, reports), **Tools** (in the headset: your computer's windows, the media
|
||||
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
||||
power) and **Devices** (your headsets and their addresses). Keys 1–5 switch
|
||||
between them. The gear in the header (key 6) opens **Settings**: privacy and
|
||||
updates, library artwork and the assistant. The battery chip opens the headset's
|
||||
details: storage, memory, temperature, Wi-Fi, uptime and SteamOS build. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset and whether it's
|
||||
connected, and how (Tailscale, or the network's name). Click it to add, edit or
|
||||
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
|
||||
reached, a banner says why in plain words, what was tried, and counts down to
|
||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
@@ -178,5 +183,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
|
||||
require a separate user approval; power also retains its password prompt. The
|
||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||
operate the headset. Tools → Open assistant opens the page; the linked guide
|
||||
operate the headset. Settings → Open assistant settings opens the page; the linked guide
|
||||
covers putting it in a Chromium panel on the Frame.
|
||||
@@ -52,7 +52,6 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
||||
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
||||
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
|
||||
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
+2
-2
@@ -114,8 +114,8 @@ don't.
|
||||
|
||||
## Family and comfort
|
||||
|
||||
The shared Home card sets session limits, breaks and check-ins, and offers
|
||||
**Cast headset view**. **Enable / test notifications** requests iOS notification
|
||||
The shared Home card sets session limits, breaks and check-ins; **Cast to this
|
||||
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
|
||||
permission and sends a local test. These are local notifications, not APNs push;
|
||||
iOS background suspension can interrupt phone alerts. The headset timer still
|
||||
runs. See [the behavior and verification limits](family-comfort.md).
|
||||
+15
-85
@@ -1,14 +1,11 @@
|
||||
# Flat-to-VR mods and Beat Saber songs
|
||||
|
||||
**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
|
||||
songs are blocked.** Installed games have a **VR mod** button. It installs
|
||||
UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
|
||||
loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
|
||||
UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
|
||||
the VR image, head tracking and controls haven't been seen yet. Beat Saber
|
||||
custom songs need an owned Beat Saber on the Frame, which there isn't
|
||||
([below](#beat-saber-songs-first)).
|
||||
[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those.
|
||||
**Status: feasibility work, not an installer.** Frame Control does not yet
|
||||
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
|
||||
stays open: neither UEVR injection nor Beat Saber custom-song playback has
|
||||
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
|
||||
has not been inspected. There is no Mods button until the underlying
|
||||
install, playback and removal have been checked.
|
||||
|
||||
The mod manager must be Frame Control's own implementation. Mods and songs
|
||||
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
|
||||
@@ -18,87 +15,27 @@ unofficial mod mirrors are part of this work.
|
||||
|
||||
## Per-game support
|
||||
|
||||
Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
|
||||
**20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
|
||||
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
the observation stated, not a promise that the game is playable. “Documented”
|
||||
means an upstream source describes it; “inferred” means it still needs a test.
|
||||
|
||||
| Game / build | Mod or content | Evidence and support status | Next check |
|
||||
|---|---|---|---|
|
||||
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
|
||||
| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. |
|
||||
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
|
||||
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
|
||||
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
|
||||
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
|
||||
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
|
||||
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
|
||||
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
|
||||
|
||||
The test records ([2026-09-28](evidence/mods-2026-09-28.md),
|
||||
[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
|
||||
visible output, injection and failures.
|
||||
|
||||
## UEVR from Frame Control
|
||||
|
||||

|
||||
|
||||
**Get games → an installed game → VR mod.** The dialog says whether the game
|
||||
can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove
|
||||
UEVR**. The server route is `POST /api/mods {"action": "status" | "install" |
|
||||
"start" | "uninstall", "appid": …}`, and the work happens on the Frame in
|
||||
[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library,
|
||||
like `frame_steam.py`.
|
||||
|
||||
- **Which games.** Unreal Engine games, recognised by their
|
||||
`…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked
|
||||
with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
|
||||
prefix, so it must have been played once.
|
||||
- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
|
||||
Each is checked against a pinned SHA-256; Python's comes from python.org's
|
||||
release-file API. Archives are unpacked into a staging folder, rejecting
|
||||
entries that escape it, symlinks and oversized contents, then moved into
|
||||
`<prefix>/drive_c/frame-control/` (about 47 MB). Nothing goes into the
|
||||
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists
|
||||
what to remove, including UEVR's own settings folders only if they didn't
|
||||
exist before. Install and remove refuse while the game is running, and only
|
||||
one mod action runs at a time.
|
||||
- **Play in VR.** Launches the game through Steam if it isn't running and
|
||||
waits for its window. It then runs a short script under that Python, with
|
||||
the game's Proton, prefix and Wine settings read from the game process, so
|
||||
both share one Wine session. The script repeats what UEVR's own injector does
|
||||
when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
|
||||
call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in
|
||||
UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a
|
||||
`LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start`
|
||||
then checks that `UEVRBackend.dll` is mapped into the game and reads
|
||||
SteamVR's frame count for the app (`vrcmd --stats`).
|
||||
- **Remove.** Deletes what the receipt lists, then the download cache once
|
||||
no game uses it. Verified: the prefix's file listing matched its
|
||||
pre-install state afterwards.
|
||||
|
||||
Why not UEVR's own injector (verified 2026-09-29):
|
||||
|
||||
- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
|
||||
of downloads) in the prefix.
|
||||
- Under gamescope, the full-screen game keeps the pointer and focus. XTest
|
||||
clicks, raising or refocusing the injector window don't change that, so in
|
||||
the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
|
||||
events straight to its window does work.
|
||||
- `--attach=<process>` didn't inject under Wine, and it didn't restore its
|
||||
saved OpenVR choice.
|
||||
- Driven that way, it injected in 6 of 8 cold starts. In a bad session every
|
||||
new injector ignored input or never showed its window, even after
|
||||
relaunching. Its process list reads each process's main window title, a
|
||||
cross-process window message; a window that isn't answering would block its
|
||||
UI thread (inferred from the frontend's source, not proven). Without a
|
||||
`HOME` it also died in .NET at startup.
|
||||
- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI.
|
||||
- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for
|
||||
Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
|
||||
Neither stopped the injection.
|
||||
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
|
||||
visible output and failures. It also records a SteamVR restart during the
|
||||
shared session, which prevents attributing the failed UEVR attempt to FEX.
|
||||
|
||||
## Beat Saber: songs first
|
||||
|
||||
@@ -125,10 +62,7 @@ or dependency for Frame Control.
|
||||
|
||||
## Requirements for our manager
|
||||
|
||||
For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by
|
||||
fake-library tests (`tests/test_mods.py`), apart from checking ownership
|
||||
through Steam; item 6 is met for Gravitas. Beat Saber songs would need the
|
||||
same, and none of it is built for them:
|
||||
These are **planned**, not implemented or verified:
|
||||
|
||||
1. Resolve the selected Steam game's real library, installed build, executable
|
||||
architecture and Proton prefix. Confirm ownership through Steam; a directory
|
||||
@@ -157,11 +91,7 @@ same, and none of it is built for them:
|
||||
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
|
||||
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
|
||||
including the SHA-512 hashes used for the injector tests.
|
||||
- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs`
|
||||
(`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows.
|
||||
- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the
|
||||
embeddable Python's `sha256_sum`.
|
||||
including the SHA-512 hashes used for the test runtimes.
|
||||
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
|
||||
including per-game installation and the need to preserve original DLLs.
|
||||
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
|
||||
separate from Frame Control. Public reports describe some Proton support;
|
||||
Windows-only does not by itself prove a Frame app cannot run. Local status
|
||||
and sources are in [VR utilities](vr-utilities.md).
|
||||
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
|
||||
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
|
||||
headset creates its own gamescope panel using built-in tools. It needs no
|
||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||
|
||||
|
||||
+2
-2
@@ -97,8 +97,8 @@ The same error is sent at most once every 10 minutes.
|
||||
|
||||
## Report a problem
|
||||
|
||||
**Report a problem** is the warning-sign button in the header, also in the
|
||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||
**Report a problem** is the speech-bubble button in the header, also in the
|
||||
Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report
|
||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||
same way as the analytics above, so only the maintainer can read it and
|
||||
nothing is published. It works whatever the analytics settings are, because
|
||||
|
||||
@@ -65,6 +65,8 @@ flow, not evidence of an anonymous download API. It is not implemented here.
|
||||
- `search()` raises a user-readable `SourceError` with the browse URL (zero
|
||||
limit returns no rows). It does not invent app results or report a false
|
||||
“no matching games”. The aggregate search UI should surface this source error.
|
||||
In the app, Discover doesn't link to it; the **Sources** dialog lists SideQuest
|
||||
with a link to its website instead of an on/off switch.
|
||||
- `details()` accepts a numeric listing id and returns its canonical page link,
|
||||
`downloadable: False`, empty versions/tags/headsets and the `images` shape
|
||||
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's
|
||||
Steam Frame rating, installs them on the Frame, and searches the Steam store.
|
||||
This page covers how it works underneath, so you can do the same from a shell.
|
||||
|
||||
Installed Unreal Engine games also have a **VR mod** button that installs,
|
||||
starts and removes UEVR; see [VR mods](mods.md) for how it works and the
|
||||
per-game table. Mod support is separate from Steam's Frame rating.
|
||||
For flat-to-VR mods and Beat Saber custom songs, see the
|
||||
[per-game feasibility table](mods.md). Mod support is separate from Steam's
|
||||
Frame rating; there is no mod installer yet.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as
|
||||
an installed app with a blank tile.
|
||||
|
||||
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
||||
Set the optional free key in Frame Control's **Library artwork settings**, or
|
||||
Set the optional free key in Frame Control's **Settings → Library artwork**, or
|
||||
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
||||
settings override the saved key. Without a key there are no provider calls or
|
||||
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
||||
|
||||
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
|
||||
|
||||
## Our performance HUD
|
||||
|
||||
On **Home → VR comfort and performance**, the app shows a timestamped sample
|
||||
On **Home → VR performance** (folded until you open it), the app shows a timestamped sample
|
||||
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
||||
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
||||
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
|
||||
|
||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||
it running. This mode also returned live timing in a read-only device probe.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path.
|
||||
|
||||
## Use it
|
||||
|
||||
In **Tools → Media in the headset**, send a file, choose its layout and press
|
||||
In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press
|
||||
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
||||
**Stop** removes both. Refresh reads the library and the player's state.
|
||||
The screen follows your head; it isn't a saved world-space panel.
|
||||
|
||||
@@ -55,6 +55,48 @@ const log = ()=>{}, toast = ()=>{};
|
||||
assert.equal($('sessionMinutes').disabled,false);
|
||||
assert.equal($('sessionCancel').disabled,true);
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_a_finished_session_leaves_the_defaults(self):
|
||||
"""A one-minute test session that has ended mustn't fill the form with 1 / 0 / 0."""
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
|
||||
setup = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const elements = new Map();
|
||||
const $ = id => {
|
||||
// The form's own defaults, as in the page's HTML.
|
||||
const value = {sessionMinutes:'30', breakMinutes:'20', stillMinutes:'30'}[id] || '';
|
||||
if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value, defaultValue:value,
|
||||
checked:true, defaultChecked:true, type: /Alert$/.test(id) ? 'checkbox' : 'number'});
|
||||
return elements.get(id);
|
||||
};
|
||||
const window = {frameApp:{notify:async()=>{}}};
|
||||
const log = ()=>{}, toast = ()=>{};
|
||||
'''
|
||||
checks = r'''
|
||||
renderComfort({id:'old', active:false, time:100, options:{minutes:1,breakMinutes:0,stillMinutes:0,batteryAlert:false,heatAlert:false},
|
||||
events:[{id:'old:1',kind:'finished',time:90,message:'Session ended'}]});
|
||||
assert.equal($('sessionMinutes').value, '30');
|
||||
assert.equal($('breakMinutes').value, '20');
|
||||
assert.equal($('batteryAlert').checked, true);
|
||||
assert.match($('comfortEvents').textContent, /Session ended/);
|
||||
assert.notEqual($('comfortEvents').textContent, 'Session ended'); // it carries a time
|
||||
renderComfort({id:'new', active:true, time:200, remaining:600, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, events:[]});
|
||||
assert.equal($('sessionMinutes').value, 45); // a running session shows its own settings
|
||||
assert.equal($('heatAlert').checked, false);
|
||||
// The page stays open while that session ends: the next one starts from the defaults again.
|
||||
renderComfort({id:'new', active:false, time:900, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false},
|
||||
events:[{id:'new:1',kind:'finished',time:899,message:'Session ended'}]});
|
||||
assert.equal($('sessionMinutes').value, '30');
|
||||
assert.equal($('breakMinutes').value, '20');
|
||||
assert.equal($('heatAlert').checked, true);
|
||||
renderComfort({id:'new', active:false, time:905, options:{minutes:45}, events:[]});
|
||||
$('sessionMinutes').value = '50'; // what's typed for the next session
|
||||
renderComfort({id:'new', active:false, time:910, options:{minutes:45}, events:[]});
|
||||
assert.equal($('sessionMinutes').value, '50'); // later polls leave it alone
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
@@ -296,6 +296,20 @@ class Pins(Base):
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
def test_an_address_added_first_wins_on_its_own_network(self):
|
||||
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
|
||||
# worked here before. The LAN address has to go ahead of it to be used at home.
|
||||
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
|
||||
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
|
||||
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
|
||||
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
|
||||
addrs = self.reg.get(d["id"])["addresses"]
|
||||
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
|
||||
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
|
||||
self.assertEqual(at_home[0], "192.168.1.40")
|
||||
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
|
||||
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
|
||||
|
||||
def test_address_editing(self):
|
||||
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||
|
||||
@@ -457,6 +457,47 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual(self.routes, [])
|
||||
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
||||
|
||||
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
|
||||
d = self.device("nothing.invalid")
|
||||
entered = {1: threading.Event(), 2: threading.Event()}
|
||||
release = {1: threading.Event(), 2: threading.Event()}
|
||||
calls = []
|
||||
|
||||
def probe(host, port, update=None):
|
||||
calls.append(host)
|
||||
n = len(calls)
|
||||
entered[n].set()
|
||||
release[n].wait(10)
|
||||
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
|
||||
|
||||
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
|
||||
with mock.patch.object(fl, "probe", probe):
|
||||
try:
|
||||
tests[0].start()
|
||||
self.assertTrue(entered[1].wait(5), "the first test never probed")
|
||||
tests[1].start()
|
||||
self.assertTrue(entered[2].wait(5), "the second test never probed")
|
||||
# The first finishes while the second is still probing: it mustn't show its
|
||||
# rows or mark the second done.
|
||||
release[1].set()
|
||||
tests[0].join(10)
|
||||
self.assertFalse(tests[0].is_alive())
|
||||
running = self.link.snapshot()["tests"][d["id"]]
|
||||
self.assertFalse(running["done"])
|
||||
self.assertEqual(running["rows"][0]["detail"], "Waiting")
|
||||
release[2].set()
|
||||
tests[1].join(10)
|
||||
self.assertFalse(tests[1].is_alive())
|
||||
finally:
|
||||
for e in release.values():
|
||||
e.set()
|
||||
for t in tests:
|
||||
if t.ident: # started
|
||||
t.join(10)
|
||||
result = self.link.snapshot()["tests"][d["id"]]
|
||||
self.assertTrue(result["done"])
|
||||
self.assertEqual(result["rows"][0]["detail"], "test 2")
|
||||
|
||||
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
"""VR mod (UEVR) checks that need no headset or network: a fake Steam library on disk.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_mods # noqa: E402
|
||||
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
|
||||
|
||||
APPID = 1067310
|
||||
|
||||
|
||||
def zip_bytes(files, links=()):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
for name in links:
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
z.writestr(info, "/etc/passwd")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "the helper runs on the Frame (Linux): /proc, flock and POSIX prefix paths")
|
||||
class FakeLibrary(unittest.TestCase):
|
||||
"""A Steam library with Gravitas's real layout, and pinned downloads served from memory."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
home = Path(tmp.name)
|
||||
self.steam = home / ".local/share/Steam"
|
||||
apps = self.steam / "steamapps"
|
||||
(apps / "common/Gravitas/SkyArk/Binaries/Win64").mkdir(parents=True)
|
||||
(apps / "common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").write_bytes(b"MZ" * 100)
|
||||
(apps / "common/Gravitas/Drop.exe").write_bytes(b"MZ")
|
||||
self.prefix = apps / f"compatdata/{APPID}/pfx"
|
||||
for sub in ("Roaming", "Local"):
|
||||
(self.prefix / "drive_c/users/steamuser/AppData" / sub).mkdir(parents=True)
|
||||
(apps / f"appmanifest_{APPID}.acf").write_text(
|
||||
f'"AppState"\n{{\n\t"appid"\t\t"{APPID}"\n\t"name"\t\t"Gravitas"\n\t"installdir"\t\t"Gravitas"\n}}\n')
|
||||
for name, value in (("STEAM", self.steam), ("CACHE", home / ".cache/frame-control/mods"),
|
||||
("RECEIPTS", home / ".local/share/frame-control/mods")):
|
||||
p = mock.patch.object(frame_mods, name, value)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.running = None
|
||||
p = mock.patch.object(frame_mods, "game_pid", side_effect=lambda exe: self.running)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ",
|
||||
"openvr_api.dll": b"MZ"})}
|
||||
self.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"})
|
||||
self.pin(self.downloads)
|
||||
|
||||
def pin(self, downloads):
|
||||
"""Serve these bytes and pin their real hashes in place of the published ones."""
|
||||
for name in ("UEVR", "PYTHON"):
|
||||
pinned = getattr(frame_mods, name)
|
||||
p = mock.patch.object(frame_mods, name,
|
||||
dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest()))
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
p = mock.patch.object(frame_mods.urllib.request, "urlopen",
|
||||
side_effect=lambda url, timeout: io.BytesIO(downloads[url]))
|
||||
self.urlopen = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def managed(self):
|
||||
return self.prefix / "drive_c/frame-control"
|
||||
|
||||
|
||||
class Status(FakeLibrary):
|
||||
def test_unreal_game_is_eligible_and_not_installed(self):
|
||||
st = frame_mods.status(APPID)
|
||||
self.assertEqual((st["eligible"], st["exe"], st["installed"], st["running"], st["prefix"]),
|
||||
(True, "Drop-Win64-Shipping.exe", False, False, True))
|
||||
|
||||
def test_game_without_unreal_binary_is_refused(self):
|
||||
(self.steam / "steamapps/common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").unlink()
|
||||
self.assertFalse(frame_mods.status(APPID)["eligible"])
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "Unreal Engine"):
|
||||
frame_mods.install(APPID)
|
||||
self.urlopen.assert_not_called()
|
||||
|
||||
def test_uninstalled_game(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
|
||||
frame_mods.status(620)
|
||||
|
||||
|
||||
class InstallUninstall(FakeLibrary):
|
||||
def test_install_then_uninstall_leaves_prefix_as_found(self):
|
||||
before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
|
||||
res = frame_mods.install(APPID)
|
||||
self.assertTrue(res["installed"])
|
||||
self.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").is_file())
|
||||
self.assertTrue((self.managed() / "python-3.14.7/python.exe").is_file())
|
||||
self.assertIn("UEVRBackend.dll", (self.managed() / "python-3.14.7/frame_inject.py").read_text())
|
||||
self.assertFalse(list(self.managed().glob(".staging-*")))
|
||||
# What UEVR writes on its first injection.
|
||||
roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
|
||||
roaming.mkdir(parents=True)
|
||||
(roaming / "log.txt").write_text("x")
|
||||
(self.prefix / "drive_c/users/steamuser/AppData/Local/praydog/x").mkdir(parents=True)
|
||||
frame_mods.uninstall(APPID)
|
||||
after = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
|
||||
self.assertEqual(after, before)
|
||||
self.assertFalse(frame_mods.CACHE.exists(), "the downloads go once no game uses them")
|
||||
self.assertIsNone(frame_mods.read_receipt(APPID))
|
||||
|
||||
def test_existing_uevr_settings_survive_uninstall(self):
|
||||
profile = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
|
||||
profile.mkdir(parents=True)
|
||||
(profile / "config.txt").write_text("mine")
|
||||
frame_mods.install(APPID)
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertEqual((profile / "config.txt").read_text(), "mine")
|
||||
|
||||
def test_second_install_is_a_no_op(self):
|
||||
frame_mods.install(APPID)
|
||||
calls = self.urlopen.call_count
|
||||
self.assertIn("already installed", frame_mods.install(APPID)["message"])
|
||||
self.assertEqual(self.urlopen.call_count, calls)
|
||||
|
||||
def test_refuses_while_game_runs(self):
|
||||
self.running = 4242
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "running"):
|
||||
frame_mods.install(APPID)
|
||||
self.running = None
|
||||
frame_mods.install(APPID)
|
||||
self.running = 4242
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "running"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
|
||||
|
||||
def test_needs_a_prefix(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.prefix.parent)
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "play it once"):
|
||||
frame_mods.install(APPID)
|
||||
|
||||
def test_receipt_cannot_point_outside_the_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
victim = self.steam / "steamapps/common/Gravitas"
|
||||
r["remove"].append(str(self.prefix / "../../../common/Gravitas"))
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue(victim.is_dir())
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad")
|
||||
|
||||
|
||||
def test_receipt_cannot_delete_other_things_in_the_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
users = self.prefix / "drive_c/users"
|
||||
r["remove"].append(str(users))
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue(users.is_dir())
|
||||
|
||||
def test_failed_removal_keeps_the_receipt(self):
|
||||
frame_mods.install(APPID)
|
||||
with mock.patch.object(frame_mods.shutil, "rmtree"): # e.g. a permission error, swallowed
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "couldn't remove"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertIsNotNone(frame_mods.read_receipt(APPID), "Remove can be tried again")
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertFalse(self.managed().exists())
|
||||
|
||||
def test_receipt_cannot_name_another_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
r["prefix"] = "/"
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't app"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
|
||||
|
||||
|
||||
class Downloads(FakeLibrary):
|
||||
def test_hash_mismatch_deletes_and_installs_nothing(self):
|
||||
self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRBackend.dll": b"tampered"})
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
|
||||
self.assertFalse(self.managed().exists())
|
||||
self.assertIsNone(frame_mods.read_receipt(APPID))
|
||||
|
||||
def test_archive_escaping_its_folder_is_refused(self):
|
||||
for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}),
|
||||
zip_bytes({"UEVRBackend.dll": b"MZ"}, links=["openvr_api.dll"])):
|
||||
downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad})
|
||||
self.pin(downloads)
|
||||
with self.subTest(), self.assertRaises(frame_mods.Fail):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse((self.prefix / "drive_c/evil.dll").exists())
|
||||
self.assertFalse(list(self.managed().glob("*")) if self.managed().exists() else [])
|
||||
import shutil
|
||||
shutil.rmtree(frame_mods.CACHE, ignore_errors=True)
|
||||
|
||||
def test_oversized_archive_is_refused(self):
|
||||
with mock.patch.object(frame_mods, "MAX_UNPACKED", 3):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "more than expected"):
|
||||
frame_mods.install(APPID)
|
||||
|
||||
|
||||
def test_oversized_download_is_stopped(self):
|
||||
with mock.patch.object(frame_mods, "MAX_DOWNLOAD", 10):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "bigger than expected"):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse(list(frame_mods.CACHE.glob("*")))
|
||||
|
||||
|
||||
class Actions(FakeLibrary):
|
||||
def test_one_change_at_a_time(self):
|
||||
import fcntl
|
||||
lock = Path(tempfile.mkdtemp()) / "mods.lock"
|
||||
with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held:
|
||||
fcntl.flock(held, fcntl.LOCK_EX)
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "another mod action"):
|
||||
frame_mods.main(["install", str(APPID)])
|
||||
self.assertFalse(frame_mods.status(APPID)["installed"])
|
||||
self.assertEqual(frame_mods.main(["status", str(APPID)])["appid"], APPID, "status never waits")
|
||||
|
||||
def test_zero_frames_is_an_answer(self):
|
||||
stats = json.dumps([{"key": f"steam.app.{APPID}", "frame_submits": 0}, {"operation": "status"}])
|
||||
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=stats)):
|
||||
self.assertEqual(frame_mods.wait("frames", 5, lambda: frame_mods.frame_submits(APPID)), 0)
|
||||
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout='{"odd": 1}')):
|
||||
self.assertIsNone(frame_mods.frame_submits(APPID))
|
||||
|
||||
|
||||
class Start(FakeLibrary):
|
||||
def environ(self, text):
|
||||
"""The game's /proc/<pid>/environ; every other file reads normally."""
|
||||
real = frame_mods.Path.read_text
|
||||
return mock.patch.object(frame_mods.Path, "read_text", autospec=True, side_effect=lambda path, *a, **k:
|
||||
text if str(path).startswith("/proc/") else real(path, *a, **k))
|
||||
|
||||
def test_needs_install_first(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
|
||||
frame_mods.start(APPID)
|
||||
|
||||
def run_start(self, reply, loaded=(False, True, True, True)):
|
||||
frame_mods.install(APPID)
|
||||
self.running = 4242
|
||||
env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0"
|
||||
with self.environ(env), \
|
||||
mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \
|
||||
mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \
|
||||
mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \
|
||||
mock.patch.object(frame_mods, "frame_submits", return_value=7), \
|
||||
mock.patch.object(frame_mods.time, "sleep"):
|
||||
return frame_mods.start(APPID), run
|
||||
|
||||
def test_injects_with_the_games_wine_settings(self):
|
||||
res, run = self.run_start('noise\n{"pid": 320}\n')
|
||||
self.assertEqual((res["frame_submits"], res["message"]), (7, "UEVR injected into Gravitas; SteamVR is receiving its frames"))
|
||||
cmd, env = run.call_args.args[0], run.call_args.kwargs["env"]
|
||||
self.assertEqual(cmd[:2], ["wine", "C:\\frame-control\\python-3.14.7\\python.exe"])
|
||||
self.assertEqual(cmd[3:], ["Drop-Win64-Shipping.exe", "C:\\frame-control\\uevr-1.05",
|
||||
"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\Drop-Win64-Shipping"])
|
||||
self.assertEqual(env["WINEPREFIX"], "/x")
|
||||
self.assertNotIn("WINESERVERSOCKET", env, "an inherited fd number means nothing to a new process")
|
||||
self.assertIn("HOME", env, "fontconfig needs a HOME for its cache")
|
||||
|
||||
def test_reports_the_injection_error(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "couldn't open"):
|
||||
self.run_start('{"error": "couldn\'t open Drop-Win64-Shipping.exe (5)"}\n')
|
||||
|
||||
def test_success_is_checked_in_the_game(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "has no UEVRBackend.dll"):
|
||||
with mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)):
|
||||
self.run_start('{"pid": 320}\n', loaded=[False] * 40)
|
||||
|
||||
def test_inject_script_is_valid_python(self):
|
||||
compile(frame_mods.INJECT_PY, "frame_inject.py", "exec")
|
||||
|
||||
|
||||
class Helper(unittest.TestCase):
|
||||
@unittest.skipIf(sys.platform == "win32", "uninstall takes the flock lock, which only exists on the Frame (Linux)")
|
||||
def test_unexpected_errors_are_still_json(self):
|
||||
# A receipt from another version, without "exe": a KeyError, not a traceback.
|
||||
script = (ROOT / "ui" / "frame_mods.py").read_text().replace(
|
||||
"def read_receipt(appid):", "def read_receipt(appid):\n return {'prefix': '/x'}\n\n\ndef _unused(appid):")
|
||||
out = subprocess.run([sys.executable, "-", "uninstall", "1"], input=script,
|
||||
capture_output=True, text=True, timeout=30, env={"HOME": tempfile.mkdtemp()})
|
||||
self.assertEqual(out.returncode, 1, out.stderr)
|
||||
self.assertIn("KeyError", json.loads(out.stdout)["error"])
|
||||
|
||||
def test_bad_usage_prints_json_error(self):
|
||||
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
|
||||
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(out.returncode, 1, args)
|
||||
self.assertIn("error", json.loads(out.stdout), args)
|
||||
|
||||
|
||||
class ModRoutes(test_server.ServerGuards):
|
||||
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
|
||||
|
||||
def test_mod_input_validation(self):
|
||||
for body in ({"action": "install", "appid": "1; reboot"}, {"action": "install", "appid": ""},
|
||||
{"action": "delete", "appid": "1067310"}, {"appid": "1067310"}):
|
||||
status, payload = self.post("/api/mods", body)
|
||||
self.assertEqual(status, 400, f"{body} -> {payload}")
|
||||
|
||||
def test_needs_custom_header(self):
|
||||
self.assertEqual(self.request("POST", "/api/mods", {"action": "status", "appid": "1"})[0], 403)
|
||||
|
||||
def test_runs_the_mods_helper_with_its_timeout(self):
|
||||
import server
|
||||
with mock.patch.object(server, "steam_frame", return_value={"ok": 1}) as run:
|
||||
self.assertEqual(server.mods({"action": "install", "appid": "1067310"}), {"ok": 1})
|
||||
run.assert_called_once_with("install", "1067310", timeout=600, script="frame_mods.py")
|
||||
|
||||
|
||||
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
|
||||
setattr(ModRoutes, name, None)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""The page's inline scripts share one global scope, so a second top-level function or
|
||||
variable with a name already used replaces the first everywhere, silently."""
|
||||
import html.parser
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
# Compiles the scripts as one strict-mode block. There, functions are block scoped like let
|
||||
# and const, so V8 itself rejects a name declared twice in the shared scope (however it's
|
||||
# indented or declared: function, class, let, const, destructuring), while helpers with the
|
||||
# same name inside different functions stay legal. var is the exception (declaring one twice
|
||||
# is allowed), so the page doesn't use var at all: Node's own copy of the acorn parser finds
|
||||
# real var declarations, not the word in comments, strings or CSS var().
|
||||
DUPLICATES = r'''
|
||||
const vm = require('vm');
|
||||
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
try { new vm.Script('"use strict"; {\n' + scripts.join('\n;\n') + '\n}'); console.log('ok'); }
|
||||
catch (e) { console.log(e.message); }
|
||||
'''
|
||||
VARS = r'''
|
||||
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
let acorn, walk;
|
||||
try {
|
||||
acorn = require('internal/deps/acorn/acorn/dist/acorn');
|
||||
walk = require('internal/deps/acorn/acorn-walk/dist/walk');
|
||||
} catch { console.log('no parser'); process.exit(0); }
|
||||
for (const code of scripts) {
|
||||
let found = null;
|
||||
try {
|
||||
walk.simple(acorn.parse(code, { ecmaVersion: 'latest' }), {
|
||||
VariableDeclaration(n) { if (n.kind === 'var' && !found) found = code.slice(n.start, n.end).slice(0, 40); },
|
||||
});
|
||||
} catch (e) { console.log(e.message); process.exit(0); }
|
||||
if (found) { console.log('declares with var: ' + found); process.exit(0); }
|
||||
}
|
||||
console.log('ok');
|
||||
'''
|
||||
|
||||
|
||||
def node(script, scripts, *flags):
|
||||
r = subprocess.run(['node', *flags, '-e', script], input=json.dumps(scripts), capture_output=True, text=True)
|
||||
return r.stdout.strip() or r.stderr.strip()
|
||||
|
||||
|
||||
def check(scripts):
|
||||
"""'ok', or V8's message for the first name declared twice."""
|
||||
return node(DUPLICATES, scripts)
|
||||
|
||||
|
||||
def check_vars(scripts):
|
||||
"""'ok', or the first var declaration. Skips where Node doesn't include acorn."""
|
||||
out = node(VARS, scripts, '--expose-internals')
|
||||
if out == 'no parser':
|
||||
raise unittest.SkipTest("this Node doesn't include acorn")
|
||||
return out
|
||||
|
||||
|
||||
class ClassicScripts(html.parser.HTMLParser):
|
||||
"""The page's inline classic scripts: no src, and no type other than a JavaScript one (a
|
||||
module has its own scope, and JSON data isn't code). The types a browser runs as script:
|
||||
https://mimesniff.spec.whatwg.org/#javascript-mime-type"""
|
||||
JS = {'', 'application/ecmascript', 'application/javascript', 'application/x-ecmascript',
|
||||
'application/x-javascript', 'text/ecmascript', 'text/javascript', 'text/javascript1.0',
|
||||
'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4',
|
||||
'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript'}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.scripts, self.current = [], None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'script':
|
||||
a = dict(attrs)
|
||||
inline = 'src' not in a and (a.get('type') or '').strip().lower() in self.JS
|
||||
self.current = [] if inline else None
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.current is not None:
|
||||
self.current.append(data)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == 'script' and self.current is not None:
|
||||
self.scripts.append(''.join(self.current))
|
||||
if tag == 'script':
|
||||
self.current = None
|
||||
|
||||
|
||||
def classic_scripts(page):
|
||||
p = ClassicScripts()
|
||||
p.feed(page)
|
||||
return p.scripts
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node parses the page scripts')
|
||||
class PageScripts(unittest.TestCase):
|
||||
def test_no_top_level_name_is_declared_twice(self):
|
||||
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
|
||||
self.assertGreaterEqual(len(scripts), 2)
|
||||
self.assertEqual(check(scripts), 'ok')
|
||||
|
||||
def test_the_page_declares_nothing_with_var(self):
|
||||
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
|
||||
self.assertEqual(check_vars(scripts), 'ok')
|
||||
|
||||
def test_the_check_finds_what_it_should(self):
|
||||
twice = {
|
||||
'indented function': ['function loadPanels() {}', ' async function loadPanels() {}'],
|
||||
'class': ['class Panel {}', 'class Panel {}'],
|
||||
'destructured': ['const { a, b } = {};', 'let [b] = [];'],
|
||||
'later declarator': ['let x = 1;', 'const y = 2, x = 3;'],
|
||||
'function and const': ['function f() {}', 'const f = 1;'],
|
||||
}
|
||||
for what, scripts in twice.items():
|
||||
with self.subTest(what):
|
||||
self.assertIn('already been declared', check(scripts))
|
||||
helpers = ['function a() { function help() {} }', 'function b() { const help = 1; }']
|
||||
self.assertEqual(check(helpers), 'ok')
|
||||
|
||||
def test_the_var_check_finds_what_it_should(self):
|
||||
# var: legal to declare twice, so not allowed at all; however it's written.
|
||||
for code in ['var x = 1;', 'var/*c*/x = 1;', 'var {x} = {x: 1};', 'function f() { var y; }']:
|
||||
with self.subTest(code):
|
||||
self.assertIn('declares with var', check_vars([code]))
|
||||
# The word var elsewhere is fine.
|
||||
text = ['// var x\nconst a = "var y", b = `var ${a}`, c = "color: var(--blue)", d = {}.var;']
|
||||
self.assertEqual(check_vars(text), 'ok')
|
||||
|
||||
def test_only_inline_classic_scripts_are_checked(self):
|
||||
page = ('<script>let a;</script><script type="module">export const m = 1;</script>'
|
||||
'<script type="application/json">{"b": 1}</script><script src = "x.js"></script>'
|
||||
'<script data-src="y" type="text/javascript">let c;</script>'
|
||||
'<script type="text/ecmascript">let d;</script><script type=" Application/X-JavaScript ">let e;</script>')
|
||||
self.assertEqual(classic_scripts(page), ['let a;', 'let c;', 'let d;', 'let e;'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""The page calls showPage() while its first script is still running, before names declared
|
||||
later (in that script or the second one) exist. Touching one of them there throws, and the
|
||||
rest of the page's setup never runs: opening Frame Control at #games did exactly that."""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles']
|
||||
|
||||
RUN = r'''
|
||||
const vm = require('vm');
|
||||
const {code, hashes} = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||
const failures = [];
|
||||
for (const hash of hashes) {
|
||||
const context = vm.createContext({
|
||||
location: {hash}, document: {querySelectorAll: () => [], title: ''}, live: false,
|
||||
});
|
||||
try { vm.runInContext(code, context); }
|
||||
catch (e) { failures.push(`${hash}: ${e.message}`); }
|
||||
}
|
||||
console.log(JSON.stringify(failures));
|
||||
'''
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node runs the page code')
|
||||
class PageStartup(unittest.TestCase):
|
||||
def test_opening_any_page_or_section_at_startup_runs(self):
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
first, second = re.findall(r'<script>(.*?)</script>', page, re.S)[:2]
|
||||
tables = first[first.index('const PAGES ='):first.index('let page =')]
|
||||
start = first.index('function showPage(')
|
||||
show = first[start:first.index('\n}\n', start) + 3]
|
||||
# Everything declared after the startup call is still uninitialised when it runs.
|
||||
call = re.search(r'^showPage\(\);', first, re.M).end()
|
||||
later = re.findall(r'^(?:const|let)\s+(\w+)', first[call:] + second, re.M)
|
||||
later = [n for n in dict.fromkeys(later) if n not in STUBS and n not in ('PAGES', 'SECTION_PAGE', 'page')]
|
||||
self.assertIn('link', later) # the name that broke #games
|
||||
stubs = ''.join(f'function {n}() {{ return {{ classList: {{ toggle() {{}} }}, scrollIntoView() {{}} }}; }}\n'
|
||||
for n in STUBS if n != '$')
|
||||
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + show +
|
||||
'showPage();\n' + ''.join(f'let {n};\n' for n in later))
|
||||
hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#nowhere']
|
||||
hashes += ['#' + k for k in re.findall(r'(\w+): "', tables)]
|
||||
r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertEqual(json.loads(r.stdout), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+4
-2
@@ -623,7 +623,9 @@ class Registry:
|
||||
return a
|
||||
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
||||
|
||||
def add_address(self, device_id, host, kind=None, label=""):
|
||||
def add_address(self, device_id, host, kind=None, label="", first=False):
|
||||
"""Add an address at the end of the list, or at the front (first=True), where the
|
||||
user's order makes it win over the others that work on the same network."""
|
||||
with self._changing():
|
||||
d = self._find(device_id)
|
||||
a = new_address(host, kind, label)
|
||||
@@ -631,7 +633,7 @@ class Registry:
|
||||
raise DeviceError(f"{a['host']} is already on the list")
|
||||
if len(d["addresses"]) >= 32:
|
||||
raise DeviceError("That's enough addresses for one headset")
|
||||
d["addresses"].append(a)
|
||||
d["addresses"].insert(0 if first else len(d["addresses"]), a)
|
||||
self.save()
|
||||
return copy.deepcopy(a)
|
||||
|
||||
|
||||
+7
-1
@@ -169,6 +169,7 @@ class Link:
|
||||
self.version = 0
|
||||
self.stopped = False
|
||||
self.kicks = [] # reasons someone asked for a (re)connect
|
||||
self.test_gen = {} # device id -> its newest test of the addresses (see test())
|
||||
self.busy = False # the loop is handling kicks
|
||||
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
|
||||
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
|
||||
@@ -955,9 +956,13 @@ class Link:
|
||||
started = now()
|
||||
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
|
||||
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
|
||||
with self.cond:
|
||||
gen = self.test_gen[device_id] = self.test_gen.get(device_id, 0) + 1
|
||||
|
||||
def put(**fields):
|
||||
with self.cond:
|
||||
if gen != self.test_gen[device_id]:
|
||||
return # a newer test has started: its results are the ones to show
|
||||
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
|
||||
self.version += 1
|
||||
self.cond.notify_all()
|
||||
@@ -1111,7 +1116,8 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
|
||||
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
|
||||
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
|
||||
elif action == "address-add":
|
||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
|
||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "",
|
||||
first=body.get("first") is True)
|
||||
if is_active and link.state["phase"] == "failed":
|
||||
link.kick("retry")
|
||||
msg = f"Added {a['host']}"
|
||||
|
||||
@@ -1,532 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the Frame (piped over SSH): UEVR, a flat-to-VR mod for Unreal games.
|
||||
|
||||
Usage: python3 - status APPID # can UEVR run here, is it installed, is the game running
|
||||
python3 - install APPID # download the pinned official release into the game's prefix
|
||||
python3 - start APPID # launch the game if needed, inject UEVR, confirm SteamVR frames
|
||||
python3 - uninstall APPID # remove exactly what install (and UEVR's first run) created
|
||||
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
|
||||
|
||||
Verified 2026-09-29 on SteamOS 0.4.1 (build 20260925.6191901), Proton 11.0
|
||||
ARM64 and SteamVR 2.18.1 with Gravitas (1067310): after injection the game has
|
||||
UEVRBackend.dll loaded and SteamVR counts frame submits for it. Stereo image,
|
||||
head tracking and controls were not seen: the headset was unworn (docs/mods.md).
|
||||
|
||||
UEVR's own injector (a .NET WPF app) is not used. Under Wine and FEX it hung
|
||||
or ignored input in about one session in four, and gamescope keeps real input
|
||||
away from it. Instead the official Windows embeddable Python runs INJECT_PY in
|
||||
the game's Wine session, doing what the injector does on Inject.
|
||||
|
||||
Nothing here is bundled. UEVR comes from praydog's GitHub release and Python
|
||||
from python.org, each checked against a pinned hash. Files live in
|
||||
<prefix>/drive_c/frame-control, never in the game's own folder, and a receipt
|
||||
records what to remove.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
STEAM = HOME / ".local/share/Steam"
|
||||
CACHE = HOME / ".cache/frame-control/mods"
|
||||
RECEIPTS = HOME / ".local/share/frame-control/mods"
|
||||
LOCK = Path("/tmp/frame-control-mods.lock") # tmpfs: gone at reboot, never left in ~
|
||||
VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
|
||||
MANAGED = "frame-control" # drive_c/<this> inside the game's prefix
|
||||
|
||||
UEVR = {"version": "1.05", "dir": "uevr-1.05",
|
||||
"url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip",
|
||||
"sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"}
|
||||
# Windows x64 embeddable Python, to run INJECT_PY. Hash from python.org's
|
||||
# release-file API (sha256_sum).
|
||||
PYTHON = {"version": "3.14.7", "dir": "python-3.14.7",
|
||||
"url": "https://www.python.org/ftp/python/3.14.7/python-3.14.7-embed-amd64.zip",
|
||||
"sha256": "d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15"}
|
||||
MAX_UNPACKED = 200 << 20 # the two archives unpack to about 50 MB
|
||||
MAX_DOWNLOAD = 50 << 20 # each archive is 7–13 MB
|
||||
|
||||
# The game's environment a second program needs to join its Wine session. Not
|
||||
# WINESERVERSOCKET and friends: those are inherited file descriptors.
|
||||
GAME_ENV = re.compile(r"(WINEPREFIX|WINEDLLPATH|WINEDLLOVERRIDES|WINEFSYNC|WINEDEBUG|PATH|DISPLAY|"
|
||||
r"XDG_RUNTIME_DIR|PROTON_VR_RUNTIME|FEX_APP_CONFIG|FEX_APP_CONFIG_LOCATION|"
|
||||
r"SteamAppId|SteamGameId|STEAM_COMPAT_DATA_PATH|WINE_LARGE_ADDRESS_AWARE)=")
|
||||
|
||||
|
||||
# Runs under Windows Python in the game's Wine session: the steps of UEVR
|
||||
# 1.05's frontend (UEVR/MainWindow.xaml.cs Inject_Clicked, UEVR/Injector.cs)
|
||||
# with "Nullify VR plugins" on and the OpenVR runtime. Prints one JSON line.
|
||||
INJECT_PY = r"""
|
||||
import ctypes, json, os, sys
|
||||
from ctypes import wintypes as W
|
||||
exe, uevr, profile = sys.argv[1:4]
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
H, P, SZ = W.HANDLE, ctypes.c_void_p, ctypes.c_size_t
|
||||
for name, res, args in (
|
||||
("OpenProcess", H, [W.DWORD, W.BOOL, W.DWORD]),
|
||||
("CloseHandle", W.BOOL, [H]),
|
||||
("VirtualAllocEx", P, [H, P, SZ, W.DWORD, W.DWORD]),
|
||||
("VirtualFreeEx", W.BOOL, [H, P, SZ, W.DWORD]),
|
||||
("WriteProcessMemory", W.BOOL, [H, P, P, SZ, P]),
|
||||
("CreateRemoteThread", H, [H, P, SZ, P, P, W.DWORD, P]),
|
||||
("WaitForSingleObject", W.DWORD, [H, W.DWORD]),
|
||||
("GetModuleHandleW", W.HMODULE, [W.LPCWSTR]),
|
||||
("LoadLibraryW", W.HMODULE, [W.LPCWSTR]),
|
||||
("FreeLibrary", W.BOOL, [W.HMODULE]),
|
||||
("GetProcAddress", P, [W.HMODULE, ctypes.c_char_p]),
|
||||
("CreateToolhelp32Snapshot", H, [W.DWORD, W.DWORD]),
|
||||
("Process32FirstW", W.BOOL, [H, P]), ("Process32NextW", W.BOOL, [H, P]),
|
||||
("Module32FirstW", W.BOOL, [H, P]), ("Module32NextW", W.BOOL, [H, P])):
|
||||
fn = getattr(k32, name)
|
||||
fn.restype, fn.argtypes = res, args
|
||||
INVALID = ctypes.c_void_p(-1).value
|
||||
|
||||
class Entry(ctypes.Structure): # PROCESSENTRY32W
|
||||
_fields_ = [("size", W.DWORD), ("usage", W.DWORD), ("pid", W.DWORD), ("heap", ctypes.c_void_p),
|
||||
("module", W.DWORD), ("threads", W.DWORD), ("parent", W.DWORD), ("prio", ctypes.c_long),
|
||||
("flags", W.DWORD), ("name", W.WCHAR * 260)]
|
||||
|
||||
class Module(ctypes.Structure): # MODULEENTRY32W
|
||||
_fields_ = [("size", W.DWORD), ("mid", W.DWORD), ("pid", W.DWORD), ("glbl", W.DWORD), ("proc", W.DWORD),
|
||||
("base", ctypes.c_void_p), ("bytes", W.DWORD), ("handle", W.HMODULE),
|
||||
("name", W.WCHAR * 256), ("path", W.WCHAR * 260)]
|
||||
|
||||
def fail(msg):
|
||||
print(json.dumps({"error": f"{msg} (Windows error {ctypes.get_last_error()})"})); sys.exit(1)
|
||||
|
||||
def snapshot(flags, pid, entry, first, next_, match):
|
||||
snap = k32.CreateToolhelp32Snapshot(flags, pid)
|
||||
if not snap or snap == INVALID:
|
||||
fail("couldn't list processes")
|
||||
try:
|
||||
entry.size = ctypes.sizeof(entry)
|
||||
ok = first(snap, ctypes.byref(entry))
|
||||
while ok:
|
||||
if match(entry):
|
||||
return entry
|
||||
ok = next_(snap, ctypes.byref(entry))
|
||||
finally:
|
||||
k32.CloseHandle(snap)
|
||||
|
||||
def remote_call(proc, fn, arg, wait_ms):
|
||||
t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None)
|
||||
if not t:
|
||||
fail("CreateRemoteThread failed")
|
||||
try:
|
||||
return k32.WaitForSingleObject(t, wait_ms) == 0 # WAIT_OBJECT_0
|
||||
finally:
|
||||
k32.CloseHandle(t)
|
||||
|
||||
def inject(proc, pid, name):
|
||||
path = os.path.join(uevr, name)
|
||||
data = ctypes.create_unicode_buffer(path)
|
||||
mem = k32.VirtualAllocEx(proc, None, ctypes.sizeof(data), 0x3000, 0x04) # commit|reserve, read/write
|
||||
if not mem:
|
||||
fail(f"couldn't allocate in {exe}")
|
||||
try:
|
||||
if not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None):
|
||||
fail(f"couldn't write into {exe}")
|
||||
load = k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW")
|
||||
if not remote_call(proc, load, mem, 30000):
|
||||
fail(f"loading {name} into {exe} didn't finish in 30 s")
|
||||
finally:
|
||||
k32.VirtualFreeEx(proc, mem, 0, 0x8000) # MEM_RELEASE
|
||||
m = snapshot(0x18, pid, Module(), k32.Module32FirstW, k32.Module32NextW, # TH32CS_SNAPMODULE | SNAPMODULE32
|
||||
lambda m: m.path.lower() == path.lower())
|
||||
if not m:
|
||||
fail(f"{name} didn't load into {exe}")
|
||||
return path, m.base
|
||||
|
||||
e = snapshot(2, 0, Entry(), k32.Process32FirstW, k32.Process32NextW, # TH32CS_SNAPPROCESS
|
||||
lambda e: e.name.lower() == exe.lower())
|
||||
if not e:
|
||||
fail(f"{exe} isn't running")
|
||||
pid = e.pid
|
||||
proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS
|
||||
if not proc:
|
||||
fail(f"couldn't open {exe}")
|
||||
path, base = inject(proc, pid, "UEVRPluginNullifier.dll")
|
||||
local = k32.LoadLibraryW(path)
|
||||
fn = local and k32.GetProcAddress(local, b"nullify")
|
||||
if not fn:
|
||||
fail("couldn't find UEVRPluginNullifier.dll's nullify")
|
||||
offset = fn - local # same DLL, same layout in both processes
|
||||
k32.FreeLibrary(local)
|
||||
# The frontend waits 2 s for nullify and carries on either way; so do we, but say so.
|
||||
nullified = remote_call(proc, base + offset, None, 2000)
|
||||
inject(proc, pid, "openvr_api.dll")
|
||||
# The frontend saves the runtime choice in the per-game config before UEVRBackend reads it.
|
||||
os.makedirs(profile, exist_ok=True)
|
||||
cfg = os.path.join(profile, "config.txt")
|
||||
lines = open(cfg).read().splitlines() if os.path.exists(cfg) else []
|
||||
lines = [l for l in lines if not l.startswith("Frontend_RequestedRuntime=")] + ["Frontend_RequestedRuntime=openvr_api.dll"]
|
||||
open(cfg, "w").write("\n".join(lines) + "\n")
|
||||
inject(proc, pid, "UEVRBackend.dll")
|
||||
k32.CloseHandle(proc)
|
||||
print(json.dumps({"pid": pid, "nullified": nullified}))
|
||||
"""
|
||||
|
||||
|
||||
class Fail(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def libraries():
|
||||
"""Steam library folders: the default one plus any in libraryfolders.vdf."""
|
||||
libs = [STEAM]
|
||||
try:
|
||||
text = (STEAM / "steamapps/libraryfolders.vdf").read_text(errors="replace")
|
||||
libs += [Path(p.replace("\\\\", "\\")) for p in re.findall(r'"path"\s+"([^"]+)"', text)]
|
||||
except OSError:
|
||||
pass
|
||||
seen, out = set(), []
|
||||
for lib in libs:
|
||||
key = os.path.realpath(lib)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(lib)
|
||||
return out
|
||||
|
||||
|
||||
def game(appid):
|
||||
"""Where the game is installed and its Proton prefix, from its app manifest."""
|
||||
for lib in libraries():
|
||||
manifest = lib / f"steamapps/appmanifest_{appid}.acf"
|
||||
try:
|
||||
text = manifest.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
m = re.search(r'"installdir"\s+"([^"]+)"', text)
|
||||
if not m:
|
||||
continue
|
||||
name = re.search(r'"name"\s+"([^"]*)"', text)
|
||||
return {"name": name.group(1) if name else str(appid),
|
||||
"dir": lib / "steamapps/common" / m.group(1),
|
||||
"prefix": lib / f"steamapps/compatdata/{appid}/pfx"}
|
||||
raise Fail(f"app {appid} isn't installed on this Frame")
|
||||
|
||||
|
||||
def shipping_exe(game_dir):
|
||||
"""The Unreal Engine game binary (…/Binaries/Win64/*-Win64-Shipping.exe), or None."""
|
||||
found = [p for p in game_dir.glob("*/Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
|
||||
found += [p for p in game_dir.glob("Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
|
||||
return max(found, key=lambda p: p.stat().st_size) if found else None
|
||||
|
||||
|
||||
def game_pid(exe_name):
|
||||
"""The running Wine process for this .exe (its cmdline is the Windows path)."""
|
||||
want = "\\" + exe_name.lower()
|
||||
for d in Path("/proc").iterdir():
|
||||
if not d.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
cmd = (d / "cmdline").read_bytes().split(b"\0")[0].decode(errors="replace").lower()
|
||||
except OSError:
|
||||
continue
|
||||
if cmd.endswith(want):
|
||||
return int(d.name)
|
||||
return None
|
||||
|
||||
|
||||
def loaded(pid, name):
|
||||
try:
|
||||
return any(line.rstrip().endswith("/" + name) for line in open(f"/proc/{pid}/maps"))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def receipt_path(appid):
|
||||
return RECEIPTS / f"{appid}-uevr.json"
|
||||
|
||||
|
||||
def read_receipt(appid):
|
||||
try:
|
||||
return json.loads(receipt_path(appid).read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def managed_dir(g):
|
||||
return g["prefix"] / "drive_c" / MANAGED
|
||||
|
||||
|
||||
def user_dirs(g, exe):
|
||||
"""What UEVR itself writes into the prefix when it first runs."""
|
||||
user = g["prefix"] / "drive_c/users/steamuser/AppData"
|
||||
return {"profile": user / "Roaming/UnrealVRMod" / exe.stem, # per-game settings and log.txt
|
||||
"injector": user / "Local/praydog"} # the injector's own settings
|
||||
|
||||
|
||||
def status(appid):
|
||||
g = game(appid)
|
||||
exe = shipping_exe(g["dir"])
|
||||
r = read_receipt(appid)
|
||||
pid = game_pid(exe.name) if exe else None
|
||||
return {"appid": appid, "name": g["name"], "mod": "UEVR", "version": UEVR["version"],
|
||||
"eligible": bool(exe), "exe": exe.name if exe else None,
|
||||
"prefix": g["prefix"].is_dir(), "installed": bool(r),
|
||||
"running": bool(pid), "injected": bool(pid and loaded(pid, "UEVRBackend.dll"))}
|
||||
|
||||
|
||||
def fetch(url, digest, algo):
|
||||
"""Download into the cache once, and check its hash every time it's used."""
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
path = CACHE / url.rsplit("/", 1)[1]
|
||||
if not path.exists():
|
||||
part = path.with_suffix(path.suffix + ".part")
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f:
|
||||
for block in iter(lambda: r.read(1 << 20), b""):
|
||||
if f.tell() + len(block) > MAX_DOWNLOAD:
|
||||
raise Fail(f"{url} is bigger than expected; stopped the download")
|
||||
f.write(block)
|
||||
except (OSError, Fail) as e:
|
||||
part.unlink(missing_ok=True)
|
||||
raise Fail(f"download failed: {url}: {e}")
|
||||
part.rename(path)
|
||||
h = hashlib.new(algo)
|
||||
with open(path, "rb") as f:
|
||||
for block in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(block)
|
||||
if h.hexdigest() != digest:
|
||||
path.unlink()
|
||||
raise Fail(f"{path.name} doesn't match its published {algo}; deleted it, try again")
|
||||
return path
|
||||
|
||||
|
||||
def unpack(archive, dest, budget):
|
||||
"""Extract a ZIP into dest, refusing anything that would land outside it."""
|
||||
root = os.path.realpath(dest)
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
for info in z.infolist():
|
||||
target = os.path.realpath(os.path.join(dest, info.filename))
|
||||
if target != root and not target.startswith(root + os.sep):
|
||||
raise Fail(f"{archive.name} has an entry outside its folder: {info.filename}")
|
||||
if (info.external_attr >> 16) & 0o170000 == 0o120000:
|
||||
raise Fail(f"{archive.name} contains a symlink: {info.filename}")
|
||||
budget -= info.file_size
|
||||
if budget < 0:
|
||||
raise Fail(f"{archive.name} unpacks to more than expected")
|
||||
z.extractall(dest)
|
||||
return budget
|
||||
|
||||
|
||||
def install(appid):
|
||||
g = game(appid)
|
||||
exe = shipping_exe(g["dir"])
|
||||
if not exe:
|
||||
raise Fail(f"{g['name']} doesn't look like an Unreal Engine game (no *-Win64-Shipping.exe), so UEVR can't hook it")
|
||||
if not g["prefix"].is_dir():
|
||||
raise Fail(f"{g['name']} has no Proton prefix yet; play it once, then add the mod")
|
||||
if game_pid(exe.name):
|
||||
raise Fail(f"{g['name']} is running; quit it first")
|
||||
if read_receipt(appid):
|
||||
return {"message": f"UEVR {UEVR['version']} is already installed for {g['name']}", **status(appid)}
|
||||
archives = [(fetch(UEVR["url"], UEVR["sha256"], "sha256"), UEVR["dir"])]
|
||||
archives.append((fetch(PYTHON["url"], PYTHON["sha256"], "sha256"), PYTHON["dir"]))
|
||||
base = managed_dir(g)
|
||||
dirs = user_dirs(g, exe)
|
||||
fresh = [str(d) for d in (dirs["profile"].parent, base) if not d.exists()] # remove on uninstall if empty
|
||||
stage = base / f".staging-{os.getpid()}"
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
try:
|
||||
budget = MAX_UNPACKED
|
||||
for archive, sub in archives:
|
||||
budget = unpack(archive, stage / sub, budget)
|
||||
for need in (stage / UEVR["dir"] / "UEVRBackend.dll", stage / PYTHON["dir"] / "python.exe"):
|
||||
if not need.is_file():
|
||||
raise Fail(f"the download has no {need.name}")
|
||||
if game_pid(exe.name): # it may have started during the download
|
||||
raise Fail(f"{g['name']} started; quit it first")
|
||||
(stage / PYTHON["dir"] / "frame_inject.py").write_text(INJECT_PY)
|
||||
for sub in (UEVR["dir"], PYTHON["dir"]):
|
||||
shutil.rmtree(base / sub, ignore_errors=True)
|
||||
(stage / sub).rename(base / sub)
|
||||
finally:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
if str(base) in fresh:
|
||||
try:
|
||||
base.rmdir() # only succeeds if the install failed and left it empty
|
||||
except OSError:
|
||||
pass
|
||||
receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name,
|
||||
"installed": int(time.time()), "prefix": str(g["prefix"]),
|
||||
"remove": [str(base / UEVR["dir"]), str(base / PYTHON["dir"])],
|
||||
# Parents that didn't exist yet (UEVR makes UnrealVRMod), deepest first.
|
||||
"remove_if_empty": fresh,
|
||||
# UEVR creates these on first injection. Remove them on uninstall
|
||||
# only if they weren't there before, so earlier settings survive.
|
||||
"remove_if_created": {k: str(v) for k, v in dirs.items() if not v.exists()},
|
||||
"sources": [UEVR["url"], PYTHON["url"]]}
|
||||
RECEIPTS.mkdir(parents=True, exist_ok=True)
|
||||
tmp = receipt_path(appid).with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(receipt, indent=1))
|
||||
tmp.rename(receipt_path(appid))
|
||||
return {"message": f"Installed UEVR {UEVR['version']} for {g['name']}", **status(appid)}
|
||||
|
||||
|
||||
def x11_windows(display):
|
||||
"""(id, name, width) for each named top-level window, via xwininfo."""
|
||||
try:
|
||||
out = subprocess.run(["xwininfo", "-root", "-tree"], env={**os.environ, "DISPLAY": display},
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
return [(int(m[1], 16), m[2], int(m[3])) for m in re.finditer(r'(0x[0-9a-f]+) "([^"]*)":.*?\)\s+(\d+)x\d+', out)]
|
||||
|
||||
|
||||
def frame_submits(appid):
|
||||
"""SteamVR's count of frames the app has submitted, or None if it isn't a scene app."""
|
||||
try:
|
||||
stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout)
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if not isinstance(stats, list):
|
||||
return None
|
||||
return next((s.get("frame_submits") for s in stats if isinstance(s, dict) and s.get("key") == f"steam.app.{appid}"), None)
|
||||
|
||||
|
||||
def wait(what, seconds, check, step=2):
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
v = check()
|
||||
if v is not None and v is not False: # 0 frames is still an answer
|
||||
return v
|
||||
time.sleep(step)
|
||||
raise Fail(f"timed out waiting for {what}")
|
||||
|
||||
|
||||
def start(appid):
|
||||
g = game(appid)
|
||||
r = read_receipt(appid)
|
||||
if not r:
|
||||
raise Fail(f"UEVR isn't installed for {g['name']}")
|
||||
exe = r["exe"]
|
||||
pid = game_pid(exe)
|
||||
if pid and loaded(pid, "UEVRBackend.dll"):
|
||||
return {"message": f"UEVR is already running in {g['name']}", "frame_submits": frame_submits(appid), **status(appid)}
|
||||
if not pid:
|
||||
subprocess.Popen(["steam", f"steam://rungameid/{appid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")}, start_new_session=True)
|
||||
pid = wait(f"{g['name']} to start", 120, lambda: game_pid(exe))
|
||||
try:
|
||||
environ = Path(f"/proc/{pid}/environ").read_text(errors="replace")
|
||||
except OSError:
|
||||
raise Fail(f"{exe} exited") from None
|
||||
game_env = dict(line.split("=", 1) for line in environ.split("\0") if GAME_ENV.match(line))
|
||||
display = game_env.get("DISPLAY")
|
||||
if not display or "WINEPREFIX" not in game_env:
|
||||
raise Fail(f"{exe} doesn't look like a Proton game process")
|
||||
# UEVR hooks the game's D3D11/12 device, so wait for its window. Proton's
|
||||
# own helper windows ("Steam", "Default IME", …) are small.
|
||||
wait("the game window", 90, lambda: any(w[2] >= 640 for w in x11_windows(display)))
|
||||
time.sleep(10)
|
||||
# Our own environment (HOME and the rest) under the game's Wine settings.
|
||||
env = {**os.environ, **game_env}
|
||||
base = f"C:\\{MANAGED}"
|
||||
profile = f"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\{Path(exe).stem}"
|
||||
try:
|
||||
out = subprocess.run(["wine", f"{base}\\{PYTHON['dir']}\\python.exe", f"{base}\\{PYTHON['dir']}\\frame_inject.py",
|
||||
exe, f"{base}\\{UEVR['dir']}", profile], env=env, capture_output=True, text=True,
|
||||
timeout=120, stdin=subprocess.DEVNULL,
|
||||
cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED))
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Fail("the injection step didn't finish in 2 minutes") from None
|
||||
reply = None
|
||||
for line in reversed(out.stdout.splitlines()):
|
||||
try:
|
||||
reply = json.loads(line)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(reply, dict):
|
||||
reply = None
|
||||
if not reply or "error" in reply:
|
||||
raise Fail("UEVR injection failed: " + (reply or {}).get("error", (out.stderr.strip().splitlines() or ["no output"])[-1]))
|
||||
try:
|
||||
wait("UEVRBackend.dll to load in the game", 20, lambda: loaded(pid, "UEVRBackend.dll"))
|
||||
except Fail:
|
||||
raise Fail(f"the injection reported success but {exe} has no UEVRBackend.dll") from None
|
||||
submits = None
|
||||
try:
|
||||
submits = wait("SteamVR frames", 30, lambda: frame_submits(appid))
|
||||
except Fail:
|
||||
pass
|
||||
return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits is not None
|
||||
else "; SteamVR hasn't reported frames from it yet"),
|
||||
"frame_submits": submits, **status(appid)}
|
||||
|
||||
|
||||
def uninstall(appid):
|
||||
r = read_receipt(appid)
|
||||
if not r:
|
||||
raise Fail(f"UEVR isn't installed for app {appid}")
|
||||
if game_pid(r["exe"]):
|
||||
raise Fail("the game is running; quit it first")
|
||||
prefix = os.path.realpath(r["prefix"])
|
||||
if not prefix.endswith(f"/steamapps/compatdata/{appid}/pfx"):
|
||||
raise Fail(f"receipt names a prefix that isn't app {appid}'s: {r['prefix']}")
|
||||
# Only ever our folder and UEVR's own settings folders for this game, even
|
||||
# if the receipt says otherwise.
|
||||
managed = os.path.join(prefix, "drive_c", MANAGED)
|
||||
uevr_dirs = {os.path.realpath(str(d)) for d in user_dirs({"prefix": Path(prefix)}, Path(r["exe"])).values()}
|
||||
targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values())
|
||||
for t in targets:
|
||||
real = os.path.realpath(t)
|
||||
if not (real.startswith(managed + os.sep) or real in uevr_dirs):
|
||||
raise Fail(f"receipt lists a path Frame Control didn't create: {t}")
|
||||
for t in targets:
|
||||
shutil.rmtree(t, ignore_errors=True)
|
||||
left = [t for t in targets if os.path.lexists(t)]
|
||||
if left: # keep the receipt, so Remove can be tried again
|
||||
raise Fail("couldn't remove " + ", ".join(left))
|
||||
parents = {os.path.realpath(os.path.join(prefix, "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod")), managed}
|
||||
for t in r.get("remove_if_empty", []):
|
||||
if os.path.realpath(t) in parents:
|
||||
try:
|
||||
os.rmdir(t)
|
||||
except OSError:
|
||||
pass # something else lives there now
|
||||
receipt_path(appid).unlink()
|
||||
if not any(RECEIPTS.glob("*-uevr.json")): # no other game uses the downloads
|
||||
shutil.rmtree(CACHE, ignore_errors=True)
|
||||
try:
|
||||
RECEIPTS.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return {"message": "Removed UEVR", "appid": appid, "removed": targets}
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2 or argv[0] not in ("status", "install", "start", "uninstall") or not argv[1].isdigit():
|
||||
raise Fail("usage: status|install|start|uninstall APPID")
|
||||
action = {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]]
|
||||
if action is status:
|
||||
return status(int(argv[1]))
|
||||
import fcntl # only on the Frame; the server and its tests also import this module on Windows
|
||||
# One change at a time, or an uninstall could delete what an install is moving in.
|
||||
LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOCK, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
raise Fail("another mod action is still running on the Frame; try again when it finishes") from None
|
||||
return action(int(argv[1]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(json.dumps(main(sys.argv[1:])))
|
||||
except Fail as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
except Exception as e: # still one JSON object, for the server to show
|
||||
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
|
||||
sys.exit(1)
|
||||
+648
-348
File diff suppressed because it is too large.
Load diff
+5
-20
@@ -572,14 +572,13 @@ def launch(body):
|
||||
return {"message": f"Launching {appid}"}
|
||||
|
||||
|
||||
def steam_frame(*args, timeout=40, script="frame_steam.py"):
|
||||
"""Run frame_steam.py (it drives the Steam client), or another on-Frame
|
||||
helper with the same JSON contract, on the Frame and return its JSON."""
|
||||
def steam_frame(*args, timeout=40):
|
||||
"""Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON."""
|
||||
try:
|
||||
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
|
||||
stdin=(HERE / script).read_text(), timeout=timeout)
|
||||
stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout)
|
||||
except Failure as e:
|
||||
# The helper prints {"error": ...} on stdout when it fails, but ssh()
|
||||
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh()
|
||||
# reports stderr instead if there was any, so look in both.
|
||||
for line in [*reversed(getattr(e, "stdout", "").splitlines()), *reversed(str(e).splitlines())]:
|
||||
try:
|
||||
@@ -630,20 +629,6 @@ def vr(body):
|
||||
return result
|
||||
|
||||
|
||||
# Downloads are about 20 MB; starting waits for the game, the injection and SteamVR.
|
||||
MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60}
|
||||
|
||||
|
||||
def mods(body):
|
||||
"""UEVR for one installed Unreal game: status, install, start (inject) or uninstall."""
|
||||
appid, action = str(body.get("appid", "")), body.get("action")
|
||||
if not APPID.match(appid):
|
||||
raise Failure("bad appid", 400)
|
||||
if action not in MOD_TIMEOUT:
|
||||
raise Failure("action must be " + ", ".join(MOD_TIMEOUT), 400)
|
||||
return steam_frame(action, appid, timeout=MOD_TIMEOUT[action], script="frame_mods.py")
|
||||
|
||||
|
||||
def steam_search(query):
|
||||
q = parse_qs(query)
|
||||
cc = (q.get("cc") or [""])[0].upper()
|
||||
@@ -2161,7 +2146,7 @@ POST = {
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
|
||||
Reference in new issue
Block a user